diff --git a/README.md b/README.md index a8407fe..74ca6fd 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,15 @@ it (char 99.9% → 54.5%, evenness 0.029 → 0.226). The judge notices both. And MITEY is its own skill: spread it on, then take almost all of it back off. He wants to see the toast *through* it. +Marmalade adds a second axis: the rind. Bits drop off the blade **per unit of +stroke distance**, so a dabbing knife makes a pile and a moving knife lays a +trail — and because the shed rate scales with what's left on the blade, one dip +can't cover the slice. The judge scores the scatter with the Clark–Evans +nearest-neighbour index (R ≈ 0 one clump, 1 random, above 1 deliberately even) +and quotes it on the card: a corner dab reads *"one clump (R 0.24)"*, three +well-placed dips read *"evenly strewn (R 1.17)"*. Scraping picks rind back up, +so a clump can be argued with rather than started over. + ## The bread Same dial, same 18 seconds, four breads: diff --git a/public/assets/models/marmalade_jar.glb b/public/assets/models/marmalade_jar.glb new file mode 100644 index 0000000..6ab7e66 Binary files /dev/null and b/public/assets/models/marmalade_jar.glb differ diff --git a/src/dev.ts b/src/dev.ts index b0c9422..0bdc4af 100644 --- a/src/dev.ts +++ b/src/dev.ts @@ -54,7 +54,10 @@ export function installDevHarness(app: App, game: Game): void { }, dip() { - point(3.15, 0.29, 0.45, true); + const hit = (kitchen as unknown as { potHit: THREE.Mesh | null }).potHit; + const wp = new THREE.Vector3(3.15, 0.29, 0.45); + hit?.getWorldPosition(wp); + point(wp.x, wp.y, wp.z, true); run(4); app.input.down = false; app.step(1 / 60); diff --git a/src/game/judging.ts b/src/game/judging.ts index 1a137c2..c7997d0 100644 --- a/src/game/judging.ts +++ b/src/game/judging.ts @@ -89,6 +89,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)}%`, }, + ...(def.particles ? [rindCriterion(slice, def.particles.name)] : []), { key: 'amount', label: 'The Right Amount', @@ -136,6 +137,57 @@ export function judge(slice: Slice, order: Order, usedTool: ToolId, seconds: num }; } +/** + * 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 + * the same number of bits were scattered at random. R near 0 = one clump, + * R = 1 = random scatter, R above 1 = deliberately even. The judge accepts + * random; he rewards deliberate. + */ +function rindCriterion(slice: Slice, name: string): Criterion { + const pts = slice.rind; + const n = pts.length; + if (n < 4) { + return { + key: 'rind', + label: cap(name), + score: n === 0 ? 0.05 : 0.3, + weight: 1.1, + detail: n === 0 ? `no ${name} at all` : `only ${n} bits of ${name}`, + }; + } + // 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 score = clamp01(R / 0.95); + const word = R < 0.45 ? 'one clump' : R < 0.75 ? 'clumped' : R < 1.05 ? 'scattered' : 'evenly strewn'; + return { + key: 'rind', + label: cap(name), + score, + weight: 1.1, + detail: `${n} bits, ${word} (R ${R.toFixed(2)})`, + }; +} + +function cap(s: string): string { + return s.charAt(0).toUpperCase() + s.slice(1); +} + function spreadStdevOverCovered(slice: Slice, floor: number): number { const sp = slice.spread.data; const m = slice.mask.data; diff --git a/src/game/lines.ts b/src/game/lines.ts index 8df7320..239db78 100644 --- a/src/game/lines.ts +++ b/src/game/lines.ts @@ -106,6 +106,20 @@ const BANK: Bank = { 'The quantity is correct. Thank you.', ], }, + rind: { + bad: [ + 'The rind is all in one postcode.', + 'You have made a toast with a rind district.', + 'Every piece of rind, huddled together for warmth. Spread them OUT.', + 'One bite of this is all rind. The others are an apology.', + 'There is no rind on this. Did you eat it? You ate it.', + ], + good: [ + 'The rind is evenly strewn. That takes patience, and you had it.', + 'Every bite gets its piece of rind. That is the entire craft.', + 'Rind distribution: textbook. I did not expect that today.', + ], + }, tool: { bad: [ 'You did this with a {tool}. I can tell. Everyone can tell.', diff --git a/src/game/orders.ts b/src/game/orders.ts index febc620..830cc4d 100644 --- a/src/game/orders.ts +++ b/src/game/orders.ts @@ -135,12 +135,25 @@ export const DAYS: Order[] = [ who: 'the inspector himself', text: "Thick sourdough, well done, and a *scrape* of MITEY. I want to see the toast through it.", }, + { + day: 8, + bread: 'white', + spread: 'marmalade', + amount: 'normal', + browning: 0.55, + browningName: 'golden', + noChar: true, + tool: 'spreader', + butterSoftness: 0.5, + who: 'Deidre', + text: 'Marmalade! And mind the rind, love — a piece in every bite, not a pile in one corner.', + }, ]; -/** Days 8+ — keep going, with everything cranked. */ +/** Days 9+ — keep going, with everything cranked. */ export function proceduralOrder(day: number, rand: () => number): Order { const breads: BreadId[] = ['white', 'multigrain', 'raisin', 'sourdough']; - const spreads: SpreadId[] = ['butter', 'peanut', 'mitey']; + const spreads: SpreadId[] = ['butter', 'peanut', 'mitey', 'marmalade']; const amounts: AmountClass[] = ['thin', 'normal', 'thick']; const tools: ToolId[] = [ 'butter_knife', @@ -155,7 +168,8 @@ export function proceduralOrder(day: number, rand: () => number): Order { ]; const pick = (a: T[]): T => a[Math.floor(rand() * a.length)]; const spread = pick(spreads); - const amount: AmountClass = spread === 'mitey' ? (rand() < 0.75 ? 'thin' : 'normal') : pick(amounts); + const amount: AmountClass = + spread === 'mitey' ? (rand() < 0.75 ? 'thin' : 'normal') : pick(amounts); const browning = 0.3 + rand() * 0.5; return { day, @@ -167,7 +181,7 @@ export function proceduralOrder(day: number, rand: () => number): Order { noChar: rand() < 0.8, tool: pick(tools), // It only gets colder from here. - butterSoftness: Math.max(0, 0.4 - (day - 8) * 0.08) * rand(), + butterSoftness: Math.max(0, 0.4 - (day - 9) * 0.08) * rand(), who: pick(['Deidre', 'Ray', 'a regular', 'someone new', 'the inspector himself']), text: '', }; diff --git a/src/scenes/spreadrig.ts b/src/scenes/spreadrig.ts index fb6160e..d9c24b7 100644 --- a/src/scenes/spreadrig.ts +++ b/src/scenes/spreadrig.ts @@ -86,8 +86,36 @@ export function makeSpreadPot(def: SpreadDef): { root: THREE.Group; hit: THREE.M hit.position.y = isButter ? 0.34 : 0.56; g.add(hit); - const file = isButter ? 'butter_dish' : def.id === 'peanut' ? 'pb_jar' : 'mitey_jar'; - void loadProp(`/assets/models/${file}.glb`, isButter ? 1.5 : 1.15).then((prop) => g.add(prop)); + const file = + isButter ? 'butter_dish' + : def.id === 'peanut' ? 'pb_jar' + : def.id === 'marmalade' ? 'marmalade_jar' + : 'mitey_jar'; + loadProp(`/assets/models/${file}.glb`, isButter ? 1.5 : 1.15) + .then((prop) => g.add(prop)) + .catch(() => { + // Asset still generating (or missing): a plain glass jar of the right + // colour keeps the order playable. + const jar = new THREE.Mesh( + new THREE.CylinderGeometry(0.42, 0.4, 0.66, 24), + new THREE.MeshStandardMaterial({ + color: 0xdfe6e6, + roughness: 0.12, + transparent: true, + opacity: 0.42, + }), + ); + jar.position.y = 0.33; + const fill = new THREE.Mesh( + new THREE.CylinderGeometry(0.36, 0.34, 0.5, 24), + new THREE.MeshStandardMaterial({ + color: new THREE.Color(def.color[0], def.color[1], def.color[2]), + roughness: 0.35, + }), + ); + fill.position.y = 0.29; + g.add(jar, fill); + }); return { root: g, hit }; } diff --git a/src/sim/slice.ts b/src/sim/slice.ts index 574bcc0..0c8f42a 100644 --- a/src/sim/slice.ts +++ b/src/sim/slice.ts @@ -9,6 +9,9 @@ export const FIELD_N = 128; /** Past this, the surface has stopped being toast and started being charcoal. */ export const CHAR_THRESHOLD = 0.85; +/** Hard cap on rind bits — also the instanced mesh's allocation. */ +export const RIND_MAX = 96; + /** * One slice of bread: the mesh, the four scalar fields the whole game reads and * writes, and the shader that turns them into something appetising (or not). @@ -44,6 +47,15 @@ export class Slice { readonly sizeZ: number; readonly halfThickness: number; + /** + * Solid bits sitting on the spread (marmalade rind), in UV space. Discrete + * points rather than a fifth field: the judge scores their *distribution*, and + * nearest-neighbour statistics want points, not a raster. + */ + readonly rind: { u: number; v: number }[] = []; + private rindMesh: THREE.InstancedMesh | null = null; + private rindDirty = false; + private tex: THREE.DataTexture; private texData: Uint8Array; private dirty = true; @@ -95,12 +107,91 @@ export class Slice { return this.mesh.position.y + this.halfThickness; } + /** Drop a bit of rind at (u,v). It rides the slice from then on. */ + addRind(u: number, v: number): void { + if (this.rind.length >= RIND_MAX) return; + this.rind.push({ u, v }); + this.rindDirty = true; + } + + /** Scrape rind off within `radius` (UV units) of (u,v). Returns how many went. */ + removeRindNear(u: number, v: number, radius: number): number { + const r2 = radius * radius; + let removed = 0; + for (let i = this.rind.length - 1; i >= 0; i--) { + const p = this.rind[i]; + const du = p.u - u; + const dv = p.v - v; + if (du * du + dv * dv <= r2) { + this.rind.splice(i, 1); + removed++; + } + } + if (removed) this.rindDirty = true; + return removed; + } + + clearRind(): void { + if (this.rind.length) this.rindDirty = true; + this.rind.length = 0; + } + + /** + * Rebuild the rind instances. The instanced mesh is a child of the slice mesh + * and positioned in its local space, so rind rides along when the toast flies, + * lands, and turns on the judge's pedestal. + */ + private syncRind(): void { + if (!this.rindDirty) return; + this.rindDirty = false; + if (!this.rindMesh) { + if (this.rind.length === 0) return; + const def = this.spreadDef?.particles; + const s = def?.size ?? 0.05; + const geo = new THREE.BoxGeometry(s * 1.6, s * 0.55, s * 0.9); + // The palette is authored in sRGB; passing the floats raw would have the + // material read them as linear and render candied orange as pale butter — + // the same trap as the slice shader's albedo. + const col = new THREE.Color().setRGB( + ...(def?.color ?? ([0.6, 0.25, 0.05] as [number, number, number])), + THREE.SRGBColorSpace, + ); + const mat = new THREE.MeshStandardMaterial({ color: col, roughness: 0.38 }); + this.rindMesh = new THREE.InstancedMesh(geo, mat, RIND_MAX); + this.rindMesh.castShadow = true; + this.mesh.add(this.rindMesh); + } + const m = new THREE.Matrix4(); + const q = new THREE.Quaternion(); + const pos = new THREE.Vector3(); + const scl = new THREE.Vector3(); + const axis = new THREE.Vector3(0, 1, 0); + for (let i = 0; i < this.rind.length; i++) { + const p = this.rind[i]; + pos.set( + (p.u - 0.5) * this.sizeX, + this.halfThickness + 0.008, + (0.5 - p.v) * this.sizeZ, + ); + // Deterministic per-index jitter so the pieces read as strewn, not stamped. + const h = Math.sin(i * 127.1) * 43758.5453; + const r = h - Math.floor(h); + q.setFromAxisAngle(axis, r * Math.PI * 2); + scl.setScalar(0.75 + r * 0.6); + m.compose(pos, q, scl); + this.rindMesh.setMatrixAt(i, m); + } + this.rindMesh.count = this.rind.length; + this.rindMesh.instanceMatrix.needsUpdate = true; + } + get uniforms() { return this.material.uniforms; } /** Push the sim fields into the GPU texture. Cheap: 64KB. */ sync(): void { + this.syncRind(); if (!this.dirty) return; const d = this.texData; const b = this.browning.data; @@ -163,6 +254,10 @@ export class Slice { } dispose(): void { + if (this.rindMesh) { + this.rindMesh.geometry.dispose(); + (this.rindMesh.material as THREE.Material).dispose(); + } this.mesh.geometry.dispose(); this.material.dispose(); this.tex.dispose(); diff --git a/src/sim/spreading.ts b/src/sim/spreading.ts index 6672ab3..e4a36c7 100644 --- a/src/sim/spreading.ts +++ b/src/sim/spreading.ts @@ -41,6 +41,10 @@ export interface Knife { angle: number; /** Spread carried on the blade, in mean-thickness units (same as the field). */ load: number; + /** Solid bits (rind) riding the blade, in whole pieces. */ + rindLoad: number; + /** Fractional shed accumulator — pieces drop one at a time. */ + rindFrac: number; u: number; v: number; hasPrev: boolean; @@ -65,11 +69,15 @@ export interface StrokeFx { export function newKnife(): Knife { // Starts inside the window where warm-toast butter actually flows: the very // first stroke of the game shouldn't be a mysterious failure. - return { angle: 0.45, load: 0, u: 0.5, v: 0.5, hasPrev: false }; + return { angle: 0.45, load: 0, rindLoad: 0, rindFrac: 0, u: 0.5, v: 0.5, hasPrev: false }; } export function dip(knife: Knife, def: SpreadDef): void { knife.load = def.pickup; + if (def.particles) { + knife.rindLoad = def.particles.perDip; + knife.rindFrac = 0; + } } /** Cheap per-texel hash, for blotchy tools. */ @@ -183,6 +191,26 @@ function contact( }); knife.load = Math.max(0, massLeft / (slice.mask.n * slice.mask.n)); + // Suspended solids drop off the blade as it works — per unit of stroke + // DISTANCE, not per second. That's the whole feel: a moving knife lays an + // even trail, a dabbing knife piles everything into one spot, and because + // the rate scales with what's left on the blade, one dip can't cover the + // slice — even rind takes several dips, placed with intent. + if (def.particles && knife.rindLoad > 0.01) { + knife.rindFrac += knife.rindLoad * def.particles.shedRate * speed * dt; + while (knife.rindFrac >= 1 && knife.rindLoad >= 1) { + knife.rindFrac -= 1; + knife.rindLoad -= 1; + const h1 = blotchAt(slice.rind.length * 7 + 1); + const h2 = blotchAt(slice.rind.length * 13 + 5); + const ang = h1 * Math.PI * 2; + const dist = Math.sqrt(h2) * radius * 0.8; + const ru = u + Math.cos(ang) * dist; + const rv = v + Math.sin(ang) * dist; + if (slice.mask.sample(ru, rv) > 0.5) slice.addRind(ru, rv); + } + } + if (!flowing && moving > 0.05) { const tear = TEAR_RATE * deficit * moving * tool.tearProne * dt; slice.damage.brush(u, v, radius, (i, w) => { @@ -197,6 +225,11 @@ function contact( } // --- scrape --- + // The blade takes rind with it too — some of it survives the trip and can be + // redistributed, which is how you fix a clump without starting over. + const rindOff = slice.removeRindNear(u, v, radius * 1.15); + if (rindOff > 0) knife.rindLoad += rindOff * 0.6; + let removedSpread = 0; let removedChar = 0; let gouged = 0; diff --git a/src/sim/spreads.ts b/src/sim/spreads.ts index 75dd42b..293f156 100644 --- a/src/sim/spreads.ts +++ b/src/sim/spreads.ts @@ -8,9 +8,31 @@ * better technique, it's warmer toast: don't dawdle in the drawer. */ -export type SpreadId = 'butter' | 'peanut' | 'mitey'; +export type SpreadId = 'butter' | 'peanut' | 'mitey' | 'marmalade'; export type AmountClass = 'thin' | 'normal' | 'thick'; +/** + * Solid bits suspended in a spread — marmalade rind. They ride the knife with + * the jelly, drop off as the blade travels, and the judge scores how evenly + * they ended up scattered. Dabbing makes a pile; a moving knife lays a trail; + * covering the whole slice takes several dips placed with intent. + */ +export interface ParticleDef { + /** What the judge calls them. */ + name: string; + color: [number, number, number]; + /** World-space size of a bit. */ + size: number; + /** How many the knife picks up per dip. */ + perDip: number; + /** + * Fraction of the carried bits shed per WORLD-UNIT of stroke, at a full load. + * Distance-based on purpose: a moving knife lays an even trail, a dabbing one + * makes a pile. + */ + shedRate: number; +} + export interface SpreadDef { id: SpreadId; name: string; @@ -21,6 +43,8 @@ export interface SpreadDef { opaqueAt: number; /** Surface relief strength. */ bump: number; + /** Suspended solids, if any. */ + particles?: ParticleDef; /** Pressure needed to make it flow, before any warming. */ baseYield: number; @@ -92,6 +116,34 @@ export const SPREADS: Record = { scrapeEase: 1.25, amounts: { thin: [0.03, 0.11], normal: [0.14, 0.28], thick: [0.32, 0.6] }, }, + marmalade: { + id: 'marmalade', + name: 'Marmalade', + blurb: 'The jelly is easy. The rind goes where it wants — your job is to argue.', + color: [0.91, 0.45, 0.08], + gloss: 0.95, + // Jelly stays translucent even laid on thick — you always see toast through it. + opaqueAt: 0.85, + bump: 0.05, + particles: { + name: 'rind', + color: [0.62, 0.24, 0.04], + size: 0.055, + perDip: 9, + // ~0.12 world-units between bits at a full load — and wider as the blade + // empties, so one dip can't do the whole slice. Several dips, placed with + // intent, is the skill. + shedRate: 0.95, + }, + baseYield: 0.2, + tempSoftening: 0.45, + viscosity: 0.55, + pickup: 0.62, + transferRate: 3.2, + drag: 0.35, + scrapeEase: 1.1, + amounts: { thin: [0.08, 0.18], normal: [0.22, 0.42], thick: [0.48, 0.8] }, + }, }; /** Knife angle past which contact narrows enough to scrape instead of spread. */