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:
1 parent
f08138718a
commit
84112d8e42
8 files changed
+1057
-946
No files matched your search
@@ -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'
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user