M11 WIP: the prep bench core — one board, and it remembers
Code-complete and verified headless; NOT wired into a day yet (see brief §0.5). The core, all reusing the M0-M10 bones: - sim/ingredients.ts: the cast (tomato/orange/onion/garlic/parmesan/brie/ cucumber/avocado/roast_tomato) with shape, skin slip, flesh moisture+layers, seeds, sting, temperature needs, gratability. - sim/cutting.ts: CutSession = ingredient + knife + pattern (halve/slices/ wedges/dice), generalizing the M10 slicing sim. New physics: slip (press a flat blade on slippery skin and it skates, the aim jumps, juice sprays) and juice (moisture x speed, so deliberate leaks and frantic hoses). - sim/mess.ts: the bench is judged. Field over the board UV + a solids list; the wipe is the butter brush in reverse and mass-conserving, so one pass smears and the second cleans. - scenes/prep.ts: aim/bite/saw, deepening seams, pieces fan out, juice stain texture, instanced solids, live 'bench: clean/smeared/a crime scene'. - judging.ts: The Pieces (CV, w1.3) + The Bench (w0.9); clarkEvans extracted for M15's toppings; criteria grouped by station and the card grouped with it. - lines.ts: 8 lines each, in his voice. Measured, not asserted: - Even deliberate slices -> CV ~0, bench mass 0.51. - Sloppy + frantic (0.3 u/frame) -> CV 0.72, mass 18.8, 16 solids, 'a crime scene'. - Dinner knife pressed on a tomato without sawing -> skates, aim jumps 0 -> 0.12. The Best Thing under the same press bites in 7 frames, 0 slips. - Wipe two passes -> mass 0.51 -> 0.09. Tuning found by playing: slice CV runs over piece WIDTHS not volumes (round ingredients make end slices thin by geometry — a perfect cut scored 0.40, which is a geometry lesson, not a skill test); juice coefficient 0.5 -> 0.1 (the first tomato flooded the board before the second cut). Assets: gen_3d retries once per stage, bakery seeds bumped, prep cast queued. 2D landed (bruschetta_hero, wonderslice_bag). 3D is BLOCKED: every bg_remove fails because m4pro's disk is 100% full. Procedural stand-ins carry the bench. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
e5be266ce6
commit
faf196657e
@ -35,7 +35,59 @@ M0–M9 are shipped and verified: toasting (browning field), spreading (knife-an
|
||||
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)
|
||||
## 0.5 STATUS — updated 2026-07-17, read this before §2
|
||||
|
||||
**M10 is DONE, verified and pushed** (commit `e5be266`). §2 below is history; skip it.
|
||||
**M11 is code-complete and verified, committed as WIP** — see §3, and the notes here.
|
||||
|
||||
What M11 landed (all typechecked, all played headless via the harness):
|
||||
- `sim/ingredients.ts` — the nine-ingredient cast with the full profile from §3.1.
|
||||
- `sim/cutting.ts` — `CutSession`, patterns halve/slices/wedges/dice, slip physics,
|
||||
juice ejection, `pieceCV`. **Note:** CV for `slices`/`halve` runs over piece WIDTHS,
|
||||
not volumes — on a round ingredient the end slices are thinner by geometry and a
|
||||
perfect cut still scored CV 0.40, which is not a skill test. Wedges/dice keep volumes.
|
||||
- `sim/mess.ts` — the judged mess field + solids list + mass-conserving wipe.
|
||||
- `scenes/prep.ts` — the bench: aim/bite/saw, seams, pieces fan out at the end,
|
||||
juice stain texture, instanced solids, live `bench: clean/smeared/a crime scene`.
|
||||
Session end does NOT auto-exit — ENTER hands off, so you can wipe first.
|
||||
- `judging.ts` — `PrepResult`, `cutEvennessCriterion` (The Pieces, w 1.3),
|
||||
`benchCriterion` (The Bench, w 0.9), `clarkEvans` extracted for M15's toppings,
|
||||
criteria tagged with `group` and the scorecard grouped by station.
|
||||
- `lines.ts` — 8 lines each for `cutcv` and `bench`.
|
||||
- Harness: `t.prep(ing, pattern, knife)`, `t.chop({pos, dy, wobble, press})`,
|
||||
`t.wipe(passes)`, `t.prepStats()`, plus `t.start()`, `t.day(n)`, `t.pick(id)`, `t.slice()`.
|
||||
|
||||
Measured on the bench (repeat these before changing tuning):
|
||||
- Even deliberate slices (dinner knife, 0.045 u/frame) → CV ~0, bench mass 0.51.
|
||||
- Sloppy positions + frantic speed (0.3 u/frame) → CV 0.72, mass 18.8, 16 solids, "a crime scene".
|
||||
- Press a dinner knife on a tomato without sawing → it skates: 1 slip, aim jumped 0 → 0.12.
|
||||
- Same press with The Best Thing → bites in 7 frames, 0 slips (sharp edges sink on downforce).
|
||||
- Wipe two passes → mass 0.51 → 0.09.
|
||||
|
||||
**M11 is NOT finished — remaining before it can be called done:**
|
||||
1. No order actually routes through the bench yet. `Game.startPrep()` exists and the
|
||||
harness drives it, but `Order.prep?: PrepStep[]` is declared and unused — no day
|
||||
sends you there. Wire a day-10 prep order and play it end to end.
|
||||
2. `dice` two-phase and `wedges` are implemented in the sim but never driven —
|
||||
no rotate-the-board input, no verification. M14 needs the dice; do it there or here.
|
||||
3. The pieces fan-out is placeholder-grade (cylinders). Pieces aren't physics bodies yet.
|
||||
4. The wipe is the same drag as cutting, disambiguated only by distance from the
|
||||
ingredient — it needs a real cloth tool/mode before it reads to a stranger.
|
||||
5. No screenshots taken of the bench yet. Do that when the visuals are past placeholder.
|
||||
|
||||
**BLOCKER — the asset farm is down for 3D.** Every `bg_remove_local` job fails:
|
||||
m4pro's disk is 100% full (118MB free; ~3.4TB is `~/Music` — hands off, that's John's).
|
||||
Flux 2D still works — `bruschetta_hero.png` and `wonderslice_bag.png` generated fine and
|
||||
are committed. The whole 3D cast (bakery loaves, cutting board, prep-bench props) is
|
||||
stuck at the cutout stage. `mb settings` is owner-only (403), so re-routing the
|
||||
bg_remove lane needs John. Once there's space: `bash scripts/gen-assets.sh bakery`
|
||||
then `bash scripts/gen-assets.sh prep` — retry-once is now built into `gen_3d`, seeds
|
||||
are bumped, and the prep cast + 2D are already queued in the script. Everything
|
||||
degrades gracefully: `loadProp` catches, so the procedural stand-ins just stay.
|
||||
|
||||
---
|
||||
|
||||
## 2. M10 finish line — verify the bakery (DONE — kept for the record)
|
||||
|
||||
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:
|
||||
|
||||
|
||||
BIN
public/assets/img/bruschetta_hero.png
Normal file
BIN
public/assets/img/bruschetta_hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 822 KiB |
BIN
public/assets/img/wonderslice_bag.png
Normal file
BIN
public/assets/img/wonderslice_bag.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 608 KiB |
71
src/dev.ts
71
src/dev.ts
@ -203,6 +203,77 @@ export function installDevHarness(app: App, game: Game): void {
|
||||
return !drawer.root.visible;
|
||||
},
|
||||
|
||||
/** Enter the prep bench with an ingredient, a knife, and a pattern. */
|
||||
prep(ing = 'tomato', pattern: { kind: string; n?: number } = { kind: 'slices', n: 5 }, knife = 'dinner_knife') {
|
||||
(game as unknown as { startPrep(i: string, p: unknown, k: string): void }).startPrep(ing, pattern, knife);
|
||||
run(3);
|
||||
},
|
||||
|
||||
/**
|
||||
* One cut at the prep bench: aim at `pos` (-0.5..0.5 along the axis),
|
||||
* bite, saw to completion. `press` seconds of leaning without sawing
|
||||
* first, to invite the slip.
|
||||
*/
|
||||
chop(opts: { pos?: number; dy?: number; wobble?: number; press?: number } = {}) {
|
||||
const { pos = 0, dy = 0.045, wobble = 0, press = 0 } = opts;
|
||||
const pv = game.prepView as unknown as {
|
||||
session: { phase: string; aimPos: number; progress: number; slips: number; cuts: number[] };
|
||||
};
|
||||
const s = pv.session;
|
||||
const ing = (game.prepView as unknown as { ing: { size: number } }).ing;
|
||||
run(2);
|
||||
const Z = -0.2;
|
||||
const X = () => s.aimPos * ing.size; // slip moves the aim; follow it
|
||||
point(pos * ing.size, 1.0, Z, false);
|
||||
run(2);
|
||||
point(pos * ing.size, 1.0, Z, true);
|
||||
run(1);
|
||||
if (press > 0) {
|
||||
// Lean on it without sawing.
|
||||
for (let i = 0; i < Math.round(press * 60); i++) {
|
||||
point(X(), 1.0, Z, true);
|
||||
run(1);
|
||||
if (s.phase === 'aim') break; // it slipped and threw the knife off
|
||||
}
|
||||
}
|
||||
let y = 1.0;
|
||||
let dir = -1;
|
||||
let f = 0;
|
||||
while ((s.phase === 'bite' || s.phase === 'saw') && f < 3000) {
|
||||
y += dir * dy;
|
||||
if (y < 0.8 || y > 1.3) dir = -dir;
|
||||
point(X() + (f % 2 ? wobble : -wobble), y, Z, true);
|
||||
run(1);
|
||||
f++;
|
||||
}
|
||||
app.input.down = false;
|
||||
run(2);
|
||||
return { phase: s.phase, cuts: [...s.cuts], slips: s.slips, frames: f };
|
||||
},
|
||||
|
||||
/** Drag the cloth across the board `passes` times. */
|
||||
wipe(passes = 1, vAt = 0.5) {
|
||||
const bw = 4.6;
|
||||
const bd = 2.4;
|
||||
for (let p = 0; p < passes; p++) {
|
||||
for (let i = 0; i <= 40; i++) {
|
||||
const u = p % 2 ? 1 - i / 40 : i / 40;
|
||||
const wx = (0.15 + u * 0.7) * bw - bw / 2;
|
||||
const wz = (vAt - 0.5) * bd;
|
||||
point(wx, 0.12, wz, true);
|
||||
run(1);
|
||||
}
|
||||
}
|
||||
app.input.down = false;
|
||||
run(2);
|
||||
return game.prepView.mess.stats();
|
||||
},
|
||||
|
||||
prepStats() {
|
||||
const r = game.prepView.result();
|
||||
return { ...r, bench: game.prepView.mess.stats(), word: game.prepView.mess.benchWord() };
|
||||
},
|
||||
|
||||
/**
|
||||
* Cut a slice off the loaf. `dy` is world-units per frame of saw motion
|
||||
* (0.05 ≈ deliberate, 0.4 ≈ slamming); `wobble` is sideways drift per frame.
|
||||
|
||||
@ -2,6 +2,10 @@ import type { App } from '../core/app';
|
||||
import { Rng } from '../core/rng';
|
||||
import { KitchenView } from '../scenes/kitchen';
|
||||
import { SlicerView } from '../scenes/slicer';
|
||||
import { PrepView } from '../scenes/prep';
|
||||
import type { CutPattern } from '../sim/cutting';
|
||||
import type { IngredientId } from '../sim/ingredients';
|
||||
import type { PrepResult } from './judging';
|
||||
import { JudgeView } from '../scenes/judge';
|
||||
import { DrawerView } from '../scenes/drawer';
|
||||
import { TOOLS, type ToolId } from '../sim/cutlery';
|
||||
@ -30,6 +34,9 @@ export class Game {
|
||||
private judgeView: JudgeView;
|
||||
private drawer: DrawerView;
|
||||
private slicer: SlicerView;
|
||||
private prep: PrepView;
|
||||
/** What the prep bench reported for the current order, if it ran. */
|
||||
private prepResult: PrepResult | null = null;
|
||||
/** The pre-toast drawer trip on loaf days: you're after a bread knife. */
|
||||
private knifeTrip = false;
|
||||
private rng = new Rng(20260716);
|
||||
@ -47,13 +54,16 @@ export class Game {
|
||||
this.judgeView = new JudgeView(app);
|
||||
this.drawer = new DrawerView(app, 7);
|
||||
this.slicer = new SlicerView(app);
|
||||
this.prep = new PrepView(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);
|
||||
app.scene.add(this.prep.root);
|
||||
this.judgeView.root.visible = false;
|
||||
this.drawer.root.visible = false;
|
||||
this.slicer.root.visible = false;
|
||||
this.prep.root.visible = false;
|
||||
|
||||
this.ticket = new Panel('ticket');
|
||||
this.ticketEl = el('div', 'ticket-body', this.ticket.root);
|
||||
@ -120,6 +130,29 @@ export class Game {
|
||||
get slicerView(): SlicerView {
|
||||
return this.slicer;
|
||||
}
|
||||
get prepView(): PrepView {
|
||||
return this.prep;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the player to the prep bench. Orders with prep steps route through
|
||||
* here (M12+); the harness drives it directly for verification.
|
||||
*/
|
||||
startPrep(ingredient: IngredientId, pattern: CutPattern, knife: ToolId = 'dinner_knife', ask = ''): void {
|
||||
this.prep.reset(ingredient, knife, pattern, ask || `${pattern.kind} the ${ingredient.replace(/_/g, ' ')}`);
|
||||
this.kitchen.root.visible = false;
|
||||
this.drawer.root.visible = false;
|
||||
this.slicer.root.visible = false;
|
||||
this.prep.root.visible = true;
|
||||
this.app.setView(this.prep);
|
||||
this.prep.onDone = (result) => {
|
||||
this.prepResult = result;
|
||||
this.prep.root.visible = false;
|
||||
this.kitchen.root.visible = true;
|
||||
this.app.setView(this.kitchen);
|
||||
this.kitchen.flash('prep done — the bench remembers');
|
||||
};
|
||||
}
|
||||
|
||||
/** Test hook: jump straight to a day, through the real order table. */
|
||||
setDay(day: number): void {
|
||||
@ -180,6 +213,7 @@ export class Game {
|
||||
if (!o.text) o.text = `${o.browningName}, ${o.amount} ${o.spread === 'mitey' ? 'MITEY' : o.spread}. ${o.noChar ? 'No burnt bits.' : ''}`;
|
||||
this.order = o;
|
||||
this.orderSeconds = 0;
|
||||
this.prepResult = null;
|
||||
this.timing = true;
|
||||
|
||||
const s = this.judgeView.release();
|
||||
@ -238,7 +272,7 @@ export class Game {
|
||||
this.timing = false;
|
||||
const seconds = this.orderSeconds;
|
||||
const slice = this.kitchen.currentSlice;
|
||||
const v = judge(slice, this.order, this.kitchen.toolId, seconds);
|
||||
const v = judge(slice, this.order, this.kitchen.toolId, seconds, this.prepResult ?? undefined);
|
||||
this.save.total += v.total;
|
||||
const key = `day${this.order.day}`;
|
||||
this.save.best[key] = Math.max(this.save.best[key] ?? 0, v.total);
|
||||
|
||||
@ -12,6 +12,19 @@ export interface Criterion {
|
||||
weight: number;
|
||||
/** What the number actually was, in words. */
|
||||
detail: string;
|
||||
/** Which station earned it — the scorecard groups by this on prep orders. */
|
||||
group?: 'prep' | 'toast' | 'spread';
|
||||
}
|
||||
|
||||
/** What the prep bench hands the judge, when the order had prep on it. */
|
||||
export interface PrepResult {
|
||||
/** Coefficient of variation of piece volumes, from sim/cutting pieceCV. */
|
||||
cv?: number;
|
||||
pieces?: number;
|
||||
patternName?: string;
|
||||
slips?: number;
|
||||
/** From sim/mess Mess.stats() at serve time. */
|
||||
bench?: { mass: number; spreadFrac: number; solids: number };
|
||||
}
|
||||
|
||||
export type Grade = 'S' | 'A' | 'B' | 'C' | 'F';
|
||||
@ -35,7 +48,13 @@ export const COVER_FLOOR = 0.06;
|
||||
* Every number here is read off the same fields the player was pushing around
|
||||
* with the knife, so the scorecard can always point at something real.
|
||||
*/
|
||||
export function judge(slice: Slice, order: Order, usedTool: ToolId, seconds: number): Verdict {
|
||||
export function judge(
|
||||
slice: Slice,
|
||||
order: Order,
|
||||
usedTool: ToolId,
|
||||
seconds: number,
|
||||
prep?: PrepResult,
|
||||
): Verdict {
|
||||
const b = slice.browning.stats(slice.mask);
|
||||
const s = slice.spread.stats(slice.mask);
|
||||
const d = slice.damage.stats(slice.mask);
|
||||
@ -54,8 +73,11 @@ export function judge(slice: Slice, order: Order, usedTool: ToolId, seconds: num
|
||||
const covered = spreadStdevOverCovered(slice, COVER_FLOOR);
|
||||
|
||||
const criteria: Criterion[] = [
|
||||
...(prep?.cv !== undefined ? [cutEvennessCriterion(prep.cv, prep.pieces ?? 0, prep.patternName ?? 'cut')] : []),
|
||||
...(prep?.bench ? [benchCriterion(prep.bench)] : []),
|
||||
{
|
||||
key: 'browning',
|
||||
group: 'toast',
|
||||
label: 'Browning',
|
||||
score: clamp01(1 - Math.abs(b.mean - order.browning) / 0.3),
|
||||
weight: 1.5,
|
||||
@ -63,6 +85,7 @@ export function judge(slice: Slice, order: Order, usedTool: ToolId, seconds: num
|
||||
},
|
||||
{
|
||||
key: 'evenness',
|
||||
group: 'toast',
|
||||
label: 'Evenness',
|
||||
score: clamp01(1 - b.stdev / 0.2),
|
||||
weight: 1.1,
|
||||
@ -70,6 +93,7 @@ export function judge(slice: Slice, order: Order, usedTool: ToolId, seconds: num
|
||||
},
|
||||
{
|
||||
key: 'coverage',
|
||||
group: 'spread',
|
||||
label: 'Coverage',
|
||||
score: clamp01((coverage - 0.15) / 0.75),
|
||||
weight: 1.3,
|
||||
@ -77,6 +101,7 @@ export function judge(slice: Slice, order: Order, usedTool: ToolId, seconds: num
|
||||
},
|
||||
{
|
||||
key: 'uniformity',
|
||||
group: 'spread',
|
||||
label: 'Uniformity',
|
||||
score: clamp01(1 - covered / 0.22),
|
||||
weight: 0.9,
|
||||
@ -84,6 +109,7 @@ export function judge(slice: Slice, order: Order, usedTool: ToolId, seconds: num
|
||||
},
|
||||
{
|
||||
key: 'char',
|
||||
group: 'toast',
|
||||
label: 'Char',
|
||||
score: order.noChar ? clamp01(1 - charFrac / 0.12) : clamp01(1 - charFrac / 0.45),
|
||||
weight: order.noChar ? 1.3 : 0.5,
|
||||
@ -91,6 +117,7 @@ export function judge(slice: Slice, order: Order, usedTool: ToolId, seconds: num
|
||||
},
|
||||
{
|
||||
key: 'integrity',
|
||||
group: 'spread',
|
||||
label: 'Integrity',
|
||||
score: clamp01(1 - d.mean / 0.06),
|
||||
weight: 1.2,
|
||||
@ -101,6 +128,7 @@ export function judge(slice: Slice, order: Order, usedTool: ToolId, seconds: num
|
||||
...(def.separates ? [consistencyCriterion(slice)] : []),
|
||||
{
|
||||
key: 'amount',
|
||||
group: 'spread',
|
||||
label: 'The Right Amount',
|
||||
score: amountScore(s.mean, lo, hi, target),
|
||||
weight: 1.4,
|
||||
@ -154,7 +182,7 @@ export function judge(slice: Slice, order: Order, usedTool: ToolId, seconds: num
|
||||
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' };
|
||||
return { key: 'cut', group: 'prep', 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;
|
||||
@ -172,6 +200,7 @@ function cutCriterion(slice: Slice, order: Order): Criterion {
|
||||
c.wedge > 0.45 ? 'a full wedge' : c.wedge > 0.18 ? 'a bit wedged' : c.squash > 0.3 ? 'squashed' : 'clean';
|
||||
return {
|
||||
key: 'cut',
|
||||
group: 'prep',
|
||||
label: 'The Cut',
|
||||
score,
|
||||
weight: 1.3,
|
||||
@ -200,21 +229,7 @@ function rindCriterion(slice: Slice, name: string): Criterion {
|
||||
}
|
||||
// Mask area in UV units — the bread is the study region, not the unit square.
|
||||
const area = slice.mask.data.reduce((a, v) => a + (v > 0.5 ? 1 : 0), 0) / (slice.mask.n * slice.mask.n);
|
||||
let sum = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
let best = Infinity;
|
||||
for (let j = 0; j < n; j++) {
|
||||
if (i === j) continue;
|
||||
const du = pts[i].u - pts[j].u;
|
||||
const dv = pts[i].v - pts[j].v;
|
||||
const d2 = du * du + dv * dv;
|
||||
if (d2 < best) best = d2;
|
||||
}
|
||||
sum += Math.sqrt(best);
|
||||
}
|
||||
const observed = sum / n;
|
||||
const expected = 0.5 * Math.sqrt(area / n);
|
||||
const R = observed / expected;
|
||||
const R = clarkEvans(pts, area);
|
||||
const score = clamp01(R / 0.95);
|
||||
const word = R < 0.45 ? 'one clump' : R < 0.75 ? 'clumped' : R < 1.05 ? 'scattered' : 'evenly strewn';
|
||||
return {
|
||||
@ -260,6 +275,74 @@ function consistencyCriterion(slice: Slice): Criterion {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clark–Evans nearest-neighbour index over a point set in a study region of
|
||||
* `area` (UV² units). R ≈ 0 one clump, 1 random, >1 deliberately even. Used
|
||||
* by the rind today; topping distribution (M15) takes the same point list.
|
||||
*/
|
||||
export function clarkEvans(pts: { u: number; v: number }[], area: number): number {
|
||||
const n = pts.length;
|
||||
if (n < 2 || area <= 0) return 0;
|
||||
let sum = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
let best = Infinity;
|
||||
for (let j = 0; j < n; j++) {
|
||||
if (i === j) continue;
|
||||
const du = pts[i].u - pts[j].u;
|
||||
const dv = pts[i].v - pts[j].v;
|
||||
const d2 = du * du + dv * dv;
|
||||
if (d2 < best) best = d2;
|
||||
}
|
||||
sum += Math.sqrt(best);
|
||||
}
|
||||
const observed = sum / n;
|
||||
const expected = 0.5 * Math.sqrt(area / n);
|
||||
return observed / expected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Piece evenness off the prep bench: the CV of piece volumes, banded the way
|
||||
* the live readout words it. Anyone can cut five pieces; the marks are for
|
||||
* five pieces that could be mistaken for each other.
|
||||
*/
|
||||
export function cutEvennessCriterion(cv: number, pieces: number, patternName: string): Criterion {
|
||||
const score = clamp01(1 - (cv - 0.05) / 0.32);
|
||||
const word = cv < 0.08 ? 'even' : cv < 0.16 ? 'respectable' : cv < 0.25 ? 'uneven' : 'a lottery';
|
||||
return {
|
||||
key: 'cutcv',
|
||||
group: 'prep',
|
||||
label: 'The Pieces',
|
||||
score,
|
||||
weight: 1.3,
|
||||
detail: `${pieces} ${patternName}, CV ${cv.toFixed(3)} — ${word}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The state of the board at serve time. Weighted mildly (0.9): mess is a
|
||||
* running gag before it's a crime, but it IS on the card.
|
||||
*/
|
||||
export function benchCriterion(bench: { mass: number; spreadFrac: number; solids: number }): Criterion {
|
||||
const badness = bench.spreadFrac * 3 + bench.solids * 0.04 + bench.mass * 0.4;
|
||||
const score = clamp01(1 - badness / 1.1);
|
||||
const worst =
|
||||
bench.solids >= 3 && bench.solids * 0.04 > bench.spreadFrac * 3
|
||||
? `${bench.solids} bits on my board`
|
||||
: bench.spreadFrac > 0.08
|
||||
? `juice over ${Math.round(bench.spreadFrac * 100)}% of it`
|
||||
: badness < 0.25
|
||||
? 'clean'
|
||||
: 'smeared';
|
||||
return {
|
||||
key: 'bench',
|
||||
group: 'prep',
|
||||
label: 'The Bench',
|
||||
score,
|
||||
weight: 0.9,
|
||||
detail: worst,
|
||||
};
|
||||
}
|
||||
|
||||
function cap(s: string): string {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
@ -159,6 +159,34 @@ const BANK: Bank = {
|
||||
'Correct utensil. Small thing. Not nothing.',
|
||||
],
|
||||
},
|
||||
cutcv: {
|
||||
bad: [
|
||||
'These pieces are related, at best. Distant cousins.',
|
||||
'One of these is a slice. The rest are opinions.',
|
||||
'I lined them up by size. That took a while. That is the problem.',
|
||||
'You cut this the way weather cuts a coastline.',
|
||||
'Every piece is a surprise. Nobody ordered surprises.',
|
||||
],
|
||||
good: [
|
||||
'Even pieces. Actually even. I measured.',
|
||||
'You could deal these like cards.',
|
||||
'Uniform. The knife was under instruction, and it listened.',
|
||||
],
|
||||
},
|
||||
bench: {
|
||||
bad: [
|
||||
'There is juice on everything. Everything.',
|
||||
"Somebody's seeds are on my floor.",
|
||||
'The board looks like it lost an argument.',
|
||||
'You cooked here the way storms cook.',
|
||||
'I am not inspecting the food yet. I am inspecting the aftermath.',
|
||||
],
|
||||
good: [
|
||||
"A clean board. You'd be surprised how rare.",
|
||||
'The bench is spotless. I checked the corners.',
|
||||
'Somebody wiped as they went. Somebody was raised right.',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/** MITEY gets its own vocabulary. */
|
||||
|
||||
@ -2,6 +2,19 @@ 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';
|
||||
import type { CutPattern } from '../sim/cutting';
|
||||
import type { IngredientId } from '../sim/ingredients';
|
||||
|
||||
/**
|
||||
* A stop at the prep bench before (or instead of) the toaster. M11 ships the
|
||||
* 'cut' kind; juicing, zesting, grating and roasting arrive with their
|
||||
* stations (M12+) and extend this union.
|
||||
*/
|
||||
export interface PrepStep {
|
||||
kind: 'cut';
|
||||
ingredient: IngredientId;
|
||||
pattern: CutPattern;
|
||||
}
|
||||
|
||||
export interface Order {
|
||||
day: number;
|
||||
@ -28,6 +41,8 @@ export interface Order {
|
||||
text: string;
|
||||
/** Who's asking. */
|
||||
who: string;
|
||||
/** Work at the prep bench, when the order has any. */
|
||||
prep?: PrepStep[];
|
||||
}
|
||||
|
||||
export const BROWNING_NAMES: [number, string][] = [
|
||||
|
||||
@ -105,7 +105,20 @@ export class JudgeView implements View {
|
||||
this.orderEl.textContent = `Day ${order.day} · ${order.who} asked for ${order.browningName}, ${order.amount} ${order.spread === 'mitey' ? 'MITEY' : order.spread}`;
|
||||
|
||||
clear(this.cardEl);
|
||||
for (const c of verdict.criteria) {
|
||||
// On prep orders the card is long enough to need signposts: group rows by
|
||||
// station. Ordinary toast orders keep the flat card they always had.
|
||||
const grouped = verdict.criteria.some((c) => c.group === 'prep');
|
||||
const headers: Record<string, string> = { prep: 'THE PREP', toast: 'THE TOAST', spread: 'THE SPREAD' };
|
||||
let lastGroup: string | undefined;
|
||||
const rank = (g?: string) => (g === 'prep' ? 0 : g === 'toast' ? 1 : g === 'spread' ? 2 : 3);
|
||||
const ordered = grouped
|
||||
? [...verdict.criteria].sort((a, b) => rank(a.group) - rank(b.group))
|
||||
: verdict.criteria;
|
||||
for (const c of ordered) {
|
||||
if (grouped && c.group !== lastGroup) {
|
||||
lastGroup = c.group;
|
||||
if (c.group) el('div', 'crit-group', this.cardEl, headers[c.group]);
|
||||
}
|
||||
const row = el('div', 'crit', this.cardEl);
|
||||
el('span', 'crit-name', row, c.label);
|
||||
const bar = el('span', 'crit-bar', row);
|
||||
|
||||
429
src/scenes/prep.ts
Normal file
429
src/scenes/prep.ts
Normal file
@ -0,0 +1,429 @@
|
||||
import * as THREE from 'three';
|
||||
import type { App, View } from '../core/app';
|
||||
import { audio } from '../core/audio';
|
||||
import { INGREDIENTS, type Ingredient, type IngredientId } from '../sim/ingredients';
|
||||
import {
|
||||
aimCut,
|
||||
beginBite,
|
||||
cutFrame,
|
||||
cvWord,
|
||||
liftKnife,
|
||||
newCutSession,
|
||||
pieceCV,
|
||||
pieceVolumes,
|
||||
plannedCuts,
|
||||
type CutPattern,
|
||||
type CutSession,
|
||||
} from '../sim/cutting';
|
||||
import { Mess } from '../sim/mess';
|
||||
import { makeCutleryMesh, TOOLS, type ToolId } from '../sim/cutlery';
|
||||
import type { PrepResult } from '../game/judging';
|
||||
import { el, Panel } from '../ui/hud';
|
||||
import { Rng } from '../core/rng';
|
||||
import { loadProp } from './assets';
|
||||
|
||||
const BOARD_Y = 0.06;
|
||||
const BOARD_W = 4.6;
|
||||
const BOARD_D = 2.4;
|
||||
|
||||
/**
|
||||
* The prep bench. One board, one knife, one ingredient at a time, and a mess
|
||||
* field that remembers everything you did to all three.
|
||||
*
|
||||
* Controls mirror the slicer on purpose: horizontal mouse aims the cut, press
|
||||
* bites, vertical strokes saw. The two new verbs are the ingredient's, not
|
||||
* yours: slippery skin skates if you lean without sawing, and wet flesh
|
||||
* juices the board. Drag on the empty board (off the ingredient) to wipe.
|
||||
*/
|
||||
export class PrepView implements View {
|
||||
readonly root = new THREE.Group();
|
||||
readonly mess = new Mess(96);
|
||||
|
||||
private session: CutSession | null = null;
|
||||
private ing!: Ingredient;
|
||||
private ingMesh: THREE.Group | null = null;
|
||||
private pieces: THREE.Group | null = null;
|
||||
private seams: THREE.Mesh[] = [];
|
||||
private knifeMesh!: THREE.Group;
|
||||
private rng = new Rng(20260717);
|
||||
|
||||
private messTexData: Uint8Array;
|
||||
private messTex: THREE.DataTexture;
|
||||
private messVersion = -1;
|
||||
private solidsMesh: THREE.InstancedMesh;
|
||||
|
||||
private ray = new THREE.Raycaster();
|
||||
private planePt = new THREE.Vector3();
|
||||
private plane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0.2);
|
||||
private boardPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -(BOARD_Y * 2));
|
||||
private prev = new THREE.Vector2();
|
||||
private hasPrev = false;
|
||||
private wipePrev: THREE.Vector3 | null = null;
|
||||
private sawPhase = 0;
|
||||
private doneTimer = 0;
|
||||
|
||||
private panel: Panel;
|
||||
private askEl!: HTMLElement;
|
||||
private cutsEl!: HTMLElement;
|
||||
private progFill!: HTMLElement;
|
||||
private benchEl!: HTMLElement;
|
||||
private hintEl!: HTMLElement;
|
||||
|
||||
onDone: ((result: PrepResult) => void) | null = null;
|
||||
|
||||
constructor(private app: App) {
|
||||
this.buildScenery();
|
||||
const n = this.mess.field.n;
|
||||
this.messTexData = new Uint8Array(n * n * 4);
|
||||
this.messTex = new THREE.DataTexture(this.messTexData, n, n, THREE.RGBAFormat);
|
||||
this.messTex.minFilter = THREE.LinearFilter;
|
||||
this.messTex.magFilter = THREE.LinearFilter;
|
||||
const stain = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(BOARD_W, BOARD_D),
|
||||
new THREE.MeshBasicMaterial({ map: this.messTex, transparent: true, depthWrite: false }),
|
||||
);
|
||||
stain.rotation.x = -Math.PI / 2;
|
||||
stain.position.y = BOARD_Y * 2 + 0.004;
|
||||
this.root.add(stain);
|
||||
|
||||
// Solids: seeds and skins, instanced like the rind bits.
|
||||
const seedGeo = new THREE.SphereGeometry(0.028, 8, 6);
|
||||
seedGeo.scale(1, 0.45, 0.72);
|
||||
this.solidsMesh = new THREE.InstancedMesh(
|
||||
seedGeo,
|
||||
new THREE.MeshStandardMaterial({ color: 0xf0e6c8, roughness: 0.6 }),
|
||||
256,
|
||||
);
|
||||
this.solidsMesh.count = 0;
|
||||
this.solidsMesh.castShadow = true;
|
||||
this.root.add(this.solidsMesh);
|
||||
|
||||
this.panel = new Panel('prep-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, 'PREP IT');
|
||||
this.askEl = el('div', 'slicer-ask', card, '');
|
||||
this.cutsEl = el('div', 'slicer-thick', card, '');
|
||||
const bar = el('div', 'timer-bar', card);
|
||||
this.progFill = el('div', 'timer-fill', bar);
|
||||
this.benchEl = 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(BOARD_W, BOARD_Y * 2, BOARD_D),
|
||||
new THREE.MeshStandardMaterial({ color: 0xa87c4f, roughness: 0.7 }),
|
||||
);
|
||||
board.position.y = BOARD_Y;
|
||||
board.receiveShadow = true;
|
||||
board.castShadow = true;
|
||||
this.root.add(board);
|
||||
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);
|
||||
}
|
||||
|
||||
/** Procedural stand-in silhouettes — the shapes carry the physics. */
|
||||
private buildIngredientMesh(ing: Ingredient): THREE.Group {
|
||||
const g = new THREE.Group();
|
||||
const skin = new THREE.MeshStandardMaterial({
|
||||
color: new THREE.Color().setRGB(...ing.colors.skin, THREE.SRGBColorSpace),
|
||||
roughness: 0.55,
|
||||
});
|
||||
let mesh: THREE.Mesh;
|
||||
const r = ing.size / 2;
|
||||
switch (ing.shape) {
|
||||
case 'ovoid': {
|
||||
mesh = new THREE.Mesh(new THREE.SphereGeometry(r, 28, 20), skin);
|
||||
mesh.scale.set(1.25, 0.92, 0.92);
|
||||
break;
|
||||
}
|
||||
case 'block': {
|
||||
mesh = new THREE.Mesh(new THREE.BoxGeometry(ing.size, ing.size * 0.6, ing.size * 0.7), skin);
|
||||
break;
|
||||
}
|
||||
case 'wedge': {
|
||||
mesh = new THREE.Mesh(new THREE.CylinderGeometry(r, r, ing.size * 0.5, 24, 1, false, 0, Math.PI / 2.2), skin);
|
||||
mesh.rotation.y = Math.PI / 4;
|
||||
break;
|
||||
}
|
||||
case 'bulb': {
|
||||
mesh = new THREE.Mesh(new THREE.SphereGeometry(r, 20, 14), skin);
|
||||
mesh.scale.set(1, 0.85, 1);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
mesh = new THREE.Mesh(new THREE.SphereGeometry(r, 28, 20), skin);
|
||||
}
|
||||
mesh.castShadow = true;
|
||||
mesh.receiveShadow = true;
|
||||
g.add(mesh);
|
||||
g.position.y = BOARD_Y * 2 + (ing.shape === 'block' ? ing.size * 0.3 : r * (ing.shape === 'bulb' ? 0.85 : 0.92));
|
||||
return g;
|
||||
}
|
||||
|
||||
/** A fresh ingredient and a fresh session. */
|
||||
reset(ingredientId: IngredientId, knife: ToolId, pattern: CutPattern, askText: string): void {
|
||||
this.ing = INGREDIENTS[ingredientId];
|
||||
this.session = newCutSession(this.ing, knife, pattern);
|
||||
if (this.ingMesh) this.root.remove(this.ingMesh);
|
||||
if (this.pieces) this.root.remove(this.pieces);
|
||||
this.pieces = null;
|
||||
for (const s of this.seams) this.root.remove(s);
|
||||
this.seams = [];
|
||||
this.ingMesh = this.buildIngredientMesh(this.ing);
|
||||
this.root.add(this.ingMesh);
|
||||
if (this.knifeMesh) this.root.remove(this.knifeMesh);
|
||||
this.knifeMesh = makeCutleryMesh(TOOLS[knife]);
|
||||
this.root.add(this.knifeMesh);
|
||||
this.mess.reset();
|
||||
this.askEl.textContent = askText;
|
||||
this.doneTimer = 0;
|
||||
this.hasPrev = false;
|
||||
this.wipePrev = null;
|
||||
this.hintEl.textContent = 'aim · press on it and saw up-down · drag the board to wipe';
|
||||
}
|
||||
|
||||
enter(): void {
|
||||
this.panel.show();
|
||||
}
|
||||
exit(): void {
|
||||
this.panel.hide();
|
||||
}
|
||||
|
||||
/** Board-local UV under the pointer, if the ray hits the board. */
|
||||
private boardUV(): { u: number; v: number } | null {
|
||||
const pt = new THREE.Vector3();
|
||||
if (!this.ray.ray.intersectPlane(this.boardPlane, pt)) return null;
|
||||
const u = (pt.x + BOARD_W / 2) / BOARD_W;
|
||||
const v = (pt.z + BOARD_D / 2) / BOARD_D;
|
||||
if (u < 0 || u > 1 || v < 0 || v > 1) return null;
|
||||
return { u, v };
|
||||
}
|
||||
|
||||
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 s = this.session;
|
||||
if (!s) return;
|
||||
|
||||
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);
|
||||
|
||||
// Cutting happens on the vertical plane near the ingredient; wiping on the
|
||||
// board everywhere else. "Near" = within a size of the centre.
|
||||
const nearIngredient = hit && Math.abs(this.planePt.x) < this.ing.size * 0.85 && s.phase !== 'done';
|
||||
|
||||
if (s.phase !== 'done' && nearIngredient && hit) {
|
||||
if (s.phase === 'aim') {
|
||||
aimCut(s, this.planePt.x / this.ing.size);
|
||||
if (inp.down) {
|
||||
beginBite(s);
|
||||
audio.clatter(0.15, 1.8);
|
||||
}
|
||||
} else if (inp.down && this.hasPrev) {
|
||||
const dy = this.planePt.y - this.prev.y;
|
||||
const dx = this.planePt.x - this.prev.x;
|
||||
const f = cutFrame(s, dy, dx, dt, () => this.rng.next());
|
||||
if (Math.abs(dy) > 0.004) {
|
||||
this.sawPhase += Math.abs(dy) * 3;
|
||||
audio.scrape(0.4, (Math.abs(dy) / Math.max(dt, 1e-3)) * 0.22);
|
||||
}
|
||||
this.applyFrame(f, dy);
|
||||
} else if (!inp.down) {
|
||||
liftKnife(s);
|
||||
}
|
||||
} else if (inp.down) {
|
||||
// Off the ingredient with the button down: the cloth.
|
||||
const uv = this.boardUV();
|
||||
if (uv && this.wipePrev) {
|
||||
this.mess.wipe(this.wipePrev.x, this.wipePrev.y, uv.u, uv.v);
|
||||
}
|
||||
if (uv) this.wipePrev = new THREE.Vector3(uv.u, uv.v, 0);
|
||||
}
|
||||
if (!inp.down) this.wipePrev = null;
|
||||
|
||||
if (s.phase === 'done') {
|
||||
this.doneTimer += dt;
|
||||
// No auto-exit: the bench is judged at serve, so the moment after the
|
||||
// last cut is exactly when you'd want the cloth. ENTER hands it over.
|
||||
this.hintEl.textContent = 'done — wipe the board if you dare care · ENTER takes it to the kitchen';
|
||||
if (inp.justPressed('Enter') && this.onDone) {
|
||||
const result = this.result();
|
||||
const cb = this.onDone;
|
||||
this.onDone = null;
|
||||
cb(result);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.prev.set(this.planePt.x, this.planePt.y);
|
||||
this.hasPrev = !!hit;
|
||||
this.positionKnife(s);
|
||||
this.syncMess();
|
||||
this.updateHud(s);
|
||||
}
|
||||
|
||||
/** What the judge reads. Snapshot at any time; final after onDone. */
|
||||
result(): PrepResult {
|
||||
const s = this.session!;
|
||||
return {
|
||||
cv: pieceCV(s),
|
||||
pieces: pieceVolumes(s).length,
|
||||
patternName: s.pattern.kind === 'halve' ? 'halves' : s.pattern.kind,
|
||||
slips: s.slips,
|
||||
bench: this.mess.stats(),
|
||||
};
|
||||
}
|
||||
|
||||
private applyFrame(f: ReturnType<typeof cutFrame>, dy: number): void {
|
||||
const s = this.session!;
|
||||
const cutWorldX = s.aimPos * this.ing.size;
|
||||
const u = (cutWorldX + BOARD_W / 2) / BOARD_W;
|
||||
const v = 0.5;
|
||||
if (f.juice > 0) {
|
||||
// Sprayed along the blade (board Z), the side picked by the stroke.
|
||||
const side = dy > 0 ? 1 : -1;
|
||||
this.mess.addJuice(u, v, f.juice, (this.rng.next() - 0.5) * 0.3, side * (0.6 + this.rng.next() * 0.4));
|
||||
}
|
||||
if (f.slipped) {
|
||||
audio.clatter(0.5, 0.9);
|
||||
this.app.shake = 0.015;
|
||||
this.hintEl.textContent = 'it skated. saw as you press — the blade needs to bite';
|
||||
}
|
||||
if (f.cutDone) {
|
||||
audio.clunk(false);
|
||||
this.addSeam(s.cuts[s.cuts.length - 1]);
|
||||
for (let i = 0; i < f.seedsSpilled; i++) {
|
||||
this.mess.addSolid(u + (this.rng.next() - 0.5) * 0.12, v + (this.rng.next() - 0.5) * 0.3, 'seed');
|
||||
}
|
||||
}
|
||||
if (f.sessionDone) {
|
||||
this.showPieces();
|
||||
}
|
||||
}
|
||||
|
||||
/** Committed cuts read as dark seams on the ingredient. */
|
||||
private addSeam(pos: number): void {
|
||||
const seam = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(0.016, this.ing.size * 0.95, this.ing.size * 0.95),
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: new THREE.Color().setRGB(
|
||||
this.ing.colors.flesh[0] * 0.45,
|
||||
this.ing.colors.flesh[1] * 0.45,
|
||||
this.ing.colors.flesh[2] * 0.45,
|
||||
THREE.SRGBColorSpace,
|
||||
),
|
||||
roughness: 1,
|
||||
}),
|
||||
);
|
||||
seam.position.set(pos * this.ing.size, this.ingMesh!.position.y, 0);
|
||||
this.root.add(seam);
|
||||
this.seams.push(seam);
|
||||
}
|
||||
|
||||
/** Session done: the whole becomes pieces, fanned apart so the CV is visible. */
|
||||
private showPieces(): void {
|
||||
if (!this.session || this.pieces) return;
|
||||
const s = this.session;
|
||||
if (this.ingMesh) this.ingMesh.visible = false;
|
||||
for (const seam of this.seams) this.root.remove(seam);
|
||||
this.seams = [];
|
||||
this.pieces = new THREE.Group();
|
||||
const flesh = new THREE.MeshStandardMaterial({
|
||||
color: new THREE.Color().setRGB(...this.ing.colors.flesh, THREE.SRGBColorSpace),
|
||||
roughness: 0.5,
|
||||
});
|
||||
const edges = [-0.5, ...[...s.cuts].sort((a, b) => a - b), 0.5];
|
||||
let x = -this.ing.size * 0.55 - edges.length * 0.02;
|
||||
for (let i = 0; i + 1 < edges.length; i++) {
|
||||
const w = (edges[i + 1] - edges[i]) * this.ing.size;
|
||||
const mid = (edges[i] + edges[i + 1]) / 2;
|
||||
const rHere = Math.sqrt(Math.max(0.05, 0.25 - mid * mid)) * this.ing.size;
|
||||
const piece = new THREE.Mesh(new THREE.CylinderGeometry(rHere, rHere, Math.max(0.02, w * 0.92), 20), flesh);
|
||||
piece.rotation.z = Math.PI / 2;
|
||||
piece.castShadow = true;
|
||||
piece.position.set(x + w / 2, BOARD_Y * 2 + rHere, 0.1);
|
||||
this.pieces.add(piece);
|
||||
x += w + 0.05;
|
||||
}
|
||||
this.root.add(this.pieces);
|
||||
}
|
||||
|
||||
private positionKnife(s: CutSession): void {
|
||||
if (!this.knifeMesh) return;
|
||||
const topY = this.ingMesh ? this.ingMesh.position.y + this.ing.size * 0.55 : 1;
|
||||
this.knifeMesh.rotation.set(0, Math.PI / 2, Math.PI / 2);
|
||||
const sawZ = s.phase === 'saw' ? Math.sin(this.sawPhase * 6) * 0.3 : 0.15;
|
||||
const sink = s.phase === 'aim' || s.phase === 'done' ? 0 : s.progress * this.ing.size * 0.9;
|
||||
this.knifeMesh.visible = s.phase !== 'done';
|
||||
this.knifeMesh.position.set(s.aimPos * this.ing.size, topY + 0.22 - sink, sawZ + 0.35);
|
||||
}
|
||||
|
||||
/** Push the mess field into the stain texture when it changed. */
|
||||
private syncMess(): void {
|
||||
if (this.mess.version === this.messVersion) return;
|
||||
this.messVersion = this.mess.version;
|
||||
const d = this.mess.field.data;
|
||||
const px = this.messTexData;
|
||||
for (let i = 0; i < d.length; i++) {
|
||||
const m = Math.min(1, d[i] * 2.2);
|
||||
px[i * 4] = 190;
|
||||
px[i * 4 + 1] = 68;
|
||||
px[i * 4 + 2] = 36;
|
||||
px[i * 4 + 3] = Math.round(Math.min(0.85, m) * 255);
|
||||
}
|
||||
this.messTex.needsUpdate = true;
|
||||
const solids = this.mess.solids.filter((s) => !s.collected);
|
||||
const mat = new THREE.Matrix4();
|
||||
let n = 0;
|
||||
for (const s of solids) {
|
||||
if (n >= 256) break;
|
||||
mat.setPosition(s.u * BOARD_W - BOARD_W / 2, BOARD_Y * 2 + 0.02, s.v * BOARD_D - BOARD_D / 2);
|
||||
this.solidsMesh.setMatrixAt(n++, mat);
|
||||
}
|
||||
this.solidsMesh.count = n;
|
||||
this.solidsMesh.instanceMatrix.needsUpdate = true;
|
||||
}
|
||||
|
||||
private updateHud(s: CutSession): void {
|
||||
const total = plannedCuts(s.pattern);
|
||||
const done = s.cuts.length + s.diceCutsA.length;
|
||||
if (s.phase === 'done') {
|
||||
const cv = pieceCV(s);
|
||||
this.cutsEl.textContent = `${pieceVolumes(s).length} pieces — CV ${cv.toFixed(3)}, ${cvWord(cv)}`;
|
||||
} else {
|
||||
this.cutsEl.textContent = `cut ${Math.min(done + 1, total)} of ${total}${s.phase === 'aim' ? ' (aiming)' : ''}`;
|
||||
}
|
||||
this.progFill.style.width = `${Math.round(s.progress * 100)}%`;
|
||||
this.progFill.style.background = '#e8a53a';
|
||||
const bench = this.mess.benchWord();
|
||||
const wobble =
|
||||
s.phase === 'saw' && s.wedge > 0.12 ? (s.wedge > 0.3 ? ' · that cut is wandering' : ' · drifting…') : '';
|
||||
this.benchEl.textContent = `bench: ${bench}${wobble}${s.slips ? ` · slips: ${s.slips}` : ''}`;
|
||||
this.benchEl.style.color = bench === 'clean' ? '' : bench === 'smeared' ? '#e8a53a' : '#e2603a';
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.panel.dispose();
|
||||
}
|
||||
}
|
||||
336
src/sim/cutting.ts
Normal file
336
src/sim/cutting.ts
Normal file
@ -0,0 +1,336 @@
|
||||
import { DEFAULT_SAW, TOOLS, type ToolId } from './cutlery';
|
||||
import type { Ingredient } from './ingredients';
|
||||
|
||||
/**
|
||||
* Cutting, generalized from the loaf (sim/slicing.ts) to anything on the board.
|
||||
*
|
||||
* A CutSession is an ingredient, a knife, and a pattern. Each cut in the
|
||||
* pattern runs the same three-act physics the loaf taught us — bite, saw,
|
||||
* through — with two additions the loaf never needed:
|
||||
*
|
||||
* slip — skin.slippery × pressing-without-sawing. A serrated blade in
|
||||
* motion bites; a flat blade leaning on a tomato skates. When it
|
||||
* skates the cut position jumps and the juice goes somewhere.
|
||||
* juice — flesh.moisture ejected along the stroke, superlinear in speed,
|
||||
* so the frantic cut soaks the board and the deliberate one doesn't.
|
||||
*
|
||||
* The judged number is piece evenness: the coefficient of variation of piece
|
||||
* volumes. CV < 0.08 reads as "even"; past 0.25 it's a lottery.
|
||||
*/
|
||||
|
||||
export type CutPattern =
|
||||
| { kind: 'halve' }
|
||||
| { kind: 'slices'; n: number }
|
||||
| { kind: 'wedges'; n: number }
|
||||
| { kind: 'dice'; n: number };
|
||||
|
||||
export interface CutFrame {
|
||||
/** Skated this frame: the aim jumped to `aimPos`, juice sprayed. */
|
||||
slipped: boolean;
|
||||
/** Juice mass ejected this frame (scene feeds it to the mess field). */
|
||||
juice: number;
|
||||
/** A cut just went through. */
|
||||
cutDone: boolean;
|
||||
/** Seeds shaken loose by the cut that just finished. */
|
||||
seedsSpilled: number;
|
||||
/** The whole pattern is complete. */
|
||||
sessionDone: boolean;
|
||||
}
|
||||
|
||||
export interface CutSession {
|
||||
ing: Ingredient;
|
||||
knife: ToolId;
|
||||
pattern: CutPattern;
|
||||
phase: 'aim' | 'bite' | 'saw' | 'done';
|
||||
/** Committed cut positions, -0.5..0.5 along the axis (fraction of size).
|
||||
* For wedges the same range reads as a fraction of the half-turn. */
|
||||
cuts: number[];
|
||||
/** Dice is two passes: 0 = first slices, 1 = rotated criss-cross. */
|
||||
dicePhase: 0 | 1;
|
||||
diceCutsA: number[];
|
||||
aimPos: number;
|
||||
/** Per-cut accumulators, reset when a cut commits. */
|
||||
progress: number;
|
||||
wedge: number;
|
||||
squash: number;
|
||||
strokes: number;
|
||||
biteTravel: number;
|
||||
pressTime: number;
|
||||
lastDir: number;
|
||||
strokeTravel: number;
|
||||
/** Session totals the judge and the sting math read. */
|
||||
slips: number;
|
||||
juiceTotal: number;
|
||||
/** Crushing over the whole session — onion sting scales with this (M14). */
|
||||
aggression: number;
|
||||
wedgeWorst: number;
|
||||
squashWorst: number;
|
||||
}
|
||||
|
||||
const STROKE_GAIN = 0.11;
|
||||
const WOBBLE_GAIN = 0.55;
|
||||
const SPEED_CAP = 3.2;
|
||||
const SQUASH_GAIN = 0.55;
|
||||
/** Seconds of leaning on slippery skin before it skates, at slippery = 1
|
||||
* with a knife that has no saw stats worth the name. */
|
||||
const SLIP_PATIENCE = 0.35;
|
||||
|
||||
export function plannedCuts(pattern: CutPattern): number {
|
||||
switch (pattern.kind) {
|
||||
case 'halve':
|
||||
return 1;
|
||||
case 'slices':
|
||||
return pattern.n - 1;
|
||||
case 'wedges':
|
||||
return Math.ceil(pattern.n / 2); // diametral cuts, each makes two wedges
|
||||
case 'dice':
|
||||
return (pattern.n - 1) * 2; // two rotated passes of n-1
|
||||
}
|
||||
}
|
||||
|
||||
export function newCutSession(ing: Ingredient, knife: ToolId, pattern: CutPattern): CutSession {
|
||||
return {
|
||||
ing,
|
||||
knife,
|
||||
pattern,
|
||||
phase: 'aim',
|
||||
cuts: [],
|
||||
dicePhase: 0,
|
||||
diceCutsA: [],
|
||||
aimPos: 0,
|
||||
progress: 0,
|
||||
wedge: 0,
|
||||
squash: 0,
|
||||
strokes: 0,
|
||||
biteTravel: 0,
|
||||
pressTime: 0,
|
||||
lastDir: 0,
|
||||
strokeTravel: 0,
|
||||
slips: 0,
|
||||
juiceTotal: 0,
|
||||
aggression: 0,
|
||||
wedgeWorst: 0,
|
||||
squashWorst: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function cutSawStats(knife: ToolId): { eff: number; steady: number; gentle: number } {
|
||||
return TOOLS[knife].saw ?? DEFAULT_SAW;
|
||||
}
|
||||
|
||||
/** While aiming: where the next cut will land. */
|
||||
export function aimCut(s: CutSession, pos: number): void {
|
||||
if (s.phase !== 'aim') return;
|
||||
s.aimPos = Math.min(0.5, Math.max(-0.5, pos));
|
||||
}
|
||||
|
||||
/** Mouse went down on the ingredient: the blade is on the skin. */
|
||||
export function beginBite(s: CutSession): void {
|
||||
if (s.phase === 'aim') {
|
||||
s.phase = 'bite';
|
||||
s.biteTravel = 0;
|
||||
s.pressTime = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** Mouse released mid-cut: progress keeps, the blade comes off the skin. */
|
||||
export function liftKnife(s: CutSession): void {
|
||||
if (s.phase === 'bite') s.phase = 'aim';
|
||||
}
|
||||
|
||||
/**
|
||||
* One frame of blade-on-ingredient. `dz` is saw motion along the blade, `dx`
|
||||
* sideways wander. `rand` feeds the slip jump only — everything else is
|
||||
* deterministic, which is what makes it verifiable.
|
||||
*/
|
||||
export function cutFrame(s: CutSession, dz: number, dx: number, dt: number, rand: () => number): CutFrame {
|
||||
const out: CutFrame = { slipped: false, juice: 0, cutDone: false, seedsSpilled: 0, sessionDone: false };
|
||||
if (s.phase !== 'bite' && s.phase !== 'saw') return out;
|
||||
const k = cutSawStats(s.knife);
|
||||
const speed = dt > 0 ? Math.abs(dz) / dt : 0;
|
||||
|
||||
if (s.phase === 'bite') {
|
||||
// The first bite: saw motion earns grip, leaning earns a skate. A truly
|
||||
// sharp edge (The Best Thing) sinks in on downforce alone — that is the
|
||||
// whole legend of it.
|
||||
s.biteTravel += Math.abs(dz) + Math.max(0, k.eff - 1.05) * 0.08 * dt;
|
||||
s.pressTime += dt;
|
||||
const biteNeed = 0.015 + s.ing.skin.resistance * 0.05 * (1.6 - Math.min(1.2, k.eff));
|
||||
if (s.biteTravel >= biteNeed) {
|
||||
s.phase = 'saw';
|
||||
} else {
|
||||
const patience = (SLIP_PATIENCE / (s.ing.skin.slippery + 0.05)) * (0.4 + k.eff);
|
||||
if (s.pressTime > patience) {
|
||||
// It skates. The cut position jumps, the juice goes on the board.
|
||||
s.slips++;
|
||||
s.pressTime = 0;
|
||||
s.biteTravel = 0;
|
||||
const jump = (0.07 + rand() * 0.09) * (rand() < 0.5 ? -1 : 1);
|
||||
s.aimPos = Math.min(0.5, Math.max(-0.5, s.aimPos + jump));
|
||||
out.slipped = true;
|
||||
out.juice = Math.max(0, s.ing.flesh.moisture - 0.3) * 0.35;
|
||||
s.juiceTotal += out.juice;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
// Sawing. Same speed-capped progress as the loaf, scaled by flesh fight.
|
||||
const hardness = 0.35 / (0.12 + s.ing.flesh.resistance * 0.55);
|
||||
const sizeFactor = 0.5 / (0.2 + s.ing.size * 0.6);
|
||||
const useful = Math.min(Math.abs(dz), SPEED_CAP * dt);
|
||||
s.progress += useful * STROKE_GAIN * k.eff * hardness * sizeFactor * 14;
|
||||
|
||||
const excess = Math.max(0, speed - SPEED_CAP * (0.6 + k.gentle));
|
||||
const crush = excess * SQUASH_GAIN * (1 - k.gentle) * (1.3 - s.ing.flesh.resistance) * dt;
|
||||
s.squash = Math.min(1, s.squash + crush);
|
||||
s.aggression += crush;
|
||||
|
||||
s.wedge = Math.min(1, s.wedge + Math.abs(dx) * WOBBLE_GAIN * (1 - k.steady));
|
||||
|
||||
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);
|
||||
|
||||
// Juice: moisture × speed², so the deliberate cut leaks and the frantic one
|
||||
// hoses. Only flesh wetter than 0.4 juices at all.
|
||||
if (s.ing.flesh.moisture > 0.4) {
|
||||
out.juice = (s.ing.flesh.moisture - 0.35) * speed * Math.abs(dz) * 0.1;
|
||||
s.juiceTotal += out.juice;
|
||||
}
|
||||
|
||||
if (s.progress >= 1) {
|
||||
out.cutDone = true;
|
||||
s.cuts.push(s.aimPos);
|
||||
s.wedgeWorst = Math.max(s.wedgeWorst, s.wedge);
|
||||
s.squashWorst = Math.max(s.squashWorst, s.squash);
|
||||
out.seedsSpilled = seedsFor(s);
|
||||
s.progress = 0;
|
||||
s.wedge = 0;
|
||||
s.squash = 0;
|
||||
s.biteTravel = 0;
|
||||
s.pressTime = 0;
|
||||
if (s.pattern.kind === 'dice' && s.dicePhase === 0 && s.cuts.length >= plannedCuts(s.pattern) / 2) {
|
||||
// First pass done: bank it, rotate the board, go again.
|
||||
s.diceCutsA = s.cuts;
|
||||
s.cuts = [];
|
||||
s.dicePhase = 1;
|
||||
s.phase = 'aim';
|
||||
} else if (totalCutsDone(s) >= plannedCuts(s.pattern)) {
|
||||
s.phase = 'done';
|
||||
out.sessionDone = true;
|
||||
} else {
|
||||
s.phase = 'aim';
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function totalCutsDone(s: CutSession): number {
|
||||
return s.cuts.length + s.diceCutsA.length;
|
||||
}
|
||||
|
||||
function seedsFor(s: CutSession): number {
|
||||
const seeds = s.ing.seeds;
|
||||
if (!seeds) return 0;
|
||||
const justCut = s.cuts[s.cuts.length - 1] ?? 0;
|
||||
if (seeds.pocket === 'core') {
|
||||
return Math.abs(justCut) < 0.12 ? Math.ceil(seeds.count * 0.6) : 0;
|
||||
}
|
||||
// Distributed pockets shed a share per cut, more when squashed.
|
||||
return Math.round((seeds.count / Math.max(1, plannedCuts(s.pattern))) * (0.35 + s.squash * 0.5));
|
||||
}
|
||||
|
||||
/**
|
||||
* Piece volumes implied by the committed cuts. For slices/halve these are
|
||||
* slab volumes through the actual solid of revolution, so a cut near the end
|
||||
* of a sphere honestly yields a sliver.
|
||||
*/
|
||||
export function pieceVolumes(s: CutSession): number[] {
|
||||
const p = s.pattern;
|
||||
if (p.kind === 'wedges') {
|
||||
// Diametral cuts at angles (aimPos as fraction of the half-turn).
|
||||
const angles = [...s.cuts].map((c) => (c + 0.5) * Math.PI).sort((a, b) => a - b);
|
||||
if (angles.length === 0) return [];
|
||||
const out: number[] = [];
|
||||
for (let i = 0; i < angles.length; i++) {
|
||||
const a = angles[i];
|
||||
const b = i + 1 < angles.length ? angles[i + 1] : angles[0] + Math.PI;
|
||||
out.push(b - a, b - a); // each diametral gap is two opposite wedges
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (p.kind === 'dice') {
|
||||
const a = widths(s.diceCutsA);
|
||||
const b = widths(s.cuts);
|
||||
const cells: number[] = [];
|
||||
for (const wa of a) for (const wb of b) cells.push(wa * wb);
|
||||
return cells;
|
||||
}
|
||||
const edges = [-0.5, ...[...s.cuts].sort((a, b) => a - b), 0.5];
|
||||
const out: number[] = [];
|
||||
for (let i = 0; i + 1 < edges.length; i++) out.push(slabVolume(s.ing, edges[i], edges[i + 1]));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Sorted cut positions -> piece widths including both ends. */
|
||||
function widths(cuts: number[]): number[] {
|
||||
const c = [...cuts].sort((a, b) => a - b);
|
||||
const edges = [-0.5, ...c, 0.5];
|
||||
const out: number[] = [];
|
||||
for (let i = 0; i + 1 < edges.length; i++) out.push(edges[i + 1] - edges[i]);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Volume of the slab between fractions a..a+w of the solid, unit size. */
|
||||
function slabVolume(ing: Ingredient, a: number, b: number): number {
|
||||
// Numeric integral of the cross-section through a sphere/ovoid; blocks and
|
||||
// wedges are uniform enough that width is volume.
|
||||
if (ing.shape === 'block' || ing.shape === 'wedge') return Math.abs(b - a);
|
||||
const steps = 24;
|
||||
let v = 0;
|
||||
for (let i = 0; i < steps; i++) {
|
||||
const x = a + ((i + 0.5) / steps) * (b - a);
|
||||
const r2 = Math.max(0, 0.25 - x * x); // unit sphere of radius 0.5
|
||||
v += r2;
|
||||
}
|
||||
return (v / steps) * Math.abs(b - a) * Math.PI;
|
||||
}
|
||||
|
||||
/**
|
||||
* The judged statistic: coefficient of variation of the pieces.
|
||||
*
|
||||
* For straight slices the CV runs over piece WIDTHS, not volumes — on a round
|
||||
* ingredient the end slices are thinner by geometry, and a statistic the
|
||||
* player cannot get under 0.4 with a laser-guided knife isn't a skill test.
|
||||
* Widths are exactly the thing the hand controls. Wedges and dice keep the
|
||||
* volume/cell statistic: there the spread really is all yours.
|
||||
*/
|
||||
export function pieceCV(s: CutSession): number {
|
||||
const vals = s.pattern.kind === 'slices' || s.pattern.kind === 'halve' ? sliceWidths(s) : pieceVolumes(s);
|
||||
if (vals.length < 2) return 0;
|
||||
const mean = vals.reduce((a, v) => a + v, 0) / vals.length;
|
||||
if (mean <= 0) return 1;
|
||||
const acc = vals.reduce((a, v) => a + (v - mean) * (v - mean), 0) / vals.length;
|
||||
return Math.sqrt(acc) / mean;
|
||||
}
|
||||
|
||||
function sliceWidths(s: CutSession): number[] {
|
||||
const edges = [-0.5, ...[...s.cuts].sort((a, b) => a - b), 0.5];
|
||||
const out: number[] = [];
|
||||
for (let i = 0; i + 1 < edges.length; i++) out.push(edges[i + 1] - edges[i]);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Halve quality: centre offset and the worst wedge — M12's yield cap reads this. */
|
||||
export function halveQuality(s: CutSession): { offset: number; wedge: number } {
|
||||
return { offset: Math.abs(s.cuts[0] ?? 0.5), wedge: s.wedgeWorst };
|
||||
}
|
||||
|
||||
export function cvWord(cv: number): string {
|
||||
return cv < 0.08 ? 'even' : cv < 0.16 ? 'respectable' : cv < 0.25 ? 'uneven' : 'a lottery';
|
||||
}
|
||||
139
src/sim/ingredients.ts
Normal file
139
src/sim/ingredients.ts
Normal file
@ -0,0 +1,139 @@
|
||||
/**
|
||||
* The prep-bench cast. Everything a knife can be taken to, described by the
|
||||
* numbers the cutting sim actually consumes — shape for the silhouette and the
|
||||
* piece math, skin for the slip check, flesh for juice and layering, and the
|
||||
* station-specific extras (seeds, sting, temperature needs, gratability).
|
||||
*
|
||||
* 1 world unit ≈ 11cm, same convention as the loaf.
|
||||
*/
|
||||
|
||||
export type IngredientShape = 'sphere' | 'ovoid' | 'block' | 'wedge' | 'bulb';
|
||||
|
||||
export interface Ingredient {
|
||||
id: string;
|
||||
name: string;
|
||||
shape: IngredientShape;
|
||||
/** Characteristic diameter/length in world units. */
|
||||
size: number;
|
||||
/** resistance: how hard the skin fights the first bite. slippery: how keenly
|
||||
* it skates when pressed without sawing — the tomato number. */
|
||||
skin: { resistance: number; slippery: number };
|
||||
/** moisture > 0.4 juices when cut; layered flesh falls apart when mistreated. */
|
||||
flesh: { resistance: number; moisture: number; layered?: boolean };
|
||||
seeds?: { count: number; pocket: 'core' | 'distributed' };
|
||||
/** Onions. 0..1, scales how fast cutting stings the eyes. */
|
||||
sting?: number;
|
||||
temp?: { state: 'fridge' | 'bench'; softensInSec?: number; needsSoft?: boolean; needsCold?: boolean };
|
||||
gratable?: { yield: number; pithDepth?: number };
|
||||
roastable?: boolean;
|
||||
/** Authored sRGB, same convention as bread crumb/crust. */
|
||||
colors: { skin: [number, number, number]; flesh: [number, number, number] };
|
||||
}
|
||||
|
||||
export type IngredientId =
|
||||
| 'tomato'
|
||||
| 'roast_tomato'
|
||||
| 'orange'
|
||||
| 'onion'
|
||||
| 'garlic'
|
||||
| 'parmesan'
|
||||
| 'brie'
|
||||
| 'cucumber'
|
||||
| 'avocado';
|
||||
|
||||
export const INGREDIENTS: Record<IngredientId, Ingredient> = {
|
||||
tomato: {
|
||||
id: 'tomato',
|
||||
name: 'tomato',
|
||||
shape: 'sphere',
|
||||
size: 0.6,
|
||||
skin: { resistance: 0.35, slippery: 0.9 },
|
||||
flesh: { resistance: 0.2, moisture: 0.75 },
|
||||
seeds: { count: 20, pocket: 'distributed' },
|
||||
roastable: true,
|
||||
colors: { skin: [0.82, 0.18, 0.12], flesh: [0.9, 0.35, 0.22] },
|
||||
},
|
||||
roast_tomato: {
|
||||
id: 'roast_tomato',
|
||||
name: 'roast tomato',
|
||||
shape: 'sphere',
|
||||
size: 0.52,
|
||||
// The skin blisters off in the oven; what's left is a water balloon.
|
||||
skin: { resistance: 0.1, slippery: 0.55 },
|
||||
flesh: { resistance: 0.08, moisture: 0.95 },
|
||||
colors: { skin: [0.62, 0.16, 0.1], flesh: [0.75, 0.28, 0.16] },
|
||||
},
|
||||
orange: {
|
||||
id: 'orange',
|
||||
name: 'orange',
|
||||
shape: 'ovoid',
|
||||
size: 0.68,
|
||||
skin: { resistance: 0.55, slippery: 0.45 },
|
||||
flesh: { resistance: 0.3, moisture: 0.85 },
|
||||
seeds: { count: 5, pocket: 'core' },
|
||||
gratable: { yield: 1.0, pithDepth: 0.35 },
|
||||
colors: { skin: [0.95, 0.55, 0.1], flesh: [0.98, 0.68, 0.2] },
|
||||
},
|
||||
onion: {
|
||||
id: 'onion',
|
||||
name: 'brown onion',
|
||||
shape: 'sphere',
|
||||
size: 0.62,
|
||||
skin: { resistance: 0.4, slippery: 0.6 },
|
||||
flesh: { resistance: 0.45, moisture: 0.55, layered: true },
|
||||
sting: 1.0,
|
||||
colors: { skin: [0.72, 0.5, 0.28], flesh: [0.93, 0.9, 0.82] },
|
||||
},
|
||||
garlic: {
|
||||
id: 'garlic',
|
||||
name: 'garlic',
|
||||
shape: 'bulb',
|
||||
size: 0.28,
|
||||
skin: { resistance: 0.25, slippery: 0.35 },
|
||||
flesh: { resistance: 0.5, moisture: 0.3, layered: true },
|
||||
sting: 0.15,
|
||||
colors: { skin: [0.9, 0.86, 0.8], flesh: [0.95, 0.92, 0.85] },
|
||||
},
|
||||
parmesan: {
|
||||
id: 'parmesan',
|
||||
name: 'parmesan',
|
||||
shape: 'block',
|
||||
size: 0.5,
|
||||
skin: { resistance: 0.8, slippery: 0.15 },
|
||||
flesh: { resistance: 0.85, moisture: 0.1 },
|
||||
temp: { state: 'fridge', softensInSec: 90, needsCold: true },
|
||||
gratable: { yield: 0.9 },
|
||||
colors: { skin: [0.85, 0.75, 0.5], flesh: [0.93, 0.87, 0.68] },
|
||||
},
|
||||
brie: {
|
||||
id: 'brie',
|
||||
name: 'brie',
|
||||
shape: 'wedge',
|
||||
size: 0.45,
|
||||
skin: { resistance: 0.3, slippery: 0.2 },
|
||||
flesh: { resistance: 0.15, moisture: 0.35 },
|
||||
temp: { state: 'fridge', softensInSec: 120, needsSoft: true },
|
||||
colors: { skin: [0.95, 0.93, 0.88], flesh: [0.97, 0.92, 0.78] },
|
||||
},
|
||||
cucumber: {
|
||||
id: 'cucumber',
|
||||
name: 'cucumber',
|
||||
shape: 'ovoid',
|
||||
size: 1.4,
|
||||
skin: { resistance: 0.5, slippery: 0.5 },
|
||||
flesh: { resistance: 0.25, moisture: 0.8 },
|
||||
seeds: { count: 14, pocket: 'distributed' },
|
||||
colors: { skin: [0.2, 0.45, 0.16], flesh: [0.85, 0.94, 0.75] },
|
||||
},
|
||||
avocado: {
|
||||
id: 'avocado',
|
||||
name: 'avocado',
|
||||
shape: 'ovoid',
|
||||
size: 0.62,
|
||||
skin: { resistance: 0.7, slippery: 0.3 },
|
||||
// Ripeness is a lottery: roll flesh resistance per-avocado at spawn.
|
||||
flesh: { resistance: 0.5, moisture: 0.45 },
|
||||
seeds: { count: 1, pocket: 'core' },
|
||||
colors: { skin: [0.16, 0.2, 0.1], flesh: [0.78, 0.85, 0.5] },
|
||||
},
|
||||
};
|
||||
156
src/sim/mess.ts
Normal file
156
src/sim/mess.ts
Normal file
@ -0,0 +1,156 @@
|
||||
import { Field } from '../core/field';
|
||||
|
||||
/**
|
||||
* The bench is judged now.
|
||||
*
|
||||
* Juice, pulp and stray solids live on the cutting board's own UV square —
|
||||
* the same Field machinery as the toast, pointed at the crime scene around
|
||||
* it. Two kinds of mess:
|
||||
*
|
||||
* the field — juice and smear, a mass per texel
|
||||
* solids — seeds, skins, zest: discrete bits with positions, rendered
|
||||
* instanced, counted by the judge individually ("three pips.
|
||||
* I counted.")
|
||||
*
|
||||
* The wipe is spreading in reverse, and it keeps the spread's honesty: a
|
||||
* cloth pass picks SOME mass up and pushes the rest ahead of itself, so one
|
||||
* pass smears and the second one cleans. That's the feel; it's also why
|
||||
* wiping late beats wiping never but costs you twice the service clock.
|
||||
*/
|
||||
|
||||
export type SolidKind = 'seed' | 'skin' | 'zest' | 'pith' | 'pulp' | 'crumb';
|
||||
|
||||
export interface Solid {
|
||||
u: number;
|
||||
v: number;
|
||||
kind: SolidKind;
|
||||
/** Set when a wipe collects it; the scene fades it out. */
|
||||
collected?: boolean;
|
||||
}
|
||||
|
||||
/** "There is visibly juice on this texel." Shared with the live readout. */
|
||||
export const MESS_FLOOR = 0.03;
|
||||
|
||||
export class Mess {
|
||||
readonly field: Field;
|
||||
readonly mask: Field;
|
||||
readonly solids: Solid[] = [];
|
||||
/** What the cloth is currently carrying. */
|
||||
clothLoad = 0;
|
||||
/** Bumped whenever anything changes, so the scene knows to re-texture. */
|
||||
version = 0;
|
||||
|
||||
constructor(n = 96) {
|
||||
this.field = new Field(n);
|
||||
this.mask = new Field(n, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Juice lands as a streak: a few blobs along the stroke direction from the
|
||||
* cut point, weaker with distance. `du,dv` is the (unnormalized) stroke
|
||||
* direction; more speed = longer throw.
|
||||
*/
|
||||
addJuice(u: number, v: number, amount: number, du: number, dv: number): void {
|
||||
if (amount <= 0) return;
|
||||
const len = Math.hypot(du, dv);
|
||||
const nu = len > 1e-6 ? du / len : 0;
|
||||
const nv = len > 1e-6 ? dv / len : 1;
|
||||
const throwLen = Math.min(0.28, 0.06 + amount * 0.5);
|
||||
const blobs = 3;
|
||||
for (let i = 0; i < blobs; i++) {
|
||||
const t = i / (blobs - 1);
|
||||
const bu = u + nu * throwLen * t;
|
||||
const bv = v + nv * throwLen * t;
|
||||
const share = (amount * (1 - t * 0.55)) / blobs;
|
||||
const r = 0.03 + amount * 0.04;
|
||||
const total = this.field.brush(bu, bv, r, () => undefined);
|
||||
if (total <= 0) continue;
|
||||
this.field.brush(bu, bv, r, (idx, w) => {
|
||||
this.field.data[idx] += (share * w) / total;
|
||||
});
|
||||
}
|
||||
this.version++;
|
||||
}
|
||||
|
||||
addSolid(u: number, v: number, kind: SolidKind): void {
|
||||
this.solids.push({ u: clamp01(u), v: clamp01(v), kind });
|
||||
this.version++;
|
||||
}
|
||||
|
||||
/**
|
||||
* One cloth step from (pu,pv) to (u,v). Picks up ~half of what it crosses,
|
||||
* shoves the rest ahead of the leading edge — mass conserving until the
|
||||
* cloth finally absorbs it. Collects solids it passes over. Returns mass
|
||||
* picked up this step (the scene can wring the cloth on a threshold).
|
||||
*/
|
||||
wipe(pu: number, pv: number, u: number, v: number, radius = 0.07): number {
|
||||
const du = u - pu;
|
||||
const dv = v - pv;
|
||||
const len = Math.hypot(du, dv);
|
||||
if (len < 1e-5) return 0;
|
||||
const nu = du / len;
|
||||
const nv = dv / len;
|
||||
let lifted = 0;
|
||||
let pushed = 0;
|
||||
this.field.brush(u, v, radius, (idx, w) => {
|
||||
const take = this.field.data[idx] * Math.min(1, w * 0.9);
|
||||
const absorb = take * 0.55;
|
||||
lifted += absorb;
|
||||
pushed += take - absorb;
|
||||
this.field.data[idx] -= take;
|
||||
});
|
||||
// The un-absorbed mass lands just past the leading edge: the smear.
|
||||
if (pushed > 0) {
|
||||
const fu = u + nu * radius * 1.35;
|
||||
const fv = v + nv * radius * 1.35;
|
||||
const total = this.field.brush(fu, fv, radius * 0.8, () => undefined);
|
||||
if (total > 0) {
|
||||
this.field.brush(fu, fv, radius * 0.8, (idx, w) => {
|
||||
this.field.data[idx] += (pushed * w) / total;
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const s of this.solids) {
|
||||
if (s.collected) continue;
|
||||
const d = Math.hypot(s.u - u, s.v - v);
|
||||
if (d < radius) {
|
||||
s.collected = true;
|
||||
}
|
||||
}
|
||||
this.clothLoad += lifted;
|
||||
this.version++;
|
||||
return lifted;
|
||||
}
|
||||
|
||||
/** The judge's numbers: total juice mass, how far it's spread, loose bits. */
|
||||
stats(): { mass: number; spreadFrac: number; solids: number } {
|
||||
const d = this.field.data;
|
||||
let mass = 0;
|
||||
let dirty = 0;
|
||||
for (let i = 0; i < d.length; i++) {
|
||||
mass += d[i];
|
||||
if (d[i] > MESS_FLOOR) dirty++;
|
||||
}
|
||||
return {
|
||||
mass,
|
||||
spreadFrac: dirty / d.length,
|
||||
solids: this.solids.filter((s) => !s.collected).length,
|
||||
};
|
||||
}
|
||||
|
||||
/** The live readout, in the judge's own thresholds. */
|
||||
benchWord(): 'clean' | 'smeared' | 'a crime scene' {
|
||||
const s = this.stats();
|
||||
const badness = s.spreadFrac * 3 + s.solids * 0.04 + s.mass * 0.4;
|
||||
return badness < 0.25 ? 'clean' : badness < 0.9 ? 'smeared' : 'a crime scene';
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.field.clear();
|
||||
this.solids.length = 0;
|
||||
this.clothLoad = 0;
|
||||
this.version++;
|
||||
}
|
||||
}
|
||||
|
||||
const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v);
|
||||
@ -325,6 +325,16 @@ canvas#c {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
/* Station headers on prep-order scorecards. */
|
||||
.crit-group {
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.22em;
|
||||
opacity: 0.55;
|
||||
margin: 8px 0 2px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
|
||||
padding-bottom: 3px;
|
||||
}
|
||||
|
||||
.crit-name {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.1em;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user