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>
381 lines
13 KiB
TypeScript
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();
|
|
}
|
|
}
|