Add interactivity to the background animation:

This commit is contained in:
2025-04-17 12:39:48 -07:00
parent 0c2e7f505d
commit 0589ff9c7c

View File

@@ -4,6 +4,7 @@ interface Cell {
alive: boolean; alive: boolean;
next: boolean; next: boolean;
color: [number, number, number]; color: [number, number, number];
baseColor: [number, number, number]; // Original color
currentX: number; currentX: number;
currentY: number; currentY: number;
targetX: number; targetX: number;
@@ -12,8 +13,11 @@ interface Cell {
targetOpacity: number; targetOpacity: number;
scale: number; scale: number;
targetScale: number; targetScale: number;
elevation: number; // For 3D effect
targetElevation: number;
transitioning: boolean; transitioning: boolean;
transitionComplete: boolean; transitionComplete: boolean;
rippleEffect: number; // For ripple animation
} }
interface Grid { interface Grid {
@@ -24,17 +28,30 @@ interface Grid {
offsetY: number; offsetY: number;
} }
interface MousePosition {
x: number;
y: number;
isDown: boolean;
lastClickTime: number;
cellX: number;
cellY: number;
}
interface BackgroundProps { interface BackgroundProps {
layout?: 'index' | 'sidebar'; layout?: 'index' | 'sidebar';
position?: 'left' | 'right'; position?: 'left' | 'right';
} }
const CELL_SIZE = 25; const CELL_SIZE = 25;
const TRANSITION_SPEED = 0.05; // Reduced from 0.1 for slower animations const TRANSITION_SPEED = 0.05;
const SCALE_SPEED = 0.05; // Reduced from 0.15 for slower animations const SCALE_SPEED = 0.05;
const CYCLE_FRAMES = 180; // Increased from 120 to give more time for transitions const CYCLE_FRAMES = 180;
const INITIAL_DENSITY = 0.15; 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.2; // Speed of ripple propagation
const ELEVATION_FACTOR = 15; // Max height for 3D effect
const Background: React.FC<BackgroundProps> = ({ const Background: React.FC<BackgroundProps> = ({
layout = 'index', layout = 'index',
@@ -45,6 +62,14 @@ const Background: React.FC<BackgroundProps> = ({
const animationFrameRef = useRef<number>(); const animationFrameRef = useRef<number>();
const frameCount = useRef(0); const frameCount = useRef(0);
const resizeTimeoutRef = useRef<NodeJS.Timeout>(); const resizeTimeoutRef = useRef<NodeJS.Timeout>();
const mouseRef = useRef<MousePosition>({
x: -1000,
y: -1000,
isDown: false,
lastClickTime: 0,
cellX: -1,
cellY: -1
});
const randomColor = (): [number, number, number] => { const randomColor = (): [number, number, number] => {
const colors = [ const colors = [
@@ -70,10 +95,13 @@ const Background: React.FC<BackgroundProps> = ({
const { cols, rows, offsetX, offsetY } = calculateGridDimensions(width, height); const { cols, rows, offsetX, offsetY } = calculateGridDimensions(width, height);
const cells = Array(cols).fill(0).map((_, i) => const cells = Array(cols).fill(0).map((_, i) =>
Array(rows).fill(0).map((_, j) => ({ Array(rows).fill(0).map((_, j) => {
const baseColor = randomColor();
return {
alive: Math.random() < INITIAL_DENSITY, alive: Math.random() < INITIAL_DENSITY,
next: false, next: false,
color: randomColor(), color: [...baseColor] as [number, number, number],
baseColor: baseColor,
currentX: i, currentX: i,
currentY: j, currentY: j,
targetX: i, targetX: i,
@@ -82,9 +110,13 @@ const Background: React.FC<BackgroundProps> = ({
targetOpacity: 0, targetOpacity: 0,
scale: 0, scale: 0,
targetScale: 0, targetScale: 0,
elevation: 0,
targetElevation: 0,
transitioning: false, transitioning: false,
transitionComplete: false transitionComplete: false,
})) rippleEffect: 0
};
})
); );
const grid = { cells, cols, rows, offsetX, offsetY }; const grid = { cells, cols, rows, offsetX, offsetY };
@@ -119,11 +151,9 @@ const Background: React.FC<BackgroundProps> = ({
const col = (x + i + grid.cols) % grid.cols; const col = (x + i + grid.cols) % grid.cols;
const row = (y + j + grid.rows) % grid.rows; const row = (y + j + grid.rows) % grid.rows;
// Only count cells that are actually alive according to the game state
// Not cells that are just visually transitioning
if (grid.cells[col][row].alive) { if (grid.cells[col][row].alive) {
neighbors.count++; neighbors.count++;
neighbors.colors.push(grid.cells[col][row].color); neighbors.colors.push(grid.cells[col][row].baseColor);
} }
} }
} }
@@ -158,7 +188,8 @@ const Background: React.FC<BackgroundProps> = ({
} else { } else {
cell.next = count === 3; cell.next = count === 3;
if (cell.next) { if (cell.next) {
cell.color = averageColors(colors); cell.baseColor = averageColors(colors);
cell.color = [...cell.baseColor];
} }
} }
} }
@@ -180,9 +211,11 @@ const Background: React.FC<BackgroundProps> = ({
if (!cell.next) { if (!cell.next) {
cell.targetScale = 0; cell.targetScale = 0;
cell.targetOpacity = 0; cell.targetOpacity = 0;
cell.targetElevation = 0;
} else { } else {
cell.targetScale = 1; cell.targetScale = 1;
cell.targetOpacity = 1; cell.targetOpacity = 1;
cell.targetElevation = 0;
} }
}, delay); }, delay);
} }
@@ -190,7 +223,59 @@ const Background: React.FC<BackgroundProps> = ({
} }
}; };
const createRippleEffect = (grid: Grid, centerX: number, centerY: number) => {
const maxDistance = Math.max(grid.cols, grid.rows) / 2;
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) {
// Delayed animation based on distance from center
setTimeout(() => {
cell.rippleEffect = 1; // Start ripple
// After a short time, reset ripple
setTimeout(() => {
cell.rippleEffect = 0;
}, 300 + distance * 50);
}, distance * 100);
}
}
}
};
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) => { const updateCellAnimations = (grid: Grid) => {
const mouseX = mouseRef.current.x;
const mouseY = mouseRef.current.y;
for (let i = 0; i < grid.cols; i++) { for (let i = 0; i < grid.cols; i++) {
for (let j = 0; j < grid.rows; j++) { for (let j = 0; j < grid.rows; j++) {
const cell = grid.cells[i][j]; const cell = grid.cells[i][j];
@@ -198,7 +283,44 @@ const Background: React.FC<BackgroundProps> = ({
// Smooth transitions // Smooth transitions
cell.opacity += (cell.targetOpacity - cell.opacity) * TRANSITION_SPEED; cell.opacity += (cell.targetOpacity - cell.opacity) * TRANSITION_SPEED;
cell.scale += (cell.targetScale - cell.scale) * SCALE_SPEED; cell.scale += (cell.targetScale - cell.scale) * SCALE_SPEED;
cell.elevation += (cell.targetElevation - cell.elevation) * SCALE_SPEED;
// Apply mouse interaction
const cellCenterX = grid.offsetX + i * CELL_SIZE + CELL_SIZE / 2;
const cellCenterY = grid.offsetY + j * CELL_SIZE + CELL_SIZE / 2;
const dx = cellCenterX - mouseX;
const dy = cellCenterY - mouseY;
const distanceToMouse = Math.sqrt(dx * dx + dy * dy);
// Color wave effect based on mouse position
if (distanceToMouse < MOUSE_INFLUENCE_RADIUS && cell.opacity > 0.1) {
// Calculate color adjustment based on distance
const influenceFactor = 1 - (distanceToMouse / MOUSE_INFLUENCE_RADIUS);
// Wave effect with sine function
const waveOffset = (frameCount.current * 0.05 + distanceToMouse * 0.05) % (Math.PI * 2);
const waveFactor = (Math.sin(waveOffset) * 0.5 + 0.5) * influenceFactor;
// Adjust color based on wave
cell.color = [
Math.min(255, Math.max(0, cell.baseColor[0] + COLOR_SHIFT_AMOUNT * waveFactor)),
Math.min(255, Math.max(0, cell.baseColor[1] - COLOR_SHIFT_AMOUNT * waveFactor)),
Math.min(255, Math.max(0, cell.baseColor[2] + COLOR_SHIFT_AMOUNT * waveFactor))
] as [number, number, number];
// 3D elevation effect when mouse is close
cell.targetElevation = ELEVATION_FACTOR * influenceFactor;
} else {
// Gradually return to base color 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;
// Reset elevation when mouse moves away
cell.targetElevation = 0;
}
// Handle cell state transitions
if (cell.transitioning) { if (cell.transitioning) {
// When a cell is completely faded out, update its alive state // When a cell is completely faded out, update its alive state
if (!cell.next && cell.opacity < 0.01 && cell.scale < 0.01) { if (!cell.next && cell.opacity < 0.01 && cell.scale < 0.01) {
@@ -207,6 +329,7 @@ const Background: React.FC<BackgroundProps> = ({
cell.transitionComplete = true; cell.transitionComplete = true;
cell.opacity = 0; cell.opacity = 0;
cell.scale = 0; cell.scale = 0;
cell.elevation = 0;
} }
// When a new cell is born // When a new cell is born
else if (cell.next && !cell.alive && !cell.transitionComplete) { else if (cell.next && !cell.alive && !cell.transitionComplete) {
@@ -215,8 +338,66 @@ const Background: React.FC<BackgroundProps> = ({
cell.transitionComplete = true; cell.transitionComplete = true;
} }
} }
// Gradually decrease ripple effect
if (cell.rippleEffect > 0) {
cell.rippleEffect = Math.max(0, cell.rippleEffect - RIPPLE_SPEED);
} }
} }
}
};
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;
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) / CELL_SIZE);
const cellY = Math.floor((mouseY - grid.offsetY) / CELL_SIZE);
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) return;
const canvas = canvasRef.current;
const rect = canvas.getBoundingClientRect();
mouseRef.current.x = e.clientX - rect.left;
mouseRef.current.y = e.clientY - rect.top;
};
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 setupCanvas = (canvas: HTMLCanvasElement, width: number, height: number) => {
@@ -280,6 +461,12 @@ const Background: React.FC<BackgroundProps> = ({
gridRef.current = initGrid(displayWidth, displayHeight); gridRef.current = initGrid(displayWidth, displayHeight);
} }
// Add mouse event listeners
canvas.addEventListener('mousedown', handleMouseDown, { signal });
canvas.addEventListener('mousemove', handleMouseMove, { signal });
canvas.addEventListener('mouseup', handleMouseUp, { signal });
canvas.addEventListener('mouseleave', handleMouseLeave, { signal });
const animate = () => { const animate = () => {
if (signal.aborted) return; if (signal.aborted) return;
@@ -310,16 +497,71 @@ const Background: React.FC<BackgroundProps> = ({
if ((cell.alive || cell.targetOpacity > 0) && cell.opacity > 0.01) { if ((cell.alive || cell.targetOpacity > 0) && cell.opacity > 0.01) {
const [r, g, b] = cell.color; const [r, g, b] = cell.color;
ctx.fillStyle = `rgb(${r},${g},${b})`; ctx.fillStyle = `rgb(${r},${g},${b})`;
ctx.globalAlpha = cell.opacity * 0.8;
// Apply ripple and elevation effects to opacity
const rippleBoost = cell.rippleEffect * 0.4; // Boost opacity during ripple
ctx.globalAlpha = Math.min(1, cell.opacity * 0.8 + rippleBoost);
const scaledSize = cellSize * cell.scale; const scaledSize = cellSize * cell.scale;
const xOffset = (cellSize - scaledSize) / 2; const xOffset = (cellSize - scaledSize) / 2;
const yOffset = (cellSize - scaledSize) / 2; const yOffset = (cellSize - scaledSize) / 2;
// Apply 3D elevation effect
const elevationOffset = cell.elevation;
const x = grid.offsetX + i * CELL_SIZE + (CELL_SIZE - cellSize) / 2 + xOffset; const x = grid.offsetX + i * CELL_SIZE + (CELL_SIZE - cellSize) / 2 + xOffset;
const y = grid.offsetY + j * CELL_SIZE + (CELL_SIZE - cellSize) / 2 + yOffset; const y = grid.offsetY + j * CELL_SIZE + (CELL_SIZE - cellSize) / 2 + yOffset - elevationOffset;
const scaledRoundness = roundness * cell.scale; const scaledRoundness = roundness * cell.scale;
// Draw shadow for 3D effect if cell has elevation
if (elevationOffset > 1) {
ctx.fillStyle = 'rgba(0, 0, 0, 0.3)';
ctx.beginPath();
ctx.moveTo(x + scaledRoundness, y + elevationOffset + 5);
ctx.lineTo(x + scaledSize - scaledRoundness, y + elevationOffset + 5);
ctx.quadraticCurveTo(x + scaledSize, y + elevationOffset + 5, x + scaledSize, y + elevationOffset + 5 + scaledRoundness);
ctx.lineTo(x + scaledSize, y + elevationOffset + 5 + scaledSize - scaledRoundness);
ctx.quadraticCurveTo(x + scaledSize, y + elevationOffset + 5 + scaledSize, x + scaledSize - scaledRoundness, y + elevationOffset + 5 + scaledSize);
ctx.lineTo(x + scaledRoundness, y + elevationOffset + 5 + scaledSize);
ctx.quadraticCurveTo(x, y + elevationOffset + 5 + scaledSize, x, y + elevationOffset + 5 + scaledSize - scaledRoundness);
ctx.lineTo(x, y + elevationOffset + 5 + scaledRoundness);
ctx.quadraticCurveTo(x, y + elevationOffset + 5, x + scaledRoundness, y + elevationOffset + 5);
ctx.fill();
// Draw side of elevated cell
const sideHeight = elevationOffset;
ctx.fillStyle = `rgba(${r*0.7}, ${g*0.7}, ${b*0.7}, ${ctx.globalAlpha})`;
// Left side
ctx.beginPath();
ctx.moveTo(x, y + scaledRoundness);
ctx.lineTo(x, y + scaledSize - scaledRoundness + sideHeight);
ctx.lineTo(x + scaledRoundness, y + scaledSize + sideHeight);
ctx.lineTo(x + scaledRoundness, y + scaledSize);
ctx.lineTo(x, y + scaledSize - scaledRoundness);
ctx.fill();
// Right side
ctx.beginPath();
ctx.moveTo(x + scaledSize, y + scaledRoundness);
ctx.lineTo(x + scaledSize, y + scaledSize - scaledRoundness + sideHeight);
ctx.lineTo(x + scaledSize - scaledRoundness, y + scaledSize + sideHeight);
ctx.lineTo(x + scaledSize - scaledRoundness, y + scaledSize);
ctx.lineTo(x + scaledSize, y + scaledSize - scaledRoundness);
ctx.fill();
// Bottom side
ctx.fillStyle = `rgba(${r*0.5}, ${g*0.5}, ${b*0.5}, ${ctx.globalAlpha})`;
ctx.beginPath();
ctx.moveTo(x + scaledRoundness, y + scaledSize);
ctx.lineTo(x + scaledSize - scaledRoundness, y + scaledSize);
ctx.lineTo(x + scaledSize - scaledRoundness, y + scaledSize + sideHeight);
ctx.lineTo(x + scaledRoundness, y + scaledSize + sideHeight);
ctx.fill();
}
// Draw main cell with original color
ctx.fillStyle = `rgb(${r},${g},${b})`;
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(x + scaledRoundness, y); ctx.moveTo(x + scaledRoundness, y);
ctx.lineTo(x + scaledSize - scaledRoundness, y); ctx.lineTo(x + scaledSize - scaledRoundness, y);
@@ -331,6 +573,38 @@ const Background: React.FC<BackgroundProps> = ({
ctx.lineTo(x, y + scaledRoundness); ctx.lineTo(x, y + scaledRoundness);
ctx.quadraticCurveTo(x, y, x + scaledRoundness, y); ctx.quadraticCurveTo(x, y, x + scaledRoundness, y);
ctx.fill(); ctx.fill();
// Draw highlight on top for 3D effect
if (elevationOffset > 1) {
ctx.fillStyle = `rgba(255, 255, 255, ${0.2 * 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();
}
// Draw ripple effect
if (cell.rippleEffect > 0) {
const rippleRadius = cell.rippleEffect * cellSize * 2;
const rippleAlpha = (1 - cell.rippleEffect) * 0.5;
ctx.strokeStyle = `rgba(255, 255, 255, ${rippleAlpha})`;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(
x + scaledSize / 2,
y + scaledSize / 2,
rippleRadius,
0,
Math.PI * 2
);
ctx.stroke();
}
} }
} }
} }
@@ -361,7 +635,7 @@ const Background: React.FC<BackgroundProps> = ({
return 'fixed inset-0 -z-10'; return 'fixed inset-0 -z-10';
} }
const baseClasses = 'fixed top-0 bottom-0 hidden lg:block -z-10 pointer-events-none'; 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`;
@@ -371,9 +645,9 @@ const Background: React.FC<BackgroundProps> = ({
<div className={getContainerClasses()}> <div className={getContainerClasses()}>
<canvas <canvas
ref={canvasRef} ref={canvasRef}
className="w-full h-full bg-black" className="w-full h-full bg-black cursor-pointer"
/> />
<div className="absolute inset-0 bg-black/30 backdrop-blur-sm" /> <div className="absolute inset-0 bg-black/30 backdrop-blur-sm pointer-events-none" />
</div> </div>
); );
}; };