Files
personal-site/static/animation.js
T
2026-05-07 23:49:38 +03:00

206 lines
7.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Conway's Game of Life background animation
// 100×100 toroidal grid, 1cm cells with 0.1cm gaps, tiled to fill the viewport.
// State persists in a cookie until midnight; cells fade in/out on state change.
const COOKIE_STATE = 'cgol_state';
const ROWS = 100;
const COLS = 100;
const CELL_CM = 1.0; // cell size in cm
const PAD_CM = 0.1; // gap between cells in cm
const FADE_SPEED = 4.0; // opacity units/sec → full transition in 0.25s
const STEP_MS = 1000; // game step interval in ms
const CELL_ALPHA = 0.03; // max cell opacity (keeps it a subtle background)
let canvas, ctx;
let cmPx = 38; // pixels per cm, measured at setup
let cellColor = '#2f4f4f';
let board = null; // Uint8Array ROWS*COLS — 0=dead, 1=alive
let opacity = null; // Float32Array ROWS*COLS — current fade value [0,1]
let animFrame = null;
let lastStepTime = 0;
let lastFrameTime = 0;
// ── Board helpers ──────────────────────────────────────────────────────────
function idx(r, c) { return r * COLS + c; }
function randomBoard() {
const b = new Uint8Array(ROWS * COLS);
for (let i = 0; i < b.length; i++) b[i] = Math.random() < 0.3 ? 1 : 0;
return b;
}
function stepConway(b) {
const next = new Uint8Array(ROWS * COLS);
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
let n = 0;
for (let dr = -1; dr <= 1; dr++) {
for (let dc = -1; dc <= 1; dc++) {
if (dr === 0 && dc === 0) continue;
n += b[idx((r + dr + ROWS) % ROWS, (c + dc + COLS) % COLS)];
}
}
const alive = b[idx(r, c)];
next[idx(r, c)] = alive
? (n === 2 || n === 3 ? 1 : 0)
: (n === 3 ? 1 : 0);
}
}
return next;
}
// ── Serialisation (bit-packed → base64url, ~1667 chars for 100×100) ────────
function serializeBoard(b) {
const bytes = new Uint8Array(Math.ceil(ROWS * COLS / 8));
for (let i = 0; i < ROWS * COLS; i++) {
if (b[i]) bytes[i >> 3] |= 1 << (i & 7);
}
let s = '';
for (const byte of bytes) s += String.fromCharCode(byte);
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_');
}
function deserializeBoard(b64) {
const s = atob(b64.replace(/-/g, '+').replace(/_/g, '/'));
const b = new Uint8Array(ROWS * COLS);
for (let i = 0; i < ROWS * COLS; i++) {
b[i] = (s.charCodeAt(i >> 3) >> (i & 7)) & 1;
}
return b;
}
// ── Cookie (expires at midnight tonight) ──────────────────────────────────
function saveToCookie() {
if (!board) return;
const midnight = new Date();
midnight.setHours(24, 0, 0, 0);
const data = encodeURIComponent(serializeBoard(board));
document.cookie =
`${COOKIE_STATE}=${data};expires=${midnight.toUTCString()};path=/;SameSite=Lax`;
}
function loadFromCookie() {
const match = document.cookie.match(
new RegExp('(?:^|; )' + COOKIE_STATE + '=([^;]*)')
);
if (!match) return null;
try { return deserializeBoard(decodeURIComponent(match[1])); }
catch { return null; }
}
// ── Setup ──────────────────────────────────────────────────────────────────
function measureCmPx() {
const el = document.createElement('div');
el.style.cssText = 'position:absolute;width:1cm;height:0;visibility:hidden';
document.body.appendChild(el);
const px = el.offsetWidth || 38;
document.body.removeChild(el);
return px;
}
function setup() {
cmPx = measureCmPx();
cellColor = getComputedStyle(document.documentElement)
.getPropertyValue('--fg').trim() || '#2f4f4f';
canvas = document.createElement('canvas');
canvas.id = 'bg-animation';
document.body.prepend(canvas);
ctx = canvas.getContext('2d');
resize();
window.addEventListener('resize', resize);
board = loadFromCookie() || randomBoard();
opacity = new Float32Array(ROWS * COLS);
// Initialise opacity to match loaded state (no fade-in on first load)
for (let i = 0; i < ROWS * COLS; i++) opacity[i] = board[i];
lastStepTime = performance.now();
lastFrameTime = performance.now();
animFrame = requestAnimationFrame(frame);
}
function resize() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
// ── Animation loop ─────────────────────────────────────────────────────────
function frame(ts) {
const dt = Math.min((ts - lastFrameTime) / 1000, 0.1); // cap delta at 100ms
lastFrameTime = ts;
if (ts - lastStepTime >= STEP_MS) {
board = stepConway(board);
lastStepTime = ts;
}
// Nudge each cell's opacity toward its target (0 or 1)
const fade = FADE_SPEED * dt;
for (let i = 0; i < ROWS * COLS; i++) {
if (board[i]) {
if (opacity[i] < 1) opacity[i] = Math.min(1, opacity[i] + fade);
} else {
if (opacity[i] > 0) opacity[i] = Math.max(0, opacity[i] - fade);
}
}
draw();
animFrame = requestAnimationFrame(frame);
}
// ── Draw ───────────────────────────────────────────────────────────────────
function draw() {
const w = canvas.width;
const h = canvas.height;
ctx.clearRect(0, 0, w, h);
const cellPx = cmPx * CELL_CM;
const stepPx = cmPx * (CELL_CM + PAD_CM);
const gridW = COLS * stepPx;
const gridH = ROWS * stepPx;
ctx.fillStyle = cellColor;
// Tile grid to fill canvas; start one full grid before origin to cover edges.
for (let tileY = -gridH; tileY < h; tileY += gridH) {
for (let tileX = -gridW; tileX < w; tileX += gridW) {
for (let r = 0; r < ROWS; r++) {
const y = tileY + r * stepPx;
if (y + cellPx < 0 || y > h) continue;
for (let c = 0; c < COLS; c++) {
const op = opacity[idx(r, c)];
if (op < 0.005) continue;
const x = tileX + c * stepPx;
if (x + cellPx < 0 || x > w) continue;
ctx.globalAlpha = op * CELL_ALPHA;
ctx.fillRect(x, y, cellPx, cellPx);
}
}
}
}
ctx.globalAlpha = 1;
}
// ── Teardown ───────────────────────────────────────────────────────────────
function teardown() {
saveToCookie();
cancelAnimationFrame(animFrame);
window.removeEventListener('resize', resize);
if (canvas) canvas.remove();
canvas = ctx = board = opacity = null;
}
// ── Init ───────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', setup);
window.addEventListener('pagehide', teardown);