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:
parent
16860cf5d6
commit
31f2eff28e
144
OPUS-BUILD-BRIEF-2.md
Normal file
144
OPUS-BUILD-BRIEF-2.md
Normal 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
|
||||
|
||||
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.
|
||||
@ -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."
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.',
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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');
|
||||
}
|
||||
|
||||
|
||||
@ -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
247
src/scenes/slicer.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -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];
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
139
src/sim/slicing.ts
Normal 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';
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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"/>`
|
||||
|
||||
Loading…
Reference in New Issue
Block a user