toastsim/src/scenes/spreadrig.ts
monster 70922bd855 M7: marmalade — rind, and a judge who scores its distribution
A particulate spread. The jelly is ordinary rheology; the rind is discrete
solids that ride the knife and drop off per unit of stroke DISTANCE, not per
second — a dabbing knife makes a pile, a moving knife lays a trail, and since
the shed rate scales with what's left on the blade, one dip cannot cover the
slice. Even rind takes several dips placed with intent. Scraping picks bits
back up (60% survive the blade), so a clump can be argued with.

The judge scores the scatter with the Clark–Evans nearest-neighbour index over
the bread's own area, and quotes it: R near 0 is one clump, 1 is random, above
1 is deliberate. Verified end to end on day 8:

  dab in a corner      8 bits   "one clump (R 0.24)"        drags grade to B
  three placed dips    24 bits  "evenly strewn (R 1.17)"    A, 8.9/10

First cut shed per-second; even careful strokes dumped the whole load in the
first half-second and scored "clumped (R 0.50)". Distance-based shedding is
what makes evenness the reward for technique rather than luck.

Rind renders as instanced chunks parented to the slice mesh, so it rides the
pop, the landing, and the pedestal turn. Points, not a field: the judge wants
nearest-neighbour statistics, and those want points.

Same sRGB trap as M3, new spot: the instanced material's colour was passed as
raw floats, which the renderer reads as linear — candied orange rendered as
pale butter. setRGB(..., SRGBColorSpace).

New content: day 8 handwritten (Deidre wants a piece in every bite), marmalade
in the procedural pool, five rind-district lines for the judge, and a marmalade
jar GLB generated on-device like everything else, with a plain-glass fallback
while it loads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 22:45:57 +10:00

122 lines
4.5 KiB
TypeScript

import * as THREE from 'three';
import { makeCutleryMesh, type Tool } from '../sim/cutlery';
import { loadProp } from './assets';
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, because you only ever get one spread.
*
* The vessel is a generated GLB; the thing you actually dip into is an invisible
* box at its mouth. Raycasting the generated mesh directly would be at the mercy
* of whatever the mesher produced, and the dip target needs to be exactly where
* the player expects it.
*/
export function makeSpreadPot(def: SpreadDef): { root: THREE.Group; hit: THREE.Mesh } {
const g = new THREE.Group();
const isButter = def.id === 'butter';
// material.visible = false, not object.visible = false: the renderer skips it
// either way, but the raycaster still needs a live object to hit.
const hit = new THREE.Mesh(
new THREE.BoxGeometry(isButter ? 0.8 : 0.62, 0.08, isButter ? 0.5 : 0.62),
new THREE.MeshBasicMaterial({ visible: false }),
);
hit.position.y = isButter ? 0.34 : 0.56;
g.add(hit);
const file =
isButter ? 'butter_dish'
: def.id === 'peanut' ? 'pb_jar'
: def.id === 'marmalade' ? 'marmalade_jar'
: 'mitey_jar';
loadProp(`/assets/models/${file}.glb`, isButter ? 1.5 : 1.15)
.then((prop) => g.add(prop))
.catch(() => {
// Asset still generating (or missing): a plain glass jar of the right
// colour keeps the order playable.
const jar = new THREE.Mesh(
new THREE.CylinderGeometry(0.42, 0.4, 0.66, 24),
new THREE.MeshStandardMaterial({
color: 0xdfe6e6,
roughness: 0.12,
transparent: true,
opacity: 0.42,
}),
);
jar.position.y = 0.33;
const fill = new THREE.Mesh(
new THREE.CylinderGeometry(0.36, 0.34, 0.5, 24),
new THREE.MeshStandardMaterial({
color: new THREE.Color(def.color[0], def.color[1], def.color[2]),
roughness: 0.35,
}),
);
fill.position.y = 0.29;
g.add(jar, fill);
});
return { root: g, hit };
}