import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'; import { Sparkles, Trash2, Download, RefreshCcw, Zap, CloudLightning, Activity, Heart, DollarSign, Code, Camera, XCircle, BookOpen, Play, SkipForward, Eye, EyeOff, Scroll } from 'lucide-react'; /** * ------------------------------------------------------------------ * MediaPipe 外部脚本加载器 * ------------------------------------------------------------------ */ const loadScript = (src: string) => { return new Promise((resolve, reject) => { if (document.querySelector(`script[src="${src}"]`)) { resolve(true); return; } const script = document.createElement('script'); script.src = src; script.async = true; script.onload = () => resolve(true); script.onerror = () => reject(new Error(`Failed to load script: ${src}`)); document.body.appendChild(script); }); }; /** * ------------------------------------------------------------------ * 配置与常量:模板库 (六字真言 + 道家符箓) * ------------------------------------------------------------------ * SVG 路径基于 200x200 画布。 */ const TEMPLATES = [ // --- 梵文种子字 --- { category: 'mantra', id: 'om', name: '唵 (Om)', desc: '宇宙原音,身口意清净', color: '#FFD700', svgStrokes: [ "M60,50 Q110,50 110,80 Q110,110 60,110 Q110,110 110,150 Q110,190 60,190", "M110,105 Q150,105 160,70", "M120,40 Q140,20 160,40", "M140,30 L140.1,30" ], difficulty: 3000 }, { category: 'mantra', id: 'ma', name: '嘛 (Ma)', desc: '布施波罗蜜,除修罗道苦', color: '#FFFFFF', svgStrokes: [ "M40,60 L160,60", "M60,60 L60,160", "M140,60 L140,180", "M60,100 L120,100 L120,80 Q120,60 100,60 Q80,60 80,80 L80,120", ], difficulty: 2500 }, { category: 'mantra', id: 'ni', name: '呢 (Ni)', desc: '持戒波罗蜜,除人道苦', color: '#228B22', svgStrokes: [ "M40,60 L200,60", "M60,60 L60,180", "M180,60 L180,180", "M100,80 Q100,160 140,160 Q180,160 180,80", ], difficulty: 2800 }, { category: 'mantra', id: 'pad', name: '叭 (Pad)', desc: '忍辱波罗蜜,除畜生道苦', color: '#FF4500', svgStrokes: [ "M40,60 L180,60", "M140,60 L140,180", "M60,60 L60,120 Q60,160 100,160 Q140,160 140,120", "M100,140 L40,180", ], difficulty: 2200 }, { category: 'mantra', id: 'me', name: '咪 (Me)', desc: '精进波罗蜜,除饿鬼道苦', color: '#1E90FF', svgStrokes: [ "M60,20 L40,50", "M40,60 L160,60", "M80,80 L80,140 Q80,170 120,170", "M120,80 L120,170", "M120,120 L160,180", ], difficulty: 3500 }, { category: 'mantra', id: 'hum', name: '吽 (Hum)', desc: '般若波罗蜜,除地狱道苦', color: '#3b82f6', svgStrokes: [ "M40,60 L140,60", "M60,60 L60,100 Q60,120 80,120 L100,120", "M100,60 L100,110 L100,140 Q100,180 60,180", "M120,140 Q140,180 160,160", ], difficulty: 3200 }, // --- 道家符箓 --- { category: 'taoist', id: 'safe_talisman', name: '平安符 (Peace)', desc: '三清护佑,出入平安', color: '#d93a3a', // 朱砂红 svgStrokes: [ "M60,30 L100,50 L140,30", // 1. 符头 (三清V) "M100,60 L100,80", // 2. 敕令竖 "M70,90 L130,90", // 3. 宝盖头横 "M100,90 L80,110", // 4. 宝盖头撇 "M100,90 L120,110", // 5. 宝盖头捺 "M100,120 Q80,140 100,160 Q120,140 100,120", // 6. 符胆圈 (安) "M80,170 L80,190", // 7. 符脚左 "M120,170 L120,190" // 8. 符脚右 ], difficulty: 2800 }, { category: 'taoist', id: 'wealth_talisman', name: '招财符 (Wealth)', desc: '财源广进,金玉满堂', color: '#d93a3a', svgStrokes: [ "M60,30 L100,50 L140,30", // 1. 符头 "M100,50 L100,80", // 2. 连接 "M70,80 L70,140", // 3. 贝字左 "M70,80 L100,80 L100,140",// 4. 贝字框 "M80,100 L90,100", // 5. 贝字内横1 "M80,120 L90,120", // 6. 贝字内横2 "M110,80 L140,80", // 7. 才字横 "M125,70 L125,150", // 8. 才字竖钩 "M125,110 L140,130", // 9. 才字撇 "M80,160 Q100,180 120,160 Q100,140 80,160" // 10. 金币圈 ], difficulty: 3500 }, { category: 'taoist', id: 'protect_talisman', name: '驱邪符 (Protect)', desc: '罡气护体,百无禁忌', color: '#d93a3a', svgStrokes: [ "M60,30 L100,50 L140,30", // 1. 符头 "M60,60 L140,60", // 2. 四字横 "M70,60 L80,90 L120,90 L130,60", // 3. 四字框 "M90,70 L90,85", // 4. 内竖1 "M110,70 L110,85", // 5. 内竖2 "M60,110 L140,110", // 6. 正字横 "M100,110 L100,150", // 7. 正字竖 "M100,130 L130,130", // 8. 正字中横 "M70,150 L130,150", // 9. 正字底横 "M60,170 L100,190 L140,170" // 10. 符脚叉 ], difficulty: 3800 } ]; // 手部骨架连接定义 const HAND_CONNECTIONS = [ [0, 1], [1, 2], [2, 3], [3, 4], // Thumb [0, 5], [5, 6], [6, 7], [7, 8], // Index [0, 9], [9, 10], [10, 11], [11, 12], // Middle [0, 13], [13, 14], [14, 15], [15, 16], // Ring [0, 17], [17, 18], [18, 19], [19, 20], // Pinky [5, 9], [9, 13], [13, 17] // Palm ]; // SVG 路径平滑算法 const getSvgPathFromStroke = (points: {x: number, y: number}[]) => { if (points.length === 0) return ''; if (points.length < 3) { return `M ${points[0].x},${points[0].y} L ${points[0].x + 0.1},${points[0].y + 0.1}`; } let path = `M ${points[0].x},${points[0].y}`; for (let i = 1; i < points.length - 1; i++) { const p0 = points[i]; const p1 = points[i + 1]; const midX = (p0.x + p1.x) / 2; const midY = (p0.y + p1.y) / 2; path += ` Q ${p0.x},${p0.y} ${midX},${midY}`; } return path; }; // 辅助:从 SVG 路径字符串中提取起始点 (M x,y) const getStartPoint = (pathStr: string) => { const match = pathStr.match(/M\s*([\d.]+)[ ,]\s*([\d.]+)/); if (match) { return { x: parseFloat(match[1]), y: parseFloat(match[2]) }; } return { x: 0, y: 0 }; }; // 计算两点距离 const dist = (p1: any, p2: any) => { return Math.hypot(p1.x - p2.x, p1.y - p2.y); }; /** * ------------------------------------------------------------------ * 组件:主应用 * ------------------------------------------------------------------ */ export default function CyberTalismanApp() { const [selectedTemplate, setSelectedTemplate] = useState(TEMPLATES[0]); const [strokes, setStrokes] = useState<{x: number, y: number}[][]>([]); const [currentStroke, setCurrentStroke] = useState<{x: number, y: number}[]>([]); const [isSealed, setIsSealed] = useState(false); const [showEffect, setShowEffect] = useState(false); const [progress, setProgress] = useState(0); const [showNumbers, setShowNumbers] = useState(true); // 教学模式状态 const [isDemoPlaying, setIsDemoPlaying] = useState(false); const [activeStrokeIndex, setActiveStrokeIndex] = useState(-1); const [demoProgressIndex, setDemoProgressIndex] = useState(-1); const svgRef = useRef(null); // 摄像头与 AI 状态 const [isCameraMode, setIsCameraMode] = useState(false); const [cameraLoading, setCameraLoading] = useState(false); const [handCursor, setHandCursor] = useState<{x: number, y: number, isDrawing: boolean} | null>(null); const [handLandmarks, setHandLandmarks] = useState([]); const videoRef = useRef(null); const handsRef = useRef(null); const cameraRef = useRef(null); // ------------------------- 笔画演示动画逻辑 ------------------------- const playDemo = useCallback(() => { if (isDemoPlaying || isSealed) return; setIsDemoPlaying(true); setStrokes([]); setCurrentStroke([]); setActiveStrokeIndex(-1); setDemoProgressIndex(-1); const totalStrokes = selectedTemplate.svgStrokes.length; let currentIdx = 0; const playNext = () => { if (currentIdx >= totalStrokes) { setIsDemoPlaying(false); setActiveStrokeIndex(-1); setDemoProgressIndex(totalStrokes); return; } setActiveStrokeIndex(currentIdx); setDemoProgressIndex(currentIdx); currentIdx++; setTimeout(playNext, 1500); }; playNext(); }, [isDemoPlaying, isSealed, selectedTemplate]); // ------------------------- 覆盖率/完成度检测逻辑 ------------------------- useEffect(() => { if (isSealed || isDemoPlaying) return; let totalLength = 0; const allPoints = [...strokes.flat(), ...currentStroke]; for (let i = 1; i < allPoints.length; i++) { totalLength += Math.hypot(allPoints[i].x - allPoints[i-1].x, allPoints[i].y - allPoints[i-1].y); } const percentage = Math.min(100, Math.round((totalLength / selectedTemplate.difficulty) * 100)); setProgress(percentage); if (percentage >= 95 && currentStroke.length === 0 && !isSealed) { handleSeal(); } }, [strokes, currentStroke, selectedTemplate, isSealed, isDemoPlaying]); // ------------------------- 核心绘图逻辑 ------------------------- const startStroke = useCallback((point: {x: number, y: number}) => { if (isSealed || isDemoPlaying) return; setCurrentStroke([point]); }, [isSealed, isDemoPlaying]); const moveStroke = useCallback((point: {x: number, y: number}) => { if (isSealed || isDemoPlaying) return; setCurrentStroke((prev) => { if (prev.length === 0) return [point]; return [...prev, point]; }); }, [isSealed, isDemoPlaying]); const endStroke = useCallback(() => { if (isSealed || isDemoPlaying) return; setCurrentStroke((prev) => { if (prev.length > 0) { setStrokes((allStrokes) => [...allStrokes, prev]); } return []; }); }, [isSealed, isDemoPlaying]); // ------------------------- 鼠标/触控事件适配器 ------------------------- const getPoint = (e: React.PointerEvent) => { const svg = svgRef.current; if (!svg) return { x: 0, y: 0 }; const rect = svg.getBoundingClientRect(); return { x: e.clientX - rect.left, y: e.clientY - rect.top, }; }; const handlePointerDown = (e: React.PointerEvent) => { if (isCameraMode || isDemoPlaying) return; e.target.setPointerCapture(e.pointerId); startStroke(getPoint(e)); }; const handlePointerMove = (e: React.PointerEvent) => { if (isCameraMode || isDemoPlaying) return; if (e.buttons !== 1) return; moveStroke(getPoint(e)); }; const handlePointerUp = (e: React.PointerEvent) => { if (isCameraMode || isDemoPlaying) return; endStroke(); }; // ------------------------- AI 手势识别逻辑 ------------------------- useEffect(() => { let active = true; const initMediaPipe = async () => { if (!isCameraMode) return; setCameraLoading(true); try { await loadScript('https://cdn.jsdelivr.net/npm/@mediapipe/camera_utils/camera_utils.js'); await loadScript('https://cdn.jsdelivr.net/npm/@mediapipe/control_utils/control_utils.js'); await loadScript('https://cdn.jsdelivr.net/npm/@mediapipe/drawing_utils/drawing_utils.js'); await loadScript('https://cdn.jsdelivr.net/npm/@mediapipe/hands/hands.js'); if (!active) return; // @ts-ignore const hands = new window.Hands({locateFile: (file) => { return `https://cdn.jsdelivr.net/npm/@mediapipe/hands/${file}`; }}); hands.setOptions({ maxNumHands: 1, modelComplexity: 1, minDetectionConfidence: 0.6, minTrackingConfidence: 0.5 }); hands.onResults(onResults); handsRef.current = hands; if (videoRef.current) { // @ts-ignore const camera = new window.Camera(videoRef.current, { onFrame: async () => { if (videoRef.current && handsRef.current) { await handsRef.current.send({image: videoRef.current}); } }, width: 640, height: 480 }); camera.start(); cameraRef.current = camera; } setCameraLoading(false); } catch (error) { console.error("MediaPipe load failed", error); setCameraLoading(false); alert("摄像头加载失败,请检查网络或权限"); setIsCameraMode(false); } }; if (isCameraMode) { initMediaPipe(); } else { if (cameraRef.current) cameraRef.current.stop(); setHandCursor(null); setHandLandmarks([]); } return () => { active = false; if (cameraRef.current) cameraRef.current.stop(); }; }, [isCameraMode]); const onResults = (results: any) => { if (!svgRef.current) return; if (results.multiHandLandmarks && results.multiHandLandmarks.length > 0) { const landmarks = results.multiHandLandmarks[0]; setHandLandmarks(landmarks); const wrist = landmarks[0]; const indexTip = landmarks[8]; const middleTip = landmarks[12]; const ringTip = landmarks[16]; const pinkyTip = landmarks[20]; const rect = svgRef.current.getBoundingClientRect(); const x = (1 - indexTip.x) * rect.width; const y = indexTip.y * rect.height; const distIndex = dist(indexTip, wrist); const distMiddle = dist(middleTip, wrist); const distRing = dist(ringTip, wrist); const distPinky = dist(pinkyTip, wrist); const isIndexExtended = distIndex > distMiddle * 1.2 && distIndex > distRing * 1.2 && distIndex > distPinky * 1.2; const isDrawing = isIndexExtended; setHandCursor({ x, y, isDrawing }); if (isDrawing && !isDemoPlaying) { setCurrentStroke(prev => { if (prev.length === 0) return [{x, y}]; return [...prev, {x, y}]; }); } else { setCurrentStroke(prev => { if (prev.length > 0) { setStrokes(all => [...all, prev]); } return []; }); } } else { setHandCursor(null); setHandLandmarks([]); setCurrentStroke(prev => { if (prev.length > 0) setStrokes(all => [...all, prev]); return []; }); } }; const mapLandmark = (landmark: any) => { if (!svgRef.current) return { x: 0, y: 0 }; const rect = svgRef.current.getBoundingClientRect(); return { x: (1 - landmark.x) * rect.width, y: landmark.y * rect.height }; }; // ------------------------- 通用交互逻辑 ------------------------- const clearCanvas = () => { setStrokes([]); setCurrentStroke([]); setIsSealed(false); setShowEffect(false); setProgress(0); setIsDemoPlaying(false); setActiveStrokeIndex(-1); setDemoProgressIndex(-1); }; const handleSeal = () => { if (isSealed) return; setShowEffect(true); setTimeout(() => { setIsSealed(true); setShowEffect(false); }, 1500); }; // 辅助函数:按类别分组渲染侧边栏 const renderSidebarItems = (category: string) => { return TEMPLATES.filter(t => t.category === category).map((t) => ( )); }; return (
{/* --- 左侧/顶部:菜单栏 --- */}

赛博书写阁

Mantra & Talisman v2.5

{isCameraMode && (
✊ 握拳伸出食指=下笔 | 🖐️ 张开手掌=悬停
)}
{/* 梵文部分 */}
六字真言 (Sanskrit)
{renderSidebarItems('mantra')}
{/* 道家符箓部分 */}
道家符箓 (Taoist)
{renderSidebarItems('taoist')}
{/* --- 右侧/主要:画板区域 --- */}
{/* 顶部提示栏 */}
书写进度
{progress}%
{/* AI 视觉反馈层 */} {isCameraMode && !isSealed && svgRef.current && (
{handLandmarks.length > 0 && HAND_CONNECTIONS.map(([start, end], i) => { const p1 = mapLandmark(handLandmarks[start]); const p2 = mapLandmark(handLandmarks[end]); return ; })} {handCursor && (
{!handCursor.isDrawing && (
握拳下笔
)}
)}
)} {/* 圆满完成特效 */} {showEffect && (
圆满
)} {/* 纸张容器 */}
{/* 绘图层 */} {/* 1. 笔画骨架 (Guide/Ghost) */} {selectedTemplate.svgStrokes.map((pathStr, index) => { const isActive = index === activeStrokeIndex; const isDone = isDemoPlaying && index < demoProgressIndex; const startPoint = getStartPoint(pathStr); const strokeColor = isActive ? '#ffffff' : (isDone ? selectedTemplate.color : '#57534e'); const strokeOpacity = isActive ? 1 : (isDone ? 1 : 0.3); const strokeDash = (isActive || isDone) ? "0" : "8,8"; return ( {/* 底层骨架 */} {/* 演示模式:当前高亮的笔画动画 */} {isActive && ( )} {/* 演示模式:起点提示点 */} {isActive && ( )} {/* 笔顺编号 Badge */} {showNumbers && ( {index + 1} )} ); })} {/* 用户绘图层 (Overlay SVG) */} {strokes.map((stroke, index) => ( ))} {currentStroke.length > 0 && ( )} {/* 完成后的粒子光点装饰 */} {isSealed && (
{[...Array(20)].map((_, i) => (
))}
)}
{/* 底部帮助 */}
{isSealed ? '✨ 功德圆满 ✨' : isDemoPlaying ? '👀 观察笔顺演示...' : isCameraMode ? 'AI 教学中: 食指书写,填满文字' : '提示:先点击演示看笔顺,再临摹'}
); }