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(); } }