toastsim/src/sim/spreading.ts
monster ff006e65db M8: crunchy vs smooth, and the jar that separates
Natural peanut butter stratifies while it sits. The jar carries the state:
strat 0..1, set per order (smooth 0.75, crunchy 0.9 — chunks sink). The first
unstirred dip takes the oil slick off the top; the ones after it dig into what
the oil left behind. Verified dip trace, no stirring: 0.875, 0.669, 0.462,
0.256. Swirl the knife in the jar (~2.5 circles of angular sweep; holding still
stirs nothing) and every dip is 0.5. Stirring costs time, and time costs toast
warmth — the mechanic pays the same tax as everything else.

Consistency pushes back through the physics that already exist: an oily load
multiplies the yield down (glides at any angle, goes on thin), a dry one
multiplies it up (fights like cold butter, tears). The shader shows it — slick
is dark and shiny, grout is pale and matte.

The judge reads what LANDED, not what the jar was: every deposit remembers the
oil it landed at, and the criterion scores the mass-weighted mean AND stdev.
The stdev is the design catch, found by verification: a slick strip and a grout
strip average to 0.54 — "just right" — and the first cut scored the lazy play
as if the jar had been stirred. Nobody eats the average:

  never stirred, strips   oil 0.54 ±0.23  "slick here, grout there"  C
  one slick dip, whole    oil 0.87 ±0.00  "a slick"
  stirred first           oil 0.50 ±0.00  "just right"

Crunchy reuses the particle system wholesale — chunks ride the knife, shed per
stroke-distance, get judged with the same Clark–Evans index ("Peanut chunks —
21 bits, evenly strewn (R 1.20)"), and the rind line bank is genericised via a
{bits} placeholder so he complains correctly about either.

Day 6 is crunchy now ("the PROPER kind"), the ticket warns when a jar has been
sitting, and the JAR bar shows how mixed it is. Five new judge lines, including
"You did not stir the jar. The jar knows. I know."

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 23:03:57 +10:00

287 lines
9.8 KiB
TypeScript

import type { Slice } from './slice';
import type { SpreadDef } from './spreads';
import { SCRAPE_ANGLE, contactRadius, effectiveYield, pressureFromAngle } from './spreads';
import type { Tool } from './cutlery';
/**
* The star mechanic.
*
* One control — knife angle — and three behaviours fall out of it:
*
* flat -> wide contact, low pressure -> spreads (or tears, if the spread is
* stiffer than the pressure you're allowed to apply)
* steep -> narrow contact, high pressure -> scrapes spread back off, or scrapes
* char off toast that's burnt
* steep and fast on bread that has nothing left to remove -> gouges
*
* The trap: pressure comes from steepness, but steepness past SCRAPE_ANGLE stops
* spreading. So cold butter can demand more pressure than the spread mode can
* physically give — and the answer isn't technique, it's warmer toast.
*/
/** Scraping lightens toast down to here; past that you're into the crumb. */
export const BROWN_FLOOR = 0.62;
const TEAR_RATE = 1.15;
const SCRAPE_RATE = 2.4;
/**
* Lifting char has to feel like progress. A steep knife has a narrow contact
* patch (~0.05 UV), so a pass only dwells on any given texel for ~0.2s — at a
* lower rate than this you scrape a burnt slice for a minute and it stays burnt.
*/
const CHAR_SCRAPE_RATE = 4.0;
const GOUGE_RATE = 0.85;
/** Below this pressure you're just wiping; you can't gouge with a flat knife. */
const GOUGE_PRESSURE = 0.5;
/** Gouging and tearing both need the knife to be moving. */
const MOVE_REF = 0.55;
export interface Knife {
/** 0 = flat on the toast, 1 = up on its edge. */
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;
/**
* Consistency of what's on the blade: 0.5 is properly mixed, 1 is the oil
* slick off the top of an unstirred jar, 0 is the dried-out paste under it.
*/
oil: number;
u: number;
v: number;
hasPrev: boolean;
}
export type StrokeMode = 'idle' | 'spread' | 'tear' | 'scrape' | 'char' | 'gouge';
export interface StrokeFx {
mode: StrokeMode;
deposited: number;
tore: number;
gouged: number;
scrapedSpread: number;
scrapedChar: number;
speed: number;
pressure: number;
/** How far short of the yield pressure we are: 0 = flowing, 1 = hopeless. */
deficit: number;
crumbs: number;
}
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, rindLoad: 0, rindFrac: 0, oil: 0.5, u: 0.5, v: 0.5, hasPrev: false };
}
/** `oil` is what the jar handed over — 0.5 unless the jar has stratified. */
export function dip(knife: Knife, def: SpreadDef, oil = 0.5): void {
knife.load = def.pickup;
knife.oil = oil;
if (def.particles) {
knife.rindLoad = def.particles.perDip;
knife.rindFrac = 0;
}
}
/** Cheap per-texel hash, for blotchy tools. */
function blotchAt(i: number): number {
const x = Math.sin(i * 12.9898) * 43758.5453;
return x - Math.floor(x);
}
/**
* Drag the knife from where it was to (toU,toV). Substeps along the path so a
* fast flick can't skip over the middle of the slice.
*/
export function stroke(
slice: Slice,
def: SpreadDef,
tool: Tool,
knife: Knife,
toU: number,
toV: number,
dt: number,
softness: number,
): StrokeFx {
const fx: StrokeFx = {
mode: 'idle',
deposited: 0,
tore: 0,
gouged: 0,
scrapedSpread: 0,
scrapedChar: 0,
speed: 0,
pressure: 0,
deficit: 0,
crumbs: 0,
};
if (!knife.hasPrev) {
knife.u = toU;
knife.v = toV;
knife.hasPrev = true;
return fx;
}
const du = toU - knife.u;
const dv = toV - knife.v;
const dist = Math.hypot(du, dv);
const speed = dt > 0 ? dist / dt : 0;
fx.speed = speed;
const radius = Math.max(0.012, contactRadius(knife.angle) * tool.contactScale);
// Pressure is force over area: the same wrist angle through a narrow blade
// presses harder than through a wide one.
const pressure = Math.min(1.4, pressureFromAngle(knife.angle) / tool.contactScale);
fx.pressure = pressure;
const steps = Math.max(1, Math.min(24, Math.ceil(dist / (radius * 0.45))));
const stepDt = dt / steps;
for (let s = 1; s <= steps; s++) {
const t = s / steps;
contact(slice, def, tool, knife, knife.u + du * t, knife.v + dv * t, pressure, radius, speed, stepDt, softness, fx);
}
knife.u = toU;
knife.v = toV;
// Low-viscosity spreads level themselves out; peanut butter never does.
const relax = (1 - def.viscosity) * dt * 3.2;
if (relax > 0.001) slice.spread.relax(Math.min(0.5, relax), slice.mask);
slice.touch();
return fx;
}
function contact(
slice: Slice,
def: SpreadDef,
tool: Tool,
knife: Knife,
u: number,
v: number,
pressure: number,
radius: number,
speed: number,
dt: number,
softness: number,
fx: StrokeFx,
): void {
const mask = slice.mask.data;
const spread = slice.spread.data;
const brown = slice.browning.data;
const damage = slice.damage.data;
const moving = Math.min(1.6, speed / MOVE_REF);
if (knife.angle < SCRAPE_ANGLE) {
if (knife.load <= 0.0005) return;
// Consistency pushes back through the same physics as everything else:
// an oily load flows at almost no pressure, a dried-out one fights like
// cold butter. Only spreads that separate care.
const oilK = def.separates ? 1.25 - knife.oil * 0.5 : 1;
const yieldP = effectiveYield(def, softness, slice.warmth) * oilK;
const flowing = pressure >= yieldP;
const deficit = flowing ? 0 : Math.min(1, (yieldP - pressure) / Math.max(yieldP, 0.001));
fx.deficit = Math.max(fx.deficit, deficit);
fx.mode = flowing ? 'spread' : 'tear';
// Below the yield pressure the spread won't flow, so the blade grabs the
// crumb and takes it with it. Some still smears on, badly.
const transfer = flowing ? 1 : 0.25;
// Runny goes on thin, dry goes on in slabs.
const oilRate = def.separates ? 1.15 - knife.oil * 0.4 : 1;
const rate = def.transferRate * tool.transferScale * dt * transfer * oilRate;
let massLeft = knife.load * slice.mask.n * slice.mask.n;
let laid = 0;
slice.spread.brush(u, v, radius, (i, w) => {
if (mask[i] < 0.5 || massLeft <= 0) return;
const blot = 1 - tool.blotch * blotchAt(i);
const add = Math.min(rate * w * blot, massLeft);
spread[i] += add;
massLeft -= add;
laid += add;
fx.deposited += add;
});
knife.load = Math.max(0, massLeft / (slice.mask.n * slice.mask.n));
// What lands remembers the consistency it landed at — the judge reads the
// mass-weighted mean at the end.
slice.recordDeposit(laid, knife.oil);
// 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) => {
if (mask[i] < 0.5) return;
const add = tear * w;
damage[i] = Math.min(1, damage[i] + add);
fx.tore += add;
});
fx.crumbs += tear * 26;
}
return;
}
// --- 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;
slice.spread.brush(u, v, radius, (i, w) => {
if (mask[i] < 0.5) return;
if (spread[i] > 0.002) {
const off = Math.min(spread[i], SCRAPE_RATE * pressure * def.scrapeEase * dt * w);
spread[i] -= off;
removedSpread += off;
return;
}
if (brown[i] > BROWN_FLOOR) {
// Lifting char off. Cheap in browning, expensive in evenness — the saved
// patch ends up lighter than everything around it.
const off = Math.min(brown[i] - BROWN_FLOOR, CHAR_SCRAPE_RATE * pressure * dt * w);
brown[i] -= off;
removedChar += off;
return;
}
// 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;
damage[i] = Math.min(1, damage[i] + add);
gouged += add;
}
});
// What comes off the toast mostly stays on the blade.
knife.load += (removedSpread * 0.6) / (slice.mask.n * slice.mask.n);
slice.recordRemoval(removedSpread);
fx.scrapedSpread += removedSpread;
fx.scrapedChar += removedChar;
fx.gouged += gouged;
fx.crumbs += removedChar * 900 + gouged * 30;
fx.mode = gouged > 1e-6 ? 'gouge' : removedChar > 1e-6 ? 'char' : removedSpread > 1e-6 ? 'scrape' : 'idle';
}