206 lines
7.4 KiB
JavaScript
206 lines
7.4 KiB
JavaScript
// 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);
|