refactor: extract PixiJS rendering into pixi/ modules

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>
This commit is contained in:
TehRiehlDealandClaude Opus 4.8 committed 2026-06-03 10:23:54 -07:00
1 parent f08138718a
commit 84112d8e42
8 files changed
+1057 -946

No files matched your search

+222
View File
@@ -0,0 +1,222 @@
// 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'
});
}
}