diff --git a/js/engine.js b/js/engine.js new file mode 100644 index 0000000..d3f2d41 --- /dev/null +++ b/js/engine.js @@ -0,0 +1,160 @@ +import { cellCoordsToIndex } from "./utils.js"; + +export class LifeEngine { + constructor(rows, cols, { wrap = false } = {}) { + this.rows = rows; + this.cols = cols; + this.wrap = wrap; + this.generation = 0; + this.current = new Uint8Array(rows * cols); + this.next = new Uint8Array(rows * cols); + this.liveCount = 0; + } + + index(row, col) { + return cellCoordsToIndex(row, col, this.cols); + } + + inBounds(row, col) { + return row >= 0 && row < this.rows && col >= 0 && col < this.cols; + } + + getCell(row, col) { + if (!this.inBounds(row, col)) { + return 0; + } + return this.current[this.index(row, col)]; + } + + setCell(row, col, value) { + if (!this.inBounds(row, col)) { + return; + } + const idx = this.index(row, col); + const normalized = value ? 1 : 0; + if (this.current[idx] === normalized) { + return; + } + this.liveCount += normalized === 1 ? 1 : -1; + this.current[idx] = normalized; + } + + toggleCell(row, col) { + if (!this.inBounds(row, col)) { + return; + } + const idx = this.index(row, col); + const nextValue = this.current[idx] ? 0 : 1; + this.current[idx] = nextValue; + this.liveCount += nextValue ? 1 : -1; + } + + clear() { + this.current.fill(0); + this.next.fill(0); + this.generation = 0; + this.liveCount = 0; + } + + randomize(probability = 0.28) { + this.generation = 0; + let live = 0; + for (let i = 0; i < this.current.length; i += 1) { + const alive = Math.random() < probability ? 1 : 0; + this.current[i] = alive; + live += alive; + } + this.next.fill(0); + this.liveCount = live; + } + + countNeighbors(row, col) { + let count = 0; + for (let dr = -1; dr <= 1; dr += 1) { + for (let dc = -1; dc <= 1; dc += 1) { + if (dr === 0 && dc === 0) { + continue; + } + let nr = row + dr; + let nc = col + dc; + + if (this.wrap) { + nr = (nr + this.rows) % this.rows; + nc = (nc + this.cols) % this.cols; + count += this.current[this.index(nr, nc)]; + continue; + } + + if (this.inBounds(nr, nc)) { + count += this.current[this.index(nr, nc)]; + } + } + } + return count; + } + + step() { + const { rows, cols } = this; + let live = 0; + for (let row = 0; row < rows; row += 1) { + for (let col = 0; col < cols; col += 1) { + const idx = this.index(row, col); + const alive = this.current[idx] === 1; + const neighbors = this.countNeighbors(row, col); + const nextAlive = neighbors === 3 || (alive && neighbors === 2); + this.next[idx] = nextAlive ? 1 : 0; + live += this.next[idx]; + } + } + + const temp = this.current; + this.current = this.next; + this.next = temp; + this.generation += 1; + this.liveCount = live; + } + + resize(newRows, newCols) { + const oldRows = this.rows; + const oldCols = this.cols; + const oldCurrent = this.current; + + this.rows = newRows; + this.cols = newCols; + this.current = new Uint8Array(newRows * newCols); + this.next = new Uint8Array(newRows * newCols); + + const copyRows = Math.min(oldRows, newRows); + const copyCols = Math.min(oldCols, newCols); + let live = 0; + for (let row = 0; row < copyRows; row += 1) { + for (let col = 0; col < copyCols; col += 1) { + const oldIdx = row * oldCols + col; + const newIdx = row * newCols + col; + const value = oldCurrent[oldIdx]; + this.current[newIdx] = value; + live += value; + } + } + this.liveCount = live; + } + + pastePattern(patternCells, anchorRow, anchorCol, mode = "set") { + for (const [dr, dc] of patternCells) { + let row = anchorRow + dr; + let col = anchorCol + dc; + if (this.wrap) { + row = (row + this.rows) % this.rows; + col = (col + this.cols) % this.cols; + } + if (!this.inBounds(row, col)) { + continue; + } + if (mode === "toggle") { + this.toggleCell(row, col); + } else { + this.setCell(row, col, 1); + } + } + } +} diff --git a/js/renderer.js b/js/renderer.js new file mode 100644 index 0000000..6e17883 --- /dev/null +++ b/js/renderer.js @@ -0,0 +1,66 @@ +export class Renderer { + constructor(canvas, { cellSize = 12 } = {}) { + this.canvas = canvas; + this.ctx = canvas.getContext("2d", { alpha: false }); + this.cellSize = cellSize; + } + + setCellSize(cellSize) { + this.cellSize = cellSize; + } + + resizeCanvas(rows, cols) { + this.canvas.width = cols * this.cellSize; + this.canvas.height = rows * this.cellSize; + } + + clear(backgroundColor) { + this.ctx.fillStyle = backgroundColor; + this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); + } + + drawGrid(rows, cols, color) { + const { ctx, cellSize } = this; + ctx.save(); + ctx.beginPath(); + ctx.strokeStyle = color; + ctx.lineWidth = 1; + ctx.globalAlpha = 0.35; + for (let col = 0; col <= cols; col += 1) { + const x = col * cellSize + 0.5; + ctx.moveTo(x, 0); + ctx.lineTo(x, this.canvas.height); + } + for (let row = 0; row <= rows; row += 1) { + const y = row * cellSize + 0.5; + ctx.moveTo(0, y); + ctx.lineTo(this.canvas.width, y); + } + ctx.stroke(); + ctx.restore(); + } + + drawCells(buffer, rows, cols, fillColor) { + const { ctx, cellSize } = this; + ctx.fillStyle = fillColor; + for (let row = 0; row < rows; row += 1) { + const rowOffset = row * cols; + for (let col = 0; col < cols; col += 1) { + const idx = rowOffset + col; + if (buffer[idx] === 1) { + const x = col * cellSize; + const y = row * cellSize; + ctx.fillRect(x, y, cellSize, cellSize); + } + } + } + } + + render(engine, { showGrid, colors }) { + this.clear(colors.background); + this.drawCells(engine.current, engine.rows, engine.cols, colors.cell); + if (showGrid) { + this.drawGrid(engine.rows, engine.cols, colors.grid); + } + } +} diff --git a/js/utils.js b/js/utils.js new file mode 100644 index 0000000..724de4c --- /dev/null +++ b/js/utils.js @@ -0,0 +1,33 @@ +export function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)); +} + +export function debounce(callback, delayMs = 150) { + let timeoutId = null; + return (...args) => { + clearTimeout(timeoutId); + timeoutId = setTimeout(() => callback(...args), delayMs); + }; +} + +export function cellCoordsToIndex(row, col, cols) { + return row * cols + col; +} + +export function listLiveCells(buffer, rows, cols) { + const result = []; + for (let row = 0; row < rows; row += 1) { + for (let col = 0; col < cols; col += 1) { + const idx = row * cols + col; + if (buffer[idx] === 1) { + result.push([row, col]); + } + } + } + return result; +} + +export function parsePositiveInt(value, fallback) { + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +}