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, 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(); } }