toastsim/src/scenes/prep.ts
monster faf196657e M11 WIP: the prep bench core — one board, and it remembers
Code-complete and verified headless; NOT wired into a day yet (see brief §0.5).

The core, all reusing the M0-M10 bones:
- sim/ingredients.ts: the cast (tomato/orange/onion/garlic/parmesan/brie/
  cucumber/avocado/roast_tomato) with shape, skin slip, flesh moisture+layers,
  seeds, sting, temperature needs, gratability.
- sim/cutting.ts: CutSession = ingredient + knife + pattern (halve/slices/
  wedges/dice), generalizing the M10 slicing sim. New physics: slip (press a
  flat blade on slippery skin and it skates, the aim jumps, juice sprays) and
  juice (moisture x speed, so deliberate leaks and frantic hoses).
- sim/mess.ts: the bench is judged. Field over the board UV + a solids list;
  the wipe is the butter brush in reverse and mass-conserving, so one pass
  smears and the second cleans.
- scenes/prep.ts: aim/bite/saw, deepening seams, pieces fan out, juice stain
  texture, instanced solids, live 'bench: clean/smeared/a crime scene'.
- judging.ts: The Pieces (CV, w1.3) + The Bench (w0.9); clarkEvans extracted
  for M15's toppings; criteria grouped by station and the card grouped with it.
- lines.ts: 8 lines each, in his voice.

Measured, not asserted:
- Even deliberate slices -> CV ~0, bench mass 0.51.
- Sloppy + frantic (0.3 u/frame) -> CV 0.72, mass 18.8, 16 solids, 'a crime scene'.
- Dinner knife pressed on a tomato without sawing -> skates, aim jumps 0 -> 0.12.
  The Best Thing under the same press bites in 7 frames, 0 slips.
- Wipe two passes -> mass 0.51 -> 0.09.

Tuning found by playing: slice CV runs over piece WIDTHS not volumes (round
ingredients make end slices thin by geometry — a perfect cut scored 0.40, which
is a geometry lesson, not a skill test); juice coefficient 0.5 -> 0.1 (the first
tomato flooded the board before the second cut).

Assets: gen_3d retries once per stage, bakery seeds bumped, prep cast queued.
2D landed (bruschetta_hero, wonderslice_bag). 3D is BLOCKED: every bg_remove
fails because m4pro's disk is 100% full. Procedural stand-ins carry the bench.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 17:17:47 +10:00

430 lines
15 KiB
TypeScript

import * as THREE from 'three';
import type { App, View } from '../core/app';
import { audio } from '../core/audio';
import { INGREDIENTS, type Ingredient, type IngredientId } from '../sim/ingredients';
import {
aimCut,
beginBite,
cutFrame,
cvWord,
liftKnife,
newCutSession,
pieceCV,
pieceVolumes,
plannedCuts,
type CutPattern,
type CutSession,
} from '../sim/cutting';
import { Mess } from '../sim/mess';
import { makeCutleryMesh, TOOLS, type ToolId } from '../sim/cutlery';
import type { PrepResult } from '../game/judging';
import { el, Panel } from '../ui/hud';
import { Rng } from '../core/rng';
import { loadProp } from './assets';
const BOARD_Y = 0.06;
const BOARD_W = 4.6;
const BOARD_D = 2.4;
/**
* The prep bench. One board, one knife, one ingredient at a time, and a mess
* field that remembers everything you did to all three.
*
* Controls mirror the slicer on purpose: horizontal mouse aims the cut, press
* bites, vertical strokes saw. The two new verbs are the ingredient's, not
* yours: slippery skin skates if you lean without sawing, and wet flesh
* juices the board. Drag on the empty board (off the ingredient) to wipe.
*/
export class PrepView implements View {
readonly root = new THREE.Group();
readonly mess = new Mess(96);
private session: CutSession | null = null;
private ing!: Ingredient;
private ingMesh: THREE.Group | null = null;
private pieces: THREE.Group | null = null;
private seams: THREE.Mesh[] = [];
private knifeMesh!: THREE.Group;
private rng = new Rng(20260717);
private messTexData: Uint8Array;
private messTex: THREE.DataTexture;
private messVersion = -1;
private solidsMesh: THREE.InstancedMesh;
private ray = new THREE.Raycaster();
private planePt = new THREE.Vector3();
private plane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0.2);
private boardPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -(BOARD_Y * 2));
private prev = new THREE.Vector2();
private hasPrev = false;
private wipePrev: THREE.Vector3 | null = null;
private sawPhase = 0;
private doneTimer = 0;
private panel: Panel;
private askEl!: HTMLElement;
private cutsEl!: HTMLElement;
private progFill!: HTMLElement;
private benchEl!: HTMLElement;
private hintEl!: HTMLElement;
onDone: ((result: PrepResult) => void) | null = null;
constructor(private app: App) {
this.buildScenery();
const n = this.mess.field.n;
this.messTexData = new Uint8Array(n * n * 4);
this.messTex = new THREE.DataTexture(this.messTexData, n, n, THREE.RGBAFormat);
this.messTex.minFilter = THREE.LinearFilter;
this.messTex.magFilter = THREE.LinearFilter;
const stain = new THREE.Mesh(
new THREE.PlaneGeometry(BOARD_W, BOARD_D),
new THREE.MeshBasicMaterial({ map: this.messTex, transparent: true, depthWrite: false }),
);
stain.rotation.x = -Math.PI / 2;
stain.position.y = BOARD_Y * 2 + 0.004;
this.root.add(stain);
// Solids: seeds and skins, instanced like the rind bits.
const seedGeo = new THREE.SphereGeometry(0.028, 8, 6);
seedGeo.scale(1, 0.45, 0.72);
this.solidsMesh = new THREE.InstancedMesh(
seedGeo,
new THREE.MeshStandardMaterial({ color: 0xf0e6c8, roughness: 0.6 }),
256,
);
this.solidsMesh.count = 0;
this.solidsMesh.castShadow = true;
this.root.add(this.solidsMesh);
this.panel = new Panel('prep-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, 'PREP IT');
this.askEl = el('div', 'slicer-ask', card, '');
this.cutsEl = el('div', 'slicer-thick', card, '');
const bar = el('div', 'timer-bar', card);
this.progFill = el('div', 'timer-fill', bar);
this.benchEl = 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(BOARD_W, BOARD_Y * 2, BOARD_D),
new THREE.MeshStandardMaterial({ color: 0xa87c4f, roughness: 0.7 }),
);
board.position.y = BOARD_Y;
board.receiveShadow = true;
board.castShadow = true;
this.root.add(board);
void loadProp('/assets/models/cutting_board.glb', 2.2)
.then((prop) => {
prop.position.y = BOARD_Y;
board.visible = false;
this.root.add(prop);
})
.catch(() => undefined);
}
/** Procedural stand-in silhouettes — the shapes carry the physics. */
private buildIngredientMesh(ing: Ingredient): THREE.Group {
const g = new THREE.Group();
const skin = new THREE.MeshStandardMaterial({
color: new THREE.Color().setRGB(...ing.colors.skin, THREE.SRGBColorSpace),
roughness: 0.55,
});
let mesh: THREE.Mesh;
const r = ing.size / 2;
switch (ing.shape) {
case 'ovoid': {
mesh = new THREE.Mesh(new THREE.SphereGeometry(r, 28, 20), skin);
mesh.scale.set(1.25, 0.92, 0.92);
break;
}
case 'block': {
mesh = new THREE.Mesh(new THREE.BoxGeometry(ing.size, ing.size * 0.6, ing.size * 0.7), skin);
break;
}
case 'wedge': {
mesh = new THREE.Mesh(new THREE.CylinderGeometry(r, r, ing.size * 0.5, 24, 1, false, 0, Math.PI / 2.2), skin);
mesh.rotation.y = Math.PI / 4;
break;
}
case 'bulb': {
mesh = new THREE.Mesh(new THREE.SphereGeometry(r, 20, 14), skin);
mesh.scale.set(1, 0.85, 1);
break;
}
default:
mesh = new THREE.Mesh(new THREE.SphereGeometry(r, 28, 20), skin);
}
mesh.castShadow = true;
mesh.receiveShadow = true;
g.add(mesh);
g.position.y = BOARD_Y * 2 + (ing.shape === 'block' ? ing.size * 0.3 : r * (ing.shape === 'bulb' ? 0.85 : 0.92));
return g;
}
/** A fresh ingredient and a fresh session. */
reset(ingredientId: IngredientId, knife: ToolId, pattern: CutPattern, askText: string): void {
this.ing = INGREDIENTS[ingredientId];
this.session = newCutSession(this.ing, knife, pattern);
if (this.ingMesh) this.root.remove(this.ingMesh);
if (this.pieces) this.root.remove(this.pieces);
this.pieces = null;
for (const s of this.seams) this.root.remove(s);
this.seams = [];
this.ingMesh = this.buildIngredientMesh(this.ing);
this.root.add(this.ingMesh);
if (this.knifeMesh) this.root.remove(this.knifeMesh);
this.knifeMesh = makeCutleryMesh(TOOLS[knife]);
this.root.add(this.knifeMesh);
this.mess.reset();
this.askEl.textContent = askText;
this.doneTimer = 0;
this.hasPrev = false;
this.wipePrev = null;
this.hintEl.textContent = 'aim · press on it and saw up-down · drag the board to wipe';
}
enter(): void {
this.panel.show();
}
exit(): void {
this.panel.hide();
}
/** Board-local UV under the pointer, if the ray hits the board. */
private boardUV(): { u: number; v: number } | null {
const pt = new THREE.Vector3();
if (!this.ray.ray.intersectPlane(this.boardPlane, pt)) return null;
const u = (pt.x + BOARD_W / 2) / BOARD_W;
const v = (pt.z + BOARD_D / 2) / BOARD_D;
if (u < 0 || u > 1 || v < 0 || v > 1) return null;
return { u, v };
}
update(dt: number): void {
const inp = this.app.input;
this.app.camera.position.set(1.2, 2.3, 4.4);
this.app.camera.lookAt(0.6, 0.5, -0.1);
const s = this.session;
if (!s) return;
this.ray.setFromCamera(inp.ndc, this.app.camera);
this.plane.set(new THREE.Vector3(0, 0, 1), 0.2);
const hit = this.ray.ray.intersectPlane(this.plane, this.planePt);
// Cutting happens on the vertical plane near the ingredient; wiping on the
// board everywhere else. "Near" = within a size of the centre.
const nearIngredient = hit && Math.abs(this.planePt.x) < this.ing.size * 0.85 && s.phase !== 'done';
if (s.phase !== 'done' && nearIngredient && hit) {
if (s.phase === 'aim') {
aimCut(s, this.planePt.x / this.ing.size);
if (inp.down) {
beginBite(s);
audio.clatter(0.15, 1.8);
}
} else if (inp.down && this.hasPrev) {
const dy = this.planePt.y - this.prev.y;
const dx = this.planePt.x - this.prev.x;
const f = cutFrame(s, dy, dx, dt, () => this.rng.next());
if (Math.abs(dy) > 0.004) {
this.sawPhase += Math.abs(dy) * 3;
audio.scrape(0.4, (Math.abs(dy) / Math.max(dt, 1e-3)) * 0.22);
}
this.applyFrame(f, dy);
} else if (!inp.down) {
liftKnife(s);
}
} else if (inp.down) {
// Off the ingredient with the button down: the cloth.
const uv = this.boardUV();
if (uv && this.wipePrev) {
this.mess.wipe(this.wipePrev.x, this.wipePrev.y, uv.u, uv.v);
}
if (uv) this.wipePrev = new THREE.Vector3(uv.u, uv.v, 0);
}
if (!inp.down) this.wipePrev = null;
if (s.phase === 'done') {
this.doneTimer += dt;
// No auto-exit: the bench is judged at serve, so the moment after the
// last cut is exactly when you'd want the cloth. ENTER hands it over.
this.hintEl.textContent = 'done — wipe the board if you dare care · ENTER takes it to the kitchen';
if (inp.justPressed('Enter') && this.onDone) {
const result = this.result();
const cb = this.onDone;
this.onDone = null;
cb(result);
return;
}
}
this.prev.set(this.planePt.x, this.planePt.y);
this.hasPrev = !!hit;
this.positionKnife(s);
this.syncMess();
this.updateHud(s);
}
/** What the judge reads. Snapshot at any time; final after onDone. */
result(): PrepResult {
const s = this.session!;
return {
cv: pieceCV(s),
pieces: pieceVolumes(s).length,
patternName: s.pattern.kind === 'halve' ? 'halves' : s.pattern.kind,
slips: s.slips,
bench: this.mess.stats(),
};
}
private applyFrame(f: ReturnType<typeof cutFrame>, dy: number): void {
const s = this.session!;
const cutWorldX = s.aimPos * this.ing.size;
const u = (cutWorldX + BOARD_W / 2) / BOARD_W;
const v = 0.5;
if (f.juice > 0) {
// Sprayed along the blade (board Z), the side picked by the stroke.
const side = dy > 0 ? 1 : -1;
this.mess.addJuice(u, v, f.juice, (this.rng.next() - 0.5) * 0.3, side * (0.6 + this.rng.next() * 0.4));
}
if (f.slipped) {
audio.clatter(0.5, 0.9);
this.app.shake = 0.015;
this.hintEl.textContent = 'it skated. saw as you press — the blade needs to bite';
}
if (f.cutDone) {
audio.clunk(false);
this.addSeam(s.cuts[s.cuts.length - 1]);
for (let i = 0; i < f.seedsSpilled; i++) {
this.mess.addSolid(u + (this.rng.next() - 0.5) * 0.12, v + (this.rng.next() - 0.5) * 0.3, 'seed');
}
}
if (f.sessionDone) {
this.showPieces();
}
}
/** Committed cuts read as dark seams on the ingredient. */
private addSeam(pos: number): void {
const seam = new THREE.Mesh(
new THREE.BoxGeometry(0.016, this.ing.size * 0.95, this.ing.size * 0.95),
new THREE.MeshStandardMaterial({
color: new THREE.Color().setRGB(
this.ing.colors.flesh[0] * 0.45,
this.ing.colors.flesh[1] * 0.45,
this.ing.colors.flesh[2] * 0.45,
THREE.SRGBColorSpace,
),
roughness: 1,
}),
);
seam.position.set(pos * this.ing.size, this.ingMesh!.position.y, 0);
this.root.add(seam);
this.seams.push(seam);
}
/** Session done: the whole becomes pieces, fanned apart so the CV is visible. */
private showPieces(): void {
if (!this.session || this.pieces) return;
const s = this.session;
if (this.ingMesh) this.ingMesh.visible = false;
for (const seam of this.seams) this.root.remove(seam);
this.seams = [];
this.pieces = new THREE.Group();
const flesh = new THREE.MeshStandardMaterial({
color: new THREE.Color().setRGB(...this.ing.colors.flesh, THREE.SRGBColorSpace),
roughness: 0.5,
});
const edges = [-0.5, ...[...s.cuts].sort((a, b) => a - b), 0.5];
let x = -this.ing.size * 0.55 - edges.length * 0.02;
for (let i = 0; i + 1 < edges.length; i++) {
const w = (edges[i + 1] - edges[i]) * this.ing.size;
const mid = (edges[i] + edges[i + 1]) / 2;
const rHere = Math.sqrt(Math.max(0.05, 0.25 - mid * mid)) * this.ing.size;
const piece = new THREE.Mesh(new THREE.CylinderGeometry(rHere, rHere, Math.max(0.02, w * 0.92), 20), flesh);
piece.rotation.z = Math.PI / 2;
piece.castShadow = true;
piece.position.set(x + w / 2, BOARD_Y * 2 + rHere, 0.1);
this.pieces.add(piece);
x += w + 0.05;
}
this.root.add(this.pieces);
}
private positionKnife(s: CutSession): void {
if (!this.knifeMesh) return;
const topY = this.ingMesh ? this.ingMesh.position.y + this.ing.size * 0.55 : 1;
this.knifeMesh.rotation.set(0, Math.PI / 2, Math.PI / 2);
const sawZ = s.phase === 'saw' ? Math.sin(this.sawPhase * 6) * 0.3 : 0.15;
const sink = s.phase === 'aim' || s.phase === 'done' ? 0 : s.progress * this.ing.size * 0.9;
this.knifeMesh.visible = s.phase !== 'done';
this.knifeMesh.position.set(s.aimPos * this.ing.size, topY + 0.22 - sink, sawZ + 0.35);
}
/** Push the mess field into the stain texture when it changed. */
private syncMess(): void {
if (this.mess.version === this.messVersion) return;
this.messVersion = this.mess.version;
const d = this.mess.field.data;
const px = this.messTexData;
for (let i = 0; i < d.length; i++) {
const m = Math.min(1, d[i] * 2.2);
px[i * 4] = 190;
px[i * 4 + 1] = 68;
px[i * 4 + 2] = 36;
px[i * 4 + 3] = Math.round(Math.min(0.85, m) * 255);
}
this.messTex.needsUpdate = true;
const solids = this.mess.solids.filter((s) => !s.collected);
const mat = new THREE.Matrix4();
let n = 0;
for (const s of solids) {
if (n >= 256) break;
mat.setPosition(s.u * BOARD_W - BOARD_W / 2, BOARD_Y * 2 + 0.02, s.v * BOARD_D - BOARD_D / 2);
this.solidsMesh.setMatrixAt(n++, mat);
}
this.solidsMesh.count = n;
this.solidsMesh.instanceMatrix.needsUpdate = true;
}
private updateHud(s: CutSession): void {
const total = plannedCuts(s.pattern);
const done = s.cuts.length + s.diceCutsA.length;
if (s.phase === 'done') {
const cv = pieceCV(s);
this.cutsEl.textContent = `${pieceVolumes(s).length} pieces — CV ${cv.toFixed(3)}, ${cvWord(cv)}`;
} else {
this.cutsEl.textContent = `cut ${Math.min(done + 1, total)} of ${total}${s.phase === 'aim' ? ' (aiming)' : ''}`;
}
this.progFill.style.width = `${Math.round(s.progress * 100)}%`;
this.progFill.style.background = '#e8a53a';
const bench = this.mess.benchWord();
const wobble =
s.phase === 'saw' && s.wedge > 0.12 ? (s.wedge > 0.3 ? ' · that cut is wandering' : ' · drifting…') : '';
this.benchEl.textContent = `bench: ${bench}${wobble}${s.slips ? ` · slips: ${s.slips}` : ''}`;
this.benchEl.style.color = bench === 'clean' ? '' : bench === 'smeared' ? '#e8a53a' : '#e2603a';
}
dispose(): void {
this.panel.dispose();
}
}