diff --git a/src/core/audio.ts b/src/core/audio.ts new file mode 100644 index 0000000..a36abf4 --- /dev/null +++ b/src/core/audio.ts @@ -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(); diff --git a/src/game/game.ts b/src/game/game.ts index fbe7c16..646551f 100644 --- a/src/game/game.ts +++ b/src/game/game.ts @@ -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 { + 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(); } diff --git a/src/main.ts b/src/main.ts index 1712d1e..f5c1aae 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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)); diff --git a/src/scenes/drawer.ts b/src/scenes/drawer.ts new file mode 100644 index 0000000..66435bc --- /dev/null +++ b/src/scenes/drawer.ts @@ -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 { + 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(); + } +} diff --git a/src/scenes/kitchen.ts b/src/scenes/kitchen.ts index ff4b34a..4c2a145 100644 --- a/src/scenes/kitchen.ts +++ b/src/scenes/kitchen.ts @@ -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; diff --git a/src/style.css b/src/style.css index 859fe94..6670337 100644 --- a/src/style.css +++ b/src/style.css @@ -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; +} diff --git a/src/ui/silhouette.ts b/src/ui/silhouette.ts new file mode 100644 index 0000000..4edaa00 --- /dev/null +++ b/src/ui/silhouette.ts @@ -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( + ``, + ); + + if (tool.kind === 'knife') { + const round = tool.id === 'butter_knife' || tool.id === 'spreader'; + parts.push( + round + ? `` + : ``, + ); + } else if (tool.kind === 'fork') { + const shoulderY = top + hl * 0.66; + parts.push( + ``, + ); + const tw = (hw * 2) / 7; + for (let i = 0; i < 4; i++) { + const x = cx + (i - 1.5) * ((hw * 2) / 4); + parts.push(``); + } + } else { + parts.push(``); + parts.push( + ``, + ); + } + + return ``; +}