toastsim/src/scenes/oven.ts
type-two b76820b88e Heat sources M-H1/M-H2: the knob is not the heat
New bone src/sim/heatsource.ts: a HeatSource sits between the knob and
the browning integrator — the dial sets target, output chases it on a
fuel-specific lag, every station reads output. Five fuels (gas/electric/
induction/charcoal/radiant) + appliance SKUs (oven_gas, oven_fan,
cheap_electric). roasting.ts retrofit is golden-preserving: optional
src?, reads src.output ?? power, byte-identical when absent.

Verified in-browser:
- GOLDEN: no-src oven = mean 0.698 / blister 1.0 / collapse 0 (== HEAD).
- M-H1 chase 3->9: gas 0.433s, electric 6.0s, induction 0.133s (distinct).
- residual after 9->0: gas 0@1s, electric 6.6@6s, charcoal 9@30s.
- M-H2: gas oven stdev 0.1207 vs fan 0.0016 at equal mean 0.6001 (75x).
- day 14 bruschetta now roasts on oven_gas, still grades S (9.62); oven
  HUD reads 'GAS OVEN 8 · blue flame', tomatoes visibly hot-at-centre.

Doctrine: OPUS-BUILD-BRIEF-HEAT.md (the cross-cutting heat & environment
doctrine — 'you never cook the knob, you cook the lag'). Harness:
t.heatChase/residual/ovenStdev/ovenGolden/ovenDemo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 14:16:54 +10:00

271 lines
9.8 KiB
TypeScript

import * as THREE from 'three';
import type { App, View } from '../core/app';
import { audio } from '../core/audio';
import { INGREDIENTS } from '../sim/ingredients';
import {
newRoastSession,
roastStep,
roastResult,
roastWord,
BLISTER_AT,
COLLAPSE_AT,
type RoastResult,
type RoastSession,
} from '../sim/roasting';
import { el, Panel } from '../ui/hud';
import { loadProp } from './assets';
import { newHeatSource, fuelWord } from '../sim/heatsource';
const TRAY_Y = 0.06;
const TRAY_W = 3.4;
const TRAY_D = 1.6;
/**
* The oven — the toaster, generalized. A tray of tomato halves, a heat dial, and
* a door that is really the toaster's lever wearing a different hat: SPACE slides
* the tray in, SPACE pulls it out. While it's in, the same browning Field the
* toast uses climbs on the tomatoes (roasting.ts). Past 0.6 they blister; hold
* too long and they slump to sauce.
*
* The visual is deliberately cheap (a tray, red domes that darken and sag) — the
* gameplay is the clock and the dial, and both are read straight off the sim, so
* the harness roasts headless and reads the exact numbers the judge scores.
*/
export class OvenView implements View {
readonly root = new THREE.Group();
private session: RoastSession | null = null;
private halves: THREE.Mesh[] = [];
private tray!: THREE.Group;
private door!: THREE.Mesh;
private glow!: THREE.Mesh;
/** 'ready' → tray out, 'roasting' → tray in and cooking, 'done' → pulled out. */
private phase: 'ready' | 'roasting' | 'done' = 'ready';
/** The oven dial, 1..10. The harness writes it before the first SPACE. */
power = 6;
private halfCount = 4;
private doneTimer = 0;
/** The oven's fuel character, if any — 'oven_gas' (hot-at-top, uneven) or
* 'oven_fan' (laboratory-flat). Undefined = the legacy dial-is-heat oven. */
private fuel: string | undefined;
private fleshColor = new THREE.Color().setRGB(...INGREDIENTS.tomato.colors.flesh, THREE.SRGBColorSpace);
private panel: Panel;
private askEl!: HTMLElement;
private stateEl!: HTMLElement;
private progFill!: HTMLElement;
private wordEl!: HTMLElement;
private hintEl!: HTMLElement;
onDone: ((result: RoastResult) => void) | null = null;
constructor(private app: App) {
this.buildScenery();
this.panel = new Panel('oven-panel');
this.buildUi();
this.panel.hide();
}
private buildUi(): void {
const p = this.panel.root;
const card = el('div', 'slicer-card', p);
el('div', 'drawer-lbl', card, 'ROAST IT');
this.askEl = el('div', 'slicer-ask', card, '');
this.stateEl = el('div', 'slicer-thick', card, '');
const bar = el('div', 'timer-bar', card);
this.progFill = el('div', 'timer-fill', bar);
this.wordEl = el('div', 'slicer-wobble', card, '');
this.hintEl = el('div', 'drawer-hint', p, '');
}
private buildScenery(): void {
const bench = new THREE.Mesh(
new THREE.BoxGeometry(24, 0.4, 12),
new THREE.MeshStandardMaterial({ color: 0x3a2c20, roughness: 0.85 }),
);
bench.position.y = -0.2;
bench.receiveShadow = true;
this.root.add(bench);
// The oven box behind the tray, with a glowing element inside.
const oven = new THREE.Mesh(
new THREE.BoxGeometry(TRAY_W + 0.6, 1.4, TRAY_D + 0.5),
new THREE.MeshStandardMaterial({ color: 0x22201e, roughness: 0.6, metalness: 0.3 }),
);
oven.position.set(0, 0.7, -1.4);
oven.castShadow = true;
oven.receiveShadow = true;
this.root.add(oven);
this.glow = new THREE.Mesh(
new THREE.PlaneGeometry(TRAY_W, TRAY_D),
new THREE.MeshBasicMaterial({ color: 0xff5a1e, transparent: true, opacity: 0 }),
);
this.glow.rotation.x = -Math.PI / 2;
this.glow.position.set(0, 1.28, -1.4);
this.root.add(this.glow);
// The tray.
this.tray = new THREE.Group();
const trayMesh = new THREE.Mesh(
new THREE.BoxGeometry(TRAY_W, TRAY_Y * 2, TRAY_D),
new THREE.MeshStandardMaterial({ color: 0x555049, roughness: 0.5, metalness: 0.6 }),
);
trayMesh.position.y = TRAY_Y;
trayMesh.receiveShadow = true;
trayMesh.castShadow = true;
this.tray.add(trayMesh);
this.root.add(this.tray);
void loadProp('/assets/models/oven_tray.glb', TRAY_W)
.then((prop) => {
prop.position.y = 0;
trayMesh.visible = false;
this.tray.add(prop);
})
.catch(() => undefined);
// A little door flap that lifts as the tray slides in.
this.door = new THREE.Mesh(
new THREE.BoxGeometry(TRAY_W + 0.6, 0.7, 0.08),
new THREE.MeshStandardMaterial({ color: 0x2c2825, roughness: 0.5, metalness: 0.4 }),
);
this.door.position.set(0, 0.35, -0.62);
this.root.add(this.door);
}
/** A tray of `halves` tomato halves and a fresh roast. */
reset(halves = 4, power = 6, askText = 'roast the tomatoes', fuel?: string): void {
this.halfCount = halves;
this.power = power;
this.fuel = fuel;
this.session = newRoastSession(halves, 20260718, power, fuel ? newHeatSource(fuel) : undefined);
this.phase = 'ready';
this.doneTimer = 0;
for (const h of this.halves) this.tray.remove(h);
this.halves = [];
const r = INGREDIENTS.roast_tomato.size / 2;
for (let i = 0; i < halves; i++) {
// A cut half: a squashed dome, flat side down on the tray.
const geo = new THREE.SphereGeometry(r, 20, 12, 0, Math.PI * 2, 0, Math.PI / 2);
geo.scale(1, 0.7, 1);
const mesh = new THREE.Mesh(
geo,
new THREE.MeshStandardMaterial({ color: this.fleshColor.clone(), roughness: 0.5 }),
);
const x = ((i + 0.5) / halves - 0.5) * (TRAY_W - 0.5);
mesh.position.set(x, TRAY_Y * 2, 0);
mesh.castShadow = true;
mesh.receiveShadow = true;
this.tray.add(mesh);
this.halves.push(mesh);
}
this.askEl.textContent = askText;
this.hintEl.textContent = 'DIAL sets the heat · SPACE slides the tray in · SPACE again pulls it out';
}
enter(): void {
this.panel.show();
}
exit(): void {
this.panel.hide();
}
update(dt: number): void {
this.app.camera.position.set(0, 2.5, 3.9);
this.app.camera.lookAt(0, 0.35, -0.5);
const s = this.session;
if (!s) return;
const inp = this.app.input;
if (inp.justPressed('Space')) {
if (this.phase === 'ready') {
this.phase = 'roasting';
audio.clunk();
this.hintEl.textContent = 'roasting… SPACE pulls the tray back out. blistered, not black.';
} else if (this.phase === 'roasting') {
this.phase = 'done';
audio.clunk(true);
this.hintEl.textContent = 'out. ENTER takes them to the bench — or SPACE back in if they need more.';
} else if (this.phase === 'done') {
// Changed your mind: back in for another blast.
this.phase = 'roasting';
}
}
s.power = this.power;
const inOven = this.phase === 'roasting';
// Slide the tray in/out and lift the door.
const targetZ = inOven ? -1.4 : 0;
this.tray.position.z += (targetZ - this.tray.position.z) * Math.min(1, dt * 6);
this.door.rotation.x = inOven ? -0.9 : 0;
const glowMat = this.glow.material as THREE.MeshBasicMaterial;
glowMat.opacity = inOven ? 0.16 + 0.1 * Math.sin(s.seconds * 8) : Math.max(0, glowMat.opacity - dt);
if (inOven) {
roastStep(s, dt);
this.paintHalves(s);
}
if (this.phase === 'done') {
this.doneTimer += dt;
if (inp.justPressed('Enter') && this.onDone) {
const cb = this.onDone;
this.onDone = null;
cb(roastResult(s));
return;
}
}
this.updateHud(s);
}
/** Darken and sag each half by the roast level under it. */
private paintHalves(s: RoastSession): void {
for (let i = 0; i < this.halves.length; i++) {
const cu = (i + 0.5) / this.halfCount;
const roast = s.roast.sample(cu, 0.5);
const mesh = this.halves[i];
const mat = mesh.material as THREE.MeshStandardMaterial;
// Flesh reddens then darkens; past collapse it goes to a dull sauce brown.
const t = Math.min(1, roast);
const c = this.fleshColor.clone().multiplyScalar(1 - t * 0.55);
if (roast > BLISTER_AT) c.offsetHSL(-0.02 * (roast - BLISTER_AT) * 4, 0, -0.05);
mat.color.copy(c);
// Slump: past collapse the half flattens into the tray.
const slump = roast >= COLLAPSE_AT ? Math.min(0.6, (roast - COLLAPSE_AT) * 6) : 0;
mesh.scale.y = 1 - slump;
mesh.position.y = TRAY_Y * 2;
}
}
private updateHud(s: RoastSession): void {
const r = roastResult(s);
// Diegetic fuel tell — a gas oven roars, a fan oven hums, and an electric
// element glows and *remembers*. Never a temperature bar; the fuel word and
// the tomatoes are the readout.
const ovenName = this.fuel === 'oven_gas' ? 'GAS OVEN' : this.fuel === 'oven_fan' ? 'FAN OVEN' : 'oven';
const tell = s.src ? ` · ${fuelWord(s.src)}` : '';
this.stateEl.textContent =
this.phase === 'ready'
? `${ovenName} ${this.power} · tray out${tell}`
: `${ovenName} ${this.power} · ${Math.round(s.seconds)}s · ${Math.round(r.mean * 100)}% roasted${tell}`;
// Progress toward the blister window; red once it starts collapsing.
this.progFill.style.width = `${Math.min(100, Math.round((r.mean / 0.82) * 100))}%`;
this.progFill.style.background = r.collapseFrac > 0.05 ? '#c0392b' : r.mean >= BLISTER_AT ? '#6cbf6c' : '#e8a53a';
const word = roastWord(r);
this.wordEl.textContent = `the tomatoes: ${word}${r.blisterFrac > 0.2 ? ` · ${Math.round(r.blisterFrac * 100)}% blistered` : ''}`;
this.wordEl.style.color = r.collapseFrac > 0.2 ? '#e2603a' : r.blisterFrac > 0.4 ? '#8fce8f' : '';
}
/** What the judge reads. Snapshot any time; final at hand-off. */
result(): RoastResult {
return this.session ? roastResult(this.session) : { mean: 0, stdev: 0, blisterFrac: 0, collapseFrac: 0 };
}
dispose(): void {
this.panel.dispose();
}
}