const canvas = document.getElementById('lifeCanvas'); const ctx = canvas.getContext('2d'); const cols = 80; const rows = 60; const cellSize = canvas.width / cols; let grid = makeGrid(); let running = false; let generation = 0; let births = 0; let deaths = 0; let timer = null; let speed = 8; let drawing = false; let drawValue = 1; let lastPaintedCell = null; const themeTokens = { sprout: ['oklch(58% 0.16 145)', 'oklch(72% 0.14 170)'], nebula: ['oklch(60% 0.18 285)', 'oklch(70% 0.14 235)'], sunny: ['oklch(70% 0.15 65)', 'oklch(74% 0.12 35)'] }; function makeGrid() { return Array.from({ length: rows }, () => Array(cols).fill(0)); } function randomize(chance = 0.28) { grid = grid.map(row => row.map(() => (Math.random() < chance ? 1 : 0))); generation = 0; births = 0; deaths = 0; updateLesson('A fresh universe appeared. Press Play and watch which clusters survive the first few generations.'); updateStats(); draw(); } function fireworks() { grid = makeGrid(); const centerX = Math.floor(cols / 2); const centerY = Math.floor(rows / 2); const gliderGun = [ [0,4], [1,4], [0,5], [1,5], [10,4], [10,5], [10,6], [11,3], [11,7], [12,2], [12,8], [13,2], [13,8], [14,5], [15,3], [15,7], [16,4], [16,5], [16,6], [17,5], [20,2], [20,3], [20,4], [21,2], [21,3], [21,4], [22,1], [22,5], [24,0], [24,1], [24,5], [24,6], [34,2], [35,2], [34,3], [35,3] ]; const pulsar = [ [2,0], [3,0], [4,0], [8,0], [9,0], [10,0], [0,2], [5,2], [7,2], [12,2], [0,3], [5,3], [7,3], [12,3], [0,4], [5,4], [7,4], [12,4], [2,5], [3,5], [4,5], [8,5], [9,5], [10,5], [2,7], [3,7], [4,7], [8,7], [9,7], [10,7], [0,8], [5,8], [7,8], [12,8], [0,9], [5,9], [7,9], [12,9], [0,10], [5,10], [7,10], [12,10], [2,12], [3,12], [4,12], [8,12], [9,12], [10,12] ]; const blinkers = [[0,0], [1,0], [2,0]]; addPattern(gliderGun, centerX - 18, centerY - 22); addPattern(pulsar, centerX - 6, centerY + 8); addPattern(blinkers, centerX - 28, centerY + 16); addPattern(blinkers, centerX + 24, centerY + 16); generation = 0; births = 0; deaths = 0; updateLesson('Fireworks loaded. Press Play and watch the launcher keep sending tiny spaceships across the board.'); updateStats(); draw(); } function addPattern(points, originX, originY) { points.forEach(([x, y]) => { const gridX = originX + x; const gridY = originY + y; if (gridX >= 0 && gridX < cols && gridY >= 0 && gridY < rows) { grid[gridY][gridX] = 1; } }); } function neighbors(x, y) { let total = 0; for (let dy = -1; dy <= 1; dy++) { for (let dx = -1; dx <= 1; dx++) { if (dx === 0 && dy === 0) continue; const nx = (x + dx + cols) % cols; const ny = (y + dy + rows) % rows; total += grid[ny][nx]; } } return total; } function step() { const next = makeGrid(); births = 0; deaths = 0; for (let y = 0; y < rows; y++) { for (let x = 0; x < cols; x++) { const n = neighbors(x, y); const alive = grid[y][x] === 1; const nextAlive = alive ? (n === 2 || n === 3) : n === 3; next[y][x] = nextAlive ? 1 : 0; if (!alive && nextAlive) births++; if (alive && !nextAlive) deaths++; } } grid = next; generation++; updateStats(); draw(); } function draw() { const styles = getComputedStyle(document.documentElement); const cellA = styles.getPropertyValue('--cell-a').trim(); const cellB = styles.getPropertyValue('--cell-b').trim(); ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = styles.getPropertyValue('--surface').trim(); ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.strokeStyle = styles.getPropertyValue('--grid').trim(); ctx.lineWidth = 0.7; for (let x = 0; x <= cols; x++) { ctx.beginPath(); ctx.moveTo(x * cellSize, 0); ctx.lineTo(x * cellSize, canvas.height); ctx.stroke(); } for (let y = 0; y <= rows; y++) { ctx.beginPath(); ctx.moveTo(0, y * cellSize); ctx.lineTo(canvas.width, y * cellSize); ctx.stroke(); } const gradient = ctx.createLinearGradient(0, 0, canvas.width, canvas.height); gradient.addColorStop(0, cellA); gradient.addColorStop(1, cellB); ctx.fillStyle = gradient; ctx.shadowColor = cellA; ctx.shadowBlur = 8; for (let y = 0; y < rows; y++) { for (let x = 0; x < cols; x++) { if (grid[y][x]) { const inset = 1.4; ctx.beginPath(); ctx.roundRect(x * cellSize + inset, y * cellSize + inset, cellSize - inset * 2, cellSize - inset * 2, 3); ctx.fill(); } } } ctx.shadowBlur = 0; } function population() { return grid.reduce((sum, row) => sum + row.reduce((rowSum, cell) => rowSum + cell, 0), 0); } function updateStats() { document.getElementById('generation').textContent = generation; document.getElementById('population').textContent = population(); document.getElementById('births').textContent = births; document.getElementById('deaths').textContent = deaths; } function updateLesson(text) { document.getElementById('lessonText').textContent = text; } function play() { running = true; document.getElementById('playBtn').textContent = 'Pause'; document.getElementById('runStatus').textContent = 'Running - universe is alive'; loop(); } function pause() { running = false; document.getElementById('playBtn').textContent = 'Play'; document.getElementById('runStatus').textContent = 'Paused - ready to experiment'; if (timer) window.clearTimeout(timer); } function loop() { if (!running) return; step(); timer = window.setTimeout(() => requestAnimationFrame(loop), 620 - speed * 45); } function setTheme(name) { const [a, b] = themeTokens[name]; document.documentElement.style.setProperty('--cell-a', a); document.documentElement.style.setProperty('--cell-b', b); document.documentElement.style.setProperty('--accent', a); document.querySelectorAll('.theme').forEach(button => { button.setAttribute('aria-pressed', String(button.dataset.theme === name)); }); updateLesson(name === 'sprout' ? 'Sprout mode keeps the science-lab look: calm, clear, and easy to read.' : name === 'nebula' ? 'Nebula mode feels more arcade-like. It is great for spotting moving patterns.' : 'Sunny mode is warm and playful for younger scientists presenting to family.'); draw(); } function cellFromPointer(event) { const rect = canvas.getBoundingClientRect(); const scaleX = canvas.width / rect.width; const scaleY = canvas.height / rect.height; const x = Math.floor((event.clientX - rect.left) * scaleX / cellSize); const y = Math.floor((event.clientY - rect.top) * scaleY / cellSize); if (x < 0 || x >= cols || y < 0 || y >= rows) return null; return { x, y }; } function paintCell(event) { const cell = cellFromPointer(event); if (!cell) return; const key = `${cell.x},${cell.y}`; if (key === lastPaintedCell || grid[cell.y][cell.x] === drawValue) return; lastPaintedCell = key; grid[cell.y][cell.x] = drawValue; births = 0; deaths = 0; updateLesson(drawValue ? 'You are painting a shape. Release, then press Step once to test your prediction.' : 'You are erasing cells. Release, then redraw any shape you want to test.'); updateStats(); draw(); } function stopDrawing() { drawing = false; lastPaintedCell = null; } canvas.addEventListener('pointerdown', event => { event.preventDefault(); pause(); const cell = cellFromPointer(event); if (!cell) return; drawing = true; drawValue = grid[cell.y][cell.x] ? 0 : 1; lastPaintedCell = null; canvas.setPointerCapture(event.pointerId); paintCell(event); }); canvas.addEventListener('pointermove', event => { if (!drawing) return; event.preventDefault(); paintCell(event); }); canvas.addEventListener('pointerup', event => { if (canvas.hasPointerCapture(event.pointerId)) { canvas.releasePointerCapture(event.pointerId); } stopDrawing(); }); canvas.addEventListener('pointercancel', stopDrawing); canvas.addEventListener('pointerleave', stopDrawing); document.getElementById('playBtn').addEventListener('click', () => running ? pause() : play()); document.getElementById('stepBtn').addEventListener('click', () => { pause(); step(); updateLesson('One generation advanced. Did the population go up, down, or stay balanced?'); }); document.getElementById('clearBtn').addEventListener('click', () => { pause(); grid = makeGrid(); generation = 0; births = 0; deaths = 0; updateLesson('The board is clear. Draw a creature, then test if it survives.'); updateStats(); draw(); }); document.getElementById('randomBtn').addEventListener('click', () => randomize(0.28)); document.getElementById('sparkBtn').addEventListener('click', fireworks); document.getElementById('speed').addEventListener('input', event => { speed = Number(event.target.value); document.getElementById('speedLabel').textContent = speed; }); document.querySelectorAll('.theme').forEach(button => button.addEventListener('click', () => setTheme(button.dataset.theme))); if (!CanvasRenderingContext2D.prototype.roundRect) { CanvasRenderingContext2D.prototype.roundRect = function roundRect(x, y, width, height, radius) { this.moveTo(x + radius, y); this.arcTo(x + width, y, x + width, y + height, radius); this.arcTo(x + width, y + height, x, y + height, radius); this.arcTo(x, y + height, x, y, radius); this.arcTo(x, y, x + width, y, radius); return this; }; } randomize(0.18);