Compare commits

...

5 Commits

Author SHA1 Message Date
monster
5b09f23c7e M5+M6: content, generated assets, title, sound, perf
Content (verified — same dial, same 18s, four breads that disagree):

  bread        4s     9s     18s    char
  white        0.22   0.54   1.08   93%
  multigrain   0.20   0.52   1.06   92%
  raisin       0.30   0.76   1.14   99.8%
  sourdough    0.11   0.33   0.74   14%

Sourdough stalls at half the rate then accelerates hardest (x3.0 vs white's
x2.45) as its water boils off; raisin browns 40% faster and is essentially
carbon by 18s. Seven handwritten days then procedural, day persisted.

Assets, all generated on-device and free, all reproducible from fixed seeds:
- the judge's five expressions (one seed, one description, only the expression
  phrase varying — he stays the same man while his face falls)
- title art, wooden bench, blurred kitchen backdrop
- butter dish / peanut butter / MITEY jars as GLBs, normalised on load
  (recentre, scale to fit, sit on y=0) rather than hand-fixed, so regenerating
  them doesn't mean re-fixing them

The toaster stays procedural: the generated one is prettier but this one has a
lever we can drive and a dial that turns. Judgment call, per the brief.

Sound is synthesised, no samples: clatter is driven by real Rapier contact-force
events and scaled by force, which is the only way a drawer of cutlery can sound
like one. Plus lever clunk, knife scrape pitched by pressure, and the stamp.

Perf, measured (16.7ms is the 60fps budget):
  kitchen 0.99ms/step · drawer 0.12ms · drawer while dragging 0.21ms

Production build: 2.87MB / 1.0MB gzipped, mostly Rapier's inlined wasm + three.

Textures carry colorSpace = SRGBColorSpace explicitly — same class of bug as the
albedo gamma fix in M3, and it washes them out silently if you forget.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 22:21:06 +10:00
monster
1ba66f0cdf 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>
2026-07-16 22:13:08 +10:00
monster
b07d57e9e9 M3: judging — the scorecard, the judge, and a colour-space bug
The loop closes: ticket -> toast -> spread -> ENTER -> verdict -> next day.

- judging.ts: 9 weighted criteria, each reading the same fields the knife was
  pushing around, so every line of the scorecard points at something real
  ("0.78 against 0.68 asked", "thick — thin asked", "28% burnt").
- lines.ts: ~45 lines keyed to whichever criterion actually decided the score,
  so the verdict and the scorecard always agree — the verdict just has feelings
  about it. MITEY has its own vocabulary.
- judge.ts: the toast turns on a pedestal under a spotlight, grade stamps in,
  heatmap toggle for browning/spread. The generated inspector reacts by grade.
- orders.ts: seven handwritten days, then procedural. Day 1 is soft butter on
  white; day 7 is a translucent film of MITEY on wet sourdough with fridge-hard
  butter and a steak knife.

The find: every art colour was authored as sRGB and fed straight into a linear
lighting pipeline. Linear 0.14 encodes back out to sRGB ~0.4, so "near-black"
MITEY rendered as TAN and saturated butter washed to pale cream. This was the
root cause of the legibility fights in M0 and M2 — I'd been treating the symptom
by pushing the specular around. One line (albedo = pow(albedo, 2.2), mixing
stays in sRGB because that's the space the palette was picked in) and the whole
art direction landed: MITEY is genuinely black, butter is butter, the crust went
from cream to a rich golden brown.

Also: the specular lobe was far too broad. The slice is flat with the light and
camera both above it, so dot(N,H) ~0.98 everywhere and a wide lobe blankets the
whole slice in white. Tightened, so only ridges tilted into the light catch —
which is what a spread actually looks like.

The slice rolls its own lighting and ignores scene lights, so the judge's
spotlight did nothing to it; it now swaps its own rig for presentation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 22:04:22 +10:00
monster
eb74170011 M2: spreading — one control, three behaviours
Knife angle (wheel) is the only input, and spread/scrape/gouge all fall out of it.
Verified by driving real input through the real code paths:

  cold toast + fridge butter -> damage 0.015, spread 0.029   (tears, as designed)
  fresh warm toast           -> damage 0,     spread 0.117   (flows cleanly)
  burnt toast, knife on edge -> char 99.9% -> 82.7% -> 54.5%
                                evenness    0.029 -> 0.123 -> 0.226
  pale toast, knife on edge  -> gouges; steak knife gouges 3.2x harder

That evenness column is the mechanic: scraping rescues you from char and wrecks
uniformity doing it. The judge will have opinions.

The trap is calibrated, not hoped for. Pressure comes from steepness, but past
SCRAPE_ANGLE the knife stops spreading:

  fridge butter / cold toast   yield 0.72  needs angle 0.75  IMPOSSIBLE
  bench butter  / cold toast   yield 0.54  needs angle 0.57  (cliff at 0.62)
  fridge butter / fresh toast  yield 0.36  needs angle 0.38  fine
  soft butter   / fresh toast  yield 0.16  needs angle 0.12  dream mode

So cold toast + hard butter cannot be spread at any angle, and the way out isn't
technique — it's not dawdling. The pressure gauge draws both marks so you can
see the gold sitting past the red and understand why you're losing.

Emergent and kept: a steak knife's narrow blade concentrates pressure enough to
beat cold butter's yield. It's the right tool for cold butter and a menace
everywhere else.

- cutlery.ts: 9 hand-authored archetypes (silhouettes are gameplay — the drawer
  has to be fair) + compound box colliders for M4.
- dev.ts: harness that drives real gestures deterministically. Earns its keep.

Two bugs: cutlery meshes used mesh.rotation.x = -PI/2, which sends a profile
drawn toward +y to -z and one drawn toward -y to +z — the handle and blade were
laid out in opposite directions, overlapping, nowhere near the cursor. Now
rotated at the geometry level. And resize() computed aspect = 0/0 = NaN when the
container reports zero, which poisons the projection matrix so every raycast
silently misses — i.e. the entire mechanic stops with no error.

Metals need something to reflect: added a RoomEnvironment IBL and rebalanced the
direct rig, which was tuned before it existed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 21:51:34 +10:00
monster
67cfe56bb9 M1: toasting — browning sim, toaster, lever, and the pop
Playable: pick a bread, set the dial, drop the lever, watch it brown, pop it
onto the plate. No timer readout — you go by smell.

- toasting.ts: heat map from real toaster behaviour (element stripes, cool top
  edge where the slice stands proud of the slot, edge falloff, per-run noise
  bias). Moisture must boil off before the crust browns at full rate, so
  sourdough stalls then catches up; sugar browns and burns early.
  Rate is measured against the heat map's actual ~0.77 mean, not guessed:
  power 6 -> golden 0.511 at 10s, verified in-browser.
- Props on a real scale (1 unit ~ 11cm, one slice wide) — the toaster is
  meant to dwarf the bread.
- The pop solves a genuine ballistic arc from slot to plate.

Three bugs found by driving it rather than trusting the compile:
- valueNoise2D read one past its grid at x=w-1, so heatBias was NaN along an
  edge, which poisoned mean browning to NaN — i.e. every judge score would
  have been NaN. Clamped.
- erode() clamped at the field border, so border texels could never erode out
  of the mask and kept those NaNs in scope. Off-grid now counts as outside.
- Timer.connect(document) zeroes dt whenever document.hidden is true, which
  froze the sim solid in an embedded browser. Dropped; rAF already pauses for
  hidden tabs. App.step(dt) is now exposed so the game can be driven
  deterministically for verification.

Also: screen shake was mutating the camera permanently instead of offsetting
it per-render, and the plate's lathe profile touched the axis (degenerate
triangles -> starburst normals) — rebuilt from primitives.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 21:29:51 +10:00
37 changed files with 3942 additions and 91 deletions

127
README.md Normal file
View File

@ -0,0 +1,127 @@
# TOASTSIM
Bread goes in. You are judged.
A browser game about making toast, where the inputs are analog and the output is
scored by a man who has been doing this for thirty-one years.
```bash
npm install
npm run dev # http://localhost:5173
```
## The loop
**Order → toast → drawer → spread → verdict → next day.**
1. **The ticket** tells you what someone wants: how dark, which spread, how much,
which utensil, and whether they'll tolerate burnt bits.
2. **The toaster** has a dial and a lever and no timer. You go by smell.
3. **The drawer** makes you find the right piece of cutlery in a tangle of
similar ones — while your toast goes cold.
4. **The bench** is where the game actually lives. See below.
5. **The judge** scores nine criteria and has something to say about the worst one.
## The mechanic
One control — **the knife's angle** (mouse wheel) — and three behaviours fall out
of it:
| angle | contact | what happens |
|---|---|---|
| flat | wide, low pressure | spreads — *or tears the bread, if the spread is stiffer than the pressure you're allowed to make* |
| steep | narrow, high pressure | scrapes spread back off, or lifts char off burnt toast |
| steep + fast, on bread with nothing left to take | | **gouges** |
The trap: pressure comes from steepness, but past the scrape threshold a steep
knife stops spreading. So a spread can demand more pressure than spreading mode
can physically give:
| situation | yield | angle needed | scrape cliff at 0.62 |
|---|---|---|---|
| fridge butter / cold toast | 0.72 | 0.75 | **impossible** |
| bench butter / cold toast | 0.54 | 0.57 | one click from disaster |
| fridge butter / fresh toast | 0.36 | 0.38 | fine |
| soft butter / fresh toast | 0.16 | 0.12 | dream mode |
**Cold toast with hard butter cannot be spread at any angle.** The way out isn't
technique — it's not dawdling in the drawer. That's why the drawer is there. The
pressure gauge draws both marks so you can watch the gold sit past the red and
understand exactly why you're losing.
Scraping is the other tension: it rescues burnt toast and wrecks evenness doing
it (char 99.9% → 54.5%, evenness 0.029 → 0.226). The judge notices both.
And MITEY is its own skill: spread it on, then take almost all of it back off. He
wants to see the toast *through* it.
## The bread
Same dial, same 18 seconds, four breads:
| bread | 4s | 9s | 18s | char |
|---|---|---|---|---|
| white | 0.22 | 0.54 | 1.08 | 93% |
| multigrain | 0.20 | 0.52 | 1.06 | 92% |
| raisin | 0.30 | 0.76 | 1.14 | **99.8%** |
| sourdough | 0.11 | 0.33 | 0.74 | **14%** |
Sourdough's water has to boil off before the crust can brown, so it stalls and
then accelerates hardest. Raisin bread is sugar: it browns early and burns early.
## How it's built
- **Vite + TypeScript + three.js**, **Rapier** for the drawer. No framework; the
UI is a DOM overlay over one WebGL canvas.
- The slice is the whole game's canvas: an extruded loaf silhouette with four
128×128 scalar fields — `browning`, `dryness`, `spread`, `damage` — packed into
one RGBA8 texture per frame and composited by a custom shader. Everything the
player does writes a field; everything the judge reads is a statistic over one.
- Cutlery is procedural on purpose. The drawer asks you to tell a dessert fork
from a dinner fork, and that's only fair if the silhouettes are authored.
- Physics: compound boxes, never trimeshes. Grabbing is a capped PD spring, so a
snagged piece fights what's lying on it instead of tunnelling through.
### Layout
```
src/
core/ app + loop, input, seeded rng, 2D field, synthesised audio
sim/ bread, slice (geometry/fields/shader), toasting, spreads,
spreading (the star mechanic), cutlery
scenes/ kitchen (toast + spread), drawer (rapier), judge, props
game/ game loop, orders, judging rubric, the judge's lines
ui/ hud, title, cutlery silhouettes
dev.ts dev-only harness — drives real gestures deterministically
```
### Dev harness
The game is driven by mouse gestures over a 3D scene, which makes "does it work"
hard to answer by inspection. In a dev build, `window.__t` drives the real code
paths at a chosen `dt`:
```js
__t.toast(10, 6) // lever down, 10s at power 6, pop, land
__t.spread(9, 0.55) // dip and raster the slice at 0.55 units/sec
__t.stats() // every field statistic the judge will read
```
It's also the escape hatch for headless browsers that never fire rAF —
everything calls `app.step(dt)` directly.
## Assets
Every asset was generated on-device and free via
[MODELBEAST](../MODELBEAST/AGENTS.md), and is reproducible:
```bash
./scripts/gen-assets.sh # fixed seeds; skips anything already present
```
`flux_local``bg_remove_local``hunyuan3d_mlx`. The judge's five expressions
are one fixed seed and one fixed description with only the expression phrase
varying, which is why he stays the same man while his face falls.
The toaster is procedural rather than the generated GLB — it needs a lever we can
actually drive, and a dial that turns.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 882 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 886 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 883 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 887 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 878 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 971 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,4 +1,5 @@
import * as THREE from 'three';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
/** Shared key light direction the slice shader rolls its own lighting, so it
* has to be told the same thing the real lights are doing. */
@ -104,8 +105,11 @@ export class App {
this.renderer.toneMappingExposure = 1.05;
this.camera = new THREE.PerspectiveCamera(42, 1, 0.05, 100);
this.input = new Input(canvas);
// Pauses the clock while the tab is hidden, so you don't come back to charcoal.
this.timer.connect(document);
// Deliberately NOT timer.connect(document): it zeroes the delta whenever
// document.hidden is true, which some embedded/preview browsers report
// permanently — the sim then freezes with no error. Browsers already pause
// rAF for hidden tabs, so the toast can't burn while you're away regardless,
// and the dt clamp below covers the hitch on resume.
this.scene.fog = new THREE.Fog(0x1b1512, 8, 22);
window.addEventListener('resize', () => this.resize());
this.resize();
@ -135,40 +139,77 @@ export class App {
}
resize(): void {
const w = window.innerWidth;
const h = window.innerHeight;
// Guard the zero case: a collapsed or hidden container reports 0, and 0/0
// makes the aspect NaN, which poisons the projection matrix for good. Every
// raycast then silently returns nothing — no error, the game just stops
// responding to the mouse.
const w = Math.max(1, window.innerWidth);
const h = Math.max(1, window.innerHeight);
this.renderer.setSize(w, h, false);
this.camera.aspect = w / h;
this.camera.updateProjectionMatrix();
this.view?.resize?.(w, h);
}
/**
* One simulation + render step. Normally driven by rAF; exposed so tests (and
* headless browsers that never fire rAF) can advance the game deterministically.
*/
step(dt: number): void {
for (const t of this.tickers) t(dt);
this.view?.update(dt);
this.renderFrame(dt);
this.input.endFrame();
}
private savedPos = new THREE.Vector3();
private savedQuat = new THREE.Quaternion();
private renderFrame(dt: number): void {
// Shake is an offset applied for the render only — baking it into the
// camera would let it accumulate and walk the view off the bench.
if (this.shake > 0.0001) {
const s = this.shake;
this.savedPos.copy(this.camera.position);
this.savedQuat.copy(this.camera.quaternion);
this.camera.position.x += (Math.random() - 0.5) * s;
this.camera.position.y += (Math.random() - 0.5) * s;
this.camera.rotateZ((Math.random() - 0.5) * s * 0.35);
this.renderer.render(this.scene, this.camera);
this.camera.position.copy(this.savedPos);
this.camera.quaternion.copy(this.savedQuat);
this.shake *= Math.pow(0.0015, dt);
if (this.shake < 0.0005) this.shake = 0;
} else {
this.renderer.render(this.scene, this.camera);
}
}
start(): void {
const loop = (ts?: number) => {
requestAnimationFrame(loop);
this.timer.update(ts);
// Clamp so a single frame hitch can't teleport the sim forward.
const dt = Math.min(this.timer.getDelta(), 1 / 20);
for (const t of this.tickers) t(dt);
this.view?.update(dt);
if (this.shake > 0.0001) {
const s = this.shake;
this.camera.position.x += (Math.random() - 0.5) * s;
this.camera.position.y += (Math.random() - 0.5) * s;
this.camera.rotateZ((Math.random() - 0.5) * s * 0.35);
this.shake *= Math.pow(0.0015, dt);
if (this.shake < 0.0005) this.shake = 0;
}
this.renderer.render(this.scene, this.camera);
this.input.endFrame();
this.step(Math.min(this.timer.getDelta(), 1 / 20));
};
loop();
}
}
/**
* Cutlery is the point of half this game, and polished metal with nothing to
* reflect renders black. An environment map is not optional here.
*/
export function makeEnvironment(app: App): void {
const pmrem = new THREE.PMREMGenerator(app.renderer);
app.scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
app.scene.environmentIntensity = 0.4;
pmrem.dispose();
}
/** Standard kitchen lighting rig, shared by every view. */
export function makeLights(scene: THREE.Scene): THREE.DirectionalLight {
const key = new THREE.DirectionalLight(0xfff2dd, 2.6);
const key = new THREE.DirectionalLight(0xfff2dd, 1.5);
key.position.copy(LIGHT_DIR).multiplyScalar(6);
key.castShadow = true;
key.shadow.mapSize.set(2048, 2048);
@ -182,8 +223,8 @@ export function makeLights(scene: THREE.Scene): THREE.DirectionalLight {
key.shadow.bias = -0.0016;
key.shadow.normalBias = 0.02;
scene.add(key);
scene.add(new THREE.HemisphereLight(0x9fb4d6, 0x3a2a20, 0.75));
const fill = new THREE.DirectionalLight(0xbdd6ff, 0.45);
scene.add(new THREE.HemisphereLight(0xbfc6d2, 0x4a3526, 0.22));
const fill = new THREE.DirectionalLight(0xbdd6ff, 0.18);
fill.position.set(-3, 2.2, -2.5);
scene.add(fill);
return key;

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

@ -44,7 +44,11 @@ export function valueNoise2D(rng: Rng, w: number, h: number, octaves = 3): Float
const ty = fy - y0;
const sx = tx * tx * (3 - 2 * tx);
const sy = ty * ty * (3 - 2 * ty);
const g = (gx: number, gy: number) => grid[gy * (cells + 1) + gx];
// Clamp: at x === w-1, fx lands exactly on `cells`, so the x0+1 lookup
// runs one past the end of the grid. A typed array returns undefined
// there, which silently turns the whole field into NaN.
const g = (gx: number, gy: number) =>
grid[Math.min(gy, cells) * (cells + 1) + Math.min(gx, cells)];
const a = g(x0, y0) * (1 - sx) + g(x0 + 1, y0) * sx;
const b = g(x0, y0 + 1) * (1 - sx) + g(x0 + 1, y0 + 1) * sx;
out[y * w + x] += (a * (1 - sy) + b * sy) * amp;

122
src/dev.ts Normal file
View File

@ -0,0 +1,122 @@
import * as THREE from 'three';
import type { App } from './core/app';
import type { Game } from './game/game';
/**
* Dev harness. The game is driven by mouse gestures over a 3D scene, which makes
* "does it actually work" hard to answer by inspection so this drives real
* input through the real code paths, deterministically, at a chosen dt.
*
* Also the escape hatch for headless/embedded browsers that never fire rAF:
* everything here calls app.step() directly.
*
* Dev builds only.
*/
export function installDevHarness(app: App, game: Game): void {
const kitchen = game.kitchenView;
const v = new THREE.Vector3();
const toNdc = (x: number, y: number, z: number): [number, number] => {
v.set(x, y, z).project(app.camera);
return [v.x, v.y];
};
const run = (frames: number, dt = 1 / 60) => {
for (let i = 0; i < frames; i++) app.step(dt);
};
const tap = (code: string) => {
window.dispatchEvent(new KeyboardEvent('keydown', { code }));
app.step(1 / 60);
window.dispatchEvent(new KeyboardEvent('keyup', { code }));
};
const point = (x: number, y: number, z: number, down: boolean) => {
const n = toNdc(x, y, z);
app.input.ndc.set(n[0], n[1]);
app.input.down = down;
};
const harness = {
app,
game,
kitchen,
THREE,
run,
tap,
point,
toNdc,
/** Drop the lever, brown for `seconds`, pop, and wait for it to land. */
toast(seconds = 10, power?: number) {
if (power !== undefined) kitchen.power = power;
tap('Space');
run(Math.round(seconds * 60));
tap('Space');
run(200);
return harness.stats();
},
dip() {
point(3.15, 0.29, 0.45, true);
run(4);
app.input.down = false;
app.step(1 / 60);
},
/**
* Raster the whole slice. `speed` is world-units/sec the thing that
* actually decides how thick the spread goes on.
*/
spread(passes = 9, speed = 0.55, angle?: number) {
const sl = kitchen.currentSlice;
if (angle !== undefined) kitchen.knife.angle = angle;
harness.dip();
const stepDist = speed / 60;
for (let p = 0; p < passes; p++) {
const z = 0.55 - 0.44 + (p / Math.max(1, passes - 1)) * 0.88;
const steps = Math.ceil(0.96 / stepDist);
for (let s = 0; s <= steps; s++) {
if (kitchen.knife.load < 0.02) harness.dip();
const x = 1.45 + (p % 2 ? 1 : -1) * (0.48 - s * stepDist);
point(x, sl.topY, z, true);
app.step(1 / 60);
}
}
app.input.down = false;
harness.park();
return harness.stats();
},
/** Park the cursor off the toast so a screenshot isn't full of knife. */
park() {
point(2.5, kitchen.currentSlice.topY, 1.3, false);
app.step(1 / 60);
},
stats() {
const sl = kitchen.currentSlice;
const b = sl.browning.stats(sl.mask);
const s = sl.spread.stats(sl.mask);
const d = sl.damage.stats(sl.mask);
const r = (n: number) => +n.toFixed(3);
return {
phase: kitchen.phase,
warmth: r(sl.warmth),
browning: { mean: r(b.mean), stdev: r(b.stdev), max: r(b.max) },
char: r(sl.browning.fraction(sl.mask, (x) => x > 0.85)),
spread: { mean: r(s.mean), stdev: r(s.stdev), max: r(s.max) },
coverage: r(sl.spread.fraction(sl.mask, (x) => x > 0.02)),
damage: r(d.mean),
load: r(kitchen.knife.load),
angle: r(kitchen.knife.angle),
mode: kitchen.lastFx?.mode ?? 'idle',
};
},
/** Move the camera somewhere for a screenshot without disturbing the sim. */
look(pos: [number, number, number], at: [number, number, number]) {
app.camera.position.set(...pos);
app.camera.lookAt(new THREE.Vector3(...at));
app.renderer.render(app.scene, app.camera);
},
};
(window as unknown as { __t: unknown }).__t = harness;
}

211
src/game/game.ts Normal file
View File

@ -0,0 +1,211 @@
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';
import { Title } from '../ui/title';
const SAVE_KEY = 'toastsim.save';
interface Save {
day: number;
total: number;
best: Record<string, number>;
}
/**
* The arcade loop: an order, a slice, a verdict, the next order. The day counter
* is the whole progression everything else is the orders getting nastier.
*/
export class Game {
private kitchen: KitchenView;
private judgeView: JudgeView;
private drawer: DrawerView;
private rng = new Rng(20260716);
private order!: Order;
private startedAt = 0;
private save: Save;
private ticket: Panel;
private ticketEl!: HTMLElement;
constructor(private app: App) {
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;
});
// Sit on the title until the player clicks — which also gives us the user
// gesture the browser wants before any audio is allowed to make a sound.
this.kitchen.root.visible = false;
this.ticket.hide();
new Title(this.save.day, () => {
audio.resume();
this.beginDay();
});
}
get currentOrder(): Order {
return this.order;
}
get kitchenView(): KitchenView {
return this.kitchen;
}
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;
const o =
day <= DAYS.length
? DAYS[day - 1]
: proceduralOrder(day, () => this.rng.next());
if (!o.text) o.text = `${o.browningName}, ${o.amount} ${o.spread === 'mitey' ? 'MITEY' : o.spread}. ${o.noChar ? 'No burnt bits.' : ''}`;
this.order = o;
this.startedAt = performance.now();
const s = this.judgeView.release();
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;
this.ticket.show();
this.renderTicket();
}
private renderTicket(): void {
const o = this.order;
this.ticketEl.innerHTML = '';
el('div', 'ticket-day', this.ticketEl, `DAY ${o.day}`);
el('div', 'ticket-who', this.ticketEl, o.who);
el('div', 'ticket-text', this.ticketEl, `${o.text}`);
const spec = el('div', 'ticket-spec', this.ticketEl);
el('span', 'chip', spec, o.browningName);
el('span', 'chip', spec, `${o.amount} ${o.spread === 'mitey' ? 'MITEY' : o.spread}`);
if (o.noChar) el('span', 'chip warn', spec, 'no burnt bits');
el('span', 'chip tool', spec, `use the ${o.tool.replace(/_/g, ' ')}`);
el(
'div',
'ticket-hint',
this.ticketEl,
`butter is ${o.butterSoftness > 0.6 ? 'soft' : o.butterSoftness > 0.3 ? 'firm' : 'fridge-hard'} today`,
);
}
/** Called when the player hands the plate over. */
serve(): void {
const seconds = (performance.now() - this.startedAt) / 1000;
const slice = this.kitchen.currentSlice;
const v = judge(slice, this.order, this.kitchen.toolId, seconds);
this.save.total += v.total;
const key = `day${this.order.day}`;
this.save.best[key] = Math.max(this.save.best[key] ?? 0, v.total);
writeSave(this.save);
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();
}
/** Test hook. */
gradeNow(): { total: number; grade: Grade } {
const v = judge(this.kitchen.currentSlice, this.order, this.kitchen.toolId, 60);
return { total: v.total, grade: v.grade };
}
}
function loadSave(): Save {
try {
const raw = localStorage.getItem(SAVE_KEY);
if (raw) {
const s = JSON.parse(raw) as Partial<Save>;
if (typeof s.day === 'number' && s.day >= 1) {
return { day: s.day, total: s.total ?? 0, best: s.best ?? {} };
}
}
} catch {
/* a corrupt save is not worth a crash */
}
return { day: 1, total: 0, best: {} };
}
function writeSave(s: Save): void {
try {
localStorage.setItem(SAVE_KEY, JSON.stringify(s));
} catch {
/* private mode; the day counter just won't persist */
}
}
export { nameBrowning };

182
src/game/judging.ts Normal file
View File

@ -0,0 +1,182 @@
import type { Slice } from '../sim/slice';
import { CHAR_THRESHOLD } from '../sim/slice';
import { SPREADS, amountClassOf } from '../sim/spreads';
import { TOOLS, type ToolId } from '../sim/cutlery';
import type { Order } from './orders';
export interface Criterion {
key: string;
label: string;
/** 0..1 */
score: number;
weight: number;
/** What the number actually was, in words. */
detail: string;
}
export type Grade = 'S' | 'A' | 'B' | 'C' | 'F';
export interface Verdict {
criteria: Criterion[];
/** 0..10 */
total: number;
grade: Grade;
best: Criterion;
worst: Criterion;
lines: string[];
}
const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v);
/**
* Every number here is read off the same fields the player was pushing around
* with the knife, so the scorecard can always point at something real.
*/
export function judge(slice: Slice, order: Order, usedTool: ToolId, seconds: number): Verdict {
const b = slice.browning.stats(slice.mask);
const s = slice.spread.stats(slice.mask);
const d = slice.damage.stats(slice.mask);
const def = SPREADS[order.spread];
const [lo, hi] = def.amounts[order.amount];
const target = (lo + hi) / 2;
const charFrac = slice.browning.fraction(slice.mask, (v) => v > CHAR_THRESHOLD);
const coverage = slice.spread.fraction(slice.mask, (v) => v >= lo * 0.75);
// Uniformity only over the bits you actually covered — punishing the variance
// of a half-spread slice twice (here and in coverage) isn't fair.
const covered = spreadStdevOverCovered(slice, lo * 0.75);
const criteria: Criterion[] = [
{
key: 'browning',
label: 'Browning',
score: clamp01(1 - Math.abs(b.mean - order.browning) / 0.3),
weight: 1.5,
detail: `${b.mean.toFixed(2)} against ${order.browning.toFixed(2)} asked`,
},
{
key: 'evenness',
label: 'Evenness',
score: clamp01(1 - b.stdev / 0.2),
weight: 1.1,
detail: `variation ${b.stdev.toFixed(3)}`,
},
{
key: 'coverage',
label: 'Coverage',
score: clamp01((coverage - 0.15) / 0.75),
weight: 1.3,
detail: `${Math.round(coverage * 100)}% of the slice`,
},
{
key: 'uniformity',
label: 'Uniformity',
score: clamp01(1 - covered / 0.22),
weight: 0.9,
detail: `variation ${covered.toFixed(3)}`,
},
{
key: 'char',
label: 'Char',
score: order.noChar ? clamp01(1 - charFrac / 0.12) : clamp01(1 - charFrac / 0.45),
weight: order.noChar ? 1.3 : 0.5,
detail: charFrac < 0.005 ? 'none' : `${Math.round(charFrac * 100)}% burnt`,
},
{
key: 'integrity',
label: 'Integrity',
score: clamp01(1 - d.mean / 0.06),
weight: 1.2,
detail: d.mean < 0.002 ? 'intact' : `torn over ${Math.round(slice.damage.fraction(slice.mask, (v) => v > 0.02) * 100)}%`,
},
{
key: 'amount',
label: 'The Right Amount',
score: amountScore(s.mean, lo, hi, target),
weight: 1.4,
detail: `${amountWord(slice, order)}${order.amount} asked`,
},
{
key: 'tool',
label: 'Utensil',
score: usedTool === order.tool ? 1 : TOOLS[usedTool].ideal ? 0.6 : 0.25,
weight: 0.4,
detail: usedTool === order.tool ? TOOLS[usedTool].name : `${TOOLS[usedTool].name}, not the ${TOOLS[order.tool].name}`,
},
{
key: 'time',
label: 'Service',
score: clamp01(1 - (seconds - 45) / 90),
weight: 0.3,
detail: `${Math.round(seconds)}s`,
},
];
let sum = 0;
let wsum = 0;
for (const c of criteria) {
sum += c.score * c.weight;
wsum += c.weight;
}
const total = (sum / wsum) * 10;
// Best/worst ignore Service — nobody wants a verdict about the clock.
const rankable = criteria.filter((c) => c.key !== 'time');
const sorted = [...rankable].sort((a, b2) => a.score - b2.score);
const worst = sorted[0];
const best = sorted[sorted.length - 1];
return {
criteria,
total,
grade: gradeOf(total),
best,
worst,
lines: [],
};
}
function spreadStdevOverCovered(slice: Slice, floor: number): number {
const sp = slice.spread.data;
const m = slice.mask.data;
let sum = 0;
let n = 0;
for (let i = 0; i < sp.length; i++) {
if (m[i] < 0.5 || sp[i] < floor) continue;
sum += sp[i];
n++;
}
if (n < 8) return 1;
const mean = sum / n;
let acc = 0;
for (let i = 0; i < sp.length; i++) {
if (m[i] < 0.5 || sp[i] < floor) continue;
const d = sp[i] - mean;
acc += d * d;
}
return Math.sqrt(acc / n);
}
/** Full marks anywhere inside the band, falling away outside it. */
function amountScore(mean: number, lo: number, hi: number, target: number): number {
if (mean >= lo && mean <= hi) return 1;
const dist = mean < lo ? lo - mean : mean - hi;
return clamp01(1 - dist / (target * 1.5));
}
function amountWord(slice: Slice, order: Order): string {
const def = SPREADS[order.spread];
const mean = slice.spread.stats(slice.mask).mean;
const cls = amountClassOf(def, mean);
if (cls === 'none') return 'essentially none';
if (cls === 'obscene') return 'an obscene amount';
return cls;
}
export function gradeOf(total: number): Grade {
if (total >= 9.2) return 'S';
if (total >= 7.6) return 'A';
if (total >= 5.8) return 'B';
if (total >= 3.8) return 'C';
return 'F';
}

203
src/game/lines.ts Normal file
View File

@ -0,0 +1,203 @@
import type { Criterion, Grade, Verdict } from './judging';
import type { Order } from './orders';
import { SPREADS } from '../sim/spreads';
/**
* The judge. Deadpan, specific, never cruel about anything except MITEY, about
* which he is unwell.
*
* Lines are keyed to the criterion that actually decided the score, so the
* verdict always tells you the same thing the scorecard does just meaner.
*/
type Bank = Record<string, { bad: string[]; good: string[] }>;
const BANK: Bank = {
browning: {
bad: [
'I asked for {asked}. This is {got}. These are different words.',
'You have made something adjacent to toast.',
'The colour is a decision. You appear not to have made one.',
'This is bread that has been near a toaster. Not the same thing.',
'Somewhere in this kitchen is a dial. It has numbers on it.',
],
good: [
'The colour is correct. I want that on the record.',
'That is the shade I asked for. Precisely the shade.',
'Colour: no notes.',
],
},
evenness: {
bad: [
'One half of this has had a completely different morning to the other.',
'It is striped. Toast should not be striped.',
'I can see where you rescued it. So can everyone.',
'This slice has weather.',
'There are three separate climates on this toast.',
],
good: [
'Even, corner to corner. That is harder than it looks.',
'No hot spots, no pale spots. Good.',
'Uniform. Genuinely uniform.',
],
},
coverage: {
bad: [
'You have buttered an island and left a continent.',
'The corners are not decorative. They are toast.',
'There is a border of bare toast. Why is there a border.',
'You got most of it. Most.',
'A generous interpretation of the word "spread".',
],
good: [
'Edge to edge. Somebody was raised properly.',
'Full coverage. Not a bare corner on it.',
'You went to the crusts. I noticed.',
],
},
uniformity: {
bad: [
'It has a thick end and a thin end, like a bad argument.',
'Lumpy. I can feel it from here.',
'The {spread} is doing whatever it likes.',
'This is not a film. It is terrain.',
],
good: [
'Flat, even, no ridges. Very tidy.',
'Consistent all the way across.',
'A properly level spread. Rare.',
],
},
char: {
bad: [
'I said no burnt bits. This is a burnt bit convention.',
'There is carbon on this. Actual carbon.',
'You burnt it. Then you served it. The second decision is the interesting one.',
'I asked for toast, not evidence.',
],
good: [
'Not a scorch on it.',
'No char. Well caught.',
'Clean. Nothing burnt.',
],
},
integrity: {
bad: [
'You have not spread this. You have assaulted it.',
'The surface is gone. You dragged it off and served the wound.',
'Cold butter. Warm toast. One of these was your job.',
'There is a crater. The coverage is admirable. The crater is not.',
'This bread has been through something and would rather not discuss it.',
],
good: [
'Not a tear on it. That is the whole trick, and you did it.',
'The surface is perfect. No drag, no gouges.',
'Intact. You let something be warm first, didn\'t you.',
],
},
amount: {
bad: [
'I said {asked}. You have applied {got}.',
'That is not {asked}. That is a statement.',
'Somebody has never been told no.',
],
good: [
'Exactly the right amount. Exactly.',
'The quantity is correct. Thank you.',
],
},
tool: {
bad: [
'You did this with a {tool}. I can tell. Everyone can tell.',
'The {tool} was a choice.',
'There was a whole drawer. You picked the {tool}.',
],
good: [
'Right tool. It shows.',
'Correct utensil. Small thing. Not nothing.',
],
},
};
/** MITEY gets its own vocabulary. */
const MITEY_CRIMES = [
'This is not a scrape of MITEY. This is a hate crime.',
'You have applied MITEY the way one applies paint.',
'I asked to see the toast THROUGH it. I can see nothing. There is only night.',
'That is a decade of MITEY on one slice. Some of us have to live here.',
];
const GRADE_OPENERS: Record<Grade, string[]> = {
S: ['Well.', 'I have been doing this for thirty-one years.', 'Hm.'],
A: ['Close.', 'Nearly.', 'Good.'],
B: ['Adequate.', 'It is toast.', 'Fine.'],
C: ['Hm.', 'Right.', 'Well, it exists.'],
F: ['No.', 'Absolutely not.', 'Take it away.'],
};
const GRADE_CLOSERS: Record<Grade, string[]> = {
S: [
'That is the toast. Do it again tomorrow and I will start to worry.',
'I have no notes. I dislike having no notes.',
'Perfect. Irritatingly perfect.',
],
A: ['Very nearly the toast.', 'One thing away from excellent.', 'Good work. Not finished work.'],
B: ['It will be eaten. That is the bar it cleared.', 'Serviceable.', 'Nobody will complain. Nobody will remember.'],
C: ['I have eaten worse. Not recently.', 'Try again.', 'This is why we have a scorecard.'],
F: ['I am writing this down.', 'Start again. Properly.', 'The bread deserved better.'],
};
function fill(s: string, order: Order, v: Verdict, tool: string): string {
const spread = SPREADS[order.spread];
return s
.replace('{asked}', order.amount === 'thin' ? 'a scrape' : order.amount)
.replace('{got}', v.criteria.find((c) => c.key === 'amount')?.detail.split(' — ')[0] ?? 'that')
// Mid-sentence, so lowercase — except MITEY, which is shouted on principle.
.replace('{spread}', spread.id === 'mitey' ? 'MITEY' : spread.name.toLowerCase())
.replace('{tool}', tool.toLowerCase());
}
/**
* Two lines: what went worst, and what went best. Which is what the scorecard
* says too this just says it with feeling.
*/
export function verdictLines(v: Verdict, order: Order, tool: string, rand: () => number): string[] {
const pick = <T>(a: T[]): T => a[Math.floor(rand() * a.length)];
const out: string[] = [];
out.push(pick(GRADE_OPENERS[v.grade]));
const mitey = order.spread === 'mitey';
const amountBad = (v.criteria.find((c) => c.key === 'amount')?.score ?? 1) < 0.4;
if (mitey && amountBad) {
out.push(pick(MITEY_CRIMES));
} else if (v.worst.score < 0.72) {
out.push(fill(pick(BANK[v.worst.key]?.bad ?? ['Hm.']), order, v, tool));
}
if (v.best.score > 0.85 && v.best.key !== v.worst.key) {
out.push(fill(pick(BANK[v.best.key]?.good ?? []), order, v, tool));
}
out.push(pick(GRADE_CLOSERS[v.grade]));
return out.filter(Boolean);
}
/** Which portrait to show. */
export function judgeFace(grade: Grade): string {
switch (grade) {
case 'S':
return 'impressed';
case 'A':
return 'intrigued';
case 'B':
return 'neutral';
case 'C':
return 'disappointed';
default:
return 'horrified';
}
}
export function describeCriterion(c: Criterion): string {
return c.detail;
}

174
src/game/orders.ts Normal file
View File

@ -0,0 +1,174 @@
import type { BreadId } from '../sim/bread';
import type { AmountClass, SpreadId } from '../sim/spreads';
import type { ToolId } from '../sim/cutlery';
export interface Order {
day: number;
bread: BreadId;
spread: SpreadId;
amount: AmountClass;
/** Target mean browning, 0..1. */
browning: number;
browningName: string;
/** The customer specifically does not want char. Some don't care. */
noChar: boolean;
/** What the drawer will make you find. */
tool: ToolId;
/** 0 = fridge-hard, 1 = soft. The single cruellest dial in the game. */
butterSoftness: number;
/** How the customer says it. */
text: string;
/** Who's asking. */
who: string;
}
export const BROWNING_NAMES: [number, string][] = [
[0.18, 'barely warmed through'],
[0.34, 'light'],
[0.5, 'golden'],
[0.66, 'well done'],
[0.8, 'dark'],
];
export function nameBrowning(v: number): string {
let best = BROWNING_NAMES[0];
for (const b of BROWNING_NAMES) if (Math.abs(b[0] - v) < Math.abs(best[0] - v)) best = b;
return best[1];
}
/**
* The handwritten first week. The curve is deliberate: day 1 is butter on white
* with a butter knife and butter that's been out all morning every dial set to
* "forgiving". By day 7 you're doing a translucent film of MITEY on thick wet
* sourdough with fridge-hard butter... and a steak knife, because the drawer is
* the drawer.
*/
export const DAYS: Order[] = [
{
day: 1,
bread: 'white',
spread: 'butter',
amount: 'normal',
browning: 0.5,
browningName: 'golden',
noChar: true,
tool: 'butter_knife',
butterSoftness: 0.85,
who: 'Deidre',
text: 'Just golden, love. Butter. Normal amount of butter.',
},
{
day: 2,
bread: 'white',
spread: 'butter',
amount: 'thin',
browning: 0.66,
browningName: 'well done',
noChar: true,
tool: 'dinner_knife',
butterSoftness: 0.55,
who: 'Ray',
text: "Well done. Scrape of butter — I said a scrape, not a shovel.",
},
{
day: 3,
bread: 'multigrain',
spread: 'peanut',
amount: 'thick',
browning: 0.42,
browningName: 'light',
noChar: true,
tool: 'dessert_spoon',
butterSoftness: 0.5,
who: 'a child',
text: 'PEANUT BUTTER. LOTS. Bread barely toasted please.',
},
{
day: 4,
bread: 'raisin',
spread: 'butter',
amount: 'normal',
browning: 0.44,
browningName: 'light',
noChar: true,
tool: 'butter_knife',
butterSoftness: 0.3,
who: 'Deidre',
text: "Raisin loaf. Careful — it catches. I'll know if it catches.",
},
{
day: 5,
bread: 'white',
spread: 'mitey',
amount: 'normal',
browning: 0.62,
browningName: 'well done',
noChar: true,
tool: 'teaspoon',
butterSoftness: 0.4,
who: 'Ray',
text: 'MITEY. Proper amount. You know what proper means.',
},
{
day: 6,
bread: 'sourdough',
spread: 'peanut',
amount: 'normal',
browning: 0.72,
browningName: 'dark',
noChar: false,
tool: 'steak_knife',
butterSoftness: 0.25,
who: 'a man in a hurry',
text: "Dark. Sourdough. Don't care if it catches a bit. Peanut butter, even.",
},
{
day: 7,
bread: 'sourdough',
spread: 'mitey',
amount: 'thin',
browning: 0.68,
browningName: 'well done',
noChar: true,
tool: 'steak_knife',
butterSoftness: 0.0,
who: 'the inspector himself',
text: "Thick sourdough, well done, and a *scrape* of MITEY. I want to see the toast through it.",
},
];
/** Days 8+ — keep going, with everything cranked. */
export function proceduralOrder(day: number, rand: () => number): Order {
const breads: BreadId[] = ['white', 'multigrain', 'raisin', 'sourdough'];
const spreads: SpreadId[] = ['butter', 'peanut', 'mitey'];
const amounts: AmountClass[] = ['thin', 'normal', 'thick'];
const tools: ToolId[] = [
'butter_knife',
'dinner_knife',
'steak_knife',
'spreader',
'dinner_fork',
'dessert_fork',
'teaspoon',
'dessert_spoon',
'soup_spoon',
];
const pick = <T>(a: T[]): T => a[Math.floor(rand() * a.length)];
const spread = pick(spreads);
const amount: AmountClass = spread === 'mitey' ? (rand() < 0.75 ? 'thin' : 'normal') : pick(amounts);
const browning = 0.3 + rand() * 0.5;
return {
day,
bread: pick(breads),
spread,
amount,
browning,
browningName: nameBrowning(browning),
noChar: rand() < 0.8,
tool: pick(tools),
// It only gets colder from here.
butterSoftness: Math.max(0, 0.4 - (day - 8) * 0.08) * rand(),
who: pick(['Deidre', 'Ray', 'a regular', 'someone new', 'the inspector himself']),
text: '',
};
}

View File

@ -1,64 +1,21 @@
import * as THREE from 'three';
import { App, makeLights } from './core/app';
import { Slice } from './sim/slice';
import { BREADS } from './sim/bread';
import { Rng } from './core/rng';
import { App, makeEnvironment, makeLights } from './core/app';
import { Game } from './game/game';
import './style.css';
const canvas = document.getElementById('c') as HTMLCanvasElement;
const app = new App(canvas);
makeLights(app.scene);
app.scene.background = new THREE.Color(0x1d1613);
makeEnvironment(app);
app.scene.background = new THREE.Color(0x241a15);
const bench = new THREE.Mesh(
new THREE.BoxGeometry(9, 0.3, 5),
new THREE.MeshStandardMaterial({ color: 0x8a5a33, roughness: 0.85 }),
);
bench.position.y = -0.15;
bench.receiveShadow = true;
app.scene.add(bench);
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();
const slice = new Slice(BREADS.white, new Rng(7));
slice.mesh.position.y = 0.12;
app.scene.add(slice.mesh);
// M0 smoke test: a browning gradient + a smear of butter, so the shader gets exercised.
for (let y = 0; y < slice.browning.n; y++) {
for (let x = 0; x < slice.browning.n; x++) {
const i = y * slice.browning.n + x;
slice.browning.data[i] = (x / slice.browning.n) * 1.0;
const dx = x / slice.spread.n - 0.5;
const dy = y / slice.spread.n - 0.55;
slice.spread.data[i] = Math.max(0, 0.45 - Math.hypot(dx, dy) * 1.2);
}
if (import.meta.env.DEV) {
void import('./dev').then((m) => m.installDevHarness(app, game));
}
slice.setSpread({
id: 'butter',
name: 'Butter',
blurb: '',
color: [0.97, 0.72, 0.19],
gloss: 0.85,
opaqueAt: 0.5,
bump: 0.1,
baseYield: 0.72,
tempSoftening: 0.78,
viscosity: 0.72,
pickup: 0.55,
transferRate: 1.5,
drag: 0.5,
scrapeEase: 1,
amounts: { thin: [0.1, 0.22], normal: [0.25, 0.5], thick: [0.55, 0.9] },
});
slice.touch();
slice.sync();
(window as unknown as { __t: unknown }).__t = { app, slice, THREE };
app.camera.position.set(0, 1.5, 1.6);
app.camera.lookAt(0, 0.1, 0);
app.onTick((dt) => {
slice.mesh.rotation.y += dt * 0.4;
});
app.start();

49
src/scenes/assets.ts Normal file
View File

@ -0,0 +1,49 @@
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
const loader = new GLTFLoader();
const cache = new Map<string, Promise<THREE.Group>>();
/**
* Load a generated GLB and make it usable without touching the file.
*
* Meshes that came out of an image-to-3D pipeline arrive at an arbitrary scale,
* with an arbitrary pivot, sitting wherever the mesher felt like so every one
* is normalised on load: recentre on its own bounding box, scale so the longest
* axis is `size`, and drop it so it sits on y=0. Hand-fixing the assets instead
* would mean re-fixing them every time they're regenerated.
*/
export function loadProp(url: string, size: number): Promise<THREE.Group> {
const key = `${url}@${size}`;
let p = cache.get(key);
if (!p) {
p = loader.loadAsync(url).then((gltf) => {
const root = gltf.scene;
const box = new THREE.Box3().setFromObject(root);
const dim = box.getSize(new THREE.Vector3());
const centre = box.getCenter(new THREE.Vector3());
const longest = Math.max(dim.x, dim.y, dim.z) || 1;
const s = size / longest;
// Wrap it: scaling the loaded scene directly fights whatever transforms
// the exporter already baked into it.
const wrap = new THREE.Group();
root.position.set(-centre.x, -box.min.y, -centre.z);
root.scale.setScalar(1);
const inner = new THREE.Group();
inner.add(root);
inner.scale.setScalar(s);
wrap.add(inner);
root.traverse((o) => {
if ((o as THREE.Mesh).isMesh) {
o.castShadow = true;
o.receiveShadow = true;
}
});
return wrap;
});
cache.set(key, p);
}
return p;
}

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

169
src/scenes/judge.ts Normal file
View File

@ -0,0 +1,169 @@
import * as THREE from 'three';
import type { App, View } from '../core/app';
import type { Slice } from '../sim/slice';
import type { Order } from '../game/orders';
import type { Verdict } from '../game/judging';
import { judgeFace, verdictLines } from '../game/lines';
import { TOOLS, type ToolId } from '../sim/cutlery';
import { clear, el, Panel } from '../ui/hud';
/**
* The scorecard. This is the screen the game is actually about: every number
* points at something the player did, and the toast turns slowly under a light
* so they can see it for themselves.
*/
export class JudgeView implements View {
readonly root = new THREE.Group();
private pedestal: THREE.Group;
private slice: Slice | null = null;
private spin = 0;
private heatmap: 0 | 1 | 2 = 0;
private panel: Panel;
private cardEl!: HTMLElement;
private linesEl!: HTMLElement;
private stampEl!: HTMLElement;
private faceEl!: HTMLImageElement;
private orderEl!: HTMLElement;
private nextBtn!: HTMLButtonElement;
private viewBtn!: HTMLButtonElement;
onNext: (() => void) | null = null;
constructor(private app: App) {
this.pedestal = new THREE.Group();
const top = new THREE.Mesh(
new THREE.CylinderGeometry(1.05, 1.05, 0.12, 48),
new THREE.MeshStandardMaterial({ color: 0x2a2320, roughness: 0.55 }),
);
top.receiveShadow = true;
this.pedestal.add(top);
const stem = new THREE.Mesh(
new THREE.CylinderGeometry(0.5, 0.72, 0.9, 32),
new THREE.MeshStandardMaterial({ color: 0x1d1815, roughness: 0.7 }),
);
stem.position.y = -0.5;
this.pedestal.add(stem);
this.pedestal.position.set(0, 0.9, 0);
this.root.add(this.pedestal);
const spot = new THREE.SpotLight(0xfff0d8, 90, 12, 0.5, 0.55, 1.6);
spot.position.set(1.4, 4.2, 2.2);
spot.target = this.pedestal;
spot.castShadow = true;
spot.shadow.mapSize.set(1024, 1024);
this.root.add(spot);
const rim = new THREE.SpotLight(0x88a8ff, 40, 12, 0.6, 0.7, 1.6);
rim.position.set(-2.6, 2.4, -2.2);
rim.target = this.pedestal;
this.root.add(rim);
this.panel = new Panel('judge-panel');
this.buildUi();
this.panel.hide();
}
private buildUi(): void {
const p = this.panel.root;
const left = el('div', 'judge-left', p);
this.faceEl = el('img', 'judge-face', left);
this.faceEl.alt = '';
this.linesEl = el('div', 'judge-lines', left);
const right = el('div', 'judge-right', p);
this.orderEl = el('div', 'judge-order', right);
this.cardEl = el('div', 'judge-card', right);
const foot = el('div', 'judge-foot', right);
this.stampEl = el('div', 'judge-stamp', foot);
const btns = el('div', 'judge-btns', foot);
this.viewBtn = el('button', 'btn ghost', btns, 'HEATMAP');
this.viewBtn.addEventListener('click', () => this.cycleHeatmap());
this.nextBtn = el('button', 'btn', btns, 'NEXT ORDER');
this.nextBtn.addEventListener('click', () => this.onNext?.());
}
private cycleHeatmap(): void {
this.heatmap = ((this.heatmap + 1) % 3) as 0 | 1 | 2;
this.slice?.setHeatmap(this.heatmap);
this.viewBtn.textContent = ['HEATMAP', 'BROWNING', 'SPREAD'][this.heatmap];
}
show(slice: Slice, verdict: Verdict, order: Order, tool: ToolId, rand: () => number): void {
this.slice = slice;
this.spin = 0;
this.heatmap = 0;
slice.setHeatmap(0);
slice.setPresentation(true);
this.viewBtn.textContent = 'HEATMAP';
// Take the toast off the bench and put it under the light.
this.pedestal.add(slice.mesh);
slice.mesh.position.set(0, 0.06 + slice.halfThickness, 0);
slice.mesh.rotation.set(0, 0, 0);
slice.mesh.scale.setScalar(1.55);
this.orderEl.textContent = `Day ${order.day} · ${order.who} asked for ${order.browningName}, ${order.amount} ${order.spread === 'mitey' ? 'MITEY' : order.spread}`;
clear(this.cardEl);
for (const c of verdict.criteria) {
const row = el('div', 'crit', this.cardEl);
el('span', 'crit-name', row, c.label);
const bar = el('span', 'crit-bar', row);
const fill = el('span', 'crit-fill', bar);
fill.style.width = `${Math.round(c.score * 100)}%`;
fill.style.background = c.score > 0.8 ? '#6cbf6c' : c.score > 0.5 ? '#e8a53a' : '#c0392b';
el('span', 'crit-detail', row, c.detail);
}
const totalRow = el('div', 'crit total', this.cardEl);
el('span', 'crit-name', totalRow, 'TOTAL');
el('span', 'crit-bar', totalRow);
el('span', 'crit-detail', totalRow, `${verdict.total.toFixed(1)} / 10`);
clear(this.linesEl);
const lines = verdictLines(verdict, order, TOOLS[tool].name, rand);
for (const l of lines) el('p', undefined, this.linesEl, l);
this.faceEl.src = `/assets/img/judge_${judgeFace(verdict.grade)}.png`;
this.stampEl.textContent = verdict.grade;
this.stampEl.className = `judge-stamp grade-${verdict.grade}`;
// re-trigger the stamp animation
this.stampEl.style.animation = 'none';
void this.stampEl.offsetHeight;
this.stampEl.style.animation = '';
}
/** Hand the toast back so the kitchen can bin it. */
release(): Slice | null {
const s = this.slice;
if (s) {
s.mesh.scale.setScalar(1);
s.setHeatmap(0);
s.setPresentation(false);
this.pedestal.remove(s.mesh);
}
this.slice = null;
return s;
}
enter(): void {
this.panel.show();
this.app.camera.position.set(0.15, 2.35, 3.5);
this.app.camera.lookAt(0, 1.15, 0);
this.app.shake = 0.05;
}
exit(): void {
this.panel.hide();
}
update(dt: number): void {
this.spin += dt * 0.42;
if (this.slice) this.slice.mesh.rotation.y = this.spin;
this.app.camera.position.set(0.15, 2.35, 3.5);
this.app.camera.lookAt(0, 1.15, 0);
}
dispose(): void {
this.panel.dispose();
}
}

484
src/scenes/kitchen.ts Normal file
View File

@ -0,0 +1,484 @@
import * as THREE from 'three';
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, readToast, smellCue, toastStep } from '../sim/toasting';
import {
SPREADS,
SCRAPE_ANGLE,
effectiveYield,
pressureFromAngle,
type SpreadDef,
type SpreadId,
} from '../sim/spreads';
import { dip, newKnife, stroke, type Knife, type StrokeFx } from '../sim/spreading';
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;
const PLATE_X = 1.45;
const PLATE_Z = 0.55;
const PLATE_Y = 0.05;
const POT_X = 3.15;
const POT_Z = 0.45;
export type Phase = 'ready' | 'toasting' | 'flying' | 'landed' | 'spreading';
interface CamShot {
pos: THREE.Vector3;
look: THREE.Vector3;
}
const SHOTS: Record<string, CamShot> = {
toast: { pos: new THREE.Vector3(-1.1, 3.0, 4.9), look: new THREE.Vector3(-1.3, 0.85, -0.2) },
spread: { pos: new THREE.Vector3(2.05, 2.4, 3.0), look: new THREE.Vector3(2.05, 0.1, 0.35) },
};
/** The bench: toaster, plate, pot, knife. Runs the toasting and spreading phases. */
export class KitchenView implements View {
readonly root = new THREE.Group();
private toaster: Toaster;
private slice!: Slice;
private heat!: Float32Array;
private rng: Rng;
phase: Phase = 'ready';
power = 6;
/** 0 = straight from the fridge, 1 = left out on the bench all morning. */
butterSoftness = 0.15;
toolId: ToolId = 'butter_knife';
spreadId: SpreadId = 'butter';
private leverT = 0;
private vel = new THREE.Vector3();
private spin = 0;
private toastSeconds = 0;
knife: Knife = newKnife();
private knifeRig: KnifeRig;
private pot: THREE.Group | null = null;
private potHit: THREE.Mesh | null = null;
private ray = new THREE.Raycaster();
private hitPoint = new THREE.Vector3();
private uv = new THREE.Vector2();
private plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
lastFx: StrokeFx | null = null;
private camPos = new THREE.Vector3();
private camLook = new THREE.Vector3();
private shot: CamShot = SHOTS.toast;
private panel: Panel;
private cueEl!: HTMLElement;
private hintEl!: HTMLElement;
private dialRow!: HTMLElement;
private gaugeFill!: HTMLElement;
private gaugeYield!: HTMLElement;
private gaugeScrape!: HTMLElement;
private modeEl!: HTMLElement;
private loadFill!: HTMLElement;
private breadSel!: HTMLSelectElement;
private spreadSel!: HTMLSelectElement;
private toolSel!: HTMLSelectElement;
private softInput!: HTMLInputElement;
private softVal!: HTMLElement;
/** 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,
seed = 1,
) {
this.rng = new Rng(seed);
this.root.add(makeBench());
this.toaster = makeToaster();
this.toaster.root.position.set(TOASTER_X, 0, -0.35);
this.root.add(this.toaster.root);
const plate = makePlate();
plate.position.set(PLATE_X, 0, PLATE_Z);
this.root.add(plate);
this.knifeRig = new KnifeRig(TOOLS[this.toolId]);
this.knifeRig.root.visible = false;
this.root.add(this.knifeRig.root);
this.camPos.copy(SHOTS.toast.pos);
this.camLook.copy(SHOTS.toast.look);
this.panel = new Panel('toast-panel');
this.buildUi();
this.loadBread('white');
}
private buildUi(): void {
const p = this.panel.root;
this.dialRow = el('div', 'row', p);
el('label', 'lbl', this.dialRow, 'BROWNING');
const dial = el('input', 'dial', this.dialRow);
dial.type = 'range';
dial.min = '1';
dial.max = '10';
dial.step = '1';
dial.value = String(this.power);
const val = el('span', 'val', this.dialRow, String(this.power));
dial.addEventListener('input', () => {
this.power = +dial.value;
val.textContent = dial.value;
this.toaster.dial.rotation.x = -((this.power - 1) / 9) * 2.4;
});
const row2 = el('div', 'row', p);
el('label', 'lbl', row2, 'BREAD');
const breadSel = el('select', 'sel', row2);
this.breadSel = breadSel;
for (const id of Object.keys(BREADS)) {
const o = el('option', undefined, breadSel, BREADS[id as BreadId].name);
o.value = id;
}
breadSel.addEventListener('change', () => this.loadBread(breadSel.value as BreadId));
const row3 = el('div', 'row', p);
el('label', 'lbl', row3, 'SPREAD');
const spreadSel = el('select', 'sel', row3);
this.spreadSel = spreadSel;
for (const id of Object.keys(SPREADS)) {
const o = el('option', undefined, spreadSel, SPREADS[id as SpreadId].name);
o.value = id;
}
spreadSel.addEventListener('change', () => this.setSpread(spreadSel.value as SpreadId));
const row4 = el('div', 'row', p);
el('label', 'lbl', row4, 'TOOL');
const toolSel = el('select', 'sel', row4);
this.toolSel = toolSel;
for (const id of Object.keys(TOOLS)) {
const o = el('option', undefined, toolSel, TOOLS[id as ToolId].name);
o.value = id;
}
toolSel.addEventListener('change', () => {
this.toolId = toolSel.value as ToolId;
this.knifeRig.setTool(TOOLS[this.toolId]);
});
const row5 = el('div', 'row', p);
el('label', 'lbl', row5, 'BUTTER');
const soft = el('input', 'dial', row5);
this.softInput = soft;
soft.type = 'range';
soft.min = '0';
soft.max = '100';
soft.value = String(this.butterSoftness * 100);
const softVal = el('span', 'val', row5, 'hard');
this.softVal = softVal;
soft.addEventListener('input', () => {
this.butterSoftness = +soft.value / 100;
softVal.textContent =
this.butterSoftness > 0.66 ? 'soft' : this.butterSoftness > 0.33 ? 'ok' : 'hard';
});
// The pressure gauge — the mechanic made legible. The fill is the pressure
// your wrist angle is producing; the gold mark is what this spread needs in
// order to flow; the red mark is where the knife stops spreading and starts
// scraping. When gold sits past red, no angle can win, and the answer isn't
// technique — it's warmer toast.
const gauge = el('div', 'gauge', p);
this.gaugeFill = el('div', 'gauge-fill', gauge);
this.gaugeYield = el('div', 'gauge-mark yield', gauge);
this.gaugeScrape = el('div', 'gauge-mark scrape', gauge);
this.modeEl = el('div', 'mode', p, '');
const loadBar = el('div', 'load', p);
this.loadFill = el('div', 'load-fill', loadBar);
this.cueEl = el('div', 'cue', p, 'smells like bread');
this.hintEl = el('div', 'hint', p, 'SPACE — push the lever down');
this.setSpread(this.spreadId);
}
setSpread(id: SpreadId): void {
this.spreadId = id;
const def = SPREADS[id];
this.slice?.setSpread(def);
this.knifeRig.setSpread(def);
if (this.pot) this.root.remove(this.pot);
const { root, hit } = makeSpreadPot(def);
root.position.set(POT_X, 0, POT_Z);
this.pot = root;
this.potHit = hit;
this.pot.visible = this.phase === 'spreading';
this.root.add(root);
}
loadBread(id: BreadId): void {
if (this.slice) {
this.root.remove(this.slice.mesh);
this.slice.dispose();
}
this.slice = new Slice(BREADS[id], new Rng(this.rng.int(1, 1e6)));
this.slice.setSpread(SPREADS[this.spreadId]);
this.heat = buildHeatMap(this.slice, new Rng(this.rng.int(1, 1e6)));
this.root.add(this.slice.mesh);
this.phase = 'ready';
this.leverT = 0;
this.toastSeconds = 0;
this.slice.warmth = 0;
this.knife = newKnife();
this.knifeRig.root.visible = false;
if (this.pot) this.pot.visible = false;
this.shot = SHOTS.toast;
this.dialRow.style.opacity = '1';
this.placeInSlot(0);
this.hintEl.textContent = 'SPACE — push the lever down';
}
get currentSlice(): Slice {
return this.slice;
}
/** Set the bench up for one order. The tool comes from the drawer (M4). */
applyOrder(o: Order): void {
this.butterSoftness = o.butterSoftness;
this.toolId = o.tool;
this.knifeRig.setTool(TOOLS[this.toolId]);
this.setSpread(o.spread);
this.loadBread(o.bread);
this.breadSel.value = o.bread;
this.spreadSel.value = o.spread;
this.toolSel.value = o.tool;
this.softInput.value = String(Math.round(o.butterSoftness * 100));
this.softVal.textContent =
o.butterSoftness > 0.66 ? 'soft' : o.butterSoftness > 0.33 ? 'ok' : 'hard';
}
serve(): void {
if (this.phase !== 'spreading') return;
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);
this.slice.mesh.rotation.set(Math.PI / 2, 0, 0);
}
enter(): void {
this.panel.show();
}
exit(): void {
this.panel.hide();
}
private pushDown(): void {
if (this.phase !== 'ready') return;
audio.resume();
audio.clunk();
this.phase = 'toasting';
this.hintEl.textContent = 'SPACE — pop it';
}
private pop(): void {
if (this.phase !== 'toasting') return;
this.phase = 'flying';
this.slice.warmth = 1;
this.toaster.setGlow(0);
const p0 = this.slice.mesh.position.clone();
const y1 = PLATE_Y + 0.02;
const vy = 4.2;
const g = 9.8;
const disc = Math.max(0.01, vy * vy - 2 * g * (y1 - p0.y));
const T = (vy + Math.sqrt(disc)) / g;
this.vel.set((PLATE_X - p0.x) / T, vy, (PLATE_Z - p0.z) / T);
this.spin = Math.PI / 2 / T;
this.app.shake = 0.045;
this.hintEl.textContent = '';
}
beginSpreading(): void {
this.phase = 'spreading';
this.shot = SHOTS.spread;
this.knifeRig.root.visible = true;
if (this.pot) this.pot.visible = true;
this.dialRow.style.opacity = '0.35';
this.hintEl.textContent = 'dip · drag · WHEEL tilts the knife · ENTER serves it';
}
update(dt: number): void {
const inp = this.app.input;
if (inp.justPressed('Space')) {
if (this.phase === 'ready') this.pushDown();
else if (this.phase === 'toasting') this.pop();
}
if (inp.justPressed('Enter') && this.phase === 'spreading') this.serve();
const target = this.phase === 'ready' ? 0 : this.phase === 'toasting' ? 1 : 0;
this.leverT += (target - this.leverT) * Math.min(1, dt * 14);
this.toaster.lever.position.y = 1.62 * 0.72 - this.leverT * 0.62;
if (this.phase === 'toasting') {
this.placeInSlot(this.leverT);
this.toastSeconds += dt;
toastStep(this.slice, this.heat, this.power, dt);
this.toaster.setGlow(
0.35 + 0.65 * (this.power / 10) * (0.92 + Math.sin(this.toastSeconds * 9) * 0.08),
);
this.slice.setHeatGlow(0.55 + 0.45 * Math.sin(this.toastSeconds * 7) * 0.3);
} else if (this.phase === 'ready') {
this.placeInSlot(this.leverT);
} else if (this.phase === 'flying') {
this.vel.y -= 9.8 * dt;
this.slice.mesh.position.addScaledVector(this.vel, dt);
this.slice.mesh.rotation.x = Math.max(0, this.slice.mesh.rotation.x - this.spin * dt);
this.slice.mesh.rotation.z += dt * 1.1;
this.slice.setHeatGlow(0);
if (this.slice.mesh.position.y <= PLATE_Y + 0.02 && this.vel.y < 0) {
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.phase = 'landed';
audio.clunk(true);
if (this.onPopped) this.onPopped();
else this.beginSpreading();
}
} else if (this.phase === 'spreading') {
this.updateSpreading(dt);
}
this.updateCamera(dt);
if (this.phase !== 'spreading') {
const t = readToast(this.slice);
this.cueEl.textContent = smellCue(t);
this.cueEl.style.color = t.smoke > 0.45 ? '#e2603a' : '';
}
this.slice.sync();
}
private updateSpreading(dt: number): void {
const inp = this.app.input;
const def = SPREADS[this.spreadId];
const tool = TOOLS[this.toolId];
if (inp.wheel !== 0) {
this.knife.angle = Math.max(0, Math.min(1, this.knife.angle + inp.wheel * 0.055));
}
this.ray.setFromCamera(inp.ndc, this.app.camera);
// Dipping wins over spreading: if the cursor is over the pot you're loading up.
let overPot = false;
if (this.potHit) {
const hits = this.ray.intersectObject(this.potHit, false);
if (hits.length) {
overPot = true;
this.knifeRig.update(hits[0].point, this.knife.angle * 0.3, this.knife.load, hits[0].point.y);
if (inp.down) dip(this.knife, def);
this.knife.hasPrev = false;
}
}
if (!overPot) {
this.plane.constant = -this.slice.topY;
if (this.ray.ray.intersectPlane(this.plane, this.hitPoint)) {
this.knifeRig.update(this.hitPoint, this.knife.angle, this.knife.load, this.slice.topY);
this.slice.uvAt(this.hitPoint, this.uv);
const onBread = this.slice.mask.sample(this.uv.x, this.uv.y) > 0.5;
if (inp.down && onBread) {
this.lastFx = stroke(
this.slice,
def,
tool,
this.knife,
this.uv.x,
this.uv.y,
dt,
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;
}
}
}
this.updateGauge(def);
}
private updateGauge(def: SpreadDef): void {
const tool = TOOLS[this.toolId];
const pressure = Math.min(1.4, pressureFromAngle(this.knife.angle) / tool.contactScale);
const yieldP = effectiveYield(def, this.butterSoftness, this.slice.warmth);
const scrapeP = Math.min(1.4, pressureFromAngle(SCRAPE_ANGLE) / tool.contactScale);
const pct = (v: number) => `${Math.min(100, (v / 1.4) * 100)}%`;
this.gaugeFill.style.width = pct(pressure);
this.gaugeYield.style.left = pct(yieldP);
this.gaugeScrape.style.left = pct(scrapeP);
const scraping = this.knife.angle >= SCRAPE_ANGLE;
this.gaugeFill.style.background = scraping
? '#c0392b'
: pressure >= yieldP
? '#6cbf6c'
: '#8a7f70';
const fx = this.lastFx;
let msg = scraping ? 'scraping' : 'spreading';
if (fx) {
if (fx.mode === 'tear') msg = 'TEARING — the butter is too hard for this';
else if (fx.mode === 'gouge') msg = 'GOUGING — there is nothing left to scrape';
else if (fx.mode === 'char') msg = 'lifting the burnt bits';
else if (fx.mode === 'scrape') msg = 'taking it back off';
else if (fx.mode === 'spread') msg = 'spreading nicely';
} else if (this.knife.load <= 0.01) {
msg = 'the knife is empty — go and dip';
}
this.modeEl.textContent = msg;
this.modeEl.style.color =
fx?.mode === 'tear' || fx?.mode === 'gouge'
? '#e2603a'
: fx?.mode === 'spread'
? '#8fce8f'
: '';
this.loadFill.style.width = `${Math.min(100, (this.knife.load / def.pickup) * 100)}%`;
this.loadFill.style.background = `rgb(${def.color.map((c) => Math.round(c * 255)).join(',')})`;
}
private updateCamera(dt: number): void {
const k = 1 - Math.pow(0.0035, dt);
this.camPos.lerp(this.shot.pos, k);
this.camLook.lerp(this.shot.look, k);
this.app.camera.position.copy(this.camPos);
this.app.camera.lookAt(this.camLook);
}
dispose(): void {
this.panel.dispose();
this.slice.dispose();
}
}

197
src/scenes/props.ts Normal file
View File

@ -0,0 +1,197 @@
import * as THREE from 'three';
export function roundedRect(w: number, h: number, r: number): THREE.Shape {
const hw = w / 2;
const hh = h / 2;
const s = new THREE.Shape();
s.moveTo(-hw + r, -hh);
s.lineTo(hw - r, -hh);
s.quadraticCurveTo(hw, -hh, hw, -hh + r);
s.lineTo(hw, hh - r);
s.quadraticCurveTo(hw, hh, hw - r, hh);
s.lineTo(-hw + r, hh);
s.quadraticCurveTo(-hw, hh, -hw, hh - r);
s.lineTo(-hw, -hh + r);
s.quadraticCurveTo(-hw, -hh, -hw + r, -hh);
s.closePath();
return s;
}
const CREAM = 0xefdfbc;
const CHROME = 0xc9ced6;
const DARK = 0x0d0b0a;
export interface Toaster {
root: THREE.Group;
lever: THREE.Group;
dial: THREE.Group;
/** World position of the slot mouth. */
slotY: number;
slotZ: number;
/** Glowing elements inside; intensity 0..1. */
setGlow(v: number): void;
}
/**
* A retro two-slot toaster. Procedural for now: the generated GLB is prettier
* but this one has a lever we can actually drive, and the shape is the hero of
* the toasting scene either way.
*/
export function makeToaster(): Toaster {
const root = new THREE.Group();
const bodyMat = new THREE.MeshStandardMaterial({ color: CREAM, roughness: 0.62, metalness: 0.06 });
const chromeMat = new THREE.MeshStandardMaterial({ color: CHROME, roughness: 0.22, metalness: 0.9 });
const darkMat = new THREE.MeshStandardMaterial({ color: DARK, roughness: 0.95 });
// A slice of bread is ~11cm and this game measures in slices: 1 unit ~ 11cm.
// So a 28x18x15cm toaster is this big, and it dwarfs the bread. It should.
const W = 2.52;
const H = 1.62;
const D = 1.36;
const body = new THREE.Mesh(
new THREE.ExtrudeGeometry(roundedRect(W, H, 0.42), {
depth: D,
bevelEnabled: true,
bevelThickness: 0.05,
bevelSize: 0.05,
bevelSegments: 3,
curveSegments: 20,
}),
bodyMat,
);
body.geometry.center();
body.position.y = H / 2 + 0.04;
body.castShadow = true;
body.receiveShadow = true;
root.add(body);
// chrome cap with the slot cut into it
const cap = new THREE.Mesh(new THREE.BoxGeometry(W * 0.84, 0.07, D * 0.88), chromeMat);
cap.position.set(0, H + 0.04, 0);
cap.castShadow = true;
root.add(cap);
const slot = new THREE.Mesh(new THREE.BoxGeometry(W * 0.52, 0.2, 0.3), darkMat);
slot.position.set(0, H + 0.02, 0);
root.add(slot);
// elements: a few vertical bars either side of the slot, they glow when hot
const glowMat = new THREE.MeshStandardMaterial({
color: 0x3a1508,
emissive: new THREE.Color(0xff3a08),
emissiveIntensity: 0,
roughness: 0.7,
});
for (let i = 0; i < 5; i++) {
const x = (i / 4 - 0.5) * W * 0.46;
for (const z of [-0.15, 0.15]) {
const bar = new THREE.Mesh(new THREE.BoxGeometry(0.05, 0.9, 0.028), glowMat);
bar.position.set(x, H - 0.52, z);
root.add(bar);
}
}
// feet
const footMat = new THREE.MeshStandardMaterial({ color: 0x1a1a1a, roughness: 0.9 });
for (const sx of [-1, 1]) {
for (const sz of [-1, 1]) {
const foot = new THREE.Mesh(new THREE.CylinderGeometry(0.1, 0.11, 0.09, 12), footMat);
foot.position.set(sx * W * 0.36, 0.045, sz * D * 0.3);
root.add(foot);
}
}
// browning dial on the right cheek
const dial = new THREE.Group();
const knob = new THREE.Mesh(new THREE.CylinderGeometry(0.19, 0.19, 0.09, 24), chromeMat);
knob.rotation.z = Math.PI / 2;
knob.castShadow = true;
dial.add(knob);
const pointer = new THREE.Mesh(new THREE.BoxGeometry(0.035, 0.16, 0.05), darkMat);
pointer.position.set(0.05, 0.08, 0);
dial.add(pointer);
dial.position.set(W / 2 + 0.015, H * 0.42, 0.34);
root.add(dial);
// lever on a track
const lever = new THREE.Group();
const stem = new THREE.Mesh(new THREE.BoxGeometry(0.11, 0.11, 0.26), chromeMat);
stem.position.z = 0.12;
lever.add(stem);
const grip = new THREE.Mesh(new THREE.BoxGeometry(0.18, 0.34, 0.13), bodyMat);
grip.position.z = 0.3;
grip.castShadow = true;
lever.add(grip);
lever.position.set(-W / 2 + 0.05, H * 0.72, D / 2 - 0.05);
root.add(lever);
const track = new THREE.Mesh(new THREE.BoxGeometry(0.05, 0.82, 0.07), darkMat);
track.position.set(-W / 2 + 0.012, H * 0.55, D / 2 + 0.05);
root.add(track);
return {
root,
lever,
dial,
slotY: H + 0.04,
slotZ: 0,
setGlow(v: number) {
glowMat.emissiveIntensity = v * 3.2;
glowMat.color.setRGB(0.23 + v * 0.3, 0.08, 0.03);
},
};
}
/**
* Built from primitives rather than a lathe: a lathe profile that touches the
* axis collapses a whole ring of vertices onto one point, and the degenerate
* triangles there render as a starburst of garbage normals.
*/
export function makePlate(): THREE.Group {
const g = new THREE.Group();
const mat = new THREE.MeshStandardMaterial({ color: 0xf4f2ec, roughness: 0.3, metalness: 0.02 });
const well = new THREE.Mesh(new THREE.CylinderGeometry(0.84, 0.74, 0.045, 56), mat);
well.position.y = 0.022;
well.castShadow = true;
well.receiveShadow = true;
g.add(well);
const rim = new THREE.Mesh(new THREE.TorusGeometry(0.84, 0.036, 10, 56), mat);
rim.rotation.x = Math.PI / 2;
rim.position.y = 0.05;
rim.castShadow = true;
rim.receiveShadow = true;
g.add(rim);
return g;
}
export function makeBench(): THREE.Group {
const g = new THREE.Group();
const loader = new THREE.TextureLoader();
const wood = loader.load('/assets/img/bench_wood.png');
wood.wrapS = wood.wrapT = THREE.RepeatWrapping;
wood.repeat.set(4, 2);
// The texture was generated as artwork, i.e. sRGB. Saying so is what keeps it
// from washing out once it's lit.
wood.colorSpace = THREE.SRGBColorSpace;
wood.anisotropy = 8;
const top = new THREE.Mesh(
new THREE.BoxGeometry(20, 0.5, 9),
new THREE.MeshStandardMaterial({ map: wood, color: 0xbb9068, roughness: 0.68 }),
);
top.position.y = -0.25;
top.receiveShadow = true;
g.add(top);
// Splashback: a blurred kitchen so the scene has a horizon instead of fog.
const back = loader.load('/assets/img/kitchen_backdrop.png');
back.colorSpace = THREE.SRGBColorSpace;
const wall = new THREE.Mesh(
new THREE.PlaneGeometry(22, 8),
new THREE.MeshBasicMaterial({ map: back, color: 0x6a6258 }),
);
wall.position.set(0, 3.2, -4.4);
g.add(wall);
return g;
}

93
src/scenes/spreadrig.ts Normal file
View File

@ -0,0 +1,93 @@
import * as THREE from 'three';
import { makeCutleryMesh, type Tool } from '../sim/cutlery';
import { loadProp } from './assets';
import type { SpreadDef } from '../sim/spreads';
/**
* The knife you actually hold. Two nested groups so the tilt is about the blade,
* not about the mouse cursor: `yaw` sets how the knife is held, `tilt` is the
* one control the whole mechanic hangs off.
*/
export class KnifeRig {
readonly root = new THREE.Group();
private yaw = new THREE.Group();
private tilt = new THREE.Group();
private mesh: THREE.Group;
private blob: THREE.Mesh;
private blobMat: THREE.MeshStandardMaterial;
private bladeCentre: number;
constructor(tool: Tool) {
this.bladeCentre = 0.12 + tool.headL * 0.5;
this.mesh = makeCutleryMesh(tool);
// Slide the piece so the middle of its head sits at the rig's origin — that
// point is the contact patch, and it's what we rotate about.
this.mesh.position.z = -this.bladeCentre;
this.tilt.add(this.mesh);
this.yaw.add(this.tilt);
this.root.add(this.yaw);
// Held like a right-handed person spreading: handle down toward the player.
this.yaw.rotation.y = -0.62;
// The load on the blade, so you can see when you've run out.
const blobGeo = new THREE.SphereGeometry(1, 14, 10);
blobGeo.scale(tool.headW * 0.42, 0.035, tool.headL * 0.34);
this.blobMat = new THREE.MeshStandardMaterial({ color: 0xf0c040, roughness: 0.32 });
this.blob = new THREE.Mesh(blobGeo, this.blobMat);
this.blob.position.set(0, 0.028, 0);
this.tilt.add(this.blob);
}
setTool(tool: Tool): void {
this.tilt.remove(this.mesh);
this.mesh = makeCutleryMesh(tool);
this.bladeCentre = 0.12 + tool.headL * 0.5;
this.mesh.position.z = -this.bladeCentre;
this.tilt.add(this.mesh);
}
setSpread(def: SpreadDef | null): void {
if (def) this.blobMat.color.setRGB(def.color[0], def.color[1], def.color[2]);
}
/** angle 0..1; load 0..1 */
update(pos: THREE.Vector3, angle: number, load: number, topY: number): void {
// Tilt lifts the handle and drops the blade onto its edge. At angle 1 the
// knife is all but vertical, which is exactly how you scrape burnt toast.
const tiltRad = angle * 1.38;
this.tilt.rotation.x = -tiltRad;
// Ride the contact patch just above the toast whatever the tilt is doing.
const lift = Math.sin(tiltRad) * 0.045 + 0.012;
this.root.position.set(pos.x, Math.max(topY, pos.y) + lift, pos.z);
this.blob.visible = load > 0.01;
const s = 0.35 + Math.min(1, load / 0.6) * 0.75;
this.blob.scale.set(s, Math.min(1.6, s * (0.6 + load)), s);
}
}
/**
* The pot you dip into one per order, because you only ever get one spread.
*
* The vessel is a generated GLB; the thing you actually dip into is an invisible
* box at its mouth. Raycasting the generated mesh directly would be at the mercy
* of whatever the mesher produced, and the dip target needs to be exactly where
* the player expects it.
*/
export function makeSpreadPot(def: SpreadDef): { root: THREE.Group; hit: THREE.Mesh } {
const g = new THREE.Group();
const isButter = def.id === 'butter';
// material.visible = false, not object.visible = false: the renderer skips it
// either way, but the raycaster still needs a live object to hit.
const hit = new THREE.Mesh(
new THREE.BoxGeometry(isButter ? 0.8 : 0.62, 0.08, isButter ? 0.5 : 0.62),
new THREE.MeshBasicMaterial({ visible: false }),
);
hit.position.y = isButter ? 0.34 : 0.56;
g.add(hit);
const file = isButter ? 'butter_dish' : def.id === 'peanut' ? 'pb_jar' : 'mitey_jar';
void loadProp(`/assets/models/${file}.glb`, isButter ? 1.5 : 1.15).then((prop) => g.add(prop));
return { root: g, hit };
}

353
src/sim/cutlery.ts Normal file
View File

@ -0,0 +1,353 @@
import * as THREE from 'three';
/**
* The cutlery cast. Deliberately procedural rather than generated: these
* silhouettes are gameplay the drawer asks you to find "the dessert fork"
* among things that are almost dessert forks, and that's only fair if the
* differences are authored. It also keeps the tines thin without a mesher
* mangling them, and lets each piece carry its own physics colliders.
*/
export type ToolId =
| 'butter_knife'
| 'dinner_knife'
| 'steak_knife'
| 'spreader'
| 'dinner_fork'
| 'dessert_fork'
| 'teaspoon'
| 'dessert_spoon'
| 'soup_spoon';
export type ToolKind = 'knife' | 'fork' | 'spoon';
export interface Tool {
id: ToolId;
name: string;
kind: ToolKind;
/** Overall length in slice-units (1 unit ~ 11cm). */
length: number;
/** Width of the business end. */
headW: number;
headL: number;
/** Multiplies the knife's contact patch — a spreader is wide, a steak knife isn't. */
contactScale: number;
/** How well it moves spread at all. */
transferScale: number;
/** Multiplier on gouge risk. Serrated things are bad news. */
gougeProne: number;
/** 0..1 — how blotchy the deposit is. A spoon can't lay a flat film. */
blotch: number;
/** Multiplier on tearing when the spread won't yield. */
tearProne: number;
/** The right tool for spreading. */
ideal: boolean;
blurb: string;
}
export const TOOLS: Record<ToolId, Tool> = {
butter_knife: {
id: 'butter_knife',
name: 'Butter Knife',
kind: 'knife',
length: 1.75,
headW: 0.2,
headL: 0.72,
contactScale: 1,
transferScale: 1,
gougeProne: 1,
blotch: 0,
tearProne: 1,
ideal: true,
blurb: 'Round-tipped, wide, dull. The correct answer.',
},
dinner_knife: {
id: 'dinner_knife',
name: 'Dinner Knife',
kind: 'knife',
length: 2.0,
headW: 0.16,
headL: 0.85,
contactScale: 0.86,
transferScale: 0.95,
gougeProne: 1.35,
blotch: 0.05,
tearProne: 1.1,
ideal: false,
blurb: 'Longer, narrower, and it has opinions about the crumb.',
},
steak_knife: {
id: 'steak_knife',
name: 'Steak Knife',
kind: 'knife',
length: 1.95,
headW: 0.13,
headL: 0.88,
contactScale: 0.62,
transferScale: 0.8,
gougeProne: 3.2,
blotch: 0.12,
tearProne: 1.6,
ideal: false,
blurb: 'Serrated. Every stroke is a small act of violence.',
},
spreader: {
id: 'spreader',
name: 'Pâté Spreader',
kind: 'knife',
length: 1.4,
headW: 0.3,
headL: 0.5,
contactScale: 1.35,
transferScale: 1.15,
gougeProne: 0.55,
blotch: 0,
tearProne: 0.7,
ideal: true,
blurb: 'Stubby, wide, blameless. Somehow always at the back.',
},
dinner_fork: {
id: 'dinner_fork',
name: 'Dinner Fork',
kind: 'fork',
length: 1.85,
headW: 0.26,
headL: 0.42,
contactScale: 0.7,
transferScale: 0.55,
gougeProne: 2.4,
blotch: 0.75,
tearProne: 3.0,
ideal: false,
blurb: 'Four tines. Four furrows.',
},
dessert_fork: {
id: 'dessert_fork',
name: 'Dessert Fork',
kind: 'fork',
length: 1.5,
headW: 0.23,
headL: 0.34,
contactScale: 0.6,
transferScale: 0.5,
gougeProne: 2.2,
blotch: 0.78,
tearProne: 2.8,
ideal: false,
blurb: 'Like a dinner fork, but smaller. That is the entire difference.',
},
teaspoon: {
id: 'teaspoon',
name: 'Teaspoon',
kind: 'spoon',
length: 1.3,
headW: 0.24,
headL: 0.34,
contactScale: 0.75,
transferScale: 0.7,
gougeProne: 0.5,
blotch: 0.6,
tearProne: 1.2,
ideal: false,
blurb: 'You can, technically. It will show.',
},
dessert_spoon: {
id: 'dessert_spoon',
name: 'Dessert Spoon',
kind: 'spoon',
length: 1.65,
headW: 0.3,
headL: 0.44,
contactScale: 0.85,
transferScale: 0.75,
gougeProne: 0.45,
blotch: 0.55,
tearProne: 1.15,
ideal: false,
blurb: 'A teaspoon that has been to the gym.',
},
soup_spoon: {
id: 'soup_spoon',
name: 'Soup Spoon',
kind: 'spoon',
length: 1.6,
headW: 0.38,
headL: 0.4,
contactScale: 0.9,
transferScale: 0.7,
gougeProne: 0.4,
blotch: 0.62,
tearProne: 1.1,
ideal: false,
blurb: 'Round. Deep. Utterly wrong, but confidently so.',
},
};
export const TOOL_IDS = Object.keys(TOOLS) as ToolId[];
const STEEL = new THREE.MeshStandardMaterial({
color: 0xd2d7dd,
roughness: 0.24,
metalness: 0.95,
});
/**
* Extrude a profile drawn in shape-space (x = width, y = length) into a part
* lying in the XZ plane: shape +y becomes +z, and the extrusion thickness ends
* up centred on y=0.
*
* Done at the geometry level on purpose. Setting mesh.rotation.x = -PI/2 instead
* sends a profile drawn toward +y to -z and one drawn toward -y to +z which
* silently lays the handle and the blade out in opposite directions, on top of
* each other, and the piece is nowhere near where the code says it is.
*/
function extrudeFlat(shape: THREE.Shape, depth: number, bevel: number): THREE.BufferGeometry {
const geo = new THREE.ExtrudeGeometry(shape, {
depth,
bevelEnabled: bevel > 0,
bevelThickness: bevel,
bevelSize: bevel,
bevelSegments: 2,
curveSegments: 12,
});
geo.rotateX(Math.PI / 2); // shape +y -> +z, extrusion depth -> -y
geo.translate(0, depth / 2, 0);
return geo;
}
/**
* Build a piece of cutlery lying in the XZ plane, handle at -Z, head at +Z.
* Y is thickness.
*/
export function makeCutleryMesh(tool: Tool): THREE.Group {
const g = new THREE.Group();
const L = tool.length;
const handleL = L - tool.headL - 0.12;
// handle: a tapered, slightly domed bar
const handleShape = new THREE.Shape();
const hw0 = 0.052; // at the neck
const hw1 = 0.085; // at the butt
handleShape.moveTo(-hw0, 0);
handleShape.lineTo(-hw1 * 0.92, -handleL * 0.55);
handleShape.quadraticCurveTo(-hw1, -handleL, 0, -handleL);
handleShape.quadraticCurveTo(hw1, -handleL, hw1 * 0.92, -handleL * 0.55);
handleShape.lineTo(hw0, 0);
handleShape.closePath();
const handle = new THREE.Mesh(extrudeFlat(handleShape, 0.036, 0.014), STEEL);
handle.position.z = -0.02;
g.add(handle);
// neck
const neck = new THREE.Mesh(new THREE.BoxGeometry(0.055, 0.028, 0.16), STEEL);
neck.position.z = 0.06;
g.add(neck);
if (tool.kind === 'knife') g.add(makeBlade(tool));
else if (tool.kind === 'fork') g.add(makeForkHead(tool));
else g.add(makeSpoonBowl(tool));
for (const c of g.children) {
c.castShadow = true;
c.receiveShadow = true;
}
return g;
}
function makeBlade(tool: Tool): THREE.Mesh {
const w = tool.headW / 2;
const l = tool.headL;
const s = new THREE.Shape();
s.moveTo(-0.028, 0);
s.lineTo(-w * 0.8, l * 0.22);
s.lineTo(-w, l * 0.55);
// rounded tip for a butter knife, a point for the aggressive ones
if (tool.id === 'butter_knife' || tool.id === 'spreader') {
s.quadraticCurveTo(-w, l, 0, l);
s.quadraticCurveTo(w, l, w, l * 0.55);
} else {
s.lineTo(-w * 0.55, l * 0.93);
s.quadraticCurveTo(0, l * 1.02, w * 0.72, l * 0.86);
s.lineTo(w, l * 0.55);
}
s.lineTo(w * 0.8, l * 0.22);
s.lineTo(0.028, 0);
s.closePath();
const blade = new THREE.Mesh(extrudeFlat(s, 0.014, 0.006), STEEL);
blade.position.set(0, 0, 0.12);
return blade;
}
function makeForkHead(tool: Tool): THREE.Group {
const g = new THREE.Group();
const w = tool.headW / 2;
const l = tool.headL;
// the shoulder the tines grow out of
const base = new THREE.Shape();
base.moveTo(-0.03, 0);
base.lineTo(-w, l * 0.34);
base.lineTo(w, l * 0.34);
base.lineTo(0.03, 0);
base.closePath();
const shoulder = new THREE.Mesh(extrudeFlat(base, 0.016, 0), STEEL);
shoulder.position.set(0, 0, 0.12);
g.add(shoulder);
// four tines
const tineL = l * 0.66;
const tineW = (w * 2) / 7;
for (let i = 0; i < 4; i++) {
const x = (i - 1.5) * (w * 2) / 4;
const tine = new THREE.Mesh(new THREE.BoxGeometry(tineW, 0.014, tineL), STEEL);
tine.position.set(x, 0, 0.12 + l * 0.34 + tineL / 2);
g.add(tine);
const tip = new THREE.Mesh(new THREE.ConeGeometry(tineW * 0.5, 0.05, 6), STEEL);
tip.rotation.x = Math.PI / 2;
tip.position.set(x, 0, 0.12 + l * 0.34 + tineL + 0.02);
g.add(tip);
}
return g;
}
function makeSpoonBowl(tool: Tool): THREE.Mesh {
const geo = new THREE.SphereGeometry(0.5, 20, 14, 0, Math.PI * 2, 0, Math.PI * 0.52);
geo.scale(tool.headW * 0.5, 0.11, tool.headL * 0.6);
geo.rotateX(Math.PI); // open side up
const bowl = new THREE.Mesh(geo, STEEL);
bowl.position.set(0, 0.005, 0.12 + tool.headL * 0.42);
return bowl;
}
/**
* Compound collider primitives for the drawer, in the mesh's local space.
* Boxes only, and few of them: a trimesh of a fork is both slow and a stability
* nightmare when a dozen of them are tangled together.
*/
export interface ColliderPart {
half: [number, number, number];
pos: [number, number, number];
}
export function colliderParts(tool: Tool): ColliderPart[] {
const L = tool.length;
const handleL = L - tool.headL - 0.12;
const parts: ColliderPart[] = [
{ half: [0.075, 0.03, handleL / 2], pos: [0, 0, -0.02 - handleL / 2] },
{ half: [0.03, 0.016, 0.08], pos: [0, 0, 0.06] },
];
if (tool.kind === 'spoon') {
parts.push({
half: [tool.headW * 0.5, 0.055, tool.headL * 0.32],
pos: [0, 0, 0.12 + tool.headL * 0.42],
});
} else {
// one slab for a blade; for a fork this is the tine envelope, which is what
// actually matters — individual tines catching each other is a physics trap.
parts.push({
half: [tool.headW * 0.5, 0.012, tool.headL * 0.5],
pos: [0, 0, 0.12 + tool.headL * 0.5],
});
}
return parts;
}

View File

@ -39,6 +39,10 @@ export class Slice {
warmth = 0;
/** Which spread is currently on the slice (for judging + shading). */
spreadDef: SpreadDef | null = null;
/** Extent of the UV projection, so world hits can be turned back into texels. */
readonly sizeX: number;
readonly sizeZ: number;
readonly halfThickness: number;
private tex: THREE.DataTexture;
private texData: Uint8Array;
@ -65,6 +69,10 @@ export class Slice {
this.tex.needsUpdate = true;
const geo = buildGeometry(shape, bread);
const bb = geo.boundingBox!;
this.sizeX = bb.max.x - bb.min.x;
this.sizeZ = bb.max.z - bb.min.z;
this.halfThickness = bb.max.y;
this.material = buildMaterial(bread, this.tex);
this.mesh = new THREE.Mesh(geo, this.material);
this.mesh.castShadow = true;
@ -72,6 +80,21 @@ export class Slice {
this.sync();
}
/**
* World point -> field UV. The UVs were planar-projected from the shape's own
* XY before the slice was laid flat, which makes shape +y become world -z
* hence the flip on v.
*/
uvAt(worldPoint: THREE.Vector3, out: THREE.Vector2): THREE.Vector2 {
const p = this.mesh.worldToLocal(worldPoint.clone());
return out.set(p.x / this.sizeX + 0.5, 0.5 - p.z / this.sizeZ);
}
/** Height of the top face in world space (the slice lies flat when spreading). */
get topY(): number {
return this.mesh.position.y + this.halfThickness;
}
get uniforms() {
return this.material.uniforms;
}
@ -119,6 +142,26 @@ export class Slice {
this.material.uniforms.uHeatmap.value = mode;
}
/**
* The slice rolls its own lighting, so scene lights don't touch it which
* means the judge's spotlight would do nothing at all. Swap the shader's own
* rig instead: hard key, near-black ambient.
*/
setPresentation(on: boolean): void {
const u = this.material.uniforms;
if (on) {
u.uLightDir.value.set(0.4, 0.86, 0.52).normalize();
u.uLightColor.value.setRGB(1.35, 1.24, 1.05);
u.uAmbientSky.value.setRGB(0.1, 0.11, 0.15);
u.uAmbientGround.value.setRGB(0.035, 0.03, 0.03);
} else {
u.uLightDir.value.set(0.5, 0.9, 0.42).normalize();
u.uLightColor.value.setRGB(1.0, 0.95, 0.86);
u.uAmbientSky.value.setRGB(0.26, 0.27, 0.31);
u.uAmbientGround.value.setRGB(0.14, 0.11, 0.09);
}
}
dispose(): void {
this.mesh.geometry.dispose();
this.material.dispose();
@ -182,6 +225,7 @@ function buildGeometry(shape: THREE.Shape, bread: Bread): THREE.BufferGeometry {
geo.rotateX(-Math.PI / 2);
geo.center();
geo.computeVertexNormals();
geo.computeBoundingBox();
return geo;
}
@ -230,19 +274,13 @@ function pointInPoly(x: number, y: number, pts: THREE.Vector2[]): boolean {
function erode(mask: Field): void {
const n = mask.n;
const src = mask.data.slice();
// Anything off the grid counts as outside, so texels on the very border erode
// away too — clamping the lookups instead would let them survive.
const at = (x: number, y: number) => (x < 0 || y < 0 || x >= n || y >= n ? 0 : src[y * n + x]);
for (let y = 0; y < n; y++) {
for (let x = 0; x < n; x++) {
if (src[y * n + x] < 0.5) continue;
const xm = Math.max(0, x - 1);
const xp = Math.min(n - 1, x + 1);
const ym = Math.max(0, y - 1);
const yp = Math.min(n - 1, y + 1);
if (
src[y * n + xm] < 0.5 ||
src[y * n + xp] < 0.5 ||
src[ym * n + x] < 0.5 ||
src[yp * n + x] < 0.5
) {
if (at(x - 1, y) < 0.5 || at(x + 1, y) < 0.5 || at(x, y - 1) < 0.5 || at(x, y + 1) < 0.5) {
mask.data[y * n + x] = 0;
}
}
@ -419,6 +457,14 @@ void main() {
// torn bread is rough
N = normalize(N + vec3(dmgN - 0.5, 0.0, fbm(vUv * 60.0 + 44.0) - 0.5) * dmg * 0.9);
// Every colour above — the ramp, the crumb, the crust, the spread — is authored
// the way a human picks colours: as sRGB. The lighting below is linear. Without
// this line those values are read as if they were already linear, which lifts
// everything: near-black MITEY renders as tan (linear 0.14 encodes back out to
// sRGB 0.4) and saturated butter washes to pale cream. Mixing happens in sRGB
// on purpose — that's the space the palette was chosen in.
albedo = pow(albedo, vec3(2.2));
// --- lighting ---
vec3 L = normalize(uLightDir);
vec3 V = normalize(cameraPosition - vPosW);
@ -432,10 +478,13 @@ void main() {
// so pair a broad sheen with a fresnel rim — that's the cue that says "wet".
float gloss = uSpreadGloss * smoothstep(0.008, 0.09, spread) * topFace;
gloss *= 1.0 + uWarmth * 0.6;
// Keep the lobe tight: a broad one just washes the whole slice white and drowns
// the colour. Tight + the thickness ridges = glints along the knife marks.
float shin = mix(18.0, 52.0, uSpreadGloss);
float spec = pow(max(dot(N, H), 0.0), shin) * gloss * 0.6;
// The lobe has to be TIGHT. The slice is flat and both the light and the camera
// are above it, so dot(N,H) is ~0.98 across the whole surface — any broad lobe
// blankets it in white and lifts near-black MITEY to tan. Tight, and the only
// thing that catches is a ridge tilted into the light, which is the actual look
// of a spread: dark film, bright knife marks.
float shin = mix(40.0, 170.0, uSpreadGloss);
float spec = pow(max(dot(N, H), 0.0), shin) * gloss * 0.9;
float fres = pow(1.0 - max(dot(N, V), 0.0), 3.0);
spec += fres * gloss * 0.22;
spec *= 1.0 - char * 0.7;

234
src/sim/spreading.ts Normal file
View File

@ -0,0 +1,234 @@
import type { Slice } from './slice';
import type { SpreadDef } from './spreads';
import { SCRAPE_ANGLE, contactRadius, effectiveYield, pressureFromAngle } from './spreads';
import type { Tool } from './cutlery';
/**
* The star mechanic.
*
* One control knife angle and three behaviours fall out of it:
*
* flat -> wide contact, low pressure -> spreads (or tears, if the spread is
* stiffer than the pressure you're allowed to apply)
* steep -> narrow contact, high pressure -> scrapes spread back off, or scrapes
* char off toast that's burnt
* steep and fast on bread that has nothing left to remove -> gouges
*
* The trap: pressure comes from steepness, but steepness past SCRAPE_ANGLE stops
* spreading. So cold butter can demand more pressure than the spread mode can
* physically give and the answer isn't technique, it's warmer toast.
*/
/** Scraping lightens toast down to here; past that you're into the crumb. */
export const BROWN_FLOOR = 0.62;
const TEAR_RATE = 1.15;
const SCRAPE_RATE = 2.4;
/**
* Lifting char has to feel like progress. A steep knife has a narrow contact
* patch (~0.05 UV), so a pass only dwells on any given texel for ~0.2s at a
* lower rate than this you scrape a burnt slice for a minute and it stays burnt.
*/
const CHAR_SCRAPE_RATE = 4.0;
const GOUGE_RATE = 0.85;
/** Below this pressure you're just wiping; you can't gouge with a flat knife. */
const GOUGE_PRESSURE = 0.5;
/** Gouging and tearing both need the knife to be moving. */
const MOVE_REF = 0.55;
export interface Knife {
/** 0 = flat on the toast, 1 = up on its edge. */
angle: number;
/** Spread carried on the blade, in mean-thickness units (same as the field). */
load: number;
u: number;
v: number;
hasPrev: boolean;
}
export type StrokeMode = 'idle' | 'spread' | 'tear' | 'scrape' | 'char' | 'gouge';
export interface StrokeFx {
mode: StrokeMode;
deposited: number;
tore: number;
gouged: number;
scrapedSpread: number;
scrapedChar: number;
speed: number;
pressure: number;
/** How far short of the yield pressure we are: 0 = flowing, 1 = hopeless. */
deficit: number;
crumbs: number;
}
export function newKnife(): Knife {
// Starts inside the window where warm-toast butter actually flows: the very
// first stroke of the game shouldn't be a mysterious failure.
return { angle: 0.45, load: 0, u: 0.5, v: 0.5, hasPrev: false };
}
export function dip(knife: Knife, def: SpreadDef): void {
knife.load = def.pickup;
}
/** Cheap per-texel hash, for blotchy tools. */
function blotchAt(i: number): number {
const x = Math.sin(i * 12.9898) * 43758.5453;
return x - Math.floor(x);
}
/**
* Drag the knife from where it was to (toU,toV). Substeps along the path so a
* fast flick can't skip over the middle of the slice.
*/
export function stroke(
slice: Slice,
def: SpreadDef,
tool: Tool,
knife: Knife,
toU: number,
toV: number,
dt: number,
softness: number,
): StrokeFx {
const fx: StrokeFx = {
mode: 'idle',
deposited: 0,
tore: 0,
gouged: 0,
scrapedSpread: 0,
scrapedChar: 0,
speed: 0,
pressure: 0,
deficit: 0,
crumbs: 0,
};
if (!knife.hasPrev) {
knife.u = toU;
knife.v = toV;
knife.hasPrev = true;
return fx;
}
const du = toU - knife.u;
const dv = toV - knife.v;
const dist = Math.hypot(du, dv);
const speed = dt > 0 ? dist / dt : 0;
fx.speed = speed;
const radius = Math.max(0.012, contactRadius(knife.angle) * tool.contactScale);
// Pressure is force over area: the same wrist angle through a narrow blade
// presses harder than through a wide one.
const pressure = Math.min(1.4, pressureFromAngle(knife.angle) / tool.contactScale);
fx.pressure = pressure;
const steps = Math.max(1, Math.min(24, Math.ceil(dist / (radius * 0.45))));
const stepDt = dt / steps;
for (let s = 1; s <= steps; s++) {
const t = s / steps;
contact(slice, def, tool, knife, knife.u + du * t, knife.v + dv * t, pressure, radius, speed, stepDt, softness, fx);
}
knife.u = toU;
knife.v = toV;
// Low-viscosity spreads level themselves out; peanut butter never does.
const relax = (1 - def.viscosity) * dt * 3.2;
if (relax > 0.001) slice.spread.relax(Math.min(0.5, relax), slice.mask);
slice.touch();
return fx;
}
function contact(
slice: Slice,
def: SpreadDef,
tool: Tool,
knife: Knife,
u: number,
v: number,
pressure: number,
radius: number,
speed: number,
dt: number,
softness: number,
fx: StrokeFx,
): void {
const mask = slice.mask.data;
const spread = slice.spread.data;
const brown = slice.browning.data;
const damage = slice.damage.data;
const moving = Math.min(1.6, speed / MOVE_REF);
if (knife.angle < SCRAPE_ANGLE) {
if (knife.load <= 0.0005) return;
const yieldP = effectiveYield(def, softness, slice.warmth);
const flowing = pressure >= yieldP;
const deficit = flowing ? 0 : Math.min(1, (yieldP - pressure) / Math.max(yieldP, 0.001));
fx.deficit = Math.max(fx.deficit, deficit);
fx.mode = flowing ? 'spread' : 'tear';
// Below the yield pressure the spread won't flow, so the blade grabs the
// crumb and takes it with it. Some still smears on, badly.
const transfer = flowing ? 1 : 0.25;
const rate = def.transferRate * tool.transferScale * dt * transfer;
let massLeft = knife.load * slice.mask.n * slice.mask.n;
slice.spread.brush(u, v, radius, (i, w) => {
if (mask[i] < 0.5 || massLeft <= 0) return;
const blot = 1 - tool.blotch * blotchAt(i);
const add = Math.min(rate * w * blot, massLeft);
spread[i] += add;
massLeft -= add;
fx.deposited += add;
});
knife.load = Math.max(0, massLeft / (slice.mask.n * slice.mask.n));
if (!flowing && moving > 0.05) {
const tear = TEAR_RATE * deficit * moving * tool.tearProne * dt;
slice.damage.brush(u, v, radius, (i, w) => {
if (mask[i] < 0.5) return;
const add = tear * w;
damage[i] = Math.min(1, damage[i] + add);
fx.tore += add;
});
fx.crumbs += tear * 26;
}
return;
}
// --- scrape ---
let removedSpread = 0;
let removedChar = 0;
let gouged = 0;
slice.spread.brush(u, v, radius, (i, w) => {
if (mask[i] < 0.5) return;
if (spread[i] > 0.002) {
const off = Math.min(spread[i], SCRAPE_RATE * pressure * def.scrapeEase * dt * w);
spread[i] -= off;
removedSpread += off;
return;
}
if (brown[i] > BROWN_FLOOR) {
// Lifting char off. Cheap in browning, expensive in evenness — the saved
// patch ends up lighter than everything around it.
const off = Math.min(brown[i] - BROWN_FLOOR, CHAR_SCRAPE_RATE * pressure * dt * w);
brown[i] -= off;
removedChar += off;
return;
}
// Nothing left to take but the bread itself.
if (pressure > GOUGE_PRESSURE && moving > 0.25) {
const add = GOUGE_RATE * (pressure - GOUGE_PRESSURE) * moving * tool.gougeProne * dt * w;
damage[i] = Math.min(1, damage[i] + add);
gouged += add;
}
});
// What comes off the toast mostly stays on the blade.
knife.load += (removedSpread * 0.6) / (slice.mask.n * slice.mask.n);
fx.scrapedSpread += removedSpread;
fx.scrapedChar += removedChar;
fx.gouged += gouged;
fx.crumbs += removedChar * 900 + gouged * 30;
fx.mode = gouged > 1e-6 ? 'gouge' : removedChar > 1e-6 ? 'char' : removedSpread > 1e-6 ? 'scrape' : 'idle';
}

View File

@ -53,7 +53,7 @@ export const SPREADS: Record<SpreadId, SpreadDef> = {
tempSoftening: 0.78,
viscosity: 0.72,
pickup: 0.55,
transferRate: 1.5,
transferRate: 3.0,
drag: 0.5,
scrapeEase: 1.0,
amounts: { thin: [0.1, 0.22], normal: [0.25, 0.5], thick: [0.55, 0.9] },
@ -70,7 +70,7 @@ export const SPREADS: Record<SpreadId, SpreadDef> = {
tempSoftening: 0.3,
viscosity: 0.96,
pickup: 0.95,
transferRate: 2.2,
transferRate: 4.5,
drag: 1.0,
scrapeEase: 0.75,
amounts: { thin: [0.18, 0.32], normal: [0.38, 0.68], thick: [0.75, 1.1] },
@ -87,7 +87,7 @@ export const SPREADS: Record<SpreadId, SpreadDef> = {
tempSoftening: 0.5,
viscosity: 0.34,
pickup: 0.5,
transferRate: 2.6,
transferRate: 3.5,
drag: 0.25,
scrapeEase: 1.25,
amounts: { thin: [0.03, 0.11], normal: [0.14, 0.28], thick: [0.32, 0.6] },

103
src/sim/toasting.ts Normal file
View File

@ -0,0 +1,103 @@
import type { Slice } from './slice';
import { Rng } from '../core/rng';
/**
* Browning rate. Measured, not guessed: the heat map's mean is ~0.77 (coils,
* edge falloff and the cool top all bite), so this is set so power 6 reaches a
* golden 0.5 in ~10s, power 10 is nearly black by then, and power 3 takes ~20s.
*/
const RATE = 0.11;
/** Where the elements actually put their heat. */
export function buildHeatMap(slice: Slice, rng: Rng): Float32Array {
const n = slice.mask.n;
const out = new Float32Array(n * n);
// Real toasters have vertical element wires spaced across the slot, so the
// hot spots are stripes, not a wash. Jitter the phase per run.
const coils = 4 + rng.int(0, 2);
const phase = rng.range(0, Math.PI * 2);
for (let y = 0; y < n; y++) {
for (let x = 0; x < n; x++) {
const u = x / (n - 1);
const v = y / (n - 1);
// Subtle: at much more than this the slice reads as a grill plate, not toast.
const coil = 0.92 + 0.08 * Math.sin(u * Math.PI * 2 * coils + phase);
// The top of the slice sticks up out of the slot and stays pale — the
// single most recognisable thing about real toast.
const vert = v > 0.72 ? 1 - 0.5 * Math.pow((v - 0.72) / 0.28, 1.5) : 1;
// and the very bottom sits on the rack, slightly shaded
const base = v < 0.06 ? 0.82 + (v / 0.06) * 0.18 : 1;
// heat escapes at the left/right edges
const edge = 0.82 + 0.18 * Math.sin(Math.min(1, Math.max(0, u)) * Math.PI);
const bias = 0.88 + slice.heatBias[y * n + x] * 0.24;
out[y * n + x] = coil * vert * base * edge * bias;
}
}
return out;
}
export interface ToastTick {
/** Mean browning over the slice — drives the smell cues. */
mean: number;
/** How much of the slice is past char. */
charFrac: number;
/** 0..1, how smoky it is right now. */
smoke: number;
}
/**
* Advance the browning one tick.
*
* Two things make this more than a timer:
* - moisture has to boil off before the surface can brown at full rate, so a wet
* crumb stalls and then accelerates once it's dry (sourdough's whole trick);
* - sugar browns early and burns early (raisin bread's whole trick).
*/
export function toastStep(slice: Slice, heat: Float32Array, power: number, dt: number): void {
const b = slice.bread;
const p = Math.max(0, Math.min(10, power)) / 10;
const moistureCap = 0.4 + b.moisture * 2.2;
const thick = 0.6 + b.thickness * 3;
const sugar = 1 + b.sugar * 0.8;
const br = slice.browning.data;
const dry = slice.dryness.data;
const mask = slice.mask.data;
for (let i = 0; i < br.length; i++) {
if (mask[i] < 0.5) continue;
const h = heat[i] * p;
if (h <= 0) continue;
dry[i] = Math.min(1, dry[i] + (h * dt) / moistureCap);
const rate = (RATE * h * (0.25 + 0.75 * dry[i]) * sugar) / thick;
br[i] = Math.min(1.15, br[i] + rate * dt);
}
slice.touch();
}
/** Warmth decays in real time. Thick slices hold heat longer. */
export function coolStep(slice: Slice, dt: number): void {
const tau = 18 + slice.bread.thickness * 90;
slice.warmth *= Math.exp(-dt / tau);
slice.material.uniforms.uWarmth.value = slice.warmth;
}
export function readToast(slice: Slice): ToastTick {
const s = slice.browning.stats(slice.mask);
const charFrac = slice.browning.fraction(slice.mask, (v) => v > 0.85);
const smoke = Math.max(0, Math.min(1, (s.max - 0.78) / 0.3));
return { mean: s.mean, charFrac, smoke };
}
/**
* No timer, no numbers you go by smell. Deliberately vague until it isn't.
*/
export function smellCue(t: ToastTick): string {
if (t.smoke > 0.75) return 'SMOKE. ACTUAL SMOKE.';
if (t.smoke > 0.45) return 'something is burning';
if (t.mean > 0.62) return 'dark, and getting darker';
if (t.mean > 0.42) return 'smells like toast';
if (t.mean > 0.22) return 'smells toasty';
if (t.mean > 0.08) return 'smells warm';
if (t.mean > 0.01) return 'a faint warmth';
return 'smells like bread';
}

View File

@ -49,3 +49,515 @@ canvas#c {
#ui > * {
pointer-events: auto;
}
.toast-panel {
position: absolute;
left: 24px;
bottom: 24px;
width: 290px;
padding: 16px 18px;
background: rgba(12, 9, 8, 0.72);
border: 1px solid rgba(243, 233, 220, 0.14);
border-radius: 10px;
backdrop-filter: blur(9px);
}
.toast-panel .row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.toast-panel .lbl {
font-size: 10px;
letter-spacing: 0.14em;
color: var(--ink-dim);
width: 74px;
flex: none;
}
.toast-panel .dial {
flex: 1;
accent-color: var(--gold);
}
.toast-panel .val {
font-family: var(--mono);
font-size: 13px;
color: var(--gold);
width: 18px;
text-align: right;
}
.toast-panel .sel {
flex: 1;
background: rgba(0, 0, 0, 0.4);
color: var(--ink);
border: 1px solid rgba(243, 233, 220, 0.18);
border-radius: 5px;
padding: 4px 6px;
font-size: 12px;
}
.toast-panel .cue {
margin-top: 14px;
font-size: 15px;
font-style: italic;
color: var(--ink);
min-height: 20px;
transition: color 0.4s;
}
.toast-panel .hint {
margin-top: 6px;
font-size: 11px;
letter-spacing: 0.1em;
color: var(--ink-dim);
min-height: 14px;
}
/* Pressure gauge: fill = what your wrist is making, gold = what the spread
needs to flow, red = where spreading becomes scraping. */
.toast-panel .gauge {
position: relative;
height: 9px;
margin-top: 14px;
border-radius: 5px;
background: rgba(255, 255, 255, 0.09);
overflow: hidden;
}
.toast-panel .gauge-fill {
position: absolute;
inset: 0 auto 0 0;
width: 0;
background: #8a7f70;
border-radius: 5px;
transition: background 0.15s;
}
.toast-panel .gauge-mark {
position: absolute;
top: -3px;
bottom: -3px;
width: 2px;
margin-left: -1px;
}
.toast-panel .gauge-mark.yield {
background: var(--gold);
}
.toast-panel .gauge-mark.scrape {
background: #e2603a;
}
.toast-panel .mode {
margin-top: 8px;
font-size: 11px;
letter-spacing: 0.09em;
color: var(--ink-dim);
min-height: 14px;
}
.toast-panel .load {
height: 5px;
margin-top: 8px;
border-radius: 3px;
background: rgba(255, 255, 255, 0.09);
overflow: hidden;
}
.toast-panel .load-fill {
height: 100%;
width: 0;
border-radius: 3px;
}
/* ---- the order ticket ---- */
.ticket {
position: absolute;
top: 22px;
left: 24px;
width: 268px;
padding: 16px 18px 14px;
color: #2b2118;
background: #efe4cf;
border-radius: 3px;
box-shadow: 0 10px 26px rgba(0, 0, 0, 0.5);
transform: rotate(-0.7deg);
}
.ticket-day {
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.22em;
color: #9b8b74;
}
.ticket-who {
font-size: 12px;
font-style: italic;
color: #7d6d58;
margin-top: 2px;
}
.ticket-text {
margin-top: 8px;
font-size: 14px;
line-height: 1.4;
}
.ticket-spec {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-top: 11px;
}
.chip {
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 3px 7px;
border-radius: 3px;
background: rgba(43, 33, 24, 0.1);
border: 1px solid rgba(43, 33, 24, 0.16);
}
.chip.warn {
background: rgba(192, 57, 43, 0.14);
border-color: rgba(192, 57, 43, 0.4);
color: #8e2c21;
}
.chip.tool {
background: rgba(40, 80, 130, 0.12);
border-color: rgba(40, 80, 130, 0.3);
color: #2d5480;
}
.ticket-hint {
margin-top: 9px;
font-size: 11px;
color: #8a7963;
font-style: italic;
}
/* ---- the scorecard ---- */
.judge-panel {
position: absolute;
inset: 0;
display: flex;
justify-content: space-between;
align-items: stretch;
pointer-events: none;
}
.judge-panel > * {
pointer-events: auto;
}
.judge-left {
width: 33%;
max-width: 400px;
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: 0 0 26px 26px;
}
.judge-face {
width: 210px;
align-self: flex-start;
filter: drop-shadow(0 12px 26px rgba(0, 0, 0, 0.6));
}
.judge-lines {
margin-top: 8px;
max-width: 380px;
}
.judge-lines p {
font-size: 17px;
line-height: 1.42;
margin-bottom: 7px;
color: var(--ink);
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.9);
}
.judge-lines p:first-child {
color: var(--ink-dim);
font-size: 14px;
}
.judge-right {
width: 380px;
padding: 26px 26px 26px 0;
display: flex;
flex-direction: column;
justify-content: center;
}
.judge-order {
font-size: 11px;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--ink-dim);
margin-bottom: 12px;
text-align: right;
}
.judge-card {
background: rgba(12, 9, 8, 0.76);
border: 1px solid rgba(243, 233, 220, 0.14);
border-radius: 9px;
padding: 14px 16px;
backdrop-filter: blur(9px);
}
.crit {
display: grid;
grid-template-columns: 96px 1fr auto;
align-items: center;
gap: 10px;
padding: 4px 0;
}
.crit-name {
font-size: 10px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--ink-dim);
}
.crit-bar {
height: 6px;
border-radius: 3px;
background: rgba(255, 255, 255, 0.09);
overflow: hidden;
}
.crit-fill {
display: block;
height: 100%;
border-radius: 3px;
}
.crit-detail {
font-family: var(--mono);
font-size: 10px;
color: var(--ink-dim);
text-align: right;
white-space: nowrap;
}
.crit.total {
border-top: 1px solid rgba(243, 233, 220, 0.16);
margin-top: 7px;
padding-top: 9px;
}
.crit.total .crit-name,
.crit.total .crit-detail {
color: var(--gold);
font-size: 13px;
}
.judge-foot {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 16px;
}
.judge-stamp {
font-size: 56px;
font-weight: 800;
line-height: 1;
padding: 4px 18px;
border: 4px solid currentColor;
border-radius: 7px;
transform: rotate(-9deg);
animation: stamp 0.42s cubic-bezier(0.2, 1.7, 0.4, 1);
}
@keyframes stamp {
0% {
transform: rotate(-9deg) scale(3.2);
opacity: 0;
}
60% {
opacity: 1;
}
100% {
transform: rotate(-9deg) scale(1);
opacity: 1;
}
}
.grade-S { color: #ffd76a; }
.grade-A { color: #7fd07f; }
.grade-B { color: #cfc4b2; }
.grade-C { color: #d99a4e; }
.grade-F { color: #d0483a; }
.judge-btns {
display: flex;
gap: 8px;
}
.btn {
font-family: var(--font);
font-size: 11px;
letter-spacing: 0.13em;
padding: 10px 16px;
border-radius: 6px;
border: 1px solid var(--gold);
background: var(--gold);
color: #211a12;
cursor: pointer;
font-weight: 700;
}
.btn:hover {
filter: brightness(1.12);
}
.btn.ghost {
background: transparent;
color: var(--ink-dim);
border-color: rgba(243, 233, 220, 0.24);
font-weight: 500;
}
.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;
}
/* ---- title ---- */
.title {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: radial-gradient(ellipse at 50% 45%, #241a15 0%, #0d0a08 75%);
}
.title-wrap {
display: flex;
align-items: center;
gap: 40px;
max-width: 900px;
padding: 24px;
}
.title-art {
width: 380px;
max-width: 44vw;
border-radius: 12px;
filter: drop-shadow(0 20px 44px rgba(0, 0, 0, 0.7));
}
.title-txt h1 {
font-size: 62px;
letter-spacing: 0.05em;
line-height: 1;
color: var(--paper);
}
.title-sub {
margin: 10px 0 26px;
font-size: 16px;
font-style: italic;
color: var(--ink-dim);
}
.title-keys {
margin-top: 26px;
font-size: 11px;
line-height: 1.9;
letter-spacing: 0.08em;
color: #6d6155;
}

37
src/ui/hud.ts Normal file
View File

@ -0,0 +1,37 @@
/** Tiny DOM helpers. The UI is an overlay; the game is the 3D scene. */
export function el<K extends keyof HTMLElementTagNameMap>(
tag: K,
cls?: string,
parent?: HTMLElement,
text?: string,
): HTMLElementTagNameMap[K] {
const e = document.createElement(tag);
if (cls) e.className = cls;
if (text !== undefined) e.textContent = text;
if (parent) parent.appendChild(e);
return e;
}
export function clear(e: HTMLElement): void {
while (e.firstChild) e.removeChild(e.firstChild);
}
export const ui = document.getElementById('ui') as HTMLElement;
/** A panel that owns a chunk of the overlay and can be shown/hidden as a unit. */
export class Panel {
readonly root: HTMLElement;
constructor(cls: string) {
this.root = el('div', cls, ui);
}
show(v = true): void {
this.root.style.display = v ? '' : 'none';
}
hide(): void {
this.show(false);
}
dispose(): void {
this.root.remove();
}
}

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>`;
}

26
src/ui/title.ts Normal file
View File

@ -0,0 +1,26 @@
import { el, Panel } from './hud';
/** The front door. One button, one piece of art, one joke. */
export class Title {
private panel: Panel;
constructor(day: number, onStart: () => void) {
this.panel = new Panel('title');
const wrap = el('div', 'title-wrap', this.panel.root);
const art = el('img', 'title-art', wrap);
art.src = '/assets/img/title_art.png';
art.alt = '';
const txt = el('div', 'title-txt', wrap);
el('h1', undefined, txt, 'TOASTSIM');
el('p', 'title-sub', txt, 'Bread goes in. You are judged.');
const btn = el('button', 'btn', txt, day > 1 ? `RESUME — DAY ${day}` : 'START DAY 1');
btn.addEventListener('click', () => {
this.panel.dispose();
onStart();
});
const keys = el('div', 'title-keys', txt);
el('div', undefined, keys, 'SPACE — lever down, then pop');
el('div', undefined, keys, 'DRAG — spread · WHEEL — tilt the knife');
el('div', undefined, keys, 'ENTER — serve it to him');
}
}