M2: spreading — one control, three behaviours

Knife angle (wheel) is the only input, and spread/scrape/gouge all fall out of it.
Verified by driving real input through the real code paths:

  cold toast + fridge butter -> damage 0.015, spread 0.029   (tears, as designed)
  fresh warm toast           -> damage 0,     spread 0.117   (flows cleanly)
  burnt toast, knife on edge -> char 99.9% -> 82.7% -> 54.5%
                                evenness    0.029 -> 0.123 -> 0.226
  pale toast, knife on edge  -> gouges; steak knife gouges 3.2x harder

That evenness column is the mechanic: scraping rescues you from char and wrecks
uniformity doing it. The judge will have opinions.

The trap is calibrated, not hoped for. Pressure comes from steepness, but past
SCRAPE_ANGLE the knife stops spreading:

  fridge butter / cold toast   yield 0.72  needs angle 0.75  IMPOSSIBLE
  bench butter  / cold toast   yield 0.54  needs angle 0.57  (cliff at 0.62)
  fridge butter / fresh toast  yield 0.36  needs angle 0.38  fine
  soft butter   / fresh toast  yield 0.16  needs angle 0.12  dream mode

So cold toast + hard butter cannot be spread at any angle, and the way out isn't
technique — it's not dawdling. The pressure gauge draws both marks so you can
see the gold sitting past the red and understand why you're losing.

Emergent and kept: a steak knife's narrow blade concentrates pressure enough to
beat cold butter's yield. It's the right tool for cold butter and a menace
everywhere else.

- cutlery.ts: 9 hand-authored archetypes (silhouettes are gameplay — the drawer
  has to be fair) + compound box colliders for M4.
- dev.ts: harness that drives real gestures deterministically. Earns its keep.

Two bugs: cutlery meshes used mesh.rotation.x = -PI/2, which sends a profile
drawn toward +y to -z and one drawn toward -y to +z — the handle and blade were
laid out in opposite directions, overlapping, nowhere near the cursor. Now
rotated at the geometry level. And resize() computed aspect = 0/0 = NaN when the
container reports zero, which poisons the projection matrix so every raycast
silently misses — i.e. the entire mechanic stops with no error.

Metals need something to reflect: added a RoomEnvironment IBL and rebalanced the
direct rig, which was tuned before it existed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
monster 2026-07-16 21:51:34 +10:00
parent 67cfe56bb9
commit eb74170011
18 changed files with 1197 additions and 56 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 882 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 886 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 883 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 887 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 971 KiB

View File

@ -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;

120
src/dev.ts Normal file
View File

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

View File

@ -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();

View File

@ -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<string, CamShot> = {
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();

113
src/scenes/spreadrig.ts Normal file
View File

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

353
src/sim/cutlery.ts Normal file
View File

@ -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<ToolId, Tool> = {
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;
}

View File

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

234
src/sim/spreading.ts Normal file
View File

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

View File

@ -53,7 +53,7 @@ export const SPREADS: Record<SpreadId, SpreadDef> = {
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<SpreadId, SpreadDef> = {
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<SpreadId, SpreadDef> = {
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] },

View File

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