TutorialRunner.tsx
1 /** 2 * TutorialRunner - Orchestrates tutorial step playback 3 * 4 * Responsibilities: 5 * - Sequences through tutorial steps 6 * - Coordinates ManimText, GoldenDot, and GoldenGlow 7 * - Executes actions (focus node, flip, etc.) 8 * - Handles auto-advance timing 9 * 10 * Renders inside DreamspaceCanvas (3D context) 11 */ 12 13 import React, { useEffect, useState, useCallback, useRef } from 'react'; 14 import { Html } from '@react-three/drei'; 15 import { ManimText } from './ManimText'; 16 import { GoldenDot } from './GoldenDot'; 17 import { TutorialAction } from './types'; 18 import { MVP_TUTORIAL_STEPS } from './steps/mvp-steps'; 19 import { useInterBrainStore } from '../../core/store/interbrain-store'; 20 import { serviceManager } from '../../core/services/service-manager'; 21 import { musicService } from './services/music-service'; 22 23 interface TutorialRunnerProps { 24 /** Whether tutorial is active */ 25 isActive: boolean; 26 /** Callback when tutorial completes */ 27 onComplete?: () => void; 28 /** Callback when tutorial is skipped */ 29 onSkip?: () => void; 30 } 31 32 export const TutorialRunner: React.FC<TutorialRunnerProps> = ({ 33 isActive, 34 onComplete, 35 onSkip, 36 }) => { 37 const [currentStepIndex, setCurrentStepIndex] = useState(0); 38 const [showText, setShowText] = useState(false); 39 const [showGoldenDot, setShowGoldenDot] = useState(false); 40 const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null); 41 42 // Store actions 43 const setSelectedNode = useInterBrainStore(state => state.setSelectedNode); 44 const setSpatialLayout = useInterBrainStore(state => state.setSpatialLayout); 45 const setHighlightedNodeId = useInterBrainStore(state => state.setHighlightedNodeId); 46 47 const currentStep = MVP_TUTORIAL_STEPS[currentStepIndex]; 48 49 /** 50 * Execute a tutorial action 51 */ 52 const executeAction = useCallback(async (action: TutorialAction) => { 53 console.log('[TutorialRunner] Executing action:', action.type); 54 55 switch (action.type) { 56 case 'select-node': { 57 const dreamNodeService = serviceManager.getActive(); 58 const node = await dreamNodeService.get(action.nodeId); 59 if (node) { 60 setSelectedNode(node); 61 } 62 break; 63 } 64 65 case 'focus-node': { 66 const dreamNodeService = serviceManager.getActive(); 67 const node = await dreamNodeService.get(action.nodeId); 68 if (node) { 69 setSelectedNode(node); 70 setSpatialLayout('liminal-web'); 71 } 72 break; 73 } 74 75 case 'flip-node': { 76 const commandId = action.direction === 'back' 77 ? 'interbrain:flip-dreamnode-to-back' 78 : 'interbrain:flip-dreamnode-to-front'; 79 const app = serviceManager.getApp(); 80 if (app) { 81 (app as any).commands.executeCommandById(commandId); 82 } 83 break; 84 } 85 86 case 'set-layout': { 87 setSpatialLayout(action.layout); 88 break; 89 } 90 91 case 'execute-command': { 92 const app = serviceManager.getApp(); 93 if (app) { 94 (app as any).commands.executeCommandById(action.commandId); 95 } 96 break; 97 } 98 99 case 'highlight-glow': { 100 setHighlightedNodeId(action.nodeId); 101 setTimeout(() => { 102 setHighlightedNodeId(null); 103 }, action.duration); 104 break; 105 } 106 107 case 'wait': { 108 await new Promise(resolve => setTimeout(resolve, action.duration)); 109 break; 110 } 111 } 112 }, [setSelectedNode, setSpatialLayout, setHighlightedNodeId]); 113 114 /** 115 * Advance to next step 116 */ 117 const advanceStep = useCallback(() => { 118 if (currentStepIndex < MVP_TUTORIAL_STEPS.length - 1) { 119 setCurrentStepIndex(prev => prev + 1); 120 } else { 121 // Tutorial complete 122 onComplete?.(); 123 } 124 }, [currentStepIndex, onComplete]); 125 126 /** 127 * Handle step entry 128 */ 129 useEffect(() => { 130 if (!isActive || !currentStep) return; 131 132 console.log('[TutorialRunner] Entering step:', currentStep.id); 133 134 // Clear any existing timer 135 if (timerRef.current) { 136 globalThis.clearTimeout(timerRef.current); 137 } 138 139 // Execute onEnter action 140 if (currentStep.onEnter) { 141 executeAction(currentStep.onEnter); 142 } 143 144 // Show text if present 145 if (currentStep.text) { 146 setShowText(true); 147 } 148 149 // Show golden dot if present 150 if (currentStep.goldenDot) { 151 setShowGoldenDot(true); 152 } 153 154 // Handle highlight 155 if (currentStep.highlightNode) { 156 setHighlightedNodeId(currentStep.highlightNode.nodeId); 157 } 158 159 // Set up auto-advance timer 160 if (currentStep.advance.type === 'auto') { 161 const textDuration = currentStep.text?.duration || 0; 162 const dotDuration = (currentStep.goldenDot?.duration || 0) * 1000; 163 const advanceDelay = currentStep.advance.delay || 0; 164 165 const totalDelay = Math.max(textDuration, dotDuration) + advanceDelay; 166 167 timerRef.current = setTimeout(() => { 168 // Execute onExit action 169 if (currentStep.onExit) { 170 executeAction(currentStep.onExit); 171 } 172 173 // Clear highlight 174 if (currentStep.highlightNode) { 175 setHighlightedNodeId(null); 176 } 177 178 // Reset visual state 179 setShowText(false); 180 setShowGoldenDot(false); 181 182 // Small delay before next step 183 setTimeout(advanceStep, 200); 184 }, totalDelay); 185 } 186 187 return () => { 188 if (timerRef.current) { 189 globalThis.clearTimeout(timerRef.current); 190 } 191 }; 192 }, [isActive, currentStepIndex, currentStep, executeAction, advanceStep, setHighlightedNodeId]); 193 194 // Handle music playback based on tutorial active state 195 useEffect(() => { 196 if (isActive) { 197 // Initialize and start music when tutorial becomes active 198 const app = serviceManager.getApp(); 199 if (app) { 200 musicService.initialize(app); 201 musicService.play(2000); // 2 second fade-in 202 } 203 } else { 204 // Stop music when tutorial ends 205 musicService.stop(1500); // 1.5 second fade-out 206 } 207 208 // Cleanup on unmount 209 return () => { 210 if (!isActive) { 211 musicService.cleanup(); 212 } 213 }; 214 }, [isActive]); 215 216 // Reset when becoming inactive 217 useEffect(() => { 218 if (!isActive) { 219 setCurrentStepIndex(0); 220 setShowText(false); 221 setShowGoldenDot(false); 222 setHighlightedNodeId(null); 223 } 224 }, [isActive, setHighlightedNodeId]); 225 226 if (!isActive || !currentStep) { 227 return null; 228 } 229 230 return ( 231 <group> 232 {/* ManimText display */} 233 {showText && currentStep.text && ( 234 <Html 235 position={currentStep.text.position} 236 center 237 transform 238 sprite 239 distanceFactor={10} 240 style={{ 241 pointerEvents: 'none', 242 userSelect: 'none', 243 width: '800px', 244 height: '200px', 245 }} 246 > 247 <ManimText 248 text={currentStep.text.content} 249 fontSize={currentStep.text.fontSize || 48} 250 strokeDuration={1.5} 251 fillDelay={0.2} 252 fadeStroke={true} 253 /> 254 </Html> 255 )} 256 257 {/* GoldenDot animation */} 258 {showGoldenDot && currentStep.goldenDot && ( 259 <GoldenDot 260 from={currentStep.goldenDot.from} 261 to={currentStep.goldenDot.to} 262 controlPoints={currentStep.goldenDot.controlPoints} 263 duration={currentStep.goldenDot.duration || 2} 264 easing={currentStep.goldenDot.easing || 'easeInOut'} 265 hitDetectionNodeIds={currentStep.goldenDot.hitDetectionNodeIds} 266 onComplete={() => setShowGoldenDot(false)} 267 /> 268 )} 269 270 {/* Skip button - positioned in corner */} 271 <Html 272 position={[20, 15, -50]} 273 center 274 transform 275 sprite 276 distanceFactor={10} 277 style={{ 278 pointerEvents: 'auto', 279 userSelect: 'none', 280 }} 281 > 282 <button 283 onClick={() => onSkip?.()} 284 style={{ 285 padding: '8px 16px', 286 background: 'rgba(0, 0, 0, 0.6)', 287 border: '1px solid rgba(255, 255, 255, 0.3)', 288 borderRadius: '4px', 289 color: 'rgba(255, 255, 255, 0.7)', 290 fontSize: '14px', 291 cursor: 'pointer', 292 transition: 'all 0.2s ease', 293 }} 294 onMouseEnter={(e) => { 295 e.currentTarget.style.background = 'rgba(0, 0, 0, 0.8)'; 296 e.currentTarget.style.color = 'white'; 297 }} 298 onMouseLeave={(e) => { 299 e.currentTarget.style.background = 'rgba(0, 0, 0, 0.6)'; 300 e.currentTarget.style.color = 'rgba(255, 255, 255, 0.7)'; 301 }} 302 > 303 Skip Tutorial 304 </button> 305 </Html> 306 307 {/* Progress indicator */} 308 <Html 309 position={[0, -18, -50]} 310 center 311 transform 312 sprite 313 distanceFactor={10} 314 style={{ 315 pointerEvents: 'none', 316 userSelect: 'none', 317 }} 318 > 319 <div style={{ 320 display: 'flex', 321 gap: '6px', 322 alignItems: 'center', 323 }}> 324 {MVP_TUTORIAL_STEPS.map((_, index) => ( 325 <div 326 key={index} 327 style={{ 328 width: index === currentStepIndex ? '24px' : '8px', 329 height: '8px', 330 borderRadius: '4px', 331 background: index <= currentStepIndex 332 ? 'rgba(255, 215, 0, 0.8)' 333 : 'rgba(255, 255, 255, 0.3)', 334 transition: 'all 0.3s ease', 335 }} 336 /> 337 ))} 338 </div> 339 </Html> 340 </group> 341 ); 342 }; 343 344 export default TutorialRunner;