SOURCE / PINNED RELEASE
Made of little things.
Powder Tool
- Release
- 242076e6da24…
- Author-recorded commit
- c4489f440f27…
- License
- LICENSE
- Author’s source reference
- nostr://npub182jczunncwe0jn6frpqwq3e0qjws7yqqnc3auccqv9nte2dnd63scjm4rf/wss%3A%2F%2Fgit.napplet.soy%2F/n-48e516e34e5
Archive hash verified: f2fd072590aac7a5…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
/** Local, deterministic physics. No browser or shell dependencies. */
export const M = { empty: 0, sand: 1, water: 2, oil: 3, fire: 4, steam: 5, lava: 6, wood: 7, wall: 8, smoke: 9, hydrogen: 10, helium: 11, co2: 12 } as const;
export type Material = number;
export const GAS_IDS: readonly number[] = [M.steam, M.smoke, M.hydrogen, M.helium, M.co2];
export const EMITTABLE: readonly number[] = [M.sand, M.water, M.oil, M.fire, M.steam, M.lava, M.smoke, M.hydrogen, M.helium, M.co2];
export type Outlet = 'down' | 'up' | 'left' | 'right';
export type Emitter = { id: number; x: number; y: number; material: number; rate: number; radius: number; direction: Outlet; enabled: boolean; carry: number };
export type EmitterSettings = Pick<Emitter, 'material' | 'rate' | 'radius' | 'direction' | 'enabled'>;
const gas = (m: number) => m === M.fire || GAS_IDS.includes(m);
export type Vec = { x: number; y: number };
export type BodyKind = 'box' | 'stone';
export type Body = { id: number; kind: BodyKind; x: number; y: number; vx: number; vy: number; angle: number; omega: number; size: number; heat: number; mass: number; invMass: number; invInertia: number; local: Vec[] };
type Collider = { vertices: Vec[]; x: number; y: number; left: number; right: number; top: number; bottom: number };
type Contact = { normal: Vec; depth: number; point: Vec };
export type SceneData = { version: 1 | 2; width: number; height: number; gravity: number; seed: number; cells: number[] | string; life: number[] | string; emitters?: Emitter[]; bodies: Pick<Body, 'kind' | 'x' | 'y' | 'vx' | 'vy' | 'angle' | 'omega' | 'size' | 'heat'>[] };
const density = [0, 4, 2, 1, -.7, -.4, 3, 9, 9, -.2, -.95, -.85, .3];
const solid = (m: number) => m === M.wall || m === M.wood;
const cross = (a: Vec, b: Vec) => a.x * b.y - a.y * b.x;
const dot = (a: Vec, b: Vec) => a.x * b.x + a.y * b.y;
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
export function vertices(b: Body): Vec[] {
const c = Math.cos(b.angle), s = Math.sin(b.angle);
return b.local.map(p => ({ x: b.x + p.x * c - p.y * s, y: b.y + p.x * s + p.y * c }));
}
export function contains(poly: Vec[], x: number, y: number): boolean {
for (let i = 0; i < poly.length; i++) {
const a = poly[i], b = poly[(i + 1) % poly.length];
if ((b.x - a.x) * (y - a.y) - (b.y - a.y) * (x - a.x) < -0.001) return false;
}
return true;
}
function bounds(v: Vec[]): Collider {
const left = Math.min(...v.map(p => p.x)), right = Math.max(...v.map(p => p.x));
const top = Math.min(...v.map(p => p.y)), bottom = Math.max(...v.map(p => p.y));
return { vertices: v, left, right, top, bottom, x: (left + right) / 2, y: (top + bottom) / 2 };
}
function rectangle(x: number, y: number, w: number, h: number): Collider {
return bounds([{ x, y }, { x: x + w, y }, { x: x + w, y: y + h }, { x, y: y + h }]);
}
/** SAT for convex polygons. Normal points from B into A. */
function contact(a: Collider, b: Collider): Contact | null {
if (a.left >= b.right || a.right <= b.left || a.top >= b.bottom || a.bottom <= b.top) return null;
let depth = Infinity, normal = { x: 0, y: 0 };
for (const poly of [a.vertices, b.vertices]) {
for (let i = 0; i < poly.length; i++) {
const p = poly[i], q = poly[(i + 1) % poly.length];
const len = Math.hypot(q.x - p.x, q.y - p.y);
const axis = { x: -(q.y - p.y) / len, y: (q.x - p.x) / len };
const av = a.vertices.map(v => dot(v, axis)), bv = b.vertices.map(v => dot(v, axis));
const overlap = Math.min(Math.max(...av) - Math.min(...bv), Math.max(...bv) - Math.min(...av));
if (overlap <= 0) return null;
if (overlap < depth) { depth = overlap; normal = axis; }
}
}
if (dot({ x: a.x - b.x, y: a.y - b.y }, normal) < 0) normal = { x: -normal.x, y: -normal.y };
// Use the contacting face on A, clamped along the tangent to B's extent.
const minA = Math.min(...a.vertices.map(v => dot(v, normal)));
const points = a.vertices.filter(v => dot(v, normal) < minA + .35);
const point = { x: points.reduce((s, v) => s + v.x, 0) / points.length, y: points.reduce((s, v) => s + v.y, 0) / points.length };
point.x = clamp(point.x, b.left, b.right); point.y = clamp(point.y, b.top, b.bottom);
return { normal, depth, point };
}
function resolve(a: Body, b: Body | null, hit: Contact): void {
const n = hit.normal, total = a.invMass + (b?.invMass ?? 0);
const correction = Math.max(0, hit.depth - .025) * .7 / total;
a.x += n.x * correction * a.invMass; a.y += n.y * correction * a.invMass;
if (b) { b.x -= n.x * correction * b.invMass; b.y -= n.y * correction * b.invMass; }
const ra = { x: hit.point.x - a.x, y: hit.point.y - a.y };
const rb = { x: hit.point.x - (b?.x ?? hit.point.x), y: hit.point.y - (b?.y ?? hit.point.y) };
const rv = { x: a.vx - a.omega * ra.y - (b ? b.vx - b.omega * rb.y : 0), y: a.vy + a.omega * ra.x - (b ? b.vy + b.omega * rb.x : 0) };
const vn = dot(rv, n);
if (vn >= 0) return;
const denom = total + cross(ra, n) ** 2 * a.invInertia + cross(rb, n) ** 2 * (b?.invInertia ?? 0);
const impulse = -(1 + (vn < -8 ? .12 : 0)) * vn / denom;
const tangent = { x: -n.y, y: n.x };
const frictionDenom = total + cross(ra, tangent) ** 2 * a.invInertia + cross(rb, tangent) ** 2 * (b?.invInertia ?? 0);
const friction = clamp(-dot(rv, tangent) / frictionDenom, -impulse * .55, impulse * .55);
const force = { x: impulse * n.x + friction * tangent.x, y: impulse * n.y + friction * tangent.y };
a.vx += force.x * a.invMass; a.vy += force.y * a.invMass; a.omega += cross(ra, force) * a.invInertia;
if (b) { b.vx -= force.x * b.invMass; b.vy -= force.y * b.invMass; b.omega -= cross(rb, force) * b.invInertia; }
}
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
function base64(bytes: number[] | Uint8Array): string {
let out = '';
for (let i = 0; i < bytes.length; i += 3) {
const n = (bytes[i] << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0);
out += alphabet[(n >>> 18) & 63] + alphabet[(n >>> 12) & 63] + (i + 1 < bytes.length ? alphabet[(n >>> 6) & 63] : '=') + (i + 2 < bytes.length ? alphabet[n & 63] : '=');
}
return out;
}
function unbase64(value: string): Uint8Array {
if (value.length % 4 || !/^[A-Za-z0-9+/]*={0,2}$/.test(value)) throw new Error('Invalid scene encoding');
const bytes: number[] = [];
for (let i = 0; i < value.length; i += 4) {
const n = (alphabet.indexOf(value[i]) << 18) | (alphabet.indexOf(value[i + 1]) << 12) | (Math.max(0, alphabet.indexOf(value[i + 2])) << 6) | Math.max(0, alphabet.indexOf(value[i + 3]));
bytes.push((n >>> 16) & 255);
if (value[i + 2] !== '=') bytes.push((n >>> 8) & 255);
if (value[i + 3] !== '=') bytes.push(n & 255);
}
return new Uint8Array(bytes);
}
/** Lossless binary RLE, with a raw fallback for noisy fire/gas fields. */
function encode(a: Uint8Array | Uint16Array): string {
const runs: number[] = [];
for (let i = 0; i < a.length;) {
const value = a[i]; let end = i + 1;
while (end < a.length && a[end] === value && end - i < 65535) end++;
runs.push(value & 255, value >>> 8, (end - i) & 255, (end - i) >>> 8); i = end;
}
if (runs.length < a.length * a.BYTES_PER_ELEMENT) return 'r' + base64(runs);
const raw: number[] = [];
for (const value of a) { raw.push(value & 255); if (a.BYTES_PER_ELEMENT === 2) raw.push(value >>> 8); }
return 'b' + base64(raw);
}
function decode(value: unknown, length: number, max: number): Uint16Array {
const result = new Uint16Array(length); let offset = 0;
const append = (v: number, count: number) => {
if (!Number.isInteger(v) || v < 0 || v > max || !Number.isInteger(count) || count < 1 || offset + count > length) throw new Error('Invalid scene data');
result.fill(v, offset, offset + count); offset += count;
};
if (Array.isArray(value)) {
if (value.length % 2 || value.length > length * 2) throw new Error('Invalid scene data');
for (let i = 0; i < value.length; i += 2) append(value[i], value[i + 1]);
} else if (typeof value === 'string' && value.length <= length * 6 + 16) {
const bytes = unbase64(value.slice(1));
if (value[0] === 'r' && bytes.length % 4 === 0) {
for (let i = 0; i < bytes.length; i += 4) append(bytes[i] | (bytes[i + 1] << 8), bytes[i + 2] | (bytes[i + 3] << 8));
} else if (value[0] === 'b') {
const stride = max > 255 ? 2 : 1;
if (bytes.length !== length * stride) throw new Error('Invalid scene data');
for (let i = 0; i < bytes.length; i += stride) append(bytes[i] | (stride === 2 ? bytes[i + 1] << 8 : 0), 1);
} else throw new Error('Invalid scene encoding');
} else throw new Error('Invalid scene data');
if (offset !== length) throw new Error('Incomplete scene data');
return result;
}
export function validEmitter(e: EmitterSettings): boolean {
return Boolean(e) && EMITTABLE.includes(e.material) && Number.isInteger(e.rate) && e.rate >= 1 && e.rate <= 2400 && Number.isInteger(e.radius) && e.radius >= 1 && e.radius <= 24 && ['up', 'down', 'left', 'right'].includes(e.direction) && typeof e.enabled === 'boolean';
}
export class Simulation {
width: number; height: number;
cells: Uint8Array; life: Uint16Array; private moved: Uint32Array; occupancy: Int32Array;
bodies: Body[] = []; emitters: Emitter[] = []; private nextEmitterId = 1; gravity = 1; tick = 0; seed: number;
private nextId = 1; private terrainDirty = true; private terrain: Collider[] = [];
grab: { id: number; x: number; y: number; localX: number; localY: number } | null = null;
constructor(width = 300, height = 200, seed = 82371) {
this.width = width; this.height = height; this.seed = seed;
this.cells = new Uint8Array(width * height); this.life = new Uint16Array(width * height);
this.moved = new Uint32Array(width * height); this.occupancy = new Int32Array(width * height);
}
get units(): number { return Math.max(1, this.width / 300); }
random(): number { let x = this.seed | 0; x ^= x << 13; x ^= x >>> 17; x ^= x << 5; this.seed = x >>> 0; return this.seed / 4294967296; }
get(x: number, y: number): number {
x = Math.floor(x); y = Math.floor(y);
return x < 0 || x >= this.width || y < 0 || y >= this.height ? M.wall : this.cells[y * this.width + x];
}
set(x: number, y: number, m: number, life = 0): void {
x = Math.floor(x); y = Math.floor(y);
if (x < 0 || x >= this.width || y < 0 || y >= this.height) return;
const i = y * this.width + x;
if (solid(m) || solid(this.cells[i])) this.terrainDirty = true;
this.cells[i] = m; this.life[i] = life || (m === M.fire ? 40 + Math.floor(this.random() * 80) : m === M.steam ? 360 + Math.floor(this.random() * 240) : m === M.smoke ? 420 + Math.floor(this.random() * 180) : 0);
}
paint(x: number, y: number, radius: number, material: number): void {
const r = Math.max(.5, radius);
for (let py = Math.floor(y - r); py <= y + r; py++) for (let px = Math.floor(x - r); px <= x + r; px++) {
if ((px - x) ** 2 + (py - y) ** 2 > r * r || px < 0 || px >= this.width || py < 0 || py >= this.height) continue;
const i = py * this.width + px;
if (material === M.empty || (!this.occupancy[i] && (!this.cells[i] || material === M.fire))) {
if (material === M.empty || solid(material) || this.random() < .6) this.set(px, py, material);
}
}
if (material === M.empty) {
this.bodies = this.bodies.filter(b => !contains(vertices(b), x, y));
this.emitters = this.emitters.filter(e => Math.hypot(e.x - x, e.y - y) > Math.max(r, 6 * this.units));
}
}
addBody(kind: BodyKind, x: number, y: number, size = 12): Body | null {
if (this.bodies.length >= 48) return null;
// Keep legacy small bodies exact; the UI scales its placement minimum.
size = clamp(size, 4, 24 * this.units);
const local = kind === 'box' ? [{ x: -size, y: -size }, { x: size, y: -size }, { x: size, y: size }, { x: -size, y: size }] : Array.from({ length: 7 }, (_, i) => {
const angle = i * Math.PI * 2 / 7; const radius = size * (i % 2 ? .91 : 1);
return { x: Math.cos(angle) * radius, y: Math.sin(angle) * radius };
});
const mass = size * size * (kind === 'box' ? .55 : 2.8);
const b: Body = { id: this.nextId++, kind, x: clamp(x, size * 1.5, this.width - size * 1.5), y: clamp(y, size * 1.5, this.height - size * 1.5), vx: 0, vy: 0, angle: kind === 'box' ? -.08 : .2, omega: 0, size, heat: 0, mass, invMass: 1 / mass, invInertia: 1 / (mass * size * size * .67), local };
// Reject placements that intersect constructed walls or existing bodies.
const shape = bounds(vertices(b));
for (let py = Math.max(0, Math.floor(shape.top)); py < Math.min(this.height, shape.bottom); py++) for (let px = Math.max(0, Math.floor(shape.left)); px < Math.min(this.width, shape.right); px++) {
if (solid(this.get(px, py)) && contains(shape.vertices, px + .5, py + .5)) return null;
}
if (this.bodies.some(other => contact(shape, bounds(vertices(other))))) return null;
this.bodies.push(b); this.rasterBodies(); return b;
}
bodyAt(x: number, y: number): Body | undefined { return [...this.bodies].reverse().find(b => contains(vertices(b), x, y)); }
private swap(i: number, j: number): void {
const m = this.cells[j], life = this.life[j]; this.cells[j] = this.cells[i]; this.life[j] = this.life[i]; this.cells[i] = m; this.life[i] = life;
this.moved[j] = this.tick; this.moved[i] = this.tick;
}
private move(i: number, x: number, y: number, rising = false): boolean {
if (x < 0 || x >= this.width || y < 0 || y >= this.height) return false;
const j = y * this.width + x;
if (this.occupancy[j] || solid(this.cells[j])) return false;
const a = this.cells[i], b = this.cells[j];
if (!b || (!rising && density[a] > density[b] && b !== M.sand)) { this.swap(i, j); return true; }
return false;
}
private react(i: number, x: number, y: number): void {
const m = this.cells[i];
if (m !== M.fire && m !== M.lava) return;
const neighbors = [[x - 1, y], [x + 1, y], [x, y - 1], [x, y + 1]];
for (const [px, py] of neighbors) {
const n = this.get(px, py);
if (n === M.co2 && m === M.fire) { this.set(x, y, M.smoke, 60); return; }
if (n === M.water) {
this.set(px, py, M.steam);
this.set(x, y, m === M.lava ? M.wall : M.steam);
return;
}
if (n === M.hydrogen) this.set(px, py, M.fire, 30 + Math.floor(this.random() * 40));
if ((n === M.oil || n === M.wood) && this.random() < (n === M.oil ? .24 : .025)) this.set(px, py, M.fire, n === M.wood ? 180 : 100);
if (m === M.lava && n === M.empty && this.random() < .004) this.set(px, py, M.fire, 35);
}
}
private updateCell(x: number, y: number, gases: boolean): void {
const i = y * this.width + x, m = this.cells[i];
if (!m || solid(m) || this.occupancy[i] || this.moved[i] === this.tick) return;
if (gases !== gas(m)) return;
const direction = this.random() < .5 ? -1 : 1;
// Both sides check contact before diffusing, so scan order cannot let fuel
// or extinguishing gas dodge a heat source that it was already touching.
if (m === M.hydrogen || m === M.co2) {
for (const [px, py] of [[x - 1, y], [x + 1, y], [x, y - 1], [x, y + 1]]) {
const neighbor = this.get(px, py);
if (m === M.hydrogen && (neighbor === M.fire || neighbor === M.lava)) { this.set(x, y, M.fire, 50); return; }
if (m === M.co2 && neighbor === M.fire) this.set(px, py, M.smoke, 60);
}
}
if (m === M.fire || m === M.lava) this.react(i, x, y);
if (this.cells[i] !== m) return;
if (gases) {
const finiteLife = m === M.fire || m === M.steam || m === M.smoke;
if (finiteLife && this.life[i]) this.life[i]--;
if (finiteLife && !this.life[i]) {
this.set(x, y, m === M.steam && this.random() < .15 ? M.water : m === M.fire && this.random() < .6 ? M.smoke : M.empty);
return;
}
// Rising gases vent at the open top. Heavy CO₂ pools against the closed floor.
if (y === 0 && m !== M.co2) { this.set(x, y, M.empty); return; }
const dy = m === M.co2 ? 1 : -1;
const speed = m === M.hydrogen ? .97 : m === M.helium ? .9 : m === M.smoke ? .3 : m === M.fire ? .45 : .7;
const moveGas = (px: number, py: number): boolean => {
if (px < 0 || px >= this.width || py < 0 || py >= this.height) return false;
const j = py * this.width + px, target = this.cells[j];
if (this.occupancy[j]) return false;
if (!target || (gas(target) && target !== m && ((dy < 0 && density[m] < density[target]) || (dy > 0 && density[m] > density[target])))) { this.swap(i, j); return true; }
return false;
};
if (this.gravity > 0 && this.random() < speed * Math.min(1, this.gravity)) {
if (moveGas(x, y + dy) || moveGas(x + direction, y + dy) || moveGas(x - direction, y + dy)) return;
}
if (this.random() < .45) { if (!moveGas(x + direction, y)) moveGas(x - direction, y); }
return;
}
if (m === M.lava && this.random() > .2) return;
if (this.gravity > 0 && this.random() < Math.min(1, this.gravity)) {
if (this.move(i, x, y + 1) || this.move(i, x + direction, y + 1) || this.move(i, x - direction, y + 1)) return;
}
if (m !== M.sand) {
const spread = Math.round((m === M.water ? 5 : m === M.oil ? 3 : 1) * this.units);
for (const dir of [direction, -direction]) {
let last = x;
for (let s = 1; s <= spread; s++) {
const px = x + dir * s;
if (px < 0 || px >= this.width || this.cells[y * this.width + px] || this.occupancy[y * this.width + px]) break;
last = px;
if (this.gravity > 0 && this.get(px, y + 1) === M.empty) break;
}
if (last !== x) { this.move(i, last, y); return; }
}
}
}
addEmitter(x: number, y: number, settings: EmitterSettings): Emitter | null {
if (this.emitters.length >= 24 || !validEmitter(settings) || !Number.isFinite(x) || !Number.isFinite(y) || x < 0 || x >= this.width || y < 0 || y >= this.height || solid(this.get(x, y))) return null;
const e: Emitter = { ...settings, id: this.nextEmitterId++, x, y, carry: 0 };
this.emitters.push(e); return e;
}
emitterAt(x: number, y: number, radius = 8 * this.units): Emitter | undefined {
return [...this.emitters].reverse().find(e => Math.hypot(e.x - x, e.y - y) <= radius);
}
updateEmitter(id: number, settings: EmitterSettings): boolean {
const e = this.emitters.find(e => e.id === id);
if (!e || !validEmitter(settings)) return false;
Object.assign(e, settings); return true;
}
private emit(): void {
for (const e of this.emitters) {
if (!e.enabled) continue;
e.carry += e.rate / 60;
const count = Math.floor(e.carry); e.carry -= count;
const dx = e.direction === 'right' ? 1 : e.direction === 'left' ? -1 : 0;
const dy = e.direction === 'down' ? 1 : e.direction === 'up' ? -1 : 0;
for (let n = 0; n < count; n++) {
// A blocked outlet drops its budget; it cannot store up a later burst.
for (let attempt = 0; attempt < 6; attempt++) {
const across = (this.random() * 2 - 1) * e.radius, ahead = 2 * this.units + this.random() * (e.radius + 1);
const x = Math.floor(e.x + dx * ahead - dy * across), y = Math.floor(e.y + dy * ahead + dx * across);
if (x < 0 || x >= this.width || y < 0 || y >= this.height) continue;
const i = y * this.width + x;
if (!this.cells[i] && !this.occupancy[i]) { this.set(x, y, e.material); break; }
}
}
}
}
private rebuildTerrain(): void {
this.terrain = [rectangle(-20, -20, 20, this.height + 40), rectangle(this.width, -20, 20, this.height + 40), rectangle(0, this.height, this.width, 20), rectangle(0, -20, this.width, 20)];
let previous = new Map<string, Collider>();
for (let y = 0; y < this.height; y++) {
const current = new Map<string, Collider>();
for (let x = 0; x < this.width; x++) {
if (!solid(this.cells[y * this.width + x])) continue;
const start = x;
while (x + 1 < this.width && solid(this.cells[y * this.width + x + 1])) x++;
const key = `${start}:${x}`;
const prior = previous.get(key);
if (prior) { prior.bottom = y + 1; prior.y = (prior.top + prior.bottom) / 2; prior.vertices[2].y = y + 1; prior.vertices[3].y = y + 1; current.set(key, prior); }
else { const collider = rectangle(start, y, x - start + 1, 1); this.terrain.push(collider); current.set(key, collider); }
}
previous = current;
}
this.terrainDirty = false;
}
private updateBodies(): void {
if (!this.bodies.length) return;
if (this.terrainDirty) this.rebuildTerrain();
const dt = 1 / 120;
for (let sub = 0; sub < 2; sub++) {
for (const b of this.bodies) {
const poly = vertices(b), box = bounds(poly);
let wet = 0, hot = 0, sand = 0, samples = 0;
// Sample water just outside the body at each depth; displaced cells stay empty.
for (let y = Math.ceil(box.top); y < box.bottom; y += 2) {
const left = this.get(box.left - 2, y), right = this.get(box.right + 1, y);
wet += Number(left === M.water || left === M.oil) + Number(right === M.water || right === M.oil);
hot += Number(left === M.fire || left === M.lava) + Number(right === M.fire || right === M.lava); samples += 2;
}
for (let x = box.left; x <= box.right; x += 2) sand += Number(this.get(x, box.bottom + 1) === M.sand);
const fraction = wet / Math.max(1, samples);
b.vy += 75 * this.units * this.gravity * (1 - fraction * (b.kind === 'box' ? 1.9 : .32)) * dt;
const drag = Math.exp(-(fraction * 3 + .08) * dt);
b.vx *= drag; b.vy *= drag; b.omega *= Math.exp(-(fraction * 4 + .7) * dt);
if (sand > b.size * .3 && b.vy > 0) {
// Sand supports light crates; heavy stones slowly settle into it.
b.vy *= b.kind === 'box' ? .15 : .8; b.vx *= .93; b.omega *= .88;
}
if (b.kind === 'box') b.heat = clamp(b.heat + (hot ? 1.5 : -1) * dt, 0, 10);
if (this.grab?.id === b.id) {
const c = Math.cos(b.angle), s = Math.sin(b.angle);
const rx = this.grab.localX * c - this.grab.localY * s, ry = this.grab.localX * s + this.grab.localY * c;
const fx = clamp((this.grab.x - b.x - rx) * 75 - (b.vx - b.omega * ry) * 12, -2500, 2500);
const fy = clamp((this.grab.y - b.y - ry) * 75 - (b.vy + b.omega * rx) * 12, -2500, 2500);
b.vx += fx * dt; b.vy += fy * dt; b.omega += (rx * fy - ry * fx) * dt / (b.size * b.size * 1.5);
}
b.vx = clamp(b.vx, -160 * this.units, 160 * this.units); b.vy = clamp(b.vy, -160 * this.units, 160 * this.units); b.omega = clamp(b.omega, -8, 8);
b.x += b.vx * dt; b.y += b.vy * dt; b.angle += b.omega * dt;
}
for (let iteration = 0; iteration < 4; iteration++) {
for (const b of this.bodies) {
let shape = bounds(vertices(b));
for (const terrain of this.terrain) {
const hit = contact(shape, terrain);
if (hit) { resolve(b, null, hit); shape = bounds(vertices(b)); }
}
}
for (let i = 0; i < this.bodies.length; i++) for (let j = i + 1; j < this.bodies.length; j++) {
const a = this.bodies[i], b = this.bodies[j];
if (Math.abs(a.x - b.x) > (a.size + b.size) * 1.5 || Math.abs(a.y - b.y) > (a.size + b.size) * 1.5) continue;
const hit = contact(bounds(vertices(a)), bounds(vertices(b)));
if (hit) resolve(a, b, hit);
}
}
}
const burned = this.bodies.filter(b => b.heat > 3);
this.bodies = this.bodies.filter(b => b.heat <= 3);
this.rasterBodies();
for (const b of burned) this.paint(b.x, b.y, b.size, M.fire);
}
rasterBodies(): void {
this.occupancy.fill(0);
for (const b of this.bodies) {
const poly = vertices(b), box = bounds(poly);
for (let y = Math.max(0, Math.floor(box.top)); y < Math.min(this.height, box.bottom); y++) for (let x = Math.max(0, Math.floor(box.left)); x < Math.min(this.width, box.right); x++) {
if (contains(poly, x + .5, y + .5)) this.occupancy[y * this.width + x] = b.id;
}
}
// Displace engulfed particles to free space instead of deleting them.
for (const b of this.bodies) {
const box = bounds(vertices(b));
for (let y = Math.max(0, Math.floor(box.top)); y < Math.min(this.height, box.bottom); y++) for (let x = Math.max(0, Math.floor(box.left)); x < Math.min(this.width, box.right); x++) {
const i = y * this.width + x;
if (this.occupancy[i] !== b.id || !this.cells[i] || solid(this.cells[i])) continue;
let found = false;
for (let r = 1; r <= b.size * 3 && !found; r++) {
const side = x < b.x ? -1 : 1;
for (const [dx, dy] of [[side * r, -Math.floor(r / 3)], [side * r, 0], [-side * r, 0], [0, -r]]) {
const px = x + dx, py = y + dy;
if (px < 0 || px >= this.width || py < 0 || py >= this.height) continue;
const j = py * this.width + px;
if (!this.occupancy[j] && !this.cells[j]) { this.swap(i, j); found = true; break; }
}
}
}
}
}
step(): void {
this.tick++;
this.updateBodies();
this.emit();
for (let y = this.height - 1; y >= 0; y--) {
const reverse = (this.tick + y) % 2;
for (let n = 0; n < this.width; n++) this.updateCell(reverse ? this.width - 1 - n : n, y, false);
}
for (let y = 0; y < this.height; y++) {
const reverse = (this.tick + y) % 2;
for (let n = 0; n < this.width; n++) this.updateCell(reverse ? this.width - 1 - n : n, y, true);
}
}
snapshot(): SceneData {
return { version: 2, emitters: this.emitters.map(e => ({ ...e })), width: this.width, height: this.height, gravity: this.gravity, seed: this.seed, cells: encode(this.cells), life: encode(this.life), bodies: this.bodies.map(({ kind, x, y, vx, vy, angle, omega, size, heat }) => ({ kind, x, y, vx, vy, angle, omega, size, heat })) };
}
static restore(value: unknown): Simulation {
if (!value || typeof value !== 'object') throw new Error('Invalid scene');
const s = value as SceneData;
if ((s.version !== 1 && s.version !== 2) || !Number.isInteger(s.width) || !Number.isInteger(s.height) || s.width < 60 || s.width > 720 || s.height < 60 || s.height > 720 || !Number.isFinite(s.gravity) || s.gravity < 0 || s.gravity > 2 || !Number.isInteger(s.seed) || s.seed <= 0 || s.seed > 4294967295) throw new Error('Unsupported scene');
const sim = new Simulation(s.width, s.height, s.seed);
sim.cells = new Uint8Array(decode(s.cells, s.width * s.height, s.version === 1 ? M.wall : M.co2)); sim.life = decode(s.life, s.width * s.height, s.version === 1 ? 255 : 65535); sim.gravity = s.gravity;
if (!Array.isArray(s.bodies) || s.bodies.length > 48) throw new Error('Invalid bodies');
for (const item of s.bodies) {
if (!item || (item.kind !== 'box' && item.kind !== 'stone') || !['x', 'y', 'vx', 'vy', 'angle', 'omega', 'size', 'heat'].every(key => Number.isFinite(item[key as keyof typeof item])) || item.size < 4 || item.size > 24 * sim.units || item.x < 0 || item.x > s.width || item.y < 0 || item.y > s.height || Math.abs(item.vx) > 200 * sim.units || Math.abs(item.vy) > 200 * sim.units || Math.abs(item.omega) > 20 || Math.abs(item.angle) > 1e8 || item.heat < 0 || item.heat > 10) throw new Error('Invalid body');
// Restore exact poses, including transient contacts; interactive placement checks do not apply.
const holder = new Simulation(s.width, s.height);
const b = holder.addBody(item.kind, item.x, item.y, item.size)!;
Object.assign(b, item, { id: sim.nextId++ }); sim.bodies.push(b);
}
if (s.version === 2) {
if (!Array.isArray(s.emitters) || s.emitters.length > 24) throw new Error('Invalid emitters');
const ids = new Set<number>();
for (const e of s.emitters) {
if (!validEmitter(e) || !Number.isSafeInteger(e.id) || e.id < 1 || ids.has(e.id) || !Number.isFinite(e.x) || !Number.isFinite(e.y) || e.x < 0 || e.x >= s.width || e.y < 0 || e.y >= s.height || !Number.isFinite(e.carry) || e.carry < 0 || e.carry >= 1) throw new Error('Invalid emitter');
ids.add(e.id); sim.emitters.push({ id: e.id, x: e.x, y: e.y, material: e.material, rate: e.rate, radius: e.radius, direction: e.direction, enabled: e.enabled, carry: e.carry }); sim.nextEmitterId = Math.max(sim.nextEmitterId, e.id + 1);
}
}
sim.rasterBodies(); return sim;
}
/** Resample the whole stage, preserving shapes, relative poses, and source settings. */
resized(width: number, height: number): Simulation {
if (!Number.isInteger(width) || !Number.isInteger(height) || width < 60 || height < 60 || width > 720 || height > 720) throw new Error('Invalid stage resolution');
const next = new Simulation(width, height, this.seed); next.gravity = this.gravity;
const sx = width / this.width, sy = height / this.height, scale = Math.min(sx, sy);
for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) {
const src = Math.min(this.height - 1, Math.floor(y / sy)) * this.width + Math.min(this.width - 1, Math.floor(x / sx));
next.cells[y * width + x] = this.cells[src]; next.life[y * width + x] = this.life[src];
}
const snapshot = next.snapshot();
snapshot.bodies = this.snapshot().bodies.map(b => ({ ...b, x: b.x * sx, y: b.y * sy, vx: b.vx * sx, vy: b.vy * sy, size: clamp(b.size * scale, 4, 24 * next.units) }));
snapshot.emitters = this.emitters.map(e => ({ ...e, x: e.x * sx, y: e.y * sy, radius: clamp(Math.round(e.radius * scale), 1, 24), rate: e.rate }));
return Simulation.restore(snapshot);
}
}
export function makeScene(name: string, width = 300, height = 200): Simulation {
const sim = new Simulation(width, height);
const rect = (x: number, y: number, w: number, h: number, m: number) => {
for (let py = Math.floor(y * height); py < (y + h) * height; py++) for (let px = Math.floor(x * width); px < (x + w) * width; px++) sim.set(px, py, m);
};
const body = (kind: BodyKind, x: number, y: number, size: number) => sim.addBody(kind, x * width, y * height, size * Math.min(width, height));
if (name === 'empty') return sim;
if (name === 'gaslab') {
rect(.08, .86, .84, .025, M.wall);
rect(.08, .54, .014, .32, M.wall); rect(.90, .54, .02, .32, M.wall);
rect(.48, .58, .014, .28, M.wall);
for (const [material, x, y, direction] of [[M.helium, .2, .76, 'up'], [M.smoke, .38, .76, 'up'], [M.co2, .65, .34, 'down'], [M.hydrogen, .81, .76, 'up']] as const) {
sim.addEmitter(x * width, y * height, { material, rate: Math.round(100 * sim.units), radius: Math.round(4 * sim.units), direction, enabled: true });
}
body('box', .58, .74, .04);
} else if (name === 'waterworks') {
rect(.08, .42, .3, .025, M.wall); rect(.07, .18, .015, .27, M.wall); rect(.08, .25, .19, .16, M.water);
rect(.58, .64, .32, .025, M.wall); rect(.885, .43, .015, .23, M.wall);
rect(.05, .88, .9, .035, M.wall); rect(.05, .65, .012, .24, M.wall); rect(.94, .65, .012, .24, M.wall);
rect(.065, .78, .87, .1, M.water); body('box', .5, .52, .045); body('stone', .3, .56, .045);
} else if (name === 'furnace') {
rect(.12, .85, .76, .04, M.wall); rect(.12, .55, .02, .3, M.wall); rect(.86, .55, .02, .3, M.wall);
rect(.14, .76, .72, .09, M.lava); rect(.3, .55, .36, .035, M.wood);
rect(.32, .37, .045, .18, M.wood); rect(.59, .37, .045, .18, M.wood);
rect(.32, .31, .32, .055, M.oil); body('box', .45, .2, .055); body('stone', .75, .3, .05);
} else {
// An open basin, a sand bank, a timber bridge and a small oil pocket.
rect(.055, .86, .89, .035, M.wall); rect(.055, .57, .015, .29, M.wall); rect(.93, .55, .015, .31, M.wall);
rect(.39, .735, .54, .125, M.water);
for (let y = Math.floor(height * .57); y < height * .86; y++) {
const half = (y / height - .57) * width * .6;
for (let x = Math.max(width * .07, width * .25 - half); x < width * .25 + half; x++) sim.set(x, y, M.sand);
}
rect(.11, .38, .19, .02, M.wall); rect(.108, .27, .012, .13, M.wall); rect(.285, .27, .015, .13, M.wall);
rect(.123, .31, .16, .07, M.oil);
rect(.45, .56, .19, .02, M.wood); rect(.455, .58, .016, .14, M.wood); rect(.617, .58, .016, .14, M.wood);
body('box', .51, .47, .047); body('box', .58, .35, .034);
body('box', .8, .64, .047); body('stone', .73, .38, .045); body('stone', .87, .5, .032);
}
sim.rasterBodies(); return sim;
}
