toastsim/src/scenes/drawer.ts
monster 31f2eff28e M10 WIP + Build Brief 2: the bakery is code-complete, the prep bench is specced
M10 (code-complete, verification pending — see OPUS-BUILD-BRIEF-2.md §2):
- Brands with mechanical quality (WONDERSLICE 0.25 vs Crustworthy 0.8 etc.):
  quality scales heat-map patchiness and crumb fragility. Bakery sourdough is
  loaf-only.
- Slicing minigame: aim thickness, saw with vertical strokes; progress/wobble/
  squash; wedge slices brown unevenly in the toaster (the bad cut follows you);
  squash arrives as pre-existing damage.
- Bread Knife + THE BEST THING (heavy, soft-scalloped, saw eff 1.65) join the
  drawer; on loaf days the drawer trip happens BEFORE toasting ("they are
  waiting") and whatever you fish out is what you slice with.
- Day 9 handwritten (the inspector wants a doorstop with parallel faces),
  procedural brands/loaf days from day 10, The Cut criterion (band + wedge +
  squash, TBT signature bonus), cut line bank.
- Verified so far: day-9 ticket chips, knife-trip drawer with bread_knife AND
  the_best_thing present, timer label, typecheck + build. Slicer end-to-end
  play, tuning, and couplings assertions are itemised in the brief.

OPUS-BUILD-BRIEF-2.md — the prep bench: generalized ingredients + cutting
(slip, juice, piece-evenness CV), the judged mess field, oranges + ribbed
pyramid juicer (halve evenness caps yield, seeds by juicer tier, the
Juicernaut), the grater (zest/pith line, cheese needs cold, knuckles), onions
(sting that blurs REAL vision, the stop-short-of-the-stem criss-cross dice),
and bruschetta as the capstone (temperature planning, roast tomatoes on the
browning field, topping Clark-Evans, the sog clock vs doorstop slices).

Bakery asset batch partially failed (flux errors + one lost GLB) — regen
instructions with retries are in the brief §8.

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

381 lines
13 KiB
TypeScript

import * as THREE from 'three';
import RAPIER from '@dimforge/rapier3d-compat';
import type { App, View } from '../core/app';
import { Rng } from '../core/rng';
import { audio } from '../core/audio';
import { TOOLS, TOOL_IDS, colliderParts, makeCutleryMesh, type ToolId } from '../sim/cutlery';
import { el, Panel } from '../ui/hud';
import { toolSilhouette } from '../ui/silhouette';
const W = 3.1;
const D = 2.2;
const H = 1.15;
const WALL = 0.12;
/** Above this you've got it clear of the drawer. */
const RIM_Y = H + 0.32;
interface Piece {
id: ToolId;
body: RAPIER.RigidBody;
mesh: THREE.Group;
}
/**
* The cutlery drawer.
*
* Rules that keep a dozen tangled steel objects stable: compound boxes rather
* than trimeshes (a trimesh fork catching another trimesh fork is a solver
* nightmare and slow with it), and grabbing pulls with a spring rather than a
* fixed joint — so a snagged piece drags its neighbours instead of teleporting
* through them, which is the entire feel we're after.
*/
export class DrawerView implements View {
readonly root = new THREE.Group();
private world!: RAPIER.World;
private pieces: Piece[] = [];
private eventQueue!: RAPIER.EventQueue;
private rng: Rng;
private grabbed: Piece | null = null;
private grabLocal = new THREE.Vector3();
private grabPlane = new THREE.Plane();
private target = new THREE.Vector3();
private ray = new THREE.Raycaster();
private tmp = new THREE.Vector3();
private tmpQ = new THREE.Quaternion();
private accum = 0;
private panel: Panel;
private cardEl!: HTMLElement;
private timerFill!: HTMLElement;
private timerLbl!: HTMLElement;
private hintEl!: HTMLElement;
/** Called with whatever you actually pulled out. */
onPicked: ((tool: ToolId) => void) | null = null;
/** How warm the toast still is, 0..1 — drawn as the timer. */
warmth = 1;
setTimerLabel(text: string): void {
this.timerLbl.textContent = text;
}
/** The silhouette is the puzzle: the name only appears as a pity, later. */
private wantName = '';
private nameShown = false;
private fumbleSeconds = 0;
/** Holding a piece above the rim commits after a beat — a moment to compare
* it against the silhouette, and no accidental soup-spoon commitments while
* rummaging blockers out of the way. */
private rimDwell = 0;
constructor(
private app: App,
seed = 7,
) {
this.rng = new Rng(seed);
this.buildScenery();
this.panel = new Panel('drawer-panel');
this.buildUi();
this.panel.hide();
}
private buildUi(): void {
const p = this.panel.root;
const card = el('div', 'drawer-card', p);
el('div', 'drawer-lbl', card, 'FIND THE');
this.cardEl = el('div', 'drawer-sil', card);
const timer = el('div', 'drawer-timer', card);
const bar = el('div', 'timer-bar', timer);
this.timerFill = el('div', 'timer-fill', bar);
this.timerLbl = el('div', 'timer-lbl', timer, 'your toast is going cold');
this.hintEl = el('div', 'drawer-hint', p, 'drag it out of the drawer');
}
private buildScenery(): void {
const wood = new THREE.MeshStandardMaterial({ color: 0x6f4c2e, roughness: 0.8 });
const inner = new THREE.MeshStandardMaterial({ color: 0x8a6740, roughness: 0.9 });
// The drawer used to float in a black void; give it the same kitchen it
// came from — a bench under it and a warm pool of light around it.
const bench = new THREE.Mesh(
new THREE.BoxGeometry(24, 0.4, 12),
new THREE.MeshStandardMaterial({ color: 0x4a3524, roughness: 0.85 }),
);
bench.position.y = -0.33;
bench.receiveShadow = true;
this.root.add(bench);
const floor = new THREE.Mesh(new THREE.BoxGeometry(W, WALL, D), inner);
floor.position.y = -WALL / 2;
floor.receiveShadow = true;
this.root.add(floor);
const walls: [number, number, number, number, number, number][] = [
[W, H, WALL, 0, H / 2, -D / 2],
[W, H, WALL, 0, H / 2, D / 2],
[WALL, H, D, -W / 2, H / 2, 0],
[WALL, H, D, W / 2, H / 2, 0],
];
for (const [w, h, d, x, y, z] of walls) {
const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), wood);
m.position.set(x, y, z);
m.receiveShadow = true;
m.castShadow = true;
this.root.add(m);
}
}
async init(): Promise<void> {
await RAPIER.init();
this.world = new RAPIER.World({ x: 0, y: -9.81, z: 0 });
this.eventQueue = new RAPIER.EventQueue(true);
const fixed = (hx: number, hy: number, hz: number, x: number, y: number, z: number) => {
const rb = this.world.createRigidBody(RAPIER.RigidBodyDesc.fixed().setTranslation(x, y, z));
this.world.createCollider(
RAPIER.ColliderDesc.cuboid(hx, hy, hz).setFriction(0.7).setRestitution(0.04),
rb,
);
};
fixed(W / 2, WALL / 2, D / 2, 0, -WALL / 2, 0);
fixed(W / 2, H, WALL / 2, 0, H, -D / 2 - WALL / 2);
fixed(W / 2, H, WALL / 2, 0, H, D / 2 + WALL / 2);
fixed(WALL / 2, H, D / 2, -W / 2 - WALL / 2, H, 0);
fixed(WALL / 2, H, D / 2, W / 2 + WALL / 2, H, 0);
}
/** Fill the drawer and let it settle into a mess. */
reset(want: ToolId, extras = 10): void {
for (const p of this.pieces) {
this.world.removeRigidBody(p.body);
this.root.remove(p.mesh);
}
this.pieces = [];
this.grabbed = null;
// The wanted piece, plus a cast chosen to be confusably similar to it.
const ids: ToolId[] = [want];
const kin = TOOL_IDS.filter((t) => t !== want && TOOLS[t].kind === TOOLS[want].kind);
for (const k of kin) ids.push(k);
const rest = TOOL_IDS.filter((t) => !ids.includes(t));
while (ids.length < extras + 1 && rest.length) {
ids.push(rest.splice(Math.floor(this.rng.next() * rest.length), 1)[0]);
}
// If the pool runs dry, pad with extra spoons and forks — every real drawer
// has too many teaspoons, and density is what makes fishing fishing.
const padding: ToolId[] = ['teaspoon', 'dessert_spoon', 'dinner_fork', 'soup_spoon', 'dinner_knife'];
let pi = 0;
while (ids.length < extras + 1) ids.push(padding[pi++ % padding.length]);
ids.forEach((id, i) => this.spawn(id, i));
// The name is the answer, and the silhouette is the question — so no name.
// It fades in after ten seconds of fumbling, like a sigh.
this.cardEl.innerHTML = toolSilhouette(TOOLS[want]);
this.wantName = TOOLS[want].name;
this.nameShown = false;
this.fumbleSeconds = 0;
this.rimDwell = 0;
// Settle offline so the player opens the drawer on an existing mess rather
// than watching the cutlery rain in.
for (let i = 0; i < 300; i++) this.world.step();
this.syncMeshes();
}
private spawn(id: ToolId, i: number): void {
const tool = TOOLS[id];
// Pass the quaternion itself, not .toArray(): Rapier wants {x,y,z,w}, and an
// array reads back as undefined on every axis, which is NaN in the solver and
// a wasm panic one step later.
const q = new THREE.Quaternion().setFromEuler(
new THREE.Euler(
this.rng.range(-0.5, 0.5),
this.rng.range(0, Math.PI * 2),
this.rng.range(-2.6, 2.6),
),
);
const body = this.world.createRigidBody(
RAPIER.RigidBodyDesc.dynamic()
// Drop them down a narrow column in the middle so they land on top of
// one another rather than politely finding their own patch of floor.
.setTranslation(
this.rng.range(-0.32, 0.32),
0.65 + i * 0.42,
this.rng.range(-0.22, 0.22),
)
.setRotation({ x: q.x, y: q.y, z: q.z, w: q.w })
.setLinearDamping(0.35)
.setAngularDamping(0.45),
);
for (const part of colliderParts(tool)) {
this.world.createCollider(
RAPIER.ColliderDesc.cuboid(...part.half)
.setTranslation(...part.pos)
// Steel. Heavy, slippery, and it rings.
.setDensity(7.8)
.setFriction(0.28)
.setRestitution(0.12)
.setActiveEvents(RAPIER.ActiveEvents.CONTACT_FORCE_EVENTS)
.setContactForceEventThreshold(2.5),
body,
);
}
const mesh = makeCutleryMesh(tool);
this.root.add(mesh);
this.pieces.push({ id, body, mesh });
}
private syncMeshes(): void {
for (const p of this.pieces) {
const t = p.body.translation();
const r = p.body.rotation();
p.mesh.position.set(t.x, t.y, t.z);
p.mesh.quaternion.set(r.x, r.y, r.z, r.w);
}
}
enter(): void {
this.panel.show();
this.app.camera.position.set(0, 3.3, 2.35);
this.app.camera.lookAt(0, 0.15, 0.05);
this.hintEl.textContent = 'drag a piece up and out · ENTER takes what you\'re holding';
}
exit(): void {
this.panel.hide();
this.grabbed = null;
}
update(dt: number): void {
const inp = this.app.input;
this.app.camera.position.set(0, 3.3, 2.35);
this.app.camera.lookAt(0, 0.15, 0.05);
this.ray.setFromCamera(inp.ndc, this.app.camera);
if (inp.down && !this.grabbed) this.tryGrab();
if (!inp.down) this.grabbed = null;
if (this.grabbed) this.dragGrabbed();
// Fixed-step the solver so a frame hitch can't explode the tangle.
this.accum += dt;
let steps = 0;
while (this.accum >= 1 / 60 && steps < 4) {
this.world.step(this.eventQueue);
this.accum -= 1 / 60;
steps++;
this.drainContacts();
}
this.syncMeshes();
this.timerFill.style.width = `${Math.max(0, this.warmth * 100)}%`;
this.timerFill.style.background = this.warmth > 0.5 ? '#e8a53a' : this.warmth > 0.25 ? '#d2703a' : '#c0392b';
// Pity reveal: after ten seconds the card quietly tells you the name.
if (!this.nameShown) {
this.fumbleSeconds += dt;
if (this.fumbleSeconds > 10) {
this.nameShown = true;
el('div', 'sil-name revealed', this.cardEl, `fine. it's the ${this.wantName.toLowerCase()}.`);
}
}
// Holding a piece above the rim for a beat commits it. The beat matters
// twice over: no accidental commitment while lifting blockers around, and
// a moment to hold your candidate against the silhouette and doubt yourself.
if (this.grabbed && this.grabbed.body.translation().y > RIM_Y) {
this.rimDwell += dt;
this.hintEl.textContent = 'holding it up… keep holding to take it';
if (this.rimDwell > 0.45) {
const got = this.grabbed.id;
this.grabbed = null;
this.onPicked?.(got);
return;
}
} else {
this.rimDwell = 0;
this.hintEl.textContent = "drag a piece up and out · ENTER takes what you're holding";
}
if (inp.justPressed('Enter') && this.grabbed) {
const got = this.grabbed.id;
this.grabbed = null;
this.onPicked?.(got);
}
}
private drainContacts(): void {
this.eventQueue.drainContactForceEvents((e) => {
const mag = e.totalForceMagnitude();
if (mag > 3) audio.clatter(Math.min(1, mag / 260), 0.8 + this.rng.next() * 0.5);
});
}
private tryGrab(): void {
// Raycast the visual meshes: they're what the player can see, and the
// colliders are a coarse stand-in for them.
const hits = this.ray.intersectObjects(
this.pieces.map((p) => p.mesh),
true,
);
if (!hits.length) return;
let obj: THREE.Object3D | null = hits[0].object;
let piece: Piece | undefined;
while (obj && !piece) {
piece = this.pieces.find((p) => p.mesh === obj);
obj = obj.parent;
}
if (!piece) return;
this.grabbed = piece;
piece.body.wakeUp();
// Remember where on the piece we took hold, in its own frame.
this.grabLocal.copy(hits[0].point);
piece.mesh.worldToLocal(this.grabLocal);
// Drag on a VERTICAL plane through the grab point, facing the camera
// horizontally. A camera-facing plane sounds right but the camera looks down
// into the drawer, so dragging to the top of the screen only lifts a little
// and you can't physically get anything over the rim. Killing the Y of the
// normal makes up-screen mean up.
this.app.camera.getWorldDirection(this.tmp);
this.tmp.y = 0;
this.tmp.normalize();
this.grabPlane.setFromNormalAndCoplanarPoint(this.tmp, hits[0].point);
audio.clatter(0.25, 1.2);
}
private dragGrabbed(): void {
const p = this.grabbed!;
if (!this.ray.ray.intersectPlane(this.grabPlane, this.target)) return;
// Where the grab point is right now, in world space.
const t = p.body.translation();
const r = p.body.rotation();
this.tmp.copy(this.grabLocal).applyQuaternion(this.tmpQ.set(r.x, r.y, r.z, r.w));
const px = t.x + this.tmp.x;
const py = t.y + this.tmp.y;
const pz = t.z + this.tmp.z;
// A spring, not a joint: a snagged piece has to fight the ones on top of it
// and lose, rather than tunnel through them.
const v = p.body.linvel();
const m = p.body.mass();
const K = 165 * m;
const C = 13 * m;
const fx = (this.target.x - px) * K - v.x * C;
const fy = (this.target.y - py) * K - v.y * C;
const fz = (this.target.z - pz) * K - v.z * C;
const cap = 90 * m;
const mag = Math.hypot(fx, fy, fz);
const s = mag > cap ? cap / mag : 1;
p.body.wakeUp();
p.body.addForceAtPoint(
{ x: fx * s, y: fy * s, z: fz * s },
{ x: px, y: py, z: pz },
true,
);
}
dispose(): void {
this.panel.dispose();
}
}