diff --git a/OPUS-BUILD-BRIEF-2.md b/OPUS-BUILD-BRIEF-2.md new file mode 100644 index 0000000..3c4d928 --- /dev/null +++ b/OPUS-BUILD-BRIEF-2.md @@ -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 + +M0–M9 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 Clark–Evans, 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 M0–M9 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.** Clark–Evans 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 = 26–36mm 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 ~4–6 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` (3–7). 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 = Clark–Evans 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. diff --git a/scripts/gen-assets.sh b/scripts/gen-assets.sh index 43bb317..644ee57 100755 --- a/scripts/gen-assets.sh +++ b/scripts/gen-assets.sh @@ -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." diff --git a/src/game/game.ts b/src/game/game.ts index 194ee3f..eafe1cc 100644 --- a/src/game/game.ts +++ b/src/game/game.ts @@ -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'); diff --git a/src/game/judging.ts b/src/game/judging.ts index 43d2625..091d6c7 100644 --- a/src/game/judging.ts +++ b/src/game/judging.ts @@ -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 Clark–Evans index: the mean * nearest-neighbour distance between bits, over what that distance would be if diff --git a/src/game/lines.ts b/src/game/lines.ts index d68d8d9..bdf0a12 100644 --- a/src/game/lines.ts +++ b/src/game/lines.ts @@ -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.', diff --git a/src/game/orders.ts b/src/game/orders.ts index e49eb5c..e752f90 100644 --- a/src/game/orders.ts +++ b/src/game/orders.ts @@ -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 = (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, diff --git a/src/scenes/drawer.ts b/src/scenes/drawer.ts index e4f4c0e..0a96a3c 100644 --- a/src/scenes/drawer.ts +++ b/src/scenes/drawer.ts @@ -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'); } diff --git a/src/scenes/kitchen.ts b/src/scenes/kitchen.ts index f3493cd..dc296ca 100644 --- a/src/scenes/kitchen.ts +++ b/src/scenes/kitchen.ts @@ -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; diff --git a/src/scenes/slicer.ts b/src/scenes/slicer.ts new file mode 100644 index 0000000..1f36c48 --- /dev/null +++ b/src/scenes/slicer.ts @@ -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(); + } +} diff --git a/src/sim/bread.ts b/src/sim/bread.ts index fbe657c..8ca5c58 100644 --- a/src/sim/bread.ts +++ b/src/sim/bread.ts @@ -99,3 +99,41 @@ export const BREADS: Record = { }; 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 = { + 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]; +} diff --git a/src/sim/cutlery.ts b/src/sim/cutlery.ts index 4b344a0..28bcb7e 100644 --- a/src/sim/cutlery.ts +++ b/src/sim/cutlery.ts @@ -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 = { butter_knife: { id: 'butter_knife', @@ -106,6 +119,39 @@ export const TOOLS: Record = { 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 { diff --git a/src/sim/slice.ts b/src/sim/slice.ts index 809faa2..8f16ed0 100644 --- a/src/sim/slice.ts +++ b/src/sim/slice.ts @@ -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, diff --git a/src/sim/slicing.ts b/src/sim/slicing.ts new file mode 100644 index 0000000..20ed58c --- /dev/null +++ b/src/sim/slicing.ts @@ -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 = { + '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'; +} diff --git a/src/sim/spreading.ts b/src/sim/spreading.ts index dce7d22..efe9665 100644 --- a/src/sim/spreading.ts +++ b/src/sim/spreading.ts @@ -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; } diff --git a/src/sim/toasting.ts b/src/sim/toasting.ts index 3d8571a..dc5b4bc 100644 --- a/src/sim/toasting.ts +++ b/src/sim/toasting.ts @@ -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; diff --git a/src/style.css b/src/style.css index 7a197cb..076f7f8 100644 --- a/src/style.css +++ b/src/style.css @@ -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; +} diff --git a/src/ui/silhouette.ts b/src/ui/silhouette.ts index 4edaa00..1ee4e1a 100644 --- a/src/ui/silhouette.ts +++ b/src/ui/silhouette.ts @@ -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 ? ``