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();