/ src / features / tutorial / TutorialPortalOverlay.tsx
TutorialPortalOverlay.tsx
  1  /**
  2   * TutorialPortalOverlay - Full-screen portal entry experience
  3   *
  4   * Displays a Project Liminality logo on a black background that:
  5   * - Tilts towards the mouse cursor (like DreamNodes do)
  6   * - Straightens and scales up on hover
  7   * - Shows "Enter Dreamspace" text on hover
  8   * - On click: fades out, starts music, begins tutorial
  9   *
 10   * Shown on first startup or when tutorial is triggered.
 11   */
 12  
 13  import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react';
 14  import ReactDOM from 'react-dom';
 15  import { STAR_GRADIENT } from '../constellation-layout';
 16  import { ProjectLiminalityLogo } from './components/ProjectLiminalityLogo';
 17  
 18  /**
 19   * Generate a deterministic but organic-looking star field
 20   * Uses rectangular grid with jitter for full coverage including corners
 21   * Size is in viewport units (vh) for responsive scaling
 22   */
 23  function generateStarField(count: number, seed: number = 42): Array<{
 24    x: number;
 25    y: number;
 26    sizeVh: number; // viewport-relative size
 27    opacity: number;
 28  }> {
 29    const stars: Array<{ x: number; y: number; sizeVh: number; opacity: number }> = [];
 30  
 31    // Pseudo-random number generator with seed
 32    const seededRandom = (i: number, offset: number = 0) => {
 33      const x = Math.sin(i * 12.9898 + offset * 78.233 + seed) * 43758.5453;
 34      return x - Math.floor(x);
 35    };
 36  
 37    for (let i = 0; i < count; i++) {
 38      // Distribute across full rectangle with pseudo-random positions
 39      const x = seededRandom(i, 1) * 100; // 0-100%
 40      const y = seededRandom(i, 2) * 100; // 0-100%
 41  
 42      // Size distribution: viewport-relative
 43      const sizeRandom = seededRandom(i, 3);
 44      const sizeVh = 1.2 + sizeRandom * 2.0; // 1.2-3.2vh range
 45  
 46      // Opacity: slight variation
 47      const opacity = 0.4 + seededRandom(i, 4) * 0.5;
 48  
 49      stars.push({ x, y, sizeVh, opacity });
 50    }
 51  
 52    return stars;
 53  }
 54  
 55  interface TutorialPortalOverlayProps {
 56    /** Whether the overlay is visible */
 57    isVisible: boolean;
 58    /** Callback when portal is entered (overlay should close) */
 59    onEnter?: () => void;
 60  }
 61  
 62  // Animation duration for portal transition (ms)
 63  const PORTAL_TRANSITION_DURATION = 1500;
 64  
 65  // Staggered animation timing (as fractions of total duration)
 66  // Fade-out: 0% → 50% (first half)
 67  // Scale-up: 25% → 100% (last 3/4)
 68  const FADE_START = 0;
 69  const FADE_END = 0.5;
 70  const SCALE_START = 0.25;
 71  const SCALE_END = 1.0;
 72  
 73  export const TutorialPortalOverlay: React.FC<TutorialPortalOverlayProps> = ({
 74    isVisible,
 75    onEnter,
 76  }) => {
 77    const [isHovered, setIsHovered] = useState(false);
 78    const [isEntering, setIsEntering] = useState(false); // Portal entry animation
 79    const [animatedScale, setAnimatedScale] = useState(1); // Animated scale for star masking and logo
 80    const [animatedFade, setAnimatedFade] = useState(1); // Animated opacity for fade elements (1 = visible, 0 = hidden)
 81    const [tiltX, setTiltX] = useState(0);
 82    const [tiltY, setTiltY] = useState(0);
 83    const containerRef = useRef<HTMLDivElement>(null);
 84    const logoRef = useRef<HTMLDivElement>(null);
 85    const animationRef = useRef<number | null>(null);
 86  
 87    // Generate star field once (memoized)
 88    const stars = useMemo(() => generateStarField(80, 42), []);
 89  
 90    // Animate scale and fade with staggered timing when entering portal
 91    useEffect(() => {
 92      if (!isEntering) {
 93        // Reset when not entering (no mask effect before click)
 94        setAnimatedScale(isHovered ? 1.02 : 1);
 95        setAnimatedFade(1);
 96        return;
 97      }
 98  
 99      // Animation parameters
100      const startScale = isHovered ? 1.02 : 1;
101      const endScale = 6; // portalExitScale
102      const startTime = performance.now();
103      const duration = PORTAL_TRANSITION_DURATION;
104  
105      // Ease-in-out timing function
106      const easeInOut = (t: number) => {
107        return t < 0.5
108          ? 2 * t * t
109          : 1 - Math.pow(-2 * t + 2, 2) / 2;
110      };
111  
112      // Map progress to a sub-range and apply easing
113      const mapToRange = (progress: number, rangeStart: number, rangeEnd: number) => {
114        if (progress <= rangeStart) return 0;
115        if (progress >= rangeEnd) return 1;
116        const rangeProgress = (progress - rangeStart) / (rangeEnd - rangeStart);
117        return easeInOut(rangeProgress);
118      };
119  
120      const animate = (currentTime: number) => {
121        const elapsed = currentTime - startTime;
122        const progress = Math.min(elapsed / duration, 1);
123  
124        // Fade: 0% → 75% of total duration (opacity goes 1 → 0)
125        const fadeProgress = mapToRange(progress, FADE_START, FADE_END);
126        setAnimatedFade(1 - fadeProgress);
127  
128        // Scale: 25% → 100% of total duration
129        const scaleProgress = mapToRange(progress, SCALE_START, SCALE_END);
130        const newScale = startScale + (endScale - startScale) * scaleProgress;
131        setAnimatedScale(newScale);
132  
133        if (progress < 1) {
134          animationRef.current = requestAnimationFrame(animate);
135        }
136      };
137  
138      animationRef.current = requestAnimationFrame(animate);
139  
140      return () => {
141        if (animationRef.current) {
142          cancelAnimationFrame(animationRef.current);
143        }
144      };
145    }, [isEntering, isHovered]);
146  
147    // Handle mouse move for tilt effect
148    const handleMouseMove = useCallback((e: React.MouseEvent) => {
149      if (!logoRef.current || isHovered) return;
150  
151      const rect = logoRef.current.getBoundingClientRect();
152      const centerX = rect.left + rect.width / 2;
153      const centerY = rect.top + rect.height / 2;
154  
155      // Calculate distance from logo center
156      const deltaX = e.clientX - centerX;
157      const deltaY = e.clientY - centerY;
158  
159      // Max tilt angle (degrees) - reduced for subtler effect
160      const maxTilt = 12.5;
161  
162      // Calculate tilt based on mouse position relative to logo
163      // Normalize by screen dimensions for consistent feel
164      const normalizedX = deltaX / (window.innerWidth / 2);
165      const normalizedY = deltaY / (window.innerHeight / 2);
166  
167      // Tilt towards the mouse (inverted for natural feel)
168      setTiltY(normalizedX * maxTilt);
169      setTiltX(-normalizedY * maxTilt);
170    }, [isHovered]);
171  
172    // Handle logo hover
173    const handleLogoEnter = useCallback(() => {
174      setIsHovered(true);
175      // Reset tilt when hovered (logo faces forward)
176      setTiltX(0);
177      setTiltY(0);
178    }, []);
179  
180    const handleLogoLeave = useCallback(() => {
181      setIsHovered(false);
182    }, []);
183  
184    // Handle portal click - start the journey through the portal
185    const handlePortalClick = useCallback(() => {
186      if (isEntering) return;
187  
188      console.log('[TutorialPortal] Entering Dreamspace...');
189      setIsEntering(true);
190      setIsHovered(false); // Reset hover state
191  
192      // Music disabled for now - needs permissions for copyrighted tracks
193      // const app = serviceManager.getApp();
194      // if (app) {
195      //   musicService.initialize(app);
196      //   musicService.play(2000);
197      // }
198  
199      // TODO: Re-enable tutorial start after portal animation is finalized
200      // startTutorial();
201  
202      // Portal animation, then notify parent and unmount
203      setTimeout(() => {
204        setIsEntering(false); // Reset so component unmounts
205        onEnter?.();
206      }, PORTAL_TRANSITION_DURATION);
207    }, [isEntering, onEnter]);
208  
209    if (!isVisible && !isEntering) {
210      return null;
211    }
212  
213    // Logo size and hole calculations
214    const logoSizeVh = 50;
215  
216    // The hole radius should match the blue circle's inner edge at scale 1
217    // Blue circle stroke is 1.5 units in a 100-unit viewbox, so inner radius is (49 - 0.75) = 48.25
218    // As a percentage of the viewbox: 48.25%
219    // Logo is logoSizeVh tall, so the base hole radius in vh is: logoSizeVh * 0.4825
220    const baseHoleRadiusVh = logoSizeVh * 0.4825;
221  
222    // For star filtering, use the OUTER edge of the blue circle (49 + 0.75 = 49.75)
223    // This ensures stars disappear right at the visible edge of the blue circle
224    const blueCircleOuterRadiusVh = logoSizeVh * 0.4975;
225  
226    const overlayContent = (
227      <div
228        ref={containerRef}
229        className="tutorial-portal-overlay"
230        onMouseMove={handleMouseMove}
231        style={{
232          position: 'fixed',
233          top: 0,
234          left: 0,
235          width: '100vw',
236          height: '100vh',
237          backgroundColor: 'transparent',
238          display: 'flex',
239          flexDirection: 'column',
240          alignItems: 'center',
241          justifyContent: 'center',
242          zIndex: 99999,
243          cursor: 'default',
244          pointerEvents: 'auto',
245          overflow: 'hidden',
246        }}
247      >
248        {/*
249          Layer structure (bottom to top):
250          1. Black background with hole punch (covers DreamSpace outside circle)
251          2. Stars with same hole punch (visible outside circle only)
252          3. Logo (on top of everything)
253  
254          The "hole" is where both layers are transparent, revealing DreamSpace.
255          As the hole scales up, more DreamSpace is revealed and stars disappear.
256        */}
257  
258        {/* Layer 1a: Black background with hole - covers DreamSpace outside the circle */}
259        {/* Uses JS-animated scale for the portal opening animation */}
260        <div
261          style={{
262            position: 'absolute',
263            width: `${baseHoleRadiusVh * 2}vh`,
264            height: `${baseHoleRadiusVh * 2}vh`,
265            borderRadius: '50%',
266            backgroundColor: 'transparent',
267            boxShadow: '0 0 0 200vmax black',
268            transform: `scale(${animatedScale})`,
269            pointerEvents: 'none',
270            zIndex: 1,
271          }}
272        />
273  
274        {/* Layer 1b: Solid black circle that covers the hole BEFORE click */}
275        {/* This prevents DreamSpace from peeking through when logo tilts */}
276        {/* Disappears immediately on click (no animation needed) */}
277        {!isEntering && (
278          <div
279            style={{
280              position: 'absolute',
281              width: `${baseHoleRadiusVh * 2.1}vh`, // Slightly larger to cover any gaps
282              height: `${baseHoleRadiusVh * 2.1}vh`,
283              borderRadius: '50%',
284              backgroundColor: 'black',
285              pointerEvents: 'none',
286              zIndex: 1,
287            }}
288          />
289        )}
290  
291        {/* Layer 2: Stars container */}
292        {/* Stars are filtered by distance from center based on animatedScale */}
293        {/* Before click, animatedScale=1 so stars inside the base hole are hidden */}
294        {/* As portal opens, more stars get hidden as the hole grows */}
295        <div
296          style={{
297            position: 'absolute',
298            top: 0,
299            left: 0,
300            width: '100%',
301            height: '100%',
302            pointerEvents: 'none',
303            zIndex: 2,
304          }}
305        >
306          {stars.map((star, i) => {
307            // Only filter stars when entering portal (animation in progress)
308            // Before click, show all stars - the black hole cover hides them anyway
309            if (isEntering) {
310              // Calculate if star is outside the current hole
311              // Star position is in % (0-100), center is at 50%, 50%
312              //
313              // Filter radius tuned to match the visible blue circle edge
314              // Use constant offset (not scaling factor) so it scales proportionally with blue circle
315              const holeRadiusAsPercentOfHeight = (blueCircleOuterRadiusVh * animatedScale) - 10;
316  
317              // Star distance from center (50%, 50%) in percentage units
318              // Note: This treats x and y equally, which works for square viewports
319              // For non-square, the circle appears elliptical in star-space, but
320              // since the logo is centered and vh-based, we compare against height
321              const dx = star.x - 50;
322              const dy = star.y - 50;
323              const distancePercent = Math.sqrt(dx * dx + dy * dy);
324  
325              // Only render stars outside the hole
326              const isOutsideHole = distancePercent > holeRadiusAsPercentOfHeight;
327  
328              if (!isOutsideHole) return null;
329            }
330  
331            return (
332              <div
333                key={i}
334                style={{
335                  position: 'absolute',
336                  left: `${star.x}%`,
337                  top: `${star.y}%`,
338                  width: `${star.sizeVh}vh`,
339                  height: `${star.sizeVh}vh`,
340                  borderRadius: '50%',
341                  background: STAR_GRADIENT,
342                  opacity: star.opacity,
343                  pointerEvents: 'none',
344                  transform: 'translate(-50%, -50%)',
345                }}
346              />
347            );
348          })}
349        </div>
350  
351        {/* Logo with tilt effect - z-index above stars */}
352        {/* Uses JS-animated scale for staggered timing with fade */}
353        <div
354          ref={logoRef}
355          className="tutorial-portal-logo"
356          onMouseEnter={handleLogoEnter}
357          onMouseLeave={handleLogoLeave}
358          onClick={handlePortalClick}
359          style={{
360            height: `${logoSizeVh}vh`,
361            aspectRatio: '1 / 1',
362            cursor: isEntering ? 'default' : 'pointer',
363            // During portal entry: JS-animated scale, no tilt
364            // Otherwise: normal tilt and hover behavior with CSS transition
365            transform: isEntering
366              ? `scale(${animatedScale})`
367              : `perspective(2000px) rotateX(${isHovered ? 0 : tiltX}deg) rotateY(${isHovered ? 0 : tiltY}deg) scale(${isHovered ? 1.02 : 1})`,
368            transition: isEntering
369              ? 'none' // JS handles animation
370              : (isHovered ? 'transform 0.3s ease-out' : 'transform 0.1s ease-out'),
371            transformStyle: 'preserve-3d',
372            zIndex: 10,
373            position: 'relative',
374          }}
375        >
376          {/* Black circle behind logo to occlude stars - fades using JS-animated opacity */}
377          <div
378            style={{
379              position: 'absolute',
380              width: '100%',
381              height: '100%',
382              borderRadius: '50%',
383              backgroundColor: 'black',
384              opacity: animatedFade,
385            }}
386          />
387          {/* SVG Logo - red circle and lines fade out using JS-animated opacity */}
388          <ProjectLiminalityLogo
389            size="100%"
390            redOpacity={animatedFade}
391            linesOpacity={animatedFade}
392            blueOpacity={1} // Blue stays visible
393            transitionDuration={0} // JS handles animation
394            style={{
395              position: 'absolute',
396              pointerEvents: 'none',
397            }}
398          />
399        </div>
400  
401        {/* Enter DreamSpace text - absolutely positioned below logo center */}
402        <div
403          className="tutorial-portal-text"
404          style={{
405            position: 'absolute',
406            top: `calc(50% + ${logoSizeVh / 2}vh + 4vh)`, // Below logo with 4vh gap
407            left: '50%',
408            transform: 'translateX(-50%)',
409            opacity: isHovered && !isEntering ? 1 : 0,
410            transition: 'opacity 0.3s ease-out',
411            color: 'white',
412            fontSize: '4vh',
413            fontFamily: '"TeX Gyre Termes", "Times New Roman", serif',
414            fontWeight: 400,
415            letterSpacing: '0.1em',
416            pointerEvents: 'none',
417            userSelect: 'none',
418            zIndex: 10,
419          }}
420        >
421          Enter DreamSpace
422        </div>
423      </div>
424    );
425  
426    // Use React Portal to render to document.body, escaping all container constraints
427    return ReactDOM.createPortal(overlayContent, document.body);
428  };
429  
430  export default TutorialPortalOverlay;