diff --git a/src/src/components/animation-switcher/index.tsx b/src/src/components/animation-switcher/index.tsx new file mode 100644 index 0000000..015656d --- /dev/null +++ b/src/src/components/animation-switcher/index.tsx @@ -0,0 +1,58 @@ +import { useState, useEffect, useRef } from "react"; +import { + getStoredAnimationId, + getNextAnimation, + saveAnimation, +} from "@/lib/animations/engine"; +import { ANIMATION_LABELS } from "@/lib/animations"; + +export default function AnimationSwitcher() { + const [hovering, setHovering] = useState(false); + const [nextLabel, setNextLabel] = useState(""); + const committedRef = useRef(""); + + useEffect(() => { + committedRef.current = getStoredAnimationId(); + setNextLabel(ANIMATION_LABELS[getNextAnimation(committedRef.current)]); + + const handleSwap = () => { + const id = getStoredAnimationId(); + committedRef.current = id; + setNextLabel(ANIMATION_LABELS[getNextAnimation(id)]); + }; + + document.addEventListener("astro:after-swap", handleSwap); + return () => { + document.removeEventListener("astro:after-swap", handleSwap); + }; + }, []); + + const handleClick = () => { + const nextId = getNextAnimation( + committedRef.current as Parameters[0] + ); + saveAnimation(nextId); + committedRef.current = nextId; + setNextLabel(ANIMATION_LABELS[getNextAnimation(nextId)]); + document.dispatchEvent( + new CustomEvent("animation-changed", { detail: { id: nextId } }) + ); + }; + + return ( +
setHovering(true)} + onMouseLeave={() => setHovering(false)} + onClick={handleClick} + style={{ cursor: "pointer" }} + > + + {nextLabel} + +
+ ); +} diff --git a/src/src/components/background/engines/game-of-life.ts b/src/src/components/background/engines/game-of-life.ts new file mode 100644 index 0000000..a81cab2 --- /dev/null +++ b/src/src/components/background/engines/game-of-life.ts @@ -0,0 +1,615 @@ +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; + + 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); + } + } + } + + update(deltaTime: number): void { + if (!this.grid) return; + + 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; + } + + 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) { + 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) 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[Math.floor(Math.random() * palette.length)]; + } + } + } + } + } +} diff --git a/src/src/components/background/engines/lava-lamp.ts b/src/src/components/background/engines/lava-lamp.ts new file mode 100644 index 0000000..2ced51d --- /dev/null +++ b/src/src/components/background/engines/lava-lamp.ts @@ -0,0 +1,498 @@ +import type { AnimationEngine } from "@/lib/animations/types"; + +interface Blob { + x: number; + y: number; + vx: number; + vy: number; + baseRadius: number; + radiusScale: number; + targetRadiusScale: number; + color: [number, number, number]; + targetColor: [number, number, number]; + phase: number; + phaseSpeed: number; + staggerDelay: number; // -1 means already revealed +} + +const BLOB_COUNT = 26; +const BASE_MAX_BLOBS = 80; // at 1080p; scales with canvas area +const MIN_SPEED = 0.1; +const MAX_SPEED = 0.35; +const RESOLUTION_SCALE = 5; // render at 1/5 resolution (was 1/4) +const FIELD_THRESHOLD = 1.0; +const SMOOTHSTEP_RANGE = 0.25; +const MOUSE_REPEL_RADIUS = 150; +const MOUSE_REPEL_FORCE = 0.2; +const COLOR_LERP_SPEED = 0.02; +const DRIFT_AMPLITUDE = 0.2; +const RADIUS_LERP_SPEED = 0.06; +const STAGGER_INTERVAL = 60; +const CYCLE_MIN_MS = 2000; // min time between natural spawn/despawn +const CYCLE_MAX_MS = 5000; // max time + +function smoothstep(edge0: number, edge1: number, x: number): number { + const t = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0))); + return t * t * (3 - 2 * t); +} + +export class LavaLampEngine implements AnimationEngine { + id = "lava-lamp"; + name = "Lava Lamp"; + + private blobs: Blob[] = []; + private palette: [number, number, number][] = []; + private bgRgb: [number, number, number] = [0, 0, 0]; + private width = 0; + private height = 0; + private mouseX = -1000; + private mouseY = -1000; + private offCanvas: HTMLCanvasElement | null = null; + private offCtx: CanvasRenderingContext2D | null = null; + private shadowCanvas: HTMLCanvasElement | null = null; + private shadowCtx: CanvasRenderingContext2D | null = null; + private elapsed = 0; + private nextCycleTime = 0; + + // Pre-allocated typed arrays for the inner render loop (avoid per-frame GC) + private blobX: Float64Array = new Float64Array(0); + private blobY: Float64Array = new Float64Array(0); + private blobR: Float64Array = new Float64Array(0); + private blobCR: Float64Array = new Float64Array(0); + private blobCG: Float64Array = new Float64Array(0); + private blobCB: Float64Array = new Float64Array(0); + private activeBlobCount = 0; + + init( + width: number, + height: number, + palette: [number, number, number][], + bgColor: string + ): void { + this.width = width; + this.height = height; + this.palette = palette; + this.parseBgColor(bgColor); + this.elapsed = 0; + this.nextCycleTime = CYCLE_MIN_MS + Math.random() * (CYCLE_MAX_MS - CYCLE_MIN_MS); + this.initBlobs(); + this.initOffscreenCanvas(); + } + + private parseBgColor(bgColor: string): void { + const match = bgColor.match(/(\d+)/g); + if (match && match.length >= 3) { + this.bgRgb = [parseInt(match[0]), parseInt(match[1]), parseInt(match[2])]; + } + } + + private getMaxBlobs(): number { + const area = this.width * this.height; + const scale = area / 2_073_600; // normalize to 1080p + return Math.max(BASE_MAX_BLOBS, Math.round(BASE_MAX_BLOBS * scale)); + } + + private getRadiusRange(): { min: number; max: number } { + const area = this.width * this.height; + const scale = Math.sqrt(area / 2_073_600); + const min = Math.max(8, Math.round(25 * scale)); + const max = Math.max(15, Math.round(65 * scale)); + return { min, max }; + } + + private makeBlob(x: number, y: number, radiusOverride?: number): Blob { + const { min, max } = this.getRadiusRange(); + const color = this.palette[ + Math.floor(Math.random() * this.palette.length) + ] || [128, 128, 128]; + return { + x, + y, + vx: (Math.random() - 0.5) * 2 * MAX_SPEED, + vy: (Math.random() - 0.5) * 2 * MAX_SPEED, + baseRadius: radiusOverride ?? (min + Math.random() * (max - min)), + radiusScale: 0, + targetRadiusScale: 1, + color: [...color], + targetColor: [...color], + phase: Math.random() * Math.PI * 2, + phaseSpeed: 0.0005 + Math.random() * 0.001, + staggerDelay: -1, + }; + } + + private initBlobs(): void { + this.blobs = []; + const { max } = this.getRadiusRange(); + const minDist = max * 2.5; // minimum distance between blob centers + + for (let i = 0; i < BLOB_COUNT; i++) { + let x: number, y: number; + let attempts = 0; + + // Try to find a position that doesn't overlap existing blobs + do { + x = Math.random() * this.width; + y = Math.random() * this.height; + attempts++; + } while (attempts < 50 && this.tooCloseToExisting(x, y, minDist)); + + const blob = this.makeBlob(x, y); + blob.targetRadiusScale = 0; + blob.staggerDelay = i * STAGGER_INTERVAL + Math.random() * STAGGER_INTERVAL; + this.blobs.push(blob); + } + } + + private tooCloseToExisting(x: number, y: number, minDist: number): boolean { + for (const blob of this.blobs) { + const dx = blob.x - x; + const dy = blob.y - y; + if (dx * dx + dy * dy < minDist * minDist) return true; + } + return false; + } + + private initOffscreenCanvas(): void { + const rw = Math.ceil(this.width / RESOLUTION_SCALE); + const rh = Math.ceil(this.height / RESOLUTION_SCALE); + + this.offCanvas = document.createElement("canvas"); + this.offCanvas.width = rw; + this.offCanvas.height = rh; + this.offCtx = this.offCanvas.getContext("2d", { willReadFrequently: true }); + + this.shadowCanvas = document.createElement("canvas"); + this.shadowCanvas.width = rw; + this.shadowCanvas.height = rh; + this.shadowCtx = this.shadowCanvas.getContext("2d", { + willReadFrequently: true, + }); + } + + cleanup(): void { + this.blobs = []; + this.offCanvas = null; + this.offCtx = null; + this.shadowCanvas = null; + this.shadowCtx = null; + } + + /** Snapshot active blob data into flat typed arrays for fast inner-loop access */ + private syncBlobArrays(): void { + const blobs = this.blobs; + const n = blobs.length; + + // Grow arrays if needed + if (this.blobX.length < n) { + const cap = n + 32; + this.blobX = new Float64Array(cap); + this.blobY = new Float64Array(cap); + this.blobR = new Float64Array(cap); + this.blobCR = new Float64Array(cap); + this.blobCG = new Float64Array(cap); + this.blobCB = new Float64Array(cap); + } + + let count = 0; + for (let i = 0; i < n; i++) { + const b = blobs[i]; + const r = b.baseRadius * b.radiusScale; + if (r < 1) continue; // skip invisible blobs entirely + this.blobX[count] = b.x; + this.blobY[count] = b.y; + this.blobR[count] = r; + this.blobCR[count] = b.color[0]; + this.blobCG[count] = b.color[1]; + this.blobCB[count] = b.color[2]; + count++; + } + this.activeBlobCount = count; + } + + update(deltaTime: number): void { + const dt = deltaTime / (1000 / 60); + this.elapsed += deltaTime; + + for (const blob of this.blobs) { + // Staggered load-in + if (blob.staggerDelay >= 0) { + if (this.elapsed >= blob.staggerDelay) { + blob.targetRadiusScale = 1; + blob.staggerDelay = -1; + } + } + + blob.radiusScale += + (blob.targetRadiusScale - blob.radiusScale) * RADIUS_LERP_SPEED * dt; + + blob.phase += blob.phaseSpeed * deltaTime; + const driftX = Math.sin(blob.phase) * DRIFT_AMPLITUDE; + const driftY = Math.cos(blob.phase * 0.7) * DRIFT_AMPLITUDE; + + blob.vx += driftX * dt * 0.01; + blob.vy += driftY * dt * 0.01; + blob.vx += (Math.random() - 0.5) * 0.008 * dt; + blob.vy += (Math.random() - 0.5) * 0.008 * dt; + + const speed = Math.sqrt(blob.vx * blob.vx + blob.vy * blob.vy); + if (speed > MAX_SPEED) { + blob.vx = (blob.vx / speed) * MAX_SPEED; + blob.vy = (blob.vy / speed) * MAX_SPEED; + } + if (speed < MIN_SPEED) { + const angle = Math.atan2(blob.vy, blob.vx); + blob.vx = Math.cos(angle) * MIN_SPEED; + blob.vy = Math.sin(angle) * MIN_SPEED; + } + + blob.x += blob.vx * dt; + blob.y += blob.vy * dt; + + const pad = blob.baseRadius * 0.3; + if (blob.x < -pad) { blob.x = -pad; blob.vx = Math.abs(blob.vx) * 0.8; } + if (blob.x > this.width + pad) { blob.x = this.width + pad; blob.vx = -Math.abs(blob.vx) * 0.8; } + if (blob.y < -pad) { blob.y = -pad; blob.vy = Math.abs(blob.vy) * 0.8; } + if (blob.y > this.height + pad) { blob.y = this.height + pad; blob.vy = -Math.abs(blob.vy) * 0.8; } + + // Mouse repulsion + const dx = blob.x - this.mouseX; + const dy = blob.y - this.mouseY; + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist < MOUSE_REPEL_RADIUS && dist > 0) { + const force = (1 - dist / MOUSE_REPEL_RADIUS) * MOUSE_REPEL_FORCE * dt; + blob.vx += (dx / dist) * force; + blob.vy += (dy / dist) * force; + } + + for (let c = 0; c < 3; c++) { + blob.color[c] += (blob.targetColor[c] - blob.color[c]) * COLOR_LERP_SPEED * dt; + } + } + + // Remove blobs that have fully shrunk away (but not ones still waiting to stagger in) + for (let i = this.blobs.length - 1; i >= 0; i--) { + const b = this.blobs[i]; + if (b.targetRadiusScale === 0 && b.radiusScale < 0.01 && b.staggerDelay < 0) { + this.blobs.splice(i, 1); + } + } + + // Natural spawn/despawn cycle — keeps the scene alive + if (this.elapsed >= this.nextCycleTime) { + // Pick a random visible blob to fade out (skip ones still staggering in) + const visible = []; + for (let i = 0; i < this.blobs.length; i++) { + if (this.blobs[i].radiusScale > 0.5 && this.blobs[i].staggerDelay < 0) { + visible.push(i); + } + } + if (visible.length > 0) { + const killIdx = visible[Math.floor(Math.random() * visible.length)]; + this.blobs[killIdx].targetRadiusScale = 0; + } + + // Spawn a fresh one at a random position + const blob = this.makeBlob( + Math.random() * this.width, + Math.random() * this.height + ); + this.blobs.push(blob); + + // Schedule next cycle + this.nextCycleTime = this.elapsed + CYCLE_MIN_MS + Math.random() * (CYCLE_MAX_MS - CYCLE_MIN_MS); + } + + // Prune excess blobs (keep the initial set, drop oldest user-spawned ones) + const maxBlobs = this.getMaxBlobs(); + if (this.blobs.length > maxBlobs) { + this.blobs.splice(BLOB_COUNT, this.blobs.length - maxBlobs); + } + } + + render( + ctx: CanvasRenderingContext2D, + width: number, + height: number + ): void { + if (!this.offCtx || !this.offCanvas || !this.shadowCtx || !this.shadowCanvas) + return; + + // Snapshot blob positions/radii into typed arrays for fast pixel loop + this.syncBlobArrays(); + + const rw = this.offCanvas.width; + const rh = this.offCanvas.height; + + // Render shadow layer + const shadowData = this.shadowCtx.createImageData(rw, rh); + this.renderField(shadowData, rw, rh, true); + this.shadowCtx.putImageData(shadowData, 0, 0); + + // Render main layer + const imageData = this.offCtx.createImageData(rw, rh); + this.renderField(imageData, rw, rh, false); + this.offCtx.putImageData(imageData, 0, 0); + + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = "medium"; + + ctx.globalAlpha = 0.2; + ctx.drawImage(this.shadowCanvas, 0, 4, width, height); + + ctx.globalAlpha = 1; + ctx.drawImage(this.offCanvas, 0, 0, width, height); + } + + private renderField( + imageData: ImageData, + rw: number, + rh: number, + isShadow: boolean + ): void { + const data = imageData.data; + const threshold = isShadow ? FIELD_THRESHOLD * 0.75 : FIELD_THRESHOLD; + const bgR = this.bgRgb[0]; + const bgG = this.bgRgb[1]; + const bgB = this.bgRgb[2]; + const scale = RESOLUTION_SCALE; + const n = this.activeBlobCount; + const bx = this.blobX; + const by = this.blobY; + const br = this.blobR; + const bcr = this.blobCR; + const bcg = this.blobCG; + const bcb = this.blobCB; + const threshLow = threshold - SMOOTHSTEP_RANGE; + + for (let py = 0; py < rh; py++) { + const wy = py * scale; + for (let px = 0; px < rw; px++) { + const wx = px * scale; + + let fieldSum = 0; + let weightedR = 0; + let weightedG = 0; + let weightedB = 0; + + for (let i = 0; i < n; i++) { + const dx = wx - bx[i]; + const dy = wy - by[i]; + const distSq = dx * dx + dy * dy; + const ri = br[i]; + const rSq = ri * ri; + // Raw metaball field + const raw = rSq / (distSq + rSq * 0.1); + // Cap per-blob contribution so color stays flat inside the blob + const contribution = raw > 2 ? 2 : raw; + + fieldSum += contribution; + + if (contribution > 0.01) { + weightedR += bcr[i] * contribution; + weightedG += bcg[i] * contribution; + weightedB += bcb[i] * contribution; + } + } + + const idx = (py * rw + px) << 2; + + if (fieldSum > threshLow) { + const alpha = smoothstep(threshLow, threshold, fieldSum); + + if (isShadow) { + data[idx] = 0; + data[idx + 1] = 0; + data[idx + 2] = 0; + data[idx + 3] = (alpha * 150) | 0; + } else { + const invField = 1 / fieldSum; + const r = Math.min(255, (weightedR * invField) | 0); + const g = Math.min(255, (weightedG * invField) | 0); + const b = Math.min(255, (weightedB * invField) | 0); + + data[idx] = bgR + (r - bgR) * alpha; + data[idx + 1] = bgG + (g - bgG) * alpha; + data[idx + 2] = bgB + (b - bgB) * alpha; + data[idx + 3] = 255; + } + } else { + if (isShadow) { + // data stays 0 (already zeroed by createImageData) + } else { + data[idx] = bgR; + data[idx + 1] = bgG; + data[idx + 2] = bgB; + data[idx + 3] = 255; + } + } + } + } + } + + handleResize(width: number, height: number): void { + this.width = width; + this.height = height; + 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 { + let closest: Blob | null = null; + let closestDist = Infinity; + + for (const blob of this.blobs) { + const dx = blob.x - x; + const dy = blob.y - y; + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist < blob.baseRadius * 1.5 && dist < closestDist) { + closestDist = dist; + closest = blob; + } + } + + return closest ? ([...closest.color] as [number, number, number]) : null; + } + + private spawnAt(x: number, y: number): void { + const { max } = this.getRadiusRange(); + const blob = this.makeBlob(x, y, max * (0.8 + Math.random() * 0.4)); + const nearby = this.sampleColorAt(x, y); + if (nearby) { + blob.color = nearby; + blob.targetColor = [...nearby]; + } + this.blobs.push(blob); + } + + handleMouseMove(x: number, y: number, _isDown: boolean): void { + this.mouseX = x; + this.mouseY = y; + } + + handleMouseDown(x: number, y: number): void { + this.spawnAt(x, y); + } + + handleMouseUp(): void {} + + handleMouseLeave(): void { + this.mouseX = -1000; + this.mouseY = -1000; + } + + updatePalette(palette: [number, number, number][], bgColor: string): void { + this.palette = palette; + this.parseBgColor(bgColor); + + for (let i = 0; i < this.blobs.length; i++) { + this.blobs[i].targetColor = [ + ...palette[i % palette.length], + ] as [number, number, number]; + } + } +} diff --git a/src/src/components/background/index.tsx b/src/src/components/background/index.tsx index e8c23ce..36afcf6 100644 --- a/src/src/components/background/index.tsx +++ b/src/src/components/background/index.tsx @@ -1,74 +1,34 @@ import { useEffect, useRef } from "react"; +import { GameOfLifeEngine } from "./engines/game-of-life"; +import { LavaLampEngine } from "./engines/lava-lamp"; +import { getStoredAnimationId } from "@/lib/animations/engine"; +import type { AnimationEngine } from "@/lib/animations/types"; +import type { AnimationId } from "@/lib/animations"; - -interface Cell { - alive: boolean; - next: boolean; - color: [number, number, number]; - baseColor: [number, number, number]; // Original color - currentX: number; - currentY: number; - targetX: number; - targetY: number; - opacity: number; - targetOpacity: number; - scale: number; - targetScale: number; - elevation: number; // For 3D effect - targetElevation: number; - transitioning: boolean; - transitionComplete: boolean; - rippleEffect: number; // For ripple animation - rippleStartTime: number; // When ripple started - rippleDistance: number; // Distance from ripple center -} - -interface Grid { - cells: Cell[][]; - cols: number; - rows: number; - offsetX: number; - offsetY: number; -} - -interface MousePosition { - x: number; - y: number; - isDown: boolean; - lastClickTime: number; - cellX: number; - cellY: number; -} - -interface BackgroundProps { - layout?: 'index' | 'sidebar'; - position?: 'left' | 'right'; -} - -const CELL_SIZE_MOBILE = 15; -const CELL_SIZE_DESKTOP = 25; -const TARGET_FPS = 60; // Target frame rate -const CYCLE_TIME = 3000; // 3 seconds per full cycle, regardless of FPS -const TRANSITION_SPEED = 0.05; -const SCALE_SPEED = 0.05; -const INITIAL_DENSITY = 0.15; const SIDEBAR_WIDTH = 240; -const MOUSE_INFLUENCE_RADIUS = 150; // Radius of mouse influence in pixels -const COLOR_SHIFT_AMOUNT = 30; // Maximum color shift amount -const RIPPLE_SPEED = 0.02; // Speed of ripple propagation -const RIPPLE_ELEVATION_FACTOR = 4; // Height of ripple wave -const ELEVATION_FACTOR = 8; // Max height for 3D effect - reduced for more subtle effect const FALLBACK_PALETTE: [number, number, number][] = [ [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], ]; -// Read palette from current CSS variables +function createEngine(id: AnimationId): AnimationEngine { + switch (id) { + case "lava-lamp": + return new LavaLampEngine(); + case "game-of-life": + default: + return new GameOfLifeEngine(); + } +} + function readPaletteFromCSS(): [number, number, number][] { try { const style = getComputedStyle(document.documentElement); - const keys = ["--color-red", "--color-green", "--color-yellow", "--color-blue", "--color-purple", "--color-aqua"]; + const keys = [ + "--color-red", "--color-green", "--color-yellow", + "--color-blue", "--color-purple", "--color-aqua", + ]; const palette: [number, number, number][] = []; for (const key of keys) { const val = style.getPropertyValue(key).trim(); @@ -87,7 +47,9 @@ function readPaletteFromCSS(): [number, number, number][] { function readBgFromCSS(): string { try { - const val = getComputedStyle(document.documentElement).getPropertyValue("--color-background").trim(); + const val = getComputedStyle(document.documentElement) + .getPropertyValue("--color-background") + .trim(); if (val) { const [r, g, b] = val.split(" "); return `rgb(${r}, ${g}, ${b})`; @@ -96,421 +58,38 @@ function readBgFromCSS(): string { return "rgb(0, 0, 0)"; } +interface BackgroundProps { + layout?: "index" | "sidebar" | "content"; + position?: "left" | "right"; +} + const Background: React.FC = ({ - layout = 'index', - position = 'left' + layout = "index", + position = "left", }) => { const canvasRef = useRef(null); - const gridRef = useRef(); + const engineRef = useRef(null); const animationFrameRef = useRef(); const lastUpdateTimeRef = useRef(0); - const lastCycleTimeRef = useRef(0); const resizeTimeoutRef = useRef(); - const paletteRef = useRef<[number, number, number][]>(FALLBACK_PALETTE); - const bgColorRef = useRef("rgb(0, 0, 0)"); - const mouseRef = useRef({ - x: -1000, - y: -1000, - isDown: false, - lastClickTime: 0, - cellX: -1, - cellY: -1 - }); + const dimensionsRef = useRef({ width: 0, height: 0 }); - const randomColor = (): [number, number, number] => { - const palette = paletteRef.current; - return palette[Math.floor(Math.random() * palette.length)]; - }; - - const getCellSize = () => { - // Check if we're on mobile based on screen width - const isMobile = window.innerWidth <= 768; - return isMobile ? CELL_SIZE_MOBILE : CELL_SIZE_DESKTOP; - }; - - const calculateGridDimensions = (width: number, height: number) => { - const cellSize = 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); - return { cols, rows, offsetX, offsetY }; - }; - - const initGrid = (width: number, height: number): Grid => { - const { cols, rows, offsetX, offsetY } = calculateGridDimensions(width, height); - - const cells = Array(cols).fill(0).map((_, i) => - Array(rows).fill(0).map((_, j) => { - const baseColor = randomColor(); - return { - alive: Math.random() < INITIAL_DENSITY, - next: false, - color: [...baseColor] as [number, number, number], - baseColor: 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 }; - computeNextState(grid); - - // Initialize cells with staggered animation - 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; - setTimeout(() => { - cell.targetOpacity = 1; - cell.targetScale = 1; - }, Math.random() * 1000); - } else { - cell.alive = false; - } - } - } - - return grid; - }; - - const 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; - }; - - const 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) - ]; - }; - - const computeNextState = (grid: Grid) => { - // First, calculate the next state for all cells based on standard rules - 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 } = countNeighbors(grid, i, j); - - // Standard Conway's Game of Life rules - if (cell.alive) { - cell.next = count === 2 || count === 3; - } else { - cell.next = count === 3; - if (cell.next) { - cell.baseColor = averageColors(colors); - cell.color = [...cell.baseColor]; - } - } - } - } - - // Then, set up animations for cells that need to change state - 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; - - // Random delay for staggered animation effect - const delay = Math.random() * 800; - - setTimeout(() => { - if (!cell.next) { - cell.targetScale = 0; - cell.targetOpacity = 0; - cell.targetElevation = 0; - } else { - cell.targetScale = 1; - cell.targetOpacity = 1; - cell.targetElevation = 0; - } - }, delay); - } - } - } - }; - - const createRippleEffect = (grid: Grid, centerX: number, centerY: number) => { - 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]; - - // Calculate distance from cell to ripple center - const dx = i - centerX; - const dy = j - centerY; - const distance = Math.sqrt(dx * dx + dy * dy); - - // Only apply ripple to visible cells - if (cell.opacity > 0.1) { - cell.rippleStartTime = currentTime + distance * 100; // Delayed start based on distance - cell.rippleDistance = distance; - cell.rippleEffect = 0; - } - } - } - }; - - const spawnCellAtPosition = (grid: Grid, x: number, y: number) => { - 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 = randomColor(); - cell.color = [...cell.baseColor]; - cell.targetScale = 1; - cell.targetOpacity = 1; - cell.targetElevation = 0; - - // Create a small ripple from the new cell - createRippleEffect(grid, x, y); - } - } - }; - - const updateCellAnimations = (grid: Grid, deltaTime: number) => { - const mouseX = mouseRef.current.x; - const mouseY = mouseRef.current.y; - const cellSize = getCellSize(); - - // Adjust transition speeds based on time - 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]; - - // Smooth transitions - cell.opacity += (cell.targetOpacity - cell.opacity) * transitionFactor; - cell.scale += (cell.targetScale - cell.scale) * scaleFactor; - cell.elevation += (cell.targetElevation - cell.elevation) * scaleFactor; - - // Apply mouse interaction - 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); - - // 3D hill effect based on mouse position - if (distanceToMouse < MOUSE_INFLUENCE_RADIUS && cell.opacity > 0.1) { - // Calculate height based on distance - peak at center, gradually decreasing - const influenceFactor = Math.cos((distanceToMouse / MOUSE_INFLUENCE_RADIUS) * Math.PI / 2); - // Only positive elevation (growing upward) - cell.targetElevation = ELEVATION_FACTOR * influenceFactor * influenceFactor; // squared for more pronounced effect - - // Slight color shift as cells rise - 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 { - // Gradually return to base color and zero elevation when mouse is away - 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; - } - - // Handle cell state transitions - if (cell.transitioning) { - // When a cell is completely faded out, update its alive state - 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; - } - // When a new cell is born - else if (cell.next && !cell.alive && !cell.transitionComplete) { - cell.alive = true; - cell.transitioning = false; - cell.transitionComplete = true; - } - } - - // Handle ripple animation - if (cell.rippleStartTime > 0) { - const elapsedTime = Date.now() - cell.rippleStartTime; - if (elapsedTime > 0) { - // Calculate ripple progress (0 to 1) - const rippleProgress = elapsedTime / 1000; // 1 second for full animation - - if (rippleProgress < 1) { - // Create a smooth wave effect - const wavePhase = rippleProgress * Math.PI * 2; - const waveHeight = Math.sin(wavePhase) * Math.exp(-rippleProgress * 4); - - // Apply wave height to cell elevation only if it's not being overridden by mouse - if (distanceToMouse >= MOUSE_INFLUENCE_RADIUS) { - cell.rippleEffect = waveHeight; - cell.targetElevation = RIPPLE_ELEVATION_FACTOR * waveHeight; - } else { - cell.rippleEffect = waveHeight * 0.3; // Reduced effect when mouse is influencing - } - } else { - // Reset ripple effects - cell.rippleEffect = 0; - cell.rippleStartTime = 0; - if (distanceToMouse >= MOUSE_INFLUENCE_RADIUS) { - cell.targetElevation = 0; - } - } - } - } - } - } - }; - - const handleMouseDown = (e: MouseEvent) => { - if (!gridRef.current || !canvasRef.current) return; - - const canvas = canvasRef.current; - const rect = canvas.getBoundingClientRect(); - const mouseX = e.clientX - rect.left; - const mouseY = e.clientY - rect.top; - - // Ignore clicks outside the canvas bounds - if (mouseX < 0 || mouseX > rect.width || mouseY < 0 || mouseY > rect.height) return; - - // Prevent text selection when interacting with the canvas - e.preventDefault(); - - const cellSize = getCellSize(); - - mouseRef.current.isDown = true; - mouseRef.current.lastClickTime = Date.now(); - - const grid = gridRef.current; - - // Calculate which cell was clicked - const cellX = Math.floor((mouseX - grid.offsetX) / cellSize); - const cellY = Math.floor((mouseY - grid.offsetY) / cellSize); - - if (cellX >= 0 && cellX < grid.cols && cellY >= 0 && cellY < grid.rows) { - mouseRef.current.cellX = cellX; - mouseRef.current.cellY = cellY; - - const cell = grid.cells[cellX][cellY]; - - if (cell.alive) { - // Create ripple effect from existing cell - createRippleEffect(grid, cellX, cellY); - } else { - // Spawn new cell at empty position - spawnCellAtPosition(grid, cellX, cellY); - } - } - }; - - const handleMouseMove = (e: MouseEvent) => { - if (!canvasRef.current || !gridRef.current) return; - - const canvas = canvasRef.current; - const rect = canvas.getBoundingClientRect(); - const cellSize = getCellSize(); - - mouseRef.current.x = e.clientX - rect.left; - mouseRef.current.y = e.clientY - rect.top; - - // Drawing functionality - place cells while dragging - if (mouseRef.current.isDown) { - const grid = gridRef.current; - - // Calculate which cell the mouse is over - const cellX = Math.floor((mouseRef.current.x - grid.offsetX) / cellSize); - const cellY = Math.floor((mouseRef.current.y - grid.offsetY) / cellSize); - - // Only draw if we're on a new cell - if (cellX !== mouseRef.current.cellX || cellY !== mouseRef.current.cellY) { - mouseRef.current.cellX = cellX; - mouseRef.current.cellY = cellY; - - // Spawn cell at this position if it's empty - if (cellX >= 0 && cellX < grid.cols && cellY >= 0 && cellY < grid.rows) { - const cell = grid.cells[cellX][cellY]; - if (!cell.alive && !cell.transitioning) { - spawnCellAtPosition(grid, cellX, cellY); - } - } - } - } - }; - - const handleMouseUp = () => { - mouseRef.current.isDown = false; - }; - - const handleMouseLeave = () => { - mouseRef.current.isDown = false; - mouseRef.current.x = -1000; - mouseRef.current.y = -1000; - }; - - const setupCanvas = (canvas: HTMLCanvasElement, width: number, height: number) => { - const ctx = canvas.getContext('2d'); + const setupCanvas = ( + canvas: HTMLCanvasElement, + width: number, + height: number + ) => { + const ctx = canvas.getContext("2d"); if (!ctx) return; const dpr = window.devicePixelRatio || 1; canvas.width = width * dpr; canvas.height = height * dpr; ctx.scale(dpr, dpr); - + canvas.style.width = `${width}px`; canvas.style.height = `${height}px`; - + return ctx; }; @@ -518,223 +97,184 @@ const Background: React.FC = ({ const canvas = canvasRef.current; if (!canvas) return; - // Create an AbortController for cleanup const controller = new AbortController(); const signal = controller.signal; + const displayWidth = + layout === "index" ? window.innerWidth : SIDEBAR_WIDTH; + const displayHeight = window.innerHeight; + dimensionsRef.current = { width: displayWidth, height: displayHeight }; + + const ctx = setupCanvas(canvas, displayWidth, displayHeight); + if (!ctx) return; + + const palette = readPaletteFromCSS(); + const bgColor = readBgFromCSS(); + + // Initialize engine + if (!engineRef.current) { + const animId = getStoredAnimationId(); + engineRef.current = createEngine(animId); + engineRef.current.init(displayWidth, displayHeight, palette, bgColor); + } + + // Handle animation switching + const handleAnimationChanged = (e: Event) => { + const detail = (e as CustomEvent).detail; + if (!detail?.id) return; + + if (engineRef.current) { + engineRef.current.cleanup(); + } + + const w = layout === "index" ? window.innerWidth : SIDEBAR_WIDTH; + const h = window.innerHeight; + engineRef.current = createEngine(detail.id); + engineRef.current.init(w, h, readPaletteFromCSS(), readBgFromCSS()); + }; + + document.addEventListener("animation-changed", handleAnimationChanged, { + signal, + }); + + // Handle theme changes + const handleThemeChanged = () => { + const newPalette = readPaletteFromCSS(); + const newBg = readBgFromCSS(); + if (engineRef.current) { + engineRef.current.updatePalette(newPalette, newBg); + } + }; + + document.addEventListener("theme-changed", handleThemeChanged, { signal }); + + // Handle resize const handleResize = () => { if (signal.aborted) return; - + if (resizeTimeoutRef.current) { clearTimeout(resizeTimeoutRef.current); } resizeTimeoutRef.current = setTimeout(() => { if (signal.aborted) return; - - const displayWidth = layout === 'index' ? window.innerWidth : SIDEBAR_WIDTH; - const displayHeight = window.innerHeight; - const ctx = setupCanvas(canvas, displayWidth, displayHeight); - if (!ctx) return; + const w = layout === "index" ? window.innerWidth : SIDEBAR_WIDTH; + const h = window.innerHeight; + + const newCtx = setupCanvas(canvas, w, h); + if (!newCtx) return; lastUpdateTimeRef.current = 0; - lastCycleTimeRef.current = 0; - - const cellSize = getCellSize(); - - // Only initialize new grid if one doesn't exist or dimensions changed - if (!gridRef.current || - gridRef.current.cols !== Math.floor(displayWidth / cellSize) || - gridRef.current.rows !== Math.floor(displayHeight / cellSize)) { - gridRef.current = initGrid(displayWidth, displayHeight); - } + dimensionsRef.current = { width: w, height: h }; + + if (engineRef.current) { + engineRef.current.handleResize(w, h); + } }, 250); }; - const displayWidth = layout === 'index' ? window.innerWidth : SIDEBAR_WIDTH; - const displayHeight = window.innerHeight; - - const ctx = setupCanvas(canvas, displayWidth, displayHeight); - if (!ctx) return; + // Mouse events + const handleMouseDown = (e: MouseEvent) => { + if (!engineRef.current || !canvas) return; - // Only initialize grid if it doesn't exist - if (!gridRef.current) { - gridRef.current = initGrid(displayWidth, displayHeight); - } + // Don't spawn when clicking interactive elements + const target = e.target as HTMLElement; + if (target.closest("a, button, [role='button'], input, select, textarea, [onclick]")) return; - // Bind to window so mouse events work even when content overlays the canvas - window.addEventListener('mousedown', handleMouseDown, { signal }); - window.addEventListener('mousemove', handleMouseMove, { signal }); - window.addEventListener('mouseup', handleMouseUp, { signal }); + const rect = canvas.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + const mouseY = e.clientY - rect.top; - // Read theme colors from CSS variables - paletteRef.current = readPaletteFromCSS(); - bgColorRef.current = readBgFromCSS(); + if ( + mouseX < 0 || + mouseX > rect.width || + mouseY < 0 || + mouseY > rect.height + ) + return; - // Listen for theme changes and update colors - const handleThemeChanged = () => { - paletteRef.current = readPaletteFromCSS(); - bgColorRef.current = readBgFromCSS(); + e.preventDefault(); + engineRef.current.handleMouseDown(mouseX, mouseY); + }; - if (gridRef.current) { - const grid = gridRef.current; - const palette = paletteRef.current; - 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[Math.floor(Math.random() * palette.length)]; - } - } - } + const handleMouseMove = (e: MouseEvent) => { + if (!engineRef.current || !canvas) return; + + const rect = canvas.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + const mouseY = e.clientY - rect.top; + + engineRef.current.handleMouseMove(mouseX, mouseY, e.buttons === 1); + }; + + const handleMouseUp = () => { + if (engineRef.current) { + engineRef.current.handleMouseUp(); } }; - document.addEventListener("theme-changed", handleThemeChanged, { signal }); + const handleMouseLeave = () => { + if (engineRef.current) { + engineRef.current.handleMouseLeave(); + } + }; + window.addEventListener("mousedown", handleMouseDown, { signal }); + window.addEventListener("mousemove", handleMouseMove, { signal }); + window.addEventListener("mouseup", handleMouseUp, { signal }); + + // Visibility change const handleVisibilityChange = () => { if (document.hidden) { - // Tab is hidden if (animationFrameRef.current) { cancelAnimationFrame(animationFrameRef.current); animationFrameRef.current = undefined; } } else { - // Tab is visible again if (!animationFrameRef.current) { - // Reset timing references to prevent catching up lastUpdateTimeRef.current = performance.now(); - lastCycleTimeRef.current = performance.now(); animationFrameRef.current = requestAnimationFrame(animate); } } }; + // Animation loop const animate = (currentTime: number) => { if (signal.aborted) return; - - // Initialize timing if first frame + if (!lastUpdateTimeRef.current) { lastUpdateTimeRef.current = currentTime; - lastCycleTimeRef.current = currentTime; } - - // Calculate time since last frame + const deltaTime = currentTime - lastUpdateTimeRef.current; - - // Limit delta time to prevent large jumps when tab becomes active again const clampedDeltaTime = Math.min(deltaTime, 100); - lastUpdateTimeRef.current = currentTime; - - // Calculate time since last cycle update - const cycleElapsed = currentTime - lastCycleTimeRef.current; - - if (gridRef.current) { - // Check if it's time for the next life cycle - if (cycleElapsed >= CYCLE_TIME) { - computeNextState(gridRef.current); - lastCycleTimeRef.current = currentTime; - } - - updateCellAnimations(gridRef.current, clampedDeltaTime); - } - - // Draw frame - ctx.fillStyle = bgColorRef.current; - ctx.fillRect(0, 0, canvas.width, canvas.height); - if (gridRef.current) { - const grid = gridRef.current; - const cellSize = getCellSize(); - const displayCellSize = cellSize * 0.8; - const roundness = displayCellSize * 0.2; + const engine = engineRef.current; + if (engine) { + engine.update(clampedDeltaTime); - for (let i = 0; i < grid.cols; i++) { - for (let j = 0; j < grid.rows; j++) { - const cell = grid.cells[i][j]; - // Draw all transitioning cells, even if they're fading out - if ((cell.alive || cell.targetOpacity > 0) && cell.opacity > 0.01) { - const [r, g, b] = cell.color; - - // Base opacity - ctx.globalAlpha = cell.opacity * 0.9; + // Clear canvas + const bg = readBgFromCSS(); + ctx.fillStyle = bg; + ctx.fillRect(0, 0, canvas.width, canvas.height); - const scaledSize = displayCellSize * cell.scale; - const xOffset = (displayCellSize - scaledSize) / 2; - const yOffset = (displayCellSize - scaledSize) / 2; - - // Apply 3D elevation effect - 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; - - // Draw shadow for 3D effect when cell is elevated - 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(); - } - - // Draw 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(); - - // Draw 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(); - } - - // No need for separate ripple drawing since the elevation handles the 3D ripple effect - } - } - } - - ctx.globalAlpha = 1; + const { width: rw, height: rh } = dimensionsRef.current; + engine.render(ctx, rw, rh); } animationFrameRef.current = requestAnimationFrame(animate); }; - document.addEventListener('visibilitychange', handleVisibilityChange, { signal }); - window.addEventListener('resize', handleResize, { signal }); + document.addEventListener("visibilitychange", handleVisibilityChange, { + signal, + }); + window.addEventListener("resize", handleResize, { signal }); animate(performance.now()); return () => { controller.abort(); - document.removeEventListener('visibilitychange', handleVisibilityChange); - window.removeEventListener('resize', handleResize); if (animationFrameRef.current) { cancelAnimationFrame(animationFrameRef.current); } @@ -742,25 +282,43 @@ const Background: React.FC = ({ clearTimeout(resizeTimeoutRef.current); } }; - }, [layout]); // Added layout as a dependency since it's used in the effect + }, [layout]); + + const isIndex = layout === "index"; + const isSidebar = !isIndex; + + const getContainerStyle = (): React.CSSProperties => { + if (isIndex) return {}; + // Fade the inner edge so blobs don't hard-cut at the content boundary + return { + maskImage: + position === "left" + ? "linear-gradient(to right, black 60%, transparent 100%)" + : "linear-gradient(to left, black 60%, transparent 100%)", + WebkitMaskImage: + position === "left" + ? "linear-gradient(to right, black 60%, transparent 100%)" + : "linear-gradient(to left, black 60%, transparent 100%)", + }; + }; const getContainerClasses = () => { - if (layout === 'index') { - return 'fixed inset-0 -z-10'; + if (isIndex) { + return "fixed inset-0 -z-10"; } - - const baseClasses = 'fixed top-0 bottom-0 hidden lg:block -z-10'; - return position === 'left' - ? `${baseClasses} left-0` + + const baseClasses = "fixed top-0 bottom-0 hidden lg:block -z-10"; + return position === "left" + ? `${baseClasses} left-0` : `${baseClasses} right-0`; }; return ( -
+
diff --git a/src/src/layouts/content.astro b/src/src/layouts/content.astro index 959b8f1..ced33e5 100644 --- a/src/src/layouts/content.astro +++ b/src/src/layouts/content.astro @@ -6,7 +6,9 @@ import Header from "@/components/header"; import Footer from "@/components/footer"; import Background from "@/components/background"; import ThemeSwitcher from "@/components/theme-switcher"; +import AnimationSwitcher from "@/components/animation-switcher"; import { THEME_LOADER_SCRIPT, THEME_NAV_SCRIPT } from "@/lib/themes/loader"; +import { ANIMATION_LOADER_SCRIPT, ANIMATION_NAV_SCRIPT } from "@/lib/animations/loader"; export interface Props { title: string; @@ -48,6 +50,7 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg"; }