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>
63 lines
2.1 KiB
TypeScript
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;
|
|
}
|