初始化项目并上传代码
This commit is contained in:
549
app.js
Normal file
549
app.js
Normal 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);
|
||||||
226
docs/superpowers/plans/2026-03-17-conway-life-demo.md
Normal file
226
docs/superpowers/plans/2026-03-17-conway-life-demo.md
Normal 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`
|
||||||
105
docs/superpowers/specs/2026-03-17-conway-life-demo-design.md
Normal file
105
docs/superpowers/specs/2026-03-17-conway-life-demo-design.md
Normal 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
|
||||||
49
docs/项目结构图与运行入口说明.md
Normal file
49
docs/项目结构图与运行入口说明.md
Normal 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
196
index.html
Normal 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
667
styles.css
Normal 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
148
tests/life-demo.test.js
Normal 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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user