Add simulation engine, renderer, and utilities
This commit is contained in:
160
js/engine.js
Normal file
160
js/engine.js
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
66
js/renderer.js
Normal file
66
js/renderer.js
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
33
js/utils.js
Normal file
33
js/utils.js
Normal file
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user