// Sequenced entrance animation: title → origin → zoom-out → nodes → lines, // then continuous ambient pulsing. Must run AFTER nodes exist — it re-enables // node interactivity (eventMode 'static') once each node finishes appearing. import { Container } from 'pixi.js'; import { gsap } from 'gsap'; import { getDistanceFromOrigin } from '$lib/utils/tree'; import { prefersReducedMotion } from '$lib/config'; import { updateParallax } from './interaction'; import type { SkillTreeController } from './controller'; import type { Lifecycle } from './lifecycle'; interface EntranceOpts { isMobile: boolean; life: Lifecycle; /** Called when the welcome title should be removed (matches its CSS fade). */ onTitleDone: () => void; } export function runEntranceAnimations(ctrl: SkillTreeController, opts: EntranceOpts) { const { isMobile } = opts; const { treeContainer, nodeObjects, lineObjects, endScale } = ctrl; // Reduced motion: show the finished tree immediately. No zoom, no stagger, // and critically no `repeat: -1` pulses — a static scene, not a faster one. if (prefersReducedMotion()) { treeContainer.scale.set(endScale); for (const obj of nodeObjects) { obj.graphics.alpha = 1; obj.graphics.scale.set(1); obj.glowGraphics.alpha = 1; if (obj.graphics instanceof Container) obj.graphics.eventMode = 'static'; } for (const line of lineObjects) { line.graphics.alpha = 1; line.glowGraphics.alpha = 1; line.plasmaBeam.alpha = 1; } const toggle = document.querySelector('.stats-panel-toggle'); if (toggle) toggle.style.opacity = '1'; opts.onTitleDone(); return; } // Responsive animation timing const titleDuration = isMobile ? 1.5 : 2.5; const zoomDuration = isMobile ? 2.5 : 4.5; const nodeStagger = isMobile ? 0.05 : 0.08; // The entrance allocates ~340 finite tweens plus ~170 `repeat: -1` pulses, // some created inside an onComplete. GSAP restores an animation's context // while running its callbacks, so wrapping the whole sequence here makes // even those late-created pulses killable via the lifecycle. opts.life.animate(() => { // Hide all lines and plasma beams initially lineObjects.forEach((line) => { line.graphics.alpha = 0; line.glowGraphics.alpha = 0; line.plasmaBeam.alpha = 0; }); // Hide all nodes initially nodeObjects.forEach((obj) => { obj.graphics.alpha = 0; obj.graphics.scale.set(0.5); obj.glowGraphics.alpha = 0; }); // Sort by distance from origin const sortedNodes = [...nodeObjects].sort( (a, b) => getDistanceFromOrigin(a.node) - getDistanceFromOrigin(b.node) ); const sortedLines = [...lineObjects].sort( (a, b) => getDistanceFromOrigin(a.node) - getDistanceFromOrigin(b.node) ); // STEP 1: After title fades, show origin node first (overlap on mobile) const originNode = sortedNodes.find((obj) => obj.node.id === 'origin'); const originDelay = isMobile ? titleDuration - 0.5 : titleDuration; const originDuration = isMobile ? 0.3 : 0.6; if (originNode && isMobile) { originNode.graphics.scale.set(0.8); // Start slightly larger } if (originNode) { gsap.to(originNode.graphics, { alpha: 1, duration: originDuration, delay: originDelay, ease: 'power2.out' }); gsap.to(originNode.graphics.scale, { x: 1, y: 1, duration: originDuration, delay: originDelay, ease: 'back.out(1.7)', onComplete: () => { if (originNode.graphics instanceof Container) { originNode.graphics.eventMode = 'static'; } } }); gsap.to(originNode.glowGraphics, { alpha: 1, duration: originDuration, delay: originDelay, ease: 'power2.out' }); } // STEP 2: Start zoom out after origin appears const zoomDelay = isMobile ? titleDuration - 0.3 : titleDuration + 0.3; gsap.to(treeContainer.scale, { x: endScale, y: endScale, duration: zoomDuration, delay: zoomDelay, ease: 'power1.out', onUpdate: () => updateParallax(ctrl) }); // STEP 3: Animate in the rest of the nodes (excluding origin) const otherNodes = sortedNodes.filter((obj) => obj.node.id !== 'origin'); const nodeStartDelay = isMobile ? titleDuration - 0.2 : titleDuration + 0.5; otherNodes.forEach((obj, index) => { const delay = nodeStartDelay + index * nodeStagger; gsap.to(obj.graphics, { alpha: 1, duration: 0.5, delay, ease: 'power2.out' }); gsap.to(obj.graphics.scale, { x: 1, y: 1, duration: 0.5, delay, ease: 'back.out(1.7)', onComplete: () => { if (obj.graphics instanceof Container) { obj.graphics.eventMode = 'static'; } } }); gsap.to(obj.glowGraphics, { alpha: 1, duration: 0.5, delay, ease: 'power2.out' }); // Start ambient pulse after node appears gsap.to(obj.glowGraphics.scale, { x: 1.4, y: 1.4, duration: 2 + Math.random() * 1, delay: delay + 0.5, ease: 'sine.inOut', yoyo: true, repeat: -1 }); }); // Start origin pulse too if (originNode) { gsap.to(originNode.glowGraphics.scale, { x: 1.4, y: 1.4, duration: 2 + Math.random() * 1, delay: titleDuration + 1, ease: 'sine.inOut', yoyo: true, repeat: -1 }); } // STEP 4: Animate lines and plasma beams as nodes appear const lineStartDelay = isMobile ? titleDuration - 0.3 : titleDuration + 0.4; sortedLines.forEach((line, index) => { const delay = lineStartDelay + index * nodeStagger; // Core line appears first gsap.to(line.graphics, { alpha: 1, duration: 0.4, delay, ease: 'power2.out' }); // Base glow appears with line gsap.to(line.glowGraphics, { alpha: 1, duration: 0.6, delay: delay + 0.1, ease: 'power2.out' }); // Plasma beam fades in, then breathes continuously gsap.to(line.plasmaBeam, { alpha: 1, duration: 0.8, delay: delay + 0.2, ease: 'power2.out', onComplete: () => { const pulseDuration = 2.0 + Math.random() * 1.0; // Vary pulse speed const minAlpha = 0.6 * line.intensity; // Minimum brightness - stays visible gsap.to(line.plasmaBeam, { alpha: minAlpha, duration: pulseDuration, ease: 'sine.inOut', yoyo: true, repeat: -1 }); const glowMinAlpha = 0.12 * line.intensity; gsap.to(line.glowGraphics, { alpha: glowMinAlpha, duration: pulseDuration * 1.2, ease: 'sine.inOut', yoyo: true, repeat: -1 }); } }); }); // Clean up title card after animation (matches titleDuration) const titleTimer = setTimeout( () => { opts.onTitleDone(); }, isMobile ? 1500 : 2500 ); opts.life.add(() => clearTimeout(titleTimer)); // Fade in stats panel toggle button with the tree const toggleButton = document.querySelector('.stats-panel-toggle'); if (toggleButton) { gsap.to(toggleButton, { opacity: 1, duration: 0.5, delay: nodeStartDelay + otherNodes.length * nodeStagger * 0.5, // midway through node animation ease: 'power2.out' }); } }); }