From bffee40c6f239e41d500e891cb4e3dc85ef6432c Mon Sep 17 00:00:00 2001 From: type-two Date: Mon, 20 Jul 2026 02:34:50 +1000 Subject: [PATCH] =?UTF-8?q?THE=20POACH:=20day=2021=20=E2=80=94=20the=20shi?= =?UTF-8?q?ver,=20the=20vortex,=20the=20drop,=20the=20wobble=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The near-game, treated with respect (masterplan P3). sim/poach.ts (pure, rides the heat-source bone): the pot chases the knob on the fuel's lag and the poach wants the SHIVER (5.8-7.4) — cooler rags the white on the way in, a rolling boil (>8.2) tears it the whole time. The VORTEX is the reamer-twist at pot scale: swirl builds it, it DECAYS (swirl then drop, not swirl then admire), and the strength at the drop is the judged form. Two-layer set: white first, the yolk only firms once the white is mostly there — lift in the window (white >=0.85, yolk <=0.35) or you've boiled an egg the long way. Then DRAIN on the slotted spoon. Stirring a dropped egg is vandalism (rags it directly). scenes/poach.ts: WHEEL flame, circular-drag swirl (real angular sweep), SPACE drop, L lift, hold-to-drain, ENTER serves; vortex streaks that spin and fade, shiver-vs-boil bubbles, the white gathering tighter as it sets, a hard yolk going pale. judgePoach: THE POACH (White/Wobble/Form) + The Drain; the wobble line is the dossier's: 'It should tremble. Like you, right now.' Day 21 authored + poach in the endless rotation (electric pot past day 27). Verified with REAL input: shiver + 91% vortex + lift-at-tremble + drain = 9.9/10 'it TREMBLES — perfect', 'one neat comet (vortex 91%)'; still water into a rolling boil, overstayed, undrained = 3.6/10, 'a pot of rags', 'it does not move at all'. Co-Authored-By: Claude Fable 5 --- src/dev.ts | 44 ++++++ src/game/game.ts | 46 +++++- src/game/judging.ts | 61 +++++++- src/game/lines.ts | 34 +++++ src/game/orders.ts | 31 +++- src/scenes/judge.ts | 7 +- src/scenes/poach.ts | 343 ++++++++++++++++++++++++++++++++++++++++++++ src/sim/poach.ts | 184 ++++++++++++++++++++++++ 8 files changed, 744 insertions(+), 6 deletions(-) create mode 100644 src/scenes/poach.ts create mode 100644 src/sim/poach.ts diff --git a/src/dev.ts b/src/dev.ts index 0d8b7a3..d382d96 100644 --- a/src/dev.ts +++ b/src/dev.ts @@ -637,6 +637,50 @@ export function installDevHarness(app: App, game: Game): void { return { floats: s.eggs.map((e) => e.floatWord), rottenIn: s.rottenIn, dumped: s.bowlsDumped, rows }; }, + /** Play the day-21 poach with REAL input. good=true: shiver + vortex + drop + * + lift-at-set + drain. false: still water, rolling boil, overstay, no drain. */ + poachDay(good = true) { + game.setDay(21); + run(6); + const pv = game.poachView as unknown as { session: import('./sim/poach').PoachSession }; + const s = pv.session; + if (!s) return { error: 'not at poach' }; + const swirlCircles = (turns: number) => { + // real circular drags over the pot + for (let k = 0; k < turns * 24; k++) { + const a = (k / 24) * Math.PI * 2; + point(Math.cos(a) * 0.7, 0.7, Math.sin(a) * 0.7, true); + run(1); + } + point(1.6, 0.7, 1.6, false); + run(2); + }; + if (good) { + s.src.target = 7; // the shiver band + let g = 0; + while (s.src.output < 6 && g++ < 1200) run(1); + swirlCircles(3); + tap('Space'); // the drop, into the spin + g = 0; + while ((s.whiteSet < 0.86 || s.yolkSet > 0.35) && s.yolkSet <= 0.3 && g++ < 60 * 40) run(1); + tap('KeyL'); + for (let i = 0; i < 60 * 4; i++) run(1); // drain + } else { + s.src.target = 9; // straight to a rolling boil + let g = 0; + while (s.src.output < 8.5 && g++ < 1200) run(1); + tap('Space'); // no vortex, into the boil + for (let i = 0; i < 60 * 30; i++) run(1); // overstay — yolk hardens, rags tear + tap('KeyL'); + // no drain + } + tap('Enter'); + run(6); + const rows = [...document.querySelectorAll('.crit')].map((e) => e.textContent); + const q = (n: number) => +n.toFixed(3); + return { white: q(s.whiteSet), yolk: q(s.yolkSet), rags: q(s.rags), vortexAtDrop: q(s.vortexAtDrop), rows }; + }, + // ---- THE EGGS: the crack window, the float test, the rotten beat. ---- /** The crack window: soft tap does nothing, the window opens clean, past it diff --git a/src/game/game.ts b/src/game/game.ts index e26005d..80c9bf9 100644 --- a/src/game/game.ts +++ b/src/game/game.ts @@ -10,6 +10,7 @@ import { PanView } from '../scenes/pan'; import { FridgeView } from '../scenes/fridge'; import { SteakBoardView } from '../scenes/steakboard'; import { EggBenchView } from '../scenes/eggbench'; +import { PoachView } from '../scenes/poach'; import { serializeStore, restoreStore, fetch as fetchStock, usableQuality, type StoredItemSave } from '../sim/coldchain'; import { AssemblyView } from '../scenes/assembly'; import type { CutPattern } from '../sim/cutting'; @@ -18,7 +19,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, judgePan, judgeFridge, judgeSteak, judgeEggs, type BruschettaResult, type PrepResult, type Verdict } from './judging'; +import { judgeBruschetta, judgeGrill, judgePan, judgeFridge, judgeSteak, judgeEggs, judgePoach, 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'; @@ -64,6 +65,7 @@ export class Game { private fridge: FridgeView; private steakBoard: SteakBoardView; private eggBench: EggBenchView; + private poach: PoachView; private assembly: AssemblyView; /** What the prep bench reported for the current order, if it ran. */ private prepResult: PrepResult | null = null; @@ -103,6 +105,7 @@ export class Game { this.fridge = new FridgeView(app); this.steakBoard = new SteakBoardView(app); this.eggBench = new EggBenchView(app); + this.poach = new PoachView(app); this.assembly = new AssemblyView(app); app.scene.add(this.kitchen.root); app.scene.add(this.judgeView.root); @@ -116,6 +119,7 @@ export class Game { app.scene.add(this.fridge.root); app.scene.add(this.steakBoard.root); app.scene.add(this.eggBench.root); + app.scene.add(this.poach.root); app.scene.add(this.assembly.root); this.judgeView.root.visible = false; this.drawer.root.visible = false; @@ -128,6 +132,7 @@ export class Game { this.fridge.root.visible = false; this.steakBoard.root.visible = false; this.eggBench.root.visible = false; + this.poach.root.visible = false; this.assembly.root.visible = false; this.ticket = new Panel('ticket'); @@ -246,6 +251,9 @@ export class Game { get eggBenchView(): EggBenchView { return this.eggBench; } + get poachView(): PoachView { + return this.poach; + } get assemblyView(): AssemblyView { return this.assembly; } @@ -417,6 +425,7 @@ export class Game { this.fridge.root.visible = false; this.steakBoard.root.visible = false; this.eggBench.root.visible = false; + this.poach.root.visible = false; this.assembly.root.visible = false; } @@ -571,9 +580,44 @@ export class Game { this.enterEggs(); return; } + // A poach day: the shiver, the vortex, the drop, the wobble. + if (this.order.poach) { + this.enterPoach(); + return; + } this.enterToastFlow(); } + /** The poach pot: spin it, drop it, lift it, drain it, serve it. */ + private enterPoach(): void { + this.poach.reset(this.order.poach!.fuel); + this.hideAllScenes(); + this.poach.root.visible = true; + this.app.setView(this.poach); + this.ticket.show(); + this.poach.onDone = (result) => { + this.poach.root.visible = false; + this.servePoach(result); + }; + } + + private servePoach(result: ReturnType): void { + this.timing = false; + const seconds = this.orderSeconds; + const slice = this.kitchen.currentSlice; + const v = judgePoach(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 egg bench: float-test the suspects, crack, separate, serve. */ private enterEggs(): void { const e = this.order.eggs!; diff --git a/src/game/judging.ts b/src/game/judging.ts index 2a12609..66e8bd0 100644 --- a/src/game/judging.ts +++ b/src/game/judging.ts @@ -13,6 +13,7 @@ import type { PanResult } from '../sim/pan'; import type { storeHealth } from '../sim/coldchain'; import type { SteakResult } from '../sim/steak'; import type { EggResult } from '../sim/eggs'; +import type { PoachResult } from '../sim/poach'; export interface Criterion { key: string; @@ -25,7 +26,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' | 'pan' | 'cold' | 'steak' | 'eggs'; + group?: 'prep' | 'toast' | 'spread' | 'bread' | 'topping' | 'bench' | 'grill' | 'pan' | 'cold' | 'steak' | 'eggs' | 'poach'; } /** What the prep bench hands the judge, when the order had prep on it. */ @@ -557,6 +558,64 @@ export function judgeEggs(r: EggResult, order: Order, seconds: number): Verdict return { criteria, total, grade: gradeOf(total), best: sorted[sorted.length - 1], worst: sorted[0], lines: [] }; } +// ===================================================================== +// The poach — the white, the wobble, the form, the drain. He presses it. + +export function judgePoach(r: PoachResult, order: Order, seconds: number): Verdict { + const criteria: Criterion[] = [ + { + key: 'poachwhite', + group: 'poach', + label: 'The White', + score: clamp01(r.whiteScore), + weight: 1.2, + detail: r.whiteSet >= 0.85 ? 'set, not snotty' : r.whiteSet >= 0.55 ? 'soft in the middle' : 'raw — it never set', + }, + { + key: 'poachwobble', + group: 'poach', + label: 'The Wobble', + score: clamp01(r.yolkScore), + weight: 1.3, + detail: r.wobbleWord, + }, + { + key: 'poachform', + group: 'poach', + label: 'The Form', + score: clamp01(r.formScore), + weight: 1.0, + detail: r.rags > 0.5 ? 'a pot of rags' : r.rags > 0.15 ? 'trailing ribbons' : `one neat comet (vortex ${Math.round(r.vortexAtDrop * 100)}%)`, + }, + { + key: 'poachdrain', + group: 'bench', + label: 'The Drain', + score: clamp01(r.drainScore), + weight: 0.6, + detail: r.cling > 0.3 ? 'served with its own puddle' : 'drained on the spoon', + }, + { + key: 'time', + label: 'Service', + score: clamp01(1 - (seconds - 60) / 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: [] }; +} + // ===================================================================== // The rib eye — cook it, rest it, cut it. The Rest is the nerve test: it reads // the flood, so cutting a hot steak bleeds points onto the board. diff --git a/src/game/lines.ts b/src/game/lines.ts index fa2d1b0..2eceab5 100644 --- a/src/game/lines.ts +++ b/src/game/lines.ts @@ -402,6 +402,40 @@ Object.assign(BANK, { }, }); +// The poach — he presses it. He always presses it. +Object.assign(BANK, { + poachwhite: { + bad: [ + 'The white never set. This is egg soup wearing a napkin.', + 'Snotty. The water was too cool and we are both paying for it.', + ], + good: ['The white is set clean through. Silk, not snot.', 'Set, tender, done. The shiver did its work.'], + }, + poachwobble: { + bad: [ + 'I pressed it. Nothing. You boiled an egg the long way.', + 'The yolk is a rock. The whole point of this dish is that it is not a rock.', + 'It should tremble. Like you, right now.', + ], + good: [ + 'I pressed it and it TREMBLED. That is the dish. That is the whole dish.', + 'Liquid gold under silk. You lifted it at the exact right breath.', + ], + }, + poachform: { + bad: [ + 'This is not an egg, it is a weather event. Rags everywhere.', + 'You dropped it into still water and it simply left. Spin the vortex FIRST.', + 'Ribbons. The pot was rolling, or the drop was from orbit. Either way: ribbons.', + ], + good: ['One neat comet. The vortex held it like a thought.', 'Tucked, tight, whole. The spin was there when it mattered.'], + }, + poachdrain: { + bad: ['Served with its own puddle. The spoon has HOLES for a reason.', 'Poach water on the plate. The toast beneath is a casualty.'], + good: ['Drained properly. Dry plate, intact dignity.'], + }, +}); + // The rib eye — the steakhouse grandfather who has watched a thousand cooks panic. Object.assign(BANK, { steakcook: { diff --git a/src/game/orders.ts b/src/game/orders.ts index d4d76a2..23e109d 100644 --- a/src/game/orders.ts +++ b/src/game/orders.ts @@ -114,6 +114,8 @@ export interface Order { /** The egg bench: `need` separated eggs from a carton whose `age` sets the * rotten odds. The float test is on the bench; using it is the skill. */ eggs?: { need: number; cartonAge: number }; + /** The poach: fuel under the pot. The vortex, the shiver, the drop, the wobble. */ + poach?: { fuel: string }; } export const BROWNING_NAMES: [number, string][] = [ @@ -455,6 +457,21 @@ export const DAYS: Order[] = [ text: "Three eggs, separated, for the pav. No shell in my whites, no yolk in my whites — and the carton's been sitting a bit, so FLOAT them first. Crack one rotten egg into that bowl and I will hear it from the front room.", eggs: { need: 3, cartonAge: 0.55 }, }, + { + day: 21, + 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 benedict table', + text: 'One poached egg, proper. Bring the water to a SHIVER — not a boil, it will tear. Spin a vortex, drop it in the eye, and lift it when the white sets and the yolk still trembles. I am going to press it.', + poach: { fuel: 'gas' }, + }, ]; /** Days beyond the script — keep going, with everything cranked. */ @@ -472,8 +489,9 @@ export function proceduralOrder(day: number, rand: () => number): Order { if (roll < 0.53) return proceduralJuice(day, rand); if (roll < 0.64) return proceduralGrill(day, rand); if (roll < 0.75) return proceduralPan(day, rand); - if (roll < 0.85) return proceduralSteak(day, rand); - if (roll < 0.93) return proceduralEggs(day, rand); + if (roll < 0.84) return proceduralSteak(day, rand); + if (roll < 0.91) return proceduralEggs(day, rand); + if (roll < 0.96) return proceduralPoach(day, rand); return proceduralFridge(day, rand); } @@ -571,6 +589,15 @@ function proceduralEggs(day: number, rand: () => number): Order { return o; } +function proceduralPoach(day: number, rand: () => number): Order { + // Electric arrives late here too — a pot with lag and memory is a cruel poach. + const fuel = day > 27 && rand() < 0.4 ? 'electric' : 'gas'; + const o = baseOrder(day, pickOf(rand, ['the benedict table', 'a poet, apparently', 'the man who presses things']), ''); + o.poach = { fuel }; + o.text = `One poached egg${fuel === 'electric' ? ' — on the electric, mind the lag' : ''}. Shiver, vortex, drop, lift. White set, yolk trembling. I will press it.`; + return o; +} + function proceduralFridge(day: number, rand: () => number): Order { const o = baseOrder(day, 'the delivery, at 6am', ''); void rand(); diff --git a/src/scenes/judge.ts b/src/scenes/judge.ts index e92e82d..cfafd4c 100644 --- a/src/scenes/judge.ts +++ b/src/scenes/judge.ts @@ -107,6 +107,8 @@ 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.poach + ? `Day ${order.day} · ${order.who} asked for the poached egg` : order.eggs ? `Day ${order.day} · ${order.who} asked for the eggs` : order.steak @@ -125,7 +127,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.group === 'pan' || c.group === 'cold' || c.group === 'steak' || c.group === 'eggs', + (c) => c.group === 'prep' || c.group === 'bread' || c.group === 'topping' || c.group === 'bench' || c.group === 'grill' || c.group === 'pan' || c.group === 'cold' || c.group === 'steak' || c.group === 'eggs' || c.group === 'poach', ); const headers: Record = { prep: 'THE PREP', @@ -138,9 +140,10 @@ export class JudgeView implements View { cold: 'THE FRIDGE', steak: 'THE STEAK', eggs: 'THE EGGS', + poach: 'THE POACH', bench: 'THE BENCH', }; - const groupRank: Record = { prep: 0, bread: 1, toast: 2, topping: 3, grill: 3, pan: 3, cold: 3, steak: 3, eggs: 3, spread: 4, bench: 5 }; + const groupRank: Record = { prep: 0, bread: 1, toast: 2, topping: 3, grill: 3, pan: 3, cold: 3, steak: 3, eggs: 3, poach: 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/poach.ts b/src/scenes/poach.ts new file mode 100644 index 0000000..c7d7dde --- /dev/null +++ b/src/scenes/poach.ts @@ -0,0 +1,343 @@ +import * as THREE from 'three'; +import type { App, View } from '../core/app'; +import { audio } from '../core/audio'; +import { + type PoachSession, + newPoachSession, + setPotKnob, + swirl, + dropEgg, + liftEgg, + drainStep, + poachStep, + poachResult, + waterWord, + SHIVER_LO, + SHIVER_HI, + BOIL_AT, + WHITE_SET, + YOLK_TREMBLE, +} from '../sim/poach'; +import { el, Panel } from '../ui/hud'; + +const POT_Y = 0.55; +const POT_R = 1.15; + +/** + * THE POACH POT — the vortex, the shiver, the drop, the lift. + * + * WHEEL sets the flame (the water answers on the fuel's lag — watch for the + * SHIVER, not a thermometer). DRAG CIRCLES over the pot to spin the vortex — + * it decays, so swirl then drop, not swirl then admire. SPACE drops the egg + * into the eye. Wait for the white to gather and set. L lifts it onto the + * spoon; HOLD it there to drain. ENTER serves — and he WILL press it. + */ +export class PoachView implements View { + readonly root = new THREE.Group(); + + private session: PoachSession | null = null; + private flame!: THREE.Mesh; + private water!: THREE.Mesh; + private streaks: THREE.Line[] = []; + private streakAngle = 0; + private bubbles: THREE.Mesh[] = []; + private eggWhite!: THREE.Mesh; + private eggYolk!: THREE.Mesh; + private ragBits: THREE.Mesh[] = []; + private spoon!: THREE.Group; + + private ray = new THREE.Raycaster(); + private hit = new THREE.Vector3(); + private plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -(POT_Y + 0.12)); + private lastAngle: number | null = null; + + 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('poach-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, 'POACH 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); + + // Burner + flame under the pot (the pan's grammar). + const burner = new THREE.Mesh( + new THREE.CylinderGeometry(0.85, 0.95, 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.65, 0.45, 20), + new THREE.MeshBasicMaterial({ color: 0x3a7bff, transparent: true, opacity: 0 }), + ); + this.flame.position.y = 0.32; + this.root.add(this.flame); + + // The pot. + const pot = new THREE.Mesh( + new THREE.CylinderGeometry(POT_R, POT_R * 0.92, 0.75, 32, 1, true), + new THREE.MeshStandardMaterial({ color: 0x8a8f94, roughness: 0.35, metalness: 0.7, side: THREE.DoubleSide }), + ); + pot.position.y = POT_Y - 0.05; + this.root.add(pot); + this.water = new THREE.Mesh( + new THREE.CylinderGeometry(POT_R * 0.93, POT_R * 0.93, 0.06, 32), + new THREE.MeshStandardMaterial({ color: 0x7fa8bc, roughness: 0.2, transparent: true, opacity: 0.75 }), + ); + this.water.position.y = POT_Y + 0.1; + this.root.add(this.water); + + // Vortex streaks: arcs that spin with the swirl. + const streakMat = new THREE.LineBasicMaterial({ color: 0xd8ecf4, transparent: true, opacity: 0.0 }); + for (let i = 0; i < 3; i++) { + const pts: THREE.Vector3[] = []; + const r = POT_R * (0.35 + i * 0.2); + for (let a = 0; a <= 20; a++) { + const th = (a / 20) * Math.PI * 1.1; + pts.push(new THREE.Vector3(Math.cos(th) * r, POT_Y + 0.14, Math.sin(th) * r)); + } + const line = new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts), streakMat); + this.streaks.push(line); + this.root.add(line); + } + + // Shiver/boil bubbles. + for (let i = 0; i < 10; i++) { + const b = new THREE.Mesh( + new THREE.SphereGeometry(0.035, 8, 6), + new THREE.MeshBasicMaterial({ color: 0xeef6fa, transparent: true, opacity: 0 }), + ); + b.position.set((Math.random() - 0.5) * POT_R * 1.4, POT_Y + 0.14, (Math.random() - 0.5) * POT_R * 1.4); + this.bubbles.push(b); + this.root.add(b); + } + + // The egg: a white comet + a yolk, hidden until the drop. + this.eggWhite = new THREE.Mesh( + new THREE.SphereGeometry(0.3, 16, 12), + new THREE.MeshStandardMaterial({ color: 0xf2efe6, roughness: 0.5, transparent: true, opacity: 0.65 }), + ); + this.eggWhite.scale.set(1.25, 0.5, 1.1); + this.eggWhite.position.set(0, POT_Y + 0.12, 0); + this.eggWhite.visible = false; + this.root.add(this.eggWhite); + this.eggYolk = new THREE.Mesh( + new THREE.SphereGeometry(0.14, 14, 10), + new THREE.MeshStandardMaterial({ color: 0xf0b428, roughness: 0.45 }), + ); + this.eggYolk.scale.set(1, 0.65, 1); + this.eggYolk.position.set(0, POT_Y + 0.16, 0); + this.eggYolk.visible = false; + this.root.add(this.eggYolk); + + // The slotted spoon, arriving at the lift. + this.spoon = new THREE.Group(); + const bowl = new THREE.Mesh( + new THREE.SphereGeometry(0.38, 16, 10, 0, Math.PI * 2, 0, Math.PI / 2.4), + new THREE.MeshStandardMaterial({ color: 0xb8bcc0, roughness: 0.4, metalness: 0.7, side: THREE.DoubleSide }), + ); + bowl.rotation.x = Math.PI; + const handle = new THREE.Mesh( + new THREE.BoxGeometry(1.3, 0.05, 0.1), + new THREE.MeshStandardMaterial({ color: 0xb8bcc0, roughness: 0.4, metalness: 0.7 }), + ); + handle.position.set(0.85, 0.12, 0); + this.spoon.add(bowl, handle); + this.spoon.visible = false; + this.root.add(this.spoon); + } + + /** A fresh pot on `fuel`, cold water, no egg. */ + reset(fuel = 'gas', askText = 'one poached egg — white set, yolk trembling'): void { + this.session = newPoachSession(fuel); + this.lastAngle = null; + this.eggWhite.visible = false; + this.eggYolk.visible = false; + this.spoon.visible = false; + for (const r of this.ragBits) this.root.remove(r); + this.ragBits = []; + this.askEl.textContent = askText; + this.hintEl.textContent = 'WHEEL — flame · DRAG CIRCLES — spin the vortex · SPACE — drop · L — lift · hold to drain · ENTER serves'; + } + + enter(): void { + this.panel.show(); + } + exit(): void { + this.panel.hide(); + } + + update(dt: number): void { + this.app.camera.position.set(0, 3.6, 3.1); + this.app.camera.lookAt(0, POT_Y, 0); + const s = this.session; + if (!s) return; + const inp = this.app.input; + + if (inp.wheel !== 0) { + const k = Math.max(0, Math.min(9, Math.round(s.src.target) + (inp.wheel > 0 ? 1 : -1))); + setPotKnob(s, k); + } + + // Circular drag over the pot = the swirl (angular sweep around the centre). + this.ray.setFromCamera(inp.ndc, this.app.camera); + const on = this.ray.ray.intersectPlane(this.plane, this.hit); + if (inp.down && on && Math.hypot(this.hit.x, this.hit.z) < POT_R * 1.15) { + const ang = Math.atan2(this.hit.z, this.hit.x); + if (this.lastAngle !== null) { + let d = ang - this.lastAngle; + if (d > Math.PI) d -= Math.PI * 2; + if (d < -Math.PI) d += Math.PI * 2; + swirl(s, d); + if (Math.abs(d) > 0.02 && !s.dropped) audio.scrape(0.3, Math.abs(d) * 3); + } + this.lastAngle = ang; + } else { + this.lastAngle = null; + } + + if (inp.justPressed('Space') && !s.dropped) { + dropEgg(s, 0); + this.eggWhite.visible = true; + this.eggYolk.visible = true; + audio.clatter(0.4, 0.9); + // Rags appear immediately if the drop was bad. + this.spawnRags(s.rags); + } + if (inp.justPressed('KeyL') && s.dropped && !s.lifted) { + liftEgg(s); + this.spoon.visible = true; + audio.clunk(true); + this.hintEl.textContent = 'hold it on the spoon — let it DRAIN · ENTER serves'; + } + if (s.lifted) drainStep(s, dt); + + poachStep(s, dt); + this.syncVisuals(dt); + this.updateHud(); + + if (inp.justPressed('Enter') && s.dropped) { + const cb = this.onDone; + this.onDone = null; + cb?.(poachResult(s)); + } + } + + private spawnRags(rags: number): void { + const n = Math.round(rags * 6); + for (let i = this.ragBits.length; i < n; i++) { + const bit = new THREE.Mesh( + new THREE.SphereGeometry(0.07 + Math.random() * 0.06, 8, 6), + new THREE.MeshStandardMaterial({ color: 0xf0ece0, roughness: 0.6, transparent: true, opacity: 0.7 }), + ); + const a = Math.random() * Math.PI * 2; + const r = POT_R * (0.4 + Math.random() * 0.45); + bit.scale.y = 0.4; + bit.position.set(Math.cos(a) * r, POT_Y + 0.12, Math.sin(a) * r); + this.ragBits.push(bit); + this.root.add(bit); + } + } + + private syncVisuals(dt: number): void { + const s = this.session!; + const t = s.src.output; + // Flame answers the knob. + const fm = this.flame.material as THREE.MeshBasicMaterial; + fm.opacity = s.src.flame ? Math.min(0.8, t / 10) : 0; + this.flame.scale.y = 0.5 + t / 9; + + // Vortex streaks spin with the swirl and fade with it. + this.streakAngle += s.vortex * dt * 7; + const sm = this.streaks[0].material as THREE.LineBasicMaterial; + sm.opacity = Math.min(0.85, s.vortex * 1.1); + this.streaks.forEach((line, i) => { + line.rotation.y = this.streakAngle * (1 + i * 0.15); + }); + + // Bubbles: none when still, a shy shiver in band, a jitterbug at boil. + const inBand = t >= SHIVER_LO && t <= SHIVER_HI; + const boiling = t > BOIL_AT; + for (const b of this.bubbles) { + const bm = b.material as THREE.MeshBasicMaterial; + bm.opacity = boiling ? 0.85 : inBand ? 0.35 : t > 4 ? 0.12 : 0; + if (boiling) b.position.y = POT_Y + 0.14 + Math.abs(Math.sin(s.seconds * 9 + b.position.x * 7)) * 0.09; + else b.position.y = POT_Y + 0.14 + (inBand ? Math.abs(Math.sin(s.seconds * 3.5 + b.position.z * 5)) * 0.02 : 0); + } + + // The egg gathers as it sets: opacity and tightness track whiteSet; it rides + // the vortex; the spoon lifts it clear. + if (s.dropped) { + const em = this.eggWhite.material as THREE.MeshStandardMaterial; + em.opacity = 0.45 + s.whiteSet * 0.55; + const gather = 1.35 - s.whiteSet * 0.35 + s.rags * 0.3; + this.eggWhite.scale.set(gather, 0.5 + s.whiteSet * 0.12, gather * 0.9); + const ym = this.eggYolk.material as THREE.MeshStandardMaterial; + ym.color.setHex(s.yolkSet > 0.6 ? 0xd8c26a : 0xf0b428); // a hard yolk goes pale + const y = s.lifted ? POT_Y + 0.55 : POT_Y + 0.12; + this.eggWhite.position.set(0, y, 0); + this.eggYolk.position.set(0, y + 0.05, 0); + if (!s.lifted) { + this.eggWhite.rotation.y += s.vortex * dt * 4; + } else { + this.spoon.position.set(0, POT_Y + 0.42, 0); + // Drips while it drains. + this.eggWhite.position.y = POT_Y + 0.55; + } + } + } + + private updateHud(): void { + const s = this.session!; + const r = poachResult(s); + const vortexWord = s.vortex > 0.6 ? 'spinning hard' : s.vortex > 0.25 ? 'a lazy spin' : 'still'; + this.stateEl.textContent = `flame ${Math.round(s.src.target)} · ${waterWord(s)} · water: ${vortexWord}`; + if (!s.dropped) { + this.wordEl.textContent = s.vortex > 0.5 && s.src.output >= SHIVER_LO && s.src.output <= SHIVER_HI ? 'NOW — drop it in the eye' : ''; + this.wordEl.style.color = '#8fce8f'; + } else if (!s.lifted) { + const word = s.whiteSet < 0.55 ? 'the white is gathering…' : s.whiteSet < WHITE_SET ? 'nearly set…' : s.yolkSet <= YOLK_TREMBLE ? 'SET — lift it NOW (L)' : 'it is overstaying'; + this.wordEl.textContent = word; + this.wordEl.style.color = s.whiteSet >= WHITE_SET && s.yolkSet <= YOLK_TREMBLE ? '#8fce8f' : s.yolkSet > YOLK_TREMBLE ? '#e2603a' : ''; + } else { + this.wordEl.textContent = s.cling > 0.3 ? 'draining…' : `drained — serve it. ${r.wobbleWord}`; + this.wordEl.style.color = s.cling > 0.3 ? '' : '#8fce8f'; + } + } + + result() { + return this.session + ? poachResult(this.session) + : { whiteScore: 0, yolkScore: 0, formScore: 0, drainScore: 0, whiteSet: 0, yolkSet: 0, rags: 0, cling: 1, vortexAtDrop: 0, wobbleWord: 'there is no egg' }; + } + + dispose(): void { + this.panel.dispose(); + } +} diff --git a/src/sim/poach.ts b/src/sim/poach.ts new file mode 100644 index 0000000..6f880da --- /dev/null +++ b/src/sim/poach.ts @@ -0,0 +1,184 @@ +import { type HeatSource, newHeatSource, setKnob, heatStep } from './heatsource'; + +/** + * THE POACH — almost a whole game, and treated like one. + * + * Four acts, none skippable: + * + * THE WATER — the pot chases the knob on the fuel's lag (the heat-source + * bone). The poach wants the SHIVER — that band where the surface + * trembles but nothing rolls. Too cool and the white disperses into + * rags; a rolling boil and it tears the egg apart while it cooks. + * THE VORTEX — swirl the water first. A good vortex wraps the white around + * the yolk as it lands; drop into still water and the white walks off + * on its own. The vortex DECAYS — swirl, then drop, not swirl and + * admire. + * THE DROP — from low, into the eye of the spin. Height splats it. + * THE SET — white sets first, then the yolk starts to firm. Lift when the + * white is set and the yolk still trembles; every second past that is + * a second toward a boiled egg in disguise. Then DRAIN it on the + * spoon — nobody wants poach water on the plate. + * + * The wobble test at the pass reads yolkSet: the judge presses it, on camera. + * "It should tremble. Like you, right now." + * + * Pure — no three.js — the harness poaches headless. + */ + +/** The shiver: where a poach wants the water. Above BOIL it tears. */ +export const SHIVER_LO = 5.8; +export const SHIVER_HI = 7.4; +export const BOIL_AT = 8.2; + +/** White is SET past here (snotty below ~0.6). */ +export const WHITE_SET = 0.85; +/** Yolk still trembles below here; past it you've boiled an egg in disguise. */ +export const YOLK_TREMBLE = 0.35; + +/** How fast the vortex dies once you stop stirring. */ +const VORTEX_DECAY = 0.09; + +export interface PoachSession { + src: HeatSource; + /** Vortex strength 0..1 — built by swirling, decays on its own. */ + vortex: number; + dropped: boolean; + lifted: boolean; + /** 0 raw .. 1 fully set. */ + whiteSet: number; + /** 0 liquid .. 1 hard. The wobble test reads this. */ + yolkSet: number; + /** White lost to rags — still water, a high drop, or a rolling boil. */ + rags: number; + /** Poach water still clinging — drain on the spoon before the pass. */ + cling: number; + /** Vortex strength at the moment of the drop (the judged number). */ + vortexAtDrop: number; + tempAtDrop: number; + seconds: number; +} + +export function newPoachSession(fuel = 'gas'): PoachSession { + return { + src: newHeatSource(fuel), + vortex: 0, + dropped: false, + lifted: false, + whiteSet: 0, + yolkSet: 0, + rags: 0, + cling: 1, + vortexAtDrop: 0, + tempAtDrop: 0, + seconds: 0, + }; +} + +export function setPotKnob(s: PoachSession, k: number): void { + setKnob(s.src, k); +} + +/** Stir: add angular sweep (radians this frame) into the vortex. */ +export function swirl(s: PoachSession, sweep: number): void { + if (s.dropped) { + // Stirring a dropped egg is vandalism — it rags the white directly. + s.rags = Math.min(1, s.rags + Math.abs(sweep) * 0.06); + return; + } + s.vortex = Math.min(1, s.vortex + Math.abs(sweep) * 0.055); +} + +/** + * Drop the egg from `height` (0 rim-low .. 1 show-off). The vortex catches the + * white; still water and altitude both cost you form, immediately. + */ +export function dropEgg(s: PoachSession, height = 0): void { + if (s.dropped) return; + s.dropped = true; + s.vortexAtDrop = s.vortex; + s.tempAtDrop = s.src.output; + s.rags = Math.min(1, s.rags + (1 - s.vortex) * 0.45 + height * 0.4); + // Cold water at the drop lets the white walk before any heat can catch it. + if (s.src.output < SHIVER_LO) s.rags = Math.min(1, s.rags + (SHIVER_LO - s.src.output) * 0.12); +} + +/** Lift it out on the spoon. The set stops; the drain begins. */ +export function liftEgg(s: PoachSession): void { + if (s.dropped) s.lifted = true; +} + +/** Hold it on the spoon: cling drains away. Patience, again. */ +export function drainStep(s: PoachSession, dt: number): void { + if (s.lifted) s.cling = Math.max(0, s.cling - dt / 4); +} + +export function poachStep(s: PoachSession, dt: number): void { + heatStep(s.src, dt); + s.vortex = Math.max(0, s.vortex - VORTEX_DECAY * dt); + const t = s.src.output; + if (s.dropped && !s.lifted) { + // The white sets fastest in the shiver; cooler is slower (and it rags on + // the way in); a boil sets it fast but TEARS it the whole time. + const inBand = t >= SHIVER_LO && t <= SHIVER_HI; + const rate = inBand ? 0.055 : t > SHIVER_HI ? 0.07 : t > 4 ? 0.03 : 0.012; + s.whiteSet = Math.min(1, s.whiteSet + rate * dt); + if (t > BOIL_AT) s.rags = Math.min(1, s.rags + (t - BOIL_AT) * 0.045 * dt); + // The yolk only starts once the white is mostly there. + if (s.whiteSet > 0.75) s.yolkSet = Math.min(1, s.yolkSet + 0.035 * (inBand ? 1 : 1.4) * dt); + } + s.seconds += dt; +} + +export interface PoachResult { + /** Set, not snotty. */ + whiteScore: number; + /** The wobble: liquid yolk full marks, hard yolk none. */ + yolkScore: number; + /** One neat comet, not a pot of rags. */ + formScore: number; + /** Drained on the spoon, or served with its own puddle. */ + drainScore: number; + whiteSet: number; + yolkSet: number; + rags: number; + cling: number; + vortexAtDrop: number; + wobbleWord: string; +} + +const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v); + +export function poachResult(s: PoachSession): PoachResult { + const whiteScore = !s.dropped ? 0 : clamp01((s.whiteSet - 0.55) / (WHITE_SET - 0.55)); + const yolkScore = !s.dropped ? 0 : clamp01(1 - Math.max(0, s.yolkSet - YOLK_TREMBLE) / 0.4); + const formScore = clamp01(1 - s.rags * 1.3); + const drainScore = clamp01(1 - s.cling * 0.9); + const wobbleWord = + !s.dropped ? 'there is no egg' + : s.yolkSet < 0.15 && s.whiteSet < WHITE_SET ? 'it collapses — raw inside' + : s.yolkSet <= YOLK_TREMBLE ? 'it TREMBLES — perfect' + : s.yolkSet < 0.6 ? 'a stiff little nod' + : 'it does not move at all'; + return { + whiteScore, + yolkScore, + formScore, + drainScore, + whiteSet: s.whiteSet, + yolkSet: s.yolkSet, + rags: s.rags, + cling: s.cling, + vortexAtDrop: s.vortexAtDrop, + wobbleWord, + }; +} + +/** The water's diegetic tell — never a thermometer. */ +export function waterWord(s: PoachSession): string { + const t = s.src.output; + if (t < 3) return 'still water'; + if (t < SHIVER_LO) return 'warming — not yet'; + if (t <= SHIVER_HI) return 'a gentle SHIVER — now'; + if (t <= BOIL_AT) return 'simmering hard'; + return 'ROLLING — it will tear'; +}