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'; }