commit 2bd6c441d4b13fdda7b46e974f15c79624f41c17 Author: seekee421 Date: Wed Apr 8 20:56:47 2026 +0800 初始化项目并上传代码 diff --git a/app.js b/app.js new file mode 100644 index 0000000..107dc94 --- /dev/null +++ b/app.js @@ -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); diff --git a/docs/superpowers/plans/2026-03-17-conway-life-demo.md b/docs/superpowers/plans/2026-03-17-conway-life-demo.md new file mode 100644 index 0000000..8f3d916 --- /dev/null +++ b/docs/superpowers/plans/2026-03-17-conway-life-demo.md @@ -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` diff --git a/docs/superpowers/specs/2026-03-17-conway-life-demo-design.md b/docs/superpowers/specs/2026-03-17-conway-life-demo-design.md new file mode 100644 index 0000000..65cd61e --- /dev/null +++ b/docs/superpowers/specs/2026-03-17-conway-life-demo-design.md @@ -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 diff --git a/docs/项目结构图与运行入口说明.md b/docs/项目结构图与运行入口说明.md new file mode 100644 index 0000000..9406c85 --- /dev/null +++ b/docs/项目结构图与运行入口说明.md @@ -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` 底部通过 `` 引入引擎与交互逻辑。 +- 打开方式:双击 `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 项测试。 diff --git a/index.html b/index.html new file mode 100644 index 0000000..cecc4fa --- /dev/null +++ b/index.html @@ -0,0 +1,196 @@ + + + + + + Conway Life Lab + + + +
+
+
+

CELLULAR AUTOMATA / CONWAY LIFE LAB

+

观察八个邻居,如何让一张静止网格自己长出秩序。

+

+ 这是一个可以直接打开的康威生命游戏演示页:先看规则,再改格子、切预设、调速度, + 用最经典的元胞自动机感受局部规则如何生成整体行为。 +

+
+ 进入实验台 + +
+
    +
  • 默认载入 脉冲星 并暂停
  • +
  • 支持点击与拖拽绘制
  • +
  • 可切换滑翔机、脉冲星、滑翔机枪
  • +
+
+ +
+ +
+
+
+

Interactive Lab

+

在画布里编辑生命,控制它的下一代。

+

+ 每个细胞只看周围 8 个邻居。存活、诞生、死亡这三条规则,会在一代代迭代里形成移动、 + 呼吸、振荡甚至复制的图案。 +

+
+ +
+
+
+
+

Simulation Surface

+

Conway Field

+
+
+ Paused + Drift +
+
+ +
+ +
+ 点击或拖拽即可绘制细胞 +
+
+ + +
+ + +
+
+ +
+
+

Rule Snapshot

+

三条规则,就足够让图案产生复杂行为。

+
+ +
+
+ 01 +

存活

+

一个活细胞如果有 2 或 3 个邻居,就继续存在到下一代。

+
+
+ 02 +

诞生

+

一个空白位置如果恰好有 3 个邻居,就会在下一代长出新的活细胞。

+
+
+ 03 +

死亡

+

活细胞邻居少于 2 个会孤独死亡,多于 3 个则会因为拥挤而死亡。

+
+
+
+ +
+
+

Preset Patterns

+

一键加载经典结构,观察它们如何移动、振荡与扩散。

+
+ +
+ + + +
+
+
+
+ + + + diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..cfb1245 --- /dev/null +++ b/styles.css @@ -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; + } +} diff --git a/tests/life-demo.test.js b/tests/life-demo.test.js new file mode 100644 index 0000000..423a7e3 --- /dev/null +++ b/tests/life-demo.test.js @@ -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); +});