The rib eye is playable: day 19, sear -> REST -> cut (it bleeds)

Wired steak.ts into a live day. scenes/steakboard.ts — the resting board:
the steak lands off the pan full of juice, DRAG across the grain (pale
fibre lines shown) to slice; cut it before it rests and a dark juice
puddle FLOODS the board. Day 19 routes pan-sear -> resting board -> judge
(pan's two seared faces carry the interior doneness). judgeSteak scores
THE STEAK (The Cook / The Rest / The Cut) + The Board, with a steakhouse-
grandfather line bank ('You cut it the second it left the pan. It bled out
on the board. I watched the whole thing.').

Verified live end-to-end: rested + across grain -> S (9.5); cut hot -> 'it
bled' -> 5.4; rested but along the grain -> 'chewy' -> 6.9. The three acts
each move the grade. Real-drag cutting confirmed (4 slices, flood 6.9).

Also fixed a latent crash: verdictLines could call fill() with an
undefined line when the best-scoring row (e.g. The Board) had no BANK
entry — now guarded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-19 23:05:45 +10:00
parent 848f4b3fcb
commit f81d1b5c82
7 changed files with 464 additions and 6 deletions

View File

@ -603,6 +603,37 @@ export function installDevHarness(app: App, game: Game): void {
return { moistureAtCut: q(moistureAtCut), flood: q(r.flood), rest: q(r.restScore), cut: q(r.cutScore), across: q(r.meanAcross), tears: r.tears, cook: q(r.cookScore) }; return { moistureAtCut: q(moistureAtCut), flood: q(r.flood), rest: q(r.restScore), cut: q(r.cutScore), across: q(r.meanAcross), tears: r.tears, cook: q(r.cookScore) };
}, },
/** Play the whole day-19 rib-eye order: sear on the pan, then rest (or don't)
* and cut across (or along) the grain on the board, serve. */
steakDay(opts: { rest?: boolean; alongGrain?: boolean } = {}) {
const { rest = true, alongGrain = false } = opts;
game.setDay(19);
run(6);
// Sear both faces on the pan (reuse the verified pan cook).
const pv = game.panView as unknown as { session: import('./sim/pan').PanSession };
const ps = pv.session;
if (ps) {
setPanKnob(ps, 7); addButter(ps);
let guard = 0; while (butterState(ps) !== 'foaming' && guard++ < 1800) run(1);
addFood(ps, 'rib_eye', 'A');
for (let i = 0; i < 60 * 8; i++) run(1);
flip(ps);
for (let i = 0; i < 60 * 8; i++) run(1);
tap('Enter'); run(6); // pan done → resting board
}
const sb = game.steakBoardView as unknown as { session: import('./sim/steak').SteakSession };
const ss = sb.session;
if (!ss) return { error: 'not at board', view: app.currentView === game.steakBoardView ? 'board' : 'other' };
// Rest (or cut hot), then cut 6 slices across (or along) the grain.
if (rest) for (let i = 0; i < 60 * 20; i++) run(1);
const angle = alongGrain ? ss.grainAxis : ss.grainAxis + Math.PI / 2;
for (let i = 0; i < 6; i++) steakCut(ss, angle, 0);
tap('Enter'); run(6);
const rows = [...document.querySelectorAll('.crit')].map((e) => e.textContent);
const gradeEl = document.querySelector('.judge-grade, .grade');
return { grade: gradeEl ? gradeEl.textContent : null, rows };
},
/** The marquee, side by side: cut it straight off the heat vs after a rest; /** The marquee, side by side: cut it straight off the heat vs after a rest;
* and across the grain vs along it. */ * and across the grain vs along it. */
steakMarquee() { steakMarquee() {

View File

@ -8,6 +8,7 @@ import { OvenView } from '../scenes/oven';
import { GrillView } from '../scenes/grill'; import { GrillView } from '../scenes/grill';
import { PanView } from '../scenes/pan'; import { PanView } from '../scenes/pan';
import { FridgeView } from '../scenes/fridge'; import { FridgeView } from '../scenes/fridge';
import { SteakBoardView } from '../scenes/steakboard';
import { serializeStore, restoreStore, fetch as fetchStock, usableQuality, type StoredItemSave } from '../sim/coldchain'; import { serializeStore, restoreStore, fetch as fetchStock, usableQuality, type StoredItemSave } from '../sim/coldchain';
import { AssemblyView } from '../scenes/assembly'; import { AssemblyView } from '../scenes/assembly';
import type { CutPattern } from '../sim/cutting'; import type { CutPattern } from '../sim/cutting';
@ -16,7 +17,7 @@ import { juiceCriteria, type JuiceResult } from '../sim/juicing';
import { TempClock } from '../sim/tempclock'; import { TempClock } from '../sim/tempclock';
import type { RoastResult } from '../sim/roasting'; import type { RoastResult } from '../sim/roasting';
import type { AssemblyResult } from '../sim/assembly'; import type { AssemblyResult } from '../sim/assembly';
import { judgeBruschetta, judgeGrill, judgePan, judgeFridge, type BruschettaResult, type PrepResult, type Verdict } from './judging'; import { judgeBruschetta, judgeGrill, judgePan, judgeFridge, judgeSteak, type BruschettaResult, type PrepResult, type Verdict } from './judging';
import { JudgeView } from '../scenes/judge'; import { JudgeView } from '../scenes/judge';
import { DrawerView } from '../scenes/drawer'; import { DrawerView } from '../scenes/drawer';
import { TOOLS, type ToolId } from '../sim/cutlery'; import { TOOLS, type ToolId } from '../sim/cutlery';
@ -57,6 +58,7 @@ export class Game {
private grill: GrillView; private grill: GrillView;
private pan: PanView; private pan: PanView;
private fridge: FridgeView; private fridge: FridgeView;
private steakBoard: SteakBoardView;
private assembly: AssemblyView; private assembly: AssemblyView;
/** What the prep bench reported for the current order, if it ran. */ /** What the prep bench reported for the current order, if it ran. */
private prepResult: PrepResult | null = null; private prepResult: PrepResult | null = null;
@ -94,6 +96,7 @@ export class Game {
this.grill = new GrillView(app); this.grill = new GrillView(app);
this.pan = new PanView(app); this.pan = new PanView(app);
this.fridge = new FridgeView(app); this.fridge = new FridgeView(app);
this.steakBoard = new SteakBoardView(app);
this.assembly = new AssemblyView(app); this.assembly = new AssemblyView(app);
app.scene.add(this.kitchen.root); app.scene.add(this.kitchen.root);
app.scene.add(this.judgeView.root); app.scene.add(this.judgeView.root);
@ -105,6 +108,7 @@ export class Game {
app.scene.add(this.grill.root); app.scene.add(this.grill.root);
app.scene.add(this.pan.root); app.scene.add(this.pan.root);
app.scene.add(this.fridge.root); app.scene.add(this.fridge.root);
app.scene.add(this.steakBoard.root);
app.scene.add(this.assembly.root); app.scene.add(this.assembly.root);
this.judgeView.root.visible = false; this.judgeView.root.visible = false;
this.drawer.root.visible = false; this.drawer.root.visible = false;
@ -115,6 +119,7 @@ export class Game {
this.grill.root.visible = false; this.grill.root.visible = false;
this.pan.root.visible = false; this.pan.root.visible = false;
this.fridge.root.visible = false; this.fridge.root.visible = false;
this.steakBoard.root.visible = false;
this.assembly.root.visible = false; this.assembly.root.visible = false;
this.ticket = new Panel('ticket'); this.ticket = new Panel('ticket');
@ -220,6 +225,9 @@ export class Game {
get fridgeView(): FridgeView { get fridgeView(): FridgeView {
return this.fridge; return this.fridge;
} }
get steakBoardView(): SteakBoardView {
return this.steakBoard;
}
get assemblyView(): AssemblyView { get assemblyView(): AssemblyView {
return this.assembly; return this.assembly;
} }
@ -389,6 +397,7 @@ export class Game {
this.grill.root.visible = false; this.grill.root.visible = false;
this.pan.root.visible = false; this.pan.root.visible = false;
this.fridge.root.visible = false; this.fridge.root.visible = false;
this.steakBoard.root.visible = false;
this.assembly.root.visible = false; this.assembly.root.visible = false;
} }
@ -527,9 +536,56 @@ export class Game {
this.enterFridge(); this.enterFridge();
return; return;
} }
// A steak day: sear on the pan, then rest + cut on the board.
if (this.order.steak) {
this.enterSteak();
return;
}
this.enterToastFlow(); this.enterToastFlow();
} }
/** The rib eye: sear it on the pan, carry the sear's doneness to the resting
* board, rest + cut, serve. Pan board judge, like the bruschetta chain. */
private enterSteak(): void {
const st = this.order.steak!;
this.pan.reset('rib_eye', st.fuel, 'sear the rib eye — both faces, foam the butter');
this.hideAllScenes();
this.pan.root.visible = true;
this.app.setView(this.pan);
this.ticket.show();
this.pan.onDone = (panResult) => {
this.pan.root.visible = false;
// The two seared faces stand in for the interior doneness carried to the board.
const cookDoneness = (panResult.downMean + panResult.upMean) / 2;
this.steakBoard.reset(cookDoneness, st.target, st.grain);
this.hideAllScenes();
this.steakBoard.root.visible = true;
this.app.setView(this.steakBoard);
this.ticket.show();
this.steakBoard.onDone = (r) => {
this.steakBoard.root.visible = false;
this.serveSteak(r);
};
};
}
private serveSteak(result: ReturnType<SteakBoardView['result']>): void {
this.timing = false;
const seconds = this.orderSeconds;
const slice = this.kitchen.currentSlice;
const v = judgeSteak(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 cold-chain day: stock the fridge right, age it, let the inspector look. */ /** The cold-chain day: stock the fridge right, age it, let the inspector look. */
private enterFridge(): void { private enterFridge(): void {
this.fridge.reset(); this.fridge.reset();

View File

@ -11,6 +11,7 @@ import type { GrillResult } from '../sim/charcoal';
import { grillWord } from '../sim/charcoal'; import { grillWord } from '../sim/charcoal';
import type { PanResult } from '../sim/pan'; import type { PanResult } from '../sim/pan';
import type { storeHealth } from '../sim/coldchain'; import type { storeHealth } from '../sim/coldchain';
import type { SteakResult } from '../sim/steak';
export interface Criterion { export interface Criterion {
key: string; key: string;
@ -23,7 +24,7 @@ export interface Criterion {
/** Which station earned it the scorecard groups by this on prep orders. /** Which station earned it the scorecard groups by this on prep orders.
* M15 adds the bruschetta trio: bread (cut+toast+rub), topping (roast, * M15 adds the bruschetta trio: bread (cut+toast+rub), topping (roast,
* distribution, balance, sog), bench (temperature + mess). */ * distribution, balance, sog), bench (temperature + mess). */
group?: 'prep' | 'toast' | 'spread' | 'bread' | 'topping' | 'bench' | 'grill' | 'pan' | 'cold'; group?: 'prep' | 'toast' | 'spread' | 'bread' | 'topping' | 'bench' | 'grill' | 'pan' | 'cold' | 'steak';
} }
/** What the prep bench hands the judge, when the order had prep on it. */ /** What the prep bench hands the judge, when the order had prep on it. */
@ -495,6 +496,65 @@ 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: [] }; 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.
export function judgeSteak(r: SteakResult, order: Order, seconds: number): Verdict {
const criteria: Criterion[] = [
{
key: 'steakcook',
group: 'steak',
label: 'The Cook',
score: clamp01(r.cookScore),
weight: 1.2,
detail: `${r.target} — interior ${Math.round(r.doneness * 100)}%`,
},
{
key: 'steakrest',
group: 'steak',
label: 'The Rest',
score: clamp01(r.restScore),
weight: 1.3,
detail: r.flood > 1.1 ? 'it bled — you cut it early' : r.flood > 0.2 ? 'a little weep' : 'rested, held its juice',
},
{
key: 'steakcut',
group: 'steak',
label: 'The Cut',
score: clamp01(r.cutScore),
weight: 1.2,
detail: r.meanAcross < 0.4 ? 'along the grain — chewy' : r.tears > 0 ? `${r.tears} torn, not sliced` : 'across the grain, clean',
},
{
key: 'steakboard',
group: 'bench',
label: 'The Board',
score: clamp01(1 - r.flood / 8),
weight: 0.6,
detail: r.flood > 0.5 ? 'juice all over the board' : 'a clean board',
},
{
key: 'time',
label: 'Service',
score: clamp01(1 - (seconds - 55) / 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 cold chain — the fridge day. After three days, how much of your delivery // The cold chain — the fridge day. After three days, how much of your delivery
// survived (placement/cold), did anything drip (hygiene), and what did you waste. // survived (placement/cold), did anything drip (hygiene), and what did you waste.

View File

@ -367,6 +367,40 @@ Object.assign(BANK, {
}, },
}); });
// The rib eye — the steakhouse grandfather who has watched a thousand cooks panic.
Object.assign(BANK, {
steakcook: {
bad: [
'You asked it a question and pulled it out before it answered. Grey in the middle, or raw — pick a lane.',
'The interior is a guess. Cooking a steak is not a guess.',
'Over. You cooked the life out of it and then kept going.',
],
good: ['Edge to edge, the doneness they asked for. That is a cook.', 'The interior is exactly right. You read it.'],
},
steakrest: {
bad: [
'You cut it the second it left the pan. It bled out on the board. I watched the whole thing.',
'No rest. All that juice is on the wood, not in the meat. That was the dinner, weeping.',
'Patience is the last ingredient and you left it out. It bled.',
],
good: [
'You let it rest. You held your nerve while it did nothing, and it kept every drop. THAT is the job.',
'Rested. Cut it and it held. A cook who can wait is a cook.',
],
},
steakcut: {
bad: [
'You cut along the grain. Every slice is a rope. Turn the knife ninety degrees, always.',
'Torn, not sliced. Steady the hand — a steak deserves one clean pass.',
'Chewy. You cut WITH the fibres. Across. Across the grain.',
],
good: [
'Across the grain, one clean pass each. Short fibres, tender. You know where the muscle runs.',
'Every slice across the grain, none torn. That is knife work.',
],
},
});
// The fetched ingredient — the cook who reads a use-by date. // The fetched ingredient — the cook who reads a use-by date.
Object.assign(BANK, { Object.assign(BANK, {
ingredient: { ingredient: {
@ -488,7 +522,10 @@ export function verdictLines(v: Verdict, order: Order, tool: string, rand: () =>
} }
if (v.best.score > 0.85 && v.best.key !== v.worst.key) { if (v.best.score > 0.85 && v.best.key !== v.worst.key) {
out.push(fill(pick(BANK[v.best.key]?.good ?? []), order, v, tool)); // Guard: a criterion with no BANK entry (or an empty good[]) must not push an
// undefined line into fill() — some rows (The Board, Service) carry no praise.
const good = BANK[v.best.key]?.good;
if (good && good.length) out.push(fill(pick(good), order, v, tool));
} }
out.push(pick(GRADE_CLOSERS[v.grade])); out.push(pick(GRADE_CLOSERS[v.grade]));

View File

@ -108,6 +108,9 @@ export interface Order {
/** The cold chain: a delivery to put away. Presence sends the day to the /** The cold chain: a delivery to put away. Presence sends the day to the
* fridge raw low, perishables cold, don't cram it, then three days pass. */ * fridge raw low, perishables cold, don't cram it, then three days pass. */
fridge?: boolean; fridge?: boolean;
/** The rib eye: sear it on the pan, then REST it and cut across the grain.
* `target` is the doneness asked; `grain` is the fibre direction to cut across. */
steak?: { fuel: string; target: 'rare' | 'medium' | 'well'; grain: number };
} }
export const BROWNING_NAMES: [number, string][] = [ export const BROWNING_NAMES: [number, string][] = [
@ -419,6 +422,21 @@ export const DAYS: Order[] = [
text: 'Pan-sear the mushrooms — the ones from your own fridge. Whatever shape they\'re in now is the shape you stored them in. Butter, foam, both faces. No excuses about the delivery; that was your fridge.', text: 'Pan-sear the mushrooms — the ones from your own fridge. Whatever shape they\'re in now is the shape you stored them in. Butter, foam, both faces. No excuses about the delivery; that was your fridge.',
pan: { food: FRIDGE_MUSHROOM_ID, fuel: 'gas', fromFridge: true }, pan: { food: FRIDGE_MUSHROOM_ID, fuel: 'gas', fromFridge: true },
}, },
{
day: 19,
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 man at table nine',
text: 'Rib eye, medium. Sear it in the pan, both faces, foam the butter. Then REST it — do not touch it, do not cut it, let it settle. And when you slice, go ACROSS the grain, clean. Cut it early and I will see it bleed.',
steak: { fuel: 'gas', target: 'medium', grain: 0.35 },
},
]; ];
/** Days beyond the script — keep going, with everything cranked. */ /** Days beyond the script — keep going, with everything cranked. */

View File

@ -107,7 +107,9 @@ export class JudgeView implements View {
// asked and what for, not the unread toast defaults. // asked and what for, not the unread toast defaults.
this.orderEl.textContent = order.grill this.orderEl.textContent = order.grill
? `Day ${order.day} · ${order.who} asked for the barbie` ? `Day ${order.day} · ${order.who} asked for the barbie`
: order.pan : order.steak
? `Day ${order.day} · ${order.who} asked for the rib eye`
: order.pan
? `Day ${order.day} · ${order.who} asked for the pan-sear` ? `Day ${order.day} · ${order.who} asked for the pan-sear`
: order.fridge : order.fridge
? `Day ${order.day} · the fridge, three days on` ? `Day ${order.day} · the fridge, three days on`
@ -121,7 +123,7 @@ export class JudgeView implements View {
// Station orders (prep, and M15's bruschetta trio) get the grouped card; // Station orders (prep, and M15's bruschetta trio) get the grouped card;
// a plain toast order keeps the flat card it always had. // a plain toast order keeps the flat card it always had.
const grouped = verdict.criteria.some( 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) => c.group === 'prep' || c.group === 'bread' || c.group === 'topping' || c.group === 'bench' || c.group === 'grill' || c.group === 'pan' || c.group === 'cold' || c.group === 'steak',
); );
const headers: Record<string, string> = { const headers: Record<string, string> = {
prep: 'THE PREP', prep: 'THE PREP',
@ -132,9 +134,10 @@ export class JudgeView implements View {
grill: 'THE GRILL', grill: 'THE GRILL',
pan: 'THE PAN', pan: 'THE PAN',
cold: 'THE FRIDGE', cold: 'THE FRIDGE',
steak: 'THE STEAK',
bench: 'THE BENCH', bench: 'THE BENCH',
}; };
const groupRank: Record<string, number> = { prep: 0, bread: 1, toast: 2, topping: 3, grill: 3, pan: 3, cold: 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, spread: 4, bench: 5 };
let lastGroup: string | undefined; let lastGroup: string | undefined;
const rank = (g?: string) => (g && g in groupRank ? groupRank[g] : 9); const rank = (g?: string) => (g && g in groupRank ? groupRank[g] : 9);
const ordered = grouped const ordered = grouped

253
src/scenes/steakboard.ts Normal file
View File

@ -0,0 +1,253 @@
import * as THREE from 'three';
import type { App, View } from '../core/app';
import { audio } from '../core/audio';
import {
type SteakSession,
newSteakSession,
restStep,
cut as steakCut,
finishSteak,
steakResult,
restWord,
type Doneness,
} from '../sim/steak';
import { el, Panel } from '../ui/hud';
const BOARD_Y = 0.06;
const STEAK_W = 1.9;
const STEAK_D = 1.3;
/**
* THE RESTING BOARD the nerve test, then the cut.
*
* The steak lands here straight off the pan, full of juice. DRAG across it to
* slice but cut it now and it BLEEDS all over the board. Let it REST first
* (the meat relaxes; there's no timer, just the steak) and it barely weeps.
* And cut ACROSS the grain the pale fibre lines show which way they run
* not along it, and keep the knife steady or you'll tear it. ENTER serves.
*/
export class SteakBoardView implements View {
readonly root = new THREE.Group();
private session: SteakSession | null = null;
private steak!: THREE.Mesh;
private grainLines: THREE.Line[] = [];
private seams: THREE.Mesh[] = [];
private puddle!: THREE.Mesh;
private knife!: THREE.Mesh;
private ray = new THREE.Raycaster();
private hit = new THREE.Vector3();
private plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -(BOARD_Y * 2 + 0.08));
private strokeStart: THREE.Vector3 | null = null;
private strokePath: THREE.Vector3[] = [];
private wasDown = false;
private panel: Panel;
private askEl!: HTMLElement;
private stateEl!: HTMLElement;
private wordEl!: HTMLElement;
private hintEl!: HTMLElement;
onDone: ((result: ReturnType<typeof steakResult>) => void) | null = null;
constructor(private app: App) {
this.buildScenery();
this.panel = new Panel('steak-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, 'REST IT, THEN CUT');
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: 0x4a3524, roughness: 0.85 }),
);
bench.position.y = -0.2;
bench.receiveShadow = true;
this.root.add(bench);
const board = new THREE.Mesh(
new THREE.BoxGeometry(STEAK_W + 1, BOARD_Y * 2, STEAK_D + 0.8),
new THREE.MeshStandardMaterial({ color: 0x8a5a34, roughness: 0.7 }),
);
board.position.y = BOARD_Y;
board.receiveShadow = true;
this.root.add(board);
// The juice puddle: a dark-red disc that grows with the flood, under the steak.
this.puddle = new THREE.Mesh(
new THREE.CircleGeometry(1, 32),
new THREE.MeshBasicMaterial({ color: 0x5a0f0a, transparent: true, opacity: 0.0, depthWrite: false }),
);
this.puddle.rotation.x = -Math.PI / 2;
this.puddle.position.y = BOARD_Y * 2 + 0.005;
this.puddle.scale.setScalar(0.01);
this.root.add(this.puddle);
// The steak slab.
this.steak = new THREE.Mesh(
new THREE.BoxGeometry(STEAK_W, 0.22, STEAK_D),
new THREE.MeshStandardMaterial({ color: 0x6e2b22, roughness: 0.55 }),
);
this.steak.position.y = BOARD_Y * 2 + 0.11;
this.steak.castShadow = true;
this.root.add(this.steak);
// The knife, following the cursor.
this.knife = new THREE.Mesh(
new THREE.BoxGeometry(1.4, 0.04, 0.12),
new THREE.MeshStandardMaterial({ color: 0xcdd2d6, roughness: 0.3, metalness: 0.8 }),
);
this.knife.position.y = BOARD_Y * 2 + 0.4;
this.root.add(this.knife);
}
/** A steak fresh off the pan: its interior doneness, the ordered target, and
* a fibre direction to cut across. */
reset(cookDoneness: number, target: Doneness = 'medium', grainAxis = 0.35, askText = 'let it rest — then cut across the grain'): void {
this.session = newSteakSession(cookDoneness, target, grainAxis);
for (const l of this.grainLines) this.root.remove(l);
for (const s of this.seams) this.root.remove(s);
this.grainLines = [];
this.seams = [];
this.strokeStart = null;
(this.puddle.material as THREE.MeshBasicMaterial).opacity = 0;
this.puddle.scale.setScalar(0.01);
// Draw the grain: pale parallel fibre lines at grainAxis across the top face.
const y = BOARD_Y * 2 + 0.23;
const mat = new THREE.LineBasicMaterial({ color: 0x9a6a5a, transparent: true, opacity: 0.7 });
const dir = new THREE.Vector2(Math.cos(grainAxis), Math.sin(grainAxis));
const perp = new THREE.Vector2(-dir.y, dir.x);
for (let i = -6; i <= 6; i++) {
const off = perp.clone().multiplyScalar(i * 0.13);
const a = new THREE.Vector3(off.x - dir.x * 1.2, y, off.y - dir.y * 1.2);
const b = new THREE.Vector3(off.x + dir.x * 1.2, y, off.y + dir.y * 1.2);
const g = new THREE.BufferGeometry().setFromPoints([a, b]);
const line = new THREE.Line(g, mat);
// Clip to the steak footprint by hiding lines whose midpoint is off-slab.
if (Math.abs(off.x) < STEAK_W / 2 && Math.abs(off.y) < STEAK_D / 2) {
this.grainLines.push(line);
this.root.add(line);
}
}
this.askEl.textContent = askText;
this.hintEl.textContent = 'wait for it to REST · DRAG across the grain to slice · steady hands · ENTER serves';
}
enter(): void {
this.panel.show();
}
exit(): void {
this.panel.hide();
}
update(dt: number): void {
this.app.camera.position.set(0, 3.0, 2.6);
this.app.camera.lookAt(0, BOARD_Y, 0.05);
const s = this.session;
if (!s) return;
const inp = this.app.input;
restStep(s, dt);
this.ray.setFromCamera(inp.ndc, this.app.camera);
const on = this.ray.ray.intersectPlane(this.plane, this.hit);
if (on) {
this.knife.position.set(this.hit.x, BOARD_Y * 2 + 0.4, this.hit.z);
}
const overSteak = on && Math.abs(this.hit.x) < STEAK_W / 2 + 0.2 && Math.abs(this.hit.z) < STEAK_D / 2 + 0.2;
if (inp.down && overSteak) {
if (!this.wasDown) {
this.strokeStart = this.hit.clone();
this.strokePath = [this.hit.clone()];
} else if (this.strokeStart) {
this.strokePath.push(this.hit.clone());
}
}
if (!inp.down && this.wasDown && this.strokeStart) {
this.commitStroke();
this.strokeStart = null;
}
this.wasDown = inp.down;
// The puddle grows with the flood — capped for the eye.
const flood = Math.min(1, s.flood / 6);
const pm = this.puddle.material as THREE.MeshBasicMaterial;
pm.opacity = Math.min(0.8, flood * 0.9);
this.puddle.scale.setScalar(0.4 + flood * 1.4);
// Knife tilts to the current stroke's implied angle when steady.
this.updateHud();
if (inp.justPressed('Enter')) {
finishSteak(s);
const cb = this.onDone;
this.onDone = null;
cb?.(steakResult(s));
}
}
private commitStroke(): void {
const s = this.session!;
const start = this.strokeStart!;
const end = this.hit;
const dx = end.x - start.x;
const dz = end.z - start.z;
const len = Math.hypot(dx, dz);
if (len < 0.35) return; // a tap, not a slice
const angle = Math.atan2(dz, dx);
// Wobble: how much the path deviated from the straight start→end line.
let dev = 0;
for (const p of this.strokePath) {
const t = ((p.x - start.x) * dx + (p.z - start.z) * dz) / (len * len);
const px = start.x + dx * t;
const pz = start.z + dz * t;
dev = Math.max(dev, Math.hypot(p.x - px, p.z - pz));
}
const wobble = Math.min(1, dev / 0.25);
const c = steakCut(s, angle, wobble);
audio.clunk(false);
if (c.torn) audio.clatter(0.5, 0.7);
// Draw the slice seam along the stroke.
const seam = new THREE.Mesh(
new THREE.BoxGeometry(len, 0.24, 0.02),
new THREE.MeshStandardMaterial({ color: 0x3a1712, roughness: 1 }),
);
seam.position.set((start.x + end.x) / 2, BOARD_Y * 2 + 0.11, (start.z + end.z) / 2);
seam.rotation.y = -angle;
this.root.add(seam);
this.seams.push(seam);
}
private updateHud(): void {
const s = this.session!;
const r = steakResult(s);
this.stateEl.textContent = `${s.cuts.length} slice${s.cuts.length === 1 ? '' : 's'}${s.flood > 0.5 ? ` · ${Math.round(Math.min(100, s.flood * 15))}% bled out` : ''}`;
if (s.cuts.length === 0) {
this.wordEl.textContent = restWord(s);
this.wordEl.style.color = s.moisture > 0.72 ? '#e2603a' : s.moisture < 0.66 ? '#8fce8f' : '';
} else {
const word = r.flood > 1.1 ? 'it bled — you cut it early' : r.meanAcross < 0.4 ? 'along the grain — chewy' : r.tears > 0 ? 'torn, not sliced' : 'across, clean, rested';
this.wordEl.textContent = word;
this.wordEl.style.color = r.cutScore > 0.7 && r.restScore > 0.7 ? '#8fce8f' : '#e2603a';
}
}
result() {
return this.session ? steakResult(this.session) : { cookScore: 0, restScore: 0, cutScore: 0, doneness: 0, target: 'medium' as const, flood: 0, meanAcross: 0, tears: 0 };
}
dispose(): void {
this.panel.dispose();
}
}