fix: audit phase 2 — teardown, failure paths, service worker, tooltip, resize
Correctness phase of the frontend audit. Nothing here is visible on a happy
path; it is what the app does when something goes wrong, when the window
changes, or when the component goes away.
Teardown (new src/lib/pixi/lifecycle.ts):
- Nothing was ever released: no app.destroy(), no ticker.remove(), no
killTweensOf, no onDestroy. ~480 GSAP tweens (most repeat:-1), a ticker
callback, and a dozen DOM listeners lived forever.
- 112 of those tweens are created inside an onComplete, so they don't exist
when a call-site sweep would run. gsap.context() handles exactly this:
GSAP records each animation's context on creation and restores it while
invoking that animation's callbacks (see _callback in gsap-core), so
late-created pulses land in the same context. One ctx.kill() gets all of
them. kill(), not revert(), so GSAP doesn't write properties back onto
objects about to be destroyed.
- All DOM listeners now go through life.listen(), which registers removal at
the same time. Previously 6 canvas listeners were never removed, each
closing over the whole ~713-object scene graph.
- The unmount race: cleanup used to be registered on the LAST line of an
async onMount, after the asset waterfall, so unmounting mid-load cleaned up
nothing. The lifecycle is now created before any await, with life.disposed
guards after each one.
- Hover tweens re-enter the context per event and use overwrite:'auto' so
rapid sweeps don't stack conflicting tweens.
Failure paths:
- onMount had no try/catch and no WebGL detection. Any init or asset failure
left the spinner up forever, the stats toggle at opacity:0, and Escape dead
(attachKeyboard never ran). Now caught, and it hands off to +page.svelte to
reveal the text version.
- There was no <noscript> anywhere, and SeoContent is clipped to 1x1px with
html/body overflow:hidden — so JS-disabled visitors got a pure black page.
A <noscript><style> block now un-hides it with no JS involved.
Service worker (moved static/ -> src/, so SvelteKit builds it):
- It can now use $service-worker, so precache comes from the real build
manifest instead of a hand-maintained 5-entry list that omitted all JS/CSS,
and the cache name embeds `version` so old caches actually get pruned.
- cache.addAll was atomic: one 404 failed the whole install silently. Now
per-asset with a warning on partial failure.
- The catch returned undefined for non-image assets, which makes
respondWith throw a TypeError — a soft offline failure became a hard one.
Every path now resolves to a Response.
- Unhashed assets (icons, headshot, resume PDF) were cache-first with no
expiry, so replacing the resume at the same URL never reached a returning
visitor. Those are network-first now; only hashed build output is
cache-first.
- Navigation responses were cached without a status check, so a 404/500 body
could become the offline fallback.
- Dropped skipWaiting() from install and added a real update prompt +
controllerchange reload. The SKIP_WAITING handler was previously dead code
and the "check for updates periodically" comment described an interval that
did not exist.
Tooltip:
- Wrote Svelte state on every canvas mousemove even with nothing hovered.
Now gated on hover and coalesced to one write per frame; raw coords are
still tracked cheaply so the first hover isn't positioned from stale data.
- Animated left/top (layout + paint per frame on an element with two
color-mix shadows); now translate3d.
- Listener moved to window, so coordinates no longer freeze when a drag
leaves the canvas.
- Flip logic measured a hardcoded 280x120; now measures the real element
(actual height is 113, so tall tooltips mis-flipped near the bottom edge).
- Dropped role="tooltip" + aria-live="polite", which announced the full
label and description on every hover. Nothing referenced it via
aria-describedby, so the role was inert. Now aria-hidden.
Resize:
- Star positions, gradient radius, vignette size and dust wrap bounds were
all baked at first paint, so enlarging the window left an unlit band. The
background is now rebuilt on a rAF-coalesced resize, under its own
sub-lifecycle. The view is shifted by half the delta rather than re-centred,
so the user's current view stays anchored.
- isTouchDevice used 'ontouchstart', true on touch-capable laptops and thus
permanently killing mouse hover. Now matchMedia('(hover: hover)').
Known gap: node radii still don't change when the 768px breakpoint is
crossed mid-session; re-creating all 57 nodes would reset interaction state.
Verified in the production build: resize 1278x1220 -> 1734x821 rebuilds with
zero errors, tooltip positions via transform and stays on screen, fallback
renders as a readable resume page. svelte-check clean, lint clean, 14/14.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
8874b35998
commit
746d81b151
14 files changed
+1127
-692
No files matched your search
+195
-186
@@ -6,9 +6,11 @@ import { gsap } from 'gsap';
|
||||
import { getDistanceFromOrigin } from '$lib/utils/tree';
|
||||
import { updateParallax } from './interaction';
|
||||
import type { SkillTreeController } from './controller';
|
||||
import type { Lifecycle } from './lifecycle';
|
||||
|
||||
interface EntranceOpts {
|
||||
isMobile: boolean;
|
||||
life: Lifecycle;
|
||||
/** Called when the welcome title should be removed (matches its CSS fade). */
|
||||
onTitleDone: () => void;
|
||||
}
|
||||
@@ -22,201 +24,208 @@ export function runEntranceAnimations(ctrl: SkillTreeController, opts: EntranceO
|
||||
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'
|
||||
// The entrance allocates ~340 finite tweens plus ~170 `repeat: -1` pulses,
|
||||
// some created inside an onComplete. GSAP restores an animation's context
|
||||
// while running its callbacks, so wrapping the whole sequence here makes
|
||||
// even those late-created pulses killable via the lifecycle.
|
||||
opts.life.animate(() => {
|
||||
// Hide all lines and plasma beams initially
|
||||
lineObjects.forEach((line) => {
|
||||
line.graphics.alpha = 0;
|
||||
line.glowGraphics.alpha = 0;
|
||||
line.plasmaBeam.alpha = 0;
|
||||
});
|
||||
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';
|
||||
|
||||
// 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)
|
||||
});
|
||||
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;
|
||||
// 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;
|
||||
|
||||
gsap.to(treeContainer.scale, {
|
||||
x: endScale,
|
||||
y: endScale,
|
||||
duration: zoomDuration,
|
||||
delay: zoomDelay,
|
||||
ease: 'power1.out',
|
||||
onUpdate: () => updateParallax(ctrl)
|
||||
});
|
||||
otherNodes.forEach((obj, index) => {
|
||||
const delay = nodeStartDelay + index * nodeStagger;
|
||||
|
||||
// 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.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'
|
||||
});
|
||||
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 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)
|
||||
const titleTimer = setTimeout(
|
||||
() => {
|
||||
opts.onTitleDone();
|
||||
},
|
||||
isMobile ? 1500 : 2500
|
||||
);
|
||||
opts.life.add(() => clearTimeout(titleTimer));
|
||||
|
||||
// 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'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 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'
|
||||
});
|
||||
}
|
||||
}
|
||||
+228
-217
@@ -6,9 +6,15 @@ import { gsap } from 'gsap';
|
||||
import { nebulaPalette } from '$lib/theme';
|
||||
import { PARTICLE_COUNTS, byDevice } from '$lib/config';
|
||||
import type { BackgroundLayers } from './types';
|
||||
import type { Lifecycle } from './lifecycle';
|
||||
|
||||
export function createBackground(app: Application, isMobile: boolean): BackgroundLayers {
|
||||
export function createBackground(
|
||||
app: Application,
|
||||
isMobile: boolean,
|
||||
life: Lifecycle
|
||||
): BackgroundLayers {
|
||||
const deepBackgroundContainer = new Container();
|
||||
let vignette!: Graphics;
|
||||
const nebulaContainer = new Container();
|
||||
const starContainer = new Container();
|
||||
|
||||
@@ -16,238 +22,243 @@ export function createBackground(app: Application, isMobile: boolean): Backgroun
|
||||
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;
|
||||
// Everything below allocates `repeat: -1` ambient tweens; building it inside
|
||||
// the lifecycle's GSAP context makes all of them killable on teardown.
|
||||
life.animate(() => {
|
||||
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);
|
||||
// ========== 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 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;
|
||||
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);
|
||||
spaceGradient.circle(centerX, centerY, radius).fill({ color, alpha: 0.8 });
|
||||
}
|
||||
deepBackgroundContainer.addChild(spaceGradient);
|
||||
|
||||
// ========== NEBULA CLOUDS ==========
|
||||
const nebulaColors = nebulaPalette;
|
||||
// ========== 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;
|
||||
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();
|
||||
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 });
|
||||
// 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
|
||||
});
|
||||
}
|
||||
|
||||
// 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;
|
||||
// Layer 2: Medium stars with colour tints
|
||||
const mediumStarCount = byDevice(PARTICLE_COUNTS.mediumStars, isMobile);
|
||||
const starTints = [0xffffff, 0xfff8e7, 0xe7f0ff, 0xffe7e7, 0xe7ffe7];
|
||||
|
||||
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 });
|
||||
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. Named function + indexed loop: `forEach`
|
||||
// with an inline arrow allocated a fresh closure on every frame.
|
||||
function driftDust() {
|
||||
for (let i = 0; i < dustParticles.length; i++) {
|
||||
const p = dustParticles[i];
|
||||
const g = p.graphics;
|
||||
g.x += p.vx;
|
||||
g.y += p.vy;
|
||||
|
||||
if (g.x < -20) g.x = screenWidth + 20;
|
||||
else if (g.x > screenWidth + 20) g.x = -20;
|
||||
if (g.y < -20) g.y = screenHeight + 20;
|
||||
else if (g.y > screenHeight + 20) g.y = -20;
|
||||
}
|
||||
}
|
||||
app.ticker.add(driftDust);
|
||||
life.add(() => app.ticker.remove(driftDust));
|
||||
|
||||
nebulaCloud.x = baseX;
|
||||
nebulaCloud.y = baseY;
|
||||
nebulaContainer.addChild(nebulaCloud);
|
||||
// ========== VIGNETTE OVERLAY ==========
|
||||
vignette = new Graphics();
|
||||
const vignetteSize = Math.max(screenWidth, screenHeight) * 1.2;
|
||||
|
||||
// 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
|
||||
});
|
||||
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);
|
||||
|
||||
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
|
||||
});
|
||||
// The gradient (16 fills) and vignette (13 fills) are near-full-screen and
|
||||
// never change — bake them to a texture instead of recompositing every frame.
|
||||
deepBackgroundContainer.cacheAsTexture(true);
|
||||
vignette.cacheAsTexture(true);
|
||||
|
||||
// Nothing in the background is clickable; skip it during hit-testing.
|
||||
deepBackgroundContainer.interactiveChildren = false;
|
||||
nebulaContainer.interactiveChildren = false;
|
||||
starContainer.interactiveChildren = false;
|
||||
vignette.eventMode = 'none';
|
||||
});
|
||||
|
||||
// ========== 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. Named function + indexed loop: `forEach`
|
||||
// with an inline arrow allocated a fresh closure on every frame.
|
||||
function driftDust() {
|
||||
for (let i = 0; i < dustParticles.length; i++) {
|
||||
const p = dustParticles[i];
|
||||
const g = p.graphics;
|
||||
g.x += p.vx;
|
||||
g.y += p.vy;
|
||||
|
||||
if (g.x < -20) g.x = screenWidth + 20;
|
||||
else if (g.x > screenWidth + 20) g.x = -20;
|
||||
if (g.y < -20) g.y = screenHeight + 20;
|
||||
else if (g.y > screenHeight + 20) g.y = -20;
|
||||
}
|
||||
}
|
||||
app.ticker.add(driftDust);
|
||||
|
||||
// ========== 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);
|
||||
|
||||
// The gradient (16 fills) and vignette (13 fills) are near-full-screen and
|
||||
// never change — bake them to a texture instead of recompositing every frame.
|
||||
deepBackgroundContainer.cacheAsTexture(true);
|
||||
vignette.cacheAsTexture(true);
|
||||
|
||||
// Nothing in the background is clickable; skip it during hit-testing.
|
||||
deepBackgroundContainer.interactiveChildren = false;
|
||||
nebulaContainer.interactiveChildren = false;
|
||||
starContainer.interactiveChildren = false;
|
||||
vignette.eventMode = 'none';
|
||||
|
||||
return { deepBackgroundContainer, nebulaContainer, starContainer };
|
||||
return { deepBackgroundContainer, nebulaContainer, starContainer, vignette };
|
||||
}
|
||||
+97
-47
@@ -6,6 +6,7 @@
|
||||
import { gsap } from 'gsap';
|
||||
import { ZOOM, PARALLAX, PAN_SPEED } from '$lib/config';
|
||||
import type { SkillTreeController } from './controller';
|
||||
import type { Lifecycle } from './lifecycle';
|
||||
|
||||
/** Offset the background layers behind the tree to create parallax depth. */
|
||||
export function updateParallax(ctrl: SkillTreeController) {
|
||||
@@ -25,16 +26,31 @@ export function updateParallax(ctrl: SkillTreeController) {
|
||||
|
||||
interface PanZoomOpts {
|
||||
onTooltipMove: (x: number, y: number) => void;
|
||||
/** Tooltip coords are only worth computing while a node is actually hovered. */
|
||||
hasHover: () => boolean;
|
||||
}
|
||||
|
||||
/** Wire mouse/touch/wheel pan + zoom. Returns a cleanup for window listeners. */
|
||||
export function attachPanZoom(ctrl: SkillTreeController, opts: PanZoomOpts): () => void {
|
||||
export interface PanZoomHandle {
|
||||
/**
|
||||
* Push the current pointer position to the tooltip immediately. Needed on
|
||||
* hover-start: the move handler below only writes state while a node is
|
||||
* hovered, so without this the first hover would render at stale coords.
|
||||
*/
|
||||
flushTooltip: () => void;
|
||||
}
|
||||
|
||||
/** Wire mouse/touch/wheel pan + zoom. Every listener is owned by `life`. */
|
||||
export function attachPanZoom(
|
||||
ctrl: SkillTreeController,
|
||||
opts: PanZoomOpts,
|
||||
life: Lifecycle
|
||||
): PanZoomHandle {
|
||||
const { app, treeContainer } = ctrl;
|
||||
const minZoom = ZOOM.min;
|
||||
const maxZoom = ZOOM.max;
|
||||
|
||||
// ===== MOUSE PANNING =====
|
||||
app.canvas.addEventListener('mousedown', (e: MouseEvent) => {
|
||||
life.listen(app.canvas, 'mousedown', (e: MouseEvent) => {
|
||||
ctrl.isDragging = true;
|
||||
ctrl.hasMoved = false;
|
||||
ctrl.dragStartX = e.clientX;
|
||||
@@ -55,23 +71,47 @@ export function attachPanZoom(ctrl: SkillTreeController, opts: PanZoomOpts): ()
|
||||
treeContainer.y = ctrl.containerStartY + dy;
|
||||
updateParallax(ctrl);
|
||||
};
|
||||
window.addEventListener('mousemove', onWindowMouseMove);
|
||||
life.listen(window, 'mousemove', onWindowMouseMove);
|
||||
|
||||
const onWindowMouseUp = () => {
|
||||
ctrl.isDragging = false;
|
||||
app.canvas.style.cursor = 'grab';
|
||||
};
|
||||
window.addEventListener('mouseup', onWindowMouseUp);
|
||||
life.listen(window, 'mouseup', onWindowMouseUp);
|
||||
|
||||
app.canvas.style.cursor = 'grab';
|
||||
|
||||
// Track mouse position for tooltip
|
||||
app.canvas.addEventListener('mousemove', (e: MouseEvent) => {
|
||||
opts.onTooltipMove(e.clientX, e.clientY);
|
||||
// Track mouse position for the tooltip.
|
||||
//
|
||||
// Three things matter here. It is gated on `hasHover()` so moving the mouse
|
||||
// across empty canvas costs nothing (this previously wrote Svelte state on
|
||||
// every single mousemove, hovered or not). It coalesces to one write per
|
||||
// frame instead of one per pointer event. And it listens on `window`, not the
|
||||
// canvas, so coordinates keep updating when a drag leaves the canvas bounds.
|
||||
let tooltipFrame = -1;
|
||||
let tooltipPointerX = 0;
|
||||
let tooltipPointerY = 0;
|
||||
|
||||
const flushTooltip = () => {
|
||||
tooltipFrame = -1;
|
||||
opts.onTooltipMove(tooltipPointerX, tooltipPointerY);
|
||||
};
|
||||
|
||||
life.listen(window, 'mousemove', (e: MouseEvent) => {
|
||||
// Raw coords are always tracked — two plain number writes, negligible.
|
||||
// What was expensive, and is now gated, is writing Svelte state.
|
||||
tooltipPointerX = e.clientX;
|
||||
tooltipPointerY = e.clientY;
|
||||
if (!opts.hasHover()) return;
|
||||
if (tooltipFrame === -1) tooltipFrame = requestAnimationFrame(flushTooltip);
|
||||
});
|
||||
life.add(() => {
|
||||
if (tooltipFrame !== -1) cancelAnimationFrame(tooltipFrame);
|
||||
});
|
||||
|
||||
// ===== TOUCH PANNING & PINCH ZOOM =====
|
||||
app.canvas.addEventListener(
|
||||
life.listen(
|
||||
app.canvas,
|
||||
'touchstart',
|
||||
(e: TouchEvent) => {
|
||||
if (e.touches.length === 1) {
|
||||
@@ -94,7 +134,8 @@ export function attachPanZoom(ctrl: SkillTreeController, opts: PanZoomOpts): ()
|
||||
{ passive: true }
|
||||
);
|
||||
|
||||
app.canvas.addEventListener(
|
||||
life.listen(
|
||||
app.canvas,
|
||||
'touchmove',
|
||||
(e: TouchEvent) => {
|
||||
if (e.touches.length === 1 && ctrl.isDragging && !ctrl.isTouchZooming) {
|
||||
@@ -129,7 +170,8 @@ export function attachPanZoom(ctrl: SkillTreeController, opts: PanZoomOpts): ()
|
||||
{ passive: true }
|
||||
);
|
||||
|
||||
app.canvas.addEventListener(
|
||||
life.listen(
|
||||
app.canvas,
|
||||
'touchend',
|
||||
(e: TouchEvent) => {
|
||||
if (e.touches.length === 0) {
|
||||
@@ -150,22 +192,26 @@ export function attachPanZoom(ctrl: SkillTreeController, opts: PanZoomOpts): ()
|
||||
);
|
||||
|
||||
// ===== 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));
|
||||
// Explicitly non-passive: this calls preventDefault(). It works by default on
|
||||
// a non-root element, but relying on that is an implicit dependency.
|
||||
life.listen(
|
||||
app.canvas,
|
||||
'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);
|
||||
});
|
||||
// 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);
|
||||
},
|
||||
{ passive: false }
|
||||
);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onWindowMouseMove);
|
||||
window.removeEventListener('mouseup', onWindowMouseUp);
|
||||
};
|
||||
return { flushTooltip };
|
||||
}
|
||||
|
||||
interface KeyboardOpts {
|
||||
@@ -175,8 +221,8 @@ interface KeyboardOpts {
|
||||
isStatsVisible: () => boolean;
|
||||
}
|
||||
|
||||
/** Wire keyboard navigation (pan/zoom/reset/stats/escape). Returns cleanup. */
|
||||
export function attachKeyboard(ctrl: SkillTreeController, opts: KeyboardOpts): () => void {
|
||||
/** Wire keyboard navigation (pan/zoom/reset/stats/escape). Owned by `life`. */
|
||||
export function attachKeyboard(ctrl: SkillTreeController, opts: KeyboardOpts, life: Lifecycle) {
|
||||
const { app, treeContainer } = ctrl;
|
||||
const minZoom = ZOOM.min;
|
||||
const maxZoom = ZOOM.max;
|
||||
@@ -241,28 +287,32 @@ export function attachKeyboard(ctrl: SkillTreeController, opts: KeyboardOpts): (
|
||||
// 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)
|
||||
// Re-enter the GSAP context: these are created per keypress.
|
||||
life.animate(() => {
|
||||
gsap.to(treeContainer, {
|
||||
x: app.screen.width / 2,
|
||||
y: app.screen.height / 2,
|
||||
duration: 0.5,
|
||||
ease: 'power2.out',
|
||||
overwrite: 'auto',
|
||||
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',
|
||||
overwrite: 'auto',
|
||||
onUpdate: () => updateParallax(ctrl)
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeydown);
|
||||
return () => window.removeEventListener('keydown', handleKeydown);
|
||||
life.listen(window, 'keydown', handleKeydown);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// Single owner for every resource the tree allocates, so one dispose() call
|
||||
// releases all of it.
|
||||
//
|
||||
// Why this exists: the PixiJS layer creates ~480 infinite GSAP tweens, a ticker
|
||||
// callback, and a dozen DOM listeners. None of them were ever released, and 112
|
||||
// of the tweens are created inside an `onComplete` — so they don't exist yet at
|
||||
// the moment a naive `killTweensOf` sweep would run.
|
||||
//
|
||||
// `gsap.context()` solves exactly that: GSAP records each animation's context on
|
||||
// creation, and restores that context while invoking the animation's callbacks
|
||||
// (see `_callback` in gsap-core). So a tween created inside a tracked tween's
|
||||
// onComplete is tracked by the same context, and ctx.kill() gets all of them.
|
||||
import { gsap } from 'gsap';
|
||||
|
||||
export function createLifecycle() {
|
||||
const disposers: Array<() => void> = [];
|
||||
const gsapCtx = gsap.context(() => {});
|
||||
let isDisposed = false;
|
||||
|
||||
return {
|
||||
/** True once dispose() has run — guards async work that resolves late. */
|
||||
get disposed() {
|
||||
return isDisposed;
|
||||
},
|
||||
|
||||
/**
|
||||
* Run `fn` with GSAP's active context set, so every tween it creates is
|
||||
* tracked for teardown. Returns whatever `fn` returns.
|
||||
*/
|
||||
animate<T>(fn: () => T): T {
|
||||
return gsapCtx.add(fn) as T;
|
||||
},
|
||||
|
||||
/** Register an arbitrary teardown step. */
|
||||
add(fn: () => void) {
|
||||
disposers.push(fn);
|
||||
},
|
||||
|
||||
/** Add a DOM listener and register its removal in one call. */
|
||||
listen<T extends EventTarget>(
|
||||
target: T,
|
||||
type: string,
|
||||
handler: (event: never) => void,
|
||||
options?: AddEventListenerOptions
|
||||
) {
|
||||
target.addEventListener(type, handler as EventListener, options);
|
||||
disposers.push(() => target.removeEventListener(type, handler as EventListener, options));
|
||||
},
|
||||
|
||||
/**
|
||||
* Release everything, newest first. `kill()` rather than `revert()`: we do
|
||||
* not want GSAP writing properties back onto Pixi objects that are about
|
||||
* to be destroyed.
|
||||
*/
|
||||
dispose() {
|
||||
if (isDisposed) return;
|
||||
isDisposed = true;
|
||||
|
||||
gsapCtx.kill();
|
||||
|
||||
for (let i = disposers.length - 1; i >= 0; i--) {
|
||||
try {
|
||||
disposers[i]();
|
||||
} catch (err) {
|
||||
// One failing teardown step must not strand the rest.
|
||||
console.warn('Teardown step failed:', err);
|
||||
}
|
||||
}
|
||||
disposers.length = 0;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export type Lifecycle = ReturnType<typeof createLifecycle>;
|
||||
+16
-5
@@ -8,11 +8,13 @@ import { categoryColors } from '$lib/theme';
|
||||
import { getNodeRadius } from '$lib/utils/tree';
|
||||
import type { NodeObject } from './types';
|
||||
import type { SkillTreeController } from './controller';
|
||||
import type { Lifecycle } from './lifecycle';
|
||||
|
||||
interface NodeOpts {
|
||||
isMobile: boolean;
|
||||
isTouchDevice: boolean;
|
||||
controller: SkillTreeController;
|
||||
life: Lifecycle;
|
||||
onOpen: (node: TreeNode) => void;
|
||||
onHover: (node: TreeNode | null) => void;
|
||||
}
|
||||
@@ -34,15 +36,22 @@ function attachNodeInteraction(
|
||||
}
|
||||
});
|
||||
|
||||
// Hover tweens are created per-event, long after setup, so each creation has
|
||||
// to re-enter the GSAP context to stay killable. `overwrite: 'auto'` stops
|
||||
// rapid in/out sweeps from stacking conflicting tweens on the same property.
|
||||
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 });
|
||||
opts.life.animate(() => {
|
||||
gsap.to(target.scale, { x: 1.2, y: 1.2, duration: 0.2, overwrite: 'auto' });
|
||||
gsap.to(glowGraphics, { alpha: 1.5, duration: 0.2, overwrite: 'auto' });
|
||||
});
|
||||
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 });
|
||||
opts.life.animate(() => {
|
||||
gsap.to(target.scale, { x: 1, y: 1, duration: 0.2, overwrite: 'auto' });
|
||||
gsap.to(glowGraphics, { alpha: 1, duration: 0.2, overwrite: 'auto' });
|
||||
});
|
||||
if (!opts.isTouchDevice) opts.onHover(null);
|
||||
});
|
||||
}
|
||||
@@ -59,7 +68,9 @@ export async function createNodes(treeContainer: Container, opts: NodeOpts): Pro
|
||||
const [profileTexture, iconTextures] = await Promise.all([
|
||||
Assets.load<Texture>('/headshot.jpg'),
|
||||
// Array form resolves to a url -> Texture map and fetches concurrently.
|
||||
iconUrls.length ? Assets.load<Texture>(iconUrls) : Promise.resolve({} as Record<string, Texture>)
|
||||
iconUrls.length
|
||||
? Assets.load<Texture>(iconUrls)
|
||||
: Promise.resolve({} as Record<string, Texture>)
|
||||
]);
|
||||
|
||||
for (const node of treeNodes) {
|
||||
|
||||
@@ -19,9 +19,11 @@ export interface NodeObject {
|
||||
glowGraphics: Graphics;
|
||||
}
|
||||
|
||||
/** The three parallax background layers (vignette lives on the stage). */
|
||||
/** The parallax background layers plus the vignette overlay. */
|
||||
export interface BackgroundLayers {
|
||||
deepBackgroundContainer: Container;
|
||||
nebulaContainer: Container;
|
||||
starContainer: Container;
|
||||
/** Static overlay on the stage; returned so a resize rebuild can destroy it. */
|
||||
vignette: Graphics;
|
||||
}
|
||||
Reference in new issue
Block a user