THE POACH: day 21 — the shiver, the vortex, the drop, the wobble test
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 <noreply@anthropic.com>
This commit is contained in:
parent
717ef56150
commit
bffee40c6f
44
src/dev.ts
44
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
|
||||
|
||||
@ -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<PoachView['result']>): 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!;
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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<string, string> = {
|
||||
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<string, number> = { 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<string, number> = { 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
|
||||
|
||||
343
src/scenes/poach.ts
Normal file
343
src/scenes/poach.ts
Normal file
@ -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<typeof poachResult>) => 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();
|
||||
}
|
||||
}
|
||||
184
src/sim/poach.ts
Normal file
184
src/sim/poach.ts
Normal file
@ -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';
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user