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