Implement app controller, patterns, controls, and import/export

This commit is contained in:
2026-07-08 19:48:42 +00:00
parent 5f0a510a99
commit f86a7ef5b1
2 changed files with 712 additions and 0 deletions

555
js/app.js Normal file
View File

@@ -0,0 +1,555 @@
import { LifeEngine } from "./engine.js";
import { Renderer } from "./renderer.js";
import { getPatternByKey, PATTERNS } from "./patterns.js";
import { clamp, debounce, listLiveCells, parsePositiveInt } from "./utils.js";
class GameOfLifeApp {
constructor() {
this.elements = this.getElements();
this.state = {
speed: 10,
cellSize: 12,
running: false,
showGrid: true,
stampMode: false,
wrap: false,
drawValue: 1,
pointerDown: false,
lastPaintedCell: null,
simulationTimerId: null,
};
const { rows, cols } = this.computeGridDimensions(this.state.cellSize);
this.engine = new LifeEngine(rows, cols, { wrap: this.state.wrap });
this.renderer = new Renderer(this.elements.canvas, {
cellSize: this.state.cellSize,
});
this.renderer.resizeCanvas(rows, cols);
this.applyThemeFromStorage();
this.populatePatternSelect();
this.bindEvents();
this.updateStaticUiState();
this.render();
}
getElements() {
return {
canvas: document.getElementById("life-canvas"),
canvasContainer: document.getElementById("canvas-container"),
playPauseBtn: document.getElementById("play-pause-btn"),
stepBtn: document.getElementById("step-btn"),
clearBtn: document.getElementById("clear-btn"),
randomizeBtn: document.getElementById("randomize-btn"),
speedRange: document.getElementById("speed-range"),
speedValue: document.getElementById("speed-value"),
cellSizeRange: document.getElementById("cell-size-range"),
cellSizeValue: document.getElementById("cell-size-value"),
wrapToggle: document.getElementById("wrap-toggle"),
gridlineToggle: document.getElementById("gridline-toggle"),
generationCount: document.getElementById("generation-count"),
liveCount: document.getElementById("live-count"),
boardSize: document.getElementById("board-size"),
boundaryMode: document.getElementById("boundary-mode"),
patternSelect: document.getElementById("pattern-select"),
stampBtn: document.getElementById("stamp-btn"),
cancelStampBtn: document.getElementById("cancel-stamp-btn"),
toolStatus: document.getElementById("tool-status"),
ioFormat: document.getElementById("io-format"),
stateText: document.getElementById("state-text"),
exportBtn: document.getElementById("export-btn"),
importBtn: document.getElementById("import-btn"),
helpPanel: document.getElementById("help-panel"),
themeToggle: document.getElementById("theme-toggle"),
};
}
computeGridDimensions(cellSize) {
const width = Math.max(320, this.elements.canvasContainer.clientWidth);
const maxHeight = Math.max(280, Math.floor(window.innerHeight * 0.58));
const cols = Math.max(20, Math.floor(width / cellSize));
const rows = Math.max(20, Math.floor(maxHeight / cellSize));
return { rows, cols };
}
populatePatternSelect() {
const { patternSelect } = this.elements;
patternSelect.innerHTML = "";
for (const pattern of PATTERNS) {
const option = document.createElement("option");
option.value = pattern.key;
option.textContent = pattern.label;
patternSelect.appendChild(option);
}
}
bindEvents() {
const e = this.elements;
e.playPauseBtn.addEventListener("click", () => {
if (this.state.running) {
this.pause();
} else {
this.play();
}
});
e.stepBtn.addEventListener("click", () => {
this.step();
});
e.clearBtn.addEventListener("click", () => {
this.pause();
this.engine.clear();
this.render();
});
e.randomizeBtn.addEventListener("click", () => {
this.pause();
this.engine.randomize();
this.render();
});
e.speedRange.addEventListener("input", (event) => {
const speed = clamp(parsePositiveInt(event.target.value, 10), 1, 30);
this.state.speed = speed;
e.speedValue.textContent = String(speed);
if (this.state.running) {
this.play(true);
}
});
e.cellSizeRange.addEventListener("input", (event) => {
const nextSize = clamp(parsePositiveInt(event.target.value, 12), 6, 24);
this.state.cellSize = nextSize;
e.cellSizeValue.textContent = String(nextSize);
this.renderer.setCellSize(nextSize);
this.resizeBoardToViewport();
});
e.wrapToggle.addEventListener("change", (event) => {
this.state.wrap = event.target.checked;
this.engine.wrap = this.state.wrap;
this.render();
});
e.gridlineToggle.addEventListener("change", (event) => {
this.state.showGrid = event.target.checked;
this.render();
});
e.stampBtn.addEventListener("click", () => {
this.state.stampMode = true;
this.updateToolStatus();
});
e.cancelStampBtn.addEventListener("click", () => {
this.state.stampMode = false;
this.updateToolStatus();
});
e.exportBtn.addEventListener("click", () => {
e.stateText.value = this.serializeBoard(e.ioFormat.value);
});
e.importBtn.addEventListener("click", () => {
try {
this.importBoard(e.stateText.value.trim());
} catch (error) {
const message = error instanceof Error ? error.message : "Import failed.";
e.stateText.value = `Import error: ${message}`;
}
});
e.themeToggle.addEventListener("click", () => {
const next = document.documentElement.dataset.theme === "dark" ? "light" : "dark";
this.setTheme(next);
});
this.bindCanvasPointerEvents();
window.addEventListener(
"resize",
debounce(() => {
this.resizeBoardToViewport();
}, 180),
);
document.addEventListener("keydown", (event) => this.handleKeyboard(event));
}
bindCanvasPointerEvents() {
const { canvas } = this.elements;
canvas.addEventListener("pointerdown", (event) => {
event.preventDefault();
const point = this.pointerEventToCell(event);
if (!point) {
return;
}
if (this.state.stampMode) {
const pattern = getPatternByKey(this.elements.patternSelect.value);
if (pattern) {
this.engine.pastePattern(pattern.cells, point.row, point.col, "set");
this.state.stampMode = false;
this.updateToolStatus();
this.render();
}
return;
}
this.state.pointerDown = true;
this.state.lastPaintedCell = null;
canvas.setPointerCapture(event.pointerId);
this.state.drawValue = this.engine.getCell(point.row, point.col) === 1 ? 0 : 1;
this.paintCell(point.row, point.col);
});
canvas.addEventListener("pointermove", (event) => {
if (!this.state.pointerDown) {
return;
}
const point = this.pointerEventToCell(event);
if (!point) {
return;
}
this.paintCell(point.row, point.col);
});
const endPointer = (event) => {
this.state.pointerDown = false;
this.state.lastPaintedCell = null;
if (this.elements.canvas.hasPointerCapture(event.pointerId)) {
this.elements.canvas.releasePointerCapture(event.pointerId);
}
};
canvas.addEventListener("pointerup", endPointer);
canvas.addEventListener("pointercancel", endPointer);
canvas.addEventListener("pointerleave", endPointer);
}
pointerEventToCell(event) {
const rect = this.elements.canvas.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) {
return null;
}
const scaleX = this.elements.canvas.width / rect.width;
const scaleY = this.elements.canvas.height / rect.height;
const x = (event.clientX - rect.left) * scaleX;
const y = (event.clientY - rect.top) * scaleY;
if (x < 0 || y < 0 || x >= this.elements.canvas.width || y >= this.elements.canvas.height) {
return null;
}
const col = Math.floor(x / this.state.cellSize);
const row = Math.floor(y / this.state.cellSize);
if (!this.engine.inBounds(row, col)) {
return null;
}
return { row, col };
}
paintCell(row, col) {
const key = `${row},${col}`;
if (this.state.lastPaintedCell === key) {
return;
}
this.state.lastPaintedCell = key;
this.engine.setCell(row, col, this.state.drawValue);
this.render();
}
play(restart = false) {
if (this.state.running && !restart) {
return;
}
this.pause();
this.state.running = true;
const intervalMs = Math.max(16, Math.floor(1000 / this.state.speed));
this.state.simulationTimerId = window.setInterval(() => {
this.engine.step();
this.render();
}, intervalMs);
this.updateRunButton();
}
pause() {
this.state.running = false;
if (this.state.simulationTimerId !== null) {
window.clearInterval(this.state.simulationTimerId);
this.state.simulationTimerId = null;
}
this.updateRunButton();
}
step() {
this.engine.step();
this.render();
}
resizeBoardToViewport() {
const { rows, cols } = this.computeGridDimensions(this.state.cellSize);
this.engine.resize(rows, cols);
this.renderer.resizeCanvas(rows, cols);
this.render();
}
getThemeColors() {
const rootStyle = getComputedStyle(document.documentElement);
return {
background: rootStyle.getPropertyValue("--canvas-bg").trim(),
cell: rootStyle.getPropertyValue("--cell").trim(),
grid: rootStyle.getPropertyValue("--border").trim(),
};
}
render() {
this.renderer.render(this.engine, {
showGrid: this.state.showGrid,
colors: this.getThemeColors(),
});
this.updateStatus();
}
updateStatus() {
const e = this.elements;
e.generationCount.textContent = String(this.engine.generation);
e.liveCount.textContent = String(this.engine.liveCount);
e.boardSize.textContent = `${this.engine.cols} × ${this.engine.rows}`;
e.boundaryMode.textContent = this.state.wrap ? "Toroidal" : "Fixed";
}
updateRunButton() {
this.elements.playPauseBtn.textContent = this.state.running ? "⏸ Pause" : "▶ Play";
}
updateToolStatus() {
this.elements.toolStatus.textContent = this.state.stampMode
? "Stamp mode active: click on the board to place the selected pattern."
: "Draw mode: click and drag to paint cells.";
}
updateStaticUiState() {
const e = this.elements;
e.speedRange.value = String(this.state.speed);
e.speedValue.textContent = String(this.state.speed);
e.cellSizeRange.value = String(this.state.cellSize);
e.cellSizeValue.textContent = String(this.state.cellSize);
e.gridlineToggle.checked = this.state.showGrid;
e.wrapToggle.checked = this.state.wrap;
this.updateRunButton();
this.updateToolStatus();
}
serializeBoard(format) {
const liveCells = listLiveCells(this.engine.current, this.engine.rows, this.engine.cols);
if (format === "compact") {
const header = `GOL1 ${this.engine.rows} ${this.engine.cols} ${this.engine.generation} ${
this.state.wrap ? 1 : 0
}`;
const body = liveCells.map(([row, col]) => `${row},${col}`).join(";");
return `${header}\n${body}`;
}
return JSON.stringify(
{
version: 1,
rows: this.engine.rows,
cols: this.engine.cols,
generation: this.engine.generation,
wrap: this.state.wrap,
liveCells,
},
null,
2,
);
}
importBoard(text) {
if (!text) {
return;
}
this.pause();
if (text.startsWith("{")) {
this.importJson(text);
return;
}
this.importCompact(text);
}
importJson(text) {
const parsed = JSON.parse(text);
const rows = clamp(parsePositiveInt(parsed.rows, this.engine.rows), 10, 400);
const cols = clamp(parsePositiveInt(parsed.cols, this.engine.cols), 10, 600);
this.engine.resize(rows, cols);
this.engine.clear();
if (Array.isArray(parsed.liveCells)) {
for (const cell of parsed.liveCells) {
if (!Array.isArray(cell) || cell.length < 2) {
continue;
}
const row = Number.parseInt(cell[0], 10);
const col = Number.parseInt(cell[1], 10);
this.engine.setCell(row, col, 1);
}
}
const generation = Number.parseInt(parsed.generation, 10);
this.engine.generation = Number.isFinite(generation) && generation >= 0 ? generation : 0;
this.state.wrap = Boolean(parsed.wrap);
this.engine.wrap = this.state.wrap;
this.elements.wrapToggle.checked = this.state.wrap;
this.renderer.resizeCanvas(this.engine.rows, this.engine.cols);
this.render();
}
importCompact(text) {
const lines = text.split(/\r?\n/);
if (lines.length === 0) {
return;
}
const header = lines[0].trim().split(/\s+/);
if (header[0] !== "GOL1" || header.length < 5) {
throw new Error("Invalid compact format header.");
}
const rows = clamp(parsePositiveInt(header[1], this.engine.rows), 10, 400);
const cols = clamp(parsePositiveInt(header[2], this.engine.cols), 10, 600);
const generation = Math.max(0, Number.parseInt(header[3], 10) || 0);
const wrap = header[4] === "1";
this.engine.resize(rows, cols);
this.engine.clear();
this.engine.generation = generation;
this.state.wrap = wrap;
this.engine.wrap = wrap;
this.elements.wrapToggle.checked = wrap;
const body = lines.slice(1).join("").trim();
if (body.length > 0) {
for (const pair of body.split(";")) {
if (!pair) {
continue;
}
const [rowRaw, colRaw] = pair.split(",");
const row = Number.parseInt(rowRaw, 10);
const col = Number.parseInt(colRaw, 10);
this.engine.setCell(row, col, 1);
}
}
this.renderer.resizeCanvas(this.engine.rows, this.engine.cols);
this.render();
}
handleKeyboard(event) {
const activeTag = document.activeElement?.tagName?.toLowerCase();
if (activeTag === "input" || activeTag === "textarea" || activeTag === "select") {
if (event.key !== "Escape") {
return;
}
}
switch (event.key) {
case " ":
event.preventDefault();
this.state.running ? this.pause() : this.play();
break;
case "s":
case "S":
event.preventDefault();
this.step();
break;
case "c":
case "C":
event.preventDefault();
this.pause();
this.engine.clear();
this.render();
break;
case "r":
case "R":
event.preventDefault();
this.pause();
this.engine.randomize();
this.render();
break;
case "g":
case "G":
event.preventDefault();
this.state.showGrid = !this.state.showGrid;
this.elements.gridlineToggle.checked = this.state.showGrid;
this.render();
break;
case "w":
case "W":
event.preventDefault();
this.state.wrap = !this.state.wrap;
this.engine.wrap = this.state.wrap;
this.elements.wrapToggle.checked = this.state.wrap;
this.render();
break;
case "+":
case "=":
event.preventDefault();
this.adjustCellSize(1);
break;
case "-":
case "_":
event.preventDefault();
this.adjustCellSize(-1);
break;
case "?":
event.preventDefault();
this.elements.helpPanel.open = !this.elements.helpPanel.open;
break;
case "Escape":
if (this.state.stampMode) {
this.state.stampMode = false;
this.updateToolStatus();
}
break;
default:
break;
}
}
adjustCellSize(delta) {
const next = clamp(this.state.cellSize + delta, 6, 24);
if (next === this.state.cellSize) {
return;
}
this.state.cellSize = next;
this.renderer.setCellSize(next);
this.elements.cellSizeRange.value = String(next);
this.elements.cellSizeValue.textContent = String(next);
this.resizeBoardToViewport();
}
applyThemeFromStorage() {
const storedTheme = localStorage.getItem("gol-theme");
if (storedTheme === "dark" || storedTheme === "light") {
this.setTheme(storedTheme, false);
return;
}
this.setTheme("dark", false);
}
setTheme(theme, rerender = true) {
document.documentElement.dataset.theme = theme;
this.elements.themeToggle.textContent = theme === "dark" ? "☀️ Light mode" : "🌙 Dark mode";
localStorage.setItem("gol-theme", theme);
if (rerender) {
this.render();
}
}
}
new GameOfLifeApp();