TutorialOverlay.tsx
1 import React, { useEffect, useState } from 'react'; 2 import { Html, Billboard } from '@react-three/drei'; 3 import { ManimText } from './ManimText'; 4 import { GoldenDot } from './GoldenDot'; 5 import { tutorialService, TutorialStep, GoldenDotAnimation, TextAnimation } from './TutorialService'; 6 import './tutorial-styles.css'; 7 8 /** 9 * TutorialOverlay - Renders tutorial steps in 3D space 10 * 11 * Uses Billboard → Html structure (like DreamNode3D but without rotation) 12 * Displays ManimText animations for each step 13 */ 14 export const TutorialOverlay: React.FC = () => { 15 const [currentStep, setCurrentStep] = useState<TutorialStep | null>(null); 16 const [showAnimation, setShowAnimation] = useState(false); 17 const [goldenDot, setGoldenDot] = useState<GoldenDotAnimation | null>(null); 18 const [textAnimation, setTextAnimation] = useState<TextAnimation | null>(null); 19 20 useEffect(() => { 21 // Subscribe to tutorial step changes 22 const unsubscribe = tutorialService.onStepChange((step) => { 23 if (step) { 24 setShowAnimation(false); // Reset animation 25 setCurrentStep(step); 26 27 // Trigger animation after brief delay 28 setTimeout(() => setShowAnimation(true), 100); 29 30 // Auto-advance if step has duration 31 if (step.duration) { 32 setTimeout(() => { 33 tutorialService.next(); 34 }, step.duration); 35 } 36 } else { 37 // Tutorial completed or skipped 38 setCurrentStep(null); 39 } 40 }); 41 42 // Subscribe to golden dot changes 43 const unsubscribeGoldenDot = tutorialService.onGoldenDotChange((animation) => { 44 setGoldenDot(animation); 45 }); 46 47 // Load current step on mount 48 const step = tutorialService.getCurrentStep(); 49 if (step) { 50 setCurrentStep(step); 51 setShowAnimation(true); 52 } 53 54 // Load current golden dot animation on mount 55 const dotAnimation = tutorialService.getGoldenDotAnimation(); 56 if (dotAnimation) { 57 setGoldenDot(dotAnimation); 58 } 59 60 // Subscribe to text animation changes 61 const unsubscribeText = tutorialService.onTextAnimationChange((animation) => { 62 setTextAnimation(animation); 63 }); 64 65 // Load current text animation on mount 66 const currentTextAnimation = tutorialService.getTextAnimation(); 67 if (currentTextAnimation) { 68 setTextAnimation(currentTextAnimation); 69 } 70 71 return () => { 72 unsubscribe(); 73 unsubscribeGoldenDot(); 74 unsubscribeText(); 75 }; 76 }, []); 77 78 // Handle golden dot completion 79 const handleGoldenDotComplete = () => { 80 tutorialService.clearGoldenDot(); 81 }; 82 83 // Render nothing if no active tutorial elements 84 if (!currentStep && !goldenDot && !textAnimation) { 85 return null; 86 } 87 88 return ( 89 <> 90 {/* Golden Dot - sovereign animation element */} 91 {goldenDot && ( 92 'fromNodeId' in goldenDot && goldenDot.fromNodeId ? ( 93 // Node-based animation 94 <GoldenDot 95 fromNodeId={goldenDot.fromNodeId} 96 toNodeId={goldenDot.toNodeId} 97 controlPoints={goldenDot.controlPoints} 98 duration={goldenDot.duration} 99 size={goldenDot.size} 100 easing={goldenDot.easing} 101 hitDetectionNodeIds={goldenDot.hitDetectionNodeIds} 102 onComplete={handleGoldenDotComplete} 103 visible={true} 104 /> 105 ) : ( 106 // Position-based animation 107 <GoldenDot 108 from={(goldenDot as any).from} 109 to={(goldenDot as any).to} 110 controlPoints={goldenDot.controlPoints} 111 duration={goldenDot.duration} 112 size={goldenDot.size} 113 easing={goldenDot.easing} 114 hitDetectionNodeIds={goldenDot.hitDetectionNodeIds} 115 onComplete={handleGoldenDotComplete} 116 visible={true} 117 /> 118 ) 119 )} 120 121 {/* Tutorial Text - sovereign animation element (from steps) */} 122 {currentStep && ( 123 <group position={currentStep.position || [0, 0, -25]}> 124 <Billboard follow={true} lockX={false} lockY={false} lockZ={false}> 125 <Html 126 position={[0, 0, 0]} 127 center 128 transform 129 distanceFactor={10} 130 style={{ 131 pointerEvents: 'none' 132 }} 133 > 134 <div style={{ 135 background: 'transparent', 136 width: '600px', 137 textAlign: 'center' 138 }}> 139 {showAnimation && ( 140 <ManimText 141 key={currentStep.title} 142 text={currentStep.title} 143 strokeDuration={2} 144 fillDelay={0.3} 145 fadeStroke={true} 146 fontSize={48} 147 /> 148 )} 149 </div> 150 </Html> 151 </Billboard> 152 </group> 153 )} 154 155 {/* Standalone Text Animation - decoupled from tutorial steps */} 156 {textAnimation && ( 157 <Html 158 position={textAnimation.position} 159 center 160 transform 161 sprite 162 distanceFactor={10} 163 style={{ 164 pointerEvents: 'none', 165 userSelect: 'none', 166 }} 167 > 168 <div style={{ 169 background: 'transparent', 170 whiteSpace: 'nowrap', 171 }}> 172 <ManimText 173 key={`${textAnimation.text}-${textAnimation.position.join(',')}`} 174 text={textAnimation.text} 175 strokeDuration={1.5} 176 fillDelay={0.2} 177 fadeStroke={true} 178 fontSize={textAnimation.fontSize || 48} 179 /> 180 </div> 181 </Html> 182 )} 183 </> 184 ); 185 };