From 78f1bc2ef61a8360641380de66b06d3763e9e717 Mon Sep 17 00:00:00 2001 From: Timothy Pidashev Date: Tue, 31 Mar 2026 11:12:24 -0700 Subject: [PATCH] Add shuffle, pipes engines; lots of polish --- .../components/animation-switcher/index.tsx | 10 +- .../background/engines/asciiquarium.ts | 574 ++++++++++++++++++ .../components/background/engines/confetti.ts | 71 ++- .../background/engines/game-of-life.ts | 73 ++- .../background/engines/lava-lamp.ts | 34 +- .../components/background/engines/pipes.ts | 480 +++++++++++++++ .../components/background/engines/shuffle.ts | 207 +++++++ src/src/components/background/index.tsx | 31 +- src/src/components/theme-switcher/index.tsx | 10 +- src/src/lib/animations/index.ts | 7 +- src/src/lib/animations/types.ts | 4 + src/src/lib/themes/engine.ts | 2 + src/src/style/globals.css | 31 + 13 files changed, 1472 insertions(+), 62 deletions(-) create mode 100644 src/src/components/background/engines/asciiquarium.ts create mode 100644 src/src/components/background/engines/pipes.ts create mode 100644 src/src/components/background/engines/shuffle.ts diff --git a/src/src/components/animation-switcher/index.tsx b/src/src/components/animation-switcher/index.tsx index 015656d..3d14d25 100644 --- a/src/src/components/animation-switcher/index.tsx +++ b/src/src/components/animation-switcher/index.tsx @@ -8,17 +8,17 @@ import { ANIMATION_LABELS } from "@/lib/animations"; export default function AnimationSwitcher() { const [hovering, setHovering] = useState(false); - const [nextLabel, setNextLabel] = useState(""); + const [currentLabel, setCurrentLabel] = useState(""); const committedRef = useRef(""); useEffect(() => { committedRef.current = getStoredAnimationId(); - setNextLabel(ANIMATION_LABELS[getNextAnimation(committedRef.current)]); + setCurrentLabel(ANIMATION_LABELS[committedRef.current]); const handleSwap = () => { const id = getStoredAnimationId(); committedRef.current = id; - setNextLabel(ANIMATION_LABELS[getNextAnimation(id)]); + setCurrentLabel(ANIMATION_LABELS[id]); }; document.addEventListener("astro:after-swap", handleSwap); @@ -33,7 +33,7 @@ export default function AnimationSwitcher() { ); saveAnimation(nextId); committedRef.current = nextId; - setNextLabel(ANIMATION_LABELS[getNextAnimation(nextId)]); + setCurrentLabel(ANIMATION_LABELS[nextId]); document.dispatchEvent( new CustomEvent("animation-changed", { detail: { id: nextId } }) ); @@ -51,7 +51,7 @@ export default function AnimationSwitcher() { className="text-foreground font-bold text-sm select-none transition-opacity duration-200" style={{ opacity: hovering ? 0.8 : 0.15 }} > - {nextLabel} + {currentLabel} ); diff --git a/src/src/components/background/engines/asciiquarium.ts b/src/src/components/background/engines/asciiquarium.ts new file mode 100644 index 0000000..0f57301 --- /dev/null +++ b/src/src/components/background/engines/asciiquarium.ts @@ -0,0 +1,574 @@ +import type { AnimationEngine } from "@/lib/animations/types"; + +// --- ASCII Art --- + +interface AsciiPattern { + lines: string[]; + width: number; + height: number; +} + +function pat(lines: string[]): AsciiPattern { + return { + lines, + width: Math.max(...lines.map((l) => l.length)), + height: lines.length, + }; +} + +const FISH_DEFS: { + size: "small" | "medium"; + weight: number; + right: AsciiPattern; + left: AsciiPattern; +}[] = [ + { size: "small", weight: 30, right: pat(["><>"]), left: pat(["<><"]) }, + { + size: "small", + weight: 30, + right: pat(["><(('>"]), + left: pat(["<'))><"]), + }, + { + size: "medium", + weight: 20, + right: pat(["><((o>"]), + left: pat(["<"]), + }, + { + size: "medium", + weight: 10, + right: pat(["><((('>"]), + left: pat(["<')))><"]), + }, +]; + +const TOTAL_FISH_WEIGHT = FISH_DEFS.reduce((s, d) => s + d.weight, 0); + +const BUBBLE_CHARS = [".", "o", "O"]; + +// --- Entity Interfaces --- + +interface FishEntity { + kind: "fish"; + x: number; + y: number; + vx: number; + pattern: AsciiPattern; + size: "small" | "medium"; + color: [number, number, number]; + baseColor: [number, number, number]; + opacity: number; + elevation: number; + targetElevation: number; + staggerDelay: number; +} + +interface BubbleEntity { + kind: "bubble"; + x: number; + y: number; + vy: number; + wobblePhase: number; + wobbleAmplitude: number; + char: string; + color: [number, number, number]; + baseColor: [number, number, number]; + opacity: number; + elevation: number; + targetElevation: number; + staggerDelay: number; + burst: boolean; +} + +type AquariumEntity = FishEntity | BubbleEntity; + +// --- Constants --- + +const BASE_AREA = 1920 * 1080; +const BASE_FISH = 16; +const BASE_BUBBLES = 12; + +const TARGET_FPS = 60; +const FONT_SIZE_MIN = 24; +const FONT_SIZE_MAX = 36; +const FONT_SIZE_REF_WIDTH = 1920; +const LINE_HEIGHT_RATIO = 1.15; +const STAGGER_INTERVAL = 15; +const PI_2 = Math.PI * 2; + +const MOUSE_INFLUENCE_RADIUS = 150; +const ELEVATION_FACTOR = 6; +const ELEVATION_LERP_SPEED = 0.05; +const COLOR_SHIFT_AMOUNT = 30; +const SHADOW_OFFSET_RATIO = 1.1; + +const FISH_SPEED: Record = { + small: { min: 0.8, max: 1.4 }, + medium: { min: 0.5, max: 0.9 }, +}; + +const BUBBLE_SPEED_MIN = 0.3; +const BUBBLE_SPEED_MAX = 0.7; +const BUBBLE_WOBBLE_MIN = 0.3; +const BUBBLE_WOBBLE_MAX = 1.0; + +const BURST_BUBBLE_COUNT = 10; + +// --- Helpers --- + +function range(a: number, b: number): number { + return (b - a) * Math.random() + a; +} + +function pickFishDef() { + let r = Math.random() * TOTAL_FISH_WEIGHT; + for (const def of FISH_DEFS) { + r -= def.weight; + if (r <= 0) return def; + } + return FISH_DEFS[0]; +} + +// --- Engine --- + +export class AsciiquariumEngine implements AnimationEngine { + id = "asciiquarium"; + name = "Asciiquarium"; + + private fish: FishEntity[] = []; + private bubbles: BubbleEntity[] = []; + private exiting = false; + private palette: [number, number, number][] = []; + private width = 0; + private height = 0; + private mouseX = -1000; + private mouseY = -1000; + private elapsed = 0; + private charWidth = 0; + private fontSize = FONT_SIZE_MAX; + private lineHeight = FONT_SIZE_MAX * LINE_HEIGHT_RATIO; + private font = `bold ${FONT_SIZE_MAX}px monospace`; + + private computeFont(width: number): void { + const t = Math.sqrt(Math.min(1, width / FONT_SIZE_REF_WIDTH)); + this.fontSize = Math.round(FONT_SIZE_MIN + (FONT_SIZE_MAX - FONT_SIZE_MIN) * t); + this.lineHeight = Math.round(this.fontSize * LINE_HEIGHT_RATIO); + this.font = `bold ${this.fontSize}px monospace`; + this.charWidth = 0; + } + + init( + width: number, + height: number, + palette: [number, number, number][], + _bgColor: string + ): void { + this.width = width; + this.height = height; + this.palette = palette; + this.elapsed = 0; + this.computeFont(width); + this.initEntities(); + } + + beginExit(): void { + if (this.exiting) return; + this.exiting = true; + + // Stagger fade-out over 3 seconds + for (const f of this.fish) { + const delay = Math.random() * 3000; + setTimeout(() => { + f.staggerDelay = -2; // signal: fading out + }, delay); + } + for (const b of this.bubbles) { + const delay = Math.random() * 3000; + setTimeout(() => { + b.staggerDelay = -2; + }, delay); + } + } + + isExitComplete(): boolean { + if (!this.exiting) return false; + for (const f of this.fish) { + if (f.opacity > 0.01) return false; + } + for (const b of this.bubbles) { + if (b.opacity > 0.01) return false; + } + return true; + } + + cleanup(): void { + this.fish = []; + this.bubbles = []; + } + + private randomColor(): [number, number, number] { + return this.palette[Math.floor(Math.random() * this.palette.length)]; + } + + private getCounts(): { fish: number; bubbles: number } { + const ratio = (this.width * this.height) / BASE_AREA; + return { + fish: Math.max(5, Math.round(BASE_FISH * ratio)), + bubbles: Math.max(5, Math.round(BASE_BUBBLES * ratio)), + }; + } + + private initEntities(): void { + this.fish = []; + this.bubbles = []; + + const counts = this.getCounts(); + let idx = 0; + + for (let i = 0; i < counts.fish; i++) { + this.fish.push(this.spawnFish(idx++)); + } + + for (let i = 0; i < counts.bubbles; i++) { + this.bubbles.push(this.spawnBubble(idx++, false)); + } + } + + private spawnFish(staggerIdx: number): FishEntity { + const def = pickFishDef(); + const goRight = Math.random() > 0.5; + const speed = range(FISH_SPEED[def.size].min, FISH_SPEED[def.size].max); + const pattern = goRight ? def.right : def.left; + const baseColor = this.randomColor(); + const cw = this.charWidth || 9.6; + const pw = pattern.width * cw; + + // Start off-screen on the side they swim from + const startX = goRight + ? -pw - range(0, this.width * 0.5) + : this.width + range(0, this.width * 0.5); + + return { + kind: "fish", + x: startX, + y: range(this.height * 0.05, this.height * 0.9), + vx: goRight ? speed : -speed, + pattern, + size: def.size, + color: [...baseColor], + baseColor, + opacity: 1, + elevation: 0, + targetElevation: 0, + staggerDelay: -1, + }; + } + + private spawnBubble(staggerIdx: number, burst: boolean): BubbleEntity { + const baseColor = this.randomColor(); + return { + kind: "bubble", + x: range(0, this.width), + y: burst ? 0 : this.height + range(10, this.height * 0.5), + vy: -range(BUBBLE_SPEED_MIN, BUBBLE_SPEED_MAX), + wobblePhase: range(0, PI_2), + wobbleAmplitude: range(BUBBLE_WOBBLE_MIN, BUBBLE_WOBBLE_MAX), + char: BUBBLE_CHARS[Math.floor(Math.random() * BUBBLE_CHARS.length)], + color: [...baseColor], + baseColor, + opacity: 1, + elevation: 0, + targetElevation: 0, + staggerDelay: -1, + burst, + }; + } + + // --- Update --- + + update(deltaTime: number): void { + const dt = deltaTime / (1000 / TARGET_FPS); + this.elapsed += deltaTime; + + const mouseX = this.mouseX; + const mouseY = this.mouseY; + const cw = this.charWidth || 9.6; + + // Fish + for (let i = this.fish.length - 1; i >= 0; i--) { + const f = this.fish[i]; + if (f.staggerDelay >= 0) { + if (this.elapsed >= f.staggerDelay) f.staggerDelay = -1; + else continue; + } + + // Fade out during exit + if (f.staggerDelay === -2) { + f.opacity -= 0.02 * dt; + if (f.opacity <= 0) { f.opacity = 0; continue; } + } else if (f.opacity < 1) { + f.opacity = Math.min(1, f.opacity + 0.03 * dt); + } + + f.x += f.vx * dt; + + const pw = f.pattern.width * cw; + if (f.vx > 0 && f.x > this.width + pw) { + f.x = -pw; + } else if (f.vx < 0 && f.x < -pw) { + f.x = this.width + pw; + } + + const cx = f.x + (f.pattern.width * cw) / 2; + const cy = f.y + (f.pattern.height * this.lineHeight) / 2; + this.applyMouseInfluence(f, cx, cy, mouseX, mouseY, dt); + } + + // Bubbles (reverse iteration for safe splice) + for (let i = this.bubbles.length - 1; i >= 0; i--) { + const b = this.bubbles[i]; + + if (b.staggerDelay >= 0) { + if (this.elapsed >= b.staggerDelay) b.staggerDelay = -1; + else continue; + } + + // Fade out during exit + if (b.staggerDelay === -2) { + b.opacity -= 0.02 * dt; + if (b.opacity <= 0) { b.opacity = 0; continue; } + } else if (b.opacity < 1) { + b.opacity = Math.min(1, b.opacity + 0.03 * dt); + } + + b.y += b.vy * dt; + b.x += + Math.sin(this.elapsed * 0.003 + b.wobblePhase) * + b.wobbleAmplitude * + dt; + + if (b.y < -20) { + if (b.burst) { + this.bubbles.splice(i, 1); + continue; + } else { + b.y = this.height + range(10, 40); + b.x = range(0, this.width); + } + } + + this.applyMouseInfluence(b, b.x, b.y, mouseX, mouseY, dt); + } + } + + private applyMouseInfluence( + entity: AquariumEntity, + cx: number, + cy: number, + mouseX: number, + mouseY: number, + dt: number + ): void { + const dx = cx - mouseX; + const dy = cy - mouseY; + const dist = Math.sqrt(dx * dx + dy * dy); + + if (dist < MOUSE_INFLUENCE_RADIUS && entity.opacity > 0.1) { + const inf = Math.cos((dist / MOUSE_INFLUENCE_RADIUS) * (Math.PI / 2)); + entity.targetElevation = ELEVATION_FACTOR * inf * inf; + + const shift = inf * COLOR_SHIFT_AMOUNT * 0.5; + entity.color = [ + Math.min(255, Math.max(0, entity.baseColor[0] + shift)), + Math.min(255, Math.max(0, entity.baseColor[1] + shift)), + Math.min(255, Math.max(0, entity.baseColor[2] + shift)), + ]; + } else { + entity.targetElevation = 0; + entity.color[0] += (entity.baseColor[0] - entity.color[0]) * 0.1; + entity.color[1] += (entity.baseColor[1] - entity.color[1]) * 0.1; + entity.color[2] += (entity.baseColor[2] - entity.color[2]) * 0.1; + } + + entity.elevation += + (entity.targetElevation - entity.elevation) * ELEVATION_LERP_SPEED * dt; + } + + // --- Render --- + + render( + ctx: CanvasRenderingContext2D, + _width: number, + _height: number + ): void { + if (!this.charWidth) { + ctx.font = this.font; + this.charWidth = ctx.measureText("M").width; + } + + ctx.font = this.font; + ctx.textBaseline = "top"; + + // Fish + for (const f of this.fish) { + if (f.opacity <= 0.01 || f.staggerDelay >= 0) continue; + this.renderPattern( + ctx, + f.pattern, + f.x, + f.y, + f.color, + f.opacity, + f.elevation + ); + } + + // Bubbles + for (const b of this.bubbles) { + if (b.opacity <= 0.01 || b.staggerDelay >= 0) continue; + this.renderChar(ctx, b.char, b.x, b.y, b.color, b.opacity, b.elevation); + } + + ctx.globalAlpha = 1; + } + + private renderPattern( + ctx: CanvasRenderingContext2D, + pattern: AsciiPattern, + x: number, + y: number, + color: [number, number, number], + opacity: number, + elevation: number + ): void { + const drawY = y - elevation; + const [r, g, b] = color; + + // Shadow + if (elevation > 0.5) { + const shadowAlpha = 0.2 * (elevation / ELEVATION_FACTOR) * opacity; + ctx.globalAlpha = shadowAlpha; + ctx.fillStyle = "rgb(0,0,0)"; + for (let line = 0; line < pattern.height; line++) { + ctx.fillText( + pattern.lines[line], + x, + drawY + line * this.lineHeight + elevation * SHADOW_OFFSET_RATIO + ); + } + } + + // Main text + ctx.globalAlpha = opacity; + ctx.fillStyle = `rgb(${r},${g},${b})`; + for (let line = 0; line < pattern.height; line++) { + ctx.fillText(pattern.lines[line], x, drawY + line * this.lineHeight); + } + + // Highlight (top half of lines) + if (elevation > 0.5) { + const highlightAlpha = 0.1 * (elevation / ELEVATION_FACTOR) * opacity; + ctx.globalAlpha = highlightAlpha; + ctx.fillStyle = "rgb(255,255,255)"; + const topLines = Math.ceil(pattern.height / 2); + for (let line = 0; line < topLines; line++) { + ctx.fillText(pattern.lines[line], x, drawY + line * this.lineHeight); + } + } + } + + private renderChar( + ctx: CanvasRenderingContext2D, + char: string, + x: number, + y: number, + color: [number, number, number], + opacity: number, + elevation: number + ): void { + const drawY = y - elevation; + const [r, g, b] = color; + + // Shadow + if (elevation > 0.5) { + const shadowAlpha = 0.2 * (elevation / ELEVATION_FACTOR) * opacity; + ctx.globalAlpha = shadowAlpha; + ctx.fillStyle = "rgb(0,0,0)"; + ctx.fillText(char, x, drawY + elevation * SHADOW_OFFSET_RATIO); + } + + // Main + ctx.globalAlpha = opacity; + ctx.fillStyle = `rgb(${r},${g},${b})`; + ctx.fillText(char, x, drawY); + + // Highlight + if (elevation > 0.5) { + const highlightAlpha = 0.1 * (elevation / ELEVATION_FACTOR) * opacity; + ctx.globalAlpha = highlightAlpha; + ctx.fillStyle = "rgb(255,255,255)"; + ctx.fillText(char, x, drawY); + } + } + + // --- Events --- + + handleResize(width: number, height: number): void { + this.width = width; + this.height = height; + this.elapsed = 0; + this.exiting = false; + this.computeFont(width); + this.initEntities(); + } + + handleMouseMove(x: number, y: number, _isDown: boolean): void { + this.mouseX = x; + this.mouseY = y; + } + + handleMouseDown(x: number, y: number): void { + for (let i = 0; i < BURST_BUBBLE_COUNT; i++) { + const baseColor = this.randomColor(); + const angle = (i / BURST_BUBBLE_COUNT) * PI_2 + range(-0.3, 0.3); + const speed = range(0.3, 1.0); + this.bubbles.push({ + kind: "bubble", + x, + y, + vy: -Math.abs(Math.sin(angle) * speed) - 0.3, + wobblePhase: range(0, PI_2), + wobbleAmplitude: Math.cos(angle) * speed * 0.5, + char: BUBBLE_CHARS[Math.floor(Math.random() * BUBBLE_CHARS.length)], + color: [...baseColor], + baseColor, + opacity: 1, + elevation: 0, + targetElevation: 0, + staggerDelay: this.exiting ? -2 : -1, + burst: true, + }); + } + } + + handleMouseUp(): void {} + + handleMouseLeave(): void { + this.mouseX = -1000; + this.mouseY = -1000; + } + + updatePalette( + palette: [number, number, number][], + _bgColor: string + ): void { + this.palette = palette; + for (let i = 0; i < this.fish.length; i++) { + this.fish[i].baseColor = palette[i % palette.length]; + } + for (let i = 0; i < this.bubbles.length; i++) { + this.bubbles[i].baseColor = palette[i % palette.length]; + } + } +} diff --git a/src/src/components/background/engines/confetti.ts b/src/src/components/background/engines/confetti.ts index 78ef085..ed09e91 100644 --- a/src/src/components/background/engines/confetti.ts +++ b/src/src/components/background/engines/confetti.ts @@ -16,7 +16,7 @@ interface ConfettiParticle { burst: boolean; } -const BASE_CONFETTI = 350; +const BASE_CONFETTI = 385; const BASE_AREA = 1920 * 1080; const PI_2 = 2 * Math.PI; const TARGET_FPS = 60; @@ -45,6 +45,7 @@ export class ConfettiEngine implements AnimationEngine { private mouseY = -1000; private mouseXNorm = 0.5; private elapsed = 0; + private exiting = false; init( width: number, @@ -60,6 +61,30 @@ export class ConfettiEngine implements AnimationEngine { this.initParticles(); } + beginExit(): void { + if (this.exiting) return; + this.exiting = true; + + // Stagger fade-out over 3 seconds + for (let i = 0; i < this.particles.length; i++) { + const p = this.particles[i]; + p.staggerDelay = -1; // ensure visible + // Random delay before fade starts, stored as negative dop + const delay = Math.random() * 3000; + setTimeout(() => { + p.dop = -0.02; + }, delay); + } + } + + isExitComplete(): boolean { + if (!this.exiting) return false; + for (let i = 0; i < this.particles.length; i++) { + if (this.particles[i].opacity > 0.01) return false; + } + return true; + } + cleanup(): void { this.particles = []; } @@ -140,15 +165,18 @@ export class ConfettiEngine implements AnimationEngine { p.x += p.vx * dt; p.y += p.vy * dt; - // Fade in only (no fade-out cycle) - if (p.opacity < 1) { + // Fade in, or fade out during exit + if (this.exiting && p.dop < 0) { + p.opacity += p.dop * dt; + if (p.opacity < 0) p.opacity = 0; + } else if (p.opacity < 1) { p.opacity += Math.abs(p.dop) * dt; if (p.opacity > 1) p.opacity = 1; } - // Past the bottom: burst particles get removed, base particles recycle + // Past the bottom: burst particles removed, base particles recycle (or remove during exit) if (p.y > this.height + p.r) { - if (p.burst) { + if (p.burst || this.exiting) { this.particles.splice(i, 1); i--; } else { @@ -230,7 +258,7 @@ export class ConfettiEngine implements AnimationEngine { } // Main circle - ctx.globalAlpha = p.opacity * 0.9; + ctx.globalAlpha = p.opacity; ctx.fillStyle = `rgb(${r},${g},${b})`; ctx.beginPath(); ctx.arc(drawX, drawY, p.r, 0, PI_2); @@ -254,29 +282,8 @@ export class ConfettiEngine implements AnimationEngine { handleResize(width: number, height: number): void { this.width = width; this.height = height; - const target = this.getParticleCount(); - while (this.particles.length < target) { - const baseColor = this.randomColor(); - const r = ~~range(3, 8); - this.particles.push({ - x: range(-r * 2, width - r * 2), - y: range(-20, height - r * 2), - vx: (range(0, 2) + 8 * 0.5 - 5) * SPEED_FACTOR, - vy: (0.7 * r + range(-1, 1)) * SPEED_FACTOR, - r, - color: [...baseColor], - baseColor, - opacity: 0, - dop: 0.03 * range(1, 4) * SPEED_FACTOR, - elevation: 0, - targetElevation: 0, - staggerDelay: -1, - burst: false, - }); - } - if (this.particles.length > target) { - this.particles.length = target; - } + this.elapsed = 0; + this.initParticles(); } handleMouseMove(x: number, y: number, _isDown: boolean): void { @@ -303,7 +310,7 @@ export class ConfettiEngine implements AnimationEngine { color: [...baseColor], baseColor, opacity: 1, - dop: 0, + dop: this.exiting ? -0.02 : 0, elevation: 0, targetElevation: 0, staggerDelay: -1, @@ -322,8 +329,8 @@ export class ConfettiEngine implements AnimationEngine { updatePalette(palette: [number, number, number][], _bgColor: string): void { this.palette = palette; - for (const p of this.particles) { - p.baseColor = palette[Math.floor(Math.random() * palette.length)]; + for (let i = 0; i < this.particles.length; i++) { + this.particles[i].baseColor = palette[i % palette.length]; } } } diff --git a/src/src/components/background/engines/game-of-life.ts b/src/src/components/background/engines/game-of-life.ts index a81cab2..ef514d4 100644 --- a/src/src/components/background/engines/game-of-life.ts +++ b/src/src/components/background/engines/game-of-life.ts @@ -59,6 +59,7 @@ export class GameOfLifeEngine implements AnimationEngine { private pendingTimeouts: ReturnType[] = []; private canvasWidth = 0; private canvasHeight = 0; + private exiting = false; init( width: number, @@ -278,13 +279,60 @@ export class GameOfLifeEngine implements AnimationEngine { } } + beginExit(): void { + if (this.exiting || !this.grid) return; + this.exiting = true; + + // Cancel all pending GOL transitions so they don't revive cells + for (const id of this.pendingTimeouts) { + clearTimeout(id); + } + this.pendingTimeouts = []; + + const grid = this.grid; + for (let i = 0; i < grid.cols; i++) { + for (let j = 0; j < grid.rows; j++) { + const cell = grid.cells[i][j]; + // Force cell into dying state, clear any pending transition + cell.next = false; + cell.transitioning = false; + cell.transitionComplete = false; + + if (cell.opacity > 0.01) { + const delay = Math.random() * 3000; + const tid = setTimeout(() => { + cell.targetOpacity = 0; + cell.targetScale = 0; + cell.targetElevation = 0; + }, delay); + this.pendingTimeouts.push(tid); + } + } + } + } + + isExitComplete(): boolean { + if (!this.exiting) return false; + if (!this.grid) return true; + + const grid = this.grid; + for (let i = 0; i < grid.cols; i++) { + for (let j = 0; j < grid.rows; j++) { + if (grid.cells[i][j].opacity > 0.01) return false; + } + } + return true; + } + update(deltaTime: number): void { if (!this.grid) return; - this.timeAccumulator += deltaTime; - if (this.timeAccumulator >= CYCLE_TIME) { - this.computeNextState(this.grid); - this.timeAccumulator -= CYCLE_TIME; + if (!this.exiting) { + this.timeAccumulator += deltaTime; + if (this.timeAccumulator >= CYCLE_TIME) { + this.computeNextState(this.grid); + this.timeAccumulator -= CYCLE_TIME; + } } this.updateCellAnimations(this.grid, deltaTime); @@ -335,7 +383,15 @@ export class GameOfLifeEngine implements AnimationEngine { cell.targetElevation = 0; } - if (cell.transitioning) { + // During exit: snap to zero once close enough + if (this.exiting) { + if (cell.opacity < 0.05) { + cell.opacity = 0; + cell.scale = 0; + cell.elevation = 0; + cell.alive = false; + } + } else if (cell.transitioning) { if (!cell.next && cell.opacity < 0.01 && cell.scale < 0.01) { cell.alive = false; cell.transitioning = false; @@ -532,7 +588,7 @@ export class GameOfLifeEngine implements AnimationEngine { this.mouseY = y; this.mouseIsDown = isDown; - if (isDown && this.grid) { + if (isDown && this.grid && !this.exiting) { const grid = this.grid; const cellSize = this.getCellSize(); const cellX = Math.floor((x - grid.offsetX) / cellSize); @@ -560,7 +616,7 @@ export class GameOfLifeEngine implements AnimationEngine { handleMouseDown(x: number, y: number): void { this.mouseIsDown = true; - if (!this.grid) return; + if (!this.grid || this.exiting) return; const grid = this.grid; const cellSize = this.getCellSize(); @@ -605,8 +661,7 @@ export class GameOfLifeEngine implements AnimationEngine { for (let j = 0; j < grid.rows; j++) { const cell = grid.cells[i][j]; if (cell.alive && cell.opacity > 0.01) { - cell.baseColor = - palette[Math.floor(Math.random() * palette.length)]; + cell.baseColor = palette[(i * grid.rows + j) % palette.length]; } } } diff --git a/src/src/components/background/engines/lava-lamp.ts b/src/src/components/background/engines/lava-lamp.ts index 2ced51d..a6465a2 100644 --- a/src/src/components/background/engines/lava-lamp.ts +++ b/src/src/components/background/engines/lava-lamp.ts @@ -53,6 +53,7 @@ export class LavaLampEngine implements AnimationEngine { private shadowCtx: CanvasRenderingContext2D | null = null; private elapsed = 0; private nextCycleTime = 0; + private exiting = false; // Pre-allocated typed arrays for the inner render loop (avoid per-frame GC) private blobX: Float64Array = new Float64Array(0); @@ -170,6 +171,27 @@ export class LavaLampEngine implements AnimationEngine { }); } + beginExit(): void { + if (this.exiting) return; + this.exiting = true; + + for (let i = 0; i < this.blobs.length; i++) { + const blob = this.blobs[i]; + if (blob.staggerDelay >= 0) { + blob.staggerDelay = -1; + } + // Stagger the shrink over ~2 seconds + setTimeout(() => { + blob.targetRadiusScale = 0; + }, Math.random() * 2000); + } + } + + isExitComplete(): boolean { + if (!this.exiting) return false; + return this.blobs.length === 0; + } + cleanup(): void { this.blobs = []; this.offCanvas = null; @@ -279,7 +301,7 @@ export class LavaLampEngine implements AnimationEngine { } // Natural spawn/despawn cycle — keeps the scene alive - if (this.elapsed >= this.nextCycleTime) { + if (!this.exiting && this.elapsed >= this.nextCycleTime) { // Pick a random visible blob to fade out (skip ones still staggering in) const visible = []; for (let i = 0; i < this.blobs.length; i++) { @@ -433,12 +455,11 @@ export class LavaLampEngine implements AnimationEngine { handleResize(width: number, height: number): void { this.width = width; this.height = height; + this.elapsed = 0; + this.exiting = false; + this.nextCycleTime = CYCLE_MIN_MS + Math.random() * (CYCLE_MAX_MS - CYCLE_MIN_MS); + this.initBlobs(); this.initOffscreenCanvas(); - - const { min, max } = this.getRadiusRange(); - for (const blob of this.blobs) { - blob.baseRadius = min + Math.random() * (max - min); - } } private sampleColorAt(x: number, y: number): [number, number, number] | null { @@ -475,6 +496,7 @@ export class LavaLampEngine implements AnimationEngine { } handleMouseDown(x: number, y: number): void { + if (this.exiting) return; this.spawnAt(x, y); } diff --git a/src/src/components/background/engines/pipes.ts b/src/src/components/background/engines/pipes.ts new file mode 100644 index 0000000..424352b --- /dev/null +++ b/src/src/components/background/engines/pipes.ts @@ -0,0 +1,480 @@ +import type { AnimationEngine } from "@/lib/animations/types"; + +// --- Directions --- + +type Dir = 0 | 1 | 2 | 3; // up, right, down, left +const DX = [0, 1, 0, -1]; +const DY = [-1, 0, 1, 0]; + +// Box-drawing characters +const HORIZONTAL = "\u2501"; // ━ +const VERTICAL = "\u2503"; // ┃ +// Corner pieces: [oldDir]-[newDir] +// oldDir determines entry side (opposite), newDir determines exit side +// ┏ = RIGHT+BOTTOM, ┓ = LEFT+BOTTOM, ┗ = RIGHT+TOP, ┛ = LEFT+TOP +const CORNER: Record = { + "0-1": "\u250F", // ┏ enter BOTTOM, exit RIGHT + "0-3": "\u2513", // ┓ enter BOTTOM, exit LEFT + "1-0": "\u251B", // ┛ enter LEFT, exit TOP + "1-2": "\u2513", // ┓ enter LEFT, exit BOTTOM + "2-1": "\u2517", // ┗ enter TOP, exit RIGHT + "2-3": "\u251B", // ┛ enter TOP, exit LEFT + "3-0": "\u2517", // ┗ enter RIGHT, exit TOP + "3-2": "\u250F", // ┏ enter RIGHT, exit BOTTOM +}; + +function getStraightChar(dir: Dir): string { + return dir === 0 || dir === 2 ? VERTICAL : HORIZONTAL; +} + +function getCornerChar(fromDir: Dir, toDir: Dir): string { + return CORNER[`${fromDir}-${toDir}`] || HORIZONTAL; +} + +// --- Grid Cell --- + +interface PipeCell { + char: string; + pipeId: number; + placedAt: number; + color: [number, number, number]; + baseColor: [number, number, number]; + opacity: number; + elevation: number; + targetElevation: number; + fadeOut: boolean; +} + +// --- Active Pipe --- + +interface ActivePipe { + id: number; + x: number; + y: number; + dir: Dir; + color: [number, number, number]; + spawnDelay: number; +} + +// --- Constants --- + +const CELL_SIZE_DESKTOP = 20; +const CELL_SIZE_MOBILE = 14; +const MAX_ACTIVE_PIPES = 4; +const GROW_INTERVAL = 80; +const TURN_CHANCE = 0.3; +const TARGET_FPS = 60; +const PIPE_LIFETIME = 12_000; // ms before a pipe's segments start fading +const FADE_IN_SPEED = 0.06; +const FADE_OUT_SPEED = 0.02; + +const MOUSE_INFLUENCE_RADIUS = 150; +const ELEVATION_FACTOR = 6; +const ELEVATION_LERP_SPEED = 0.05; +const COLOR_SHIFT_AMOUNT = 30; +const SHADOW_OFFSET_RATIO = 1.1; + +const BURST_PIPE_COUNT = 4; + +// --- Helpers --- + +function range(a: number, b: number): number { + return (b - a) * Math.random() + a; +} + +// --- Engine --- + +export class PipesEngine implements AnimationEngine { + id = "pipes"; + name = "Pipes"; + + private grid: (PipeCell | null)[][] = []; + private cols = 0; + private rows = 0; + private activePipes: ActivePipe[] = []; + private palette: [number, number, number][] = []; + private width = 0; + private height = 0; + private cellSize = CELL_SIZE_DESKTOP; + private fontSize = CELL_SIZE_DESKTOP; + private font = `bold ${CELL_SIZE_DESKTOP}px monospace`; + private mouseX = -1000; + private mouseY = -1000; + private elapsed = 0; + private growTimer = 0; + private exiting = false; + private nextPipeId = 0; + private offsetX = 0; + private offsetY = 0; + + init( + width: number, + height: number, + palette: [number, number, number][], + _bgColor: string + ): void { + this.width = width; + this.height = height; + this.palette = palette; + this.elapsed = 0; + this.growTimer = 0; + this.exiting = false; + this.computeGrid(); + this.spawnInitialPipes(); + } + + private computeGrid(): void { + this.cellSize = this.width <= 768 ? CELL_SIZE_MOBILE : CELL_SIZE_DESKTOP; + this.fontSize = this.cellSize; + this.font = `bold ${this.fontSize}px monospace`; + this.cols = Math.floor(this.width / this.cellSize); + this.rows = Math.floor(this.height / this.cellSize); + this.offsetX = Math.floor((this.width - this.cols * this.cellSize) / 2); + this.offsetY = Math.floor((this.height - this.rows * this.cellSize) / 2); + this.grid = Array.from({ length: this.cols }, () => + Array.from({ length: this.rows }, () => null) + ); + } + + private randomColor(): [number, number, number] { + // Prefer bright variants (second half of palette) if available + const brightStart = Math.floor(this.palette.length / 2); + if (brightStart > 0 && this.palette.length > brightStart) { + return this.palette[brightStart + Math.floor(Math.random() * (this.palette.length - brightStart))]; + } + return this.palette[Math.floor(Math.random() * this.palette.length)]; + } + + private spawnInitialPipes(): void { + this.activePipes = []; + for (let i = 0; i < MAX_ACTIVE_PIPES; i++) { + this.activePipes.push(this.makeEdgePipe(i * 400)); + } + } + + private makeEdgePipe(delay: number): ActivePipe { + const color = this.randomColor(); + // Pick a random edge and inward-facing direction + const edge = Math.floor(Math.random() * 4) as Dir; + let x: number, y: number, dir: Dir; + + switch (edge) { + case 0: // top edge, face down + x = Math.floor(Math.random() * this.cols); + y = 0; + dir = 2; + break; + case 1: // right edge, face left + x = this.cols - 1; + y = Math.floor(Math.random() * this.rows); + dir = 3; + break; + case 2: // bottom edge, face up + x = Math.floor(Math.random() * this.cols); + y = this.rows - 1; + dir = 0; + break; + default: // left edge, face right + x = 0; + y = Math.floor(Math.random() * this.rows); + dir = 1; + break; + } + + return { id: this.nextPipeId++, x, y, dir, color: [...color], spawnDelay: delay }; + } + + private placeSegment(x: number, y: number, char: string, color: [number, number, number], pipeId: number): void { + if (x < 0 || x >= this.cols || y < 0 || y >= this.rows) return; + this.grid[x][y] = { + char, + pipeId, + placedAt: this.elapsed, + color: [...color], + baseColor: [...color], + opacity: 0, + elevation: 0, + targetElevation: 0, + fadeOut: false, + }; + } + + private isOccupied(x: number, y: number): boolean { + if (x < 0 || x >= this.cols || y < 0 || y >= this.rows) return true; + return this.grid[x][y] !== null; + } + + private pickTurn(currentDir: Dir): Dir { + // Turn left or right relative to current direction + const leftDir = ((currentDir + 3) % 4) as Dir; + const rightDir = ((currentDir + 1) % 4) as Dir; + return Math.random() < 0.5 ? leftDir : rightDir; + } + + private growPipe(pipe: ActivePipe): boolean { + // Decide direction + let newDir = pipe.dir; + let turned = false; + if (Math.random() < TURN_CHANCE) { + newDir = this.pickTurn(pipe.dir); + turned = true; + } + + const nx = pipe.x + DX[newDir]; + const ny = pipe.y + DY[newDir]; + + // Check if destination is valid + if (this.isOccupied(nx, ny)) { + // If we tried to turn, try going straight instead + if (turned) { + const sx = pipe.x + DX[pipe.dir]; + const sy = pipe.y + DY[pipe.dir]; + if (!this.isOccupied(sx, sy)) { + // Continue straight — place straight piece at destination + this.placeSegment(sx, sy, getStraightChar(pipe.dir), pipe.color, pipe.id); + pipe.x = sx; + pipe.y = sy; + return true; + } + } + return false; // dead end + } + + if (turned) { + // Replace current head cell with corner piece (turn happens HERE) + const cell = this.grid[pipe.x]?.[pipe.y]; + if (cell) { + cell.char = getCornerChar(pipe.dir, newDir); + } + } + + // Place straight piece at destination + this.placeSegment(nx, ny, getStraightChar(newDir), pipe.color, pipe.id); + pipe.dir = newDir; + pipe.x = nx; + pipe.y = ny; + return true; + } + + // --- Interface Methods --- + + beginExit(): void { + if (this.exiting) return; + this.exiting = true; + + for (let i = 0; i < this.cols; i++) { + for (let j = 0; j < this.rows; j++) { + const cell = this.grid[i][j]; + if (cell) { + setTimeout(() => { + cell.fadeOut = true; + }, Math.random() * 3000); + } + } + } + } + + isExitComplete(): boolean { + if (!this.exiting) return false; + for (let i = 0; i < this.cols; i++) { + for (let j = 0; j < this.rows; j++) { + const cell = this.grid[i][j]; + if (cell && cell.opacity > 0.01) return false; + } + } + return true; + } + + cleanup(): void { + this.grid = []; + this.activePipes = []; + } + + update(deltaTime: number): void { + const dt = deltaTime / (1000 / TARGET_FPS); + this.elapsed += deltaTime; + + // Grow pipes + if (!this.exiting) { + this.growTimer += deltaTime; + while (this.growTimer >= GROW_INTERVAL) { + this.growTimer -= GROW_INTERVAL; + + for (let i = this.activePipes.length - 1; i >= 0; i--) { + const pipe = this.activePipes[i]; + + if (pipe.spawnDelay > 0) { + pipe.spawnDelay -= GROW_INTERVAL; + continue; + } + + // Place starting segment if this is the first step + if (!this.isOccupied(pipe.x, pipe.y)) { + this.placeSegment(pipe.x, pipe.y, getStraightChar(pipe.dir), pipe.color, pipe.id); + continue; + } + + if (!this.growPipe(pipe)) { + // Pipe is dead, replace it + this.activePipes[i] = this.makeEdgePipe(0); + } + } + } + } + + // Update cells: fade in/out, mouse influence + const mouseX = this.mouseX; + const mouseY = this.mouseY; + + for (let i = 0; i < this.cols; i++) { + for (let j = 0; j < this.rows; j++) { + const cell = this.grid[i][j]; + if (!cell) continue; + + // Age-based fade: old segments start dissolving + if (!cell.fadeOut && !this.exiting && this.elapsed - cell.placedAt > PIPE_LIFETIME) { + cell.fadeOut = true; + } + + // Fade in/out + if (cell.fadeOut) { + cell.opacity -= FADE_OUT_SPEED * dt; + if (cell.opacity <= 0) { + cell.opacity = 0; + this.grid[i][j] = null; // free the cell for new pipes + continue; + } + } else if (cell.opacity < 1) { + cell.opacity = Math.min(1, cell.opacity + FADE_IN_SPEED * dt); + } + + // Mouse influence + const cx = this.offsetX + i * this.cellSize + this.cellSize / 2; + const cy = this.offsetY + j * this.cellSize + this.cellSize / 2; + const dx = cx - mouseX; + const dy = cy - mouseY; + const dist = Math.sqrt(dx * dx + dy * dy); + + if (dist < MOUSE_INFLUENCE_RADIUS && cell.opacity > 0.1) { + const inf = Math.cos((dist / MOUSE_INFLUENCE_RADIUS) * (Math.PI / 2)); + cell.targetElevation = ELEVATION_FACTOR * inf * inf; + + const shift = inf * COLOR_SHIFT_AMOUNT * 0.5; + cell.color = [ + Math.min(255, Math.max(0, cell.baseColor[0] + shift)), + Math.min(255, Math.max(0, cell.baseColor[1] + shift)), + Math.min(255, Math.max(0, cell.baseColor[2] + shift)), + ]; + } else { + cell.targetElevation = 0; + cell.color[0] += (cell.baseColor[0] - cell.color[0]) * 0.1; + cell.color[1] += (cell.baseColor[1] - cell.color[1]) * 0.1; + cell.color[2] += (cell.baseColor[2] - cell.color[2]) * 0.1; + } + + cell.elevation += + (cell.targetElevation - cell.elevation) * ELEVATION_LERP_SPEED * dt; + } + } + } + + render( + ctx: CanvasRenderingContext2D, + _width: number, + _height: number + ): void { + ctx.font = this.font; + ctx.textBaseline = "top"; + + for (let i = 0; i < this.cols; i++) { + for (let j = 0; j < this.rows; j++) { + const cell = this.grid[i][j]; + if (!cell || cell.opacity <= 0.01) continue; + + const x = this.offsetX + i * this.cellSize; + const y = this.offsetY + j * this.cellSize - cell.elevation; + const [r, g, b] = cell.color; + + // Shadow + if (cell.elevation > 0.5) { + const shadowAlpha = 0.2 * (cell.elevation / ELEVATION_FACTOR) * cell.opacity; + ctx.globalAlpha = shadowAlpha; + ctx.fillStyle = "rgb(0,0,0)"; + ctx.fillText(cell.char, x, y + cell.elevation * SHADOW_OFFSET_RATIO); + } + + // Main + ctx.globalAlpha = cell.opacity; + ctx.fillStyle = `rgb(${r},${g},${b})`; + ctx.fillText(cell.char, x, y); + + // Highlight + if (cell.elevation > 0.5) { + const highlightAlpha = 0.1 * (cell.elevation / ELEVATION_FACTOR) * cell.opacity; + ctx.globalAlpha = highlightAlpha; + ctx.fillStyle = "rgb(255,255,255)"; + ctx.fillText(cell.char, x, y); + } + } + } + + ctx.globalAlpha = 1; + } + + handleResize(width: number, height: number): void { + this.width = width; + this.height = height; + this.elapsed = 0; + this.growTimer = 0; + this.exiting = false; + this.computeGrid(); + this.spawnInitialPipes(); + } + + handleMouseMove(x: number, y: number, _isDown: boolean): void { + this.mouseX = x; + this.mouseY = y; + } + + handleMouseDown(x: number, y: number): void { + if (this.exiting) return; + + // Convert to grid coords + const gx = Math.floor((x - this.offsetX) / this.cellSize); + const gy = Math.floor((y - this.offsetY) / this.cellSize); + + // Spawn pipes in all 4 directions from click point + for (let d = 0; d < BURST_PIPE_COUNT; d++) { + const dir = d as Dir; + const color = this.randomColor(); + this.activePipes.push({ + id: this.nextPipeId++, + x: gx, + y: gy, + dir, + color: [...color], + spawnDelay: 0, + }); + } + } + + handleMouseUp(): void {} + + handleMouseLeave(): void { + this.mouseX = -1000; + this.mouseY = -1000; + } + + updatePalette(palette: [number, number, number][], _bgColor: string): void { + this.palette = palette; + // Assign by pipeId so all segments of the same pipe get the same color + for (let i = 0; i < this.cols; i++) { + for (let j = 0; j < this.rows; j++) { + const cell = this.grid[i][j]; + if (cell) { + cell.baseColor = palette[cell.pipeId % palette.length]; + } + } + } + } +} diff --git a/src/src/components/background/engines/shuffle.ts b/src/src/components/background/engines/shuffle.ts new file mode 100644 index 0000000..aff60a0 --- /dev/null +++ b/src/src/components/background/engines/shuffle.ts @@ -0,0 +1,207 @@ +import type { AnimationEngine } from "@/lib/animations/types"; +import { GameOfLifeEngine } from "./game-of-life"; +import { LavaLampEngine } from "./lava-lamp"; +import { ConfettiEngine } from "./confetti"; +import { AsciiquariumEngine } from "./asciiquarium"; +import { PipesEngine } from "./pipes"; + +type ChildId = "game-of-life" | "lava-lamp" | "confetti" | "asciiquarium" | "pipes"; + +const CHILD_IDS: ChildId[] = [ + "game-of-life", + "lava-lamp", + "confetti", + "asciiquarium", + "pipes", +]; + +const PLAY_DURATION = 30_000; +const STATE_KEY = "shuffle-state"; + +interface StoredState { + childId: ChildId; + startedAt: number; +} + +function createChild(id: ChildId): AnimationEngine { + switch (id) { + case "game-of-life": + return new GameOfLifeEngine(); + case "lava-lamp": + return new LavaLampEngine(); + case "confetti": + return new ConfettiEngine(); + case "asciiquarium": + return new AsciiquariumEngine(); + case "pipes": + return new PipesEngine(); + } +} + +function pickDifferent(current: ChildId | null): ChildId { + const others = current + ? CHILD_IDS.filter((id) => id !== current) + : CHILD_IDS; + return others[Math.floor(Math.random() * others.length)]; +} + +function save(state: StoredState): void { + try { + localStorage.setItem(STATE_KEY, JSON.stringify(state)); + } catch {} +} + +function load(): StoredState | null { + try { + const raw = localStorage.getItem(STATE_KEY); + if (!raw) return null; + const state = JSON.parse(raw) as StoredState; + if (CHILD_IDS.includes(state.childId)) return state; + return null; + } catch { + return null; + } +} + +export class ShuffleEngine implements AnimationEngine { + id = "shuffle"; + name = "Shuffle"; + + private child: AnimationEngine | null = null; + private currentChildId: ChildId | null = null; + private startedAt = 0; + private phase: "playing" | "exiting" = "playing"; + + private width = 0; + private height = 0; + private palette: [number, number, number][] = []; + private bgColor = ""; + + init( + width: number, + height: number, + palette: [number, number, number][], + bgColor: string + ): void { + this.width = width; + this.height = height; + this.palette = palette; + this.bgColor = bgColor; + + const stored = load(); + + if (stored && Date.now() - stored.startedAt < PLAY_DURATION) { + // Animation still within its play window — continue it + // Covers: Astro nav, sidebar mount, layout switch, quick refresh + this.currentChildId = stored.childId; + } else { + // No recent state (first visit, hard refresh after timer expired) — game-of-life + this.currentChildId = "game-of-life"; + } + + this.startedAt = Date.now(); + + this.phase = "playing"; + this.child = createChild(this.currentChildId); + this.child.init(this.width, this.height, this.palette, this.bgColor); + save({ childId: this.currentChildId, startedAt: this.startedAt }); + } + + private switchTo(childId: ChildId, startedAt: number): void { + if (this.child) this.child.cleanup(); + this.currentChildId = childId; + this.startedAt = startedAt; + this.phase = "playing"; + this.child = createChild(childId); + this.child.init(this.width, this.height, this.palette, this.bgColor); + } + + private advance(): void { + // Check if another instance already advanced + const stored = load(); + if (stored && stored.childId !== this.currentChildId) { + this.switchTo(stored.childId, stored.startedAt); + } else { + const next = pickDifferent(this.currentChildId); + const now = Date.now(); + save({ childId: next, startedAt: now }); + this.switchTo(next, now); + } + } + + update(deltaTime: number): void { + if (!this.child) return; + + // Sync: if another instance (sidebar, tab) switched, follow + const stored = load(); + if (stored && stored.childId !== this.currentChildId) { + this.switchTo(stored.childId, stored.startedAt); + return; + } + + this.child.update(deltaTime); + + const elapsed = Date.now() - this.startedAt; + + if (this.phase === "playing" && elapsed >= PLAY_DURATION) { + this.child.beginExit(); + this.phase = "exiting"; + } + + if (this.phase === "exiting" && this.child.isExitComplete()) { + this.child.cleanup(); + this.advance(); + } + } + + render( + ctx: CanvasRenderingContext2D, + width: number, + height: number + ): void { + if (this.child) this.child.render(ctx, width, height); + } + + handleResize(width: number, height: number): void { + this.width = width; + this.height = height; + if (this.child) this.child.handleResize(width, height); + } + + handleMouseMove(x: number, y: number, isDown: boolean): void { + if (this.child) this.child.handleMouseMove(x, y, isDown); + } + + handleMouseDown(x: number, y: number): void { + if (this.child) this.child.handleMouseDown(x, y); + } + + handleMouseUp(): void { + if (this.child) this.child.handleMouseUp(); + } + + handleMouseLeave(): void { + if (this.child) this.child.handleMouseLeave(); + } + + updatePalette(palette: [number, number, number][], bgColor: string): void { + this.palette = palette; + this.bgColor = bgColor; + if (this.child) this.child.updatePalette(palette, bgColor); + } + + beginExit(): void { + if (this.child) this.child.beginExit(); + } + + isExitComplete(): boolean { + return this.child ? this.child.isExitComplete() : true; + } + + cleanup(): void { + if (this.child) { + this.child.cleanup(); + this.child = null; + } + } +} diff --git a/src/src/components/background/index.tsx b/src/src/components/background/index.tsx index 26f3df7..c3f8287 100644 --- a/src/src/components/background/index.tsx +++ b/src/src/components/background/index.tsx @@ -2,6 +2,9 @@ import { useEffect, useRef } from "react"; import { GameOfLifeEngine } from "./engines/game-of-life"; import { LavaLampEngine } from "./engines/lava-lamp"; import { ConfettiEngine } from "./engines/confetti"; +import { AsciiquariumEngine } from "./engines/asciiquarium"; +import { PipesEngine } from "./engines/pipes"; +import { ShuffleEngine } from "./engines/shuffle"; import { getStoredAnimationId } from "@/lib/animations/engine"; import type { AnimationEngine } from "@/lib/animations/types"; import type { AnimationId } from "@/lib/animations"; @@ -11,6 +14,8 @@ const SIDEBAR_WIDTH = 240; const FALLBACK_PALETTE: [number, number, number][] = [ [204, 36, 29], [152, 151, 26], [215, 153, 33], [69, 133, 136], [177, 98, 134], [104, 157, 106], + [251, 73, 52], [184, 187, 38], [250, 189, 47], + [131, 165, 152], [211, 134, 155], [142, 192, 124], ]; function createEngine(id: AnimationId): AnimationEngine { @@ -19,6 +24,12 @@ function createEngine(id: AnimationId): AnimationEngine { return new LavaLampEngine(); case "confetti": return new ConfettiEngine(); + case "asciiquarium": + return new AsciiquariumEngine(); + case "pipes": + return new PipesEngine(); + case "shuffle": + return new ShuffleEngine(); case "game-of-life": default: return new GameOfLifeEngine(); @@ -31,6 +42,8 @@ function readPaletteFromCSS(): [number, number, number][] { const keys = [ "--color-red", "--color-green", "--color-yellow", "--color-blue", "--color-purple", "--color-aqua", + "--color-red-bright", "--color-green-bright", "--color-yellow-bright", + "--color-blue-bright", "--color-purple-bright", "--color-aqua-bright", ]; const palette: [number, number, number][] = []; for (const key of keys) { @@ -140,11 +153,21 @@ const Background: React.FC = ({ signal, }); - // Handle theme changes + // Handle theme changes — only update if palette actually changed + let currentPalette = palette; const handleThemeChanged = () => { const newPalette = readPaletteFromCSS(); const newBg = readBgFromCSS(); - if (engineRef.current) { + const same = + newPalette.length === currentPalette.length && + newPalette.every( + (c, i) => + c[0] === currentPalette[i][0] && + c[1] === currentPalette[i][1] && + c[2] === currentPalette[i][2] + ); + if (!same && engineRef.current) { + currentPalette = newPalette; engineRef.current.updatePalette(newPalette, newBg); } }; @@ -183,7 +206,7 @@ const Background: React.FC = ({ // Don't spawn when clicking interactive elements const target = e.target as HTMLElement; - if (target.closest("a, button, [role='button'], input, select, textarea, [onclick]")) return; + if (target.closest("a, button, [role='button'], input, select, textarea, label, [onclick], [tabindex]")) return; const rect = canvas.getBoundingClientRect(); const mouseX = e.clientX - rect.left; @@ -324,6 +347,8 @@ const Background: React.FC = ({ style={{ cursor: "default" }} />
+
+
); }; diff --git a/src/src/components/theme-switcher/index.tsx b/src/src/components/theme-switcher/index.tsx index 985b69d..31b17fe 100644 --- a/src/src/components/theme-switcher/index.tsx +++ b/src/src/components/theme-switcher/index.tsx @@ -11,7 +11,7 @@ const LABELS: Record = { export default function ThemeSwitcher() { const [hovering, setHovering] = useState(false); - const [nextLabel, setNextLabel] = useState(""); + const [currentLabel, setCurrentLabel] = useState(""); const maskRef = useRef(null); const animatingRef = useRef(false); @@ -19,13 +19,13 @@ export default function ThemeSwitcher() { useEffect(() => { committedRef.current = getStoredThemeId(); - setNextLabel(LABELS[getNextTheme(committedRef.current).id] ?? ""); + setCurrentLabel(LABELS[committedRef.current] ?? ""); const handleSwap = () => { const id = getStoredThemeId(); applyTheme(id); committedRef.current = id; - setNextLabel(LABELS[getNextTheme(id).id] ?? ""); + setCurrentLabel(LABELS[id] ?? ""); }; document.addEventListener("astro:after-swap", handleSwap); @@ -54,7 +54,7 @@ export default function ThemeSwitcher() { const next = getNextTheme(committedRef.current); applyTheme(next.id); committedRef.current = next.id; - setNextLabel(LABELS[getNextTheme(next.id).id] ?? ""); + setCurrentLabel(LABELS[next.id] ?? ""); mask.offsetHeight; @@ -84,7 +84,7 @@ export default function ThemeSwitcher() { className="text-foreground font-bold text-sm select-none transition-opacity duration-200" style={{ opacity: hovering ? 0.8 : 0.15 }} > - {nextLabel} + {currentLabel}
diff --git a/src/src/lib/animations/index.ts b/src/src/lib/animations/index.ts index cf82508..39bf71d 100644 --- a/src/src/lib/animations/index.ts +++ b/src/src/lib/animations/index.ts @@ -1,9 +1,12 @@ -export const ANIMATION_IDS = ["game-of-life", "lava-lamp", "confetti"] as const; +export const ANIMATION_IDS = ["shuffle", "game-of-life", "lava-lamp", "confetti", "asciiquarium", "pipes"] as const; export type AnimationId = (typeof ANIMATION_IDS)[number]; -export const DEFAULT_ANIMATION_ID: AnimationId = "game-of-life"; +export const DEFAULT_ANIMATION_ID: AnimationId = "shuffle"; export const ANIMATION_LABELS: Record = { + "shuffle": "shuffle", "game-of-life": "life", "lava-lamp": "lava", "confetti": "confetti", + "asciiquarium": "aquarium", + "pipes": "pipes", }; diff --git a/src/src/lib/animations/types.ts b/src/src/lib/animations/types.ts index ba1cc33..d24780f 100644 --- a/src/src/lib/animations/types.ts +++ b/src/src/lib/animations/types.ts @@ -29,5 +29,9 @@ export interface AnimationEngine { updatePalette(palette: [number, number, number][], bgColor: string): void; + beginExit(): void; + + isExitComplete(): boolean; + cleanup(): void; } diff --git a/src/src/lib/themes/engine.ts b/src/src/lib/themes/engine.ts index dae6925..6cf5fb2 100644 --- a/src/src/lib/themes/engine.ts +++ b/src/src/lib/themes/engine.ts @@ -27,6 +27,7 @@ export function previewTheme(id: string): void { root.style.setProperty(prop, theme.colors[key]); } + document.dispatchEvent(new CustomEvent("theme-changed", { detail: { id } })); } @@ -55,5 +56,6 @@ export function applyTheme(id: string): void { el.textContent = css; saveTheme(id); + document.dispatchEvent(new CustomEvent("theme-changed", { detail: { id } })); } diff --git a/src/src/style/globals.css b/src/src/style/globals.css index 901b0e1..2d7dc59 100644 --- a/src/src/style/globals.css +++ b/src/src/style/globals.css @@ -37,6 +37,37 @@ @apply bg-purple/50 } } +/* CRT overlay — canvas only */ +.crt-scanlines { + background: repeating-linear-gradient( + to bottom, + transparent 0px, + transparent 2px, + rgba(0, 0, 0, 0.12) 2px, + rgba(0, 0, 0, 0.12) 4px + ); + animation: crt-scroll 12s linear infinite; + z-index: 1; +} + +.crt-bloom { + box-shadow: inset 0 0 100px 30px rgba(0, 0, 0, 0.3); + background: radial-gradient( + ellipse at center, + transparent 50%, + rgba(0, 0, 0, 0.25) 100% + ); + z-index: 2; +} + +@keyframes crt-scroll { + 0% { + background-position: 0 0; + } + 100% { + background-position: 0 200px; + } +} /* Regular */ @font-face {