toastsim/src/core/rng.ts
monster 67cfe56bb9 M1: toasting — browning sim, toaster, lever, and the pop
Playable: pick a bread, set the dial, drop the lever, watch it brown, pop it
onto the plate. No timer readout — you go by smell.

- toasting.ts: heat map from real toaster behaviour (element stripes, cool top
  edge where the slice stands proud of the slot, edge falloff, per-run noise
  bias). Moisture must boil off before the crust browns at full rate, so
  sourdough stalls then catches up; sugar browns and burns early.
  Rate is measured against the heat map's actual ~0.77 mean, not guessed:
  power 6 -> golden 0.511 at 10s, verified in-browser.
- Props on a real scale (1 unit ~ 11cm, one slice wide) — the toaster is
  meant to dwarf the bread.
- The pop solves a genuine ballistic arc from slot to plate.

Three bugs found by driving it rather than trusting the compile:
- valueNoise2D read one past its grid at x=w-1, so heatBias was NaN along an
  edge, which poisoned mean browning to NaN — i.e. every judge score would
  have been NaN. Clamped.
- erode() clamped at the field border, so border texels could never erode out
  of the mask and kept those NaNs in scope. Off-grid now counts as outside.
- Timer.connect(document) zeroes dt whenever document.hidden is true, which
  froze the sim solid in an embedded browser. Dropped; rAF already pauses for
  hidden tabs. App.step(dt) is now exposed so the game can be driven
  deterministically for verification.

Also: screen shake was mutating the camera permanently instead of offsetting
it per-render, and the plate's lathe profile touched the axis (degenerate
triangles -> starburst normals) — rebuilt from primitives.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 21:29:51 +10:00

63 lines
2.1 KiB
TypeScript

/** Small deterministic RNG (mulberry32) — seeded so a run can be replayed. */
export class Rng {
private s: number;
constructor(seed = 1) {
this.s = seed >>> 0;
}
next(): number {
this.s = (this.s + 0x6d2b79f5) >>> 0;
let t = this.s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
range(a: number, b: number): number {
return a + this.next() * (b - a);
}
int(a: number, b: number): number {
return Math.floor(this.range(a, b + 1));
}
pick<T>(arr: readonly T[]): T {
return arr[Math.floor(this.next() * arr.length)];
}
chance(p: number): boolean {
return this.next() < p;
}
}
/** Cheap 2D value noise, used to give each toasting run its own character. */
export function valueNoise2D(rng: Rng, w: number, h: number, octaves = 3): Float32Array {
const out = new Float32Array(w * h);
let amp = 1;
let total = 0;
for (let o = 0; o < octaves; o++) {
const cells = 2 << o;
const grid = new Float32Array((cells + 1) * (cells + 1));
for (let i = 0; i < grid.length; i++) grid[i] = rng.next();
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const fx = (x / (w - 1)) * cells;
const fy = (y / (h - 1)) * cells;
const x0 = Math.floor(fx);
const y0 = Math.floor(fy);
const tx = fx - x0;
const ty = fy - y0;
const sx = tx * tx * (3 - 2 * tx);
const sy = ty * ty * (3 - 2 * ty);
// Clamp: at x === w-1, fx lands exactly on `cells`, so the x0+1 lookup
// runs one past the end of the grid. A typed array returns undefined
// there, which silently turns the whole field into NaN.
const g = (gx: number, gy: number) =>
grid[Math.min(gy, cells) * (cells + 1) + Math.min(gx, cells)];
const a = g(x0, y0) * (1 - sx) + g(x0 + 1, y0) * sx;
const b = g(x0, y0 + 1) * (1 - sx) + g(x0 + 1, y0 + 1) * sx;
out[y * w + x] += (a * (1 - sy) + b * sy) * amp;
}
}
total += amp;
amp *= 0.5;
}
for (let i = 0; i < out.length; i++) out[i] /= total;
return out;
}