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>
271 lines
9.8 KiB
TypeScript
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();
|
|
}
|
|
}
|