Compare commits

...

2 Commits

Author SHA1 Message Date
68079f4f90 Merge branch 'main' of https://fun-md.com/Fun_MD/test01 2026-04-08 21:31:13 +08:00
2bd6c441d4 初始化项目并上传代码 2026-04-08 20:56:47 +08:00
7 changed files with 1940 additions and 0 deletions

549
app.js Normal file
View File

@@ -0,0 +1,549 @@
(function (global) {
'use strict';
const PATTERNS = {
glider: {
name: 'Glider',
cells: [
[0, 1],
[1, 2],
[2, 0],
[2, 1],
[2, 2],
],
},
pulsar: {
name: 'Pulsar',
cells: [
[0, 2], [0, 3], [0, 4], [0, 8], [0, 9], [0, 10],
[2, 0], [2, 5], [2, 7], [2, 12],
[3, 0], [3, 5], [3, 7], [3, 12],
[4, 0], [4, 5], [4, 7], [4, 12],
[5, 2], [5, 3], [5, 4], [5, 8], [5, 9], [5, 10],
[7, 2], [7, 3], [7, 4], [7, 8], [7, 9], [7, 10],
[8, 0], [8, 5], [8, 7], [8, 12],
[9, 0], [9, 5], [9, 7], [9, 12],
[10, 0], [10, 5], [10, 7], [10, 12],
[12, 2], [12, 3], [12, 4], [12, 8], [12, 9], [12, 10],
],
},
gosperGliderGun: {
name: 'Gosper Glider Gun',
cells: [
[0, 24],
[1, 22], [1, 24],
[2, 12], [2, 13], [2, 20], [2, 21], [2, 34], [2, 35],
[3, 11], [3, 15], [3, 20], [3, 21], [3, 34], [3, 35],
[4, 0], [4, 1], [4, 10], [4, 16], [4, 20], [4, 21],
[5, 0], [5, 1], [5, 10], [5, 14], [5, 16], [5, 17], [5, 22], [5, 24],
[6, 10], [6, 16], [6, 24],
[7, 11], [7, 15],
[8, 12], [8, 13],
],
},
};
function createEmptyGrid(rows, cols) {
return Array.from({ length: rows }, function () {
return Array(cols).fill(0);
});
}
function cloneGrid(grid) {
return grid.map(function (row) {
return row.slice();
});
}
function countLiveNeighbors(grid, row, col) {
let total = 0;
for (let rowOffset = -1; rowOffset <= 1; rowOffset += 1) {
for (let colOffset = -1; colOffset <= 1; colOffset += 1) {
if (rowOffset === 0 && colOffset === 0) {
continue;
}
const nextRow = row + rowOffset;
const nextCol = col + colOffset;
if (
nextRow >= 0 &&
nextRow < grid.length &&
nextCol >= 0 &&
nextCol < grid[0].length
) {
total += grid[nextRow][nextCol];
}
}
}
return total;
}
function stepGrid(grid) {
return grid.map(function (cells, rowIndex) {
return cells.map(function (cell, colIndex) {
const neighbors = countLiveNeighbors(grid, rowIndex, colIndex);
if (cell === 1) {
return neighbors === 2 || neighbors === 3 ? 1 : 0;
}
return neighbors === 3 ? 1 : 0;
});
});
}
function stampPattern(grid, pattern, offsetRow, offsetCol) {
pattern.cells.forEach(function (cell) {
const row = cell[0] + offsetRow;
const col = cell[1] + offsetCol;
if (row >= 0 && row < grid.length && col >= 0 && col < grid[0].length) {
grid[row][col] = 1;
}
});
return grid;
}
function countLiveCells(grid) {
return grid.reduce(function (total, row) {
return total + row.reduce(function (rowTotal, cell) {
return rowTotal + cell;
}, 0);
}, 0);
}
function centerPattern(grid, pattern) {
const lastRow = pattern.cells.reduce(function (maxRow, cell) {
return Math.max(maxRow, cell[0]);
}, 0);
const lastCol = pattern.cells.reduce(function (maxCol, cell) {
return Math.max(maxCol, cell[1]);
}, 0);
const offsetRow = Math.max(0, Math.floor((grid.length - (lastRow + 1)) / 2));
const offsetCol = Math.max(0, Math.floor((grid[0].length - (lastCol + 1)) / 2));
return stampPattern(grid, pattern, offsetRow, offsetCol);
}
function createInitialState(options) {
const rows = options.rows;
const cols = options.cols;
const defaultPattern = options.defaultPattern || 'pulsar';
const grid = createEmptyGrid(rows, cols);
centerPattern(grid, PATTERNS[defaultPattern]);
return {
cols: cols,
defaultPattern: defaultPattern,
generation: 0,
grid: grid,
liveCount: countLiveCells(grid),
rows: rows,
running: false,
selectedPattern: defaultPattern,
speed: 1,
};
}
function randomizeGrid(grid, probability) {
const chance = typeof probability === 'number' ? probability : 0.28;
return grid.map(function (row) {
return row.map(function () {
return Math.random() < chance ? 1 : 0;
});
});
}
function toggleCell(grid, row, col, forcedValue) {
const next = cloneGrid(grid);
if (row < 0 || row >= next.length || col < 0 || col >= next[0].length) {
return next;
}
next[row][col] = typeof forcedValue === 'number' ? forcedValue : next[row][col] ? 0 : 1;
return next;
}
function applyPreset(state, patternName) {
const grid = createEmptyGrid(state.rows, state.cols);
const nextPattern = PATTERNS[patternName] ? patternName : state.defaultPattern;
centerPattern(grid, PATTERNS[nextPattern]);
return {
cols: state.cols,
defaultPattern: state.defaultPattern,
generation: 0,
grid: grid,
liveCount: countLiveCells(grid),
rows: state.rows,
running: false,
selectedPattern: nextPattern,
speed: state.speed,
};
}
function advanceState(state) {
const nextGrid = stepGrid(state.grid);
return Object.assign({}, state, {
generation: state.generation + 1,
grid: nextGrid,
liveCount: countLiveCells(nextGrid),
});
}
function setSpeed(state, speed) {
return Object.assign({}, state, {
speed: Math.max(1, Math.min(6, speed)),
});
}
function getSpeedLabel(speed) {
if (speed <= 1) {
return 'Drift';
}
if (speed <= 3) {
return 'Pulse';
}
if (speed <= 5) {
return 'Hyper';
}
return 'Lightstorm';
}
function getSpeedDelay(speed) {
const delays = {
1: 520,
2: 340,
3: 220,
4: 140,
5: 90,
6: 60,
};
return delays[Math.max(1, Math.min(6, speed))];
}
function getPatternLabel(patternName) {
return PATTERNS[patternName] ? PATTERNS[patternName].name : 'Custom';
}
function createEmptyLike(state) {
return createEmptyGrid(state.rows, state.cols);
}
function replaceGrid(state, grid, selectedPattern) {
return Object.assign({}, state, {
generation: 0,
grid: grid,
liveCount: countLiveCells(grid),
running: false,
selectedPattern: selectedPattern || 'custom',
});
}
function getCanvasPoint(event, canvas, state) {
const rect = canvas.getBoundingClientRect();
const col = Math.floor(((event.clientX - rect.left) / rect.width) * state.cols);
const row = Math.floor(((event.clientY - rect.top) / rect.height) * state.rows);
if (row < 0 || row >= state.rows || col < 0 || col >= state.cols) {
return null;
}
return { row: row, col: col };
}
function resizeCanvas(canvas) {
const ratio = global.devicePixelRatio || 1;
const bounds = canvas.getBoundingClientRect();
const width = Math.max(1, Math.floor(bounds.width * ratio));
const height = Math.max(1, Math.floor(bounds.height * ratio));
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
}
function drawSimulation(canvas, state) {
const context = canvas.getContext('2d');
const width = canvas.width;
const height = canvas.height;
const cellWidth = width / state.cols;
const cellHeight = height / state.rows;
const inset = Math.max(1.5, Math.min(cellWidth, cellHeight) * 0.12);
context.clearRect(0, 0, width, height);
const background = context.createLinearGradient(0, 0, width, height);
background.addColorStop(0, '#081321');
background.addColorStop(1, '#040a13');
context.fillStyle = background;
context.fillRect(0, 0, width, height);
const bloom = context.createRadialGradient(width * 0.18, height * 0.16, 0, width * 0.18, height * 0.16, width * 0.55);
bloom.addColorStop(0, 'rgba(115, 240, 221, 0.15)');
bloom.addColorStop(1, 'rgba(115, 240, 221, 0)');
context.fillStyle = bloom;
context.fillRect(0, 0, width, height);
context.beginPath();
context.strokeStyle = 'rgba(126, 192, 212, 0.1)';
context.lineWidth = 1;
for (let row = 0; row <= state.rows; row += 1) {
const y = Math.round(row * cellHeight) + 0.5;
context.moveTo(0, y);
context.lineTo(width, y);
}
for (let col = 0; col <= state.cols; col += 1) {
const x = Math.round(col * cellWidth) + 0.5;
context.moveTo(x, 0);
context.lineTo(x, height);
}
context.stroke();
for (let rowIndex = 0; rowIndex < state.rows; rowIndex += 1) {
for (let colIndex = 0; colIndex < state.cols; colIndex += 1) {
if (!state.grid[rowIndex][colIndex]) {
continue;
}
const x = colIndex * cellWidth + inset;
const y = rowIndex * cellHeight + inset;
const drawWidth = Math.max(2, cellWidth - inset * 2);
const drawHeight = Math.max(2, cellHeight - inset * 2);
const fill = context.createLinearGradient(x, y, x + drawWidth, y + drawHeight);
fill.addColorStop(0, 'rgba(115, 240, 221, 0.98)');
fill.addColorStop(1, 'rgba(190, 252, 125, 0.94)');
context.fillStyle = fill;
context.shadowColor = 'rgba(115, 240, 221, 0.4)';
context.shadowBlur = Math.max(8, Math.min(cellWidth, cellHeight) * 0.66);
context.fillRect(x, y, drawWidth, drawHeight);
}
}
context.shadowBlur = 0;
}
function renderState(state, refs) {
resizeCanvas(refs.canvas);
drawSimulation(refs.canvas, state);
refs.statusText.textContent = state.running ? 'Running' : 'Paused';
refs.statusText.classList.toggle('running', state.running);
refs.speedLabel.textContent = getSpeedLabel(state.speed);
refs.speedReadout.textContent = state.speed + ' / 6';
refs.generationValue.textContent = String(state.generation);
refs.liveValue.textContent = String(state.liveCount);
refs.presetValue.textContent = getPatternLabel(state.selectedPattern);
refs.heroGeneration.textContent = String(state.generation);
refs.heroLive.textContent = String(state.liveCount);
refs.heroPattern.textContent = getPatternLabel(state.selectedPattern);
refs.canvasHint.textContent = state.running ? '演化进行中,拖拽会自动暂停' : '点击或拖拽即可绘制细胞';
refs.playToggle.textContent = state.running ? '暂停演化' : '开始演化';
refs.stepButton.disabled = state.running;
refs.speedSlider.value = String(state.speed);
refs.presetButtons.forEach(function (button) {
button.classList.toggle('active', button.dataset.pattern === state.selectedPattern);
});
}
function initDemo() {
const canvas = document.getElementById('life-canvas');
if (!canvas) {
return;
}
const refs = {
canvas: canvas,
canvasHint: document.getElementById('canvas-hint'),
clearButton: document.getElementById('clear-button'),
generationValue: document.getElementById('generation-value'),
heroGeneration: document.getElementById('hero-generation'),
heroLive: document.getElementById('hero-live'),
heroPattern: document.getElementById('hero-pattern'),
liveValue: document.getElementById('live-value'),
loadDefaultHero: document.getElementById('load-default-hero'),
playToggle: document.getElementById('play-toggle'),
presetButtons: Array.from(document.querySelectorAll('[data-pattern]')),
presetValue: document.getElementById('preset-value'),
randomButton: document.getElementById('random-button'),
resetButton: document.getElementById('reset-button'),
speedLabel: document.getElementById('speed-label'),
speedReadout: document.getElementById('speed-readout'),
speedSlider: document.getElementById('speed-slider'),
statusText: document.getElementById('status-text'),
stepButton: document.getElementById('step-button'),
};
const baseSize = global.innerWidth <= 720 ? 34 : 44;
let state = createInitialState({
rows: baseSize,
cols: baseSize,
defaultPattern: 'pulsar',
});
let lastTick = 0;
let drawing = false;
let drawValue = 1;
let lastCellKey = '';
function setState(nextState) {
state = nextState;
renderState(state, refs);
}
function applyCanvasPoint(event) {
const point = getCanvasPoint(event, refs.canvas, state);
if (!point) {
return;
}
const cellKey = point.row + ':' + point.col;
if (cellKey === lastCellKey) {
return;
}
lastCellKey = cellKey;
const nextGrid = toggleCell(state.grid, point.row, point.col, drawValue);
setState(replaceGrid(state, nextGrid, 'custom'));
}
refs.playToggle.addEventListener('click', function () {
setState(Object.assign({}, state, {
running: !state.running,
}));
});
refs.stepButton.addEventListener('click', function () {
if (!state.running) {
setState(advanceState(state));
}
});
refs.clearButton.addEventListener('click', function () {
setState(replaceGrid(state, createEmptyLike(state), 'custom'));
});
refs.randomButton.addEventListener('click', function () {
setState(replaceGrid(state, randomizeGrid(createEmptyLike(state), 0.22), 'custom'));
});
refs.resetButton.addEventListener('click', function () {
const patternName = PATTERNS[state.selectedPattern] ? state.selectedPattern : state.defaultPattern;
setState(applyPreset(state, patternName));
});
refs.loadDefaultHero.addEventListener('click', function () {
setState(applyPreset(state, state.defaultPattern));
document.getElementById('lab').scrollIntoView({ behavior: 'smooth', block: 'start' });
});
refs.speedSlider.addEventListener('input', function (event) {
const speed = Number(event.target.value);
setState(setSpeed(state, speed));
});
refs.presetButtons.forEach(function (button) {
button.addEventListener('click', function () {
setState(applyPreset(state, button.dataset.pattern));
});
});
refs.canvas.addEventListener('pointerdown', function (event) {
const point = getCanvasPoint(event, refs.canvas, state);
if (!point) {
return;
}
drawing = true;
lastCellKey = '';
drawValue = state.grid[point.row][point.col] ? 0 : 1;
refs.canvas.setPointerCapture(event.pointerId);
applyCanvasPoint(event);
});
refs.canvas.addEventListener('pointermove', function (event) {
if (!drawing) {
return;
}
applyCanvasPoint(event);
});
function stopDrawing(event) {
if (drawing && event && refs.canvas.hasPointerCapture(event.pointerId)) {
refs.canvas.releasePointerCapture(event.pointerId);
}
drawing = false;
lastCellKey = '';
}
refs.canvas.addEventListener('pointerup', stopDrawing);
refs.canvas.addEventListener('pointerleave', stopDrawing);
global.addEventListener('resize', function () {
renderState(state, refs);
});
function animate(timestamp) {
if (state.running && timestamp - lastTick >= getSpeedDelay(state.speed)) {
lastTick = timestamp;
setState(advanceState(state));
}
global.requestAnimationFrame(animate);
}
renderState(state, refs);
global.requestAnimationFrame(animate);
}
const api = {
advanceState: advanceState,
applyPreset: applyPreset,
PATTERNS: PATTERNS,
cloneGrid: cloneGrid,
countLiveNeighbors: countLiveNeighbors,
countLiveCells: countLiveCells,
createInitialState: createInitialState,
createEmptyGrid: createEmptyGrid,
getSpeedDelay: getSpeedDelay,
getSpeedLabel: getSpeedLabel,
randomizeGrid: randomizeGrid,
setSpeed: setSpeed,
stampPattern: stampPattern,
stepGrid: stepGrid,
toggleCell: toggleCell,
};
if (typeof module !== 'undefined' && module.exports) {
module.exports = api;
}
global.LifeDemo = api;
if (typeof document !== 'undefined') {
document.addEventListener('DOMContentLoaded', initDemo);
}
})(typeof window !== 'undefined' ? window : globalThis);

View File

@@ -0,0 +1,226 @@
# Conway Life Demo Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build a direct-open Conway's Game of Life demo page with a modern visualization style, preset patterns, canvas-based simulation, and a clean teaching-plus-exploration flow.
**Architecture:** Keep the page build-free and local-file friendly: semantic markup in `index.html`, visual styling in `styles.css`, and a single browser script in `app.js`. Put Conway engine logic and state helpers in testable pure functions exported from `app.js`, while the DOM/controller layer wires those functions to the canvas UI.
**Tech Stack:** HTML5, CSS3, vanilla JavaScript, Canvas API, Node.js built-in test runner (`node --test`)
---
Note: this workspace is not a git repository, so commit steps are intentionally omitted.
## File Structure
- Create: `index.html` - page structure, content sections, script/style includes
- Create: `styles.css` - theme variables, layout, cards, control panel, responsive styling, animation polish
- Create: `app.js` - Conway engine, preset definitions, state helpers, canvas rendering, UI events
- Create: `tests/life-demo.test.js` - Node tests for pure simulation and state logic
## Chunk 1: Build and test the Conway engine
### Task 1: Create the failing engine tests
**Files:**
- Create: `tests/life-demo.test.js`
- Test: `tests/life-demo.test.js`
- [ ] **Step 1: Write the failing test**
```js
const test = require('node:test');
const assert = require('node:assert/strict');
const {
createEmptyGrid,
stepGrid,
stampPattern,
PATTERNS,
} = require('../app.js');
test('blinker rotates after one generation', () => {
const grid = createEmptyGrid(5, 5);
grid[2][1] = 1;
grid[2][2] = 1;
grid[2][3] = 1;
const next = stepGrid(grid);
assert.deepEqual(next[1], [0, 0, 1, 0, 0]);
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `node --test tests/life-demo.test.js`
Expected: FAIL because `app.js` or the exported functions do not exist yet.
- [ ] **Step 3: Write minimal implementation**
```js
function createEmptyGrid(rows, cols) {
return Array.from({ length: rows }, () => Array(cols).fill(0));
}
```
Implement the smallest export set needed to pass:
- `createEmptyGrid(rows, cols)`
- `countLiveNeighbors(grid, row, col)`
- `stepGrid(grid)`
- `stampPattern(grid, pattern, offsetRow, offsetCol)`
- `PATTERNS` for `glider`, `pulsar`, and `gosperGliderGun`
- [ ] **Step 4: Run test to verify it passes**
Run: `node --test tests/life-demo.test.js`
Expected: PASS for the engine tests.
## Chunk 2: Add state helpers and page shell
### Task 2: Add failing tests for state helpers
**Files:**
- Modify: `tests/life-demo.test.js`
- Modify: `app.js`
- Create: `index.html`
- Create: `styles.css`
- [ ] **Step 1: Write the failing test**
Add tests for:
```js
test('createInitialState seeds the default preset and starts paused', () => {
const state = createInitialState({
rows: 20,
cols: 20,
defaultPattern: 'pulsar',
});
assert.equal(state.running, false);
assert.equal(state.selectedPattern, 'pulsar');
assert.equal(state.generation, 0);
assert.ok(state.liveCount > 0);
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `node --test tests/life-demo.test.js`
Expected: FAIL because `createInitialState` is not implemented yet.
- [ ] **Step 3: Write minimal implementation**
Implement pure helpers:
- `cloneGrid(grid)`
- `countLiveCells(grid)`
- `createInitialState({ rows, cols, defaultPattern })`
- `randomizeGrid(grid, probability)`
- `toggleCell(grid, row, col, forcedValue)`
At the same time, scaffold:
- `index.html` with Hero, Lab, Rules, and Presets sections
- `styles.css` with the modern visualization theme variables and responsive grid
- [ ] **Step 4: Run test to verify it passes**
Run: `node --test tests/life-demo.test.js`
Expected: PASS for the new state-helper tests.
## Chunk 3: Wire controls, canvas rendering, and preset switching
### Task 3: Add failing tests for UI-facing state transitions
**Files:**
- Modify: `tests/life-demo.test.js`
- Modify: `app.js`
- Modify: `index.html`
- Modify: `styles.css`
- [ ] **Step 1: Write the failing test**
Add tests for:
```js
test('applyPreset replaces the grid, pauses playback, and resets generation', () => {
const state = {
...createInitialState({ rows: 25, cols: 25, defaultPattern: 'glider' }),
running: true,
generation: 12,
};
const next = applyPreset(state, 'gosperGliderGun');
assert.equal(next.running, false);
assert.equal(next.generation, 0);
assert.equal(next.selectedPattern, 'gosperGliderGun');
assert.ok(next.liveCount > state.liveCount);
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `node --test tests/life-demo.test.js`
Expected: FAIL because `applyPreset` is not implemented yet.
- [ ] **Step 3: Write minimal implementation**
Implement:
- `applyPreset(state, patternName)`
- `advanceState(state)`
- `setSpeed(state, speed)`
- `getSpeedLabel(speed)`
Then wire browser behavior:
- draw the grid on a `canvas`
- support play/pause, step, clear, randomize, reset preset, and speed controls
- support click and drag painting on the canvas
- update status text, generation count, live-count, and active preset styling
- [ ] **Step 4: Run test to verify it passes**
Run: `node --test tests/life-demo.test.js`
Expected: PASS for the state-transition tests.
## Chunk 4: Polish visual details and run end-to-end verification
### Task 4: Finish presentation details and verify manually
**Files:**
- Modify: `index.html`
- Modify: `styles.css`
- Modify: `app.js`
- Test: `tests/life-demo.test.js`
- [ ] **Step 1: Refine the visuals**
Complete:
- luminous background gradients and grid accents
- polished cards and control panel surfaces
- hover/focus states
- responsive stacking for smaller screens
- subtle canvas/section entrance motion
- [ ] **Step 2: Run automated tests**
Run: `node --test tests/life-demo.test.js`
Expected: PASS with zero failures.
- [ ] **Step 3: Run manual verification**
Open: `index.html`
Verify:
- page opens directly without a dev server
- pulsar loads by default and the simulation starts paused
- controls behave correctly
- each preset loads and pauses correctly
- editing by click/drag works
- desktop and narrow layouts both remain usable
- [ ] **Step 4: Record any gaps**
If a browser-only issue appears, fix it and rerun:
- `node --test tests/life-demo.test.js`
- manual browser verification of `index.html`

View File

@@ -0,0 +1,105 @@
# Conway Life Demo Design
Date: 2026-03-17
Status: Approved
## Goal
Build a pure HTML/CSS/JS demo page for Conway's Game of Life that opens directly from `index.html`, balances teaching and interaction, and presents the simulation with a modern visualization aesthetic.
## Audience and Use Case
- Learners seeing cellular automata for the first time
- Users who want to experiment by drawing patterns and watching them evolve
- Anyone who wants a polished, shareable standalone demo page
## Product Direction
- Topic: Conway's Game of Life
- Page type: balanced demo page
- Delivery: direct-open HTML/CSS/JS page
- Feature scope: enhanced version with preset patterns
- Visual style: modern visualization
- Default state: load a preset pattern and remain paused
## Layout
### 1. Hero
- Full-width first screen with an atmospheric simulation-inspired background
- Title, short explanatory subtitle, and a clear entry action that scrolls to the lab
- Visual treatment based on luminous grid lines, soft glow, and scientific/data-art styling
### 2. Main Lab
- Large simulation canvas as the primary focus
- Side control panel on desktop; stacked layout on mobile
- Status area showing running/paused state and current speed
### 3. Rules Section
- Three compact rule cards explaining survival, birth, and death
- Short copy only; no heavy educational text blocks
- Small visual motifs to keep the explanation approachable
### 4. Preset Pattern Section
- Presets: glider, pulsar, and Gosper glider gun
- Clicking a preset loads it into the simulation and pauses playback
- Section doubles as both a teaching aid and a quick demo launcher
## Interaction Design
### Controls
- Start/Pause
- Step one generation
- Clear
- Randomize
- Reset to selected preset
- Speed adjustment
### Canvas Interaction
- Click a cell to toggle it
- Drag to paint cells continuously
- Keep the simulation paused when loading or editing presets so the user stays in control
### Simulation Rules
- Classic Conway rules
- Finite grid with non-wrapping edges
- Default opening preset: pulsar
## Visual System
- Deep dark background with cool white text
- Cyan and acid-green highlights for live-state emphasis
- Semi-transparent panels, subtle borders, soft bloom, and layered gradients
- Refined motion: gentle glow, state transitions, and staggered reveal rather than flashy arcade effects
## Technical Approach
- Use `canvas` for the simulation surface to keep updates smooth and visually polished
- Keep simulation rules in pure functions so they can be tested outside the browser
- Keep page assets local so `index.html` can open directly without a build step
## Responsiveness
- Desktop: wide lab layout with canvas first and controls beside it
- Mobile: stacked layout with the canvas above controls
- Preset cards become horizontally scrollable on narrower screens
## Usability Details
- Disable actions when they should not apply, such as disabling step while running
- Show the active preset and speed tier clearly
- Short helper copy explaining that each cell looks at its eight neighbors
## Verification Targets
- Conway rule updates are correct
- Start/pause and single-step work as expected
- Presets load correctly
- Editing cells updates the simulation state correctly
- Layout remains usable after resizing

View File

@@ -0,0 +1,49 @@
# 项目结构图与运行入口说明1 分钟版)
## 一、项目结构图
```text
test/
├─ index.html # 页面入口(直接在浏览器打开)
├─ styles.css # 页面样式
├─ app.js # 生命游戏核心引擎 + UI 交互控制
├─ tests/
│ └─ life-demo.test.js # Node 测试(核心规则与状态逻辑)
└─ docs/
└─ 项目结构图与运行入口说明.md # 本说明文档
```
## 二、运行入口
### 1) 浏览器入口(交互演示)
- 入口文件:`index.html`
- 加载脚本:`index.html` 底部通过 `<script src="./app.js"></script>` 引入引擎与交互逻辑。
- 打开方式:双击 `index.html`(或在浏览器中打开本地文件)。
### 2) 脚本初始化入口
- `app.js``DOMContentLoaded` 后执行 `initDemo()`,完成:
- 初始状态创建(默认预设、网格尺寸、暂停状态)
- 画布渲染与动画循环
- 控件事件绑定(开始/暂停、单步、清空、随机、重置预设、速度滑条)
- 画布点击/拖拽绘制
## 三、关键代码定位
- 预设图案定义:`app.js:4`
- 交互初始化入口:`app.js:368`
- 开始/暂停等主控事件:`app.js:430`
- Node 导出(供测试复用):`app.js:540`
- 测试起始位置:`tests/life-demo.test.js:14`
## 四、测试入口
- 测试文件:`tests/life-demo.test.js`
- 推荐命令(当前环境可用):
```bash
node tests/life-demo.test.js
```
> 说明:在当前受限环境下,`node --test tests/life-demo.test.js` 可能因子进程权限报 `EPERM`,但直接执行测试文件可正常完成 9 项测试。

196
index.html Normal file
View File

@@ -0,0 +1,196 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Conway Life Lab</title>
<link rel="stylesheet" href="./styles.css">
</head>
<body>
<div class="page-shell">
<header class="hero">
<div class="hero-copy">
<p class="eyebrow">CELLULAR AUTOMATA / CONWAY LIFE LAB</p>
<h1>观察八个邻居,如何让一张静止网格自己长出秩序。</h1>
<p class="hero-intro">
这是一个可以直接打开的康威生命游戏演示页:先看规则,再改格子、切预设、调速度,
用最经典的元胞自动机感受局部规则如何生成整体行为。
</p>
<div class="hero-actions">
<a class="primary-link" href="#lab">进入实验台</a>
<button class="secondary-link" id="load-default-hero" type="button">重新载入默认图案</button>
</div>
<ul class="hero-pills">
<li>默认载入 <strong>脉冲星</strong> 并暂停</li>
<li>支持点击与拖拽绘制</li>
<li>可切换滑翔机、脉冲星、滑翔机枪</li>
</ul>
</div>
<div class="hero-visual" aria-hidden="true">
<div class="signal-board">
<div class="signal-layer layer-a"></div>
<div class="signal-layer layer-b"></div>
<div class="signal-core">
<span class="signal-dot live"></span>
<span class="signal-dot"></span>
<span class="signal-dot live"></span>
<span class="signal-dot"></span>
<span class="signal-dot live accent"></span>
<span class="signal-dot"></span>
<span class="signal-dot live"></span>
<span class="signal-dot"></span>
<span class="signal-dot live"></span>
</div>
</div>
<div class="hero-metrics card-surface">
<div class="metric-row">
<span class="label">Preset</span>
<strong id="hero-pattern">Pulsar</strong>
</div>
<div class="metric-row">
<span class="label">Generation</span>
<strong id="hero-generation">0</strong>
</div>
<div class="metric-row">
<span class="label">Live Cells</span>
<strong id="hero-live">0</strong>
</div>
</div>
</div>
</header>
<main>
<section class="lab-section" id="lab">
<div class="section-heading">
<p class="eyebrow">Interactive Lab</p>
<h2>在画布里编辑生命,控制它的下一代。</h2>
<p>
每个细胞只看周围 8 个邻居。存活、诞生、死亡这三条规则,会在一代代迭代里形成移动、
呼吸、振荡甚至复制的图案。
</p>
</div>
<div class="lab-layout">
<div class="canvas-panel card-surface">
<div class="canvas-toolbar">
<div>
<p class="label">Simulation Surface</p>
<h3>Conway Field</h3>
</div>
<div class="status-cluster">
<span class="status-pill" id="status-text">Paused</span>
<span class="speed-pill" id="speed-label">Drift</span>
</div>
</div>
<div class="canvas-shell">
<canvas id="life-canvas" aria-label="Conway life simulation canvas"></canvas>
<div class="canvas-caption">
<span id="canvas-hint">点击或拖拽即可绘制细胞</span>
</div>
</div>
<div class="canvas-footer">
<div class="mini-stat">
<span class="label">Generation</span>
<strong id="generation-value">0</strong>
</div>
<div class="mini-stat">
<span class="label">Live</span>
<strong id="live-value">0</strong>
</div>
<div class="mini-stat">
<span class="label">Preset</span>
<strong id="preset-value">Pulsar</strong>
</div>
</div>
</div>
<aside class="control-panel card-surface">
<div class="panel-block">
<p class="label">Playback</p>
<div class="button-grid">
<button class="primary-button" id="play-toggle" type="button">开始演化</button>
<button id="step-button" type="button">单步</button>
<button id="clear-button" type="button">清空</button>
<button id="random-button" type="button">随机</button>
<button id="reset-button" type="button">重置预设</button>
</div>
</div>
<div class="panel-block">
<div class="panel-row">
<p class="label">Speed</p>
<strong id="speed-readout">1 / 6</strong>
</div>
<input id="speed-slider" type="range" min="1" max="6" step="1" value="1">
<p class="support-copy">从缓慢观察到高速演化,速度越高,图案越容易呈现整体节奏。</p>
</div>
<div class="panel-block">
<p class="label">How To Read</p>
<ul class="info-list">
<li>活细胞周围邻居过少会死亡</li>
<li>有 2 或 3 个邻居时会继续存活</li>
<li>空白位置恰好有 3 个邻居时会诞生新细胞</li>
</ul>
</div>
</aside>
</div>
</section>
<section class="rules-section">
<div class="section-heading">
<p class="eyebrow">Rule Snapshot</p>
<h2>三条规则,就足够让图案产生复杂行为。</h2>
</div>
<div class="rules-grid">
<article class="rule-card card-surface">
<span class="rule-index">01</span>
<h3>存活</h3>
<p>一个活细胞如果有 2 或 3 个邻居,就继续存在到下一代。</p>
</article>
<article class="rule-card card-surface">
<span class="rule-index">02</span>
<h3>诞生</h3>
<p>一个空白位置如果恰好有 3 个邻居,就会在下一代长出新的活细胞。</p>
</article>
<article class="rule-card card-surface">
<span class="rule-index">03</span>
<h3>死亡</h3>
<p>活细胞邻居少于 2 个会孤独死亡,多于 3 个则会因为拥挤而死亡。</p>
</article>
</div>
</section>
<section class="presets-section">
<div class="section-heading">
<p class="eyebrow">Preset Patterns</p>
<h2>一键加载经典结构,观察它们如何移动、振荡与扩散。</h2>
</div>
<div class="preset-grid">
<button class="preset-card card-surface active" data-pattern="pulsar" type="button">
<span class="preset-tag">Default</span>
<h3>脉冲星</h3>
<p>经典振荡图案,适合观察规则如何形成节律。</p>
</button>
<button class="preset-card card-surface" data-pattern="glider" type="button">
<span class="preset-tag">Mover</span>
<h3>滑翔机</h3>
<p>最著名的移动结构,会沿对角线不断前进。</p>
</button>
<button class="preset-card card-surface" data-pattern="gosperGliderGun" type="button">
<span class="preset-tag">Emitter</span>
<h3>滑翔机枪</h3>
<p>持续喷射滑翔机的结构,展示局部规则如何形成长期模式。</p>
</button>
</div>
</section>
</main>
</div>
<script src="./app.js"></script>
</body>
</html>

667
styles.css Normal file
View File

@@ -0,0 +1,667 @@
:root {
--bg: #07111f;
--bg-soft: rgba(11, 22, 38, 0.88);
--panel: rgba(10, 20, 35, 0.72);
--panel-strong: rgba(12, 24, 40, 0.92);
--line: rgba(116, 175, 199, 0.18);
--line-strong: rgba(143, 220, 232, 0.34);
--text: #edf8fb;
--muted: #98b6c5;
--accent: #73f0dd;
--accent-strong: #befc7d;
--shadow: 0 24px 80px rgba(0, 0, 0, 0.32);
--radius-xl: 30px;
--radius-lg: 22px;
--radius-md: 16px;
--heading-font: "Microsoft YaHei UI", "Noto Sans SC", sans-serif;
--body-font: "Microsoft YaHei", "Noto Sans SC", sans-serif;
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
min-height: 100vh;
color: var(--text);
font-family: var(--body-font);
background:
radial-gradient(circle at top left, rgba(45, 109, 162, 0.22), transparent 30%),
radial-gradient(circle at 80% 10%, rgba(115, 240, 221, 0.16), transparent 28%),
radial-gradient(circle at bottom right, rgba(190, 252, 125, 0.12), transparent 24%),
linear-gradient(180deg, #07111f 0%, #040914 55%, #07101d 100%);
overflow-x: hidden;
}
body::before,
body::after {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: -1;
}
body::before {
background-image:
linear-gradient(rgba(255, 255, 255, 0.028) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.028) 1px, transparent 1px);
background-size: 42px 42px;
mask-image: radial-gradient(circle at center, black 35%, transparent 88%);
}
body::after {
opacity: 0.18;
background:
radial-gradient(circle at 20% 20%, rgba(115, 240, 221, 0.4), transparent 12%),
radial-gradient(circle at 75% 25%, rgba(190, 252, 125, 0.25), transparent 10%),
radial-gradient(circle at 50% 80%, rgba(115, 167, 240, 0.18), transparent 14%);
filter: blur(50px);
}
a,
button,
input {
font: inherit;
}
button {
border: 0;
cursor: pointer;
}
.page-shell {
width: 100%;
max-width: 1240px;
margin: 0 auto;
padding: 32px 16px 72px;
}
.hero,
.lab-section,
.rules-section,
.presets-section {
position: relative;
}
.hero-copy,
.hero-visual,
.canvas-panel,
.control-panel,
.rule-card,
.preset-card {
min-width: 0;
}
.hero {
display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(320px, 0.85fr);
gap: 32px;
align-items: center;
min-height: min(92vh, 860px);
padding: 32px 0 56px;
}
.hero-copy h1,
.section-heading h2,
.canvas-toolbar h3,
.rule-card h3,
.preset-card h3 {
margin: 0;
font-family: var(--heading-font);
letter-spacing: -0.04em;
}
.hero-copy h1 {
max-width: 11ch;
font-size: clamp(3rem, 8vw, 6.8rem);
line-height: 0.95;
text-wrap: balance;
word-break: break-word;
}
.eyebrow,
.label {
margin: 0;
color: var(--accent);
letter-spacing: 0.18em;
text-transform: uppercase;
font-size: 0.72rem;
}
.hero-intro,
.section-heading p,
.support-copy,
.info-list,
.rule-card p,
.preset-card p {
color: var(--muted);
}
.hero-intro {
max-width: 36rem;
margin: 24px 0 0;
font-size: 1.06rem;
line-height: 1.75;
}
.hero-actions {
display: flex;
flex-wrap: wrap;
gap: 14px;
margin-top: 28px;
}
.primary-link,
.secondary-link,
.button-grid button {
border-radius: 999px;
transition: transform 180ms ease, box-shadow 180ms ease, border-color 180ms ease, background 180ms ease;
}
.primary-link,
.secondary-link {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 52px;
padding: 0 24px;
text-decoration: none;
}
.primary-link {
color: #05232a;
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-strong) 100%);
box-shadow: 0 18px 38px rgba(115, 240, 221, 0.25);
}
.secondary-link {
color: var(--text);
border: 1px solid var(--line-strong);
background: rgba(255, 255, 255, 0.04);
}
.hero-pills {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin: 30px 0 0;
padding: 0;
list-style: none;
}
.hero-pills li {
padding: 12px 16px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 999px;
background: rgba(255, 255, 255, 0.04);
color: var(--muted);
backdrop-filter: blur(12px);
}
.hero-visual {
position: relative;
display: grid;
place-items: center;
min-height: 560px;
}
.signal-board {
position: relative;
width: min(100%, 520px);
aspect-ratio: 1;
border-radius: 36px;
background:
linear-gradient(180deg, rgba(9, 18, 31, 0.9), rgba(10, 21, 33, 0.72)),
radial-gradient(circle at center, rgba(115, 240, 221, 0.15), transparent 58%);
border: 1px solid rgba(255, 255, 255, 0.08);
overflow: hidden;
box-shadow: var(--shadow);
}
.signal-board::before {
content: "";
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(115, 240, 221, 0.06) 1px, transparent 1px),
linear-gradient(90deg, rgba(115, 240, 221, 0.06) 1px, transparent 1px);
background-size: 10% 10%;
}
.signal-layer {
position: absolute;
inset: 14%;
border-radius: 28px;
border: 1px solid rgba(190, 252, 125, 0.18);
}
.layer-a {
animation: drift 14s linear infinite;
}
.layer-b {
inset: 22%;
border-color: rgba(115, 240, 221, 0.24);
animation: driftReverse 18s linear infinite;
}
.signal-core {
position: absolute;
inset: 50%;
width: 40%;
height: 40%;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14px;
transform: translate(-50%, -50%);
}
.signal-dot {
border-radius: 14px;
background: rgba(255, 255, 255, 0.04);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.05);
}
.signal-dot.live {
background: linear-gradient(135deg, rgba(115, 240, 221, 0.94), rgba(190, 252, 125, 0.94));
box-shadow:
0 0 28px rgba(115, 240, 221, 0.42),
inset 0 0 18px rgba(255, 255, 255, 0.22);
animation: pulseDot 3.6s ease-in-out infinite;
}
.signal-dot.accent {
animation-delay: 0.8s;
}
.hero-metrics {
position: absolute;
right: 0;
bottom: 24px;
width: min(300px, 85%);
}
.card-surface {
padding: 22px;
border-radius: var(--radius-xl);
border: 1px solid rgba(255, 255, 255, 0.08);
background:
linear-gradient(180deg, rgba(14, 24, 38, 0.82), rgba(8, 16, 29, 0.78)),
rgba(255, 255, 255, 0.02);
box-shadow: var(--shadow);
backdrop-filter: blur(20px);
}
.metric-row,
.panel-row,
.canvas-footer {
display: flex;
align-items: center;
justify-content: space-between;
}
.metric-row + .metric-row {
margin-top: 16px;
}
.metric-row strong,
.mini-stat strong,
.panel-row strong {
font-family: var(--heading-font);
font-size: 1.25rem;
}
.lab-section,
.rules-section,
.presets-section {
padding-top: 48px;
}
.section-heading {
max-width: 52rem;
margin-bottom: 28px;
}
.section-heading h2 {
font-size: clamp(2.1rem, 4vw, 3.6rem);
line-height: 0.98;
margin-top: 10px;
}
.section-heading p {
margin: 14px 0 0;
line-height: 1.75;
}
.lab-layout {
display: grid;
grid-template-columns: minmax(0, 1.25fr) minmax(320px, 0.75fr);
gap: 22px;
}
.canvas-panel,
.control-panel {
min-height: 100%;
}
.canvas-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.canvas-toolbar h3 {
margin-top: 6px;
font-size: 1.8rem;
}
.status-cluster {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10px;
}
.status-pill,
.speed-pill,
.preset-tag {
display: inline-flex;
align-items: center;
min-height: 36px;
padding: 0 14px;
border-radius: 999px;
font-size: 0.86rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.status-pill {
color: var(--bg);
background: linear-gradient(135deg, var(--accent) 0%, rgba(115, 240, 221, 0.72) 100%);
}
.status-pill.running {
background: linear-gradient(135deg, var(--accent-strong) 0%, var(--accent) 100%);
}
.speed-pill,
.preset-tag {
border: 1px solid var(--line-strong);
color: var(--text);
background: rgba(255, 255, 255, 0.05);
}
.canvas-shell {
position: relative;
margin-top: 22px;
padding: 18px;
border-radius: 26px;
background:
linear-gradient(180deg, rgba(6, 13, 24, 0.94), rgba(5, 11, 19, 0.9)),
rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.06);
}
#life-canvas {
display: block;
width: 100%;
aspect-ratio: 1;
border-radius: 18px;
background:
radial-gradient(circle at top left, rgba(115, 240, 221, 0.08), transparent 24%),
linear-gradient(180deg, rgba(6, 16, 27, 0.98), rgba(5, 12, 22, 0.98));
cursor: crosshair;
}
.canvas-caption {
display: flex;
justify-content: space-between;
margin-top: 12px;
color: var(--muted);
font-size: 0.94rem;
}
.canvas-footer {
gap: 16px;
margin-top: 18px;
}
.mini-stat {
flex: 1 1 0;
padding: 16px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.05);
}
.mini-stat .label {
display: block;
margin-bottom: 10px;
}
.panel-block + .panel-block {
margin-top: 22px;
}
.button-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-top: 14px;
}
.button-grid button,
.preset-card {
color: var(--text);
padding: 14px 16px;
border: 1px solid rgba(255, 255, 255, 0.09);
background: rgba(255, 255, 255, 0.04);
}
.button-grid button:hover,
.button-grid button:focus-visible,
.primary-link:hover,
.secondary-link:hover,
.preset-card:hover,
.preset-card:focus-visible {
transform: translateY(-2px);
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.16);
}
.button-grid .primary-button {
grid-column: span 2;
color: #04131e;
background: linear-gradient(135deg, var(--accent-strong), var(--accent));
}
.button-grid button:disabled {
cursor: not-allowed;
opacity: 0.45;
transform: none;
box-shadow: none;
}
#speed-slider {
width: 100%;
margin-top: 12px;
accent-color: var(--accent);
}
.support-copy {
margin: 12px 0 0;
line-height: 1.65;
}
.info-list {
margin: 14px 0 0;
padding-left: 18px;
line-height: 1.75;
}
.rules-grid,
.preset-grid {
display: grid;
gap: 18px;
}
.rules-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.rule-card {
min-height: 220px;
}
.rule-index {
display: inline-flex;
align-items: center;
justify-content: center;
width: 52px;
height: 52px;
border-radius: 16px;
font-family: var(--heading-font);
color: var(--bg);
background: linear-gradient(135deg, rgba(115, 240, 221, 0.94), rgba(190, 252, 125, 0.88));
}
.rule-card h3,
.preset-card h3 {
margin-top: 20px;
font-size: 1.5rem;
}
.rule-card p,
.preset-card p {
margin: 12px 0 0;
line-height: 1.75;
}
.preset-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.preset-card {
text-align: left;
border-radius: var(--radius-lg);
}
.preset-card.active {
border-color: rgba(115, 240, 221, 0.72);
background:
linear-gradient(180deg, rgba(14, 26, 39, 0.94), rgba(8, 17, 29, 0.84)),
rgba(255, 255, 255, 0.04);
box-shadow: 0 0 0 1px rgba(115, 240, 221, 0.24), var(--shadow);
}
@keyframes pulseDot {
0%,
100% {
transform: scale(0.94);
opacity: 0.72;
}
50% {
transform: scale(1.04);
opacity: 1;
}
}
@keyframes drift {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes driftReverse {
from {
transform: rotate(0deg) scale(1.02);
}
to {
transform: rotate(-360deg) scale(1.02);
}
}
@media (max-width: 980px) {
.hero,
.lab-layout,
.rules-grid,
.preset-grid {
grid-template-columns: 1fr;
}
.hero {
min-height: auto;
padding-top: 20px;
}
.hero-copy h1 {
max-width: none;
}
.hero-visual {
min-height: 420px;
}
.hero-metrics {
position: static;
width: 100%;
margin-top: 18px;
}
}
@media (max-width: 720px) {
.page-shell {
padding: 18px 10px 56px;
}
.canvas-footer,
.button-grid {
grid-template-columns: 1fr;
}
.canvas-footer {
flex-direction: column;
}
.button-grid .primary-button {
grid-column: auto;
}
.hero-actions,
.status-cluster {
flex-direction: column;
align-items: stretch;
}
.hero-copy h1 {
max-width: 9ch;
font-size: clamp(2.2rem, 11vw, 3rem);
line-height: 0.98;
}
.primary-link,
.secondary-link {
width: 100%;
}
.preset-grid {
display: flex;
overflow-x: auto;
padding-bottom: 6px;
scroll-snap-type: x proximity;
}
.preset-card {
min-width: min(300px, 84vw);
scroll-snap-align: start;
}
}

148
tests/life-demo.test.js Normal file
View File

@@ -0,0 +1,148 @@
const test = require('node:test');
const assert = require('node:assert/strict');
function loadApi() {
let api;
assert.doesNotThrow(() => {
api = require('../app.js');
}, 'expected app.js to export the Conway helpers');
return api;
}
test('blinker rotates after one generation', () => {
const { createEmptyGrid, stepGrid } = loadApi();
const grid = createEmptyGrid(5, 5);
grid[2][1] = 1;
grid[2][2] = 1;
grid[2][3] = 1;
const next = stepGrid(grid);
assert.deepEqual(next, [
[0, 0, 0, 0, 0],
[0, 0, 1, 0, 0],
[0, 0, 1, 0, 0],
[0, 0, 1, 0, 0],
[0, 0, 0, 0, 0],
]);
});
test('block stays stable across generations', () => {
const { createEmptyGrid, stepGrid } = loadApi();
const grid = createEmptyGrid(4, 4);
grid[1][1] = 1;
grid[1][2] = 1;
grid[2][1] = 1;
grid[2][2] = 1;
assert.deepEqual(stepGrid(grid), grid);
});
test('stampPattern places the glider cells inside the grid', () => {
const { createEmptyGrid, stampPattern, PATTERNS } = loadApi();
const grid = createEmptyGrid(6, 6);
stampPattern(grid, PATTERNS.glider, 1, 1);
assert.equal(grid[1][2], 1);
assert.equal(grid[2][3], 1);
assert.equal(grid[3][1], 1);
assert.equal(grid[3][2], 1);
assert.equal(grid[3][3], 1);
});
test('createInitialState seeds the default preset and starts paused', () => {
const { createInitialState } = loadApi();
const state = createInitialState({
rows: 20,
cols: 20,
defaultPattern: 'pulsar',
});
assert.equal(state.running, false);
assert.equal(state.selectedPattern, 'pulsar');
assert.equal(state.generation, 0);
assert.ok(state.liveCount > 0);
});
test('toggleCell flips a single cell without mutating the original grid', () => {
const { createEmptyGrid, toggleCell } = loadApi();
const grid = createEmptyGrid(3, 3);
const next = toggleCell(grid, 1, 1);
assert.equal(grid[1][1], 0);
assert.equal(next[1][1], 1);
});
test('applyPreset replaces the grid, pauses playback, and resets generation', () => {
const { applyPreset, createInitialState } = loadApi();
const state = {
...createInitialState({ rows: 30, cols: 30, defaultPattern: 'glider' }),
generation: 12,
running: true,
};
const next = applyPreset(state, 'gosperGliderGun');
assert.equal(next.running, false);
assert.equal(next.generation, 0);
assert.equal(next.selectedPattern, 'gosperGliderGun');
assert.ok(next.liveCount > 0);
});
test('advanceState steps the grid and increments generation', () => {
const { advanceState, createEmptyGrid } = loadApi();
const grid = createEmptyGrid(5, 5);
grid[2][1] = 1;
grid[2][2] = 1;
grid[2][3] = 1;
const next = advanceState({
cols: 5,
generation: 0,
grid: grid,
liveCount: 3,
rows: 5,
running: true,
selectedPattern: 'custom',
speed: 1,
});
assert.equal(next.generation, 1);
assert.equal(next.liveCount, 3);
assert.equal(next.grid[1][2], 1);
assert.equal(next.grid[2][2], 1);
assert.equal(next.grid[3][2], 1);
});
test('randomizeGrid respects probability extremes', () => {
const { createEmptyGrid, randomizeGrid } = loadApi();
const grid = createEmptyGrid(2, 3);
assert.deepEqual(randomizeGrid(grid, 0), [
[0, 0, 0],
[0, 0, 0],
]);
assert.deepEqual(randomizeGrid(grid, 1), [
[1, 1, 1],
[1, 1, 1],
]);
});
test('setSpeed returns a copied state with a readable label', () => {
const { createInitialState, getSpeedDelay, getSpeedLabel, setSpeed } = loadApi();
const state = createInitialState({ rows: 10, cols: 10, defaultPattern: 'glider' });
const next = setSpeed(state, 4);
assert.notEqual(next, state);
assert.equal(next.speed, 4);
assert.equal(getSpeedLabel(next.speed), 'Hyper');
assert.equal(getSpeedLabel(1), 'Drift');
assert.equal(getSpeedDelay(1), 520);
assert.equal(getSpeedDelay(6), 60);
});