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:
File diff suppressed because it is too large
Load Diff
@@ -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'
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user