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>
212 lines
6.7 KiB
TypeScript
212 lines
6.7 KiB
TypeScript
/**
|
|
* Spread rheology. The whole difficulty curve of this game lives in these numbers.
|
|
*
|
|
* The central tension: you need PRESSURE to make a stiff spread flow, but pressure
|
|
* comes from tilting the knife steeper — and past `SCRAPE_ANGLE` a steep knife stops
|
|
* spreading and starts scraping. So cold butter can want more pressure than you are
|
|
* physically allowed to apply without flipping into scrape mode. The way out isn't a
|
|
* better technique, it's warmer toast: don't dawdle in the drawer.
|
|
*/
|
|
|
|
export type SpreadId = 'butter' | 'peanut' | 'crunchy' | '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;
|
|
blurb: string;
|
|
color: [number, number, number];
|
|
gloss: number;
|
|
/** Thickness at which the film fully hides the toast underneath. */
|
|
opaqueAt: number;
|
|
/** Surface relief strength. */
|
|
bump: number;
|
|
/** Suspended solids, if any. */
|
|
particles?: ParticleDef;
|
|
/**
|
|
* Natural nut butters stratify: how badly the jar has separated when the
|
|
* order starts (0..1). The oil rises, the paste below dries out — the first
|
|
* unstirred dip is a slick and the ones after it are grout. Swirl the knife
|
|
* in the jar to mix it back; the fix costs time, and time costs toast warmth.
|
|
*/
|
|
separates?: number;
|
|
|
|
/** Pressure needed to make it flow, before any warming. */
|
|
baseYield: number;
|
|
/** How much heat (soft butter + hot toast) reduces that. */
|
|
tempSoftening: number;
|
|
/** 0 self-levels instantly, 1 stays exactly where you put it. */
|
|
viscosity: number;
|
|
/** How much the knife picks up per dip. */
|
|
pickup: number;
|
|
/** How fast it leaves the knife once it IS flowing. */
|
|
transferRate: number;
|
|
/** How much the spread resists the knife (drag on the cursor). */
|
|
drag: number;
|
|
/** How readily it comes back off when you scrape. */
|
|
scrapeEase: number;
|
|
|
|
amounts: Record<AmountClass, [number, number]>;
|
|
}
|
|
|
|
export const SPREADS: Record<SpreadId, SpreadDef> = {
|
|
butter: {
|
|
id: 'butter',
|
|
name: 'Butter',
|
|
blurb: 'Straightforward — as long as something in this scene is warm.',
|
|
color: [0.97, 0.72, 0.19],
|
|
gloss: 0.85,
|
|
opaqueAt: 0.5,
|
|
bump: 0.10,
|
|
baseYield: 0.72,
|
|
tempSoftening: 0.78,
|
|
viscosity: 0.72,
|
|
pickup: 0.55,
|
|
transferRate: 3.0,
|
|
drag: 0.5,
|
|
scrapeEase: 1.0,
|
|
amounts: { thin: [0.1, 0.22], normal: [0.25, 0.5], thick: [0.55, 0.9] },
|
|
},
|
|
peanut: {
|
|
id: 'peanut',
|
|
name: 'Smooth Peanut Butter',
|
|
blurb: 'Goes on thick, drags like wet sand — and the jar has been sitting.',
|
|
color: [0.64, 0.41, 0.18],
|
|
gloss: 0.3,
|
|
opaqueAt: 0.16,
|
|
bump: 0.28,
|
|
separates: 0.75,
|
|
baseYield: 0.38,
|
|
tempSoftening: 0.3,
|
|
viscosity: 0.96,
|
|
pickup: 0.95,
|
|
transferRate: 4.5,
|
|
drag: 1.0,
|
|
scrapeEase: 0.75,
|
|
amounts: { thin: [0.18, 0.32], normal: [0.38, 0.68], thick: [0.75, 1.1] },
|
|
},
|
|
crunchy: {
|
|
id: 'crunchy',
|
|
name: 'Crunchy Peanut Butter',
|
|
blurb: 'Everything smooth is, plus chunks with opinions about where they sit.',
|
|
color: [0.6, 0.38, 0.16],
|
|
gloss: 0.28,
|
|
opaqueAt: 0.17,
|
|
bump: 0.32,
|
|
// Chunks sink; the jar splits worse than the smooth stuff.
|
|
separates: 0.9,
|
|
particles: {
|
|
name: 'peanut chunks',
|
|
color: [0.78, 0.6, 0.34],
|
|
size: 0.042,
|
|
perDip: 8,
|
|
shedRate: 0.85,
|
|
},
|
|
baseYield: 0.42,
|
|
tempSoftening: 0.3,
|
|
viscosity: 0.97,
|
|
pickup: 0.95,
|
|
transferRate: 4.3,
|
|
drag: 1.1,
|
|
scrapeEase: 0.7,
|
|
amounts: { thin: [0.18, 0.32], normal: [0.38, 0.68], thick: [0.75, 1.1] },
|
|
},
|
|
mitey: {
|
|
id: 'mitey',
|
|
name: 'MITEY',
|
|
blurb: 'Spreads like nothing. The skill is taking almost all of it back off.',
|
|
color: [0.14, 0.08, 0.05],
|
|
gloss: 0.62,
|
|
opaqueAt: 0.26,
|
|
bump: 0.03,
|
|
baseYield: 0.14,
|
|
tempSoftening: 0.5,
|
|
viscosity: 0.34,
|
|
pickup: 0.5,
|
|
transferRate: 3.5,
|
|
drag: 0.25,
|
|
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. */
|
|
export const SCRAPE_ANGLE = 0.62;
|
|
|
|
/** Angle (0 flat .. 1 on-edge) -> contact pressure. */
|
|
export function pressureFromAngle(angle: number): number {
|
|
return 0.1 + 0.9 * Math.pow(Math.max(0, Math.min(1, angle)), 1.3);
|
|
}
|
|
|
|
/** Angle -> contact patch radius in UV units. Flat knife = wide, steep = a line. */
|
|
export function contactRadius(angle: number): number {
|
|
return 0.115 - 0.078 * Math.max(0, Math.min(1, angle));
|
|
}
|
|
|
|
/**
|
|
* What it actually takes to make this spread flow right now. Butter on a cold
|
|
* slice with fridge-hard butter is the worst case in the game, by design.
|
|
*/
|
|
export function effectiveYield(def: SpreadDef, softness: number, toastWarmth: number): number {
|
|
const heat = Math.min(1, softness * 0.55 + toastWarmth * 0.65);
|
|
return def.baseYield * (1 - def.tempSoftening * heat);
|
|
}
|
|
|
|
export function amountClassOf(def: SpreadDef, mean: number): AmountClass | 'none' | 'obscene' {
|
|
if (mean < def.amounts.thin[0] * 0.5) return 'none';
|
|
if (mean <= def.amounts.thin[1]) return 'thin';
|
|
if (mean <= def.amounts.normal[1]) return 'normal';
|
|
if (mean <= def.amounts.thick[1]) return 'thick';
|
|
return 'obscene';
|
|
}
|