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