mirror of
https://github.com/timmypidashev/web.git
synced 2026-04-14 11:03:50 +00:00
521 lines
16 KiB
TypeScript
521 lines
16 KiB
TypeScript
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];
|
|
}
|
|
}
|
|
}
|