// 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 { updateParallax } from './interaction'; import type { SkillTreeController } from './controller'; interface EntranceOpts { isMobile: boolean; /** 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; // Responsive animation timing const titleDuration = isMobile ? 1.5 : 2.5; const zoomDuration = isMobile ? 2.5 : 4.5; const nodeStagger = isMobile ? 0.05 : 0.08; // 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) setTimeout( () => { opts.onTitleDone(); }, isMobile ? 1500 : 2500 ); // 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' }); } }