mirror of
https://github.com/timmypidashev/web.git
synced 2026-04-14 02:53:51 +00:00
481 lines
14 KiB
TypeScript
481 lines
14 KiB
TypeScript
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];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|