276 lines
9.2 KiB
JavaScript
276 lines
9.2 KiB
JavaScript
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);
|