ts-hashlife
Version:
Efficient TypeScript implementation of HashLife, an optimized algorithm for simulating Conway's Game of Life with memoization and quadtree-based compression.
425 lines (350 loc) • 10.5 kB
text/typescript
import { clamp, LifeCanvasDrawer } from "./draw";
import { LifeUniverse, TreeNode } from "./life-universe";
import eventBus from "./event-bus";
import { formats, Pattern, Result } from "./formats";
import { load_macrocell } from "./macrocell";
const drawer = new LifeCanvasDrawer();
const life = new LifeUniverse();
const DEFAULT_BORDER = 2,
DEFAULT_CELL = 10,
DEFAULT_CELL_COLOR = "#f48c06",
DEFAULT_BACKGROUND_COLOR = "#000814",
DEFAULT_FPS = 60,
/*
* path to the folder with all patterns
*/
PATTERNS_PATH = "/patterns";
export type HashLifeOptions = {
max_fps?: number;
border_width?: number;
cell_width?: number;
background_color?: string;
cell_color?: string;
ui_padding?: {
top?: number;
right?: number;
bottom?: number;
left?: number;
};
};
class HashLife {
private _running = false;
private onStop: (() => void) | null = null;
private max_fps: number;
private border_width: number;
private cell_width: number;
private background_color: string;
private cell_color: string;
private ui_padding: {
top: number;
right: number;
bottom: number;
left: number;
};
private pattern: Pattern | null = null;
constructor(opts: HashLifeOptions = {}) {
this.max_fps = opts.max_fps ?? DEFAULT_FPS;
this.border_width = opts.border_width ?? DEFAULT_BORDER;
this.cell_width = opts.cell_width ?? DEFAULT_CELL;
this.background_color = opts.background_color ?? DEFAULT_BACKGROUND_COLOR;
this.cell_color = opts.cell_color ?? DEFAULT_CELL_COLOR;
this.ui_padding = {
top: opts.ui_padding?.top ?? 0,
right: opts.ui_padding?.right ?? 0,
bottom: opts.ui_padding?.bottom ?? 0,
left: opts.ui_padding?.left ?? 0,
};
}
get running() {
return this._running;
}
set running(value) {
this._running = value;
eventBus.emit(value ? "start" : "stop", value);
}
// ----------------------------------------
// Event handlers
// ----------------------------------------
handleInit(canvas: HTMLCanvasElement) {
const init = drawer.init(canvas);
if (!init) return false;
this.handleWindowResize();
this.reset_settings();
return true;
}
handleStart = () => {
if (this.running) return;
let n = 0,
start = Date.now(),
frame_time = 1000 / this.max_fps,
per_frame = frame_time,
last_frame = start - per_frame;
this.running = true;
if (life.generation === 0) {
life.save_rewind_state();
}
let interval: NodeJS.Timeout | null = null;
interval = setInterval(() => {
eventBus.emit("fps", (1000 / frame_time).toFixed(1));
}, 666);
const update = () => {
if (!this.running) {
if (interval) clearInterval(interval);
eventBus.emit("fps", "00.0");
if (this.onStop) {
this.onStop();
this.onStop = null;
}
return;
}
const time = Date.now();
if (per_frame * n < time - start) {
life.next_generation(true);
drawer.redraw(life.root);
if (life.root?.population && life.root.population > 0)
eventBus.emit("population", life.root.population);
n++;
frame_time += (-last_frame - frame_time + (last_frame = time)) / 15;
if (frame_time < 0.7 * per_frame) {
n = 1;
start = Date.now();
}
}
window.requestAnimationFrame(update);
};
update();
};
handleStop = (callback?: () => void) => {
if (this.running) {
this.running = false;
if (callback) this.onStop = callback;
} else {
if (callback) callback();
}
};
handleToggle = (callback?: () => void) => {
if (this.running) this.handleStop(callback);
else this.handleStart();
};
handleReset = () => {
if (life.rewind_state) {
this.handleStop(() => {
life.restore_rewind_state();
this.fit_pattern();
drawer.redraw(life.root);
});
}
};
handleStep = () => {
this.step(true);
};
handleSpeedUp = () => {
life.set_step(life.step + 1);
};
handleSlowDown = () => {
if (life.step <= 0) return;
life.set_step(life.step - 1);
};
handleFitPattern = () => {
this.fit_pattern();
this.lazy_redraw(life.root);
};
handleExport = (pattern_name: string = "Untitled") => {
return formats.generate_rle(life, this.pattern?.title ?? pattern_name, [
"Generated by ts-hashlife",
]);
};
handleLoadExampleFromFile = ({
pattern_text,
pattern_id,
pattern_path,
title,
}: {
pattern_text: string;
pattern_id?: string;
pattern_path?: string;
title?: string;
}) => {
const is_mc = pattern_text.startsWith("[M2]");
let result: Result;
if (!is_mc) {
const payload = formats.parse_pattern(pattern_text.trim());
if (payload.error) {
throw new Error(payload.error);
} else {
result = payload as Result;
}
} else {
result = {
comment: "",
urls: [],
};
}
this.handleStop(() => {
life.clear_pattern();
if (!is_mc) {
const bounds = life.get_bounds(result.field_x!, result.field_y!);
life.make_center(result.field_x!, result.field_y!, bounds);
life.setup_field(result.field_x!, result.field_y!, bounds);
} else {
const mc = load_macrocell(life, pattern_text);
if (!mc) {
throw new Error("Failed to load macrocell");
}
result = mc;
life.set_step(15);
}
life.save_rewind_state();
if (result.rule_s && result.rule_b) {
life.set_rules(result.rule_s, result.rule_b);
} else {
life.set_rules((1 << 2) | (1 << 3), 1 << 3); // Default rules
}
this.fit_pattern();
drawer.redraw(life.root);
if (life.root?.population)
eventBus.emit("population", life.root.population);
if (pattern_id && !result.title) result.title = pattern_id;
const pattern: Pattern = {
title: result.title || title || pattern_id || "Untitled",
author: result.author,
rule: result.rule,
description: result.comment,
source_url: PATTERNS_PATH + pattern_path,
view_url: pattern_path ?? "",
urls: result.urls,
};
this.pattern = pattern;
eventBus.emit("pattern:load", pattern);
});
};
handleLoadRandomizedPattern = ({
density,
width,
height,
}: {
density: number;
width: number;
height: number;
}) => {
const normalizedDensity = clamp(density, 0, 1);
const normalizedWidth = clamp(width, 100, 1000);
const normalizedHeight = clamp(height, 100, 1000);
this.handleStop(() => {
life.clear_pattern();
// Calculate number of cells based on density and dimensions
const cellCount = Math.round(
normalizedWidth * normalizedHeight * normalizedDensity
);
// Create arrays for cell coordinates
const field_x = new Int32Array(cellCount);
const field_y = new Int32Array(cellCount);
// Generate random positions
for (let i = 0; i < cellCount; i++) {
field_x[i] = Math.floor(Math.random() * normalizedWidth);
field_y[i] = Math.floor(Math.random() * normalizedHeight);
}
// Set up the universe with the random pattern
const bounds = life.get_bounds(field_x, field_y);
life.make_center(field_x, field_y, bounds);
life.setup_field(field_x, field_y, bounds);
// Save initial state for reset functionality
life.save_rewind_state();
// Fit pattern to view and redraw
this.fit_pattern();
drawer.redraw(life.root);
// Update population count in UI
if (life.root?.population) {
eventBus.emit("population", life.root.population);
}
// Emit pattern load event with random pattern information
const pattern: Pattern = {
title: "Random pattern",
author: "",
rule: "",
description: `Randomly generated pattern with density ${normalizedDensity.toFixed(
2
)}, width ${normalizedWidth}, height ${normalizedHeight}`,
source_url: "",
view_url: "",
urls: [],
};
this.pattern = pattern;
eventBus.emit("pattern:load", pattern);
});
};
handleWindowResize = () => {
drawer.set_size(
window.innerWidth,
document.body.offsetHeight || window.innerHeight
);
requestAnimationFrame(() => this.lazy_redraw(life.root));
};
handlePan = (dx: number, dy: number) => {
drawer.pan(dx, dy);
this.lazy_redraw(life.root);
};
handleZoom = (zoomRatio: number, x: number, y: number) => {
drawer.zoom_at(zoomRatio, x, y);
this.lazy_redraw(life.root);
};
handleSetUiPadding = (
padding: {
top?: number;
right?: number;
bottom?: number;
left?: number;
} = {}
) => {
this.ui_padding = { ...this.ui_padding, ...padding };
this.fit_pattern();
};
// ----------------------------------------
// Helpers
// ----------------------------------------
private fit_pattern = () => {
const bounds = life.get_root_bounds();
drawer.fit_bounds(
{
top: bounds.top,
bottom: bounds.bottom,
left: bounds.left,
right: bounds.right,
},
{
top: this.ui_padding.top,
bottom: this.ui_padding.bottom,
left: this.ui_padding.left,
right: this.ui_padding.right,
}
);
};
private step = (is_single = false) => {
const time = Date.now();
if (life.generation === 0) {
life.save_rewind_state();
}
life.next_generation(is_single);
drawer.redraw(life.root);
eventBus.emit("fps", (1000 / (Date.now() - time)).toFixed(1));
if (life.root?.population)
eventBus.emit("population", life.root.population);
};
private reset_settings = () => {
drawer.background_color = this.background_color;
drawer.cell_color = this.cell_color;
drawer.default_cell_width = this.cell_width;
drawer.cell_width = this.cell_width;
drawer.border_width = this.border_width;
drawer.center_view();
life.rule_b = 1 << 3;
life.rule_s = (1 << 2) | (1 << 3);
life.set_step(0);
};
private lazy_redraw = (node: TreeNode | null) => {
if (!this.running || this.max_fps < 15) {
drawer.redraw(node);
}
};
}
export default HashLife;