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>
430 lines
15 KiB
TypeScript
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();
|
|
}
|
|
}
|