Phase 5 (behavior-preserving): decompose the ~1000-line onMount into focused
modules coordinated by a plain (non-reactive) controller object:
- pixi/types.ts LineObject / NodeObject / BackgroundLayers
- pixi/controller.ts shared mutable pan/zoom state + Pixi refs (NOT $state,
which would break PixiJS object identity)
- pixi/background.ts gradient/nebula/stars/dust/vignette + ambient loops
- pixi/lines.ts plasma-beam connections
- pixi/nodes.ts origin + regular nodes; collapses the duplicated pointer
handlers into one attachNodeInteraction helper
- pixi/interaction.ts pan/zoom/parallax + keyboard nav (return cleanups)
- pixi/animation.ts sequenced entrance animation
onMount is now a thin orchestrator. SkillTree.svelte: 1177 -> 274 lines
(2453 at the start of the refactor). Listener passivity, the hasMoved click
guard, eventMode-until-animated ordering, and the R-reset parallax rebaseline
are all preserved.
svelte-check: 0 errors/0 warnings. lint/test/build clean. Verified onMount runs
with no runtime exceptions (background + stats panel render). NOTE: pan/zoom/
node-click/keyboard need a manual real-browser check (headless can't paint nodes).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
223 lines
5.6 KiB
TypeScript
223 lines
5.6 KiB
TypeScript
// 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'
|
|
});
|
|
}
|
|
}
|