Update: more science and kid friendly

This commit is contained in:
Zakaria
2026-06-06 23:40:47 -04:00
parent 15c3a0df80
commit 0891453912
4 changed files with 759 additions and 473 deletions
+207 -297
View File
@@ -1,299 +1,209 @@
// Initialize the game when the page loads
document.addEventListener('DOMContentLoaded', () => {
// Create game with 80x60 grid and 10px cells
const game = new GameOfLife(80, 60, 10);
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;
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 sparkles() {
grid = makeGrid();
const centerX = Math.floor(cols / 2);
const centerY = Math.floor(rows / 2);
const points = [[0,0], [1,0], [-1,0], [0,1], [0,-1], [2,1], [-2,-1], [4,0], [4,1], [4,-1], [-4,0], [-4,1], [-4,-1], [7,2], [8,2], [7,3], [8,3]];
points.forEach(([x, y]) => { grid[centerY + y][centerX + x] = 1; });
generation = 0;
births = 0;
deaths = 0;
updateLesson('Sparkles loaded. Step slowly and look for parts that blink while other parts float away.');
updateStats();
draw();
}
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();
}
canvas.addEventListener('pointerdown', event => {
pause();
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) {
grid[y][x] = grid[y][x] ? 0 : 1;
births = 0;
deaths = 0;
updateLesson('You drew on the board. Press Step once to test your prediction before running it fast.');
updateStats();
draw();
}
});
class GameOfLife {
constructor(width, height, cellSize = 10) {
this.width = width;
this.height = height;
this.cellSize = cellSize;
this.grid = this.createGrid();
this.nextGrid = this.createGrid();
this.running = false;
this.generation = 0;
this.births = 0;
this.deaths = 0;
this.animationId = null;
this.speed = 10;
// Canvas setup
this.canvas = document.getElementById('gameCanvas');
this.ctx = this.canvas.getContext('2d');
this.canvas.width = width * cellSize;
this.canvas.height = height * cellSize;
// Event listeners
this.setupEventListeners();
this.setupPresets();
// Initial draw
this.draw();
this.updateStats();
}
createGrid() {
return Array(this.height).fill().map(() => Array(this.width).fill(0));
}
randomize() {
for (let y = 0; y < this.height; y++) {
for (let x = 0; x < this.width; x++) {
this.grid[y][x] = Math.random() > 0.7 ? 1 : 0;
}
}
this.generation = 0;
this.draw();
}
setupEventListeners() {
// Start/Pause/Reset buttons
document.getElementById('startBtn').addEventListener('click', () => this.start());
document.getElementById('pauseBtn').addEventListener('click', () => this.pause());
document.getElementById('resetBtn').addEventListener('click', () => this.reset());
document.getElementById('randomBtn').addEventListener('click', () => this.randomize());
// Speed control
document.getElementById('speed').addEventListener('input', (e) => {
this.speed = parseInt(e.target.value);
});
// Canvas click to toggle cells
this.canvas.addEventListener('click', (e) => {
if (this.running) return;
const rect = this.canvas.getBoundingClientRect();
const x = Math.floor((e.clientX - rect.left) / this.cellSize);
const y = Math.floor((e.clientY - rect.top) / this.cellSize);
if (x >= 0 && x < this.width && y >= 0 && y < this.height) {
this.grid[y][x] = this.grid[y][x] ? 0 : 1;
this.draw();
}
});
}
setupPresets() {
document.querySelector('[data-preset="glider"]').addEventListener('click', () => {
this.reset();
// Glider pattern
this.grid[5][5] = 1;
this.grid[6][6] = 1;
this.grid[6][7] = 1;
this.grid[5][7] = 1;
this.grid[4][7] = 1;
this.draw();
});
document.querySelector('[data-preset="spaceship"]').addEventListener('click', () => {
this.reset();
// Lightweight spaceship
this.grid[10][10] = 1;
this.grid[10][13] = 1;
this.grid[11][9] = 1;
this.grid[11][13] = 1;
this.grid[12][9] = 1;
this.grid[12][10] = 1;
this.grid[12][11] = 1;
this.grid[12][12] = 1;
this.draw();
});
document.querySelector('[data-preset="pulsar"]').addEventListener('click', () => {
this.reset();
// Pulsar pattern
const pulsarPoints = [
[2,4],[2,5],[2,6],[2,10],[2,11],[2,12],
[4,2],[4,7],[4,9],[4,14],
[5,2],[5,7],[5,9],[5,14],
[6,2],[6,7],[6,9],[6,14],
[7,4],[7,5],[7,6],[7,10],[7,11],[7,12],
[9,4],[9,5],[9,6],[9,10],[9,11],[9,12],
[10,2],[10,7],[10,9],[10,14],
[11,2],[11,7],[11,9],[11,14],
[12,2],[12,7],[12,9],[12,14],
[14,4],[14,5],[14,6],[14,10],[14,11],[14,12]
];
pulsarPoints.forEach(([y, x]) => {
this.grid[y][x] = 1;
});
this.draw();
});
document.querySelector('[data-preset="gosper"]').addEventListener('click', () => {
this.reset();
// Gosper glider gun
const gunPoints = [
[5,1],[5,2],[6,1],[6,2],
[5,11],[6,11],[7,11],
[4,12],[8,12],
[3,13],[9,13],
[3,14],[9,14],
[6,15],
[4,16],[8,16],
[5,17],[6,17],[7,17],
[6,18],
[3,21],[4,21],[5,21],
[3,22],[4,22],[5,22],
[2,23],[6,23],
[1,25],[2,25],[6,25],[7,25],
[3,35],[4,35],[3,36],[4,36]
];
gunPoints.forEach(([y, x]) => {
this.grid[y][x] = 1;
});
this.draw();
});
}
countNeighbors(x, y) {
let count = 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 + this.width) % this.width;
const ny = (y + dy + this.height) % this.height;
count += this.grid[ny][nx];
}
}
return count;
}
update() {
// Reset counters
this.births = 0;
this.deaths = 0;
// Compute next generation
for (let y = 0; y < this.height; y++) {
for (let x = 0; x < this.width; x++) {
const neighbors = this.countNeighbors(x, y);
const cell = this.grid[y][x];
let nextState;
// Apply Conway's Game of Life rules
if (cell === 1) {
nextState = (neighbors === 2 || neighbors === 3) ? 1 : 0;
if (nextState === 0) this.deaths++;
} else {
nextState = (neighbors === 3) ? 1 : 0;
if (nextState === 1) this.births++;
}
this.nextGrid[y][x] = nextState;
}
}
// Swap grids
[this.grid, this.nextGrid] = [this.nextGrid, this.grid];
this.generation++;
// Update stats display
this.updateStats();
}
draw() {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
// Draw live cells with gradient
const gradient = this.ctx.createLinearGradient(0, 0, this.canvas.width, this.canvas.height);
gradient.addColorStop(0, '#4facfe');
gradient.addColorStop(1, '#00f2fe');
this.ctx.fillStyle = gradient;
for (let y = 0; y < this.height; y++) {
for (let x = 0; x < this.width; x++) {
if (this.grid[y][x] === 1) {
this.ctx.fillRect(
x * this.cellSize,
y * this.cellSize,
this.cellSize - 1,
this.cellSize - 1
);
}
}
}
// Draw grid lines
this.ctx.strokeStyle = 'rgba(255, 255, 255, 0.05)';
this.ctx.lineWidth = 0.5;
// Vertical lines
for (let x = 0; x <= this.width; x++) {
this.ctx.beginPath();
this.ctx.moveTo(x * this.cellSize, 0);
this.ctx.lineTo(x * this.cellSize, this.canvas.height);
this.ctx.stroke();
}
// Horizontal lines
for (let y = 0; y <= this.height; y++) {
this.ctx.beginPath();
this.ctx.moveTo(0, y * this.cellSize);
this.ctx.lineTo(this.canvas.width, y * this.cellSize);
this.ctx.stroke();
}
}
updateStats() {
document.getElementById('generation').textContent = this.generation;
document.getElementById('population').textContent = this.getPopulation();
document.getElementById('births').textContent = this.births;
document.getElementById('deaths').textContent = this.deaths;
}
getPopulation() {
let count = 0;
for (let y = 0; y < this.height; y++) {
for (let x = 0; x < this.width; x++) {
count += this.grid[y][x];
}
}
return count;
}
gameLoop() {
this.update();
this.draw();
// Adjust speed (higher value = faster)
const speedFactor = (21 - this.speed) * 20;
setTimeout(() => {
if (this.running) {
this.animationId = requestAnimationFrame(() => this.gameLoop());
}
}, speedFactor);
}
start() {
if (!this.running) {
this.running = true;
this.gameLoop();
}
}
pause() {
this.running = false;
if (this.animationId) {
cancelAnimationFrame(this.animationId);
this.animationId = null;
}
}
reset() {
this.pause();
this.grid = this.createGrid();
this.nextGrid = this.createGrid();
this.generation = 0;
this.births = 0;
this.deaths = 0;
this.updateStats();
this.draw();
}
}
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', sparkles);
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);