import { DEFAULT_SAW, TOOLS, type ToolId } from './cutlery'; /** * Cutting a slice off a loaf. * * Three numbers come out, and each one feeds forward honestly: * thickness — you chose it when the knife first bit; the judge holds it * against the ticket, and the toasting sim uses it as thermal mass. * wedge — sideways wander while sawing. A wedge-shaped slice doesn't just * lose points here: its thin edge browns faster in the toaster, * so a bad cut is STILL punishing you two phases later. * squash — downforce the knife didn't earn. Fresh crumb compresses and * tears; squash arrives on the slice as pre-existing damage. * * The knife decides how those accumulate. A bread knife saws clean. The Best * Thing barely needs you. A butter knife against a bakery crust is a siege. */ export interface SliceCut { /** World units; 1 unit ≈ 11cm, so 0.22 ≈ a 24mm doorstop. */ thickness: number; /** 0 = parallel faces, 1 = a full wedge. */ wedge: number; /** 0 = unbruised, 1 = flattened. */ squash: number; knife: ToolId; strokes: number; } export type CutClass = 'thin cut' | 'regular cut' | 'doorstop'; export const CUT_BANDS: Record = { 'thin cut': [0.09, 0.15], 'regular cut': [0.16, 0.24], doorstop: [0.26, 0.36], }; /** Progress a full stroke contributes, before knife efficiency. Tuned so a * clean bread-knife doorstop is ~5 deliberate strokes, The Best Thing ~3. */ const STROKE_GAIN = 0.1; /** Sideways wander → wedge, tempered by the knife's steadiness. */ const WOBBLE_GAIN = 0.55; /** Sawing faster than the knife can bite leans on the loaf instead. */ const SPEED_CAP = 3.2; const SQUASH_GAIN = 0.5; export interface Slicing { phase: 'aim' | 'saw' | 'done'; thickness: number; progress: number; wedge: number; squash: number; strokes: number; knife: ToolId; /** Direction of the current stroke, for counting reversals as strokes. */ lastDir: number; strokeTravel: number; } export function newSlicing(knife: ToolId): Slicing { return { phase: 'aim', thickness: 0.2, progress: 0, wedge: 0, squash: 0, strokes: 0, knife, lastDir: 0, strokeTravel: 0, }; } export function sawStats(knife: ToolId): { eff: number; steady: number; gentle: number } { return TOOLS[knife].saw ?? DEFAULT_SAW; } /** While aiming: the cursor picks how thick the slice will be. */ export function aim(s: Slicing, thickness: number): void { if (s.phase !== 'aim') return; s.thickness = Math.min(0.4, Math.max(0.06, thickness)); } /** First bite locks the thickness — no re-aiming mid-cut. */ export function beginCut(s: Slicing): void { if (s.phase === 'aim') s.phase = 'saw'; } /** * One frame of sawing. `dz` is the saw motion (along the blade), `dx` the * sideways wander, both in world units this frame. */ export function saw(s: Slicing, dz: number, dx: number, dt: number): void { if (s.phase !== 'saw') return; const k = sawStats(s.knife); const speed = dt > 0 ? Math.abs(dz) / dt : 0; // Progress only accrues up to the speed the blade can actually bite at. const useful = Math.min(Math.abs(dz), SPEED_CAP * dt); s.progress += useful * STROKE_GAIN * k.eff * (14 / (1 + s.thickness * 8)); // Frantic sawing past the bite speed is just downforce. const excess = Math.max(0, speed - SPEED_CAP * (0.6 + k.gentle)); s.squash = Math.min(1, s.squash + excess * SQUASH_GAIN * (1 - k.gentle) * dt); // Wander: judged against how far you've sawed, so one twitch isn't fatal. s.wedge = Math.min(1, s.wedge + Math.abs(dx) * WOBBLE_GAIN * (1 - k.steady)); // Count strokes on direction reversals — it's the rhythm that reads as sawing. const dir = Math.sign(dz); if (dir !== 0 && dir !== s.lastDir && s.strokeTravel > 0.08) { s.strokes++; s.strokeTravel = 0; } if (dir !== 0) s.lastDir = dir; s.strokeTravel += Math.abs(dz); if (s.progress >= 1) { s.progress = 1; s.phase = 'done'; } } export function finishCut(s: Slicing): SliceCut { return { thickness: s.thickness, wedge: s.wedge, squash: s.squash, knife: s.knife, strokes: s.strokes, }; } export function classOfCut(thickness: number): CutClass | 'wafer' | 'slab' { if (thickness < CUT_BANDS['thin cut'][0]) return 'wafer'; if (thickness <= CUT_BANDS['thin cut'][1]) return 'thin cut'; if (thickness <= CUT_BANDS['regular cut'][1]) return 'regular cut'; if (thickness <= CUT_BANDS.doorstop[1]) return 'doorstop'; return 'slab'; }