diff --git a/src/dev.ts b/src/dev.ts index cc9afcb..b926862 100644 --- a/src/dev.ts +++ b/src/dev.ts @@ -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) }; }, + /** 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; * and across the grain vs along it. */ steakMarquee() { diff --git a/src/game/game.ts b/src/game/game.ts index 2643de5..fc2322c 100644 --- a/src/game/game.ts +++ b/src/game/game.ts @@ -8,6 +8,7 @@ import { OvenView } from '../scenes/oven'; import { GrillView } from '../scenes/grill'; import { PanView } from '../scenes/pan'; import { FridgeView } from '../scenes/fridge'; +import { SteakBoardView } from '../scenes/steakboard'; import { serializeStore, restoreStore, fetch as fetchStock, usableQuality, type StoredItemSave } from '../sim/coldchain'; import { AssemblyView } from '../scenes/assembly'; import type { CutPattern } from '../sim/cutting'; @@ -16,7 +17,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, 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 { DrawerView } from '../scenes/drawer'; import { TOOLS, type ToolId } from '../sim/cutlery'; @@ -57,6 +58,7 @@ export class Game { private grill: GrillView; private pan: PanView; private fridge: FridgeView; + private steakBoard: SteakBoardView; private assembly: AssemblyView; /** What the prep bench reported for the current order, if it ran. */ private prepResult: PrepResult | null = null; @@ -94,6 +96,7 @@ export class Game { this.grill = new GrillView(app); this.pan = new PanView(app); this.fridge = new FridgeView(app); + this.steakBoard = new SteakBoardView(app); this.assembly = new AssemblyView(app); app.scene.add(this.kitchen.root); app.scene.add(this.judgeView.root); @@ -105,6 +108,7 @@ export class Game { app.scene.add(this.grill.root); app.scene.add(this.pan.root); app.scene.add(this.fridge.root); + app.scene.add(this.steakBoard.root); app.scene.add(this.assembly.root); this.judgeView.root.visible = false; this.drawer.root.visible = false; @@ -115,6 +119,7 @@ export class Game { this.grill.root.visible = false; this.pan.root.visible = false; this.fridge.root.visible = false; + this.steakBoard.root.visible = false; this.assembly.root.visible = false; this.ticket = new Panel('ticket'); @@ -220,6 +225,9 @@ export class Game { get fridgeView(): FridgeView { return this.fridge; } + get steakBoardView(): SteakBoardView { + return this.steakBoard; + } get assemblyView(): AssemblyView { return this.assembly; } @@ -389,6 +397,7 @@ export class Game { this.grill.root.visible = false; this.pan.root.visible = false; this.fridge.root.visible = false; + this.steakBoard.root.visible = false; this.assembly.root.visible = false; } @@ -527,9 +536,56 @@ export class Game { this.enterFridge(); return; } + // A steak day: sear on the pan, then rest + cut on the board. + if (this.order.steak) { + this.enterSteak(); + return; + } 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): 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. */ private enterFridge(): void { this.fridge.reset(); diff --git a/src/game/judging.ts b/src/game/judging.ts index a0af247..e6b32fd 100644 --- a/src/game/judging.ts +++ b/src/game/judging.ts @@ -11,6 +11,7 @@ import type { GrillResult } from '../sim/charcoal'; import { grillWord } from '../sim/charcoal'; import type { PanResult } from '../sim/pan'; import type { storeHealth } from '../sim/coldchain'; +import type { SteakResult } from '../sim/steak'; export interface Criterion { key: string; @@ -23,7 +24,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'; + 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. */ @@ -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: [] }; } +// ===================================================================== +// 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 // survived (placement/cold), did anything drip (hygiene), and what did you waste. diff --git a/src/game/lines.ts b/src/game/lines.ts index 0847143..cd143b1 100644 --- a/src/game/lines.ts +++ b/src/game/lines.ts @@ -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. Object.assign(BANK, { 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) { - 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])); diff --git a/src/game/orders.ts b/src/game/orders.ts index 71ecc1e..8dfb4a4 100644 --- a/src/game/orders.ts +++ b/src/game/orders.ts @@ -108,6 +108,9 @@ export interface Order { /** 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?: 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][] = [ @@ -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.', 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. */ diff --git a/src/scenes/judge.ts b/src/scenes/judge.ts index f7e8643..fdca047 100644 --- a/src/scenes/judge.ts +++ b/src/scenes/judge.ts @@ -107,7 +107,9 @@ 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.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` : order.fridge ? `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; // 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) => 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 = { prep: 'THE PREP', @@ -132,9 +134,10 @@ export class JudgeView implements View { grill: 'THE GRILL', pan: 'THE PAN', cold: 'THE FRIDGE', + steak: 'THE STEAK', bench: 'THE BENCH', }; - const groupRank: Record = { prep: 0, bread: 1, toast: 2, topping: 3, grill: 3, pan: 3, cold: 3, spread: 4, bench: 5 }; + const groupRank: Record = { prep: 0, bread: 1, toast: 2, topping: 3, grill: 3, pan: 3, cold: 3, steak: 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/steakboard.ts b/src/scenes/steakboard.ts new file mode 100644 index 0000000..a822774 --- /dev/null +++ b/src/scenes/steakboard.ts @@ -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) => 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(); + } +}