Compare commits
2 Commits
94a5f2f293
...
8b13ac68a8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b13ac68a8 | ||
|
|
c528ba49ab |
@ -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.
|
||||
|
||||
126
src/dev.ts
126
src/dev.ts
@ -7,6 +7,7 @@ import { type Fuel, newHeatSource, setKnob, heatStep, envStep, newOutdoorEnv, ad
|
||||
import { newRoastSession, roastStep, roastResult } from './sim/roasting';
|
||||
import { Rng } from './core/rng';
|
||||
import { newGrillSession, bankCoals, placeFood, grillStep, grillResult, bedCeiling } from './sim/charcoal';
|
||||
import { newPanSession, setPanKnob, addButter, addFood, flip, baste, panStep, panResult, butterState } from './sim/pan';
|
||||
|
||||
/**
|
||||
* Dev harness. The game is driven by mouse gestures over a 3D scene, which makes
|
||||
@ -515,6 +516,131 @@ export function installDevHarness(app: App, game: Game): void {
|
||||
return { fuel, wind, sheltered, mean: +mean.toFixed(3), stdev: +stdev.toFixed(4) };
|
||||
},
|
||||
|
||||
// ---- M17 + M-H4: the pan. Butter state timeline, the flip, the baste,
|
||||
// and the fuel differences (gas foams faster, induction never flares). ----
|
||||
|
||||
/** Butter state timeline at a given heat: seconds to foaming / noisette /
|
||||
* burnt, and the foaming-window duration (shorter at higher heat). */
|
||||
panButter(fuel = 'gas', heat = 7, dt = 1 / 60) {
|
||||
const s = newPanSession(fuel);
|
||||
setPanKnob(s, heat);
|
||||
addButter(s);
|
||||
const mark: Record<string, number> = {};
|
||||
let prev = butterState(s);
|
||||
for (let i = 0; i < 60 * 60; i++) {
|
||||
panStep(s, dt);
|
||||
const st = butterState(s);
|
||||
if (st !== prev) {
|
||||
if (!(st in mark)) mark[st] = +s.seconds.toFixed(2);
|
||||
prev = st;
|
||||
}
|
||||
if (st === 'burnt') break;
|
||||
}
|
||||
const foamWindow = mark.noisette && mark.foaming ? +(mark.noisette - mark.foaming).toFixed(2) : null;
|
||||
return { fuel, heat, toFoaming: mark.foaming ?? null, toNoisette: mark.noisette ?? null, toBurnt: mark.burnt ?? null, foamWindow };
|
||||
},
|
||||
|
||||
/** M17: cook a mushroom, flip at `flipAt`s. A well-timed flip → both sides
|
||||
* within 0.1; never flipping → one side burnt, one raw. */
|
||||
panFlip(fuel = 'gas', flipAt = 9, total = 18, heat = 7) {
|
||||
const s = newPanSession(fuel);
|
||||
setPanKnob(s, heat);
|
||||
addButter(s);
|
||||
// let the butter come up to foaming before the mushroom goes in
|
||||
for (let i = 0; i < 60 * 4; i++) panStep(s, 1 / 60);
|
||||
addFood(s, 'button_mushroom', 'A');
|
||||
for (let sec = 0; sec < total; sec++) {
|
||||
if (sec === flipAt) flip(s);
|
||||
for (let i = 0; i < 60; i++) panStep(s, 1 / 60);
|
||||
}
|
||||
const r = panResult(s);
|
||||
const q = (n: number) => +n.toFixed(3);
|
||||
return { sideGap: q(r.sideGap), doneScore: q(r.doneScore), butter: r.butter, bitterness: q(r.bitterness) };
|
||||
},
|
||||
|
||||
/** M17: never flip — one side should burn (>0.9), the other stay raw. */
|
||||
panNoFlip(fuel = 'gas', total = 20, heat = 7) {
|
||||
const s = newPanSession(fuel);
|
||||
setPanKnob(s, heat);
|
||||
addButter(s);
|
||||
for (let i = 0; i < 60 * 4; i++) panStep(s, 1 / 60);
|
||||
addFood(s, 'button_mushroom', 'A');
|
||||
for (let i = 0; i < 60 * total; i++) panStep(s, 1 / 60);
|
||||
const r = panResult(s);
|
||||
return { downMean: +r.downMean.toFixed(3), upMean: +r.upMean.toFixed(3) };
|
||||
},
|
||||
|
||||
/** M17: baste the up side during noisette — raises its mean, flags the bonus.
|
||||
* Baste with burnt butter instead → bitterness written in. */
|
||||
panBaste(fuel = 'gas', heat = 7) {
|
||||
// noisette baste
|
||||
const s = newPanSession(fuel);
|
||||
setPanKnob(s, heat); addButter(s);
|
||||
for (let i = 0; i < 60 * 4; i++) panStep(s, 1 / 60);
|
||||
addFood(s, 'button_mushroom', 'A');
|
||||
for (let i = 0; i < 60 * 8; i++) panStep(s, 1 / 60); // butter into noisette
|
||||
const upBefore = panResult(s).upMean;
|
||||
const b1 = baste(s, 4);
|
||||
const upAfter = panResult(s).upMean;
|
||||
// burnt baste (separate pan, run butter to burnt)
|
||||
const s2 = newPanSession(fuel);
|
||||
setPanKnob(s2, 9); addButter(s2);
|
||||
for (let i = 0; i < 60 * 20; i++) panStep(s2, 1 / 60); // burn the butter
|
||||
addFood(s2, 'button_mushroom', 'A');
|
||||
const b2 = baste(s2, 4);
|
||||
return {
|
||||
noisette: { state: b1.state, bonus: b1.bonus, upBefore: +upBefore.toFixed(3), upAfter: +upAfter.toFixed(3) },
|
||||
burnt: { state: b2.state, bitterness: +panResult(s2).bitterness.toFixed(3) },
|
||||
};
|
||||
},
|
||||
|
||||
/** 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) {
|
||||
const noisetteAt = (fuel: string) => {
|
||||
const s = newPanSession(fuel);
|
||||
setPanKnob(s, heat); addButter(s);
|
||||
for (let i = 0; i < 60 * 60; i++) { panStep(s, 1 / 60); if (butterState(s) === 'noisette') return +s.seconds.toFixed(2); }
|
||||
return null;
|
||||
};
|
||||
// induction flare check: cook a fatty piece, watch output — fat must not spike it
|
||||
const ind = newPanSession('induction');
|
||||
setPanKnob(ind, 8); ind.env.vesselFerrous = true; addButter(ind);
|
||||
for (let i = 0; i < 60 * 3; i++) panStep(ind, 1 / 60);
|
||||
addFood(ind, 'button_mushroom', 'A');
|
||||
let maxOut = 0;
|
||||
for (let i = 0; i < 60 * 15; i++) { panStep(ind, 1 / 60); maxOut = Math.max(maxOut, ind.src.output); }
|
||||
return { noisetteGas: noisetteAt('gas'), noisetteElectric: noisetteAt('electric'), inductionMaxOut: +maxOut.toFixed(2), inductionKnob: 8 };
|
||||
},
|
||||
|
||||
// ---- M-H6: charcoal is a Field, not a dial. Pure-sim probes for the bed
|
||||
// gradient, the monotonic decay, and one-bed-two-outcomes. ----
|
||||
|
||||
|
||||
@ -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<PanView['result']>): 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!);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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. */
|
||||
|
||||
@ -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<string, string> = {
|
||||
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<string, number> = { prep: 0, bread: 1, toast: 2, topping: 3, grill: 3, spread: 4, bench: 5 };
|
||||
const groupRank: Record<string, number> = { 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
|
||||
|
||||
265
src/scenes/pan.ts
Normal file
265
src/scenes/pan.ts
Normal file
@ -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<typeof panResult>) => 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<string, number> = { 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();
|
||||
}
|
||||
}
|
||||
277
src/sim/pan.ts
Normal file
277
src/sim/pan.ts
Normal file
@ -0,0 +1,277 @@
|
||||
import { Field } from '../core/field';
|
||||
import { Rng } from '../core/rng';
|
||||
import { INGREDIENTS, type IngredientId } from './ingredients';
|
||||
import {
|
||||
type HeatSource,
|
||||
type Environment,
|
||||
newHeatSource,
|
||||
newIndoorEnv,
|
||||
setKnob,
|
||||
heatStep,
|
||||
applyToHeatMap,
|
||||
addSmoke,
|
||||
} from './heatsource';
|
||||
|
||||
/**
|
||||
* THE PAN — the toaster generalized to a continuous, modulated heat source with
|
||||
* two things the toaster never had: BUTTER, and a second side.
|
||||
*
|
||||
* The pan reads a HeatSource: the knob commands a target and the pan temp
|
||||
* (`src.output`) chases it on the fuel's lag. Gas is instant, an electric coil
|
||||
* drags AND remembers, induction is exact. You don't cook the knob — you cook
|
||||
* the lag, and then you cook the BUTTER:
|
||||
*
|
||||
* cold → melting → FOAMING → noisette → burnt
|
||||
*
|
||||
* Foaming is the moment to land your food; noisette is the nutty peak (a flavour
|
||||
* bonus); burnt is bitterness + smoke. The food has two sides, each its own sear
|
||||
* Field — the side on the pan browns fast, the up side barely, until you FLIP it
|
||||
* or BASTE hot butter over it. Baste with burnt butter and you paint bitterness.
|
||||
*
|
||||
* On a flame fuel, rendered fat can flare (spike the output); induction never
|
||||
* flares. Pure — Field + Rng — so the harness cooks headless and reads the
|
||||
* judge's exact numbers.
|
||||
*/
|
||||
|
||||
export type ButterState = 'none' | 'cold' | 'melting' | 'foaming' | 'noisette' | 'burnt';
|
||||
|
||||
const N = 32;
|
||||
/** Butter browning per second at full pan temp. */
|
||||
const BUTTER_RATE = 0.06;
|
||||
/** Pan temp (0..10) below which butter just sits cold. */
|
||||
const MELT_TEMP = 1.5;
|
||||
/** Down-side sear per second per unit of (heat × dryness × butter). */
|
||||
const SEAR_RATE = 0.085;
|
||||
/** The up side gets this fraction of the pan's heat (radiant off the pan walls). */
|
||||
const UP_FRACTION = 0.15;
|
||||
|
||||
export interface PanFood {
|
||||
id: string;
|
||||
mask: Field;
|
||||
searA: Field;
|
||||
searB: Field;
|
||||
dryA: number;
|
||||
dryB: number;
|
||||
down: 'A' | 'B';
|
||||
/** From cooking IN or basting WITH burnt butter — the judge tastes it. */
|
||||
bitterness: number;
|
||||
/** Rendered fat, feeds the flame's flare. */
|
||||
fat: number;
|
||||
moisture: number;
|
||||
/** The fuel-shaped heat map under the food: gas hot-spots, induction flat. */
|
||||
panHeat: Float32Array;
|
||||
}
|
||||
|
||||
export interface PanSession {
|
||||
src: HeatSource;
|
||||
env: Environment;
|
||||
/** 0 = fresh butter .. 1 = burnt. Only meaningful once `hasButter`. */
|
||||
butter: number;
|
||||
hasButter: boolean;
|
||||
food: PanFood | null;
|
||||
seconds: number;
|
||||
/** Fired the frame the smoke alarm trips (burnt butter, indoors). */
|
||||
zapped: boolean;
|
||||
rng: Rng;
|
||||
}
|
||||
|
||||
export function newPanSession(fuel: string = 'gas', env?: Environment): PanSession {
|
||||
return {
|
||||
src: newHeatSource(fuel),
|
||||
env: env ?? newIndoorEnv(),
|
||||
butter: 0,
|
||||
hasButter: false,
|
||||
food: null,
|
||||
seconds: 0,
|
||||
zapped: false,
|
||||
rng: new Rng(20260719),
|
||||
};
|
||||
}
|
||||
|
||||
export function setPanKnob(s: PanSession, k: number): void {
|
||||
setKnob(s.src, k);
|
||||
}
|
||||
|
||||
export function addButter(s: PanSession): void {
|
||||
s.hasButter = true;
|
||||
s.butter = 0;
|
||||
}
|
||||
|
||||
export function butterState(s: PanSession): ButterState {
|
||||
if (!s.hasButter) return 'none';
|
||||
const b = s.butter;
|
||||
if (b < 0.02) return s.src.output > MELT_TEMP ? 'melting' : 'cold';
|
||||
if (b < 0.2) return 'melting';
|
||||
if (b < 0.45) return 'foaming';
|
||||
if (b < 0.7) return 'noisette';
|
||||
return 'burnt';
|
||||
}
|
||||
|
||||
function stampDisc(mask: Field): void {
|
||||
const n = mask.n;
|
||||
for (let y = 0; y < n; y++) {
|
||||
for (let x = 0; x < n; x++) {
|
||||
const u = x / (n - 1);
|
||||
const v = y / (n - 1);
|
||||
if (Math.hypot(u - 0.5, v - 0.5) <= 0.42) mask.data[y * n + x] = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Lay a piece of food in the pan, `down` side against the heat. */
|
||||
export function addFood(s: PanSession, id: IngredientId | string, down: 'A' | 'B' = 'A'): void {
|
||||
const mask = new Field(N);
|
||||
stampDisc(mask);
|
||||
const panHeat = new Float32Array(N * N);
|
||||
applyToHeatMap(panHeat, N, s.src, new Rng(7), mask);
|
||||
const ing = (INGREDIENTS as Record<string, { flesh?: { moisture: number } }>)[id];
|
||||
s.food = {
|
||||
id,
|
||||
mask,
|
||||
searA: new Field(N),
|
||||
searB: new Field(N),
|
||||
dryA: 0,
|
||||
dryB: 0,
|
||||
down,
|
||||
bitterness: 0,
|
||||
fat: 0,
|
||||
moisture: ing?.flesh?.moisture ?? 0.4,
|
||||
panHeat,
|
||||
};
|
||||
}
|
||||
|
||||
/** Turn the piece over — the up side comes down onto the heat. */
|
||||
export function flip(s: PanSession): void {
|
||||
if (s.food) s.food.down = s.food.down === 'A' ? 'B' : 'A';
|
||||
}
|
||||
|
||||
/**
|
||||
* Baste: spoon hot butter over the UP face. Raises its sear toward the down
|
||||
* side, and carries the butter's character — noisette butter is a flavour bonus,
|
||||
* burnt butter paints bitterness. Returns the state you basted with.
|
||||
*/
|
||||
export function baste(s: PanSession, strokes = 3): { state: ButterState; bonus: boolean } {
|
||||
const st = butterState(s);
|
||||
const f = s.food;
|
||||
if (!f || !s.hasButter) return { state: st, bonus: false };
|
||||
const up = f.down === 'A' ? f.searB : f.searA;
|
||||
const gain = st === 'burnt' ? 0.04 : st === 'noisette' || st === 'foaming' ? 0.09 : 0.05;
|
||||
for (let stroke = 0; stroke < strokes; stroke++) {
|
||||
for (let i = 0; i < up.data.length; i++) if (f.mask.data[i] >= 0.5) up.data[i] = Math.min(1.2, up.data[i] + gain);
|
||||
}
|
||||
if (st === 'burnt') f.bitterness = Math.min(1, f.bitterness + 0.12 * strokes);
|
||||
return { state: st, bonus: st === 'noisette' };
|
||||
}
|
||||
|
||||
export function panStep(s: PanSession, dt: number): void {
|
||||
s.zapped = false;
|
||||
const f = s.food;
|
||||
const fatLoad = f ? f.fat : 0;
|
||||
// The pan temp chases the knob on the fuel's lag; rendered fat can flare a flame.
|
||||
heatStep(s.src, dt, s.env, fatLoad);
|
||||
const temp = s.src.output;
|
||||
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) {
|
||||
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.
|
||||
const butterFactor = st === 'foaming' || st === 'noisette' ? 1.15 : st === 'burnt' ? 0.9 : st === 'melting' ? 1.0 : 0.85;
|
||||
|
||||
if (f) {
|
||||
const downSear = f.down === 'A' ? f.searA : f.searB;
|
||||
const upSear = f.down === 'A' ? f.searB : f.searA;
|
||||
// The down side steams its water off, then browns fast.
|
||||
const dryCap = 0.5 + f.moisture * 2;
|
||||
if (f.down === 'A') f.dryA = Math.min(1, f.dryA + (p * dt) / dryCap);
|
||||
else f.dryB = Math.min(1, f.dryB + (p * dt) / dryCap);
|
||||
const dryDown = f.down === 'A' ? f.dryA : f.dryB;
|
||||
const md = f.mask.data;
|
||||
const dd = downSear.data;
|
||||
for (let i = 0; i < dd.length; i++) {
|
||||
if (md[i] < 0.5) continue;
|
||||
const h = f.panHeat[i] * p;
|
||||
dd[i] = Math.min(1.2, dd[i] + SEAR_RATE * h * (0.3 + 0.7 * dryDown) * butterFactor * dt);
|
||||
}
|
||||
// The up side gets only the pan's radiant halo — slow, even.
|
||||
const ud = upSear.data;
|
||||
for (let i = 0; i < ud.length; i++) {
|
||||
if (md[i] < 0.5) continue;
|
||||
ud[i] = Math.min(1.2, ud[i] + SEAR_RATE * p * UP_FRACTION * dt);
|
||||
}
|
||||
// Fat renders off the browning down side and feeds the flare.
|
||||
const dmean = downSear.stats(f.mask).mean;
|
||||
if (dmean > 0.4) f.fat = Math.min(1, f.fat + 0.35 * dt);
|
||||
else f.fat = Math.max(0, f.fat - 0.2 * dt);
|
||||
// Cooking in burnt butter taints the food.
|
||||
if (st === 'burnt') f.bitterness = Math.min(1, f.bitterness + 0.05 * dt);
|
||||
}
|
||||
|
||||
// Burnt butter smokes — indoors it trips the alarm once.
|
||||
if (st === 'burnt' && addSmoke(s.env, 0.5 * dt)) s.zapped = true;
|
||||
s.seconds += dt;
|
||||
}
|
||||
|
||||
export interface PanResult {
|
||||
/** Per-side browning. */
|
||||
downMean: number;
|
||||
upMean: number;
|
||||
downStdev: number;
|
||||
/** |downMean - upMean| — evenness across the two sides (the flip test). */
|
||||
sideGap: number;
|
||||
/** Best-side / worst-side doneness in the good window (0.5..0.85). */
|
||||
doneScore: number;
|
||||
butter: ButterState;
|
||||
bitterness: number;
|
||||
flares: number;
|
||||
}
|
||||
|
||||
/** A good sear per side lives here; past it is over, then burnt. */
|
||||
export const SEAR_LO = 0.5;
|
||||
export const SEAR_HI = 0.85;
|
||||
export const BURN_AT = 0.95;
|
||||
|
||||
export function panResult(s: PanSession): PanResult {
|
||||
const f = s.food;
|
||||
if (!f) return { downMean: 0, upMean: 0, downStdev: 0, sideGap: 0, doneScore: 0, butter: butterState(s), bitterness: 0, flares: 0 };
|
||||
const a = f.searA.stats(f.mask);
|
||||
const b = f.searB.stats(f.mask);
|
||||
// A and B, not down/up — the judge sees the finished piece, both faces.
|
||||
const inWindow = (m: number) => (m >= SEAR_LO && m < BURN_AT ? 1 - Math.abs(m - 0.68) / 0.4 : m >= BURN_AT ? 0 : m / SEAR_LO * 0.5);
|
||||
const doneScore = Math.max(0, Math.min(1, (inWindow(a.mean) + inWindow(b.mean)) / 2));
|
||||
return {
|
||||
downMean: (f.down === 'A' ? a : b).mean,
|
||||
upMean: (f.down === 'A' ? b : a).mean,
|
||||
downStdev: (f.down === 'A' ? a : b).stdev,
|
||||
sideGap: Math.abs(a.mean - b.mean),
|
||||
doneScore,
|
||||
butter: butterState(s),
|
||||
bitterness: f.bitterness,
|
||||
flares: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** The live diegetic tell for the butter — never a meter. */
|
||||
export function butterWord(s: PanSession): string {
|
||||
switch (butterState(s)) {
|
||||
case 'none':
|
||||
return 'dry pan';
|
||||
case 'cold':
|
||||
return 'cold butter, sitting there';
|
||||
case 'melting':
|
||||
return 'melting…';
|
||||
case 'foaming':
|
||||
return 'FOAMING — in it goes';
|
||||
case 'noisette':
|
||||
return 'nutty brown — noisette';
|
||||
case 'burnt':
|
||||
return 'burnt. that is a smell you chose';
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user