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;