Compare commits
2 Commits
afe2bf174b
...
f81d1b5c82
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f81d1b5c82 | ||
|
|
848f4b3fcb |
61
src/dev.ts
61
src/dev.ts
@ -9,6 +9,7 @@ import { Rng } from './core/rng';
|
||||
import { newGrillSession, bankCoals, placeFood, grillStep, grillResult, bedCeiling } from './sim/charcoal';
|
||||
import { newPanSession, setPanKnob, addButter, addFood, flip, baste, panStep, panResult, butterState } from './sim/pan';
|
||||
import { newColdStore, store, coldStep, pull, freshnessWord, usableQuality, storeHealth } from './sim/coldchain';
|
||||
import { newSteakSession, restStep, cut as steakCut, finishSteak, steakResult, type Doneness } from './sim/steak';
|
||||
|
||||
/**
|
||||
* Dev harness. The game is driven by mouse gestures over a 3D scene, which makes
|
||||
@ -585,6 +586,66 @@ export function installDevHarness(app: App, game: Game): void {
|
||||
return { grade: gradeEl ? gradeEl.textContent : null, groups, rows };
|
||||
},
|
||||
|
||||
// ---- THE RIB EYE: rest clock (the flood) + the grain-aware cut. ----
|
||||
|
||||
/** Rest for `restS` seconds, then cut `n` slices at `angle` off the grain
|
||||
* (0 = along, PI/2 = across). Cut hot (restS 0) and it FLOODS; rest and it
|
||||
* whispers. Returns the flood + cut scores. */
|
||||
steak(opts: { restS?: number; n?: number; angle?: number; wobble?: number; grain?: number; cook?: number; target?: Doneness } = {}) {
|
||||
const { restS = 20, n = 6, angle = Math.PI / 2, wobble = 0, grain = 0, cook = 0.55, target = 'medium' } = opts;
|
||||
const s = newSteakSession(cook, target, grain);
|
||||
for (let i = 0; i < Math.round(restS * 60); i++) restStep(s, 1 / 60);
|
||||
const moistureAtCut = s.moisture;
|
||||
for (let i = 0; i < n; i++) steakCut(s, angle, wobble);
|
||||
finishSteak(s);
|
||||
const r = steakResult(s);
|
||||
const q = (v: number) => +v.toFixed(3);
|
||||
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() {
|
||||
return {
|
||||
cutHot: harness.steak({ restS: 0, angle: Math.PI / 2 }),
|
||||
rested: harness.steak({ restS: 20, angle: Math.PI / 2 }),
|
||||
acrossGrain: harness.steak({ restS: 20, angle: Math.PI / 2, grain: 0 }),
|
||||
alongGrain: harness.steak({ restS: 20, angle: 0, grain: 0 }),
|
||||
wobbled: harness.steak({ restS: 20, angle: Math.PI / 2, wobble: 0.6 }),
|
||||
};
|
||||
},
|
||||
|
||||
// ---- THE COLD CHAIN: spoilage by zone, rotation waste, overpack, contamination. ----
|
||||
|
||||
/** Same fish stored in each zone for `days` — freshness must fall fast on the
|
||||
|
||||
@ -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<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. */
|
||||
private enterFridge(): void {
|
||||
this.fridge.reset();
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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]));
|
||||
|
||||
@ -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. */
|
||||
|
||||
@ -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<string, string> = {
|
||||
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<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;
|
||||
const rank = (g?: string) => (g && g in groupRank ? groupRank[g] : 9);
|
||||
const ordered = grouped
|
||||
|
||||
253
src/scenes/steakboard.ts
Normal file
253
src/scenes/steakboard.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
135
src/sim/steak.ts
Normal file
135
src/sim/steak.ts
Normal file
@ -0,0 +1,135 @@
|
||||
/**
|
||||
* THE RIB EYE — cook it, REST it, cut it right.
|
||||
*
|
||||
* A steak comes off the pan full of juice (moisture ~0.95) and every fibre
|
||||
* clenched. Cut it now and it bleeds out onto the board — a flood the judge
|
||||
* watches happen. Let it REST and the juice redistributes (moisture relaxes
|
||||
* toward ~0.6); cut it then and it's a whisper. There is no timer on screen —
|
||||
* the steak tells you, if you know how to look, and the flood tells the judge.
|
||||
*
|
||||
* Then the cut itself: a steak has GRAIN, long muscle fibres running one way.
|
||||
* Cut ACROSS them (perpendicular) and each slice is short-fibred and tender;
|
||||
* cut ALONG them and it's a rope. Wobble the knife and you tear rather than
|
||||
* slice.
|
||||
*
|
||||
* Three things judged: THE COOK (did the interior hit the doneness asked), THE
|
||||
* REST (did you hold your nerve — measured by the flood), THE CUT (across the
|
||||
* grain, evenly, no tearing).
|
||||
*
|
||||
* Pure — no three.js — so the harness rests and cuts headless.
|
||||
*/
|
||||
|
||||
/** A rested steak settles here; the gap above it is the juice waiting to flood. */
|
||||
export const RESTED_MOISTURE = 0.6;
|
||||
/** Off the heat, this full. */
|
||||
export const HOT_MOISTURE = 0.95;
|
||||
/** Seconds to fully rest (moisture HOT → RESTED). */
|
||||
const REST_SECONDS = 20;
|
||||
/** How hard a cut through juicy meat bleeds (per slice, per unit over rested). */
|
||||
const BLEED_GAIN = 5.0;
|
||||
|
||||
export type Doneness = 'rare' | 'medium' | 'well';
|
||||
/** Interior doneness target windows (0 raw .. 1 grey). */
|
||||
export const DONENESS_TARGET: Record<Doneness, number> = { rare: 0.35, medium: 0.55, well: 0.78 };
|
||||
|
||||
export interface SteakCut {
|
||||
/** |sin(cut − grain)| — 1 is dead across the grain, 0 is along it. */
|
||||
across: number;
|
||||
/** Tore instead of sliced (knife wobble through clenched fibre). */
|
||||
torn: boolean;
|
||||
/** Moisture at the moment of this cut — how juicy it still was. */
|
||||
moisture: number;
|
||||
}
|
||||
|
||||
export interface SteakSession {
|
||||
/** Interior doneness the sear left (fed from the pan). */
|
||||
cookDoneness: number;
|
||||
target: Doneness;
|
||||
/** Fibre direction, radians. Cutting is scored against it. */
|
||||
grainAxis: number;
|
||||
/** 0.95 hot .. 0.6 rested. Only falls, on the rest clock. */
|
||||
moisture: number;
|
||||
restedSeconds: number;
|
||||
cuts: SteakCut[];
|
||||
/** Juice that hit the board — the nerve test, made visible. */
|
||||
flood: number;
|
||||
phase: 'resting' | 'cutting' | 'done';
|
||||
}
|
||||
|
||||
export function newSteakSession(cookDoneness: number, target: Doneness = 'medium', grainAxis = 0.4): SteakSession {
|
||||
return {
|
||||
cookDoneness,
|
||||
target,
|
||||
grainAxis,
|
||||
moisture: HOT_MOISTURE,
|
||||
restedSeconds: 0,
|
||||
cuts: [],
|
||||
flood: 0,
|
||||
phase: 'resting',
|
||||
};
|
||||
}
|
||||
|
||||
/** Rest one tick: the juice settles back in, moisture relaxes toward rested. */
|
||||
export function restStep(s: SteakSession, dt: number): void {
|
||||
if (s.phase !== 'resting') return;
|
||||
s.restedSeconds += dt;
|
||||
const t = Math.min(1, s.restedSeconds / REST_SECONDS);
|
||||
s.moisture = HOT_MOISTURE - (HOT_MOISTURE - RESTED_MOISTURE) * t;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make one cut at `angle` (radians) with `wobble` (0 steady .. 1 shaky). The
|
||||
* juice it releases depends on how rested the meat is — cut it hot and it FLOODS.
|
||||
* Cutting commits the steak to the cutting phase (resting is over once you start).
|
||||
*/
|
||||
export function cut(s: SteakSession, angle: number, wobble = 0): SteakCut {
|
||||
if (s.phase === 'done') return { across: 0, torn: false, moisture: s.moisture };
|
||||
s.phase = 'cutting';
|
||||
const across = Math.abs(Math.sin(angle - s.grainAxis));
|
||||
const torn = wobble > 0.45;
|
||||
const bleed = Math.max(0, s.moisture - RESTED_MOISTURE) * BLEED_GAIN;
|
||||
s.flood += bleed;
|
||||
const c: SteakCut = { across, torn, moisture: s.moisture };
|
||||
s.cuts.push(c);
|
||||
return c;
|
||||
}
|
||||
|
||||
export function finishSteak(s: SteakSession): void {
|
||||
s.phase = 'done';
|
||||
}
|
||||
|
||||
export interface SteakResult {
|
||||
/** How close the interior came to the doneness asked. */
|
||||
cookScore: number;
|
||||
/** The nerve: 1 if you rested it, dropping with the flood. */
|
||||
restScore: number;
|
||||
/** Across the grain and clean, or a torn rope. */
|
||||
cutScore: number;
|
||||
doneness: number;
|
||||
target: Doneness;
|
||||
flood: number;
|
||||
meanAcross: number;
|
||||
tears: number;
|
||||
}
|
||||
|
||||
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);
|
||||
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 };
|
||||
}
|
||||
|
||||
/** The live tell for the resting steak — no timer, just the meat. */
|
||||
export function restWord(s: SteakSession): string {
|
||||
if (s.moisture > 0.85) return 'straight off the heat — let it REST';
|
||||
if (s.moisture > 0.72) return 'still tight, still bleeding';
|
||||
if (s.moisture > 0.66) return 'nearly there…';
|
||||
return 'rested — cut it now';
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user