/** * 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; } export const SPREADS: Record = { 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'; }