diff --git a/src/game/game.ts b/src/game/game.ts index fc2322c..7ff7d99 100644 --- a/src/game/game.ts +++ b/src/game/game.ts @@ -491,6 +491,7 @@ export class Game { this.orderSeconds = 0; this.prepResult = null; this.juiceResult = null; + this.panIngredient = null; // a fetched-mushroom day's ingredient never leaks forward this.lastBruschetta = null; this.prepQueue = o.prep ? [...o.prep] : []; this.timing = true; @@ -557,7 +558,7 @@ export class Game { 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.steakBoard.reset(cookDoneness, st.target, st.grain, panResult.sideGap); this.hideAllScenes(); this.steakBoard.root.visible = true; this.app.setView(this.steakBoard); diff --git a/src/scenes/steakboard.ts b/src/scenes/steakboard.ts index a822774..30c2584 100644 --- a/src/scenes/steakboard.ts +++ b/src/scenes/steakboard.ts @@ -32,6 +32,7 @@ export class SteakBoardView implements View { private session: SteakSession | null = null; private steak!: THREE.Mesh; private grainLines: THREE.Line[] = []; + private grainMat: THREE.LineBasicMaterial | null = null; private seams: THREE.Mesh[] = []; private puddle!: THREE.Mesh; private knife!: THREE.Mesh; @@ -114,10 +115,9 @@ export class SteakBoardView implements View { /** 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); + reset(cookDoneness: number, target: Doneness = 'medium', grainAxis = 0.35, sideGap = 0, askText = 'let it rest — then cut across the grain'): void { + this.session = newSteakSession(cookDoneness, target, grainAxis, sideGap); + this.disposeSlab(); this.grainLines = []; this.seams = []; this.strokeStart = null; @@ -127,6 +127,7 @@ export class SteakBoardView implements View { // 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 }); + this.grainMat = mat; 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++) { @@ -181,15 +182,16 @@ export class SteakBoardView implements View { } this.wasDown = inp.down; - // The puddle grows with the flood — capped for the eye. - const flood = Math.min(1, s.flood / 6); + // The puddle tracks the SCORE penalty — saturate as the rest is destroyed + // (same /1.1 reference the judge uses), so what you see is what he tastes. + const bled = 1 - steakResult(s).restScore; 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. + pm.opacity = Math.min(0.82, bled * 0.9); + this.puddle.scale.setScalar(0.4 + bled * 1.5); this.updateHud(); - if (inp.justPressed('Enter')) { + // No serving an uncut steak — the nerve test has to actually be taken. + if (inp.justPressed('Enter') && s.cuts.length > 0) { finishSteak(s); const cb = this.onDone; this.onDone = null; @@ -197,6 +199,22 @@ export class SteakBoardView implements View { } } + /** Free the round's grain lines and slice seams (removed on reset/teardown — + * three.js only frees GPU buffers on an explicit dispose). */ + private disposeSlab(): void { + for (const l of this.grainLines) { + this.root.remove(l); + l.geometry.dispose(); + } + this.grainMat?.dispose(); + this.grainMat = null; + for (const s of this.seams) { + this.root.remove(s); + s.geometry.dispose(); + (s.material as THREE.Material).dispose(); + } + } + private commitStroke(): void { const s = this.session!; const start = this.strokeStart!; @@ -232,22 +250,26 @@ export class SteakBoardView implements View { 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` : ''}`; + // "% bled out" is the REST penalty the judge will read — 100% when the rest + // score is destroyed — not a raw flood number on a different scale. + const bled = Math.round((1 - r.restScore) * 100); + this.stateEl.textContent = `${s.cuts.length} slice${s.cuts.length === 1 ? '' : 's'}${s.cuts.length && bled > 5 ? ` · ${bled}% 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'; + const word = r.restScore < 0.5 ? '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 }; + return this.session ? steakResult(this.session) : { cookScore: 0, restScore: 0, cutScore: 0, doneness: 0, target: 'medium' as const, sideGap: 0, flood: 0, meanAcross: 0, tears: 0 }; } dispose(): void { + this.disposeSlab(); this.panel.dispose(); } } diff --git a/src/sim/steak.ts b/src/sim/steak.ts index 0b59aeb..3f2fa1d 100644 --- a/src/sim/steak.ts +++ b/src/sim/steak.ts @@ -45,6 +45,8 @@ export interface SteakSession { /** Interior doneness the sear left (fed from the pan). */ cookDoneness: number; target: Doneness; + /** |downMean − upMean| from the pan — one face burnt, one raw drags The Cook. */ + sideGap: number; /** Fibre direction, radians. Cutting is scored against it. */ grainAxis: number; /** 0.95 hot .. 0.6 rested. Only falls, on the rest clock. */ @@ -56,10 +58,11 @@ export interface SteakSession { phase: 'resting' | 'cutting' | 'done'; } -export function newSteakSession(cookDoneness: number, target: Doneness = 'medium', grainAxis = 0.4): SteakSession { +export function newSteakSession(cookDoneness: number, target: Doneness = 'medium', grainAxis = 0.4, sideGap = 0): SteakSession { return { cookDoneness, target, + sideGap, grainAxis, moisture: HOT_MOISTURE, restedSeconds: 0, @@ -99,14 +102,15 @@ export function finishSteak(s: SteakSession): void { } export interface SteakResult { - /** How close the interior came to the doneness asked. */ + /** How close the interior came to the doneness asked (and how even the sear). */ cookScore: number; - /** The nerve: 1 if you rested it, dropping with the flood. */ + /** The nerve: 1 if you rested it, dropping the juicier you cut it. */ restScore: number; /** Across the grain and clean, or a torn rope. */ cutScore: number; doneness: number; target: Doneness; + sideGap: number; flood: number; meanAcross: number; tears: number; @@ -116,14 +120,18 @@ const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v); export function steakResult(s: SteakSession): SteakResult { const tgt = DONENESS_TARGET[s.target]; - const cookScore = clamp01(1 - Math.abs(s.cookDoneness - tgt) / 0.22); - // The rest is the flood: a whisper of juice is a well-rested steak; a puddle - // is a steak you couldn't wait for. - const restScore = clamp01(1 - s.flood / 1.1); + // The cook is the interior doneness AND the evenness — one face charred, one + // raw averages to the right number but isn't the right steak. + const cookScore = clamp01(1 - Math.abs(s.cookDoneness - tgt) / 0.22 - s.sideGap * 0.6); + // The rest reads the juice released PER CUT (i.e. the moisture at the moment you + // committed to cutting) — not the summed flood, so slicing more doesn't fake a + // worse rest, and an uncut steak earns no rest credit at all. + const perCut = s.cuts.length ? s.flood / s.cuts.length : 0; + const restScore = s.cuts.length ? clamp01(1 - perCut / 1.1) : 0; const meanAcross = s.cuts.length ? s.cuts.reduce((a, c) => a + c.across, 0) / s.cuts.length : 0; const tears = s.cuts.filter((c) => c.torn).length; const cutScore = clamp01(meanAcross - (s.cuts.length ? tears / s.cuts.length : 0) * 0.8); - return { cookScore, restScore, cutScore, doneness: s.cookDoneness, target: s.target, flood: s.flood, meanAcross, tears }; + return { cookScore, restScore, cutScore, doneness: s.cookDoneness, target: s.target, sideGap: s.sideGap, flood: s.flood, meanAcross, tears }; } /** The live tell for the resting steak — no timer, just the meat. */