diff --git a/js/app.js b/js/app.js new file mode 100644 index 0000000..35e317c --- /dev/null +++ b/js/app.js @@ -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(); diff --git a/js/patterns.js b/js/patterns.js new file mode 100644 index 0000000..066dc0c --- /dev/null +++ b/js/patterns.js @@ -0,0 +1,157 @@ +const block = [ + [0, 0], + [0, 1], + [1, 0], + [1, 1], +]; + +const blinker = [ + [0, 0], + [0, 1], + [0, 2], +]; + +const toad = [ + [0, 1], + [0, 2], + [0, 3], + [1, 0], + [1, 1], + [1, 2], +]; + +const beacon = [ + [0, 0], + [0, 1], + [1, 0], + [1, 1], + [2, 2], + [2, 3], + [3, 2], + [3, 3], +]; + +const glider = [ + [0, 1], + [1, 2], + [2, 0], + [2, 1], + [2, 2], +]; + +const lightweightSpaceship = [ + [0, 1], + [0, 2], + [0, 3], + [0, 4], + [1, 0], + [1, 4], + [2, 4], + [3, 0], + [3, 3], +]; + +const pulsar = [ + [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], +]; + +const gosperGliderGun = [ + [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], +]; + +export const PATTERNS = [ + { key: "block", label: "Block (still life)", cells: block }, + { key: "blinker", label: "Blinker (oscillator)", cells: blinker }, + { key: "toad", label: "Toad (oscillator)", cells: toad }, + { key: "beacon", label: "Beacon (oscillator)", cells: beacon }, + { key: "glider", label: "Glider", cells: glider }, + { key: "lwss", label: "Lightweight spaceship", cells: lightweightSpaceship }, + { key: "pulsar", label: "Pulsar", cells: pulsar }, + { key: "gosper", label: "Gosper glider gun", cells: gosperGliderGun }, +]; + +export function getPatternByKey(key) { + return PATTERNS.find((pattern) => pattern.key === key) ?? null; +}