diff --git a/public/assets/img/bench_wood.png b/public/assets/img/bench_wood.png new file mode 100644 index 0000000..3928382 Binary files /dev/null and b/public/assets/img/bench_wood.png differ diff --git a/public/assets/img/judge_disappointed.png b/public/assets/img/judge_disappointed.png new file mode 100644 index 0000000..984769b Binary files /dev/null and b/public/assets/img/judge_disappointed.png differ diff --git a/public/assets/img/judge_horrified.png b/public/assets/img/judge_horrified.png new file mode 100644 index 0000000..501a934 Binary files /dev/null and b/public/assets/img/judge_horrified.png differ diff --git a/public/assets/img/judge_impressed.png b/public/assets/img/judge_impressed.png new file mode 100644 index 0000000..ac0596f Binary files /dev/null and b/public/assets/img/judge_impressed.png differ diff --git a/public/assets/img/judge_intrigued.png b/public/assets/img/judge_intrigued.png new file mode 100644 index 0000000..8ef1cb1 Binary files /dev/null and b/public/assets/img/judge_intrigued.png differ diff --git a/public/assets/img/kitchen_backdrop.png b/public/assets/img/kitchen_backdrop.png new file mode 100644 index 0000000..4d491dd Binary files /dev/null and b/public/assets/img/kitchen_backdrop.png differ diff --git a/public/assets/img/ticket_paper.png b/public/assets/img/ticket_paper.png new file mode 100644 index 0000000..3cfcc09 Binary files /dev/null and b/public/assets/img/ticket_paper.png differ diff --git a/public/assets/img/title_art.png b/public/assets/img/title_art.png new file mode 100644 index 0000000..43129ea Binary files /dev/null and b/public/assets/img/title_art.png differ diff --git a/src/core/app.ts b/src/core/app.ts index ad0317e..0305339 100644 --- a/src/core/app.ts +++ b/src/core/app.ts @@ -1,4 +1,5 @@ import * as THREE from 'three'; +import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js'; /** Shared key light direction — the slice shader rolls its own lighting, so it * has to be told the same thing the real lights are doing. */ @@ -138,8 +139,12 @@ export class App { } resize(): void { - const w = window.innerWidth; - const h = window.innerHeight; + // Guard the zero case: a collapsed or hidden container reports 0, and 0/0 + // makes the aspect NaN, which poisons the projection matrix for good. Every + // raycast then silently returns nothing — no error, the game just stops + // responding to the mouse. + const w = Math.max(1, window.innerWidth); + const h = Math.max(1, window.innerHeight); this.renderer.setSize(w, h, false); this.camera.aspect = w / h; this.camera.updateProjectionMatrix(); @@ -191,9 +196,20 @@ export class App { } } +/** + * Cutlery is the point of half this game, and polished metal with nothing to + * reflect renders black. An environment map is not optional here. + */ +export function makeEnvironment(app: App): void { + const pmrem = new THREE.PMREMGenerator(app.renderer); + app.scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture; + app.scene.environmentIntensity = 0.4; + pmrem.dispose(); +} + /** Standard kitchen lighting rig, shared by every view. */ export function makeLights(scene: THREE.Scene): THREE.DirectionalLight { - const key = new THREE.DirectionalLight(0xfff2dd, 2.6); + const key = new THREE.DirectionalLight(0xfff2dd, 1.5); key.position.copy(LIGHT_DIR).multiplyScalar(6); key.castShadow = true; key.shadow.mapSize.set(2048, 2048); @@ -207,8 +223,8 @@ export function makeLights(scene: THREE.Scene): THREE.DirectionalLight { key.shadow.bias = -0.0016; key.shadow.normalBias = 0.02; scene.add(key); - scene.add(new THREE.HemisphereLight(0xbfc6d2, 0x4a3526, 0.7)); - const fill = new THREE.DirectionalLight(0xbdd6ff, 0.45); + scene.add(new THREE.HemisphereLight(0xbfc6d2, 0x4a3526, 0.22)); + const fill = new THREE.DirectionalLight(0xbdd6ff, 0.18); fill.position.set(-3, 2.2, -2.5); scene.add(fill); return key; diff --git a/src/dev.ts b/src/dev.ts new file mode 100644 index 0000000..a8c9ad7 --- /dev/null +++ b/src/dev.ts @@ -0,0 +1,120 @@ +import * as THREE from 'three'; +import type { App } from './core/app'; +import type { KitchenView } from './scenes/kitchen'; + +/** + * Dev harness. The game is driven by mouse gestures over a 3D scene, which makes + * "does it actually work" hard to answer by inspection — so this drives real + * input through the real code paths, deterministically, at a chosen dt. + * + * Also the escape hatch for headless/embedded browsers that never fire rAF: + * everything here calls app.step() directly. + * + * Dev builds only. + */ +export function installDevHarness(app: App, kitchen: KitchenView): void { + const v = new THREE.Vector3(); + const toNdc = (x: number, y: number, z: number): [number, number] => { + v.set(x, y, z).project(app.camera); + return [v.x, v.y]; + }; + const run = (frames: number, dt = 1 / 60) => { + for (let i = 0; i < frames; i++) app.step(dt); + }; + const tap = (code: string) => { + window.dispatchEvent(new KeyboardEvent('keydown', { code })); + app.step(1 / 60); + window.dispatchEvent(new KeyboardEvent('keyup', { code })); + }; + const point = (x: number, y: number, z: number, down: boolean) => { + const n = toNdc(x, y, z); + app.input.ndc.set(n[0], n[1]); + app.input.down = down; + }; + + const harness = { + app, + kitchen, + THREE, + run, + tap, + point, + toNdc, + + /** Drop the lever, brown for `seconds`, pop, and wait for it to land. */ + toast(seconds = 10, power?: number) { + if (power !== undefined) kitchen.power = power; + tap('Space'); + run(Math.round(seconds * 60)); + tap('Space'); + run(200); + return harness.stats(); + }, + + dip() { + point(3.15, 0.29, 0.45, true); + run(4); + app.input.down = false; + app.step(1 / 60); + }, + + /** + * Raster the whole slice. `speed` is world-units/sec — the thing that + * actually decides how thick the spread goes on. + */ + spread(passes = 9, speed = 0.55, angle?: number) { + const sl = kitchen.currentSlice; + if (angle !== undefined) kitchen.knife.angle = angle; + harness.dip(); + const stepDist = speed / 60; + for (let p = 0; p < passes; p++) { + const z = 0.55 - 0.44 + (p / Math.max(1, passes - 1)) * 0.88; + const steps = Math.ceil(0.96 / stepDist); + for (let s = 0; s <= steps; s++) { + if (kitchen.knife.load < 0.02) harness.dip(); + const x = 1.45 + (p % 2 ? 1 : -1) * (0.48 - s * stepDist); + point(x, sl.topY, z, true); + app.step(1 / 60); + } + } + app.input.down = false; + harness.park(); + return harness.stats(); + }, + + /** Park the cursor off the toast so a screenshot isn't full of knife. */ + park() { + point(2.5, kitchen.currentSlice.topY, 1.3, false); + app.step(1 / 60); + }, + + stats() { + const sl = kitchen.currentSlice; + const b = sl.browning.stats(sl.mask); + const s = sl.spread.stats(sl.mask); + const d = sl.damage.stats(sl.mask); + const r = (n: number) => +n.toFixed(3); + return { + phase: kitchen.phase, + warmth: r(sl.warmth), + browning: { mean: r(b.mean), stdev: r(b.stdev), max: r(b.max) }, + char: r(sl.browning.fraction(sl.mask, (x) => x > 0.85)), + spread: { mean: r(s.mean), stdev: r(s.stdev), max: r(s.max) }, + coverage: r(sl.spread.fraction(sl.mask, (x) => x > 0.02)), + damage: r(d.mean), + load: r(kitchen.knife.load), + angle: r(kitchen.knife.angle), + mode: kitchen.lastFx?.mode ?? 'idle', + }; + }, + + /** Move the camera somewhere for a screenshot without disturbing the sim. */ + look(pos: [number, number, number], at: [number, number, number]) { + app.camera.position.set(...pos); + app.camera.lookAt(new THREE.Vector3(...at)); + app.renderer.render(app.scene, app.camera); + }, + }; + + (window as unknown as { __t: unknown }).__t = harness; +} diff --git a/src/main.ts b/src/main.ts index 44ba78c..79b7d69 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,20 +1,20 @@ import * as THREE from 'three'; -import { App, makeLights } from './core/app'; +import { App, makeEnvironment, makeLights } from './core/app'; import { KitchenView } from './scenes/kitchen'; import './style.css'; const canvas = document.getElementById('c') as HTMLCanvasElement; const app = new App(canvas); makeLights(app.scene); +makeEnvironment(app); app.scene.background = new THREE.Color(0x241a15); const kitchen = new KitchenView(app, 3); app.scene.add(kitchen.root); app.setView(kitchen); -app.camera.position.set(0.05, 3.15, 5.6); -app.camera.lookAt(0.0, 0.85, -0.15); - -(window as unknown as { __t: unknown }).__t = { app, kitchen, THREE }; +if (import.meta.env.DEV) { + void import('./dev').then((m) => m.installDevHarness(app, kitchen)); +} app.start(); diff --git a/src/scenes/kitchen.ts b/src/scenes/kitchen.ts index d16c315..281bfd1 100644 --- a/src/scenes/kitchen.ts +++ b/src/scenes/kitchen.ts @@ -4,43 +4,82 @@ import { Rng } from '../core/rng'; import { Slice } from '../sim/slice'; import { BREADS, type BreadId } from '../sim/bread'; import { buildHeatMap, coolStep, readToast, smellCue, toastStep } from '../sim/toasting'; +import { + SPREADS, + SCRAPE_ANGLE, + effectiveYield, + pressureFromAngle, + type SpreadDef, + type SpreadId, +} from '../sim/spreads'; +import { dip, newKnife, stroke, type Knife, type StrokeFx } from '../sim/spreading'; +import { TOOLS, type ToolId } from '../sim/cutlery'; import { makeBench, makePlate, makeToaster, type Toaster } from './props'; +import { KnifeRig, makeSpreadPot } from './spreadrig'; import { el, Panel } from '../ui/hud'; const TOASTER_X = -1.5; const PLATE_X = 1.45; const PLATE_Z = 0.55; const PLATE_Y = 0.05; +const POT_X = 3.15; +const POT_Z = 0.45; -type Phase = 'ready' | 'toasting' | 'flying' | 'landed'; +export type Phase = 'ready' | 'toasting' | 'flying' | 'spreading'; -/** - * The bench. Owns the toaster, the plate and the current slice, and runs the - * toasting phase: dial, lever, browning, and the pop. - */ +interface CamShot { + pos: THREE.Vector3; + look: THREE.Vector3; +} + +const SHOTS: Record = { + toast: { pos: new THREE.Vector3(-1.1, 3.0, 4.9), look: new THREE.Vector3(-1.3, 0.85, -0.2) }, + spread: { pos: new THREE.Vector3(2.05, 2.4, 3.0), look: new THREE.Vector3(2.05, 0.1, 0.35) }, +}; + +/** The bench: toaster, plate, pot, knife. Runs the toasting and spreading phases. */ export class KitchenView implements View { readonly root = new THREE.Group(); private toaster: Toaster; - private plate: THREE.Group; private slice!: Slice; private heat!: Float32Array; private rng: Rng; phase: Phase = 'ready'; power = 6; - private leverT = 0; // 0 up, 1 down + /** 0 = straight from the fridge, 1 = left out on the bench all morning. */ + butterSoftness = 0.15; + toolId: ToolId = 'butter_knife'; + spreadId: SpreadId = 'butter'; + + private leverT = 0; private vel = new THREE.Vector3(); private spin = 0; private toastSeconds = 0; + knife: Knife = newKnife(); + private knifeRig: KnifeRig; + private pot: THREE.Group | null = null; + private potHit: THREE.Mesh | null = null; + private ray = new THREE.Raycaster(); + private hitPoint = new THREE.Vector3(); + private uv = new THREE.Vector2(); + private plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0); + lastFx: StrokeFx | null = null; + + private camPos = new THREE.Vector3(); + private camLook = new THREE.Vector3(); + private shot: CamShot = SHOTS.toast; + private panel: Panel; private cueEl!: HTMLElement; private hintEl!: HTMLElement; - private dialEl!: HTMLInputElement; - private breadEl!: HTMLSelectElement; - - /** Fired when the slice has landed on the plate — M2 picks up from here. */ - onLanded: ((slice: Slice) => void) | null = null; + private dialRow!: HTMLElement; + private gaugeFill!: HTMLElement; + private gaugeYield!: HTMLElement; + private gaugeScrape!: HTMLElement; + private modeEl!: HTMLElement; + private loadFill!: HTMLElement; constructor( private app: App, @@ -51,9 +90,16 @@ export class KitchenView implements View { this.toaster = makeToaster(); this.toaster.root.position.set(TOASTER_X, 0, -0.35); this.root.add(this.toaster.root); - this.plate = makePlate(); - this.plate.position.set(PLATE_X, 0, PLATE_Z); - this.root.add(this.plate); + const plate = makePlate(); + plate.position.set(PLATE_X, 0, PLATE_Z); + this.root.add(plate); + + this.knifeRig = new KnifeRig(TOOLS[this.toolId]); + this.knifeRig.root.visible = false; + this.root.add(this.knifeRig.root); + + this.camPos.copy(SHOTS.toast.pos); + this.camLook.copy(SHOTS.toast.look); this.panel = new Panel('toast-panel'); this.buildUi(); @@ -62,32 +108,96 @@ export class KitchenView implements View { private buildUi(): void { const p = this.panel.root; - const row = el('div', 'row', p); - el('label', 'lbl', row, 'BROWNING'); - this.dialEl = el('input', 'dial', row); - this.dialEl.type = 'range'; - this.dialEl.min = '1'; - this.dialEl.max = '10'; - this.dialEl.step = '1'; - this.dialEl.value = String(this.power); - const val = el('span', 'val', row, String(this.power)); - this.dialEl.addEventListener('input', () => { - this.power = +this.dialEl.value; - val.textContent = this.dialEl.value; + this.dialRow = el('div', 'row', p); + el('label', 'lbl', this.dialRow, 'BROWNING'); + const dial = el('input', 'dial', this.dialRow); + dial.type = 'range'; + dial.min = '1'; + dial.max = '10'; + dial.step = '1'; + dial.value = String(this.power); + const val = el('span', 'val', this.dialRow, String(this.power)); + dial.addEventListener('input', () => { + this.power = +dial.value; + val.textContent = dial.value; this.toaster.dial.rotation.x = -((this.power - 1) / 9) * 2.4; }); const row2 = el('div', 'row', p); el('label', 'lbl', row2, 'BREAD'); - this.breadEl = el('select', 'sel', row2); + const breadSel = el('select', 'sel', row2); for (const id of Object.keys(BREADS)) { - const o = el('option', undefined, this.breadEl, BREADS[id as BreadId].name); + const o = el('option', undefined, breadSel, BREADS[id as BreadId].name); o.value = id; } - this.breadEl.addEventListener('change', () => this.loadBread(this.breadEl.value as BreadId)); + breadSel.addEventListener('change', () => this.loadBread(breadSel.value as BreadId)); + + const row3 = el('div', 'row', p); + el('label', 'lbl', row3, 'SPREAD'); + const spreadSel = el('select', 'sel', row3); + for (const id of Object.keys(SPREADS)) { + const o = el('option', undefined, spreadSel, SPREADS[id as SpreadId].name); + o.value = id; + } + spreadSel.addEventListener('change', () => this.setSpread(spreadSel.value as SpreadId)); + + const row4 = el('div', 'row', p); + el('label', 'lbl', row4, 'TOOL'); + const toolSel = el('select', 'sel', row4); + for (const id of Object.keys(TOOLS)) { + const o = el('option', undefined, toolSel, TOOLS[id as ToolId].name); + o.value = id; + } + toolSel.addEventListener('change', () => { + this.toolId = toolSel.value as ToolId; + this.knifeRig.setTool(TOOLS[this.toolId]); + }); + + const row5 = el('div', 'row', p); + el('label', 'lbl', row5, 'BUTTER'); + const soft = el('input', 'dial', row5); + soft.type = 'range'; + soft.min = '0'; + soft.max = '100'; + soft.value = String(this.butterSoftness * 100); + const softVal = el('span', 'val', row5, 'hard'); + soft.addEventListener('input', () => { + this.butterSoftness = +soft.value / 100; + softVal.textContent = + this.butterSoftness > 0.66 ? 'soft' : this.butterSoftness > 0.33 ? 'ok' : 'hard'; + }); + + // The pressure gauge — the mechanic made legible. The fill is the pressure + // your wrist angle is producing; the gold mark is what this spread needs in + // order to flow; the red mark is where the knife stops spreading and starts + // scraping. When gold sits past red, no angle can win, and the answer isn't + // technique — it's warmer toast. + const gauge = el('div', 'gauge', p); + this.gaugeFill = el('div', 'gauge-fill', gauge); + this.gaugeYield = el('div', 'gauge-mark yield', gauge); + this.gaugeScrape = el('div', 'gauge-mark scrape', gauge); + this.modeEl = el('div', 'mode', p, ''); + + const loadBar = el('div', 'load', p); + this.loadFill = el('div', 'load-fill', loadBar); this.cueEl = el('div', 'cue', p, 'smells like bread'); this.hintEl = el('div', 'hint', p, 'SPACE — push the lever down'); + this.setSpread(this.spreadId); + } + + setSpread(id: SpreadId): void { + this.spreadId = id; + const def = SPREADS[id]; + this.slice?.setSpread(def); + this.knifeRig.setSpread(def); + if (this.pot) this.root.remove(this.pot); + const { root, hit } = makeSpreadPot(def); + root.position.set(POT_X, 0, POT_Z); + this.pot = root; + this.potHit = hit; + this.pot.visible = this.phase === 'spreading'; + this.root.add(root); } loadBread(id: BreadId): void { @@ -96,12 +206,18 @@ export class KitchenView implements View { this.slice.dispose(); } this.slice = new Slice(BREADS[id], new Rng(this.rng.int(1, 1e6))); + this.slice.setSpread(SPREADS[this.spreadId]); this.heat = buildHeatMap(this.slice, new Rng(this.rng.int(1, 1e6))); this.root.add(this.slice.mesh); this.phase = 'ready'; this.leverT = 0; this.toastSeconds = 0; this.slice.warmth = 0; + this.knife = newKnife(); + this.knifeRig.root.visible = false; + if (this.pot) this.pot.visible = false; + this.shot = SHOTS.toast; + this.dialRow.style.opacity = '1'; this.placeInSlot(0); this.hintEl.textContent = 'SPACE — push the lever down'; } @@ -110,11 +226,10 @@ export class KitchenView implements View { return this.slice; } - /** t: 0 = sitting proud of the slot, 1 = fully down. */ private placeInSlot(t: number): void { const y = this.toaster.slotY + 0.16 - t * 0.74; this.slice.mesh.position.set(TOASTER_X, y, -0.35); - this.slice.mesh.rotation.set(Math.PI / 2, 0, 0); // standing, dome up + this.slice.mesh.rotation.set(Math.PI / 2, 0, 0); } enter(): void { @@ -135,9 +250,6 @@ export class KitchenView implements View { this.phase = 'flying'; this.slice.warmth = 1; this.toaster.setGlow(0); - - // Solve a real arc from the slot to the plate rather than tweening: the - // slice should look thrown, because it is. const p0 = this.slice.mesh.position.clone(); const y1 = PLATE_Y + 0.02; const vy = 4.2; @@ -145,11 +257,20 @@ export class KitchenView implements View { const disc = Math.max(0.01, vy * vy - 2 * g * (y1 - p0.y)); const T = (vy + Math.sqrt(disc)) / g; this.vel.set((PLATE_X - p0.x) / T, vy, (PLATE_Z - p0.z) / T); - this.spin = (Math.PI / 2 - 0) / T; + this.spin = Math.PI / 2 / T; this.app.shake = 0.045; this.hintEl.textContent = ''; } + beginSpreading(): void { + this.phase = 'spreading'; + this.shot = SHOTS.spread; + this.knifeRig.root.visible = true; + if (this.pot) this.pot.visible = true; + this.dialRow.style.opacity = '0.35'; + this.hintEl.textContent = 'dip in the pot · drag on the toast · WHEEL tilts the knife'; + } + update(dt: number): void { const inp = this.app.input; if (inp.justPressed('Space')) { @@ -157,7 +278,6 @@ export class KitchenView implements View { else if (this.phase === 'toasting') this.pop(); } - // lever + slice ride down together const target = this.phase === 'ready' ? 0 : this.phase === 'toasting' ? 1 : 0; this.leverT += (target - this.leverT) * Math.min(1, dt * 14); this.toaster.lever.position.y = 1.62 * 0.72 - this.leverT * 0.62; @@ -166,7 +286,9 @@ export class KitchenView implements View { this.placeInSlot(this.leverT); this.toastSeconds += dt; toastStep(this.slice, this.heat, this.power, dt); - this.toaster.setGlow(0.35 + 0.65 * (this.power / 10) * (0.92 + Math.sin(this.toastSeconds * 9) * 0.08)); + this.toaster.setGlow( + 0.35 + 0.65 * (this.power / 10) * (0.92 + Math.sin(this.toastSeconds * 9) * 0.08), + ); this.slice.setHeatGlow(0.55 + 0.45 * Math.sin(this.toastSeconds * 7) * 0.3); } else if (this.phase === 'ready') { this.placeInSlot(this.leverT); @@ -179,20 +301,121 @@ export class KitchenView implements View { if (this.slice.mesh.position.y <= PLATE_Y + 0.02 && this.vel.y < 0) { this.slice.mesh.position.set(PLATE_X, PLATE_Y + 0.02, PLATE_Z); this.slice.mesh.rotation.set(0, 0, 0); - this.phase = 'landed'; this.app.shake = 0.02; - this.onLanded?.(this.slice); + this.beginSpreading(); } + } else if (this.phase === 'spreading') { + this.updateSpreading(dt); } if (this.phase !== 'toasting') coolStep(this.slice, dt); + this.updateCamera(dt); - const t = readToast(this.slice); - this.cueEl.textContent = smellCue(t); - this.cueEl.style.color = t.smoke > 0.45 ? '#e2603a' : ''; + if (this.phase !== 'spreading') { + const t = readToast(this.slice); + this.cueEl.textContent = smellCue(t); + this.cueEl.style.color = t.smoke > 0.45 ? '#e2603a' : ''; + } this.slice.sync(); } + private updateSpreading(dt: number): void { + const inp = this.app.input; + const def = SPREADS[this.spreadId]; + const tool = TOOLS[this.toolId]; + + if (inp.wheel !== 0) { + this.knife.angle = Math.max(0, Math.min(1, this.knife.angle + inp.wheel * 0.055)); + } + + this.ray.setFromCamera(inp.ndc, this.app.camera); + + // Dipping wins over spreading: if the cursor is over the pot you're loading up. + let overPot = false; + if (this.potHit) { + const hits = this.ray.intersectObject(this.potHit, false); + if (hits.length) { + overPot = true; + this.knifeRig.update(hits[0].point, this.knife.angle * 0.3, this.knife.load, hits[0].point.y); + if (inp.down) dip(this.knife, def); + this.knife.hasPrev = false; + } + } + + if (!overPot) { + this.plane.constant = -this.slice.topY; + if (this.ray.ray.intersectPlane(this.plane, this.hitPoint)) { + this.knifeRig.update(this.hitPoint, this.knife.angle, this.knife.load, this.slice.topY); + this.slice.uvAt(this.hitPoint, this.uv); + const onBread = this.slice.mask.sample(this.uv.x, this.uv.y) > 0.5; + if (inp.down && onBread) { + this.lastFx = stroke( + this.slice, + def, + tool, + this.knife, + this.uv.x, + this.uv.y, + dt, + this.butterSoftness, + ); + if (this.lastFx.tore > 0.004 || this.lastFx.gouged > 0.004) this.app.shake = 0.012; + } else { + this.knife.hasPrev = false; + this.lastFx = null; + } + } + } + + this.updateGauge(def); + } + + private updateGauge(def: SpreadDef): void { + const tool = TOOLS[this.toolId]; + const pressure = Math.min(1.4, pressureFromAngle(this.knife.angle) / tool.contactScale); + const yieldP = effectiveYield(def, this.butterSoftness, this.slice.warmth); + const scrapeP = Math.min(1.4, pressureFromAngle(SCRAPE_ANGLE) / tool.contactScale); + const pct = (v: number) => `${Math.min(100, (v / 1.4) * 100)}%`; + this.gaugeFill.style.width = pct(pressure); + this.gaugeYield.style.left = pct(yieldP); + this.gaugeScrape.style.left = pct(scrapeP); + const scraping = this.knife.angle >= SCRAPE_ANGLE; + this.gaugeFill.style.background = scraping + ? '#c0392b' + : pressure >= yieldP + ? '#6cbf6c' + : '#8a7f70'; + + const fx = this.lastFx; + let msg = scraping ? 'scraping' : 'spreading'; + if (fx) { + if (fx.mode === 'tear') msg = 'TEARING — the butter is too hard for this'; + else if (fx.mode === 'gouge') msg = 'GOUGING — there is nothing left to scrape'; + else if (fx.mode === 'char') msg = 'lifting the burnt bits'; + else if (fx.mode === 'scrape') msg = 'taking it back off'; + else if (fx.mode === 'spread') msg = 'spreading nicely'; + } else if (this.knife.load <= 0.01) { + msg = 'the knife is empty — go and dip'; + } + this.modeEl.textContent = msg; + this.modeEl.style.color = + fx?.mode === 'tear' || fx?.mode === 'gouge' + ? '#e2603a' + : fx?.mode === 'spread' + ? '#8fce8f' + : ''; + this.loadFill.style.width = `${Math.min(100, (this.knife.load / def.pickup) * 100)}%`; + this.loadFill.style.background = `rgb(${def.color.map((c) => Math.round(c * 255)).join(',')})`; + } + + private updateCamera(dt: number): void { + const k = 1 - Math.pow(0.0035, dt); + this.camPos.lerp(this.shot.pos, k); + this.camLook.lerp(this.shot.look, k); + this.app.camera.position.copy(this.camPos); + this.app.camera.lookAt(this.camLook); + } + dispose(): void { this.panel.dispose(); this.slice.dispose(); diff --git a/src/scenes/spreadrig.ts b/src/scenes/spreadrig.ts new file mode 100644 index 0000000..0312a01 --- /dev/null +++ b/src/scenes/spreadrig.ts @@ -0,0 +1,113 @@ +import * as THREE from 'three'; +import { makeCutleryMesh, type Tool } from '../sim/cutlery'; +import type { SpreadDef } from '../sim/spreads'; + +/** + * The knife you actually hold. Two nested groups so the tilt is about the blade, + * not about the mouse cursor: `yaw` sets how the knife is held, `tilt` is the + * one control the whole mechanic hangs off. + */ +export class KnifeRig { + readonly root = new THREE.Group(); + private yaw = new THREE.Group(); + private tilt = new THREE.Group(); + private mesh: THREE.Group; + private blob: THREE.Mesh; + private blobMat: THREE.MeshStandardMaterial; + private bladeCentre: number; + + constructor(tool: Tool) { + this.bladeCentre = 0.12 + tool.headL * 0.5; + this.mesh = makeCutleryMesh(tool); + // Slide the piece so the middle of its head sits at the rig's origin — that + // point is the contact patch, and it's what we rotate about. + this.mesh.position.z = -this.bladeCentre; + this.tilt.add(this.mesh); + this.yaw.add(this.tilt); + this.root.add(this.yaw); + // Held like a right-handed person spreading: handle down toward the player. + this.yaw.rotation.y = -0.62; + + // The load on the blade, so you can see when you've run out. + const blobGeo = new THREE.SphereGeometry(1, 14, 10); + blobGeo.scale(tool.headW * 0.42, 0.035, tool.headL * 0.34); + this.blobMat = new THREE.MeshStandardMaterial({ color: 0xf0c040, roughness: 0.32 }); + this.blob = new THREE.Mesh(blobGeo, this.blobMat); + this.blob.position.set(0, 0.028, 0); + this.tilt.add(this.blob); + } + + setTool(tool: Tool): void { + this.tilt.remove(this.mesh); + this.mesh = makeCutleryMesh(tool); + this.bladeCentre = 0.12 + tool.headL * 0.5; + this.mesh.position.z = -this.bladeCentre; + this.tilt.add(this.mesh); + } + + setSpread(def: SpreadDef | null): void { + if (def) this.blobMat.color.setRGB(def.color[0], def.color[1], def.color[2]); + } + + /** angle 0..1; load 0..1 */ + update(pos: THREE.Vector3, angle: number, load: number, topY: number): void { + // Tilt lifts the handle and drops the blade onto its edge. At angle 1 the + // knife is all but vertical, which is exactly how you scrape burnt toast. + const tiltRad = angle * 1.38; + this.tilt.rotation.x = -tiltRad; + // Ride the contact patch just above the toast whatever the tilt is doing. + const lift = Math.sin(tiltRad) * 0.045 + 0.012; + this.root.position.set(pos.x, Math.max(topY, pos.y) + lift, pos.z); + this.blob.visible = load > 0.01; + const s = 0.35 + Math.min(1, load / 0.6) * 0.75; + this.blob.scale.set(s, Math.min(1.6, s * (0.6 + load)), s); + } +} + +/** The pot you dip into. One per order — you only ever get one spread. */ +export function makeSpreadPot(def: SpreadDef): { root: THREE.Group; hit: THREE.Mesh } { + const g = new THREE.Group(); + const glass = new THREE.MeshStandardMaterial({ + color: 0xdfe6e6, + roughness: 0.12, + metalness: 0.02, + transparent: true, + opacity: 0.42, + }); + const contents = new THREE.MeshStandardMaterial({ + color: new THREE.Color(def.color[0], def.color[1], def.color[2]), + roughness: def.id === 'peanut' ? 0.85 : 0.4, + }); + + if (def.id === 'butter') { + const dish = new THREE.Mesh(new THREE.BoxGeometry(1.15, 0.16, 0.75), glass); + dish.position.y = 0.08; + g.add(dish); + const block = new THREE.Mesh(new THREE.BoxGeometry(0.78, 0.3, 0.44), contents); + block.position.y = 0.29; + block.castShadow = true; + g.add(block); + return { root: g, hit: block }; + } + + const jar = new THREE.Mesh(new THREE.CylinderGeometry(0.42, 0.4, 0.66, 24), glass); + jar.position.y = 0.33; + g.add(jar); + const fill = new THREE.Mesh(new THREE.CylinderGeometry(0.36, 0.34, 0.46, 24), contents); + fill.position.y = 0.27; + g.add(fill); + const surface = new THREE.Mesh(new THREE.CylinderGeometry(0.36, 0.36, 0.02, 24), contents); + surface.position.y = 0.5; + g.add(surface); + const label = new THREE.Mesh( + new THREE.CylinderGeometry(0.425, 0.425, 0.3, 24, 1, true), + new THREE.MeshStandardMaterial({ + color: def.id === 'mitey' ? 0xc8102e : 0xf2e2c0, + roughness: 0.7, + side: THREE.DoubleSide, + }), + ); + label.position.y = 0.3; + g.add(label); + return { root: g, hit: surface }; +} diff --git a/src/sim/cutlery.ts b/src/sim/cutlery.ts new file mode 100644 index 0000000..4b344a0 --- /dev/null +++ b/src/sim/cutlery.ts @@ -0,0 +1,353 @@ +import * as THREE from 'three'; + +/** + * The cutlery cast. Deliberately procedural rather than generated: these + * silhouettes are gameplay — the drawer asks you to find "the dessert fork" + * among things that are almost dessert forks, and that's only fair if the + * differences are authored. It also keeps the tines thin without a mesher + * mangling them, and lets each piece carry its own physics colliders. + */ + +export type ToolId = + | 'butter_knife' + | 'dinner_knife' + | 'steak_knife' + | 'spreader' + | 'dinner_fork' + | 'dessert_fork' + | 'teaspoon' + | 'dessert_spoon' + | 'soup_spoon'; + +export type ToolKind = 'knife' | 'fork' | 'spoon'; + +export interface Tool { + id: ToolId; + name: string; + kind: ToolKind; + /** Overall length in slice-units (1 unit ~ 11cm). */ + length: number; + /** Width of the business end. */ + headW: number; + headL: number; + /** Multiplies the knife's contact patch — a spreader is wide, a steak knife isn't. */ + contactScale: number; + /** How well it moves spread at all. */ + transferScale: number; + /** Multiplier on gouge risk. Serrated things are bad news. */ + gougeProne: number; + /** 0..1 — how blotchy the deposit is. A spoon can't lay a flat film. */ + blotch: number; + /** Multiplier on tearing when the spread won't yield. */ + tearProne: number; + /** The right tool for spreading. */ + ideal: boolean; + blurb: string; +} + +export const TOOLS: Record = { + butter_knife: { + id: 'butter_knife', + name: 'Butter Knife', + kind: 'knife', + length: 1.75, + headW: 0.2, + headL: 0.72, + contactScale: 1, + transferScale: 1, + gougeProne: 1, + blotch: 0, + tearProne: 1, + ideal: true, + blurb: 'Round-tipped, wide, dull. The correct answer.', + }, + dinner_knife: { + id: 'dinner_knife', + name: 'Dinner Knife', + kind: 'knife', + length: 2.0, + headW: 0.16, + headL: 0.85, + contactScale: 0.86, + transferScale: 0.95, + gougeProne: 1.35, + blotch: 0.05, + tearProne: 1.1, + ideal: false, + blurb: 'Longer, narrower, and it has opinions about the crumb.', + }, + steak_knife: { + id: 'steak_knife', + name: 'Steak Knife', + kind: 'knife', + length: 1.95, + headW: 0.13, + headL: 0.88, + contactScale: 0.62, + transferScale: 0.8, + gougeProne: 3.2, + blotch: 0.12, + tearProne: 1.6, + ideal: false, + blurb: 'Serrated. Every stroke is a small act of violence.', + }, + spreader: { + id: 'spreader', + name: 'Pâté Spreader', + kind: 'knife', + length: 1.4, + headW: 0.3, + headL: 0.5, + contactScale: 1.35, + transferScale: 1.15, + gougeProne: 0.55, + blotch: 0, + tearProne: 0.7, + ideal: true, + blurb: 'Stubby, wide, blameless. Somehow always at the back.', + }, + dinner_fork: { + id: 'dinner_fork', + name: 'Dinner Fork', + kind: 'fork', + length: 1.85, + headW: 0.26, + headL: 0.42, + contactScale: 0.7, + transferScale: 0.55, + gougeProne: 2.4, + blotch: 0.75, + tearProne: 3.0, + ideal: false, + blurb: 'Four tines. Four furrows.', + }, + dessert_fork: { + id: 'dessert_fork', + name: 'Dessert Fork', + kind: 'fork', + length: 1.5, + headW: 0.23, + headL: 0.34, + contactScale: 0.6, + transferScale: 0.5, + gougeProne: 2.2, + blotch: 0.78, + tearProne: 2.8, + ideal: false, + blurb: 'Like a dinner fork, but smaller. That is the entire difference.', + }, + teaspoon: { + id: 'teaspoon', + name: 'Teaspoon', + kind: 'spoon', + length: 1.3, + headW: 0.24, + headL: 0.34, + contactScale: 0.75, + transferScale: 0.7, + gougeProne: 0.5, + blotch: 0.6, + tearProne: 1.2, + ideal: false, + blurb: 'You can, technically. It will show.', + }, + dessert_spoon: { + id: 'dessert_spoon', + name: 'Dessert Spoon', + kind: 'spoon', + length: 1.65, + headW: 0.3, + headL: 0.44, + contactScale: 0.85, + transferScale: 0.75, + gougeProne: 0.45, + blotch: 0.55, + tearProne: 1.15, + ideal: false, + blurb: 'A teaspoon that has been to the gym.', + }, + soup_spoon: { + id: 'soup_spoon', + name: 'Soup Spoon', + kind: 'spoon', + length: 1.6, + headW: 0.38, + headL: 0.4, + contactScale: 0.9, + transferScale: 0.7, + gougeProne: 0.4, + blotch: 0.62, + tearProne: 1.1, + ideal: false, + blurb: 'Round. Deep. Utterly wrong, but confidently so.', + }, +}; + +export const TOOL_IDS = Object.keys(TOOLS) as ToolId[]; + +const STEEL = new THREE.MeshStandardMaterial({ + color: 0xd2d7dd, + roughness: 0.24, + metalness: 0.95, +}); + +/** + * Extrude a profile drawn in shape-space (x = width, y = length) into a part + * lying in the XZ plane: shape +y becomes +z, and the extrusion thickness ends + * up centred on y=0. + * + * Done at the geometry level on purpose. Setting mesh.rotation.x = -PI/2 instead + * sends a profile drawn toward +y to -z and one drawn toward -y to +z — which + * silently lays the handle and the blade out in opposite directions, on top of + * each other, and the piece is nowhere near where the code says it is. + */ +function extrudeFlat(shape: THREE.Shape, depth: number, bevel: number): THREE.BufferGeometry { + const geo = new THREE.ExtrudeGeometry(shape, { + depth, + bevelEnabled: bevel > 0, + bevelThickness: bevel, + bevelSize: bevel, + bevelSegments: 2, + curveSegments: 12, + }); + geo.rotateX(Math.PI / 2); // shape +y -> +z, extrusion depth -> -y + geo.translate(0, depth / 2, 0); + return geo; +} + +/** + * Build a piece of cutlery lying in the XZ plane, handle at -Z, head at +Z. + * Y is thickness. + */ +export function makeCutleryMesh(tool: Tool): THREE.Group { + const g = new THREE.Group(); + const L = tool.length; + const handleL = L - tool.headL - 0.12; + + // handle: a tapered, slightly domed bar + const handleShape = new THREE.Shape(); + const hw0 = 0.052; // at the neck + const hw1 = 0.085; // at the butt + handleShape.moveTo(-hw0, 0); + handleShape.lineTo(-hw1 * 0.92, -handleL * 0.55); + handleShape.quadraticCurveTo(-hw1, -handleL, 0, -handleL); + handleShape.quadraticCurveTo(hw1, -handleL, hw1 * 0.92, -handleL * 0.55); + handleShape.lineTo(hw0, 0); + handleShape.closePath(); + const handle = new THREE.Mesh(extrudeFlat(handleShape, 0.036, 0.014), STEEL); + handle.position.z = -0.02; + g.add(handle); + + // neck + const neck = new THREE.Mesh(new THREE.BoxGeometry(0.055, 0.028, 0.16), STEEL); + neck.position.z = 0.06; + g.add(neck); + + if (tool.kind === 'knife') g.add(makeBlade(tool)); + else if (tool.kind === 'fork') g.add(makeForkHead(tool)); + else g.add(makeSpoonBowl(tool)); + + for (const c of g.children) { + c.castShadow = true; + c.receiveShadow = true; + } + return g; +} + +function makeBlade(tool: Tool): THREE.Mesh { + const w = tool.headW / 2; + const l = tool.headL; + const s = new THREE.Shape(); + s.moveTo(-0.028, 0); + s.lineTo(-w * 0.8, l * 0.22); + s.lineTo(-w, l * 0.55); + // rounded tip for a butter knife, a point for the aggressive ones + if (tool.id === 'butter_knife' || tool.id === 'spreader') { + s.quadraticCurveTo(-w, l, 0, l); + s.quadraticCurveTo(w, l, w, l * 0.55); + } else { + s.lineTo(-w * 0.55, l * 0.93); + s.quadraticCurveTo(0, l * 1.02, w * 0.72, l * 0.86); + s.lineTo(w, l * 0.55); + } + s.lineTo(w * 0.8, l * 0.22); + s.lineTo(0.028, 0); + s.closePath(); + + const blade = new THREE.Mesh(extrudeFlat(s, 0.014, 0.006), STEEL); + blade.position.set(0, 0, 0.12); + return blade; +} + +function makeForkHead(tool: Tool): THREE.Group { + const g = new THREE.Group(); + const w = tool.headW / 2; + const l = tool.headL; + // the shoulder the tines grow out of + const base = new THREE.Shape(); + base.moveTo(-0.03, 0); + base.lineTo(-w, l * 0.34); + base.lineTo(w, l * 0.34); + base.lineTo(0.03, 0); + base.closePath(); + const shoulder = new THREE.Mesh(extrudeFlat(base, 0.016, 0), STEEL); + shoulder.position.set(0, 0, 0.12); + g.add(shoulder); + + // four tines + const tineL = l * 0.66; + const tineW = (w * 2) / 7; + for (let i = 0; i < 4; i++) { + const x = (i - 1.5) * (w * 2) / 4; + const tine = new THREE.Mesh(new THREE.BoxGeometry(tineW, 0.014, tineL), STEEL); + tine.position.set(x, 0, 0.12 + l * 0.34 + tineL / 2); + g.add(tine); + const tip = new THREE.Mesh(new THREE.ConeGeometry(tineW * 0.5, 0.05, 6), STEEL); + tip.rotation.x = Math.PI / 2; + tip.position.set(x, 0, 0.12 + l * 0.34 + tineL + 0.02); + g.add(tip); + } + return g; +} + +function makeSpoonBowl(tool: Tool): THREE.Mesh { + const geo = new THREE.SphereGeometry(0.5, 20, 14, 0, Math.PI * 2, 0, Math.PI * 0.52); + geo.scale(tool.headW * 0.5, 0.11, tool.headL * 0.6); + geo.rotateX(Math.PI); // open side up + const bowl = new THREE.Mesh(geo, STEEL); + bowl.position.set(0, 0.005, 0.12 + tool.headL * 0.42); + return bowl; +} + +/** + * Compound collider primitives for the drawer, in the mesh's local space. + * Boxes only, and few of them: a trimesh of a fork is both slow and a stability + * nightmare when a dozen of them are tangled together. + */ +export interface ColliderPart { + half: [number, number, number]; + pos: [number, number, number]; +} + +export function colliderParts(tool: Tool): ColliderPart[] { + const L = tool.length; + const handleL = L - tool.headL - 0.12; + const parts: ColliderPart[] = [ + { half: [0.075, 0.03, handleL / 2], pos: [0, 0, -0.02 - handleL / 2] }, + { half: [0.03, 0.016, 0.08], pos: [0, 0, 0.06] }, + ]; + if (tool.kind === 'spoon') { + parts.push({ + half: [tool.headW * 0.5, 0.055, tool.headL * 0.32], + pos: [0, 0, 0.12 + tool.headL * 0.42], + }); + } else { + // one slab for a blade; for a fork this is the tine envelope, which is what + // actually matters — individual tines catching each other is a physics trap. + parts.push({ + half: [tool.headW * 0.5, 0.012, tool.headL * 0.5], + pos: [0, 0, 0.12 + tool.headL * 0.5], + }); + } + return parts; +} diff --git a/src/sim/slice.ts b/src/sim/slice.ts index fa0df9d..21f2863 100644 --- a/src/sim/slice.ts +++ b/src/sim/slice.ts @@ -39,6 +39,10 @@ export class Slice { warmth = 0; /** Which spread is currently on the slice (for judging + shading). */ spreadDef: SpreadDef | null = null; + /** Extent of the UV projection, so world hits can be turned back into texels. */ + readonly sizeX: number; + readonly sizeZ: number; + readonly halfThickness: number; private tex: THREE.DataTexture; private texData: Uint8Array; @@ -65,6 +69,10 @@ export class Slice { this.tex.needsUpdate = true; const geo = buildGeometry(shape, bread); + const bb = geo.boundingBox!; + this.sizeX = bb.max.x - bb.min.x; + this.sizeZ = bb.max.z - bb.min.z; + this.halfThickness = bb.max.y; this.material = buildMaterial(bread, this.tex); this.mesh = new THREE.Mesh(geo, this.material); this.mesh.castShadow = true; @@ -72,6 +80,21 @@ export class Slice { this.sync(); } + /** + * World point -> field UV. The UVs were planar-projected from the shape's own + * XY before the slice was laid flat, which makes shape +y become world -z — + * hence the flip on v. + */ + uvAt(worldPoint: THREE.Vector3, out: THREE.Vector2): THREE.Vector2 { + const p = this.mesh.worldToLocal(worldPoint.clone()); + return out.set(p.x / this.sizeX + 0.5, 0.5 - p.z / this.sizeZ); + } + + /** Height of the top face in world space (the slice lies flat when spreading). */ + get topY(): number { + return this.mesh.position.y + this.halfThickness; + } + get uniforms() { return this.material.uniforms; } @@ -182,6 +205,7 @@ function buildGeometry(shape: THREE.Shape, bread: Bread): THREE.BufferGeometry { geo.rotateX(-Math.PI / 2); geo.center(); geo.computeVertexNormals(); + geo.computeBoundingBox(); return geo; } diff --git a/src/sim/spreading.ts b/src/sim/spreading.ts new file mode 100644 index 0000000..6672ab3 --- /dev/null +++ b/src/sim/spreading.ts @@ -0,0 +1,234 @@ +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; + 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, u: 0.5, v: 0.5, hasPrev: false }; +} + +export function dip(knife: Knife, def: SpreadDef): void { + knife.load = def.pickup; +} + +/** 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; + const yieldP = effectiveYield(def, softness, slice.warmth); + 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; + const rate = def.transferRate * tool.transferScale * dt * transfer; + let massLeft = knife.load * slice.mask.n * slice.mask.n; + + 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; + fx.deposited += add; + }); + knife.load = Math.max(0, massLeft / (slice.mask.n * slice.mask.n)); + + 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 --- + 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); + 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'; +} diff --git a/src/sim/spreads.ts b/src/sim/spreads.ts index 682021d..75dd42b 100644 --- a/src/sim/spreads.ts +++ b/src/sim/spreads.ts @@ -53,7 +53,7 @@ export const SPREADS: Record = { tempSoftening: 0.78, viscosity: 0.72, pickup: 0.55, - transferRate: 1.5, + 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] }, @@ -70,7 +70,7 @@ export const SPREADS: Record = { tempSoftening: 0.3, viscosity: 0.96, pickup: 0.95, - transferRate: 2.2, + 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] }, @@ -87,7 +87,7 @@ export const SPREADS: Record = { tempSoftening: 0.5, viscosity: 0.34, pickup: 0.5, - transferRate: 2.6, + 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] }, diff --git a/src/style.css b/src/style.css index 30d0385..72251c6 100644 --- a/src/style.css +++ b/src/style.css @@ -116,3 +116,61 @@ canvas#c { color: var(--ink-dim); min-height: 14px; } + +/* Pressure gauge: fill = what your wrist is making, gold = what the spread + needs to flow, red = where spreading becomes scraping. */ +.toast-panel .gauge { + position: relative; + height: 9px; + margin-top: 14px; + border-radius: 5px; + background: rgba(255, 255, 255, 0.09); + overflow: hidden; +} + +.toast-panel .gauge-fill { + position: absolute; + inset: 0 auto 0 0; + width: 0; + background: #8a7f70; + border-radius: 5px; + transition: background 0.15s; +} + +.toast-panel .gauge-mark { + position: absolute; + top: -3px; + bottom: -3px; + width: 2px; + margin-left: -1px; +} + +.toast-panel .gauge-mark.yield { + background: var(--gold); +} + +.toast-panel .gauge-mark.scrape { + background: #e2603a; +} + +.toast-panel .mode { + margin-top: 8px; + font-size: 11px; + letter-spacing: 0.09em; + color: var(--ink-dim); + min-height: 14px; +} + +.toast-panel .load { + height: 5px; + margin-top: 8px; + border-radius: 3px; + background: rgba(255, 255, 255, 0.09); + overflow: hidden; +} + +.toast-panel .load-fill { + height: 100%; + width: 0; + border-radius: 3px; +}