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) => 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(); } }