/ src / features / tutorial / TutorialRunner.tsx
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;