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; private exiting = false; // 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, }); } beginExit(): void { if (this.exiting) return; this.exiting = true; for (let i = 0; i < this.blobs.length; i++) { const blob = this.blobs[i]; if (blob.staggerDelay >= 0) { blob.staggerDelay = -1; } // Stagger the shrink over ~2 seconds setTimeout(() => { blob.targetRadiusScale = 0; }, Math.random() * 2000); } } isExitComplete(): boolean { if (!this.exiting) return false; return this.blobs.length === 0; } cleanup(): void { this.blobs = []; this.offCanvas = null; 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.exiting && this.elapsed >= this.nextCycleTime) { // Pick a random visible blob to fade out (skip ones still staggering in) const visible = []; for (let i = 0; i < this.blobs.length; i++) { 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.elapsed = 0; this.exiting = false; this.nextCycleTime = CYCLE_MIN_MS + Math.random() * (CYCLE_MAX_MS - CYCLE_MIN_MS); this.initBlobs(); this.initOffscreenCanvas(); } 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 { if (this.exiting) return; 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]; } } }