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>
354 lines
9.6 KiB
TypeScript
354 lines
9.6 KiB
TypeScript
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;
|
|
}
|