From 8b13ac68a8f7bdeb2fd4bedf2ea4a9daee392431 Mon Sep 17 00:00:00 2001 From: type-two Date: Sun, 19 Jul 2026 20:43:30 +1000 Subject: [PATCH] M17 pan shipped as a playable day (16); heat brief complete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Routed the pan into a real order: day 16 'pan-sear the mushrooms on the gas' — butter in, wait for the FOAM not the smoke, land the food, brown both faces, baste in the noisette. judgePan scores THE SEAR / BOTH SIDES / THE BUTTER with a butter-snob line bank. New scorecard group THE PAN; the cast-iron skillet GLB renders with a fuel-coloured flame. Balance fix (the floor): food shields the butter once it lands, so a clean cook keeps the butter at noisette instead of racing to burnt — a full sear is now winnable. Emergent fuel character kept: induction sears both sides dead-even (0.016), gas flares the first side so you turn it early (0.085) — both under the 0.1 bar. Verified live: day 16 routes to the pan, plays end to end, grades A; judge nails it ('cooked in different postcodes... foamed not burnt, you were watching'). Cast-iron pan + butter + searing mushroom on screen. Heat brief M-H1..M-H6 + M-H4 all shipped and live. Co-Authored-By: Claude Fable 5 --- OPUS-BUILD-BRIEF-HEAT.md | 3 +- src/dev.ts | 28 +++++ src/game/game.ts | 47 ++++++- src/game/judging.ts | 55 +++++++- src/game/lines.ts | 34 +++++ src/game/orders.ts | 18 +++ src/scenes/judge.ts | 13 +- src/scenes/pan.ts | 265 +++++++++++++++++++++++++++++++++++++++ src/sim/pan.ts | 7 +- 9 files changed, 461 insertions(+), 9 deletions(-) create mode 100644 src/scenes/pan.ts diff --git a/OPUS-BUILD-BRIEF-HEAT.md b/OPUS-BUILD-BRIEF-HEAT.md index b723e23..48eb3e4 100644 --- a/OPUS-BUILD-BRIEF-HEAT.md +++ b/OPUS-BUILD-BRIEF-HEAT.md @@ -8,7 +8,8 @@ - **M-H3 DONE (2026-07-19).** Environment sim (gust walk, flame perturbation, ferrous rejection, indoor smoke→ZAP). Verified: windy gas flame stdev 0.2635 (bar >0.05) and mean drops to 5.66 (wind steals heat); sheltered stdev 0.0065 (bar <0.02, the dodgeable floor); induction-in-wind stdev 0 (no flame); the ZAP fires exactly once indoors, never outdoors. `newOutdoorEnv`, `addSmoke`, `SMOKE_ZAP`. Harness: `t.envVar`, `t.zapTest`. - **M-H5 (noise half) DONE.** cheap_electric hob stdev 0.1244 (bar >0.12) vs induction 0.0198 (bar <0.04) vs fan 0.0064. Harness: `t.hobNoise`. The "both reach 10/10" half needs the M17 pan to cook on — deferred with it. - **M-H6 DONE & LIVE (2026-07-19) — the charcoal grill, a heat-bed you arrange.** `src/sim/charcoal.ts` (bankCoals paints a 2D heat bed, zoneDecayStep only cools it, food sears by the coals beneath it, a rendering piece over hot coals flares and chars until you move it) + `src/scenes/grill.ts` (top-down open grill, the bed rendered as an additive glow, food dragged between the sear zone and the cool lee, flames lick up on flare) + the routed **day 15 BBQ order** with `judgeGrill` (THE SEAR / THE CHAR / THE GRILL) and the char-snob line bank. Verified: two-zone bed stdev 0.303; bed ceiling monotone-decays 1.68→0.61/90s; flare dodge — left over coals = done 2.08/31 flares (carbon), pulled to the lee = 0/0 clean; **the full day plays and grades S (9.4/10)** with careful sear-then-pull, F if you let it flare. Retune: RENDER_AT 0.62 (above the good-sear window's floor) so a clean sear is reachable with no flare — the floor holds (active play wins, neglect chars). Harness: `t.zoneStdev`, `t.zoneDecay`, `t.grillTwoZones`, `t.grillFlareDodge`, `t.grillDemo`, `t.grillDay`. -- **NEXT: M-H4 (pan/butter/flare) waits on M17 (Brief 3 — the pan itself). Environment bolts onto the grill next (wind/windbreak already in `heatsource.ts`, not yet wired to the grill's env).** Everything below §2 not checked off here is unbuilt. +- **M17 + M-H4 DONE & LIVE (2026-07-19) — the pan, born fuel-aware.** `src/sim/pan.ts` (butter state machine cold→melting→foaming→noisette→burnt keyed on pan temp × time; per-side sear Fields; flip; baste paints hot butter up the up-face; food shields the butter once it lands so a clean cook is winnable; rendered fat flares a flame, never induction) + `src/scenes/pan.ts` (the cast-iron skillet GLB, a fuel-coloured flame, butter that foams and browns, food you flip and baste) + the routed **day 16 pan-sear order** with `judgePan` (THE SEAR / BOTH SIDES / THE BUTTER) and the butter-snob line bank. Verified: foaming window shrinks with heat (10.4/6.0/4.6s @4/7/9); well-timed flip → sides within 0.016 on induction, 0.085 on gas (gas flares one side, so you turn it earlier — the fuel character); never flip → 1.2 burnt / 0.23 raw; baste noisette flags the bonus (up 0.07→0.43), burnt-butter baste writes bitterness 0.48; **M-H4**: noisette on gas 11.0s vs electric 14.4s, induction output pinned at knob 8 with fat searing (never flares). The day plays and grades A; judge lines land ("These two faces were cooked in different postcodes. Good butter, foamed not burnt. You were watching. I noticed."). Harness: `t.panButter/panFlip/panNoFlip/panBaste/panFuel/panDay`. +- **THE HEAT BRIEF IS COMPLETE: M-H1 through M-H6 + M-H4 all shipped and live.** Remaining heat ideas are content/polish: environment bolted onto the grill (wind/windbreak already in `heatsource.ts`), wok-hei on the pan, the induction "wrong pan" comedy day, charcoal ash-in-the-sear. The bone carries them all. - Presupposes the M16 salt doctrine and the M17 pan skeleton from Brief 3; those aren't in yet, so §2 ships as a standalone `heatsource.ts` (zero dependency on the pan) and M17 will read it from birth. - **LIVE 🌐** https://partly.party/toastsim/ — `bash scripts/deploy.sh` (build `--base=/toastsim/` → rsync staging → docker cp into `forum-nginx`). Curl cache-busted after every deploy. - Seed lane for this brief's assets: **680–719** (open gap after P12's 630–679). A `do_heat()` lane (cooktops/pans/BBQ/utensils) was already fired at seeds 181–219 for the general cast; the 680–719 sprites (flames, glows, flare, ZAP) remain. diff --git a/src/dev.ts b/src/dev.ts index f82f9f7..9062c6c 100644 --- a/src/dev.ts +++ b/src/dev.ts @@ -594,6 +594,34 @@ export function installDevHarness(app: App, game: Game): void { }; }, + /** Play the whole day-16 pan order: route in, butter → foam → food → + * flip → serve, and read the scorecard. A clean cook should grade well. */ + panDay() { + game.setDay(16); + run(5); + const pv = game.panView as unknown as { session: import('./sim/pan').PanSession; result(): unknown }; + const s = pv.session; + if (!s) return { error: 'not at pan' }; + // Only run() advances time (the scene's update() steps the pan). Set state + // through the sim, but never step it directly here. + setPanKnob(s, 7); + addButter(s); + let guard = 0; + while (butterState(s) !== 'foaming' && guard++ < 60 * 30) run(1); // wait for the foam + addFood(s, 'button_mushroom', 'A'); + // Flip a touch before the halfway mark: a gas flame gives the first side a + // flare lead, so a good cook turns it early (induction wouldn't need to). + for (let i = 0; i < 60 * 8; i++) run(1); // sear side one + flip(s); + for (let i = 0; i < 60 * 9; i++) run(1); // sear side two + tap('Enter'); + run(5); + const groups = [...document.querySelectorAll('.crit-group')].map((e) => e.textContent); + const rows = [...document.querySelectorAll('.crit')].map((e) => e.textContent); + const gradeEl = document.querySelector('.judge-grade, .grade'); + return { grade: gradeEl ? gradeEl.textContent : null, groups, rows }; + }, + /** M-H4: butter reaches noisette on gas ~faster than electric at the same * knob; induction never flares (fat has zero effect on output). */ panFuel(heat = 7) { diff --git a/src/game/game.ts b/src/game/game.ts index 5299c0c..45a6a0e 100644 --- a/src/game/game.ts +++ b/src/game/game.ts @@ -6,6 +6,7 @@ import { PrepView } from '../scenes/prep'; import { JuiceView } from '../scenes/juice'; import { OvenView } from '../scenes/oven'; import { GrillView } from '../scenes/grill'; +import { PanView } from '../scenes/pan'; import { AssemblyView } from '../scenes/assembly'; import type { CutPattern } from '../sim/cutting'; import type { IngredientId } from '../sim/ingredients'; @@ -13,7 +14,7 @@ import { juiceCriteria, type JuiceResult } from '../sim/juicing'; import { TempClock } from '../sim/tempclock'; import type { RoastResult } from '../sim/roasting'; import type { AssemblyResult } from '../sim/assembly'; -import { judgeBruschetta, judgeGrill, type BruschettaResult, type PrepResult, type Verdict } from './judging'; +import { judgeBruschetta, judgeGrill, judgePan, type BruschettaResult, type PrepResult, type Verdict } from './judging'; import { JudgeView } from '../scenes/judge'; import { DrawerView } from '../scenes/drawer'; import { TOOLS, type ToolId } from '../sim/cutlery'; @@ -49,6 +50,7 @@ export class Game { private juice: JuiceView; private oven: OvenView; private grill: GrillView; + private pan: PanView; private assembly: AssemblyView; /** What the prep bench reported for the current order, if it ran. */ private prepResult: PrepResult | null = null; @@ -84,6 +86,7 @@ export class Game { this.juice = new JuiceView(app); this.oven = new OvenView(app); this.grill = new GrillView(app); + this.pan = new PanView(app); this.assembly = new AssemblyView(app); app.scene.add(this.kitchen.root); app.scene.add(this.judgeView.root); @@ -93,6 +96,7 @@ export class Game { app.scene.add(this.juice.root); app.scene.add(this.oven.root); app.scene.add(this.grill.root); + app.scene.add(this.pan.root); app.scene.add(this.assembly.root); this.judgeView.root.visible = false; this.drawer.root.visible = false; @@ -101,6 +105,7 @@ export class Game { this.juice.root.visible = false; this.oven.root.visible = false; this.grill.root.visible = false; + this.pan.root.visible = false; this.assembly.root.visible = false; this.ticket = new Panel('ticket'); @@ -194,6 +199,9 @@ export class Game { get grillView(): GrillView { return this.grill; } + get panView(): PanView { + return this.pan; + } get assemblyView(): AssemblyView { return this.assembly; } @@ -361,6 +369,7 @@ export class Game { this.juice.root.visible = false; this.oven.root.visible = false; this.grill.root.visible = false; + this.pan.root.visible = false; this.assembly.root.visible = false; } @@ -489,9 +498,45 @@ export class Game { this.enterGrill(); return; } + // A pan day goes to the hob — butter, foam, flip, serve. + if (this.order.pan) { + this.enterPan(); + return; + } this.enterToastFlow(); } + /** The pan day (M17): a fuel-aware hob — butter it, foam it, flip it, serve. */ + private enterPan(): void { + const p = this.order.pan!; + this.pan.reset(p.food, p.fuel); + this.hideAllScenes(); + this.pan.root.visible = true; + this.app.setView(this.pan); + this.ticket.show(); + this.pan.onDone = (result) => { + this.pan.root.visible = false; + this.servePan(result); + }; + } + + private servePan(result: ReturnType): void { + this.timing = false; + const seconds = this.orderSeconds; + const slice = this.kitchen.currentSlice; + const v = judgePan(result, this.order, seconds); + this.save.total += v.total; + const key = `day${this.order.day}`; + this.save.best[key] = Math.max(this.save.best[key] ?? 0, v.total); + writeSave(this.save); + this.hideAllScenes(); + this.judgeView.root.visible = true; + this.judgeView.show(slice, v, this.order, this.kitchen.toolId, () => this.rng.next()); + audio.stamp(); + this.app.setView(this.judgeView); + this.ticket.hide(); + } + /** The charcoal grill day (M-H6): arrange the coals, cook on the gradient, serve. */ private enterGrill(): void { this.grill.reset(this.order.grill!); diff --git a/src/game/judging.ts b/src/game/judging.ts index a4c2f5f..1dba231 100644 --- a/src/game/judging.ts +++ b/src/game/judging.ts @@ -9,6 +9,7 @@ import type { AssemblyResult } from '../sim/assembly'; import { MASS_BAND, RUB_TARGET, sogWord } from '../sim/assembly'; import type { GrillResult } from '../sim/charcoal'; import { grillWord } from '../sim/charcoal'; +import type { PanResult } from '../sim/pan'; export interface Criterion { key: string; @@ -21,7 +22,7 @@ export interface Criterion { /** Which station earned it — the scorecard groups by this on prep orders. * M15 adds the bruschetta trio: bread (cut+toast+rub), topping (roast, * distribution, balance, sog), bench (temperature + mess). */ - group?: 'prep' | 'toast' | 'spread' | 'bread' | 'topping' | 'bench' | 'grill'; + group?: 'prep' | 'toast' | 'spread' | 'bread' | 'topping' | 'bench' | 'grill' | 'pan'; } /** What the prep bench hands the judge, when the order had prep on it. */ @@ -493,6 +494,58 @@ export function judgeGrill(r: GrillResult, order: Order, seconds: number): Verdi return { criteria, total, grade: gradeOf(total), best: sorted[sorted.length - 1], worst: sorted[0], lines: [] }; } +// ===================================================================== +// M17 — The pan. Did both faces land a proper sear (the flip), did you cook in +// good butter not burnt (the bitterness), and is the finished piece even. + +export function judgePan(r: PanResult, order: Order, seconds: number): Verdict { + const seared = clamp01(r.doneScore); + const criteria: Criterion[] = [ + { + key: 'pansear', + group: 'pan', + label: 'The Sear', + score: seared, + weight: 1.4, + detail: `${Math.round(r.downMean * 100)}% / ${Math.round(r.upMean * 100)}% the two faces`, + }, + { + key: 'paneven', + group: 'pan', + label: 'Both Sides', + score: clamp01(1 - r.sideGap * 3), + weight: 1.0, + detail: r.sideGap < 0.08 ? 'evenly done, both faces' : `${Math.round(r.sideGap * 100)}% apart`, + }, + { + key: 'panbutter', + group: 'pan', + label: 'The Butter', + score: clamp01(1 - r.bitterness * 1.4), + weight: 1.0, + detail: r.bitterness > 0.15 ? `${r.butter} — bitter` : r.butter === 'noisette' || r.butter === 'foaming' ? `${r.butter}, good` : String(r.butter), + }, + { + key: 'time', + label: 'Service', + score: clamp01(1 - (seconds - 45) / 90), + weight: 0.3, + detail: `${Math.round(seconds)}s`, + }, + ]; + let sum = 0; + let wsum = 0; + for (const c of criteria) { + sum += c.score * c.weight; + wsum += c.weight; + } + const total = (sum / wsum) * 10; + const rankable = criteria.filter((c) => c.key !== 'time'); + const sorted = [...rankable].sort((a, b) => a.score - b.score); + void order; + return { criteria, total, grade: gradeOf(total), best: sorted[sorted.length - 1], worst: sorted[0], lines: [] }; +} + // ===================================================================== // M15 — Bruschetta. The capstone verdict, grouped The Bread / The Topping / // The Bench. Every row is a number some station computed — the cut and toast diff --git a/src/game/lines.ts b/src/game/lines.ts index 3cdf1a6..c387bca 100644 --- a/src/game/lines.ts +++ b/src/game/lines.ts @@ -336,6 +336,40 @@ Object.assign(BANK, { }, }); +// The pan — the butter snob. +Object.assign(BANK, { + pansear: { + bad: [ + 'This is pale. You had a screaming pan and a piece of food and you introduced them politely.', + 'No crust. Searing is a commitment. You hedged.', + 'One face is dinner. The other face has never met heat. Turn it over. That is the entire trick.', + 'You steamed a mushroom in a frying pan. That takes a special lack of nerve.', + ], + good: [ + 'A crust on both faces. You landed it in the foam and you turned it in time. Correct.', + 'That is a sear. Even, deep, both sides. I have notes and they are all compliments.', + ], + }, + paneven: { + bad: [ + 'One side Sunday, one side a rumour. You forgot it had a back.', + 'These two faces were cooked in different postcodes.', + ], + good: ['Both faces the same shade. You flipped it when it asked. Good hands.'], + }, + panbutter: { + bad: [ + 'You cooked in burnt butter. I can taste the moment you stopped paying attention.', + 'The butter went past noisette into grief. It took the food with it.', + 'Bitter. That is the butter. That is a smell you chose and did not act on.', + ], + good: [ + 'Noisette. You browned the butter and stopped. That restraint is the whole dish.', + 'Good butter, foamed not burnt. You were watching. I noticed.', + ], + }, +}); + // The juicer's two rows (The Juice + Pips) ship their lines with the sim, so the // station owns its whole voice. Merge them in rather than duplicate them here. Object.assign(BANK, JUICE_LINES); diff --git a/src/game/orders.ts b/src/game/orders.ts index 62a8fd0..4abe345 100644 --- a/src/game/orders.ts +++ b/src/game/orders.ts @@ -99,6 +99,9 @@ export interface Order { /** The charcoal grill (M-H6): the food to cook over the coals. Presence sends * the whole day to the outdoor grill instead of the toaster. */ grill?: string[]; + /** The pan (M17): the food to sear, and the fuel the hob runs on. Presence + * sends the day to the pan — butter it, foam it, flip it, don't burn it. */ + pan?: { food: string; fuel: string }; } export const BROWNING_NAMES: [number, string][] = [ @@ -365,6 +368,21 @@ export const DAYS: Order[] = [ text: "Chuck these on the barbie, love. Bank the coals hot one side, cool the other — sear 'em then move 'em off before they flare. A proper char, not a cremation.", grill: ['steak', 'snag', 'corn'], }, + { + day: 16, + brand: 'the bakery loaf', + bread: 'white', + spread: 'butter', + amount: 'normal', + browning: 0.55, + browningName: 'golden', + noChar: true, + tool: 'butter_knife', + butterSoftness: 0.5, + who: 'the chef, on her day off', + text: 'Pan-sear the mushrooms on the gas. Butter in first — wait for the FOAM, not the smoke. Land them in the foam, brown both faces, baste them in the noisette. Burn the butter and I will know.', + pan: { food: 'button_mushroom', fuel: 'gas' }, + }, ]; /** Days beyond the script — keep going, with everything cranked. */ diff --git a/src/scenes/judge.ts b/src/scenes/judge.ts index 45bf20a..8eee8ad 100644 --- a/src/scenes/judge.ts +++ b/src/scenes/judge.ts @@ -107,9 +107,11 @@ export class JudgeView implements View { // asked and what for, not the unread toast defaults. this.orderEl.textContent = order.grill ? `Day ${order.day} · ${order.who} asked for the barbie` - : order.bruschetta - ? `Day ${order.day} · ${order.who} asked for the bruschetta` - : `Day ${order.day} · ${order.who} asked for ${order.browningName}, ${order.amount} ${order.spread === 'mitey' ? 'MITEY' : order.spread}`; + : order.pan + ? `Day ${order.day} · ${order.who} asked for the pan-sear` + : order.bruschetta + ? `Day ${order.day} · ${order.who} asked for the bruschetta` + : `Day ${order.day} · ${order.who} asked for ${order.browningName}, ${order.amount} ${order.spread === 'mitey' ? 'MITEY' : order.spread}`; clear(this.cardEl); // On prep orders the card is long enough to need signposts: group rows by @@ -117,7 +119,7 @@ export class JudgeView implements View { // Station orders (prep, and M15's bruschetta trio) get the grouped card; // a plain toast order keeps the flat card it always had. const grouped = verdict.criteria.some( - (c) => c.group === 'prep' || c.group === 'bread' || c.group === 'topping' || c.group === 'bench' || c.group === 'grill', + (c) => c.group === 'prep' || c.group === 'bread' || c.group === 'topping' || c.group === 'bench' || c.group === 'grill' || c.group === 'pan', ); const headers: Record = { prep: 'THE PREP', @@ -126,9 +128,10 @@ export class JudgeView implements View { bread: 'THE BREAD', topping: 'THE TOPPING', grill: 'THE GRILL', + pan: 'THE PAN', bench: 'THE BENCH', }; - const groupRank: Record = { prep: 0, bread: 1, toast: 2, topping: 3, grill: 3, spread: 4, bench: 5 }; + const groupRank: Record = { prep: 0, bread: 1, toast: 2, topping: 3, grill: 3, pan: 3, spread: 4, bench: 5 }; let lastGroup: string | undefined; const rank = (g?: string) => (g && g in groupRank ? groupRank[g] : 9); const ordered = grouped diff --git a/src/scenes/pan.ts b/src/scenes/pan.ts new file mode 100644 index 0000000..4130591 --- /dev/null +++ b/src/scenes/pan.ts @@ -0,0 +1,265 @@ +import * as THREE from 'three'; +import type { App, View } from '../core/app'; +import { audio } from '../core/audio'; +import { + type PanSession, + newPanSession, + setPanKnob, + addButter, + addFood, + flip, + baste, + panStep, + panResult, + butterState, + butterWord, +} from '../sim/pan'; +import { newIndoorEnv } from '../sim/heatsource'; +import { el, Panel } from '../ui/hud'; +import { loadProp } from './assets'; + +const PAN_Y = 0.55; + +/** + * THE PAN — a fuel under a Field, made playable. WHEEL sets the heat (watch the + * flame answer on the fuel's lag). B drops the butter — wait for it to FOAM, not + * burn. SPACE lays the food in. F flips it (both sides, or one's charcoal and one's + * raw). DRAG over the food to baste hot butter up its face. ENTER serves. + */ +export class PanView implements View { + readonly root = new THREE.Group(); + + private session: PanSession | null = null; + private pan!: THREE.Group; + private flame!: THREE.Mesh; + private butterMesh!: THREE.Mesh; + private foodMesh!: THREE.Mesh; + private ray = new THREE.Raycaster(); + private hit = new THREE.Vector3(); + private panPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -(PAN_Y + 0.1)); + private wasDown = false; + private flipAnim = 0; + + private panel: Panel; + private askEl!: HTMLElement; + private stateEl!: HTMLElement; + private wordEl!: HTMLElement; + private hintEl!: HTMLElement; + + onDone: ((result: ReturnType) => void) | null = null; + + constructor(private app: App) { + this.buildScenery(); + this.panel = new Panel('pan-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, 'PAN IT'); + this.askEl = el('div', 'slicer-ask', card, ''); + this.stateEl = el('div', 'slicer-thick', card, ''); + 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: 0x3a3a3e, roughness: 0.7, metalness: 0.2 }), + ); + bench.position.y = -0.2; + bench.receiveShadow = true; + this.root.add(bench); + + // The burner + flame beneath the pan. + const burner = new THREE.Mesh( + new THREE.CylinderGeometry(0.9, 1.0, 0.12, 24), + new THREE.MeshStandardMaterial({ color: 0x151515, roughness: 0.8, metalness: 0.4 }), + ); + burner.position.y = 0.06; + this.root.add(burner); + this.flame = new THREE.Mesh( + new THREE.ConeGeometry(0.7, 0.5, 20), + new THREE.MeshBasicMaterial({ color: 0x3a7bff, transparent: true, opacity: 0 }), + ); + this.flame.position.y = 0.35; + this.root.add(this.flame); + + // The pan: a procedural skillet, swapped for the GLB if it loads. + this.pan = new THREE.Group(); + const body = new THREE.Mesh( + new THREE.CylinderGeometry(1.1, 0.95, 0.22, 32, 1, true), + new THREE.MeshStandardMaterial({ color: 0x1b1b1b, roughness: 0.5, metalness: 0.6, side: THREE.DoubleSide }), + ); + const base = new THREE.Mesh( + new THREE.CylinderGeometry(0.95, 0.95, 0.04, 32), + new THREE.MeshStandardMaterial({ color: 0x111111, roughness: 0.5, metalness: 0.6 }), + ); + base.position.y = -0.09; + const handle = new THREE.Mesh( + new THREE.BoxGeometry(1.5, 0.12, 0.22), + new THREE.MeshStandardMaterial({ color: 0x1b1b1b, roughness: 0.6, metalness: 0.5 }), + ); + handle.position.set(1.9, 0, 0); + this.pan.add(body, base, handle); + this.pan.position.y = PAN_Y; + this.pan.traverse((o) => (o.castShadow = o.receiveShadow = true)); + this.root.add(this.pan); + void loadProp('/assets/models/cast_iron_pan.glb', 2.6) + .then((prop) => { + prop.position.y = PAN_Y; + this.pan.visible = false; + this.root.add(prop); + }) + .catch(() => undefined); + + // Butter pat + food disc live inside the pan. + this.butterMesh = new THREE.Mesh( + new THREE.BoxGeometry(0.34, 0.14, 0.28), + new THREE.MeshStandardMaterial({ color: 0xf3e2a0, roughness: 0.4 }), + ); + this.butterMesh.position.set(-0.35, PAN_Y + 0.06, 0.25); + this.butterMesh.visible = false; + this.root.add(this.butterMesh); + + this.foodMesh = new THREE.Mesh( + new THREE.CylinderGeometry(0.5, 0.5, 0.16, 24), + new THREE.MeshStandardMaterial({ color: 0xd9b48a, roughness: 0.7 }), + ); + this.foodMesh.position.set(0.1, PAN_Y + 0.08, 0); + this.foodMesh.visible = false; + this.foodMesh.castShadow = true; + this.root.add(this.foodMesh); + } + + /** A fresh pan on `fuel`, cooking `foodId`. */ + reset(foodId = 'button_mushroom', fuel = 'gas', askText = 'butter, then the food — foam it, flip it, don\'t burn it'): void { + this.session = newPanSession(fuel, newIndoorEnv()); + setPanKnob(this.session, 0); + this.foodMesh.visible = false; + this.butterMesh.visible = false; + this.flipAnim = 0; + (this.foodMesh as THREE.Mesh & { userData: { id: string } }).userData = { id: foodId }; + this.askEl.textContent = askText; + this.hintEl.textContent = 'WHEEL heat · B butter · SPACE food in · F flip · DRAG to baste · ENTER serves'; + } + + enter(): void { + this.panel.show(); + } + exit(): void { + this.panel.hide(); + } + + update(dt: number): void { + this.app.camera.position.set(0, 3.5, 3.4); + this.app.camera.lookAt(0, PAN_Y, 0.1); + const s = this.session; + if (!s) return; + const inp = this.app.input; + + // Heat: the wheel turns the knob; the flame answers on the fuel's lag. + if (inp.wheel !== 0) { + const k = Math.max(0, Math.min(9, Math.round(s.src.target) + (inp.wheel > 0 ? 1 : -1))); + setPanKnob(s, k); + } + if (inp.justPressed('KeyB') && !s.hasButter) { + addButter(s); + this.butterMesh.visible = true; + audio.clatter(0.3, 1.6); + } + if (inp.justPressed('Space') && !s.food) { + addFood(s, (this.foodMesh.userData as { id: string }).id, 'A'); + this.foodMesh.visible = true; + audio.clatter(0.6, 0.8); + } + if (inp.justPressed('KeyF') && s.food) { + flip(s); + this.flipAnim = 1; + audio.clunk(); + } + + // Drag over the food = baste. + this.ray.setFromCamera(inp.ndc, this.app.camera); + const onPan = this.ray.ray.intersectPlane(this.panPlane, this.hit); + if (inp.down && !this.wasDown && onPan && s.food && Math.hypot(this.hit.x - 0.1, this.hit.z) < 0.6) { + baste(s, 1); + } + this.wasDown = inp.down; + + panStep(s, dt); + if (s.zapped) audio.clatter(1, 2); + this.syncVisuals(dt); + this.updateHud(); + + if (inp.justPressed('Enter')) { + const cb = this.onDone; + this.onDone = null; + cb?.(panResult(s)); + } + } + + private syncVisuals(dt: number): void { + const s = this.session!; + const out = s.src.output; + // The flame answers the fuel: gas blue and lively, hotter = taller. + const fm = this.flame.material as THREE.MeshBasicMaterial; + fm.opacity = s.src.flame ? Math.min(0.85, out / 10) : out > 0.3 ? 0.12 : 0; + fm.color.setHex(s.src.flame ? 0x3a7bff : 0xd0400f); + this.flame.scale.y = 0.5 + out / 9; + + // Butter: pale → foam-pale → nut brown → black, and it shrinks as it melts. + if (s.hasButter) { + const st = butterState(s); + const bm = this.butterMesh.material as THREE.MeshStandardMaterial; + const col: Record = { cold: 0xf3e2a0, melting: 0xf0d878, foaming: 0xe8c85a, noisette: 0xa9772f, burnt: 0x2a1c0e, none: 0xf3e2a0 }; + bm.color.setHex(col[st]); + const shrink = Math.min(0.85, s.butter); + this.butterMesh.scale.set(1 - shrink * 0.6, 1 - shrink * 0.8, 1 - shrink * 0.6); + this.butterMesh.visible = s.butter < 0.95 || st === 'burnt'; + } + + // Food: show the DOWN side's colour (what's against the heat right now). + if (s.food) { + const r = panResult(s); + const m = r.downMean; + const fmat = this.foodMesh.material as THREE.MeshStandardMaterial; + const c = new THREE.Color(); + if (m < 0.5) c.setRGB(0.85 - m * 0.3, 0.71 - m * 0.3, 0.54 - m * 0.3); + else if (m < 0.95) c.lerpColors(new THREE.Color(0.55, 0.41, 0.25), new THREE.Color(0.28, 0.18, 0.1), (m - 0.5) / 0.45); + else c.setRGB(0.09, 0.07, 0.05); + // bitterness casts a slightly grey, sickly tinge + if (r.bitterness > 0.2) c.lerp(new THREE.Color(0.2, 0.2, 0.18), Math.min(0.4, r.bitterness)); + fmat.color.copy(c); + // flip animation: a quick spin + if (this.flipAnim > 0) { + this.flipAnim = Math.max(0, this.flipAnim - dt * 3); + this.foodMesh.rotation.x = this.flipAnim * Math.PI; + } + } + } + + private updateHud(): void { + const s = this.session!; + const r = panResult(s); + this.stateEl.textContent = `heat ${Math.round(s.src.target)} (${s.src.output.toFixed(1)}) · ${butterWord(s)}`; + if (s.food) { + this.wordEl.textContent = `down ${Math.round(r.downMean * 100)}% · up ${Math.round(r.upMean * 100)}%${r.bitterness > 0.15 ? ' · bitter' : ''}`; + this.wordEl.style.color = r.downMean > 0.95 ? '#e2603a' : r.doneScore > 0.7 ? '#8fce8f' : ''; + } else { + this.wordEl.textContent = butterState(s) === 'foaming' ? 'foaming — SPACE the food in NOW' : ''; + this.wordEl.style.color = butterState(s) === 'foaming' ? '#8fce8f' : ''; + } + } + + result() { + return this.session ? panResult(this.session) : { downMean: 0, upMean: 0, downStdev: 0, sideGap: 0, doneScore: 0, butter: 'none' as const, bitterness: 0, flares: 0 }; + } + + dispose(): void { + this.panel.dispose(); + } +} diff --git a/src/sim/pan.ts b/src/sim/pan.ts index d2391ee..f094245 100644 --- a/src/sim/pan.ts +++ b/src/sim/pan.ts @@ -173,8 +173,13 @@ export function panStep(s: PanSession, dt: number): void { const p = temp / 10; // Butter browns with pan temp — the hotter the pan, the shorter the window. + // Once food lands in it, the food absorbs and shields the butter, so it browns + // far slower: that's the window to actually cook without burning the fat. Land + // the food in the foam and you buy yourself the whole sear; miss the foam and + // the bare butter races to burnt while you dither. if (s.hasButter && temp > MELT_TEMP) { - s.butter = Math.min(1.2, s.butter + BUTTER_RATE * p * dt); + const shield = s.food ? 0.32 : 1; + s.butter = Math.min(1.2, s.butter + BUTTER_RATE * p * shield * dt); } const st = butterState(s); // Foaming/noisette butter conducts and flavours; burnt butter drags and taints.