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