import type { AnimationEngine } from "@/lib/animations/types"; interface Cell { alive: boolean; next: boolean; color: [number, number, number]; baseColor: [number, number, number]; currentX: number; currentY: number; targetX: number; targetY: number; opacity: number; targetOpacity: number; scale: number; targetScale: number; elevation: number; targetElevation: number; transitioning: boolean; transitionComplete: boolean; rippleEffect: number; rippleStartTime: number; rippleDistance: number; } interface Grid { cells: Cell[][]; cols: number; rows: number; offsetX: number; offsetY: number; } const CELL_SIZE_MOBILE = 15; const CELL_SIZE_DESKTOP = 25; const TARGET_FPS = 60; const CYCLE_TIME = 3000; const TRANSITION_SPEED = 0.05; const SCALE_SPEED = 0.05; const INITIAL_DENSITY = 0.15; const MOUSE_INFLUENCE_RADIUS = 150; const COLOR_SHIFT_AMOUNT = 30; const RIPPLE_ELEVATION_FACTOR = 4; const ELEVATION_FACTOR = 8; export class GameOfLifeEngine implements AnimationEngine { id = "game-of-life"; name = "Game of Life"; private grid: Grid | null = null; private palette: [number, number, number][] = []; private bgColor = "rgb(0, 0, 0)"; private mouseX = -1000; private mouseY = -1000; private mouseIsDown = false; private mouseCellX = -1; private mouseCellY = -1; private lastCycleTime = 0; private timeAccumulator = 0; private pendingTimeouts: ReturnType[] = []; private canvasWidth = 0; private canvasHeight = 0; private exiting = false; init( width: number, height: number, palette: [number, number, number][], bgColor: string ): void { this.palette = palette; this.bgColor = bgColor; this.canvasWidth = width; this.canvasHeight = height; this.lastCycleTime = 0; this.timeAccumulator = 0; this.grid = this.initGrid(width, height); } cleanup(): void { for (const id of this.pendingTimeouts) { clearTimeout(id); } this.pendingTimeouts = []; this.grid = null; } private getCellSize(): number { return this.canvasWidth <= 768 ? CELL_SIZE_MOBILE : CELL_SIZE_DESKTOP; } private randomColor(): [number, number, number] { return this.palette[Math.floor(Math.random() * this.palette.length)]; } private initGrid(width: number, height: number): Grid { const cellSize = this.getCellSize(); const cols = Math.floor(width / cellSize); const rows = Math.floor(height / cellSize); const offsetX = Math.floor((width - cols * cellSize) / 2); const offsetY = Math.floor((height - rows * cellSize) / 2); const cells = Array(cols) .fill(0) .map((_, i) => Array(rows) .fill(0) .map((_, j) => { const baseColor = this.randomColor(); return { alive: Math.random() < INITIAL_DENSITY, next: false, color: [...baseColor] as [number, number, number], baseColor, currentX: i, currentY: j, targetX: i, targetY: j, opacity: 0, targetOpacity: 0, scale: 0, targetScale: 0, elevation: 0, targetElevation: 0, transitioning: false, transitionComplete: false, rippleEffect: 0, rippleStartTime: 0, rippleDistance: 0, }; }) ); const grid = { cells, cols, rows, offsetX, offsetY }; this.computeNextState(grid); for (let i = 0; i < cols; i++) { for (let j = 0; j < rows; j++) { const cell = cells[i][j]; if (cell.next) { cell.alive = true; const tid = setTimeout(() => { cell.targetOpacity = 1; cell.targetScale = 1; }, Math.random() * 1000); this.pendingTimeouts.push(tid); } else { cell.alive = false; } } } return grid; } private countNeighbors( grid: Grid, x: number, y: number ): { count: number; colors: [number, number, number][] } { const neighbors = { count: 0, colors: [] as [number, number, number][] }; for (let i = -1; i <= 1; i++) { for (let j = -1; j <= 1; j++) { if (i === 0 && j === 0) continue; const col = (x + i + grid.cols) % grid.cols; const row = (y + j + grid.rows) % grid.rows; if (grid.cells[col][row].alive) { neighbors.count++; neighbors.colors.push(grid.cells[col][row].baseColor); } } } return neighbors; } private averageColors( colors: [number, number, number][] ): [number, number, number] { if (colors.length === 0) return [0, 0, 0]; const sum = colors.reduce( (acc, color) => [acc[0] + color[0], acc[1] + color[1], acc[2] + color[2]], [0, 0, 0] ); return [ Math.round(sum[0] / colors.length), Math.round(sum[1] / colors.length), Math.round(sum[2] / colors.length), ]; } private computeNextState(grid: Grid): void { for (let i = 0; i < grid.cols; i++) { for (let j = 0; j < grid.rows; j++) { const cell = grid.cells[i][j]; const { count, colors } = this.countNeighbors(grid, i, j); if (cell.alive) { cell.next = count === 2 || count === 3; } else { cell.next = count === 3; if (cell.next) { cell.baseColor = this.averageColors(colors); cell.color = [...cell.baseColor]; } } } } for (let i = 0; i < grid.cols; i++) { for (let j = 0; j < grid.rows; j++) { const cell = grid.cells[i][j]; if (cell.alive !== cell.next && !cell.transitioning) { cell.transitioning = true; cell.transitionComplete = false; const delay = Math.random() * 800; const tid = setTimeout(() => { if (!cell.next) { cell.targetScale = 0; cell.targetOpacity = 0; cell.targetElevation = 0; } else { cell.targetScale = 1; cell.targetOpacity = 1; cell.targetElevation = 0; } }, delay); this.pendingTimeouts.push(tid); } } } } private createRippleEffect( grid: Grid, centerX: number, centerY: number ): void { const currentTime = Date.now(); for (let i = 0; i < grid.cols; i++) { for (let j = 0; j < grid.rows; j++) { const cell = grid.cells[i][j]; const dx = i - centerX; const dy = j - centerY; const distance = Math.sqrt(dx * dx + dy * dy); if (cell.opacity > 0.1) { cell.rippleStartTime = currentTime + distance * 100; cell.rippleDistance = distance; cell.rippleEffect = 0; } } } } private spawnCellAtPosition(grid: Grid, x: number, y: number): void { if (x >= 0 && x < grid.cols && y >= 0 && y < grid.rows) { const cell = grid.cells[x][y]; if (!cell.alive && !cell.transitioning) { cell.alive = true; cell.next = true; cell.transitioning = true; cell.transitionComplete = false; cell.baseColor = this.randomColor(); cell.color = [...cell.baseColor]; cell.targetScale = 1; cell.targetOpacity = 1; cell.targetElevation = 0; this.createRippleEffect(grid, x, y); } } } 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; if (!this.exiting) { this.timeAccumulator += deltaTime; if (this.timeAccumulator >= CYCLE_TIME) { this.computeNextState(this.grid); this.timeAccumulator -= CYCLE_TIME; } } this.updateCellAnimations(this.grid, deltaTime); } private updateCellAnimations(grid: Grid, deltaTime: number): void { const mouseX = this.mouseX; const mouseY = this.mouseY; const cellSize = this.getCellSize(); const transitionFactor = TRANSITION_SPEED * (deltaTime / (1000 / TARGET_FPS)); const scaleFactor = SCALE_SPEED * (deltaTime / (1000 / TARGET_FPS)); for (let i = 0; i < grid.cols; i++) { for (let j = 0; j < grid.rows; j++) { const cell = grid.cells[i][j]; cell.opacity += (cell.targetOpacity - cell.opacity) * transitionFactor; cell.scale += (cell.targetScale - cell.scale) * scaleFactor; cell.elevation += (cell.targetElevation - cell.elevation) * scaleFactor; const cellCenterX = grid.offsetX + i * cellSize + cellSize / 2; const cellCenterY = grid.offsetY + j * cellSize + cellSize / 2; const dx = cellCenterX - mouseX; const dy = cellCenterY - mouseY; const distanceToMouse = Math.sqrt(dx * dx + dy * dy); if (distanceToMouse < MOUSE_INFLUENCE_RADIUS && cell.opacity > 0.1) { const influenceFactor = Math.cos( (distanceToMouse / MOUSE_INFLUENCE_RADIUS) * (Math.PI / 2) ); cell.targetElevation = ELEVATION_FACTOR * influenceFactor * influenceFactor; const colorShift = influenceFactor * COLOR_SHIFT_AMOUNT * 0.5; cell.color = [ Math.min(255, Math.max(0, cell.baseColor[0] + colorShift)), Math.min(255, Math.max(0, cell.baseColor[1] + colorShift)), Math.min(255, Math.max(0, cell.baseColor[2] + colorShift)), ] as [number, number, number]; } else { 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.targetElevation = 0; } // 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; cell.transitionComplete = true; cell.opacity = 0; cell.scale = 0; cell.elevation = 0; } else if (cell.next && !cell.alive && !cell.transitionComplete) { cell.alive = true; cell.transitioning = false; cell.transitionComplete = true; } } if (cell.rippleStartTime > 0) { const elapsedTime = Date.now() - cell.rippleStartTime; if (elapsedTime > 0) { const rippleProgress = elapsedTime / 1000; if (rippleProgress < 1) { const wavePhase = rippleProgress * Math.PI * 2; const waveHeight = Math.sin(wavePhase) * Math.exp(-rippleProgress * 4); if (distanceToMouse >= MOUSE_INFLUENCE_RADIUS) { cell.rippleEffect = waveHeight; cell.targetElevation = RIPPLE_ELEVATION_FACTOR * waveHeight; } else { cell.rippleEffect = waveHeight * 0.3; } } else { cell.rippleEffect = 0; cell.rippleStartTime = 0; if (distanceToMouse >= MOUSE_INFLUENCE_RADIUS) { cell.targetElevation = 0; } } } } } } } render( ctx: CanvasRenderingContext2D, width: number, height: number ): void { if (!this.grid) return; const grid = this.grid; const cellSize = this.getCellSize(); const displayCellSize = cellSize * 0.8; const roundness = displayCellSize * 0.2; for (let i = 0; i < grid.cols; i++) { for (let j = 0; j < grid.rows; j++) { const cell = grid.cells[i][j]; if ( (cell.alive || cell.targetOpacity > 0) && cell.opacity > 0.01 ) { const [r, g, b] = cell.color; ctx.globalAlpha = cell.opacity * 0.9; const scaledSize = displayCellSize * cell.scale; const xOffset = (displayCellSize - scaledSize) / 2; const yOffset = (displayCellSize - scaledSize) / 2; const elevationOffset = cell.elevation; const x = grid.offsetX + i * cellSize + (cellSize - displayCellSize) / 2 + xOffset; const y = grid.offsetY + j * cellSize + (cellSize - displayCellSize) / 2 + yOffset - elevationOffset; const scaledRoundness = roundness * cell.scale; // Shadow for 3D effect if (elevationOffset > 0.5) { ctx.fillStyle = `rgba(0, 0, 0, ${0.2 * (elevationOffset / ELEVATION_FACTOR)})`; ctx.beginPath(); ctx.moveTo(x + scaledRoundness, y + elevationOffset * 1.1); ctx.lineTo( x + scaledSize - scaledRoundness, y + elevationOffset * 1.1 ); ctx.quadraticCurveTo( x + scaledSize, y + elevationOffset * 1.1, x + scaledSize, y + elevationOffset * 1.1 + scaledRoundness ); ctx.lineTo( x + scaledSize, y + elevationOffset * 1.1 + scaledSize - scaledRoundness ); ctx.quadraticCurveTo( x + scaledSize, y + elevationOffset * 1.1 + scaledSize, x + scaledSize - scaledRoundness, y + elevationOffset * 1.1 + scaledSize ); ctx.lineTo( x + scaledRoundness, y + elevationOffset * 1.1 + scaledSize ); ctx.quadraticCurveTo( x, y + elevationOffset * 1.1 + scaledSize, x, y + elevationOffset * 1.1 + scaledSize - scaledRoundness ); ctx.lineTo(x, y + elevationOffset * 1.1 + scaledRoundness); ctx.quadraticCurveTo( x, y + elevationOffset * 1.1, x + scaledRoundness, y + elevationOffset * 1.1 ); ctx.fill(); } // Main cell ctx.fillStyle = `rgb(${r},${g},${b})`; ctx.beginPath(); ctx.moveTo(x + scaledRoundness, y); ctx.lineTo(x + scaledSize - scaledRoundness, y); ctx.quadraticCurveTo(x + scaledSize, y, x + scaledSize, y + scaledRoundness); ctx.lineTo(x + scaledSize, y + scaledSize - scaledRoundness); ctx.quadraticCurveTo( x + scaledSize, y + scaledSize, x + scaledSize - scaledRoundness, y + scaledSize ); ctx.lineTo(x + scaledRoundness, y + scaledSize); ctx.quadraticCurveTo(x, y + scaledSize, x, y + scaledSize - scaledRoundness); ctx.lineTo(x, y + scaledRoundness); ctx.quadraticCurveTo(x, y, x + scaledRoundness, y); ctx.fill(); // Highlight on elevated cells if (elevationOffset > 0.5) { ctx.fillStyle = `rgba(255, 255, 255, ${0.1 * (elevationOffset / ELEVATION_FACTOR)})`; ctx.beginPath(); ctx.moveTo(x + scaledRoundness, y); ctx.lineTo(x + scaledSize - scaledRoundness, y); ctx.quadraticCurveTo( x + scaledSize, y, x + scaledSize, y + scaledRoundness ); ctx.lineTo(x + scaledSize, y + scaledSize / 3); ctx.lineTo(x, y + scaledSize / 3); ctx.lineTo(x, y + scaledRoundness); ctx.quadraticCurveTo(x, y, x + scaledRoundness, y); ctx.fill(); } } } } ctx.globalAlpha = 1; } handleResize(width: number, height: number): void { this.canvasWidth = width; this.canvasHeight = height; const cellSize = this.getCellSize(); if ( !this.grid || this.grid.cols !== Math.floor(width / cellSize) || this.grid.rows !== Math.floor(height / cellSize) ) { for (const id of this.pendingTimeouts) { clearTimeout(id); } this.pendingTimeouts = []; this.grid = this.initGrid(width, height); } } handleMouseMove(x: number, y: number, isDown: boolean): void { this.mouseX = x; this.mouseY = y; this.mouseIsDown = isDown; if (isDown && this.grid && !this.exiting) { const grid = this.grid; const cellSize = this.getCellSize(); const cellX = Math.floor((x - grid.offsetX) / cellSize); const cellY = Math.floor((y - grid.offsetY) / cellSize); if (cellX !== this.mouseCellX || cellY !== this.mouseCellY) { this.mouseCellX = cellX; this.mouseCellY = cellY; if ( cellX >= 0 && cellX < grid.cols && cellY >= 0 && cellY < grid.rows ) { const cell = grid.cells[cellX][cellY]; if (!cell.alive && !cell.transitioning) { this.spawnCellAtPosition(grid, cellX, cellY); } } } } } handleMouseDown(x: number, y: number): void { this.mouseIsDown = true; if (!this.grid || this.exiting) return; const grid = this.grid; const cellSize = this.getCellSize(); const cellX = Math.floor((x - grid.offsetX) / cellSize); const cellY = Math.floor((y - grid.offsetY) / cellSize); if ( cellX >= 0 && cellX < grid.cols && cellY >= 0 && cellY < grid.rows ) { this.mouseCellX = cellX; this.mouseCellY = cellY; const cell = grid.cells[cellX][cellY]; if (cell.alive) { this.createRippleEffect(grid, cellX, cellY); } else { this.spawnCellAtPosition(grid, cellX, cellY); } } } handleMouseUp(): void { this.mouseIsDown = false; } handleMouseLeave(): void { this.mouseIsDown = false; this.mouseX = -1000; this.mouseY = -1000; } updatePalette(palette: [number, number, number][], bgColor: string): void { this.palette = palette; this.bgColor = bgColor; if (this.grid) { 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]; if (cell.alive && cell.opacity > 0.01) { cell.baseColor = palette[(i * grid.rows + j) % palette.length]; } } } } } }