M4: the cutlery drawer — and the link that makes it matter

toast -> drawer -> spread. Eight pieces settle into a real tangle; find the one
on the card, drag it up and over the rim, and whatever you're holding is what
you're spreading with.

The link, verified end to end (day 4, butter softness 0.3):

  4s in the drawer   warmth 0.78  yield 0.343  needs angle 0.36  comfortable
  50s in the drawer  warmth 0.17  yield 0.565  needs angle 0.60  cliff is 0.62

Dawdling walks you to within two hundredths of the scrape cliff. On day 7
(fridge-hard butter) it tips over into genuinely unspreadable. The drawer isn't
a timer bolted on the side — it's the thing that makes the butter hard.

Physics choices that earn their keep:
- Compound boxes, never trimeshes. A trimesh fork tangled in a trimesh fork is
  a solver nightmare and slow with it; the tine envelope is what matters anyway.
- Grab is a capped PD spring, not a fixed joint, so a snagged piece fights the
  ones lying on it and loses instead of tunnelling through them.
- The solver is fixed-stepped so a frame hitch can't detonate the pile, and the
  drawer settles offline so you open it on an existing mess.
- Clatter is synthesised from real contact-force events, velocity-scaled — a
  drawer of cutlery is a noise, and the noise has to come from the collisions.

Three fixes:
- Quaternion.toArray() returns [x,y,z,w]; Rapier wants {x,y,z,w}. Every axis
  read back undefined -> NaN in the solver -> wasm panic on the first step.
- The drag plane faced the camera, but the camera looks down into the drawer, so
  dragging to the top of the screen couldn't physically lift anything over the
  rim. It's a vertical plane now: up-screen means up.
- Cooling lived in kitchen.update(), which stops running the moment the drawer
  opens — so the toast stayed hot no matter how long you rummaged, quietly
  cancelling the entire mechanic. It's an app-level tick now.

The drawer was 4.6x3.4 and the pieces politely spread out flat. Tangling needs
crowding: 3.1x2.2, dropped down a narrow column so they land on each other.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
monster 2026-07-16 22:13:08 +10:00
parent b07d57e9e9
commit 1ba66f0cdf
7 changed files with 690 additions and 4 deletions

141
src/core/audio.ts Normal file
View File

@ -0,0 +1,141 @@
/**
* Synthesised sound. No samples the whole point of a drawer full of cutlery is
* the noise it makes, and that noise is velocity-dependent, so it has to be
* generated per collision rather than picked from a list.
*/
export class Audio {
private ctx: AudioContext | null = null;
private master: GainNode | null = null;
private noise: AudioBuffer | null = null;
private lastClatter = 0;
/** Browsers won't let us make noise until the user has done something. */
resume(): void {
if (!this.ctx) {
const Ctor = window.AudioContext ?? (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
if (!Ctor) return;
this.ctx = new Ctor();
this.master = this.ctx.createGain();
this.master.gain.value = 0.5;
this.master.connect(this.ctx.destination);
this.noise = this.makeNoise();
}
void this.ctx.resume();
}
private makeNoise(): AudioBuffer {
const ctx = this.ctx!;
const len = ctx.sampleRate * 0.5;
const buf = ctx.createBuffer(1, len, ctx.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < len; i++) d[i] = Math.random() * 2 - 1;
return buf;
}
private now(): number {
return this.ctx?.currentTime ?? 0;
}
/**
* Metal on metal. A bright noise burst through a resonant band, plus a couple
* of inharmonic partials that ring is what makes it read as cutlery rather
* than a click.
*/
clatter(strength: number, pitch = 1): void {
const ctx = this.ctx;
if (!ctx || !this.master || !this.noise) return;
const t = this.now();
// Dozens of contacts can resolve in one step; don't stack them all.
if (t - this.lastClatter < 0.02) return;
this.lastClatter = t;
const v = Math.min(1, strength);
const src = ctx.createBufferSource();
src.buffer = this.noise;
src.playbackRate.value = 0.8 + Math.random() * 0.6;
const bp = ctx.createBiquadFilter();
bp.type = 'bandpass';
bp.frequency.value = (2600 + Math.random() * 2600) * pitch;
bp.Q.value = 2.5;
const g = ctx.createGain();
g.gain.setValueAtTime(0.28 * v, t);
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.06 + v * 0.05);
src.connect(bp).connect(g).connect(this.master);
src.start(t);
src.stop(t + 0.2);
for (let i = 0; i < 2; i++) {
const o = ctx.createOscillator();
o.type = 'triangle';
o.frequency.value = (1900 + Math.random() * 3200) * pitch;
const og = ctx.createGain();
og.gain.setValueAtTime(0.06 * v, t);
og.gain.exponentialRampToValueAtTime(0.0001, t + 0.12 + v * 0.3);
o.connect(og).connect(this.master);
o.start(t);
o.stop(t + 0.5);
}
}
/** The lever going down, and the pop coming back. */
clunk(up = false): void {
const ctx = this.ctx;
if (!ctx || !this.master) return;
const t = this.now();
const o = ctx.createOscillator();
o.type = 'square';
o.frequency.setValueAtTime(up ? 180 : 120, t);
o.frequency.exponentialRampToValueAtTime(up ? 90 : 55, t + 0.09);
const g = ctx.createGain();
g.gain.setValueAtTime(0.22, t);
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.16);
const lp = ctx.createBiquadFilter();
lp.type = 'lowpass';
lp.frequency.value = 900;
o.connect(lp).connect(g).connect(this.master);
o.start(t);
o.stop(t + 0.25);
}
/** Knife dragging over toast. Pitch rises with pressure. */
scrape(pressure: number, speed: number): void {
const ctx = this.ctx;
if (!ctx || !this.master || !this.noise) return;
const t = this.now();
if (t - this.lastClatter < 0.045) return;
this.lastClatter = t;
const src = ctx.createBufferSource();
src.buffer = this.noise;
src.playbackRate.value = 0.35 + pressure * 0.5;
const bp = ctx.createBiquadFilter();
bp.type = 'bandpass';
bp.frequency.value = 600 + pressure * 2600;
bp.Q.value = 1.1;
const g = ctx.createGain();
const v = Math.min(0.14, 0.03 + speed * 0.05);
g.gain.setValueAtTime(v, t);
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.1);
src.connect(bp).connect(g).connect(this.master);
src.start(t);
src.stop(t + 0.2);
}
/** The judge's stamp. */
stamp(): void {
const ctx = this.ctx;
if (!ctx || !this.master) return;
const t = this.now();
const o = ctx.createOscillator();
o.type = 'sine';
o.frequency.setValueAtTime(150, t);
o.frequency.exponentialRampToValueAtTime(48, t + 0.11);
const g = ctx.createGain();
g.gain.setValueAtTime(0.4, t);
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.22);
o.connect(g).connect(this.master);
o.start(t);
o.stop(t + 0.3);
}
}
export const audio = new Audio();

View File

@ -2,6 +2,10 @@ import type { App } from '../core/app';
import { Rng } from '../core/rng';
import { KitchenView } from '../scenes/kitchen';
import { JudgeView } from '../scenes/judge';
import { DrawerView } from '../scenes/drawer';
import { TOOLS, type ToolId } from '../sim/cutlery';
import { audio } from '../core/audio';
import { coolStep } from '../sim/toasting';
import { judge, type Grade } from './judging';
import { DAYS, proceduralOrder, nameBrowning, type Order } from './orders';
import { el, Panel } from '../ui/hud';
@ -21,6 +25,7 @@ interface Save {
export class Game {
private kitchen: KitchenView;
private judgeView: JudgeView;
private drawer: DrawerView;
private rng = new Rng(20260716);
private order!: Order;
private startedAt = 0;
@ -32,20 +37,38 @@ export class Game {
this.save = loadSave();
this.kitchen = new KitchenView(app, 3);
this.judgeView = new JudgeView(app);
this.drawer = new DrawerView(app, 7);
app.scene.add(this.kitchen.root);
app.scene.add(this.judgeView.root);
app.scene.add(this.drawer.root);
this.judgeView.root.visible = false;
this.drawer.root.visible = false;
this.ticket = new Panel('ticket');
this.ticketEl = el('div', 'ticket-body', this.ticket.root);
// toast -> drawer -> spread. The drawer sits AFTER the toaster on purpose:
// its cost is your toast going cold, and cold toast is what makes butter
// impossible. Dawdling here is the punishment.
this.kitchen.onPopped = () => this.openDrawer();
this.kitchen.onServed = () => this.serve();
this.drawer.onPicked = (tool) => this.closeDrawer(tool);
this.judgeView.onNext = () => {
this.save.day++;
writeSave(this.save);
this.beginDay();
};
// Cooling runs at the app level, not in the kitchen's update: the kitchen
// stops updating the moment the drawer opens, and "your toast goes cold
// while you rummage" is the entire reason the drawer exists.
app.onTick((dt) => {
const k = this.kitchen;
if (k.phase === 'ready' || k.phase === 'toasting') return;
coolStep(k.currentSlice, dt);
this.drawer.warmth = k.currentSlice.warmth;
});
this.beginDay();
}
@ -58,6 +81,35 @@ export class Game {
get judgeScreen(): JudgeView {
return this.judgeView;
}
get drawerView(): DrawerView {
return this.drawer;
}
async init(): Promise<void> {
await this.drawer.init();
}
/** Fresh out of the toaster, and now you need something to spread with. */
private openDrawer(): void {
this.drawer.reset(this.order.tool);
this.drawer.warmth = this.kitchen.currentSlice.warmth;
this.kitchen.root.visible = false;
this.drawer.root.visible = true;
this.app.setView(this.drawer);
this.ticket.hide();
}
private closeDrawer(tool: ToolId): void {
this.kitchen.setTool(tool);
this.drawer.root.visible = false;
this.kitchen.root.visible = true;
this.app.setView(this.kitchen);
this.ticket.show();
this.kitchen.beginSpreading();
if (tool !== this.order.tool) {
this.kitchen.flash(`you brought a ${TOOLS[tool].name.toLowerCase()}`);
}
}
beginDay(): void {
const day = this.save.day;
@ -73,6 +125,7 @@ export class Game {
if (s) this.kitchen.root.add(s.mesh);
this.kitchen.applyOrder(o);
this.drawer.root.visible = false;
this.app.setView(this.kitchen);
this.judgeView.root.visible = false;
this.kitchen.root.visible = true;
@ -112,6 +165,7 @@ export class Game {
this.kitchen.root.visible = false;
this.judgeView.root.visible = true;
this.judgeView.show(slice, v, this.order, this.kitchen.toolId, () => this.rng.next());
audio.stamp();
this.app.setView(this.judgeView);
this.ticket.hide();
}

View File

@ -10,6 +10,9 @@ makeEnvironment(app);
app.scene.background = new THREE.Color(0x241a15);
const game = new Game(app);
// Rapier's wasm has to be up before the drawer can be opened, but the toaster
// works without it — so boot the game now and let physics catch up.
void game.init();
if (import.meta.env.DEV) {
void import('./dev').then((m) => m.installDevHarness(app, game));

328
src/scenes/drawer.ts Normal file
View File

@ -0,0 +1,328 @@
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 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;
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);
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 });
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 = 7): 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]);
}
ids.forEach((id, i) => this.spawn(id, i));
this.cardEl.innerHTML = toolSilhouette(TOOLS[want]);
el('div', 'sil-name', this.cardEl, TOOLS[want].name);
// 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.5, 0.5),
0.7 + i * 0.5,
this.rng.range(-0.35, 0.35),
)
.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';
// Lifted clear of the rim? That's your pick.
if (this.grabbed && this.grabbed.body.translation().y > RIM_Y) {
const got = this.grabbed.id;
this.grabbed = null;
this.onPicked?.(got);
return;
}
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();
}
}

View File

@ -3,7 +3,7 @@ import type { App, View } from '../core/app';
import { Rng } from '../core/rng';
import { Slice } from '../sim/slice';
import { BREADS, type BreadId } from '../sim/bread';
import { buildHeatMap, coolStep, readToast, smellCue, toastStep } from '../sim/toasting';
import { buildHeatMap, readToast, smellCue, toastStep } from '../sim/toasting';
import {
SPREADS,
SCRAPE_ANGLE,
@ -17,6 +17,7 @@ import { TOOLS, type ToolId } from '../sim/cutlery';
import { makeBench, makePlate, makeToaster, type Toaster } from './props';
import { KnifeRig, makeSpreadPot } from './spreadrig';
import { el, Panel } from '../ui/hud';
import { audio } from '../core/audio';
import type { Order } from '../game/orders';
const TOASTER_X = -1.5;
@ -26,7 +27,7 @@ const PLATE_Y = 0.05;
const POT_X = 3.15;
const POT_Z = 0.45;
export type Phase = 'ready' | 'toasting' | 'flying' | 'spreading';
export type Phase = 'ready' | 'toasting' | 'flying' | 'landed' | 'spreading';
interface CamShot {
pos: THREE.Vector3;
@ -89,6 +90,8 @@ export class KitchenView implements View {
/** Fired when the player hands the plate over. */
onServed: (() => void) | null = null;
/** Fired once the slice has landed on the plate — the drawer opens here. */
onPopped: (() => void) | null = null;
constructor(
private app: App,
@ -260,6 +263,22 @@ export class KitchenView implements View {
this.onServed?.();
}
/** Whatever you managed to pull out of the drawer is what you're spreading with. */
setTool(id: ToolId): void {
this.toolId = id;
this.toolSel.value = id;
this.knifeRig.setTool(TOOLS[id]);
}
flash(msg: string): void {
this.hintEl.textContent = msg;
this.hintEl.style.color = '#e2603a';
window.setTimeout(() => {
this.hintEl.style.color = '';
this.hintEl.textContent = 'dip · drag · WHEEL tilts the knife · ENTER serves it';
}, 2600);
}
private placeInSlot(t: number): void {
const y = this.toaster.slotY + 0.16 - t * 0.74;
this.slice.mesh.position.set(TOASTER_X, y, -0.35);
@ -275,6 +294,8 @@ export class KitchenView implements View {
private pushDown(): void {
if (this.phase !== 'ready') return;
audio.resume();
audio.clunk();
this.phase = 'toasting';
this.hintEl.textContent = 'SPACE — pop it';
}
@ -337,13 +358,15 @@ export class KitchenView implements View {
this.slice.mesh.position.set(PLATE_X, PLATE_Y + 0.02, PLATE_Z);
this.slice.mesh.rotation.set(0, 0, 0);
this.app.shake = 0.02;
this.beginSpreading();
this.phase = 'landed';
audio.clunk(true);
if (this.onPopped) this.onPopped();
else this.beginSpreading();
}
} else if (this.phase === 'spreading') {
this.updateSpreading(dt);
}
if (this.phase !== 'toasting') coolStep(this.slice, dt);
this.updateCamera(dt);
if (this.phase !== 'spreading') {
@ -395,6 +418,9 @@ export class KitchenView implements View {
this.butterSoftness,
);
if (this.lastFx.tore > 0.004 || this.lastFx.gouged > 0.004) this.app.shake = 0.012;
if (this.lastFx.mode !== 'idle' && this.lastFx.speed > 0.15) {
audio.scrape(this.lastFx.pressure, this.lastFx.speed);
}
} else {
this.knife.hasPrev = false;
this.lastFx = null;

View File

@ -435,3 +435,82 @@ canvas#c {
.btn.ghost:hover {
color: var(--ink);
}
/* ---- the drawer ---- */
.drawer-panel {
position: absolute;
inset: 0;
pointer-events: none;
}
.drawer-card {
position: absolute;
top: 26px;
left: 26px;
width: 168px;
padding: 14px 14px 12px;
background: rgba(12, 9, 8, 0.78);
border: 1px solid rgba(243, 233, 220, 0.16);
border-radius: 9px;
backdrop-filter: blur(9px);
text-align: center;
}
.drawer-lbl {
font-size: 10px;
letter-spacing: 0.22em;
color: var(--ink-dim);
}
.drawer-sil {
color: var(--gold);
margin: 6px 0 2px;
}
.drawer-sil .sil {
display: block;
margin: 0 auto;
}
.sil-name {
font-size: 13px;
letter-spacing: 0.05em;
color: var(--ink);
margin-top: 2px;
}
.drawer-timer {
margin-top: 12px;
}
.timer-bar {
height: 5px;
border-radius: 3px;
background: rgba(255, 255, 255, 0.1);
overflow: hidden;
}
.timer-fill {
height: 100%;
width: 100%;
border-radius: 3px;
transition: width 0.2s linear;
}
.timer-lbl {
margin-top: 5px;
font-size: 10px;
font-style: italic;
color: var(--ink-dim);
}
.drawer-hint {
position: absolute;
bottom: 26px;
left: 50%;
transform: translateX(-50%);
font-size: 11px;
letter-spacing: 0.1em;
color: var(--ink-dim);
text-shadow: 0 2px 10px #000;
}

55
src/ui/silhouette.ts Normal file
View File

@ -0,0 +1,55 @@
import type { Tool } from '../sim/cutlery';
/**
* A schematic silhouette of a piece of cutlery, drawn from the same numbers the
* mesh and the colliders use. That matters: the drawer's whole challenge is
* telling a dessert fork from a dinner fork, and the card has to be honest about
* the difference or the puzzle is a lie. Everything is drawn to a shared scale,
* so a shorter piece really does look shorter.
*/
export function toolSilhouette(tool: Tool): string {
const SCALE = 62; // px per world unit; shared across tools so sizes compare
const L = tool.length * SCALE;
const hw = (tool.headW / 2) * SCALE;
const hl = tool.headL * SCALE;
const handleL = L - hl - 0.12 * SCALE;
const w = 132;
const h = 150;
const cx = w / 2;
const top = (h - L) / 2;
const parts: string[] = [];
// handle: tapered, butt at the bottom
const bw = 0.085 * SCALE;
const nw = 0.052 * SCALE;
const y0 = top + hl + 0.12 * SCALE;
parts.push(
`<path d="M ${cx - nw} ${y0} L ${cx - bw} ${y0 + handleL * 0.55} Q ${cx - bw} ${y0 + handleL} ${cx} ${y0 + handleL} Q ${cx + bw} ${y0 + handleL} ${cx + bw} ${y0 + handleL * 0.55} L ${cx + nw} ${y0} Z"/>`,
);
if (tool.kind === 'knife') {
const round = tool.id === 'butter_knife' || tool.id === 'spreader';
parts.push(
round
? `<path d="M ${cx - nw} ${y0} L ${cx - hw} ${top + hl * 0.45} Q ${cx - hw} ${top} ${cx} ${top} Q ${cx + hw} ${top} ${cx + hw} ${top + hl * 0.45} L ${cx + nw} ${y0} Z"/>`
: `<path d="M ${cx - nw} ${y0} L ${cx - hw} ${top + hl * 0.45} L ${cx - hw * 0.5} ${top + hl * 0.07} Q ${cx} ${top - 2} ${cx + hw * 0.7} ${top + hl * 0.14} L ${cx + hw} ${top + hl * 0.45} L ${cx + nw} ${y0} Z"/>`,
);
} else if (tool.kind === 'fork') {
const shoulderY = top + hl * 0.66;
parts.push(
`<path d="M ${cx - nw} ${y0} L ${cx - hw} ${shoulderY} L ${cx + hw} ${shoulderY} L ${cx + nw} ${y0} Z"/>`,
);
const tw = (hw * 2) / 7;
for (let i = 0; i < 4; i++) {
const x = cx + (i - 1.5) * ((hw * 2) / 4);
parts.push(`<rect x="${x - tw / 2}" y="${top}" width="${tw}" height="${shoulderY - top}" rx="${tw / 2}"/>`);
}
} else {
parts.push(`<ellipse cx="${cx}" cy="${top + hl * 0.42}" rx="${hw}" ry="${hl * 0.46}"/>`);
parts.push(
`<path d="M ${cx - nw} ${y0} L ${cx - nw * 1.2} ${top + hl * 0.7} L ${cx + nw * 1.2} ${top + hl * 0.7} L ${cx + nw} ${y0} Z"/>`,
);
}
return `<svg class="sil" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" aria-hidden="true"><g fill="currentColor">${parts.join('')}</g></svg>`;
}