mirror of
https://github.com/timmypidashev/web.git
synced 2026-04-14 02:53:51 +00:00
Add shuffle, pipes engines; lots of polish
This commit is contained in:
@@ -8,17 +8,17 @@ import { ANIMATION_LABELS } from "@/lib/animations";
|
|||||||
|
|
||||||
export default function AnimationSwitcher() {
|
export default function AnimationSwitcher() {
|
||||||
const [hovering, setHovering] = useState(false);
|
const [hovering, setHovering] = useState(false);
|
||||||
const [nextLabel, setNextLabel] = useState("");
|
const [currentLabel, setCurrentLabel] = useState("");
|
||||||
const committedRef = useRef("");
|
const committedRef = useRef("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
committedRef.current = getStoredAnimationId();
|
committedRef.current = getStoredAnimationId();
|
||||||
setNextLabel(ANIMATION_LABELS[getNextAnimation(committedRef.current)]);
|
setCurrentLabel(ANIMATION_LABELS[committedRef.current]);
|
||||||
|
|
||||||
const handleSwap = () => {
|
const handleSwap = () => {
|
||||||
const id = getStoredAnimationId();
|
const id = getStoredAnimationId();
|
||||||
committedRef.current = id;
|
committedRef.current = id;
|
||||||
setNextLabel(ANIMATION_LABELS[getNextAnimation(id)]);
|
setCurrentLabel(ANIMATION_LABELS[id]);
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener("astro:after-swap", handleSwap);
|
document.addEventListener("astro:after-swap", handleSwap);
|
||||||
@@ -33,7 +33,7 @@ export default function AnimationSwitcher() {
|
|||||||
);
|
);
|
||||||
saveAnimation(nextId);
|
saveAnimation(nextId);
|
||||||
committedRef.current = nextId;
|
committedRef.current = nextId;
|
||||||
setNextLabel(ANIMATION_LABELS[getNextAnimation(nextId)]);
|
setCurrentLabel(ANIMATION_LABELS[nextId]);
|
||||||
document.dispatchEvent(
|
document.dispatchEvent(
|
||||||
new CustomEvent("animation-changed", { detail: { id: nextId } })
|
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"
|
className="text-foreground font-bold text-sm select-none transition-opacity duration-200"
|
||||||
style={{ opacity: hovering ? 0.8 : 0.15 }}
|
style={{ opacity: hovering ? 0.8 : 0.15 }}
|
||||||
>
|
>
|
||||||
{nextLabel}
|
{currentLabel}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
574
src/src/components/background/engines/asciiquarium.ts
Normal file
574
src/src/components/background/engines/asciiquarium.ts
Normal file
@@ -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(["<o))><"]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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<string, { min: number; max: number }> = {
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ interface ConfettiParticle {
|
|||||||
burst: boolean;
|
burst: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BASE_CONFETTI = 350;
|
const BASE_CONFETTI = 385;
|
||||||
const BASE_AREA = 1920 * 1080;
|
const BASE_AREA = 1920 * 1080;
|
||||||
const PI_2 = 2 * Math.PI;
|
const PI_2 = 2 * Math.PI;
|
||||||
const TARGET_FPS = 60;
|
const TARGET_FPS = 60;
|
||||||
@@ -45,6 +45,7 @@ export class ConfettiEngine implements AnimationEngine {
|
|||||||
private mouseY = -1000;
|
private mouseY = -1000;
|
||||||
private mouseXNorm = 0.5;
|
private mouseXNorm = 0.5;
|
||||||
private elapsed = 0;
|
private elapsed = 0;
|
||||||
|
private exiting = false;
|
||||||
|
|
||||||
init(
|
init(
|
||||||
width: number,
|
width: number,
|
||||||
@@ -60,6 +61,30 @@ export class ConfettiEngine implements AnimationEngine {
|
|||||||
this.initParticles();
|
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 {
|
cleanup(): void {
|
||||||
this.particles = [];
|
this.particles = [];
|
||||||
}
|
}
|
||||||
@@ -140,15 +165,18 @@ export class ConfettiEngine implements AnimationEngine {
|
|||||||
p.x += p.vx * dt;
|
p.x += p.vx * dt;
|
||||||
p.y += p.vy * dt;
|
p.y += p.vy * dt;
|
||||||
|
|
||||||
// Fade in only (no fade-out cycle)
|
// Fade in, or fade out during exit
|
||||||
if (p.opacity < 1) {
|
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;
|
p.opacity += Math.abs(p.dop) * dt;
|
||||||
if (p.opacity > 1) p.opacity = 1;
|
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.y > this.height + p.r) {
|
||||||
if (p.burst) {
|
if (p.burst || this.exiting) {
|
||||||
this.particles.splice(i, 1);
|
this.particles.splice(i, 1);
|
||||||
i--;
|
i--;
|
||||||
} else {
|
} else {
|
||||||
@@ -230,7 +258,7 @@ export class ConfettiEngine implements AnimationEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Main circle
|
// Main circle
|
||||||
ctx.globalAlpha = p.opacity * 0.9;
|
ctx.globalAlpha = p.opacity;
|
||||||
ctx.fillStyle = `rgb(${r},${g},${b})`;
|
ctx.fillStyle = `rgb(${r},${g},${b})`;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(drawX, drawY, p.r, 0, PI_2);
|
ctx.arc(drawX, drawY, p.r, 0, PI_2);
|
||||||
@@ -254,29 +282,8 @@ export class ConfettiEngine implements AnimationEngine {
|
|||||||
handleResize(width: number, height: number): void {
|
handleResize(width: number, height: number): void {
|
||||||
this.width = width;
|
this.width = width;
|
||||||
this.height = height;
|
this.height = height;
|
||||||
const target = this.getParticleCount();
|
this.elapsed = 0;
|
||||||
while (this.particles.length < target) {
|
this.initParticles();
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handleMouseMove(x: number, y: number, _isDown: boolean): void {
|
handleMouseMove(x: number, y: number, _isDown: boolean): void {
|
||||||
@@ -303,7 +310,7 @@ export class ConfettiEngine implements AnimationEngine {
|
|||||||
color: [...baseColor],
|
color: [...baseColor],
|
||||||
baseColor,
|
baseColor,
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
dop: 0,
|
dop: this.exiting ? -0.02 : 0,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
targetElevation: 0,
|
targetElevation: 0,
|
||||||
staggerDelay: -1,
|
staggerDelay: -1,
|
||||||
@@ -322,8 +329,8 @@ export class ConfettiEngine implements AnimationEngine {
|
|||||||
|
|
||||||
updatePalette(palette: [number, number, number][], _bgColor: string): void {
|
updatePalette(palette: [number, number, number][], _bgColor: string): void {
|
||||||
this.palette = palette;
|
this.palette = palette;
|
||||||
for (const p of this.particles) {
|
for (let i = 0; i < this.particles.length; i++) {
|
||||||
p.baseColor = palette[Math.floor(Math.random() * palette.length)];
|
this.particles[i].baseColor = palette[i % palette.length];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export class GameOfLifeEngine implements AnimationEngine {
|
|||||||
private pendingTimeouts: ReturnType<typeof setTimeout>[] = [];
|
private pendingTimeouts: ReturnType<typeof setTimeout>[] = [];
|
||||||
private canvasWidth = 0;
|
private canvasWidth = 0;
|
||||||
private canvasHeight = 0;
|
private canvasHeight = 0;
|
||||||
|
private exiting = false;
|
||||||
|
|
||||||
init(
|
init(
|
||||||
width: number,
|
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 {
|
update(deltaTime: number): void {
|
||||||
if (!this.grid) return;
|
if (!this.grid) return;
|
||||||
|
|
||||||
this.timeAccumulator += deltaTime;
|
if (!this.exiting) {
|
||||||
if (this.timeAccumulator >= CYCLE_TIME) {
|
this.timeAccumulator += deltaTime;
|
||||||
this.computeNextState(this.grid);
|
if (this.timeAccumulator >= CYCLE_TIME) {
|
||||||
this.timeAccumulator -= CYCLE_TIME;
|
this.computeNextState(this.grid);
|
||||||
|
this.timeAccumulator -= CYCLE_TIME;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.updateCellAnimations(this.grid, deltaTime);
|
this.updateCellAnimations(this.grid, deltaTime);
|
||||||
@@ -335,7 +383,15 @@ export class GameOfLifeEngine implements AnimationEngine {
|
|||||||
cell.targetElevation = 0;
|
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) {
|
if (!cell.next && cell.opacity < 0.01 && cell.scale < 0.01) {
|
||||||
cell.alive = false;
|
cell.alive = false;
|
||||||
cell.transitioning = false;
|
cell.transitioning = false;
|
||||||
@@ -532,7 +588,7 @@ export class GameOfLifeEngine implements AnimationEngine {
|
|||||||
this.mouseY = y;
|
this.mouseY = y;
|
||||||
this.mouseIsDown = isDown;
|
this.mouseIsDown = isDown;
|
||||||
|
|
||||||
if (isDown && this.grid) {
|
if (isDown && this.grid && !this.exiting) {
|
||||||
const grid = this.grid;
|
const grid = this.grid;
|
||||||
const cellSize = this.getCellSize();
|
const cellSize = this.getCellSize();
|
||||||
const cellX = Math.floor((x - grid.offsetX) / cellSize);
|
const cellX = Math.floor((x - grid.offsetX) / cellSize);
|
||||||
@@ -560,7 +616,7 @@ export class GameOfLifeEngine implements AnimationEngine {
|
|||||||
handleMouseDown(x: number, y: number): void {
|
handleMouseDown(x: number, y: number): void {
|
||||||
this.mouseIsDown = true;
|
this.mouseIsDown = true;
|
||||||
|
|
||||||
if (!this.grid) return;
|
if (!this.grid || this.exiting) return;
|
||||||
const grid = this.grid;
|
const grid = this.grid;
|
||||||
const cellSize = this.getCellSize();
|
const cellSize = this.getCellSize();
|
||||||
|
|
||||||
@@ -605,8 +661,7 @@ export class GameOfLifeEngine implements AnimationEngine {
|
|||||||
for (let j = 0; j < grid.rows; j++) {
|
for (let j = 0; j < grid.rows; j++) {
|
||||||
const cell = grid.cells[i][j];
|
const cell = grid.cells[i][j];
|
||||||
if (cell.alive && cell.opacity > 0.01) {
|
if (cell.alive && cell.opacity > 0.01) {
|
||||||
cell.baseColor =
|
cell.baseColor = palette[(i * grid.rows + j) % palette.length];
|
||||||
palette[Math.floor(Math.random() * palette.length)];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export class LavaLampEngine implements AnimationEngine {
|
|||||||
private shadowCtx: CanvasRenderingContext2D | null = null;
|
private shadowCtx: CanvasRenderingContext2D | null = null;
|
||||||
private elapsed = 0;
|
private elapsed = 0;
|
||||||
private nextCycleTime = 0;
|
private nextCycleTime = 0;
|
||||||
|
private exiting = false;
|
||||||
|
|
||||||
// Pre-allocated typed arrays for the inner render loop (avoid per-frame GC)
|
// Pre-allocated typed arrays for the inner render loop (avoid per-frame GC)
|
||||||
private blobX: Float64Array = new Float64Array(0);
|
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 {
|
cleanup(): void {
|
||||||
this.blobs = [];
|
this.blobs = [];
|
||||||
this.offCanvas = null;
|
this.offCanvas = null;
|
||||||
@@ -279,7 +301,7 @@ export class LavaLampEngine implements AnimationEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Natural spawn/despawn cycle — keeps the scene alive
|
// 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)
|
// Pick a random visible blob to fade out (skip ones still staggering in)
|
||||||
const visible = [];
|
const visible = [];
|
||||||
for (let i = 0; i < this.blobs.length; i++) {
|
for (let i = 0; i < this.blobs.length; i++) {
|
||||||
@@ -433,12 +455,11 @@ export class LavaLampEngine implements AnimationEngine {
|
|||||||
handleResize(width: number, height: number): void {
|
handleResize(width: number, height: number): void {
|
||||||
this.width = width;
|
this.width = width;
|
||||||
this.height = height;
|
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();
|
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 {
|
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 {
|
handleMouseDown(x: number, y: number): void {
|
||||||
|
if (this.exiting) return;
|
||||||
this.spawnAt(x, y);
|
this.spawnAt(x, y);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
480
src/src/components/background/engines/pipes.ts
Normal file
480
src/src/components/background/engines/pipes.ts
Normal file
@@ -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<string, string> = {
|
||||||
|
"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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
207
src/src/components/background/engines/shuffle.ts
Normal file
207
src/src/components/background/engines/shuffle.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,9 @@ import { useEffect, useRef } from "react";
|
|||||||
import { GameOfLifeEngine } from "./engines/game-of-life";
|
import { GameOfLifeEngine } from "./engines/game-of-life";
|
||||||
import { LavaLampEngine } from "./engines/lava-lamp";
|
import { LavaLampEngine } from "./engines/lava-lamp";
|
||||||
import { ConfettiEngine } from "./engines/confetti";
|
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 { getStoredAnimationId } from "@/lib/animations/engine";
|
||||||
import type { AnimationEngine } from "@/lib/animations/types";
|
import type { AnimationEngine } from "@/lib/animations/types";
|
||||||
import type { AnimationId } from "@/lib/animations";
|
import type { AnimationId } from "@/lib/animations";
|
||||||
@@ -11,6 +14,8 @@ const SIDEBAR_WIDTH = 240;
|
|||||||
const FALLBACK_PALETTE: [number, number, number][] = [
|
const FALLBACK_PALETTE: [number, number, number][] = [
|
||||||
[204, 36, 29], [152, 151, 26], [215, 153, 33],
|
[204, 36, 29], [152, 151, 26], [215, 153, 33],
|
||||||
[69, 133, 136], [177, 98, 134], [104, 157, 106],
|
[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 {
|
function createEngine(id: AnimationId): AnimationEngine {
|
||||||
@@ -19,6 +24,12 @@ function createEngine(id: AnimationId): AnimationEngine {
|
|||||||
return new LavaLampEngine();
|
return new LavaLampEngine();
|
||||||
case "confetti":
|
case "confetti":
|
||||||
return new ConfettiEngine();
|
return new ConfettiEngine();
|
||||||
|
case "asciiquarium":
|
||||||
|
return new AsciiquariumEngine();
|
||||||
|
case "pipes":
|
||||||
|
return new PipesEngine();
|
||||||
|
case "shuffle":
|
||||||
|
return new ShuffleEngine();
|
||||||
case "game-of-life":
|
case "game-of-life":
|
||||||
default:
|
default:
|
||||||
return new GameOfLifeEngine();
|
return new GameOfLifeEngine();
|
||||||
@@ -31,6 +42,8 @@ function readPaletteFromCSS(): [number, number, number][] {
|
|||||||
const keys = [
|
const keys = [
|
||||||
"--color-red", "--color-green", "--color-yellow",
|
"--color-red", "--color-green", "--color-yellow",
|
||||||
"--color-blue", "--color-purple", "--color-aqua",
|
"--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][] = [];
|
const palette: [number, number, number][] = [];
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
@@ -140,11 +153,21 @@ const Background: React.FC<BackgroundProps> = ({
|
|||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle theme changes
|
// Handle theme changes — only update if palette actually changed
|
||||||
|
let currentPalette = palette;
|
||||||
const handleThemeChanged = () => {
|
const handleThemeChanged = () => {
|
||||||
const newPalette = readPaletteFromCSS();
|
const newPalette = readPaletteFromCSS();
|
||||||
const newBg = readBgFromCSS();
|
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);
|
engineRef.current.updatePalette(newPalette, newBg);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -183,7 +206,7 @@ const Background: React.FC<BackgroundProps> = ({
|
|||||||
|
|
||||||
// Don't spawn when clicking interactive elements
|
// Don't spawn when clicking interactive elements
|
||||||
const target = e.target as HTMLElement;
|
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 rect = canvas.getBoundingClientRect();
|
||||||
const mouseX = e.clientX - rect.left;
|
const mouseX = e.clientX - rect.left;
|
||||||
@@ -324,6 +347,8 @@ const Background: React.FC<BackgroundProps> = ({
|
|||||||
style={{ cursor: "default" }}
|
style={{ cursor: "default" }}
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-background/30 backdrop-blur-sm pointer-events-none" />
|
<div className="absolute inset-0 bg-background/30 backdrop-blur-sm pointer-events-none" />
|
||||||
|
<div className="crt-scanlines absolute inset-0 pointer-events-none" />
|
||||||
|
<div className="crt-bloom absolute inset-0 pointer-events-none" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const LABELS: Record<string, string> = {
|
|||||||
|
|
||||||
export default function ThemeSwitcher() {
|
export default function ThemeSwitcher() {
|
||||||
const [hovering, setHovering] = useState(false);
|
const [hovering, setHovering] = useState(false);
|
||||||
const [nextLabel, setNextLabel] = useState("");
|
const [currentLabel, setCurrentLabel] = useState("");
|
||||||
|
|
||||||
const maskRef = useRef<HTMLDivElement>(null);
|
const maskRef = useRef<HTMLDivElement>(null);
|
||||||
const animatingRef = useRef(false);
|
const animatingRef = useRef(false);
|
||||||
@@ -19,13 +19,13 @@ export default function ThemeSwitcher() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
committedRef.current = getStoredThemeId();
|
committedRef.current = getStoredThemeId();
|
||||||
setNextLabel(LABELS[getNextTheme(committedRef.current).id] ?? "");
|
setCurrentLabel(LABELS[committedRef.current] ?? "");
|
||||||
|
|
||||||
const handleSwap = () => {
|
const handleSwap = () => {
|
||||||
const id = getStoredThemeId();
|
const id = getStoredThemeId();
|
||||||
applyTheme(id);
|
applyTheme(id);
|
||||||
committedRef.current = id;
|
committedRef.current = id;
|
||||||
setNextLabel(LABELS[getNextTheme(id).id] ?? "");
|
setCurrentLabel(LABELS[id] ?? "");
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener("astro:after-swap", handleSwap);
|
document.addEventListener("astro:after-swap", handleSwap);
|
||||||
@@ -54,7 +54,7 @@ export default function ThemeSwitcher() {
|
|||||||
const next = getNextTheme(committedRef.current);
|
const next = getNextTheme(committedRef.current);
|
||||||
applyTheme(next.id);
|
applyTheme(next.id);
|
||||||
committedRef.current = next.id;
|
committedRef.current = next.id;
|
||||||
setNextLabel(LABELS[getNextTheme(next.id).id] ?? "");
|
setCurrentLabel(LABELS[next.id] ?? "");
|
||||||
|
|
||||||
mask.offsetHeight;
|
mask.offsetHeight;
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ export default function ThemeSwitcher() {
|
|||||||
className="text-foreground font-bold text-sm select-none transition-opacity duration-200"
|
className="text-foreground font-bold text-sm select-none transition-opacity duration-200"
|
||||||
style={{ opacity: hovering ? 0.8 : 0.15 }}
|
style={{ opacity: hovering ? 0.8 : 0.15 }}
|
||||||
>
|
>
|
||||||
{nextLabel}
|
{currentLabel}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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 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<AnimationId, string> = {
|
export const ANIMATION_LABELS: Record<AnimationId, string> = {
|
||||||
|
"shuffle": "shuffle",
|
||||||
"game-of-life": "life",
|
"game-of-life": "life",
|
||||||
"lava-lamp": "lava",
|
"lava-lamp": "lava",
|
||||||
"confetti": "confetti",
|
"confetti": "confetti",
|
||||||
|
"asciiquarium": "aquarium",
|
||||||
|
"pipes": "pipes",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -29,5 +29,9 @@ export interface AnimationEngine {
|
|||||||
|
|
||||||
updatePalette(palette: [number, number, number][], bgColor: string): void;
|
updatePalette(palette: [number, number, number][], bgColor: string): void;
|
||||||
|
|
||||||
|
beginExit(): void;
|
||||||
|
|
||||||
|
isExitComplete(): boolean;
|
||||||
|
|
||||||
cleanup(): void;
|
cleanup(): void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export function previewTheme(id: string): void {
|
|||||||
root.style.setProperty(prop, theme.colors[key]);
|
root.style.setProperty(prop, theme.colors[key]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
document.dispatchEvent(new CustomEvent("theme-changed", { detail: { id } }));
|
document.dispatchEvent(new CustomEvent("theme-changed", { detail: { id } }));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,5 +56,6 @@ export function applyTheme(id: string): void {
|
|||||||
el.textContent = css;
|
el.textContent = css;
|
||||||
|
|
||||||
saveTheme(id);
|
saveTheme(id);
|
||||||
|
|
||||||
document.dispatchEvent(new CustomEvent("theme-changed", { detail: { id } }));
|
document.dispatchEvent(new CustomEvent("theme-changed", { detail: { id } }));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,37 @@
|
|||||||
@apply bg-purple/50
|
@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 */
|
/* Regular */
|
||||||
@font-face {
|
@font-face {
|
||||||
|
|||||||
Reference in New Issue
Block a user