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>
BIN
public/assets/img/bench_wood.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/assets/img/judge_disappointed.png
Normal file
|
After Width: | Height: | Size: 882 KiB |
BIN
public/assets/img/judge_horrified.png
Normal file
|
After Width: | Height: | Size: 886 KiB |
BIN
public/assets/img/judge_impressed.png
Normal file
|
After Width: | Height: | Size: 883 KiB |
BIN
public/assets/img/judge_intrigued.png
Normal file
|
After Width: | Height: | Size: 887 KiB |
BIN
public/assets/img/kitchen_backdrop.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/assets/img/ticket_paper.png
Normal file
|
After Width: | Height: | Size: 342 KiB |
BIN
public/assets/img/title_art.png
Normal file
|
After Width: | Height: | Size: 971 KiB |
@ -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
@ -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;
|
||||
}
|
||||
10
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();
|
||||
|
||||
@ -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
@ -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
@ -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;
|
||||
}
|
||||
@ -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
@ -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';
|
||||
}
|
||||
@ -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] },
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||