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:
2026-06-03 10:23:54 -07:00
parent f08138718a
commit 84112d8e42
8 changed files with 1057 additions and 946 deletions
File diff suppressed because it is too large Load Diff
+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'
});
}
}
+238
View File
@@ -0,0 +1,238 @@
// Multi-layer space background: gradient → nebula → stars → dust → vignette.
// Each layer is added to the stage here; the three parallax containers are
// returned so the interaction module can offset them while panning.
import { Application, Graphics, Container } from 'pixi.js';
import { gsap } from 'gsap';
import { nebulaPalette } from '$lib/theme';
import { PARTICLE_COUNTS, byDevice } from '$lib/config';
import type { BackgroundLayers } from './types';
export function createBackground(app: Application, isMobile: boolean): BackgroundLayers {
const deepBackgroundContainer = new Container();
const nebulaContainer = new Container();
const starContainer = new Container();
app.stage.addChild(deepBackgroundContainer);
app.stage.addChild(nebulaContainer);
app.stage.addChild(starContainer);
const screenWidth = app.screen.width;
const screenHeight = app.screen.height;
const centerX = screenWidth / 2;
const centerY = screenHeight / 2;
// ========== DEEP SPACE GRADIENT ==========
const spaceGradient = new Graphics();
const gradientRadius = Math.max(screenWidth, screenHeight);
const gradientSteps = 15;
for (let i = gradientSteps; i >= 0; i--) {
const ratio = i / gradientSteps;
const radius = gradientRadius * (0.3 + ratio * 0.7);
const r = Math.floor(8 * (1 - ratio));
const g = Math.floor(6 * (1 - ratio));
const b = Math.floor(20 * (1 - ratio));
const color = (r << 16) + (g << 8) + b;
spaceGradient.circle(centerX, centerY, radius).fill({ color, alpha: 0.8 });
}
deepBackgroundContainer.addChild(spaceGradient);
// ========== NEBULA CLOUDS ==========
const nebulaColors = nebulaPalette;
nebulaColors.forEach((nebula, index) => {
const angle = (index / nebulaColors.length) * Math.PI * 2 + Math.PI / 4;
const distance = Math.min(screenWidth, screenHeight) * 0.35;
const baseX = centerX + Math.cos(angle) * distance;
const baseY = centerY + Math.sin(angle) * distance;
const nebulaCloud = new Graphics();
// Outer very faint glow
const outerSize = 200 + Math.random() * 150;
for (let layer = 5; layer >= 0; layer--) {
const layerRatio = layer / 5;
const size = outerSize * (0.4 + layerRatio * 0.6);
const alpha = 0.02 * (1 - layerRatio);
nebulaCloud.circle(0, 0, size).fill({ color: nebula.color, alpha });
}
// Irregular blobs for a more organic look
for (let i = 0; i < 3; i++) {
const offsetX = (Math.random() - 0.5) * 100;
const offsetY = (Math.random() - 0.5) * 100;
const blobSize = 60 + Math.random() * 80;
for (let layer = 3; layer >= 0; layer--) {
const layerRatio = layer / 3;
const size = blobSize * (0.5 + layerRatio * 0.5);
const alpha = 0.03 * (1 - layerRatio);
nebulaCloud.circle(offsetX, offsetY, size).fill({ color: nebula.color, alpha });
}
}
nebulaCloud.x = baseX;
nebulaCloud.y = baseY;
nebulaContainer.addChild(nebulaCloud);
// Animate nebula pulsing
gsap.to(nebulaCloud, {
alpha: 0.6,
duration: 3 + Math.random() * 2,
ease: 'sine.inOut',
yoyo: true,
repeat: -1,
delay: Math.random() * 2
});
gsap.to(nebulaCloud.scale, {
x: 1.1,
y: 1.1,
duration: 4 + Math.random() * 2,
ease: 'sine.inOut',
yoyo: true,
repeat: -1,
delay: Math.random() * 2
});
});
// ========== DISTANT STAR FIELD ==========
// Layer 1: Tiny distant stars (many, dim)
const distantStarCount = byDevice(PARTICLE_COUNTS.distantStars, isMobile);
for (let i = 0; i < distantStarCount; i++) {
const star = new Graphics();
const size = Math.random() * 1 + 0.5;
const alpha = Math.random() * 0.4 + 0.1;
star.circle(0, 0, size).fill({ color: 0xffffff, alpha });
star.x = Math.random() * screenWidth;
star.y = Math.random() * screenHeight;
starContainer.addChild(star);
gsap.to(star, {
alpha: alpha * 0.3,
duration: 1 + Math.random() * 2,
ease: 'sine.inOut',
yoyo: true,
repeat: -1,
delay: Math.random() * 3
});
}
// Layer 2: Medium stars with colour tints
const mediumStarCount = byDevice(PARTICLE_COUNTS.mediumStars, isMobile);
const starTints = [0xffffff, 0xfff8e7, 0xe7f0ff, 0xffe7e7, 0xe7ffe7];
for (let i = 0; i < mediumStarCount; i++) {
const star = new Graphics();
const size = Math.random() * 1.5 + 1;
const alpha = Math.random() * 0.5 + 0.3;
const tint = starTints[Math.floor(Math.random() * starTints.length)];
star.circle(0, 0, size + 2).fill({ color: tint, alpha: alpha * 0.2 });
star.circle(0, 0, size).fill({ color: tint, alpha });
star.x = Math.random() * screenWidth;
star.y = Math.random() * screenHeight;
starContainer.addChild(star);
gsap.to(star, {
alpha: alpha * 0.4,
duration: 0.5 + Math.random() * 1.5,
ease: 'sine.inOut',
yoyo: true,
repeat: -1,
delay: Math.random() * 2
});
}
// Layer 3: Bright feature stars (few, prominent)
const brightStarCount = byDevice(PARTICLE_COUNTS.brightStars, isMobile);
for (let i = 0; i < brightStarCount; i++) {
const star = new Graphics();
const size = Math.random() * 2 + 1.5;
const tint = starTints[Math.floor(Math.random() * starTints.length)];
star.circle(0, 0, size + 6).fill({ color: tint, alpha: 0.05 });
star.circle(0, 0, size + 3).fill({ color: tint, alpha: 0.1 });
star.circle(0, 0, size).fill({ color: 0xffffff, alpha: 0.9 });
star.x = Math.random() * screenWidth;
star.y = Math.random() * screenHeight;
starContainer.addChild(star);
gsap.to(star.scale, {
x: 1.3,
y: 1.3,
duration: 2 + Math.random() * 2,
ease: 'sine.inOut',
yoyo: true,
repeat: -1,
delay: Math.random() * 2
});
}
// ========== FLOATING DUST PARTICLES ==========
const dustParticles: Array<{ graphics: Graphics; vx: number; vy: number }> = [];
const dustCount = byDevice(PARTICLE_COUNTS.dust, isMobile);
for (let i = 0; i < dustCount; i++) {
const dust = new Graphics();
const size = Math.random() * 2 + 1;
const colorIndex = Math.floor(Math.random() * nebulaColors.length);
const color = nebulaColors[colorIndex].color;
dust.circle(0, 0, size + 4).fill({ color, alpha: 0.05 });
dust.circle(0, 0, size + 2).fill({ color, alpha: 0.08 });
dust.circle(0, 0, size).fill({ color, alpha: 0.15 });
dust.x = Math.random() * screenWidth;
dust.y = Math.random() * screenHeight;
starContainer.addChild(dust);
dustParticles.push({
graphics: dust,
vx: (Math.random() - 0.5) * 0.2,
vy: (Math.random() - 0.5) * 0.2
});
gsap.to(dust, {
alpha: 0.3,
duration: 3 + Math.random() * 2,
ease: 'sine.inOut',
yoyo: true,
repeat: -1,
delay: Math.random() * 2
});
}
// Animate dust particles drifting
app.ticker.add(() => {
dustParticles.forEach((p) => {
p.graphics.x += p.vx;
p.graphics.y += p.vy;
if (p.graphics.x < -20) p.graphics.x = screenWidth + 20;
if (p.graphics.x > screenWidth + 20) p.graphics.x = -20;
if (p.graphics.y < -20) p.graphics.y = screenHeight + 20;
if (p.graphics.y > screenHeight + 20) p.graphics.y = -20;
});
});
// ========== VIGNETTE OVERLAY ==========
const vignette = new Graphics();
const vignetteSize = Math.max(screenWidth, screenHeight) * 1.2;
for (let i = 12; i >= 0; i--) {
const ratio = i / 12;
const radius = vignetteSize * (0.4 + ratio * 0.6);
const alpha = ratio * ratio * 0.4;
vignette.circle(centerX, centerY, radius).fill({ color: 0x000000, alpha });
}
app.stage.addChild(vignette);
return { deepBackgroundContainer, nebulaContainer, starContainer };
}
+58
View File
@@ -0,0 +1,58 @@
// Mutable controller object shared across the PixiJS modules.
//
// IMPORTANT: this must NOT be wrapped in Svelte's `$state` — the deep proxy
// breaks PixiJS's internal object identity checks. Keep it a plain object so
// the interaction, node, and animation modules can read/write the same
// mutable pan/zoom state by reference (the same single source of truth the
// original onMount closure relied on).
import type { Application, Container } from 'pixi.js';
import type { BackgroundLayers, LineObject, NodeObject } from './types';
export interface SkillTreeController {
app: Application;
treeContainer: Container;
bg: BackgroundLayers;
nodeObjects: NodeObject[];
lineObjects: LineObject[];
// Pan/zoom mutable state (was closure vars in onMount).
isDragging: boolean;
hasMoved: boolean;
dragStartX: number;
dragStartY: number;
containerStartX: number;
containerStartY: number;
lastTouchDistance: number;
isTouchZooming: boolean;
// Parallax baseline (re-set on view reset) + final zoom scale.
initialTreeX: number;
initialTreeY: number;
endScale: number;
}
export function createController(
app: Application,
treeContainer: Container,
bg: BackgroundLayers,
endScale: number
): SkillTreeController {
return {
app,
treeContainer,
bg,
nodeObjects: [],
lineObjects: [],
isDragging: false,
hasMoved: false,
dragStartX: 0,
dragStartY: 0,
containerStartX: 0,
containerStartY: 0,
lastTouchDistance: 0,
isTouchZooming: false,
initialTreeX: treeContainer.x,
initialTreeY: treeContainer.y,
endScale
};
}
+268
View File
@@ -0,0 +1,268 @@
// Pan, zoom, parallax, and keyboard navigation for the tree.
//
// All shared mutable state lives on the controller (see controller.ts); these
// handlers read/write it by reference. attach* functions return a cleanup that
// removes the window-level listeners they added.
import { gsap } from 'gsap';
import { ZOOM, PARALLAX, PAN_SPEED } from '$lib/config';
import type { SkillTreeController } from './controller';
/** Offset the background layers behind the tree to create parallax depth. */
export function updateParallax(ctrl: SkillTreeController) {
const treeDeltaX = ctrl.treeContainer.x - ctrl.initialTreeX;
const treeDeltaY = ctrl.treeContainer.y - ctrl.initialTreeY;
ctrl.bg.starContainer.x = treeDeltaX * PARALLAX.star;
ctrl.bg.starContainer.y = treeDeltaY * PARALLAX.star;
ctrl.bg.nebulaContainer.x = treeDeltaX * PARALLAX.nebula;
ctrl.bg.nebulaContainer.y = treeDeltaY * PARALLAX.nebula;
ctrl.bg.deepBackgroundContainer.x = treeDeltaX * PARALLAX.gradient;
ctrl.bg.deepBackgroundContainer.y = treeDeltaY * PARALLAX.gradient;
// No scale parallax - backgrounds keep scale 1.0
}
interface PanZoomOpts {
onTooltipMove: (x: number, y: number) => void;
}
/** Wire mouse/touch/wheel pan + zoom. Returns a cleanup for window listeners. */
export function attachPanZoom(ctrl: SkillTreeController, opts: PanZoomOpts): () => void {
const { app, treeContainer } = ctrl;
const minZoom = ZOOM.min;
const maxZoom = ZOOM.max;
// ===== MOUSE PANNING =====
app.canvas.addEventListener('mousedown', (e: MouseEvent) => {
ctrl.isDragging = true;
ctrl.hasMoved = false;
ctrl.dragStartX = e.clientX;
ctrl.dragStartY = e.clientY;
ctrl.containerStartX = treeContainer.x;
ctrl.containerStartY = treeContainer.y;
app.canvas.style.cursor = 'grabbing';
});
const onWindowMouseMove = (e: MouseEvent) => {
if (!ctrl.isDragging) return;
const dx = e.clientX - ctrl.dragStartX;
const dy = e.clientY - ctrl.dragStartY;
if (Math.abs(dx) > 5 || Math.abs(dy) > 5) {
ctrl.hasMoved = true;
}
treeContainer.x = ctrl.containerStartX + dx;
treeContainer.y = ctrl.containerStartY + dy;
updateParallax(ctrl);
};
window.addEventListener('mousemove', onWindowMouseMove);
const onWindowMouseUp = () => {
ctrl.isDragging = false;
app.canvas.style.cursor = 'grab';
};
window.addEventListener('mouseup', onWindowMouseUp);
app.canvas.style.cursor = 'grab';
// Track mouse position for tooltip
app.canvas.addEventListener('mousemove', (e: MouseEvent) => {
opts.onTooltipMove(e.clientX, e.clientY);
});
// ===== TOUCH PANNING & PINCH ZOOM =====
app.canvas.addEventListener(
'touchstart',
(e: TouchEvent) => {
if (e.touches.length === 1) {
ctrl.isDragging = true;
ctrl.hasMoved = false;
ctrl.isTouchZooming = false;
ctrl.dragStartX = e.touches[0].clientX;
ctrl.dragStartY = e.touches[0].clientY;
ctrl.containerStartX = treeContainer.x;
ctrl.containerStartY = treeContainer.y;
} else if (e.touches.length === 2) {
ctrl.isDragging = false;
ctrl.isTouchZooming = true;
ctrl.hasMoved = true; // Pinch zoom counts as movement
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
ctrl.lastTouchDistance = Math.sqrt(dx * dx + dy * dy);
}
},
{ passive: true }
);
app.canvas.addEventListener(
'touchmove',
(e: TouchEvent) => {
if (e.touches.length === 1 && ctrl.isDragging && !ctrl.isTouchZooming) {
const dx = e.touches[0].clientX - ctrl.dragStartX;
const dy = e.touches[0].clientY - ctrl.dragStartY;
if (Math.abs(dx) > 10 || Math.abs(dy) > 10) {
ctrl.hasMoved = true;
treeContainer.x = ctrl.containerStartX + dx;
treeContainer.y = ctrl.containerStartY + dy;
updateParallax(ctrl);
}
} else if (e.touches.length === 2 && ctrl.isTouchZooming) {
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
const distance = Math.sqrt(dx * dx + dy * dy);
if (ctrl.lastTouchDistance > 0) {
const scale = distance / ctrl.lastTouchDistance;
let newScale = treeContainer.scale.x * scale;
newScale = Math.max(minZoom, Math.min(maxZoom, newScale));
// Zoom centered on origin node (same as mouse wheel zoom)
treeContainer.scale.set(newScale);
updateParallax(ctrl);
}
ctrl.lastTouchDistance = distance;
ctrl.hasMoved = true;
}
},
{ passive: true }
);
app.canvas.addEventListener(
'touchend',
(e: TouchEvent) => {
if (e.touches.length === 0) {
ctrl.isDragging = false;
ctrl.isTouchZooming = false;
ctrl.lastTouchDistance = 0;
} else if (e.touches.length === 1) {
ctrl.isTouchZooming = false;
ctrl.isDragging = true;
ctrl.hasMoved = true; // Consider this movement since we were zooming
ctrl.dragStartX = e.touches[0].clientX;
ctrl.dragStartY = e.touches[0].clientY;
ctrl.containerStartX = treeContainer.x;
ctrl.containerStartY = treeContainer.y;
}
},
{ passive: true }
);
// ===== WHEEL ZOOM =====
app.canvas.addEventListener('wheel', (e: WheelEvent) => {
e.preventDefault();
const oldScale = treeContainer.scale.x;
const zoomFactor = e.deltaY > 0 ? ZOOM.wheelOut : ZOOM.wheelIn;
const newScale = Math.max(minZoom, Math.min(maxZoom, oldScale * zoomFactor));
// Zoom centered on origin node (0,0 in tree space): origin sits at
// (treeContainer.x, treeContainer.y), so scaling keeps it anchored.
treeContainer.scale.set(newScale);
updateParallax(ctrl);
});
return () => {
window.removeEventListener('mousemove', onWindowMouseMove);
window.removeEventListener('mouseup', onWindowMouseUp);
};
}
interface KeyboardOpts {
onToggleStats: () => void;
onCloseModal: () => void;
isModalVisible: () => boolean;
isStatsVisible: () => boolean;
}
/** Wire keyboard navigation (pan/zoom/reset/stats/escape). Returns cleanup. */
export function attachKeyboard(ctrl: SkillTreeController, opts: KeyboardOpts): () => void {
const { app, treeContainer } = ctrl;
const minZoom = ZOOM.min;
const maxZoom = ZOOM.max;
function handleKeydown(e: KeyboardEvent) {
// Toggle stats panel with 'C' key
if (e.key === 'c' || e.key === 'C') {
if (!opts.isModalVisible()) {
opts.onToggleStats();
}
}
// Escape key priority: modal > stats panel
if (e.key === 'Escape') {
if (opts.isModalVisible()) {
opts.onCloseModal();
} else if (opts.isStatsVisible()) {
opts.onToggleStats();
}
}
const panSpeed = PAN_SPEED;
const zoomSpeed = ZOOM.keyStep;
// Arrow keys for panning
if (e.key === 'ArrowUp') {
e.preventDefault();
treeContainer.y += panSpeed;
updateParallax(ctrl);
} else if (e.key === 'ArrowDown') {
e.preventDefault();
treeContainer.y -= panSpeed;
updateParallax(ctrl);
} else if (e.key === 'ArrowLeft') {
e.preventDefault();
treeContainer.x += panSpeed;
updateParallax(ctrl);
} else if (e.key === 'ArrowRight') {
e.preventDefault();
treeContainer.x -= panSpeed;
updateParallax(ctrl);
}
// + and = for zoom in
if (e.key === '+' || e.key === '=') {
e.preventDefault();
const currentScale = treeContainer.scale.x;
const newScale = Math.min(maxZoom, currentScale + zoomSpeed);
treeContainer.scale.set(newScale);
updateParallax(ctrl);
}
// - for zoom out
if (e.key === '-') {
e.preventDefault();
const currentScale = treeContainer.scale.x;
const newScale = Math.max(minZoom, currentScale - zoomSpeed);
treeContainer.scale.set(newScale);
updateParallax(ctrl);
}
// R to reset view to origin
if (e.key === 'r' || e.key === 'R') {
e.preventDefault();
gsap.to(treeContainer, {
x: app.screen.width / 2,
y: app.screen.height / 2,
duration: 0.5,
ease: 'power2.out',
onUpdate: () => updateParallax(ctrl),
onComplete: () => {
// Update parallax baseline after reset
ctrl.initialTreeX = treeContainer.x;
ctrl.initialTreeY = treeContainer.y;
}
});
gsap.to(treeContainer.scale, {
x: ctrl.endScale,
y: ctrl.endScale,
duration: 0.5,
ease: 'power2.out',
onUpdate: () => updateParallax(ctrl)
});
}
}
window.addEventListener('keydown', handleKeydown);
return () => window.removeEventListener('keydown', handleKeydown);
}
+59
View File
@@ -0,0 +1,59 @@
// Parent→child connections drawn as a glow + core line + plasma beam.
// Brightness scales with beam intensity (see $lib/utils/tree).
import { Graphics, Container } from 'pixi.js';
import { treeNodes } from '$lib/data';
import { categoryColors } from '$lib/theme';
import { getNodeById, getBeamIntensity } from '$lib/utils/tree';
import type { LineObject } from './types';
export function createLines(treeContainer: Container): LineObject[] {
const lineObjects: LineObject[] = [];
for (const node of treeNodes) {
if (node.parentId) {
const parent = getNodeById(node.parentId);
if (parent) {
const intensity = getBeamIntensity(node);
const color = categoryColors[node.category];
// Base glow layer (wide, soft)
const glowGraphics = new Graphics();
glowGraphics.alpha = 0;
glowGraphics
.moveTo(parent.x, parent.y)
.lineTo(node.x, node.y)
.stroke({ width: 12, color, alpha: 0.1 * intensity });
treeContainer.addChild(glowGraphics);
// Core line (thin, solid)
const lineGraphics = new Graphics();
lineGraphics.alpha = 0;
lineGraphics
.moveTo(parent.x, parent.y)
.lineTo(node.x, node.y)
.stroke({ width: 2, color: 0x4a4a5a });
treeContainer.addChild(lineGraphics);
// Plasma energy beam (medium, bright, animated later)
const plasmaBeam = new Graphics();
plasmaBeam.alpha = 0;
plasmaBeam
.moveTo(parent.x, parent.y)
.lineTo(node.x, node.y)
.stroke({ width: 4, color, alpha: 0.6 * intensity });
treeContainer.addChild(plasmaBeam);
lineObjects.push({
graphics: lineGraphics,
glowGraphics,
plasmaBeam,
node,
parent,
intensity
});
}
}
}
return lineObjects;
}
+142
View File
@@ -0,0 +1,142 @@
// Tree node rendering: the origin profile node and regular icon nodes.
// Nodes start non-interactive (eventMode 'none') until the entrance animation
// enables them; see animation.ts.
import { Graphics, Container, Assets, Sprite } from 'pixi.js';
import { gsap } from 'gsap';
import { treeNodes, type TreeNode } from '$lib/data';
import { categoryColors } from '$lib/theme';
import { getNodeRadius } from '$lib/utils/tree';
import type { NodeObject } from './types';
import type { SkillTreeController } from './controller';
interface NodeOpts {
isMobile: boolean;
isTouchDevice: boolean;
controller: SkillTreeController;
onOpen: (node: TreeNode) => void;
onHover: (node: TreeNode | null) => void;
}
/** Shared pointer wiring for both the origin and regular nodes. */
function attachNodeInteraction(
target: Container,
glowGraphics: Graphics,
node: TreeNode,
opts: NodeOpts
) {
target.on('pointerdown', () => {
opts.controller.hasMoved = false;
});
target.on('pointerup', () => {
if (!opts.controller.hasMoved) {
opts.onOpen(node);
}
});
target.on('pointerover', () => {
gsap.to(target.scale, { x: 1.2, y: 1.2, duration: 0.2 });
gsap.to(glowGraphics, { alpha: 1.5, duration: 0.2 });
if (!opts.isTouchDevice) opts.onHover(node);
});
target.on('pointerout', () => {
gsap.to(target.scale, { x: 1, y: 1, duration: 0.2 });
gsap.to(glowGraphics, { alpha: 1, duration: 0.2 });
if (!opts.isTouchDevice) opts.onHover(null);
});
}
export async function createNodes(treeContainer: Container, opts: NodeOpts): Promise<NodeObject[]> {
const { isMobile } = opts;
const nodeObjects: NodeObject[] = [];
const profileTexture = await Assets.load('/headshot.jpg');
for (const node of treeNodes) {
const radius = getNodeRadius(node.size, isMobile);
const color = categoryColors[node.category];
const glowGraphics = new Graphics();
glowGraphics.circle(0, 0, radius + 8).fill({ color, alpha: 0.1 });
glowGraphics.circle(0, 0, radius + 4).fill({ color, alpha: 0.15 });
glowGraphics.x = node.x;
glowGraphics.y = node.y;
glowGraphics.alpha = 0;
treeContainer.addChild(glowGraphics);
if (node.id === 'origin') {
const profileContainer = new Container();
profileContainer.x = node.x;
profileContainer.y = node.y;
const maskGraphics = new Graphics();
maskGraphics.circle(0, 0, radius).fill({ color: 0xffffff });
profileContainer.addChild(maskGraphics);
const profileSprite = new Sprite(profileTexture);
const scale = (radius * 2) / Math.min(profileTexture.width, profileTexture.height);
profileSprite.scale.set(scale);
profileSprite.anchor.set(0.5);
profileSprite.mask = maskGraphics;
profileContainer.addChild(profileSprite);
const borderGraphics = new Graphics();
borderGraphics.circle(0, 0, radius + 2).stroke({ width: 3, color });
borderGraphics.circle(0, 0, radius + 6).stroke({ width: 2, color, alpha: 0.5 });
profileContainer.addChild(borderGraphics);
profileContainer.alpha = 0;
profileContainer.scale.set(0.5);
// Disable interactivity until animation completes
profileContainer.eventMode = 'none';
profileContainer.cursor = 'pointer';
attachNodeInteraction(profileContainer, glowGraphics, node, opts);
treeContainer.addChild(profileContainer);
nodeObjects.push({ graphics: profileContainer, node, glowGraphics });
} else {
const nodeContainer = new Container();
nodeContainer.x = node.x;
nodeContainer.y = node.y;
const nodeGraphics = new Graphics();
nodeGraphics.circle(0, 0, radius).fill({ color: 0x12121a }).stroke({ width: 2, color });
nodeGraphics.circle(0, 0, radius * 0.75).stroke({ width: 1, color, alpha: 0.2 });
nodeContainer.addChild(nodeGraphics);
if (node.icon) {
try {
const iconTexture = await Assets.load(node.icon);
const iconSprite = new Sprite(iconTexture);
const iconSize = radius * 1.4;
const scale = iconSize / Math.max(iconTexture.width, iconTexture.height);
iconSprite.scale.set(scale);
iconSprite.anchor.set(0.5);
iconSprite.tint = color;
nodeContainer.addChild(iconSprite);
} catch {
console.warn(`Could not load icon for ${node.id}:`, node.icon);
}
}
nodeContainer.alpha = 0;
nodeContainer.scale.set(0.5);
// Disable interactivity until animation completes
nodeContainer.eventMode = 'none';
nodeContainer.cursor = 'pointer';
attachNodeInteraction(nodeContainer, glowGraphics, node, opts);
treeContainer.addChild(nodeContainer);
nodeObjects.push({ graphics: nodeContainer, node, glowGraphics });
}
}
return nodeObjects;
}
+27
View File
@@ -0,0 +1,27 @@
// Shared structural types for the PixiJS skill-tree rendering modules.
import type { Graphics, Container } from 'pixi.js';
import type { TreeNode } from '$lib/data';
/** A parent→child connection: glow + core line + animated plasma beam. */
export interface LineObject {
graphics: Graphics;
glowGraphics: Graphics;
plasmaBeam: Graphics;
node: TreeNode;
parent: TreeNode;
intensity: number;
}
/** A rendered node (origin profile or regular icon node) plus its glow. */
export interface NodeObject {
graphics: Graphics | Container;
node: TreeNode;
glowGraphics: Graphics;
}
/** The three parallax background layers (vignette lives on the stage). */
export interface BackgroundLayers {
deepBackgroundContainer: Container;
nebulaContainer: Container;
starContainer: Container;
}