// 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; let initDateStr = ''; // date string when animation was last initialized // ── 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; } } // ── Lightning strike ─────────────────────────────────────────────────────── function maybeLightningStrike() { if (Math.random() >= 0.001) return; const r = Math.floor(Math.random() * ROWS); const c = Math.floor(Math.random() * COLS); const s = 4 + Math.floor(Math.random() * 7); // 4–10 inclusive for (let dr = 0; dr < s; dr++) { for (let dc = 0; dc < s; dc++) { board[idx((r + dr) % ROWS, (c + dc) % COLS)] = Math.random() < 0.5 ? 1 : 0; } } } // ── Midnight rollover ────────────────────────────────────────────────────── function todayStr() { const d = new Date(); return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`; } function checkDateRollover() { if (todayStr() === initDateStr) return; // New day — reinitialize with a fresh random board initDateStr = todayStr(); board = randomBoard(); opacity = new Float32Array(ROWS * COLS); // all zeros → cells fade in saveToCookie(); } // ── 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); initDateStr = todayStr(); 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); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') checkDateRollover(); }); } 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) { checkDateRollover(); maybeLightningStrike(); 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);