M10 WIP + Build Brief 2: the bakery is code-complete, the prep bench is specced

M10 (code-complete, verification pending — see OPUS-BUILD-BRIEF-2.md §2):
- Brands with mechanical quality (WONDERSLICE 0.25 vs Crustworthy 0.8 etc.):
  quality scales heat-map patchiness and crumb fragility. Bakery sourdough is
  loaf-only.
- Slicing minigame: aim thickness, saw with vertical strokes; progress/wobble/
  squash; wedge slices brown unevenly in the toaster (the bad cut follows you);
  squash arrives as pre-existing damage.
- Bread Knife + THE BEST THING (heavy, soft-scalloped, saw eff 1.65) join the
  drawer; on loaf days the drawer trip happens BEFORE toasting ("they are
  waiting") and whatever you fish out is what you slice with.
- Day 9 handwritten (the inspector wants a doorstop with parallel faces),
  procedural brands/loaf days from day 10, The Cut criterion (band + wedge +
  squash, TBT signature bonus), cut line bank.
- Verified so far: day-9 ticket chips, knife-trip drawer with bread_knife AND
  the_best_thing present, timer label, typecheck + build. Slicer end-to-end
  play, tuning, and couplings assertions are itemised in the brief.

OPUS-BUILD-BRIEF-2.md — the prep bench: generalized ingredients + cutting
(slip, juice, piece-evenness CV), the judged mess field, oranges + ribbed
pyramid juicer (halve evenness caps yield, seeds by juicer tier, the
Juicernaut), the grater (zest/pith line, cheese needs cold, knuckles), onions
(sting that blurs REAL vision, the stop-short-of-the-stem criss-cross dice),
and bruschetta as the capstone (temperature planning, roast tomatoes on the
browning field, topping Clark-Evans, the sog clock vs doorstop slices).

Bakery asset batch partially failed (flux errors + one lost GLB) — regen
instructions with retries are in the brief §8.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
monster 2026-07-17 16:24:05 +10:00
parent 16860cf5d6
commit 31f2eff28e
17 changed files with 900 additions and 23 deletions

144
OPUS-BUILD-BRIEF-2.md Normal file
View File

@ -0,0 +1,144 @@
# TOASTSIM — Build Brief 2: The Prep Bench
**For: Claude Opus 4.8, executing in Claude Code on this Mac (M3 Ultra, cwd `/Users/m3ultra/Documents/toastsim`).**
**Approved by the owner. Asset generation via MODELBEAST is free and pre-authorized. Commit + push per milestone to `git@gitea.partly.party:monster/toastsim.git` (SSH remote already configured; HTTPS has no credentials).**
---
## 0. Where you are standing
M0M9 are shipped and verified: toasting (browning field), spreading (knife-angle rheology, butter/PB/MITEY/marmalade), the judged scorecard with a claymation judge, the tangled cutlery drawer, day loop with orders, oil-separation stirring, rind distribution scored by ClarkEvans, and a fresh-eyes pass (M9) that locked the cheat panel and added live readouts. **Read `README.md` and the git log first — the commit messages are design documents.**
**M10 (the bakery) is code-complete but NOT fully verified — that is your first job (§2).** It adds: bread brands with mechanical quality (`bread.ts` BRANDS), unsliced bakery loaves, a slicing minigame (`sim/slicing.ts` + `scenes/slicer.ts`), the bread knife and the legendary **The Best Thing** in the drawer, day 9, and cut quality feeding the toaster (wedge slices brown unevenly).
### How to run and verify (non-negotiable discipline)
- `npx vite --port 5173 --strictPort` in background; open via the Browser pane.
- **The embedded browser never fires rAF** (document.hidden is true) and can report `innerWidth 0`. The game survives both, and the dev harness exists because of them: `window.__t` (dev builds) drives the real input path deterministically — `t.run(frames)`, `t.tap('Space')`, `t.point(x,y,z,down)`, `t.toast(secs,power)`, `t.dip()`, `t.spread()`, `t.swirl()`, `t.stats()`, `t.look()`. Call `t.app.resize()` once after load. Extend the harness for every new minigame you build — if you can't drive it headless, you can't verify it.
- Verify by **playing and reading numbers the judge computes**, then screenshot. Never trust "it compiles". Every milestone: typecheck, build, played-through evidence, console clean, commit with a message that records what you *measured*, push.
### Known traps (all cost real debugging time once — don't pay twice)
- `THREE.Timer.connect(document)` freezes dt when hidden — don't reintroduce it.
- `camera.aspect = 0/0` poisons projection forever; `App.resize()` guards it — keep the guard.
- `mesh.rotation.x = -PI/2` on extrusions lays profiles out in opposite directions — use `extrudeFlat()` (geometry-level) in `cutlery.ts`.
- Rapier wants `{x,y,z,w}` quaternions, not arrays; array = NaN = wasm panic.
- Lathe profiles touching the axis = starburst normals. Build from primitives.
- Colours: palette is authored sRGB. `new THREE.Color(...floats)` reads as linear → pale. Use `.setRGB(..., THREE.SRGBColorSpace)`.
- ACES desaturates >1.0 — keep lighting products under 1.0 or yellow becomes cream.
- Shared materials can't do per-piece emissive — clone if you need highlights.
## 1. The design law (what made M0M9 good — keep obeying it)
1. **Analog inputs, judged outputs.** Every mechanic is a continuous gesture the player can be *better at*, and every score is a statistic computed from the same state the gesture mutated. The scorecard always points at something real.
2. **Mistakes travel.** A bad cut toasts unevenly. Dawdling cools toast, which hardens butter. Never punish twice at the same station — punish downstream, where it's funny.
3. **Feedback before the verdict.** Live readouts use the judge's own functions (see kitchen's `amount` readout). The verdict may be brutal; it may never be a surprise.
4. **Statistics with names.** ClarkEvans for scatter, mass-weighted mean±stdev for consistency, CV for evenness. The judge quotes the number; the number is honest.
5. **Silhouettes are gameplay** → procedural geometry for anything the player must identify or that carries physics. Generated GLBs for hero props and vessels.
6. **The judge is the reward.** Every new criterion ships with ~5 bad lines + 3 good lines in his voice: dry, specific, never cruel except about MITEY.
## 2. M10 finish line — verify the bakery (do this first)
Everything compiles and day 9 reaches the drawer with the right ticket (verified: chips show `the bakery loaf / doorstop — slice it yourself`, timer reads "they are waiting", and the drawer contains `bread_knife` AND `the_best_thing`). Remaining:
1. Play the slicer end-to-end via harness: pick the bread knife from the drawer (drag it above the rim, hold 0.45s), aim ~0.31 world units (doorstop = 2636mm at 110mm/unit), saw with vertical strokes (~0.05 units/frame stays under the squash threshold), confirm `onSliced` fires and the kitchen receives the cut.
2. Assert the couplings: a deliberate wobble cut (add dx while sawing) must show browning-evenness degradation after toasting (`stats().browning.stdev` visibly higher) and "a bit wedged" in The Cut row. A slam cut (fast dy) must arrive pre-damaged (Integrity).
3. Wrong-tool slicing: take the *soup spoon* to the loaf — confirm it's miserable but possible (saw eff 0.3) and the judge's Cut row + Utensil tell the story.
4. Tune feel: `STROKE_GAIN`, `WOBBLE_GAIN`, `SPEED_CAP` in `slicing.ts` are first guesses. A clean bread-knife cut should take ~46 deliberate strokes; The Best Thing ~3 and nearly wobble-proof. Cheap-brand WONDERSLICE toast (day 1) should visibly brown patchier than Crustworthy (day 8) — screenshot both.
5. The slicer scene has placeholder-quality visuals (flat crust box loaf, no cut seam). Minimum bar: a visible cut line that deepens with progress, the cut slice separating and falling flat at the end. Nice-to-have: crumb texture on the cut face (reuse `bread.crumb` colour).
6. `verify → commit → push` as **M10**.
## 3. M11 — The prep bench core (build once, everything else rides it)
A second bench (like the slicer scene) where ingredients get cut, and the machinery every later milestone uses.
### 3.1 Ingredients
`src/sim/ingredients.ts`:
```ts
interface Ingredient {
id: string; name: string;
shape: 'sphere' | 'ovoid' | 'block' | 'wedge' | 'bulb';
size: number; // world units, 1 ≈ 11cm
skin: { resistance: number; slippery: number }; // tomato: low res, HIGH slip
flesh: { resistance: number; moisture: number; layered?: boolean }; // onion: layered
seeds?: { count: number; pocket: 'core' | 'distributed' };
sting?: number; // onions
temp?: { state: 'fridge' | 'bench'; softensInSec?: number; needsSoft?: boolean; needsCold?: boolean };
gratable?: { yield: number; pithDepth?: number }; // citrus zest / hard cheese
roastable?: boolean;
}
```
Starting cast: `tomato` (slippery, wet), `roast_tomato` (wetter, softer), `orange` (ovoid, seeds, zestable), `onion` (layered, sting 1.0), `garlic` (bulb, small), `parmesan` (block, needsCold, gratable), `brie` (wedge, needsSoft), `cucumber`, `avocado` (seed = one giant pit, flesh softness varies by *ripeness* roll — a free lottery joke).
### 3.2 Generalized cutting (`src/sim/cutting.ts`)
Extract and extend the M10 slicing sim. A `CutSession` = ingredient + knife + a **pattern**:
- `halve` — one cut; scored on evenness of the two halves (offset from centre + wedge).
- `slices(n)` — repeated cuts; pieces recorded as widths → **evenness = coefficient of variation** of piece volumes. CV < 0.08 "even", > 0.25 "a lottery".
- `dice` — two-phase (see onions, §6).
- `wedges(n)` — radial; for oranges/tomato wedges.
New physics on top of M10's progress/wobble/squash:
- **Slip**: on the first bite, if `skin.slippery * downforce > grip`, the ingredient *skates* — the cut position jumps, juice sprays (→ mess field), and you re-aim. Grip comes from saw motion (a moving serrated blade bites; pressing a chef's knife straight down on a tomato is the canonical failure). The Best Thing and sharp knives bite at low downforce.
- **Juice**: every cut through `flesh.moisture > 0.4` ejects juice proportional to moisture × speed, sprayed along the stroke direction (→ mess field §3.3). Slow deliberate cuts leak less. Roasted tomatoes are basically water balloons — the joke is intended.
- Pieces are physics bodies on the board when it matters (dice results tumble; reuse drawer's compound-collider discipline — primitives, never trimesh).
### 3.3 The mess field (`src/sim/mess.ts`) — the bench is judged now
A `Field` over the cutting-board UV (reuse `core/field.ts` wholesale) + a particle list for solids (seeds, skins, zest, crumbs — reuse the rind instancing pattern from `slice.ts`).
- Writers: juice spray, seed ejections, onion skins, grated overflow, squashed pulp.
- Wipe tool: hold-and-drag a cloth (same brush machinery as spreading, in reverse). Wiping costs service time. Wiping juice *spreads it thinner first* (mass-conserving brush: same trick as butter) before absorbing — one pass smears, two passes clean. That's the feel.
- Judge criterion **The Bench** (weight ~0.9) on any prep order: mess mass + spread fraction, worded by the worst contributor: "There is juice on everything. Everything." / "Somebody's seeds are on my floor." Good: "A clean board. You'd be surprised how rare."
- Live readout in the prep HUD: `bench: clean / smeared / a crime scene` — judge's own thresholds.
### 3.4 Scoring plumbing
Orders grow a `prep?: PrepStep[]` list; the judge gains a per-step criterion builder (cut evenness, yield, pith, sting-time, bench). Group the scorecard visually by station (Prep / Toast / Spread) — the card is getting long, and grouping keeps it legible.
## 4. M12 — Oranges and the ribbed pyramid
**Flow: (zest first, if ordered §5) → halve → juice → serve the glass.**
- **Halving** uses `halve` pattern. Cut-quality → **theoretical yield**: `yield = base × (1 2|offset|) × (1 0.5·wedge)`. A perfect halve leaves 100% reachable; a 60/40 butchering caps you at ~70% and the judge will say why: "You have cut a lid and a bucket."
- **The juicer** (hero prop: old-school ribbed glass pyramid — generate it). Minigame reuses the *jar-stir swirl detection* (kitchen.ts) verbatim: place the half (drag onto cone), then **press + twist**. Twist sweep extracts (`extracted += sweep × pressGrip`); pressing hard *without* twisting tears pulp (mess + pulp-in-juice) and sprays. Yield bar fills toward the theoretical max — the live readout says `62% of what this orange had`. Rotate rhythm matters: reversals of twist direction give a small bonus bite (real reamers work this way; it also just feels right).
- **Seeds.** Each orange carries `seeds.count` (37). The Pyramid Classic (starter) lets seeds through into the glass: visible, clickable — fish each out at a time cost, or serve pips and eat the criterion: "There are three pips in this. I counted. I shouldn't have been able to."
- **Juicer tiers** (progression, introduced by day like brands): `Pyramid Classic` (day 12) → `Ribbed Deluxe` (seed moat catches ~80%) → **`The Juicernaut`** (legendary, day ~20 procedural rare: catches everything, +10% yield, and the judge notices: "Is that a Juicernaut. Sit down.").
- Judge criteria: **Yield** (vs theoretical, weight 1.3), **Pips** (1.1), **The Bench**, plus the halve's Cut row. Serve = the glass on the pedestal instead of toast — `JudgeView.show` needs a `subject` abstraction (toast mesh | glass mesh). Keep it small: a presented `Object3D` + criteria list.
## 5. M13 — The grater
One prop (box grater generated; microplane optional later), two customers:
- **Zest**: before juicing, some orders want `zest of one orange`. Hold the orange against the grater, rhythmic vertical drags = zest particles (distance-based shedding — the marmalade-rind pattern, reused exactly). **The pith line**: each surface region has `zestDepth`; over-grating a region past `gratable.pithDepth` sheds white pith particles into the pile — visible (white among orange), and the judge's **Pith** criterion counts the ratio: "This zest is half pith. It will taste like a headache." Scroll wheel rotates the orange to expose fresh skin — the skill is *rotation discipline*, same muscle as rind-scatter.
- **Cheese**: parmesan (needsCold) grates clean IF cold — it softens on the bench in real time, and soft parmesan (or brie, ever) **smears**: output drops, the grater clogs (a clog meter; clearing it costs time), mess accrues. The inversion of butter: butter wants warm, cheese wants cold, and both run on the same temperature clock (§7).
- **Knuckles**: grating speed over a threshold rolls a knuckle graze — small Integrity-style penalty on the *player* (brief red flash, mess spot, and a line: "That's your knuckle in there. I'm not scoring the protein.").
- Judge criteria: **Zest/Gratings amount** (band, like spread amount), **Pith**, **Clog/mess**.
## 6. M14 — Onions (the technique test)
- **The sting.** While an onion is cut open, sting pressure rises (rate × `sting` × cut aggression — crushing cuts sting more, so blunt knives and squash-heavy strokes are literally tear gas). Rendered as a growing edge-blur + welling vignette (CSS overlay, cheap). Vision cost is REAL: it blurs the cut line and the stop-line marker. Options with costs: wipe eyes (button: clears it, 4s, and if you've been handling onion it *doubles* it first — once, for the joke and the lesson), push through, or work fast and clean. Sharp knife + low squash = slow sting. The Best Thing is, again, the best thing.
- **The proper dice** — exactly the technique the owner described:
1. Halve stem-to-root.
2. Lay flat. Radial/parallel cuts toward the stem **that must STOP short of it** — a visible stop-line at ~85%; a cut that goes through the stem releases the layers early (pieces fall apart into slivers — layered flesh means the CV of your final dice explodes). Undercutting leaves connected chunks (also CV chaos, other direction).
3. Rotate 90° (scroll wheel), **criss-cross** cuts → dice falls in cubes.
- Scored on **Dice CV** (the piece-evenness statistic from §3.2 — this is where it shines), stem discipline (through-cuts counted), sting time endured, bench state (onion skins are mess solids).
- Judge lines: "You went through the stem. The onion became confetti. You served confetti." / good: "Square. Actually square. I'm keeping one."
## 7. M15 — Bruschetta (the capstone that uses everything)
The multi-component order. The ticket becomes a small recipe card; service time weight rises; **planning is the mechanic**:
- **Temperature clock** (generalizes butter softness): ingredients have bench-vs-fridge states drifting on real time. Brie must come out EARLY (soft enough to spread by assembly time); parmesan must stay in UNTIL needed. The ticket hints ("get the brie out now, love") and a small fridge/bench toggle UI per perishable — decided at order start and revisitable at a time cost.
- **Roast tomatoes**: the oven is the toaster generalized — reuse the browning Field on tomato halves (they blister rather than brown: same field, different colour ramp + a `blister` speckle past 0.6). Overdone = collapse into sauce (moisture → mess when handled).
- **Assemble**: your OWN sliced sourdough (M10), toasted (M1), rubbed with garlic (one swipe mechanic — a light 'spread' with a garlic clove, coverage judged lightly), topped: tomato pieces + cheese + basil. **Topping distribution = ClarkEvans again** (the rind code takes a point list — feed it topping placements). **Balance**: total topping mass per slice area band, "thicker but even and balanced = more score" — mass band × distribution, the user's exact ask.
- **The sog clock**: wet toppings on toast start `sog` rising (rate × topping moisture ÷ slice thickness — thick doorstop slices resist sog: ANOTHER reason the bakery loaf matters). Serve before soggy; the judge presses a finger on it, on camera: "It bends. Toast should not bend."
- Judge screen groups: The Bread (cut+toast) / The Topping (distribution, balance, sog) / The Bench. This is the order type where an S feels like a diploma.
## 8. Assets — MODELBEAST (all free, all local; fire jobs FIRST, code while they run)
`scripts/gen-assets.sh` has the pipeline (`gen_3d name seed "prompt"`; flux → bg_remove → hunyuan3d, ~6 min each, GPU lane serial). **The M10 bakery batch partially failed**`sourdough_loaf` meshed but produced no GLB, and `batard_loaf`, `cutting_board`, `tomato_vine`, `roast_tomatoes` failed at flux. First: check `./mb jobs` (source `~/Documents/MODELBEAST/data/agent.env`) for the error text, re-run with bumped seeds (+1) and slightly reworded prompts; add a retry-once into `gen_3d`. Never spam retries on `queued` — the lane is serial and shared.
Then queue the prep-bench cast (fixed seeds, style token as in the script): oranges ×2 (whole / halved showing segments), ribbed pyramid juicer (hero — prompt the ribs explicitly), juice glass, box grater, brown onion, garlic bulb, basil sprig, olive-oil bottle, oven tray, Ribbed Deluxe + Juicernaut juicer variants, plus 2D: bruschetta title-hero, "WONDERSLICE" bag art for the ticket. Anything the player must *identify by shape* or that carries physics (onion halves, dice pieces, orange halves on the reamer) stays **procedural** — silhouettes are gameplay, and meshers hate thin/segmented geometry.
## 9. Milestone order, and the bar for each
M10-verify → M11 → M12 → M13 → M14 → M15, one commit+push each, message = what you measured. For each: harness helpers first, then the sim (pure, in `src/sim/`), then the scene, then judge criteria + 8 lines, then live readouts, then played-through verification with numbers in the commit message, then screenshots. If a milestone's *feel* is wrong, fixing feel beats advancing — the loaf, the reamer twist and the onion stop-line are the three feels that must be RIGHT.
Pre-authorized judgment calls: all tuning constants; cutting scope inside a milestone to protect feel; procedural-vs-generated per asset; deferring the microplane, avocado, cucumber if the core five (orange, onion, tomato, parmesan, brie) land better without them. Do NOT add paid/cloud dependencies, accounts, or telemetry. Do not touch `~/Documents/MODELBEAST` except through `mb`.
The bet is the same as last time: the bones are good, so build each new station on the bones — Fields, point-statistics, distance-shedding, swirl detection, dwell commits, the harness — and the game stays one game instead of six minigames in a trench coat.

View File

@ -91,9 +91,21 @@ do_2d() {
gen_2d title_art 220 "a single slice of golden toast with a pat of butter melting on it, dramatic hero shot, claymation stop-motion, soft matte clay, warm rim light, plain dark background" 1024 768 cut
}
# ---- M10: the bakery (loaves), plus bruschetta ingredients for the milestone after ----
do_bakery() {
gen_3d sourdough_loaf 61 "a rustic sourdough boule loaf, round crusty artisan bread with flour-dusted scored top, unsliced"
gen_3d batard_loaf 67 "a rustic batard bread loaf, oval crusty artisan bread, scored diagonal cuts on top, unsliced"
gen_3d cutting_board 71 "a thick wooden cutting board with worn edge grain, rectangular with rounded corners"
gen_3d tomato_vine 73 "three ripe red tomatoes on a green vine, glossy"
gen_3d roast_tomatoes 79 "a small metal roasting tray of blistered roasted tomato halves, charred edges, juicy"
gen_3d cheese_hard 83 "a wedge of hard aged parmesan style cheese, pale gold, cracked granular texture"
gen_3d cheese_soft 89 "a small round of soft white brie style cheese with one wedge cut out showing creamy interior"
}
case "${1:-all}" in
3d) do_3d ;;
2d) do_2d ;;
bakery) do_bakery ;;
*) do_3d; do_2d ;;
esac
echo "done."

View File

@ -1,6 +1,7 @@
import type { App } from '../core/app';
import { Rng } from '../core/rng';
import { KitchenView } from '../scenes/kitchen';
import { SlicerView } from '../scenes/slicer';
import { JudgeView } from '../scenes/judge';
import { DrawerView } from '../scenes/drawer';
import { TOOLS, type ToolId } from '../sim/cutlery';
@ -28,6 +29,9 @@ export class Game {
private kitchen: KitchenView;
private judgeView: JudgeView;
private drawer: DrawerView;
private slicer: SlicerView;
/** The pre-toast drawer trip on loaf days: you're after a bread knife. */
private knifeTrip = false;
private rng = new Rng(20260716);
private order!: Order;
/** Sim-time service clock — wall clock lies whenever the tab throttles. */
@ -42,11 +46,14 @@ export class Game {
this.kitchen = new KitchenView(app, 3);
this.judgeView = new JudgeView(app);
this.drawer = new DrawerView(app, 7);
this.slicer = new SlicerView(app);
app.scene.add(this.kitchen.root);
app.scene.add(this.judgeView.root);
app.scene.add(this.drawer.root);
app.scene.add(this.slicer.root);
this.judgeView.root.visible = false;
this.drawer.root.visible = false;
this.slicer.root.visible = false;
this.ticket = new Panel('ticket');
this.ticketEl = el('div', 'ticket-body', this.ticket.root);
@ -57,6 +64,14 @@ export class Game {
this.kitchen.onPopped = () => this.openDrawer();
this.kitchen.onServed = () => this.serve();
this.drawer.onPicked = (tool) => this.closeDrawer(tool);
this.slicer.onSliced = (cut) => {
this.kitchen.applyCut(cut);
this.slicer.root.visible = false;
this.kitchen.root.visible = true;
this.app.setView(this.kitchen);
this.ticket.show();
this.kitchen.flash(cut.knife === 'the_best_thing' ? 'cut with The Best Thing' : 'now toast it');
};
this.judgeView.onNext = () => {
this.save.day++;
writeSave(this.save);
@ -68,6 +83,12 @@ export class Game {
// while you rummage" is the entire reason the drawer exists.
app.onTick((dt) => {
if (this.timing) this.orderSeconds += dt;
// On the knife trip there's no toast to cool yet — the timer is the
// customer's patience instead.
if (this.knifeTrip) {
this.drawer.warmth = Math.max(0, 1 - this.orderSeconds / 150);
return;
}
const k = this.kitchen;
if (k.phase === 'ready' || k.phase === 'toasting') return;
coolStep(k.currentSlice, dt);
@ -104,6 +125,7 @@ export class Game {
/** Fresh out of the toaster, and now you need something to spread with. */
private openDrawer(): void {
this.drawer.reset(this.order.tool);
this.drawer.setTimerLabel('your toast is going cold');
this.drawer.warmth = this.kitchen.currentSlice.warmth;
this.kitchen.root.visible = false;
this.drawer.root.visible = true;
@ -112,6 +134,22 @@ export class Game {
}
private closeDrawer(tool: ToolId): void {
if (this.knifeTrip) {
// Whatever you fished out is what you slice with. Good luck with the spoon.
this.knifeTrip = false;
this.drawer.root.visible = false;
this.slicer.root.visible = true;
const o = this.order;
const [lo, hi] = o.cutBand ?? [0.16, 0.24];
this.slicer.reset(
o.bread,
tool,
`${o.cutClass ?? 'regular cut'}${Math.round(lo * 110)}${Math.round(hi * 110)}mm`,
);
this.app.setView(this.slicer);
this.ticket.show();
return;
}
this.kitchen.setTool(tool);
this.drawer.root.visible = false;
this.kitchen.root.visible = true;
@ -138,12 +176,27 @@ export class Game {
if (s) this.kitchen.root.add(s.mesh);
this.kitchen.applyOrder(o);
this.drawer.root.visible = false;
this.app.setView(this.kitchen);
this.judgeView.root.visible = false;
this.renderTicket();
if (o.fromLoaf) {
// Bakery bread: first find something to slice it with.
this.knifeTrip = true;
this.drawer.reset('bread_knife');
this.drawer.setTimerLabel('they are waiting');
this.drawer.warmth = 1;
this.kitchen.root.visible = false;
this.slicer.root.visible = false;
this.drawer.root.visible = true;
this.app.setView(this.drawer);
this.ticket.show();
return;
}
this.drawer.root.visible = false;
this.slicer.root.visible = false;
this.app.setView(this.kitchen);
this.kitchen.root.visible = true;
this.ticket.show();
this.renderTicket();
}
private renderTicket(): void {
@ -153,6 +206,8 @@ export class Game {
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 brand', spec, o.brand);
if (o.fromLoaf) el('span', 'chip warn', spec, `${o.cutClass} — slice it yourself`);
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');

View File

@ -96,6 +96,7 @@ export function judge(slice: Slice, order: Order, usedTool: ToolId, seconds: num
weight: 1.2,
detail: d.mean < 0.002 ? 'intact' : `torn over ${Math.round(slice.damage.fraction(slice.mask, (v) => v > 0.02) * 100)}%`,
},
...(order.fromLoaf ? [cutCriterion(slice, order)] : []),
...(def.particles ? [rindCriterion(slice, def.particles.name)] : []),
...(def.separates ? [consistencyCriterion(slice)] : []),
{
@ -145,6 +146,39 @@ export function judge(slice: Slice, order: Order, usedTool: ToolId, seconds: num
};
}
/**
* The cut, when the bread came off a loaf. Three sins, weighted by how loudly
* they announce themselves at the table: wrong thickness, wedge faces, and a
* squashed crumb. Thick AND even is where the marks are anyone can cut thin.
*/
function cutCriterion(slice: Slice, order: Order): Criterion {
const c = slice.cut;
if (!c) {
return { key: 'cut', label: 'The Cut', score: 0.1, weight: 1.3, detail: 'it was never sliced' };
}
const [lo, hi] = order.cutBand ?? [0.16, 0.24];
const mid = (lo + hi) / 2;
const band =
c.thickness >= lo && c.thickness <= hi
? 1
: clamp01(1 - (c.thickness < lo ? lo - c.thickness : c.thickness - hi) / (mid * 0.9));
const even = clamp01(1 - c.wedge * 1.5);
const intact = clamp01(1 - c.squash * 1.2);
let score = 0.5 * band + 0.32 * even + 0.18 * intact;
// The Best Thing leaves a signature. He notices.
if (c.knife === 'the_best_thing') score = clamp01(score * 1.04);
const mm = Math.round(c.thickness * 110);
const word =
c.wedge > 0.45 ? 'a full wedge' : c.wedge > 0.18 ? 'a bit wedged' : c.squash > 0.3 ? 'squashed' : 'clean';
return {
key: 'cut',
label: 'The Cut',
score,
weight: 1.3,
detail: `${mm}mm, ${word} (asked ${Math.round(lo * 110)}${Math.round(hi * 110)}mm)`,
};
}
/**
* How evenly the rind is strewn, scored with the ClarkEvans index: the mean
* nearest-neighbour distance between bits, over what that distance would be if

View File

@ -106,6 +106,20 @@ const BANK: Bank = {
'The quantity is correct. Thank you.',
],
},
cut: {
bad: [
'You have cut a ramp. This toast has a gradient.',
'Thin at one end, a plank at the other. Pick a thickness.',
'You leaned on it. The loaf remembers. So do I.',
'The bakery wept when this happened. I heard them from here.',
'That is not a slice, that is a decision you kept changing.',
],
good: [
'A clean cut. The bakery would nod.',
'Thick, even, parallel faces. That is a SLICE.',
'Someone respected the loaf. It shows.',
],
},
rind: {
bad: [
'All the {bits}, in one postcode.',

View File

@ -1,6 +1,7 @@
import type { BreadId } from '../sim/bread';
import { BRANDS, type BreadId } from '../sim/bread';
import type { AmountClass, SpreadId } from '../sim/spreads';
import type { ToolId } from '../sim/cutlery';
import { CUT_BANDS, type CutClass } from '../sim/slicing';
export interface Order {
day: number;
@ -14,6 +15,13 @@ export interface Order {
noChar: boolean;
/** What the drawer will make you find. */
tool: ToolId;
/** Which brand of the bread — quality is mechanical, not cosmetic. */
brand: string;
/** Comes as a whole loaf: find a bread knife, slice it yourself. */
fromLoaf?: boolean;
/** How thick they want the slice, when it's a loaf. */
cutClass?: CutClass;
cutBand?: [number, number];
/** 0 = fridge-hard, 1 = soft. The single cruellest dial in the game. */
butterSoftness: number;
/** How the customer says it. */
@ -46,6 +54,7 @@ export function nameBrowning(v: number): string {
export const DAYS: Order[] = [
{
day: 1,
brand: 'WONDERSLICE',
bread: 'white',
spread: 'butter',
amount: 'normal',
@ -59,6 +68,7 @@ export const DAYS: Order[] = [
},
{
day: 2,
brand: 'WONDERSLICE',
bread: 'white',
spread: 'butter',
amount: 'thin',
@ -72,6 +82,7 @@ export const DAYS: Order[] = [
},
{
day: 3,
brand: 'Seedy Business',
bread: 'multigrain',
spread: 'peanut',
amount: 'thick',
@ -85,6 +96,7 @@ export const DAYS: Order[] = [
},
{
day: 4,
brand: 'Sun Chariot',
bread: 'raisin',
spread: 'butter',
amount: 'normal',
@ -98,6 +110,7 @@ export const DAYS: Order[] = [
},
{
day: 5,
brand: 'WONDERSLICE',
bread: 'white',
spread: 'mitey',
amount: 'normal',
@ -111,6 +124,7 @@ export const DAYS: Order[] = [
},
{
day: 6,
brand: 'Longlife Sour-ish',
bread: 'sourdough',
spread: 'crunchy',
amount: 'normal',
@ -124,6 +138,7 @@ export const DAYS: Order[] = [
},
{
day: 7,
brand: 'Longlife Sour-ish',
bread: 'sourdough',
spread: 'mitey',
amount: 'thin',
@ -137,6 +152,7 @@ export const DAYS: Order[] = [
},
{
day: 8,
brand: 'Crustworthy',
bread: 'white',
spread: 'marmalade',
amount: 'normal',
@ -148,9 +164,26 @@ export const DAYS: Order[] = [
who: 'Deidre',
text: 'Marmalade! And mind the rind, love — a piece in every bite, not a pile in one corner.',
},
{
day: 9,
brand: 'the bakery loaf',
bread: 'sourdough',
spread: 'butter',
amount: 'normal',
browning: 0.58,
browningName: 'golden',
noChar: true,
tool: 'butter_knife',
butterSoftness: 0.45,
fromLoaf: true,
cutClass: 'doorstop',
cutBand: CUT_BANDS.doorstop,
who: 'the inspector himself',
text: 'The bakery loaf. Slice it yourself — a DOORSTOP, and I want the faces parallel. Then golden, butter, normal.',
},
];
/** Days 9+ — keep going, with everything cranked. */
/** Days beyond the script — 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', 'crunchy', 'mitey', 'marmalade'];
@ -168,12 +201,25 @@ export function proceduralOrder(day: number, rand: () => number): Order {
];
const pick = <T>(a: T[]): T => a[Math.floor(rand() * a.length)];
const spread = pick(spreads);
const bread = pick(breads);
// Brand: the good stuff shows up more as the days go on — and bakery
// sourdough is loaf-only, which means the bread knife, which means the drawer.
const pool = BRANDS[bread];
const wantGood = rand() < Math.min(0.85, 0.25 + (day - 9) * 0.06);
const sorted = [...pool].sort((a, b) => a.quality - b.quality);
const brand = wantGood ? sorted[sorted.length - 1] : sorted[0];
const cuts: CutClass[] = ['thin cut', 'regular cut', 'doorstop'];
const cutClass = pick(cuts);
const amount: AmountClass =
spread === 'mitey' ? (rand() < 0.75 ? 'thin' : 'normal') : pick(amounts);
const browning = 0.3 + rand() * 0.5;
return {
day,
bread: pick(breads),
bread,
brand: brand.name,
fromLoaf: !!brand.loaf,
cutClass: brand.loaf ? cutClass : undefined,
cutBand: brand.loaf ? CUT_BANDS[cutClass] : undefined,
spread,
amount,
browning,

View File

@ -49,6 +49,7 @@ export class DrawerView implements View {
private panel: Panel;
private cardEl!: HTMLElement;
private timerFill!: HTMLElement;
private timerLbl!: HTMLElement;
private hintEl!: HTMLElement;
/** Called with whatever you actually pulled out. */
@ -56,6 +57,10 @@ export class DrawerView implements View {
/** How warm the toast still is, 0..1 — drawn as the timer. */
warmth = 1;
setTimerLabel(text: string): void {
this.timerLbl.textContent = text;
}
/** The silhouette is the puzzle: the name only appears as a pity, later. */
private wantName = '';
private nameShown = false;
@ -84,7 +89,7 @@ export class DrawerView implements View {
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.timerLbl = el('div', 'timer-lbl', timer, 'your toast is going cold');
this.hintEl = el('div', 'drawer-hint', p, 'drag it out of the drawer');
}

View File

@ -2,7 +2,7 @@ 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 { BREADS, brandOf, type BreadId } from '../sim/bread';
import { buildHeatMap, readToast, smellCue, toastStep } from '../sim/toasting';
import {
SPREADS,
@ -16,6 +16,7 @@ import {
} from '../sim/spreads';
import { COVER_FLOOR } from '../game/judging';
import { dip, newKnife, stroke, type Knife, type StrokeFx } from '../sim/spreading';
import type { SliceCut } from '../sim/slicing';
import { TOOLS, type ToolId } from '../sim/cutlery';
import { makeBench, makePlate, makeToaster, type Toaster } from './props';
import { KnifeRig, makeSpreadPot } from './spreadrig';
@ -63,6 +64,10 @@ export class KitchenView implements View {
private toastSeconds = 0;
knife: Knife = newKnife();
/** Brand quality for the current order's bread. */
private quality = 0.7;
/** The cut, when this order's slice came off a loaf. */
private cut: SliceCut | null = null;
/** How badly the jar has separated right now. 0 = mixed, 1 = oil on top. */
strat = 0;
private dipsSinceStir = 0;
@ -247,7 +252,10 @@ export class KitchenView implements View {
this.root.remove(this.slice.mesh);
this.slice.dispose();
}
this.slice = new Slice(BREADS[id], new Rng(this.rng.int(1, 1e6)));
this.slice = new Slice(BREADS[id], new Rng(this.rng.int(1, 1e6)), {
quality: this.quality,
cut: this.cut,
});
this.slice.setSpread(SPREADS[this.spreadId]);
this.heat = buildHeatMap(this.slice, new Rng(this.rng.int(1, 1e6)));
this.root.add(this.slice.mesh);
@ -278,6 +286,8 @@ export class KitchenView implements View {
this.toolSel.disabled = true;
this.softInput.disabled = true;
this.askedAmount = o.amount;
this.quality = brandOf(o.bread, o.brand).quality;
this.cut = null;
this.butterSoftness = o.butterSoftness;
this.toolId = o.tool;
this.knifeRig.setTool(TOOLS[this.toolId]);
@ -310,6 +320,12 @@ export class KitchenView implements View {
}
/** Whatever you managed to pull out of the drawer is what you're spreading with. */
/** The slice you just cut arrives at the toaster. */
applyCut(cut: SliceCut): void {
this.cut = cut;
this.loadBread(this.breadSel.value as BreadId);
}
setTool(id: ToolId): void {
this.toolId = id;
this.toolSel.value = id;

247
src/scenes/slicer.ts Normal file
View File

@ -0,0 +1,247 @@
import * as THREE from 'three';
import type { App, View } from '../core/app';
import { audio } from '../core/audio';
import { BREADS, type BreadId } from '../sim/bread';
import { makeCutleryMesh, TOOLS, type ToolId } from '../sim/cutlery';
import {
aim,
beginCut,
classOfCut,
finishCut,
newSlicing,
saw,
sawStats,
type SliceCut,
type Slicing,
} from '../sim/slicing';
import { breadShape } from '../sim/slice';
import { el, Panel } from '../ui/hud';
import { loadProp } from './assets';
const BOARD_Y = 0.06;
const LOAF_LEN = 3.0;
/**
* The slicing bench. A loaf on a board, a knife in your hand, and three ways
* to be judged later: how thick you chose, how straight you sawed, and how
* hard you leaned.
*
* Controls mirror the spreading rig on purpose horizontal mouse aims the cut
* plane, then press and saw VERTICALLY. Vertical screen motion maps to motion
* along the blade, sideways drift is wobble, and wobble is a wedge.
*/
export class SlicerView implements View {
readonly root = new THREE.Group();
private loaf!: THREE.Group;
private loafBody!: THREE.Mesh;
private endX = 0; // world X of the loaf's cut end
private knifeMesh!: THREE.Group;
private cutting!: Slicing;
private ray = new THREE.Raycaster();
private planePt = new THREE.Vector3();
private plane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0);
private prev = new THREE.Vector2();
private hasPrev = false;
private sawPhase = 0;
private doneTimer = 0;
private panel: Panel;
private askEl!: HTMLElement;
private thickEl!: HTMLElement;
private progFill!: HTMLElement;
private wobbleEl!: HTMLElement;
private hintEl!: HTMLElement;
onSliced: ((cut: SliceCut) => void) | null = null;
constructor(private app: App) {
this.buildScenery();
this.panel = new Panel('slicer-panel');
this.buildUi();
this.panel.hide();
}
private buildUi(): void {
const p = this.panel.root;
const card = el('div', 'slicer-card', p);
el('div', 'drawer-lbl', card, 'SLICE IT');
this.askEl = el('div', 'slicer-ask', card, '');
this.thickEl = el('div', 'slicer-thick', card, '');
const bar = el('div', 'timer-bar', card);
this.progFill = el('div', 'timer-fill', bar);
this.wobbleEl = el('div', 'slicer-wobble', card, '');
this.hintEl = el('div', 'drawer-hint', p, '');
}
private buildScenery(): void {
const bench = new THREE.Mesh(
new THREE.BoxGeometry(24, 0.4, 12),
new THREE.MeshStandardMaterial({ color: 0x4a3524, roughness: 0.85 }),
);
bench.position.y = -0.2;
bench.receiveShadow = true;
this.root.add(bench);
const board = new THREE.Mesh(
new THREE.BoxGeometry(4.6, BOARD_Y * 2, 2.4),
new THREE.MeshStandardMaterial({ color: 0xa87c4f, roughness: 0.7 }),
);
board.position.y = BOARD_Y;
board.receiveShadow = true;
board.castShadow = true;
this.root.add(board);
// the generated board, when it exists, sits in for the box
void loadProp('/assets/models/cutting_board.glb', 2.2).then((prop) => {
prop.position.y = BOARD_Y;
board.visible = false;
this.root.add(prop);
}).catch(() => undefined);
}
/** Build the loaf: the slice silhouette extruded into a whole loaf. */
private buildLoaf(breadId: BreadId): void {
if (this.loaf) this.root.remove(this.loaf);
this.loaf = new THREE.Group();
const bread = BREADS[breadId];
const shape = breadShape(bread);
const geo = new THREE.ExtrudeGeometry(shape, {
depth: LOAF_LEN,
bevelEnabled: true,
bevelThickness: 0.05,
bevelSize: 0.05,
bevelSegments: 2,
curveSegments: 24,
});
// silhouette is drawn in XY; lay the loaf along X, dome up
geo.rotateY(Math.PI / 2);
geo.center();
geo.computeBoundingBox();
const minY = geo.boundingBox!.min.y;
const crust = new THREE.MeshStandardMaterial({
color: new THREE.Color().setRGB(...bread.crust, THREE.SRGBColorSpace),
roughness: 0.82,
});
this.loafBody = new THREE.Mesh(geo, crust);
this.loafBody.position.y = BOARD_Y * 2 - minY;
this.loafBody.castShadow = true;
this.loafBody.receiveShadow = true;
this.loaf.add(this.loafBody);
this.endX = LOAF_LEN / 2;
this.root.add(this.loaf);
}
/** A fresh loaf and a fresh cut. */
reset(breadId: BreadId, knife: ToolId, askText: string): void {
this.cutting = newSlicing(knife);
this.buildLoaf(breadId);
if (this.knifeMesh) this.root.remove(this.knifeMesh);
this.knifeMesh = makeCutleryMesh(TOOLS[knife]);
this.root.add(this.knifeMesh);
this.askEl.textContent = askText;
this.doneTimer = 0;
this.hasPrev = false;
this.sawPhase = 0;
const k = sawStats(knife);
this.hintEl.textContent =
k.eff < 0.6
? `sawing with a ${TOOLS[knife].name.toLowerCase()}. good luck.`
: 'aim with the mouse · hold and saw up-down · steady now';
}
enter(): void {
this.panel.show();
}
exit(): void {
this.panel.hide();
}
update(dt: number): void {
const inp = this.app.input;
this.app.camera.position.set(1.2, 2.3, 4.4);
this.app.camera.lookAt(0.6, 0.5, -0.1);
const c = this.cutting;
if (!c) return;
// Mouse on the vertical plane through the loaf's axis.
this.ray.setFromCamera(inp.ndc, this.app.camera);
this.plane.set(new THREE.Vector3(0, 0, 1), 0.2);
const hit = this.ray.ray.intersectPlane(this.plane, this.planePt);
if (c.phase === 'aim' && hit) {
// Thickness = how far in from the end face you hold the knife.
aim(c, this.endX - this.planePt.x);
if (inp.down) {
beginCut(c);
audio.clatter(0.2, 1.6);
}
} else if (c.phase === 'saw' && hit) {
if (this.hasPrev && inp.down) {
const dy = this.planePt.y - this.prev.y;
const dx = this.planePt.x - this.prev.x;
saw(c, dy, dx, dt);
if (Math.abs(dy) > 0.004) {
this.sawPhase += Math.abs(dy) * 3;
audio.scrape(0.45, (Math.abs(dy) / Math.max(dt, 1e-3)) * 0.25);
}
if ((c as Slicing).phase === 'done') {
// the moment it went through
audio.clunk(false);
this.app.shake = 0.02;
}
}
} else if (c.phase === 'done') {
this.doneTimer += dt;
if (this.doneTimer > 0.7) {
const cut = finishCut(c);
this.onSliced?.(cut);
return;
}
}
this.prev.set(this.planePt.x, this.planePt.y);
this.hasPrev = true;
this.positionKnife(c);
this.updateHud(c);
// Squash reads as the loaf flinching.
const squashNow = c.phase === 'saw' ? c.squash : 0;
this.loafBody.scale.y = 1 - squashNow * 0.12 - (inp.down && c.phase === 'saw' ? 0.02 : 0);
}
private positionKnife(c: Slicing): void {
if (!this.knifeMesh) return;
const cutX = this.endX - c.thickness;
const loafTop = 1.1;
// Blade across the loaf (along Z), edge down, handle toward camera.
this.knifeMesh.rotation.set(0, Math.PI / 2, Math.PI / 2);
const sawZ = c.phase === 'saw' ? Math.sin(this.sawPhase * 6) * 0.5 : 0.2;
const sink = c.phase === 'aim' ? 0 : c.progress * 0.95;
this.knifeMesh.position.set(cutX, loafTop + 0.28 - sink, sawZ + 0.4);
}
private updateHud(c: Slicing): void {
const mm = Math.round(c.thickness * 110);
this.thickEl.textContent = `${mm}mm — ${classOfCut(c.thickness)}${c.phase === 'aim' ? ' (aiming)' : ''}`;
this.progFill.style.width = `${Math.round(c.progress * 100)}%`;
this.progFill.style.background = '#e8a53a';
const w = c.wedge;
this.wobbleEl.textContent =
c.phase === 'aim'
? 'press to bite'
: w < 0.12
? 'straight so far'
: w < 0.3
? 'drifting…'
: 'that is becoming a wedge';
this.wobbleEl.style.color = w < 0.12 ? '' : w < 0.3 ? '#e8a53a' : '#e2603a';
if (c.squash > 0.25) {
this.wobbleEl.textContent += ' · easy on the loaf';
}
}
dispose(): void {
this.panel.dispose();
}
}

View File

@ -99,3 +99,41 @@ export const BREADS: Record<BreadId, Bread> = {
};
export const BREAD_ORDER: BreadId[] = ['white', 'multigrain', 'raisin', 'sourdough'];
/**
* Brands. The same style of bread exists at very different altitudes, and the
* altitude is mechanical, not cosmetic: `quality` scales how patchily the slice
* takes heat and how easily the crumb tears. Cheap bread is a lottery you can
* partially outplay; good bread does what you tell it.
*
* `loaf: true` means it only exists unsliced the bakery does not pre-slice,
* and that's where the bread knife (and The Best Thing) earn their keep.
*/
export interface BrandDef {
name: string;
/** 0..1 — evenness of crumb, structural integrity, general dignity. */
quality: number;
/** Comes as a whole loaf: you slice it yourself. */
loaf?: boolean;
blurb: string;
}
export const BRANDS: Record<BreadId, BrandDef[]> = {
white: [
{ name: 'WONDERSLICE', quality: 0.25, blurb: 'Every slice identical. None of them good.' },
{ name: 'Crustworthy', quality: 0.8, blurb: 'White bread with references.' },
],
multigrain: [
{ name: 'Seedy Business', quality: 0.55, blurb: 'The seeds are load-bearing.' },
{ name: 'Grain Bill', quality: 0.85, blurb: 'Serious grains, alphabetised.' },
],
raisin: [{ name: 'Sun Chariot', quality: 0.6, blurb: 'The raisins travel in packs.' }],
sourdough: [
{ name: 'Longlife Sour-ish', quality: 0.35, blurb: 'Sourdough the way a photocopy is a painting.' },
{ name: 'the bakery loaf', quality: 1.0, loaf: true, blurb: 'Loaf only. The bakery does not pre-slice. The bakery is right.' },
],
};
export function brandOf(bread: BreadId, name: string): BrandDef {
return BRANDS[bread].find((b) => b.name === name) ?? BRANDS[bread][0];
}

View File

@ -13,6 +13,8 @@ export type ToolId =
| 'dinner_knife'
| 'steak_knife'
| 'spreader'
| 'bread_knife'
| 'the_best_thing'
| 'dinner_fork'
| 'dessert_fork'
| 'teaspoon'
@ -42,9 +44,20 @@ export interface Tool {
tearProne: number;
/** The right tool for spreading. */
ideal: boolean;
/**
* How it saws through a loaf. Only bread knives are any good at this;
* everything else fights the crust and squashes the crumb.
* eff progress per stroke
* steady resistance to wobble (wobble becomes a wedge-shaped slice)
* gentle how little downforce it needs (pressing squashes fresh bread)
*/
saw?: { eff: number; steady: number; gentle: number };
blurb: string;
}
/** What a non-bread-knife manages against a crusty loaf. */
export const DEFAULT_SAW = { eff: 0.3, steady: 0.45, gentle: 0.35 };
export const TOOLS: Record<ToolId, Tool> = {
butter_knife: {
id: 'butter_knife',
@ -106,6 +119,39 @@ export const TOOLS: Record<ToolId, Tool> = {
ideal: true,
blurb: 'Stubby, wide, blameless. Somehow always at the back.',
},
bread_knife: {
id: 'bread_knife',
name: 'Bread Knife',
kind: 'knife',
length: 2.55,
headW: 0.2,
headL: 1.55,
contactScale: 1.05,
transferScale: 0.85,
gougeProne: 2.3,
blotch: 0.1,
tearProne: 1.3,
ideal: false,
saw: { eff: 1.0, steady: 0.75, gentle: 0.75 },
blurb: 'Long, serrated, single-minded. Wasted on butter.',
},
the_best_thing: {
id: 'the_best_thing',
name: 'The Best Thing',
kind: 'knife',
length: 2.9,
headW: 0.26,
headL: 1.8,
contactScale: 1.1,
transferScale: 0.9,
gougeProne: 1.3,
blotch: 0.06,
tearProne: 1.1,
ideal: false,
// The weight does the work. That is the entire review.
saw: { eff: 1.65, steady: 0.94, gentle: 0.94 },
blurb: 'Heavy. Soft-scalloped. Since sliced bread, there has been nothing better.',
},
dinner_fork: {
id: 'dinner_fork',
name: 'Dinner Fork',
@ -261,8 +307,9 @@ function makeBlade(tool: Tool): THREE.Mesh {
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') {
// rounded tip for a butter knife, a point for the aggressive ones. The Best
// Thing is rounded too — soft serration, nothing to prove.
if (tool.id === 'butter_knife' || tool.id === 'spreader' || tool.id === 'the_best_thing') {
s.quadraticCurveTo(-w, l, 0, l);
s.quadraticCurveTo(w, l, w, l * 0.55);
} else {

View File

@ -3,6 +3,7 @@ import { Field } from '../core/field';
import { Rng, valueNoise2D } from '../core/rng';
import type { Bread } from './bread';
import type { SpreadDef } from './spreads';
import type { SliceCut } from './slicing';
export const FIELD_N = 128;
@ -40,6 +41,14 @@ export class Slice {
/** 1 right out of the toaster, decays to 0. Softens butter. */
warmth = 0;
/** Brand quality 0..1 — cheap bread browns patchily and tears easily. */
readonly quality: number;
/** How this slice was cut, if it came off a loaf. The judge will ask. */
readonly cut: SliceCut | null;
/** Convenience: extra damage susceptibility for cheap bread. */
get fragile(): number {
return 1 + (1 - this.quality) * 0.8;
}
/** 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. */
@ -69,8 +78,10 @@ export class Slice {
private texData: Uint8Array;
private dirty = true;
constructor(bread: Bread, rng: Rng) {
constructor(bread: Bread, rng: Rng, opts?: { quality?: number; cut?: SliceCut | null }) {
this.bread = bread;
this.quality = opts?.quality ?? 0.7;
this.cut = opts?.cut ?? null;
this.browning = new Field(FIELD_N);
this.dryness = new Field(FIELD_N);
this.spread = new Field(FIELD_N);
@ -89,7 +100,7 @@ export class Slice {
this.tex.wrapT = THREE.ClampToEdgeWrapping;
this.tex.needsUpdate = true;
const geo = buildGeometry(shape, bread);
const geo = buildGeometry(shape, bread, this.cut?.thickness);
const bb = geo.boundingBox!;
this.sizeX = bb.max.x - bb.min.x;
this.sizeZ = bb.max.z - bb.min.z;
@ -98,6 +109,18 @@ export class Slice {
this.mesh = new THREE.Mesh(geo, this.material);
this.mesh.castShadow = true;
this.mesh.receiveShadow = true;
// A squashed cut arrives already bruised: the downforce tore the crumb
// before the toaster ever saw it.
if (this.cut && this.cut.squash > 0.03) {
const blobs = 2 + Math.round(this.cut.squash * 4);
for (let i = 0; i < blobs; i++) {
this.damage.brush(rng.range(0.2, 0.8), rng.range(0.2, 0.8), rng.range(0.06, 0.14), (j, w) => {
if (this.mask.data[j] < 0.5) return;
this.damage.data[j] = Math.min(1, this.damage.data[j] + w * this.cut!.squash * 0.5);
});
}
}
this.sync();
}
@ -308,7 +331,7 @@ function clamp255(v: number): number {
}
/** The classic sandwich-loaf silhouette: square-ish body, domed top. */
function breadShape(bread: Bread): THREE.Shape {
export function breadShape(bread: Bread): THREE.Shape {
const hw = bread.width / 2;
const hh = bread.height / 2;
const dome = bread.domeH;
@ -332,10 +355,15 @@ function breadShape(bread: Bread): THREE.Shape {
* we lay the slice flat so the field grid lines up with the bread exactly, and
* both faces share it.
*/
function buildGeometry(shape: THREE.Shape, bread: Bread): THREE.BufferGeometry {
const bevel = Math.min(0.03, bread.thickness * 0.28);
function buildGeometry(
shape: THREE.Shape,
bread: Bread,
thickness?: number,
): THREE.BufferGeometry {
const t = thickness ?? bread.thickness;
const bevel = Math.min(0.03, t * 0.28);
const geo = new THREE.ExtrudeGeometry(shape, {
depth: bread.thickness - bevel * 2,
depth: t - bevel * 2,
bevelEnabled: true,
bevelThickness: bevel,
bevelSize: bevel,

139
src/sim/slicing.ts Normal file
View File

@ -0,0 +1,139 @@
import { DEFAULT_SAW, TOOLS, type ToolId } from './cutlery';
/**
* Cutting a slice off a loaf.
*
* Three numbers come out, and each one feeds forward honestly:
* thickness you chose it when the knife first bit; the judge holds it
* against the ticket, and the toasting sim uses it as thermal mass.
* wedge sideways wander while sawing. A wedge-shaped slice doesn't just
* lose points here: its thin edge browns faster in the toaster,
* so a bad cut is STILL punishing you two phases later.
* squash downforce the knife didn't earn. Fresh crumb compresses and
* tears; squash arrives on the slice as pre-existing damage.
*
* The knife decides how those accumulate. A bread knife saws clean. The Best
* Thing barely needs you. A butter knife against a bakery crust is a siege.
*/
export interface SliceCut {
/** World units; 1 unit ≈ 11cm, so 0.22 ≈ a 24mm doorstop. */
thickness: number;
/** 0 = parallel faces, 1 = a full wedge. */
wedge: number;
/** 0 = unbruised, 1 = flattened. */
squash: number;
knife: ToolId;
strokes: number;
}
export type CutClass = 'thin cut' | 'regular cut' | 'doorstop';
export const CUT_BANDS: Record<CutClass, [number, number]> = {
'thin cut': [0.09, 0.15],
'regular cut': [0.16, 0.24],
doorstop: [0.26, 0.36],
};
/** Progress a full stroke contributes, before knife efficiency. */
const STROKE_GAIN = 0.16;
/** Sideways wander → wedge, tempered by the knife's steadiness. */
const WOBBLE_GAIN = 0.55;
/** Sawing faster than the knife can bite leans on the loaf instead. */
const SPEED_CAP = 3.2;
const SQUASH_GAIN = 0.5;
export interface Slicing {
phase: 'aim' | 'saw' | 'done';
thickness: number;
progress: number;
wedge: number;
squash: number;
strokes: number;
knife: ToolId;
/** Direction of the current stroke, for counting reversals as strokes. */
lastDir: number;
strokeTravel: number;
}
export function newSlicing(knife: ToolId): Slicing {
return {
phase: 'aim',
thickness: 0.2,
progress: 0,
wedge: 0,
squash: 0,
strokes: 0,
knife,
lastDir: 0,
strokeTravel: 0,
};
}
export function sawStats(knife: ToolId): { eff: number; steady: number; gentle: number } {
return TOOLS[knife].saw ?? DEFAULT_SAW;
}
/** While aiming: the cursor picks how thick the slice will be. */
export function aim(s: Slicing, thickness: number): void {
if (s.phase !== 'aim') return;
s.thickness = Math.min(0.4, Math.max(0.06, thickness));
}
/** First bite locks the thickness — no re-aiming mid-cut. */
export function beginCut(s: Slicing): void {
if (s.phase === 'aim') s.phase = 'saw';
}
/**
* One frame of sawing. `dz` is the saw motion (along the blade), `dx` the
* sideways wander, both in world units this frame.
*/
export function saw(s: Slicing, dz: number, dx: number, dt: number): void {
if (s.phase !== 'saw') return;
const k = sawStats(s.knife);
const speed = dt > 0 ? Math.abs(dz) / dt : 0;
// Progress only accrues up to the speed the blade can actually bite at.
const useful = Math.min(Math.abs(dz), SPEED_CAP * dt);
s.progress += useful * STROKE_GAIN * k.eff * (14 / (1 + s.thickness * 8));
// Frantic sawing past the bite speed is just downforce.
const excess = Math.max(0, speed - SPEED_CAP * (0.6 + k.gentle));
s.squash = Math.min(1, s.squash + excess * SQUASH_GAIN * (1 - k.gentle) * dt);
// Wander: judged against how far you've sawed, so one twitch isn't fatal.
s.wedge = Math.min(1, s.wedge + Math.abs(dx) * WOBBLE_GAIN * (1 - k.steady));
// Count strokes on direction reversals — it's the rhythm that reads as sawing.
const dir = Math.sign(dz);
if (dir !== 0 && dir !== s.lastDir && s.strokeTravel > 0.08) {
s.strokes++;
s.strokeTravel = 0;
}
if (dir !== 0) s.lastDir = dir;
s.strokeTravel += Math.abs(dz);
if (s.progress >= 1) {
s.progress = 1;
s.phase = 'done';
}
}
export function finishCut(s: Slicing): SliceCut {
return {
thickness: s.thickness,
wedge: s.wedge,
squash: s.squash,
knife: s.knife,
strokes: s.strokes,
};
}
export function classOfCut(thickness: number): CutClass | 'wafer' | 'slab' {
if (thickness < CUT_BANDS['thin cut'][0]) return 'wafer';
if (thickness <= CUT_BANDS['thin cut'][1]) return 'thin cut';
if (thickness <= CUT_BANDS['regular cut'][1]) return 'regular cut';
if (thickness <= CUT_BANDS.doorstop[1]) return 'doorstop';
return 'slab';
}

View File

@ -230,7 +230,7 @@ function contact(
}
if (!flowing && moving > 0.05) {
const tear = TEAR_RATE * deficit * moving * tool.tearProne * dt;
const tear = TEAR_RATE * deficit * moving * tool.tearProne * slice.fragile * dt;
slice.damage.brush(u, v, radius, (i, w) => {
if (mask[i] < 0.5) return;
const add = tear * w;
@ -269,7 +269,7 @@ function contact(
}
// 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;
const add = GOUGE_RATE * (pressure - GOUGE_PRESSURE) * moving * tool.gougeProne * slice.fragile * dt * w;
damage[i] = Math.min(1, damage[i] + add);
gouged += add;
}

View File

@ -29,8 +29,14 @@ export function buildHeatMap(slice: Slice, rng: Rng): Float32Array {
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;
// Cheap bread takes heat like a raffle: the noise amplitude scales with
// how bad the brand is. Good bread flattens it out.
const amp = 0.24 * (1.4 - slice.quality * 0.8);
const bias = 1 - amp / 2 + slice.heatBias[y * n + x] * amp;
// A wedge-shaped slice has a thin edge, and thin edges brown first: the
// bad cut follows you into the toaster.
const wedge = slice.cut ? 1 + slice.cut.wedge * 0.55 * (u - 0.5) * 2 : 1;
out[y * n + x] = coil * vert * base * edge * bias * Math.max(0.3, wedge);
}
}
return out;

View File

@ -594,3 +594,45 @@ canvas#c {
.toast-panel .dial:disabled {
opacity: 0.55;
}
/* ---- the slicing bench ---- */
.slicer-card {
position: absolute;
top: 26px;
left: 26px;
width: 220px;
padding: 16px 18px;
background: rgba(12, 9, 8, 0.78);
border: 1px solid rgba(243, 233, 220, 0.14);
border-radius: 10px;
backdrop-filter: blur(9px);
}
.slicer-ask {
font-size: 14px;
color: var(--gold);
margin: 6px 0 2px;
}
.slicer-thick {
font-family: var(--mono);
font-size: 12px;
color: var(--ink);
margin: 8px 0 6px;
}
.slicer-wobble {
margin-top: 8px;
font-size: 11px;
letter-spacing: 0.08em;
color: var(--ink-dim);
min-height: 14px;
transition: color 0.3s;
}
.chip.brand {
background: rgba(120, 82, 30, 0.16);
border-color: rgba(120, 82, 30, 0.4);
color: #6d4a1c;
font-weight: 700;
}

View File

@ -8,7 +8,10 @@ import type { Tool } from '../sim/cutlery';
* 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
// px per world unit; shared across tools so sizes compare — but the bread
// knives are longer than the card, so those shrink to fit and keep their
// width-to-length honesty instead.
const SCALE = Math.min(62, 138 / tool.length);
const L = tool.length * SCALE;
const hw = (tool.headW / 2) * SCALE;
const hl = tool.headL * SCALE;
@ -28,7 +31,8 @@ export function toolSilhouette(tool: Tool): string {
);
if (tool.kind === 'knife') {
const round = tool.id === 'butter_knife' || tool.id === 'spreader';
const round =
tool.id === 'butter_knife' || tool.id === 'spreader' || tool.id === 'the_best_thing';
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"/>`