The MODELBEAST station lane finished — 14 hero props, all local, all free — and the single-instance vessels are now wired into the benches that were still cooking in grey primitives: - the deep fryer: a steel pot with handles, a wire basket that rides the lift animation, and a proper drain rack stage left - the poach: a perforated slotted spoon the egg actually sits IN - pasta night: a real stockpot, with the salt visibly clouding the water Per-piece food (wings, chips) stays procedural on purpose: their colour IS the gameplay read — raw to golden to regret — and a textured mesh tinted per-frame reads as mud. Fidelity loses to legibility. Three fixes the screenshots forced, none of which guessing would have found: - The oil disc was sized to the understudy cylinder, so it poked out past the real pot's narrower rim like a yellow skirt. Refitted. - The basket sat below the oil line — invisible, and a basket you cannot see is a basket you cannot judge. Raised proud, handle kept. - The spoon is 1.9 long in Z with its bowl at one END, so centring the model parked the bowl a full half-length away from the egg. Measured the bounding box in-browser and shifted by half the length; the egg rides the bowl now. And one prop rejected: the generated egg carton ships with six eggs baked into the mesh, and that bench is entirely about which eggs are YOURS — draggable, float-tested, state-coloured. A carton that already has eggs in it is a lie the player has to see past. An empty tray is queued instead. Verified after: chips 10.0, poach 9.9, pasta 10.0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
375 lines
14 KiB
TypeScript
375 lines
14 KiB
TypeScript
import * as THREE from 'three';
|
|
import type { App, View } from '../core/app';
|
|
import { audio } from '../core/audio';
|
|
import { eye } from '../ui/eye';
|
|
import {
|
|
type PoachSession,
|
|
newPoachSession,
|
|
setPotKnob,
|
|
swirl,
|
|
dropEgg,
|
|
liftEgg,
|
|
drainStep,
|
|
poachStep,
|
|
poachResult,
|
|
waterWord,
|
|
SHIVER_LO,
|
|
SHIVER_HI,
|
|
BOIL_AT,
|
|
WHITE_SET,
|
|
YOLK_TREMBLE,
|
|
} from '../sim/poach';
|
|
import { el, Panel } from '../ui/hud';
|
|
import { loadBackdrop } from './props';
|
|
import { loadProp } from './assets';
|
|
|
|
const POT_Y = 0.55;
|
|
const POT_R = 1.15;
|
|
|
|
/**
|
|
* THE POACH POT — the vortex, the shiver, the drop, the lift.
|
|
*
|
|
* WHEEL sets the flame (the water answers on the fuel's lag — watch for the
|
|
* SHIVER, not a thermometer). DRAG CIRCLES over the pot to spin the vortex —
|
|
* it decays, so swirl then drop, not swirl then admire. SPACE drops the egg
|
|
* into the eye. Wait for the white to gather and set. L lifts it onto the
|
|
* spoon; HOLD it there to drain. ENTER serves — and he WILL press it.
|
|
*/
|
|
export class PoachView implements View {
|
|
readonly root = new THREE.Group();
|
|
|
|
private session: PoachSession | null = null;
|
|
private flame!: THREE.Mesh;
|
|
private water!: THREE.Mesh;
|
|
private streaks: THREE.Line[] = [];
|
|
private streakAngle = 0;
|
|
private bubbles: THREE.Mesh[] = [];
|
|
private eggWhite!: THREE.Mesh;
|
|
private eggYolk!: THREE.Mesh;
|
|
private ragBits: THREE.Mesh[] = [];
|
|
private spoon!: THREE.Group;
|
|
|
|
private ray = new THREE.Raycaster();
|
|
private hit = new THREE.Vector3();
|
|
private plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -(POT_Y + 0.12));
|
|
private lastAngle: number | null = null;
|
|
|
|
private panel: Panel;
|
|
private askEl!: HTMLElement;
|
|
private stateEl!: HTMLElement;
|
|
private wordEl!: HTMLElement;
|
|
private hintEl!: HTMLElement;
|
|
|
|
onDone: ((result: ReturnType<typeof poachResult>) => void) | null = null;
|
|
|
|
private bgTex = loadBackdrop('/assets/img/bg_pot.png');
|
|
private prevBg: unknown = null;
|
|
|
|
constructor(private app: App) {
|
|
this.buildScenery();
|
|
this.panel = new Panel('poach-panel');
|
|
this.buildUi();
|
|
this.panel.hide();
|
|
}
|
|
|
|
private buildUi(): void {
|
|
const p = this.panel.root;
|
|
const card = el('div', 'slicer-card', p);
|
|
el('div', 'drawer-lbl', card, 'POACH IT');
|
|
this.askEl = el('div', 'slicer-ask', card, '');
|
|
this.stateEl = el('div', 'slicer-thick', card, '');
|
|
this.wordEl = el('div', 'slicer-wobble', card, '');
|
|
this.hintEl = el('div', 'drawer-hint', p, '');
|
|
}
|
|
|
|
private buildScenery(): void {
|
|
const bench = new THREE.Mesh(
|
|
new THREE.BoxGeometry(9, 0.4, 6),
|
|
new THREE.MeshStandardMaterial({ color: 0x3a3a3e, roughness: 0.7, metalness: 0.2 }),
|
|
);
|
|
bench.position.y = -0.2;
|
|
bench.receiveShadow = true;
|
|
this.root.add(bench);
|
|
|
|
// Burner + flame under the pot (the pan's grammar).
|
|
const burner = new THREE.Mesh(
|
|
new THREE.CylinderGeometry(0.85, 0.95, 0.12, 24),
|
|
new THREE.MeshStandardMaterial({ color: 0x151515, roughness: 0.8, metalness: 0.4 }),
|
|
);
|
|
burner.position.y = 0.06;
|
|
this.root.add(burner);
|
|
this.flame = new THREE.Mesh(
|
|
new THREE.ConeGeometry(0.65, 0.45, 20),
|
|
new THREE.MeshBasicMaterial({ color: 0x3a7bff, transparent: true, opacity: 0 }),
|
|
);
|
|
this.flame.position.y = 0.32;
|
|
this.root.add(this.flame);
|
|
|
|
// The pot.
|
|
const pot = new THREE.Mesh(
|
|
new THREE.CylinderGeometry(POT_R, POT_R * 0.92, 0.75, 32, 1, true),
|
|
new THREE.MeshStandardMaterial({ color: 0x8a8f94, roughness: 0.35, metalness: 0.7, side: THREE.DoubleSide }),
|
|
);
|
|
pot.position.y = POT_Y - 0.05;
|
|
this.root.add(pot);
|
|
this.water = new THREE.Mesh(
|
|
new THREE.CylinderGeometry(POT_R * 0.93, POT_R * 0.93, 0.06, 32),
|
|
new THREE.MeshStandardMaterial({ color: 0x7fa8bc, roughness: 0.2, transparent: true, opacity: 0.75 }),
|
|
);
|
|
this.water.position.y = POT_Y + 0.1;
|
|
this.root.add(this.water);
|
|
|
|
// Vortex streaks: arcs that spin with the swirl.
|
|
const streakMat = new THREE.LineBasicMaterial({ color: 0xd8ecf4, transparent: true, opacity: 0.0 });
|
|
for (let i = 0; i < 3; i++) {
|
|
const pts: THREE.Vector3[] = [];
|
|
const r = POT_R * (0.35 + i * 0.2);
|
|
for (let a = 0; a <= 20; a++) {
|
|
const th = (a / 20) * Math.PI * 1.1;
|
|
pts.push(new THREE.Vector3(Math.cos(th) * r, POT_Y + 0.14, Math.sin(th) * r));
|
|
}
|
|
const line = new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts), streakMat);
|
|
this.streaks.push(line);
|
|
this.root.add(line);
|
|
}
|
|
|
|
// Shiver/boil bubbles.
|
|
for (let i = 0; i < 10; i++) {
|
|
const b = new THREE.Mesh(
|
|
new THREE.SphereGeometry(0.035, 8, 6),
|
|
new THREE.MeshBasicMaterial({ color: 0xeef6fa, transparent: true, opacity: 0 }),
|
|
);
|
|
b.position.set((Math.random() - 0.5) * POT_R * 1.4, POT_Y + 0.14, (Math.random() - 0.5) * POT_R * 1.4);
|
|
this.bubbles.push(b);
|
|
this.root.add(b);
|
|
}
|
|
|
|
// The egg: a white comet + a yolk, hidden until the drop.
|
|
this.eggWhite = new THREE.Mesh(
|
|
new THREE.SphereGeometry(0.3, 16, 12),
|
|
new THREE.MeshStandardMaterial({ color: 0xf2efe6, roughness: 0.5, transparent: true, opacity: 0.65 }),
|
|
);
|
|
this.eggWhite.scale.set(1.25, 0.5, 1.1);
|
|
this.eggWhite.position.set(0, POT_Y + 0.12, 0);
|
|
this.eggWhite.visible = false;
|
|
this.root.add(this.eggWhite);
|
|
this.eggYolk = new THREE.Mesh(
|
|
new THREE.SphereGeometry(0.14, 14, 10),
|
|
new THREE.MeshStandardMaterial({ color: 0xf0b428, roughness: 0.45 }),
|
|
);
|
|
this.eggYolk.scale.set(1, 0.65, 1);
|
|
this.eggYolk.position.set(0, POT_Y + 0.16, 0);
|
|
this.eggYolk.visible = false;
|
|
this.root.add(this.eggYolk);
|
|
|
|
// The slotted spoon, arriving at the lift.
|
|
this.spoon = new THREE.Group();
|
|
const bowl = new THREE.Mesh(
|
|
new THREE.SphereGeometry(0.38, 16, 10, 0, Math.PI * 2, 0, Math.PI / 2.4),
|
|
new THREE.MeshStandardMaterial({ color: 0xb8bcc0, roughness: 0.4, metalness: 0.7, side: THREE.DoubleSide }),
|
|
);
|
|
bowl.rotation.x = Math.PI;
|
|
const handle = new THREE.Mesh(
|
|
new THREE.BoxGeometry(1.3, 0.05, 0.1),
|
|
new THREE.MeshStandardMaterial({ color: 0xb8bcc0, roughness: 0.4, metalness: 0.7 }),
|
|
);
|
|
handle.position.set(0.85, 0.12, 0);
|
|
this.spoon.add(bowl, handle);
|
|
this.spoon.visible = false;
|
|
this.root.add(this.spoon);
|
|
// The real spoon rides INSIDE the group, so it inherits every move the
|
|
// lift animation makes; the procedural bowl+handle step aside.
|
|
void loadProp('/assets/models/slotted_spoon.glb', 1.9)
|
|
.then((prop) => {
|
|
// Measured, not guessed: the model is ~1.9 long in Z with the bowl at
|
|
// the +Z end, so it needs shifting back by half its length to put the
|
|
// BOWL — not the model's centre — under the egg.
|
|
prop.position.set(0, -0.14, -0.92);
|
|
bowl.visible = false;
|
|
handle.visible = false;
|
|
this.spoon.add(prop);
|
|
})
|
|
.catch(() => undefined);
|
|
}
|
|
|
|
/** A fresh pot on `fuel`, cold water, no egg. */
|
|
reset(fuel = 'gas', askText = 'one poached egg — white set, yolk trembling'): void {
|
|
this.session = newPoachSession(fuel);
|
|
this.lastAngle = null;
|
|
this.eggWhite.visible = false;
|
|
this.eggYolk.visible = false;
|
|
this.spoon.visible = false;
|
|
for (const r of this.ragBits) this.root.remove(r);
|
|
this.ragBits = [];
|
|
this.askEl.textContent = askText;
|
|
this.hintEl.textContent = 'WHEEL — flame · DRAG CIRCLES — spin the vortex · SPACE — drop · L — lift · hold to drain · ENTER serves';
|
|
}
|
|
|
|
enter(): void {
|
|
this.prevBg = this.app.scene.background;
|
|
this.app.scene.background = this.bgTex;
|
|
this.panel.show();
|
|
}
|
|
exit(): void {
|
|
this.app.scene.background = this.prevBg as never;
|
|
this.panel.hide();
|
|
}
|
|
|
|
update(dt: number): void {
|
|
this.app.camera.position.set(0, 3.6, 3.1);
|
|
this.app.camera.lookAt(0, POT_Y, 0);
|
|
const s = this.session;
|
|
if (!s) return;
|
|
const inp = this.app.input;
|
|
|
|
if (inp.wheel !== 0) {
|
|
const k = Math.max(0, Math.min(9, Math.round(s.src.target) + (inp.wheel > 0 ? 1 : -1)));
|
|
setPotKnob(s, k);
|
|
}
|
|
|
|
// Circular drag over the pot = the swirl (angular sweep around the centre).
|
|
this.ray.setFromCamera(inp.ndc, this.app.camera);
|
|
const on = this.ray.ray.intersectPlane(this.plane, this.hit);
|
|
if (inp.down && on && Math.hypot(this.hit.x, this.hit.z) < POT_R * 1.15) {
|
|
const ang = Math.atan2(this.hit.z, this.hit.x);
|
|
if (this.lastAngle !== null) {
|
|
let d = ang - this.lastAngle;
|
|
if (d > Math.PI) d -= Math.PI * 2;
|
|
if (d < -Math.PI) d += Math.PI * 2;
|
|
swirl(s, d);
|
|
if (Math.abs(d) > 0.02 && !s.dropped) audio.scrape(0.3, Math.abs(d) * 3);
|
|
}
|
|
this.lastAngle = ang;
|
|
} else {
|
|
this.lastAngle = null;
|
|
}
|
|
|
|
if (inp.justPressed('Space') && !s.dropped) {
|
|
dropEgg(s, 0);
|
|
this.eggWhite.visible = true;
|
|
this.eggYolk.visible = true;
|
|
audio.clatter(0.4, 0.9);
|
|
eye.mood(s.rags > 0.25 ? 'disappointed' : 'intrigued', 2);
|
|
// Rags appear immediately if the drop was bad.
|
|
this.spawnRags(s.rags);
|
|
}
|
|
if (inp.justPressed('KeyL') && s.dropped && !s.lifted) {
|
|
liftEgg(s);
|
|
this.spoon.visible = true;
|
|
audio.clunk(true);
|
|
this.hintEl.textContent = 'hold it on the spoon — let it DRAIN · ENTER serves';
|
|
}
|
|
if (s.lifted) drainStep(s, dt);
|
|
|
|
poachStep(s, dt);
|
|
// The pot narrates its own temperature: a low rumble that thickens as the
|
|
// water climbs, and bubbles that go from shy blips to a rolling argument.
|
|
const t01 = Math.min(1, s.src.output / 10);
|
|
audio.bed('water', 0.02 + t01 * 0.05, 260 + t01 * 480, 0.6, 0.45 + t01 * 0.2);
|
|
const bps = s.src.output > BOIL_AT ? 13 : s.src.output >= SHIVER_LO ? 3 : s.src.output > 4 ? 1.2 : 0.15;
|
|
if (Math.random() < bps * dt) audio.blip(t01);
|
|
// A rolling boil with an egg in it: he can see the white tearing from here.
|
|
if (s.dropped && !s.lifted && s.src.output > BOIL_AT) eye.mood('disappointed', 0.8);
|
|
this.syncVisuals(dt);
|
|
this.updateHud();
|
|
|
|
if (inp.justPressed('Enter') && s.dropped) {
|
|
const cb = this.onDone;
|
|
this.onDone = null;
|
|
cb?.(poachResult(s));
|
|
}
|
|
}
|
|
|
|
private spawnRags(rags: number): void {
|
|
const n = Math.round(rags * 6);
|
|
for (let i = this.ragBits.length; i < n; i++) {
|
|
const bit = new THREE.Mesh(
|
|
new THREE.SphereGeometry(0.07 + Math.random() * 0.06, 8, 6),
|
|
new THREE.MeshStandardMaterial({ color: 0xf0ece0, roughness: 0.6, transparent: true, opacity: 0.7 }),
|
|
);
|
|
const a = Math.random() * Math.PI * 2;
|
|
const r = POT_R * (0.4 + Math.random() * 0.45);
|
|
bit.scale.y = 0.4;
|
|
bit.position.set(Math.cos(a) * r, POT_Y + 0.12, Math.sin(a) * r);
|
|
this.ragBits.push(bit);
|
|
this.root.add(bit);
|
|
}
|
|
}
|
|
|
|
private syncVisuals(dt: number): void {
|
|
const s = this.session!;
|
|
const t = s.src.output;
|
|
// Flame answers the knob.
|
|
const fm = this.flame.material as THREE.MeshBasicMaterial;
|
|
fm.opacity = s.src.flame ? Math.min(0.8, t / 10) : 0;
|
|
this.flame.scale.y = 0.5 + t / 9;
|
|
|
|
// Vortex streaks spin with the swirl and fade with it.
|
|
this.streakAngle += s.vortex * dt * 7;
|
|
const sm = this.streaks[0].material as THREE.LineBasicMaterial;
|
|
sm.opacity = Math.min(0.85, s.vortex * 1.1);
|
|
this.streaks.forEach((line, i) => {
|
|
line.rotation.y = this.streakAngle * (1 + i * 0.15);
|
|
});
|
|
|
|
// Bubbles: none when still, a shy shiver in band, a jitterbug at boil.
|
|
const inBand = t >= SHIVER_LO && t <= SHIVER_HI;
|
|
const boiling = t > BOIL_AT;
|
|
for (const b of this.bubbles) {
|
|
const bm = b.material as THREE.MeshBasicMaterial;
|
|
bm.opacity = boiling ? 0.85 : inBand ? 0.35 : t > 4 ? 0.12 : 0;
|
|
if (boiling) b.position.y = POT_Y + 0.14 + Math.abs(Math.sin(s.seconds * 9 + b.position.x * 7)) * 0.09;
|
|
else b.position.y = POT_Y + 0.14 + (inBand ? Math.abs(Math.sin(s.seconds * 3.5 + b.position.z * 5)) * 0.02 : 0);
|
|
}
|
|
|
|
// The egg gathers as it sets: opacity and tightness track whiteSet; it rides
|
|
// the vortex; the spoon lifts it clear.
|
|
if (s.dropped) {
|
|
const em = this.eggWhite.material as THREE.MeshStandardMaterial;
|
|
em.opacity = 0.45 + s.whiteSet * 0.55;
|
|
const gather = 1.35 - s.whiteSet * 0.35 + s.rags * 0.3;
|
|
this.eggWhite.scale.set(gather, 0.5 + s.whiteSet * 0.12, gather * 0.9);
|
|
const ym = this.eggYolk.material as THREE.MeshStandardMaterial;
|
|
ym.color.setHex(s.yolkSet > 0.6 ? 0xd8c26a : 0xf0b428); // a hard yolk goes pale
|
|
const y = s.lifted ? POT_Y + 0.55 : POT_Y + 0.12;
|
|
this.eggWhite.position.set(0, y, 0);
|
|
this.eggYolk.position.set(0, y + 0.05, 0);
|
|
if (!s.lifted) {
|
|
this.eggWhite.rotation.y += s.vortex * dt * 4;
|
|
} else {
|
|
this.spoon.position.set(0, POT_Y + 0.42, 0);
|
|
// Drips while it drains.
|
|
this.eggWhite.position.y = POT_Y + 0.55;
|
|
}
|
|
}
|
|
}
|
|
|
|
private updateHud(): void {
|
|
const s = this.session!;
|
|
const r = poachResult(s);
|
|
const vortexWord = s.vortex > 0.6 ? 'spinning hard' : s.vortex > 0.25 ? 'a lazy spin' : 'still';
|
|
this.stateEl.textContent = `flame ${Math.round(s.src.target)} · ${waterWord(s)} · water: ${vortexWord}`;
|
|
if (!s.dropped) {
|
|
this.wordEl.textContent = s.vortex > 0.5 && s.src.output >= SHIVER_LO && s.src.output <= SHIVER_HI ? 'NOW — drop it in the eye' : '';
|
|
this.wordEl.style.color = '#8fce8f';
|
|
} else if (!s.lifted) {
|
|
const word = s.whiteSet < 0.55 ? 'the white is gathering…' : s.whiteSet < WHITE_SET ? 'nearly set…' : s.yolkSet <= YOLK_TREMBLE ? 'SET — lift it NOW (L)' : 'it is overstaying';
|
|
this.wordEl.textContent = word;
|
|
this.wordEl.style.color = s.whiteSet >= WHITE_SET && s.yolkSet <= YOLK_TREMBLE ? '#8fce8f' : s.yolkSet > YOLK_TREMBLE ? '#e2603a' : '';
|
|
} else {
|
|
this.wordEl.textContent = s.cling > 0.3 ? 'draining…' : `drained — serve it. ${r.wobbleWord}`;
|
|
this.wordEl.style.color = s.cling > 0.3 ? '' : '#8fce8f';
|
|
}
|
|
}
|
|
|
|
result() {
|
|
return this.session
|
|
? poachResult(this.session)
|
|
: { whiteScore: 0, yolkScore: 0, formScore: 0, drainScore: 0, whiteSet: 0, yolkSet: 0, rags: 0, cling: 1, vortexAtDrop: 0, wobbleWord: 'there is no egg' };
|
|
}
|
|
|
|
dispose(): void {
|
|
this.panel.dispose();
|
|
}
|
|
}
|