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>
This commit is contained in:
monster 2026-07-16 21:29:51 +10:00
parent 10adb268a0
commit 67cfe56bb9
13 changed files with 651 additions and 79 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 878 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -104,8 +104,11 @@ export class App {
this.renderer.toneMappingExposure = 1.05;
this.camera = new THREE.PerspectiveCamera(42, 1, 0.05, 100);
this.input = new Input(canvas);
// Pauses the clock while the tab is hidden, so you don't come back to charcoal.
this.timer.connect(document);
// Deliberately NOT timer.connect(document): it zeroes the delta whenever
// document.hidden is true, which some embedded/preview browsers report
// permanently — the sim then freezes with no error. Browsers already pause
// rAF for hidden tabs, so the toast can't burn while you're away regardless,
// and the dt clamp below covers the hitch on resume.
this.scene.fog = new THREE.Fog(0x1b1512, 8, 22);
window.addEventListener('resize', () => this.resize());
this.resize();
@ -143,24 +146,46 @@ export class App {
this.view?.resize?.(w, h);
}
/**
* One simulation + render step. Normally driven by rAF; exposed so tests (and
* headless browsers that never fire rAF) can advance the game deterministically.
*/
step(dt: number): void {
for (const t of this.tickers) t(dt);
this.view?.update(dt);
this.renderFrame(dt);
this.input.endFrame();
}
private savedPos = new THREE.Vector3();
private savedQuat = new THREE.Quaternion();
private renderFrame(dt: number): void {
// Shake is an offset applied for the render only — baking it into the
// camera would let it accumulate and walk the view off the bench.
if (this.shake > 0.0001) {
const s = this.shake;
this.savedPos.copy(this.camera.position);
this.savedQuat.copy(this.camera.quaternion);
this.camera.position.x += (Math.random() - 0.5) * s;
this.camera.position.y += (Math.random() - 0.5) * s;
this.camera.rotateZ((Math.random() - 0.5) * s * 0.35);
this.renderer.render(this.scene, this.camera);
this.camera.position.copy(this.savedPos);
this.camera.quaternion.copy(this.savedQuat);
this.shake *= Math.pow(0.0015, dt);
if (this.shake < 0.0005) this.shake = 0;
} else {
this.renderer.render(this.scene, this.camera);
}
}
start(): void {
const loop = (ts?: number) => {
requestAnimationFrame(loop);
this.timer.update(ts);
// Clamp so a single frame hitch can't teleport the sim forward.
const dt = Math.min(this.timer.getDelta(), 1 / 20);
for (const t of this.tickers) t(dt);
this.view?.update(dt);
if (this.shake > 0.0001) {
const s = this.shake;
this.camera.position.x += (Math.random() - 0.5) * s;
this.camera.position.y += (Math.random() - 0.5) * s;
this.camera.rotateZ((Math.random() - 0.5) * s * 0.35);
this.shake *= Math.pow(0.0015, dt);
if (this.shake < 0.0005) this.shake = 0;
}
this.renderer.render(this.scene, this.camera);
this.input.endFrame();
this.step(Math.min(this.timer.getDelta(), 1 / 20));
};
loop();
}
@ -182,7 +207,7 @@ export function makeLights(scene: THREE.Scene): THREE.DirectionalLight {
key.shadow.bias = -0.0016;
key.shadow.normalBias = 0.02;
scene.add(key);
scene.add(new THREE.HemisphereLight(0x9fb4d6, 0x3a2a20, 0.75));
scene.add(new THREE.HemisphereLight(0xbfc6d2, 0x4a3526, 0.7));
const fill = new THREE.DirectionalLight(0xbdd6ff, 0.45);
fill.position.set(-3, 2.2, -2.5);
scene.add(fill);

View File

@ -44,7 +44,11 @@ export function valueNoise2D(rng: Rng, w: number, h: number, octaves = 3): Float
const ty = fy - y0;
const sx = tx * tx * (3 - 2 * tx);
const sy = ty * ty * (3 - 2 * ty);
const g = (gx: number, gy: number) => grid[gy * (cells + 1) + gx];
// 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;

View File

@ -1,64 +1,20 @@
import * as THREE from 'three';
import { App, makeLights } from './core/app';
import { Slice } from './sim/slice';
import { BREADS } from './sim/bread';
import { Rng } from './core/rng';
import { KitchenView } from './scenes/kitchen';
import './style.css';
const canvas = document.getElementById('c') as HTMLCanvasElement;
const app = new App(canvas);
makeLights(app.scene);
app.scene.background = new THREE.Color(0x1d1613);
app.scene.background = new THREE.Color(0x241a15);
const bench = new THREE.Mesh(
new THREE.BoxGeometry(9, 0.3, 5),
new THREE.MeshStandardMaterial({ color: 0x8a5a33, roughness: 0.85 }),
);
bench.position.y = -0.15;
bench.receiveShadow = true;
app.scene.add(bench);
const kitchen = new KitchenView(app, 3);
app.scene.add(kitchen.root);
app.setView(kitchen);
const slice = new Slice(BREADS.white, new Rng(7));
slice.mesh.position.y = 0.12;
app.scene.add(slice.mesh);
app.camera.position.set(0.05, 3.15, 5.6);
app.camera.lookAt(0.0, 0.85, -0.15);
// M0 smoke test: a browning gradient + a smear of butter, so the shader gets exercised.
for (let y = 0; y < slice.browning.n; y++) {
for (let x = 0; x < slice.browning.n; x++) {
const i = y * slice.browning.n + x;
slice.browning.data[i] = (x / slice.browning.n) * 1.0;
const dx = x / slice.spread.n - 0.5;
const dy = y / slice.spread.n - 0.55;
slice.spread.data[i] = Math.max(0, 0.45 - Math.hypot(dx, dy) * 1.2);
}
}
slice.setSpread({
id: 'butter',
name: 'Butter',
blurb: '',
color: [0.97, 0.72, 0.19],
gloss: 0.85,
opaqueAt: 0.5,
bump: 0.1,
baseYield: 0.72,
tempSoftening: 0.78,
viscosity: 0.72,
pickup: 0.55,
transferRate: 1.5,
drag: 0.5,
scrapeEase: 1,
amounts: { thin: [0.1, 0.22], normal: [0.25, 0.5], thick: [0.55, 0.9] },
});
slice.touch();
slice.sync();
(window as unknown as { __t: unknown }).__t = { app, slice, THREE };
app.camera.position.set(0, 1.5, 1.6);
app.camera.lookAt(0, 0.1, 0);
app.onTick((dt) => {
slice.mesh.rotation.y += dt * 0.4;
});
(window as unknown as { __t: unknown }).__t = { app, kitchen, THREE };
app.start();

200
src/scenes/kitchen.ts Normal file
View File

@ -0,0 +1,200 @@
import * as THREE from 'three';
import type { App, View } from '../core/app';
import { Rng } from '../core/rng';
import { Slice } from '../sim/slice';
import { BREADS, type BreadId } from '../sim/bread';
import { buildHeatMap, coolStep, readToast, smellCue, toastStep } from '../sim/toasting';
import { makeBench, makePlate, makeToaster, type Toaster } from './props';
import { el, Panel } from '../ui/hud';
const TOASTER_X = -1.5;
const PLATE_X = 1.45;
const PLATE_Z = 0.55;
const PLATE_Y = 0.05;
type Phase = 'ready' | 'toasting' | 'flying' | 'landed';
/**
* The bench. Owns the toaster, the plate and the current slice, and runs the
* toasting phase: dial, lever, browning, and the pop.
*/
export class KitchenView implements View {
readonly root = new THREE.Group();
private toaster: Toaster;
private plate: THREE.Group;
private slice!: Slice;
private heat!: Float32Array;
private rng: Rng;
phase: Phase = 'ready';
power = 6;
private leverT = 0; // 0 up, 1 down
private vel = new THREE.Vector3();
private spin = 0;
private toastSeconds = 0;
private panel: Panel;
private cueEl!: HTMLElement;
private hintEl!: HTMLElement;
private dialEl!: HTMLInputElement;
private breadEl!: HTMLSelectElement;
/** Fired when the slice has landed on the plate — M2 picks up from here. */
onLanded: ((slice: Slice) => void) | null = null;
constructor(
private app: App,
seed = 1,
) {
this.rng = new Rng(seed);
this.root.add(makeBench());
this.toaster = makeToaster();
this.toaster.root.position.set(TOASTER_X, 0, -0.35);
this.root.add(this.toaster.root);
this.plate = makePlate();
this.plate.position.set(PLATE_X, 0, PLATE_Z);
this.root.add(this.plate);
this.panel = new Panel('toast-panel');
this.buildUi();
this.loadBread('white');
}
private buildUi(): void {
const p = this.panel.root;
const row = el('div', 'row', p);
el('label', 'lbl', row, 'BROWNING');
this.dialEl = el('input', 'dial', row);
this.dialEl.type = 'range';
this.dialEl.min = '1';
this.dialEl.max = '10';
this.dialEl.step = '1';
this.dialEl.value = String(this.power);
const val = el('span', 'val', row, String(this.power));
this.dialEl.addEventListener('input', () => {
this.power = +this.dialEl.value;
val.textContent = this.dialEl.value;
this.toaster.dial.rotation.x = -((this.power - 1) / 9) * 2.4;
});
const row2 = el('div', 'row', p);
el('label', 'lbl', row2, 'BREAD');
this.breadEl = el('select', 'sel', row2);
for (const id of Object.keys(BREADS)) {
const o = el('option', undefined, this.breadEl, BREADS[id as BreadId].name);
o.value = id;
}
this.breadEl.addEventListener('change', () => this.loadBread(this.breadEl.value as BreadId));
this.cueEl = el('div', 'cue', p, 'smells like bread');
this.hintEl = el('div', 'hint', p, 'SPACE — push the lever down');
}
loadBread(id: BreadId): void {
if (this.slice) {
this.root.remove(this.slice.mesh);
this.slice.dispose();
}
this.slice = new Slice(BREADS[id], new Rng(this.rng.int(1, 1e6)));
this.heat = buildHeatMap(this.slice, new Rng(this.rng.int(1, 1e6)));
this.root.add(this.slice.mesh);
this.phase = 'ready';
this.leverT = 0;
this.toastSeconds = 0;
this.slice.warmth = 0;
this.placeInSlot(0);
this.hintEl.textContent = 'SPACE — push the lever down';
}
get currentSlice(): Slice {
return this.slice;
}
/** t: 0 = sitting proud of the slot, 1 = fully down. */
private placeInSlot(t: number): void {
const y = this.toaster.slotY + 0.16 - t * 0.74;
this.slice.mesh.position.set(TOASTER_X, y, -0.35);
this.slice.mesh.rotation.set(Math.PI / 2, 0, 0); // standing, dome up
}
enter(): void {
this.panel.show();
}
exit(): void {
this.panel.hide();
}
private pushDown(): void {
if (this.phase !== 'ready') return;
this.phase = 'toasting';
this.hintEl.textContent = 'SPACE — pop it';
}
private pop(): void {
if (this.phase !== 'toasting') return;
this.phase = 'flying';
this.slice.warmth = 1;
this.toaster.setGlow(0);
// Solve a real arc from the slot to the plate rather than tweening: the
// slice should look thrown, because it is.
const p0 = this.slice.mesh.position.clone();
const y1 = PLATE_Y + 0.02;
const vy = 4.2;
const g = 9.8;
const disc = Math.max(0.01, vy * vy - 2 * g * (y1 - p0.y));
const T = (vy + Math.sqrt(disc)) / g;
this.vel.set((PLATE_X - p0.x) / T, vy, (PLATE_Z - p0.z) / T);
this.spin = (Math.PI / 2 - 0) / T;
this.app.shake = 0.045;
this.hintEl.textContent = '';
}
update(dt: number): void {
const inp = this.app.input;
if (inp.justPressed('Space')) {
if (this.phase === 'ready') this.pushDown();
else if (this.phase === 'toasting') this.pop();
}
// lever + slice ride down together
const target = this.phase === 'ready' ? 0 : this.phase === 'toasting' ? 1 : 0;
this.leverT += (target - this.leverT) * Math.min(1, dt * 14);
this.toaster.lever.position.y = 1.62 * 0.72 - this.leverT * 0.62;
if (this.phase === 'toasting') {
this.placeInSlot(this.leverT);
this.toastSeconds += dt;
toastStep(this.slice, this.heat, this.power, dt);
this.toaster.setGlow(0.35 + 0.65 * (this.power / 10) * (0.92 + Math.sin(this.toastSeconds * 9) * 0.08));
this.slice.setHeatGlow(0.55 + 0.45 * Math.sin(this.toastSeconds * 7) * 0.3);
} else if (this.phase === 'ready') {
this.placeInSlot(this.leverT);
} else if (this.phase === 'flying') {
this.vel.y -= 9.8 * dt;
this.slice.mesh.position.addScaledVector(this.vel, dt);
this.slice.mesh.rotation.x = Math.max(0, this.slice.mesh.rotation.x - this.spin * dt);
this.slice.mesh.rotation.z += dt * 1.1;
this.slice.setHeatGlow(0);
if (this.slice.mesh.position.y <= PLATE_Y + 0.02 && this.vel.y < 0) {
this.slice.mesh.position.set(PLATE_X, PLATE_Y + 0.02, PLATE_Z);
this.slice.mesh.rotation.set(0, 0, 0);
this.phase = 'landed';
this.app.shake = 0.02;
this.onLanded?.(this.slice);
}
}
if (this.phase !== 'toasting') coolStep(this.slice, dt);
const t = readToast(this.slice);
this.cueEl.textContent = smellCue(t);
this.cueEl.style.color = t.smoke > 0.45 ? '#e2603a' : '';
this.slice.sync();
}
dispose(): void {
this.panel.dispose();
this.slice.dispose();
}
}

186
src/scenes/props.ts Normal file
View File

@ -0,0 +1,186 @@
import * as THREE from 'three';
export function roundedRect(w: number, h: number, r: number): THREE.Shape {
const hw = w / 2;
const hh = h / 2;
const s = new THREE.Shape();
s.moveTo(-hw + r, -hh);
s.lineTo(hw - r, -hh);
s.quadraticCurveTo(hw, -hh, hw, -hh + r);
s.lineTo(hw, hh - r);
s.quadraticCurveTo(hw, hh, hw - r, hh);
s.lineTo(-hw + r, hh);
s.quadraticCurveTo(-hw, hh, -hw, hh - r);
s.lineTo(-hw, -hh + r);
s.quadraticCurveTo(-hw, -hh, -hw + r, -hh);
s.closePath();
return s;
}
const CREAM = 0xefdfbc;
const CHROME = 0xc9ced6;
const DARK = 0x0d0b0a;
export interface Toaster {
root: THREE.Group;
lever: THREE.Group;
dial: THREE.Group;
/** World position of the slot mouth. */
slotY: number;
slotZ: number;
/** Glowing elements inside; intensity 0..1. */
setGlow(v: number): void;
}
/**
* A retro two-slot toaster. Procedural for now: the generated GLB is prettier
* but this one has a lever we can actually drive, and the shape is the hero of
* the toasting scene either way.
*/
export function makeToaster(): Toaster {
const root = new THREE.Group();
const bodyMat = new THREE.MeshStandardMaterial({ color: CREAM, roughness: 0.62, metalness: 0.06 });
const chromeMat = new THREE.MeshStandardMaterial({ color: CHROME, roughness: 0.22, metalness: 0.9 });
const darkMat = new THREE.MeshStandardMaterial({ color: DARK, roughness: 0.95 });
// A slice of bread is ~11cm and this game measures in slices: 1 unit ~ 11cm.
// So a 28x18x15cm toaster is this big, and it dwarfs the bread. It should.
const W = 2.52;
const H = 1.62;
const D = 1.36;
const body = new THREE.Mesh(
new THREE.ExtrudeGeometry(roundedRect(W, H, 0.42), {
depth: D,
bevelEnabled: true,
bevelThickness: 0.05,
bevelSize: 0.05,
bevelSegments: 3,
curveSegments: 20,
}),
bodyMat,
);
body.geometry.center();
body.position.y = H / 2 + 0.04;
body.castShadow = true;
body.receiveShadow = true;
root.add(body);
// chrome cap with the slot cut into it
const cap = new THREE.Mesh(new THREE.BoxGeometry(W * 0.84, 0.07, D * 0.88), chromeMat);
cap.position.set(0, H + 0.04, 0);
cap.castShadow = true;
root.add(cap);
const slot = new THREE.Mesh(new THREE.BoxGeometry(W * 0.52, 0.2, 0.3), darkMat);
slot.position.set(0, H + 0.02, 0);
root.add(slot);
// elements: a few vertical bars either side of the slot, they glow when hot
const glowMat = new THREE.MeshStandardMaterial({
color: 0x3a1508,
emissive: new THREE.Color(0xff3a08),
emissiveIntensity: 0,
roughness: 0.7,
});
for (let i = 0; i < 5; i++) {
const x = (i / 4 - 0.5) * W * 0.46;
for (const z of [-0.15, 0.15]) {
const bar = new THREE.Mesh(new THREE.BoxGeometry(0.05, 0.9, 0.028), glowMat);
bar.position.set(x, H - 0.52, z);
root.add(bar);
}
}
// feet
const footMat = new THREE.MeshStandardMaterial({ color: 0x1a1a1a, roughness: 0.9 });
for (const sx of [-1, 1]) {
for (const sz of [-1, 1]) {
const foot = new THREE.Mesh(new THREE.CylinderGeometry(0.1, 0.11, 0.09, 12), footMat);
foot.position.set(sx * W * 0.36, 0.045, sz * D * 0.3);
root.add(foot);
}
}
// browning dial on the right cheek
const dial = new THREE.Group();
const knob = new THREE.Mesh(new THREE.CylinderGeometry(0.19, 0.19, 0.09, 24), chromeMat);
knob.rotation.z = Math.PI / 2;
knob.castShadow = true;
dial.add(knob);
const pointer = new THREE.Mesh(new THREE.BoxGeometry(0.035, 0.16, 0.05), darkMat);
pointer.position.set(0.05, 0.08, 0);
dial.add(pointer);
dial.position.set(W / 2 + 0.015, H * 0.42, 0.34);
root.add(dial);
// lever on a track
const lever = new THREE.Group();
const stem = new THREE.Mesh(new THREE.BoxGeometry(0.11, 0.11, 0.26), chromeMat);
stem.position.z = 0.12;
lever.add(stem);
const grip = new THREE.Mesh(new THREE.BoxGeometry(0.18, 0.34, 0.13), bodyMat);
grip.position.z = 0.3;
grip.castShadow = true;
lever.add(grip);
lever.position.set(-W / 2 + 0.05, H * 0.72, D / 2 - 0.05);
root.add(lever);
const track = new THREE.Mesh(new THREE.BoxGeometry(0.05, 0.82, 0.07), darkMat);
track.position.set(-W / 2 + 0.012, H * 0.55, D / 2 + 0.05);
root.add(track);
return {
root,
lever,
dial,
slotY: H + 0.04,
slotZ: 0,
setGlow(v: number) {
glowMat.emissiveIntensity = v * 3.2;
glowMat.color.setRGB(0.23 + v * 0.3, 0.08, 0.03);
},
};
}
/**
* Built from primitives rather than a lathe: a lathe profile that touches the
* axis collapses a whole ring of vertices onto one point, and the degenerate
* triangles there render as a starburst of garbage normals.
*/
export function makePlate(): THREE.Group {
const g = new THREE.Group();
const mat = new THREE.MeshStandardMaterial({ color: 0xf4f2ec, roughness: 0.3, metalness: 0.02 });
const well = new THREE.Mesh(new THREE.CylinderGeometry(0.84, 0.74, 0.045, 56), mat);
well.position.y = 0.022;
well.castShadow = true;
well.receiveShadow = true;
g.add(well);
const rim = new THREE.Mesh(new THREE.TorusGeometry(0.84, 0.036, 10, 56), mat);
rim.rotation.x = Math.PI / 2;
rim.position.y = 0.05;
rim.castShadow = true;
rim.receiveShadow = true;
g.add(rim);
return g;
}
export function makeBench(): THREE.Group {
const g = new THREE.Group();
const top = new THREE.Mesh(
new THREE.BoxGeometry(20, 0.5, 9),
new THREE.MeshStandardMaterial({ color: 0x9a6134, roughness: 0.72 }),
);
top.position.y = -0.25;
top.receiveShadow = true;
g.add(top);
// splashback, so the scene has a horizon instead of fog
const wall = new THREE.Mesh(
new THREE.BoxGeometry(20, 7, 0.3),
new THREE.MeshStandardMaterial({ color: 0x5d5348, roughness: 0.9 }),
);
wall.position.set(0, 3.5, -4.4);
wall.receiveShadow = true;
g.add(wall);
return g;
}

View File

@ -230,19 +230,13 @@ function pointInPoly(x: number, y: number, pts: THREE.Vector2[]): boolean {
function erode(mask: Field): void {
const n = mask.n;
const src = mask.data.slice();
// Anything off the grid counts as outside, so texels on the very border erode
// away too — clamping the lookups instead would let them survive.
const at = (x: number, y: number) => (x < 0 || y < 0 || x >= n || y >= n ? 0 : src[y * n + x]);
for (let y = 0; y < n; y++) {
for (let x = 0; x < n; x++) {
if (src[y * n + x] < 0.5) continue;
const xm = Math.max(0, x - 1);
const xp = Math.min(n - 1, x + 1);
const ym = Math.max(0, y - 1);
const yp = Math.min(n - 1, y + 1);
if (
src[y * n + xm] < 0.5 ||
src[y * n + xp] < 0.5 ||
src[ym * n + x] < 0.5 ||
src[yp * n + x] < 0.5
) {
if (at(x - 1, y) < 0.5 || at(x + 1, y) < 0.5 || at(x, y - 1) < 0.5 || at(x, y + 1) < 0.5) {
mask.data[y * n + x] = 0;
}
}

103
src/sim/toasting.ts Normal file
View File

@ -0,0 +1,103 @@
import type { Slice } from './slice';
import { Rng } from '../core/rng';
/**
* Browning rate. Measured, not guessed: the heat map's mean is ~0.77 (coils,
* edge falloff and the cool top all bite), so this is set so power 6 reaches a
* golden 0.5 in ~10s, power 10 is nearly black by then, and power 3 takes ~20s.
*/
const RATE = 0.11;
/** Where the elements actually put their heat. */
export function buildHeatMap(slice: Slice, rng: Rng): Float32Array {
const n = slice.mask.n;
const out = new Float32Array(n * n);
// Real toasters have vertical element wires spaced across the slot, so the
// hot spots are stripes, not a wash. Jitter the phase per run.
const coils = 4 + rng.int(0, 2);
const phase = rng.range(0, Math.PI * 2);
for (let y = 0; y < n; y++) {
for (let x = 0; x < n; x++) {
const u = x / (n - 1);
const v = y / (n - 1);
// Subtle: at much more than this the slice reads as a grill plate, not toast.
const coil = 0.92 + 0.08 * Math.sin(u * Math.PI * 2 * coils + phase);
// The top of the slice sticks up out of the slot and stays pale — the
// single most recognisable thing about real toast.
const vert = v > 0.72 ? 1 - 0.5 * Math.pow((v - 0.72) / 0.28, 1.5) : 1;
// and the very bottom sits on the rack, slightly shaded
const base = v < 0.06 ? 0.82 + (v / 0.06) * 0.18 : 1;
// heat escapes at the left/right edges
const edge = 0.82 + 0.18 * Math.sin(Math.min(1, Math.max(0, u)) * Math.PI);
const bias = 0.88 + slice.heatBias[y * n + x] * 0.24;
out[y * n + x] = coil * vert * base * edge * bias;
}
}
return out;
}
export interface ToastTick {
/** Mean browning over the slice — drives the smell cues. */
mean: number;
/** How much of the slice is past char. */
charFrac: number;
/** 0..1, how smoky it is right now. */
smoke: number;
}
/**
* Advance the browning one tick.
*
* Two things make this more than a timer:
* - moisture has to boil off before the surface can brown at full rate, so a wet
* crumb stalls and then accelerates once it's dry (sourdough's whole trick);
* - sugar browns early and burns early (raisin bread's whole trick).
*/
export function toastStep(slice: Slice, heat: Float32Array, power: number, dt: number): void {
const b = slice.bread;
const p = Math.max(0, Math.min(10, power)) / 10;
const moistureCap = 0.4 + b.moisture * 2.2;
const thick = 0.6 + b.thickness * 3;
const sugar = 1 + b.sugar * 0.8;
const br = slice.browning.data;
const dry = slice.dryness.data;
const mask = slice.mask.data;
for (let i = 0; i < br.length; i++) {
if (mask[i] < 0.5) continue;
const h = heat[i] * p;
if (h <= 0) continue;
dry[i] = Math.min(1, dry[i] + (h * dt) / moistureCap);
const rate = (RATE * h * (0.25 + 0.75 * dry[i]) * sugar) / thick;
br[i] = Math.min(1.15, br[i] + rate * dt);
}
slice.touch();
}
/** Warmth decays in real time. Thick slices hold heat longer. */
export function coolStep(slice: Slice, dt: number): void {
const tau = 18 + slice.bread.thickness * 90;
slice.warmth *= Math.exp(-dt / tau);
slice.material.uniforms.uWarmth.value = slice.warmth;
}
export function readToast(slice: Slice): ToastTick {
const s = slice.browning.stats(slice.mask);
const charFrac = slice.browning.fraction(slice.mask, (v) => v > 0.85);
const smoke = Math.max(0, Math.min(1, (s.max - 0.78) / 0.3));
return { mean: s.mean, charFrac, smoke };
}
/**
* No timer, no numbers you go by smell. Deliberately vague until it isn't.
*/
export function smellCue(t: ToastTick): string {
if (t.smoke > 0.75) return 'SMOKE. ACTUAL SMOKE.';
if (t.smoke > 0.45) return 'something is burning';
if (t.mean > 0.62) return 'dark, and getting darker';
if (t.mean > 0.42) return 'smells like toast';
if (t.mean > 0.22) return 'smells toasty';
if (t.mean > 0.08) return 'smells warm';
if (t.mean > 0.01) return 'a faint warmth';
return 'smells like bread';
}

View File

@ -49,3 +49,70 @@ canvas#c {
#ui > * {
pointer-events: auto;
}
.toast-panel {
position: absolute;
left: 24px;
bottom: 24px;
width: 290px;
padding: 16px 18px;
background: rgba(12, 9, 8, 0.72);
border: 1px solid rgba(243, 233, 220, 0.14);
border-radius: 10px;
backdrop-filter: blur(9px);
}
.toast-panel .row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.toast-panel .lbl {
font-size: 10px;
letter-spacing: 0.14em;
color: var(--ink-dim);
width: 74px;
flex: none;
}
.toast-panel .dial {
flex: 1;
accent-color: var(--gold);
}
.toast-panel .val {
font-family: var(--mono);
font-size: 13px;
color: var(--gold);
width: 18px;
text-align: right;
}
.toast-panel .sel {
flex: 1;
background: rgba(0, 0, 0, 0.4);
color: var(--ink);
border: 1px solid rgba(243, 233, 220, 0.18);
border-radius: 5px;
padding: 4px 6px;
font-size: 12px;
}
.toast-panel .cue {
margin-top: 14px;
font-size: 15px;
font-style: italic;
color: var(--ink);
min-height: 20px;
transition: color 0.4s;
}
.toast-panel .hint {
margin-top: 6px;
font-size: 11px;
letter-spacing: 0.1em;
color: var(--ink-dim);
min-height: 14px;
}

37
src/ui/hud.ts Normal file
View File

@ -0,0 +1,37 @@
/** Tiny DOM helpers. The UI is an overlay; the game is the 3D scene. */
export function el<K extends keyof HTMLElementTagNameMap>(
tag: K,
cls?: string,
parent?: HTMLElement,
text?: string,
): HTMLElementTagNameMap[K] {
const e = document.createElement(tag);
if (cls) e.className = cls;
if (text !== undefined) e.textContent = text;
if (parent) parent.appendChild(e);
return e;
}
export function clear(e: HTMLElement): void {
while (e.firstChild) e.removeChild(e.firstChild);
}
export const ui = document.getElementById('ui') as HTMLElement;
/** A panel that owns a chunk of the overlay and can be shown/hidden as a unit. */
export class Panel {
readonly root: HTMLElement;
constructor(cls: string) {
this.root = el('div', cls, ui);
}
show(v = true): void {
this.root.style.display = v ? '' : 'none';
}
hide(): void {
this.show(false);
}
dispose(): void {
this.root.remove();
}
}