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>
287 lines
9.8 KiB
TypeScript
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';
|
|
}
|