mirror of
https://github.com/timmypidashev/web.git
synced 2026-04-14 02:53:51 +00:00
Add lavalamp animation
This commit is contained in:
58
src/src/components/animation-switcher/index.tsx
Normal file
58
src/src/components/animation-switcher/index.tsx
Normal file
@@ -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<typeof getNextAnimation>[0]
|
||||||
|
);
|
||||||
|
saveAnimation(nextId);
|
||||||
|
committedRef.current = nextId;
|
||||||
|
setNextLabel(ANIMATION_LABELS[getNextAnimation(nextId)]);
|
||||||
|
document.dispatchEvent(
|
||||||
|
new CustomEvent("animation-changed", { detail: { id: nextId } })
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed bottom-4 left-4 z-[101] pointer-events-auto hidden md:block"
|
||||||
|
onMouseEnter={() => setHovering(true)}
|
||||||
|
onMouseLeave={() => setHovering(false)}
|
||||||
|
onClick={handleClick}
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="text-foreground font-bold text-sm select-none transition-opacity duration-200"
|
||||||
|
style={{ opacity: hovering ? 0.8 : 0.15 }}
|
||||||
|
>
|
||||||
|
{nextLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
615
src/src/components/background/engines/game-of-life.ts
Normal file
615
src/src/components/background/engines/game-of-life.ts
Normal file
@@ -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<typeof setTimeout>[] = [];
|
||||||
|
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)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
498
src/src/components/background/engines/lava-lamp.ts
Normal file
498
src/src/components/background/engines/lava-lamp.ts
Normal file
@@ -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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,74 +1,34 @@
|
|||||||
import { useEffect, useRef } from "react";
|
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 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][] = [
|
const FALLBACK_PALETTE: [number, number, number][] = [
|
||||||
[204, 36, 29], [152, 151, 26], [215, 153, 33],
|
[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][] {
|
function readPaletteFromCSS(): [number, number, number][] {
|
||||||
try {
|
try {
|
||||||
const style = getComputedStyle(document.documentElement);
|
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][] = [];
|
const palette: [number, number, number][] = [];
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
const val = style.getPropertyValue(key).trim();
|
const val = style.getPropertyValue(key).trim();
|
||||||
@@ -87,7 +47,9 @@ function readPaletteFromCSS(): [number, number, number][] {
|
|||||||
|
|
||||||
function readBgFromCSS(): string {
|
function readBgFromCSS(): string {
|
||||||
try {
|
try {
|
||||||
const val = getComputedStyle(document.documentElement).getPropertyValue("--color-background").trim();
|
const val = getComputedStyle(document.documentElement)
|
||||||
|
.getPropertyValue("--color-background")
|
||||||
|
.trim();
|
||||||
if (val) {
|
if (val) {
|
||||||
const [r, g, b] = val.split(" ");
|
const [r, g, b] = val.split(" ");
|
||||||
return `rgb(${r}, ${g}, ${b})`;
|
return `rgb(${r}, ${g}, ${b})`;
|
||||||
@@ -96,411 +58,28 @@ function readBgFromCSS(): string {
|
|||||||
return "rgb(0, 0, 0)";
|
return "rgb(0, 0, 0)";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BackgroundProps {
|
||||||
|
layout?: "index" | "sidebar" | "content";
|
||||||
|
position?: "left" | "right";
|
||||||
|
}
|
||||||
|
|
||||||
const Background: React.FC<BackgroundProps> = ({
|
const Background: React.FC<BackgroundProps> = ({
|
||||||
layout = 'index',
|
layout = "index",
|
||||||
position = 'left'
|
position = "left",
|
||||||
}) => {
|
}) => {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const gridRef = useRef<Grid>();
|
const engineRef = useRef<AnimationEngine | null>(null);
|
||||||
const animationFrameRef = useRef<number>();
|
const animationFrameRef = useRef<number>();
|
||||||
const lastUpdateTimeRef = useRef<number>(0);
|
const lastUpdateTimeRef = useRef<number>(0);
|
||||||
const lastCycleTimeRef = useRef<number>(0);
|
|
||||||
const resizeTimeoutRef = useRef<NodeJS.Timeout>();
|
const resizeTimeoutRef = useRef<NodeJS.Timeout>();
|
||||||
const paletteRef = useRef<[number, number, number][]>(FALLBACK_PALETTE);
|
const dimensionsRef = useRef({ width: 0, height: 0 });
|
||||||
const bgColorRef = useRef<string>("rgb(0, 0, 0)");
|
|
||||||
const mouseRef = useRef<MousePosition>({
|
|
||||||
x: -1000,
|
|
||||||
y: -1000,
|
|
||||||
isDown: false,
|
|
||||||
lastClickTime: 0,
|
|
||||||
cellX: -1,
|
|
||||||
cellY: -1
|
|
||||||
});
|
|
||||||
|
|
||||||
const randomColor = (): [number, number, number] => {
|
const setupCanvas = (
|
||||||
const palette = paletteRef.current;
|
canvas: HTMLCanvasElement,
|
||||||
return palette[Math.floor(Math.random() * palette.length)];
|
width: number,
|
||||||
};
|
height: number
|
||||||
|
) => {
|
||||||
const getCellSize = () => {
|
const ctx = canvas.getContext("2d");
|
||||||
// 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');
|
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
|
||||||
const dpr = window.devicePixelRatio || 1;
|
const dpr = window.devicePixelRatio || 1;
|
||||||
@@ -518,10 +97,58 @@ const Background: React.FC<BackgroundProps> = ({
|
|||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
|
|
||||||
// Create an AbortController for cleanup
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const signal = controller.signal;
|
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 = () => {
|
const handleResize = () => {
|
||||||
if (signal.aborted) return;
|
if (signal.aborted) return;
|
||||||
|
|
||||||
@@ -532,209 +159,122 @@ const Background: React.FC<BackgroundProps> = ({
|
|||||||
resizeTimeoutRef.current = setTimeout(() => {
|
resizeTimeoutRef.current = setTimeout(() => {
|
||||||
if (signal.aborted) return;
|
if (signal.aborted) return;
|
||||||
|
|
||||||
const displayWidth = layout === 'index' ? window.innerWidth : SIDEBAR_WIDTH;
|
const w = layout === "index" ? window.innerWidth : SIDEBAR_WIDTH;
|
||||||
const displayHeight = window.innerHeight;
|
const h = window.innerHeight;
|
||||||
|
|
||||||
const ctx = setupCanvas(canvas, displayWidth, displayHeight);
|
const newCtx = setupCanvas(canvas, w, h);
|
||||||
if (!ctx) return;
|
if (!newCtx) return;
|
||||||
|
|
||||||
lastUpdateTimeRef.current = 0;
|
lastUpdateTimeRef.current = 0;
|
||||||
lastCycleTimeRef.current = 0;
|
dimensionsRef.current = { width: w, height: h };
|
||||||
|
|
||||||
const cellSize = getCellSize();
|
if (engineRef.current) {
|
||||||
|
engineRef.current.handleResize(w, h);
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
}, 250);
|
}, 250);
|
||||||
};
|
};
|
||||||
|
|
||||||
const displayWidth = layout === 'index' ? window.innerWidth : SIDEBAR_WIDTH;
|
// Mouse events
|
||||||
const displayHeight = window.innerHeight;
|
const handleMouseDown = (e: MouseEvent) => {
|
||||||
|
if (!engineRef.current || !canvas) return;
|
||||||
|
|
||||||
const ctx = setupCanvas(canvas, displayWidth, displayHeight);
|
// Don't spawn when clicking interactive elements
|
||||||
if (!ctx) return;
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.closest("a, button, [role='button'], input, select, textarea, [onclick]")) return;
|
||||||
|
|
||||||
// Only initialize grid if it doesn't exist
|
const rect = canvas.getBoundingClientRect();
|
||||||
if (!gridRef.current) {
|
const mouseX = e.clientX - rect.left;
|
||||||
gridRef.current = initGrid(displayWidth, displayHeight);
|
const mouseY = e.clientY - rect.top;
|
||||||
}
|
|
||||||
|
|
||||||
// Bind to window so mouse events work even when content overlays the canvas
|
if (
|
||||||
window.addEventListener('mousedown', handleMouseDown, { signal });
|
mouseX < 0 ||
|
||||||
window.addEventListener('mousemove', handleMouseMove, { signal });
|
mouseX > rect.width ||
|
||||||
window.addEventListener('mouseup', handleMouseUp, { signal });
|
mouseY < 0 ||
|
||||||
|
mouseY > rect.height
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
// Read theme colors from CSS variables
|
e.preventDefault();
|
||||||
paletteRef.current = readPaletteFromCSS();
|
engineRef.current.handleMouseDown(mouseX, mouseY);
|
||||||
bgColorRef.current = readBgFromCSS();
|
};
|
||||||
|
|
||||||
// Listen for theme changes and update colors
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
const handleThemeChanged = () => {
|
if (!engineRef.current || !canvas) return;
|
||||||
paletteRef.current = readPaletteFromCSS();
|
|
||||||
bgColorRef.current = readBgFromCSS();
|
|
||||||
|
|
||||||
if (gridRef.current) {
|
const rect = canvas.getBoundingClientRect();
|
||||||
const grid = gridRef.current;
|
const mouseX = e.clientX - rect.left;
|
||||||
const palette = paletteRef.current;
|
const mouseY = e.clientY - rect.top;
|
||||||
for (let i = 0; i < grid.cols; i++) {
|
|
||||||
for (let j = 0; j < grid.rows; j++) {
|
engineRef.current.handleMouseMove(mouseX, mouseY, e.buttons === 1);
|
||||||
const cell = grid.cells[i][j];
|
};
|
||||||
if (cell.alive && cell.opacity > 0.01) {
|
|
||||||
cell.baseColor = palette[Math.floor(Math.random() * palette.length)];
|
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 = () => {
|
const handleVisibilityChange = () => {
|
||||||
if (document.hidden) {
|
if (document.hidden) {
|
||||||
// Tab is hidden
|
|
||||||
if (animationFrameRef.current) {
|
if (animationFrameRef.current) {
|
||||||
cancelAnimationFrame(animationFrameRef.current);
|
cancelAnimationFrame(animationFrameRef.current);
|
||||||
animationFrameRef.current = undefined;
|
animationFrameRef.current = undefined;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Tab is visible again
|
|
||||||
if (!animationFrameRef.current) {
|
if (!animationFrameRef.current) {
|
||||||
// Reset timing references to prevent catching up
|
|
||||||
lastUpdateTimeRef.current = performance.now();
|
lastUpdateTimeRef.current = performance.now();
|
||||||
lastCycleTimeRef.current = performance.now();
|
|
||||||
animationFrameRef.current = requestAnimationFrame(animate);
|
animationFrameRef.current = requestAnimationFrame(animate);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Animation loop
|
||||||
const animate = (currentTime: number) => {
|
const animate = (currentTime: number) => {
|
||||||
if (signal.aborted) return;
|
if (signal.aborted) return;
|
||||||
|
|
||||||
// Initialize timing if first frame
|
|
||||||
if (!lastUpdateTimeRef.current) {
|
if (!lastUpdateTimeRef.current) {
|
||||||
lastUpdateTimeRef.current = currentTime;
|
lastUpdateTimeRef.current = currentTime;
|
||||||
lastCycleTimeRef.current = currentTime;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate time since last frame
|
|
||||||
const deltaTime = currentTime - lastUpdateTimeRef.current;
|
const deltaTime = currentTime - lastUpdateTimeRef.current;
|
||||||
|
|
||||||
// Limit delta time to prevent large jumps when tab becomes active again
|
|
||||||
const clampedDeltaTime = Math.min(deltaTime, 100);
|
const clampedDeltaTime = Math.min(deltaTime, 100);
|
||||||
|
|
||||||
lastUpdateTimeRef.current = currentTime;
|
lastUpdateTimeRef.current = currentTime;
|
||||||
|
|
||||||
// Calculate time since last cycle update
|
const engine = engineRef.current;
|
||||||
const cycleElapsed = currentTime - lastCycleTimeRef.current;
|
if (engine) {
|
||||||
|
engine.update(clampedDeltaTime);
|
||||||
|
|
||||||
if (gridRef.current) {
|
// Clear canvas
|
||||||
// Check if it's time for the next life cycle
|
const bg = readBgFromCSS();
|
||||||
if (cycleElapsed >= CYCLE_TIME) {
|
ctx.fillStyle = bg;
|
||||||
computeNextState(gridRef.current);
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
lastCycleTimeRef.current = currentTime;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateCellAnimations(gridRef.current, clampedDeltaTime);
|
const { width: rw, height: rh } = dimensionsRef.current;
|
||||||
}
|
engine.render(ctx, rw, rh);
|
||||||
|
|
||||||
// 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;
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
animationFrameRef.current = requestAnimationFrame(animate);
|
animationFrameRef.current = requestAnimationFrame(animate);
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener('visibilitychange', handleVisibilityChange, { signal });
|
document.addEventListener("visibilitychange", handleVisibilityChange, {
|
||||||
window.addEventListener('resize', handleResize, { signal });
|
signal,
|
||||||
|
});
|
||||||
|
window.addEventListener("resize", handleResize, { signal });
|
||||||
animate(performance.now());
|
animate(performance.now());
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
controller.abort();
|
controller.abort();
|
||||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
|
||||||
window.removeEventListener('resize', handleResize);
|
|
||||||
if (animationFrameRef.current) {
|
if (animationFrameRef.current) {
|
||||||
cancelAnimationFrame(animationFrameRef.current);
|
cancelAnimationFrame(animationFrameRef.current);
|
||||||
}
|
}
|
||||||
@@ -742,25 +282,43 @@ const Background: React.FC<BackgroundProps> = ({
|
|||||||
clearTimeout(resizeTimeoutRef.current);
|
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 = () => {
|
const getContainerClasses = () => {
|
||||||
if (layout === 'index') {
|
if (isIndex) {
|
||||||
return 'fixed inset-0 -z-10';
|
return "fixed inset-0 -z-10";
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseClasses = 'fixed top-0 bottom-0 hidden lg:block -z-10';
|
const baseClasses = "fixed top-0 bottom-0 hidden lg:block -z-10";
|
||||||
return position === 'left'
|
return position === "left"
|
||||||
? `${baseClasses} left-0`
|
? `${baseClasses} left-0`
|
||||||
: `${baseClasses} right-0`;
|
: `${baseClasses} right-0`;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={getContainerClasses()}>
|
<div className={getContainerClasses()} style={getContainerStyle()}>
|
||||||
<canvas
|
<canvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
className="w-full h-full bg-background"
|
className="w-full h-full bg-background"
|
||||||
style={{ cursor: 'default' }} // Changed from cursor-pointer to default
|
style={{ cursor: "default" }}
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-background/30 backdrop-blur-sm pointer-events-none" />
|
<div className="absolute inset-0 bg-background/30 backdrop-blur-sm pointer-events-none" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import Header from "@/components/header";
|
|||||||
import Footer from "@/components/footer";
|
import Footer from "@/components/footer";
|
||||||
import Background from "@/components/background";
|
import Background from "@/components/background";
|
||||||
import ThemeSwitcher from "@/components/theme-switcher";
|
import ThemeSwitcher from "@/components/theme-switcher";
|
||||||
|
import AnimationSwitcher from "@/components/animation-switcher";
|
||||||
import { THEME_LOADER_SCRIPT, THEME_NAV_SCRIPT } from "@/lib/themes/loader";
|
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 {
|
export interface Props {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -48,6 +50,7 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<script is:inline set:html={THEME_LOADER_SCRIPT} />
|
<script is:inline set:html={THEME_LOADER_SCRIPT} />
|
||||||
|
<script is:inline set:html={ANIMATION_LOADER_SCRIPT} />
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-background text-foreground min-h-screen flex flex-col">
|
<body class="bg-background text-foreground min-h-screen flex flex-col">
|
||||||
<Header client:load />
|
<Header client:load />
|
||||||
@@ -64,6 +67,8 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
|
|||||||
<Footer client:load transition:persist />
|
<Footer client:load transition:persist />
|
||||||
</div>
|
</div>
|
||||||
<ThemeSwitcher client:only="react" transition:persist />
|
<ThemeSwitcher client:only="react" transition:persist />
|
||||||
|
<AnimationSwitcher client:only="react" transition:persist />
|
||||||
<script is:inline set:html={`window.scrollTo(0,0);` + THEME_NAV_SCRIPT} />
|
<script is:inline set:html={`window.scrollTo(0,0);` + THEME_NAV_SCRIPT} />
|
||||||
|
<script is:inline set:html={ANIMATION_NAV_SCRIPT} />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import Header from "@/components/header";
|
|||||||
import Footer from "@/components/footer";
|
import Footer from "@/components/footer";
|
||||||
import Background from "@/components/background";
|
import Background from "@/components/background";
|
||||||
import ThemeSwitcher from "@/components/theme-switcher";
|
import ThemeSwitcher from "@/components/theme-switcher";
|
||||||
|
import AnimationSwitcher from "@/components/animation-switcher";
|
||||||
import { THEME_LOADER_SCRIPT, THEME_NAV_SCRIPT } from "@/lib/themes/loader";
|
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 {
|
export interface Props {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -37,6 +39,7 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
|
|||||||
<link rel="sitemap" href="/sitemap-index.xml" />
|
<link rel="sitemap" href="/sitemap-index.xml" />
|
||||||
<ClientRouter />
|
<ClientRouter />
|
||||||
<script is:inline set:html={THEME_LOADER_SCRIPT} />
|
<script is:inline set:html={THEME_LOADER_SCRIPT} />
|
||||||
|
<script is:inline set:html={ANIMATION_LOADER_SCRIPT} />
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-background text-foreground">
|
<body class="bg-background text-foreground">
|
||||||
<Header client:load transparent />
|
<Header client:load transparent />
|
||||||
@@ -46,6 +49,8 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
|
|||||||
</main>
|
</main>
|
||||||
<Footer client:load transition:persist fixed=true />
|
<Footer client:load transition:persist fixed=true />
|
||||||
<ThemeSwitcher client:only="react" transition:persist />
|
<ThemeSwitcher client:only="react" transition:persist />
|
||||||
|
<AnimationSwitcher client:only="react" transition:persist />
|
||||||
<script is:inline set:html={THEME_NAV_SCRIPT} />
|
<script is:inline set:html={THEME_NAV_SCRIPT} />
|
||||||
|
<script is:inline set:html={ANIMATION_NAV_SCRIPT} />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import Header from "@/components/header";
|
|||||||
import Footer from "@/components/footer";
|
import Footer from "@/components/footer";
|
||||||
import Background from "@/components/background";
|
import Background from "@/components/background";
|
||||||
import ThemeSwitcher from "@/components/theme-switcher";
|
import ThemeSwitcher from "@/components/theme-switcher";
|
||||||
|
import AnimationSwitcher from "@/components/animation-switcher";
|
||||||
import { THEME_LOADER_SCRIPT, THEME_NAV_SCRIPT } from "@/lib/themes/loader";
|
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 {
|
export interface Props {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -48,6 +50,7 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<script is:inline set:html={THEME_LOADER_SCRIPT} />
|
<script is:inline set:html={THEME_LOADER_SCRIPT} />
|
||||||
|
<script is:inline set:html={ANIMATION_LOADER_SCRIPT} />
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-background text-foreground min-h-screen flex flex-col">
|
<body class="bg-background text-foreground min-h-screen flex flex-col">
|
||||||
<main class="flex-1 flex flex-col">
|
<main class="flex-1 flex flex-col">
|
||||||
@@ -60,6 +63,8 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<ThemeSwitcher client:only="react" transition:persist />
|
<ThemeSwitcher client:only="react" transition:persist />
|
||||||
|
<AnimationSwitcher client:only="react" transition:persist />
|
||||||
<script is:inline set:html={`window.scrollTo(0,0);` + THEME_NAV_SCRIPT} />
|
<script is:inline set:html={`window.scrollTo(0,0);` + THEME_NAV_SCRIPT} />
|
||||||
|
<script is:inline set:html={ANIMATION_NAV_SCRIPT} />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
20
src/src/lib/animations/engine.ts
Normal file
20
src/src/lib/animations/engine.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { ANIMATION_IDS, DEFAULT_ANIMATION_ID } from "./index";
|
||||||
|
import type { AnimationId } from "./index";
|
||||||
|
|
||||||
|
export function getStoredAnimationId(): AnimationId {
|
||||||
|
if (typeof window === "undefined") return DEFAULT_ANIMATION_ID;
|
||||||
|
const stored = localStorage.getItem("animation");
|
||||||
|
if (stored && (ANIMATION_IDS as readonly string[]).includes(stored)) {
|
||||||
|
return stored as AnimationId;
|
||||||
|
}
|
||||||
|
return DEFAULT_ANIMATION_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveAnimation(id: AnimationId): void {
|
||||||
|
localStorage.setItem("animation", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNextAnimation(currentId: AnimationId): AnimationId {
|
||||||
|
const idx = ANIMATION_IDS.indexOf(currentId);
|
||||||
|
return ANIMATION_IDS[(idx + 1) % ANIMATION_IDS.length];
|
||||||
|
}
|
||||||
8
src/src/lib/animations/index.ts
Normal file
8
src/src/lib/animations/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export const ANIMATION_IDS = ["game-of-life", "lava-lamp"] as const;
|
||||||
|
export type AnimationId = (typeof ANIMATION_IDS)[number];
|
||||||
|
export const DEFAULT_ANIMATION_ID: AnimationId = "game-of-life";
|
||||||
|
|
||||||
|
export const ANIMATION_LABELS: Record<AnimationId, string> = {
|
||||||
|
"game-of-life": "life",
|
||||||
|
"lava-lamp": "lava",
|
||||||
|
};
|
||||||
7
src/src/lib/animations/loader.ts
Normal file
7
src/src/lib/animations/loader.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { ANIMATION_IDS, DEFAULT_ANIMATION_ID } from "./index";
|
||||||
|
|
||||||
|
const VALID_IDS = JSON.stringify(ANIMATION_IDS);
|
||||||
|
|
||||||
|
export const ANIMATION_LOADER_SCRIPT = `(function(){var id=localStorage.getItem("animation");if(id&&${VALID_IDS}.indexOf(id)!==-1)document.documentElement.dataset.animation=id;else document.documentElement.dataset.animation="${DEFAULT_ANIMATION_ID}";})();`;
|
||||||
|
|
||||||
|
export const ANIMATION_NAV_SCRIPT = `document.addEventListener("astro:after-navigation",function(){var id=localStorage.getItem("animation");if(id&&${VALID_IDS}.indexOf(id)!==-1)document.documentElement.dataset.animation=id;else document.documentElement.dataset.animation="${DEFAULT_ANIMATION_ID}";});`;
|
||||||
33
src/src/lib/animations/types.ts
Normal file
33
src/src/lib/animations/types.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
export interface AnimationEngine {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
init(
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
palette: [number, number, number][],
|
||||||
|
bgColor: string
|
||||||
|
): void;
|
||||||
|
|
||||||
|
update(deltaTime: number): void;
|
||||||
|
|
||||||
|
render(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
width: number,
|
||||||
|
height: number
|
||||||
|
): void;
|
||||||
|
|
||||||
|
handleResize(width: number, height: number): void;
|
||||||
|
|
||||||
|
handleMouseMove(x: number, y: number, isDown: boolean): void;
|
||||||
|
|
||||||
|
handleMouseDown(x: number, y: number): void;
|
||||||
|
|
||||||
|
handleMouseUp(): void;
|
||||||
|
|
||||||
|
handleMouseLeave(): void;
|
||||||
|
|
||||||
|
updatePalette(palette: [number, number, number][], bgColor: string): void;
|
||||||
|
|
||||||
|
cleanup(): void;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user