import * as THREE from 'three'; import type { App, View } from '../core/app'; import { audio } from '../core/audio'; import { eye } from '../ui/eye'; import { type FryerPotSession, newFryerPotSession, setOilKnob, basket, fryerPotStep, fryerPotResult, oilWord, chipWord, meanCooked, meanGold, BLANCH_LO, GOLD_AT, SPIT_AT, } from '../sim/fryer'; import { el, Panel } from '../ui/hud'; import { loadBackdrop } from './props'; import { loadProp } from './assets'; const POT_Y = 0.55; const POT_R = 1.05; /** * THE FRYER — the pot of oil, the basket, the rack, and where your hand is. * * WHEEL sets the flame under the pot (the oil answers SLOWLY — thermal mass * is the whole lesson). SPACE drops the basket in or lifts it to the rack. * The pointer is your HAND: raw chips in screaming oil spit, and a spit only * burns the hand parked over the pot. Stand back. ENTER serves. */ export class FryerPotView implements View { readonly root = new THREE.Group(); private session: FryerPotSession | null = null; private flame!: THREE.Mesh; private oilSurf!: THREE.Mesh; private basketGrp!: THREE.Group; private chipMeshes: THREE.Mesh[] = []; private rack!: THREE.Mesh; private spitFlashes: { m: THREE.Mesh; t: number }[] = []; private lastSpits = 0; private lastBurns = 0; private ray = new THREE.Raycaster(); private hit = new THREE.Vector3(); private plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -(POT_Y + 0.15)); private bgTex = loadBackdrop('/assets/img/bg_pot.png'); private prevBg: unknown = null; private panel: Panel; private askEl!: HTMLElement; private cardEl!: HTMLElement; private stateEl!: HTMLElement; private wordEl!: HTMLElement; private hintEl!: HTMLElement; onDone: ((result: ReturnType) => void) | null = null; constructor(private app: App) { this.buildScenery(); this.panel = new Panel('fryerpot-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, 'FRY IT. TWICE.'); this.askEl = el('div', 'slicer-ask', card, ''); this.cardEl = el('div', 'fryer-card', 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); const burner = new THREE.Mesh( new THREE.CylinderGeometry(0.8, 0.9, 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.6, 0.42, 20), new THREE.MeshBasicMaterial({ color: 0x3a7bff, transparent: true, opacity: 0 }), ); this.flame.position.y = 0.3; this.root.add(this.flame); // The pot — deeper and darker than the poach pot. It has opinions. const pot = new THREE.Mesh( new THREE.CylinderGeometry(POT_R, POT_R * 0.9, 0.85, 32, 1, true), new THREE.MeshStandardMaterial({ color: 0x2e2e33, roughness: 0.45, metalness: 0.75, side: THREE.DoubleSide }), ); pot.position.y = POT_Y - 0.05; this.root.add(pot); // The oil surface stays procedural — it is the thermometer, and it has to // shimmer, darken and be readable. Only the vessel gets replaced. void loadProp('/assets/models/fryer_pot.glb', 2.05) .then((prop) => { prop.position.set(0, 0.04, 0); pot.visible = false; this.root.add(prop); }) .catch(() => undefined); // Sized to the GLB pot's inner rim, not the understudy cylinder's — the // oil poking out past the steel was the giveaway. this.oilSurf = new THREE.Mesh( new THREE.CylinderGeometry(POT_R * 0.64, POT_R * 0.64, 0.05, 32), new THREE.MeshStandardMaterial({ color: 0xc9a23a, roughness: 0.15, metalness: 0.3, transparent: true, opacity: 0.9 }), ); this.oilSurf.position.y = POT_Y + 0.2; this.root.add(this.oilSurf); // The basket: a wire cage on a long handle, chips inside. this.basketGrp = new THREE.Group(); const cage = new THREE.Mesh( new THREE.CylinderGeometry(0.62, 0.5, 0.42, 16, 1, true), new THREE.MeshStandardMaterial({ color: 0x9aa0a6, roughness: 0.35, metalness: 0.8, side: THREE.DoubleSide, transparent: true, opacity: 0.55 }), ); const handle = new THREE.Mesh( new THREE.BoxGeometry(1.5, 0.05, 0.09), new THREE.MeshStandardMaterial({ color: 0x1b1b1b, roughness: 0.6 }), ); handle.position.set(1.35, 0.18, 0); this.basketGrp.add(cage, handle); this.root.add(this.basketGrp); void loadProp('/assets/models/fryer_basket.glb', 1.7) .then((prop) => { // Sits proud of the oil line: a basket you cannot see is a basket you // cannot judge. The handle stays procedural — it reads as the thing // your hand is on. prop.position.set(0, -0.02, 0); cage.visible = false; this.basketGrp.add(prop); }) .catch(() => undefined); // The rack, stage left — where the resting happens. this.rack = new THREE.Mesh( new THREE.BoxGeometry(1.5, 0.06, 1.1), new THREE.MeshStandardMaterial({ color: 0x8a6a3a, roughness: 0.8 }), ); this.rack.position.set(-2.5, 0.45, 0.6); this.rack.receiveShadow = true; this.root.add(this.rack); void loadProp('/assets/models/drain_rack.glb', 1.8) .then((prop) => { prop.position.set(-2.5, 0.4, 0.6); this.rack.visible = false; this.root.add(prop); }) .catch(() => undefined); } reset(count = 8, cardText = '', askText = 'chips — twice. once for cooked, once for golden'): void { this.session = newFryerPotSession(count); this.lastSpits = 0; this.lastBurns = 0; for (const m of this.chipMeshes) this.root.remove(m); for (const f of this.spitFlashes) this.root.remove(f.m); this.chipMeshes = []; this.spitFlashes = []; for (let i = 0; i < count; i++) { const chip = new THREE.Mesh( new THREE.BoxGeometry(0.34, 0.11, 0.11), new THREE.MeshStandardMaterial({ color: 0xf2e6c4, roughness: 0.75 }), ); chip.castShadow = true; this.chipMeshes.push(chip); this.root.add(chip); } // Same sticky-word trap as the mandoline: updateHud deliberately refuses // to overwrite the accident line, so a fresh session must clear it or the // next cook is told about an injury that has not happened. this.wordEl.textContent = ''; this.wordEl.style.color = ''; this.askEl.textContent = askText; this.cardEl.textContent = cardText; this.hintEl.textContent = 'WHEEL — flame · SPACE — basket in / out to the rack · keep your HAND off the pot when it spits · 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.3, 3.6); this.app.camera.lookAt(-0.4, POT_Y, 0); const s = this.session; if (!s) return; const inp = this.app.input; if (inp.wheel !== 0) setOilKnob(s, Math.round(s.knob) + (inp.wheel > 0 ? 1 : -1)); if (inp.justPressed('Space')) { basket(s, !s.basketIn); audio.clatter(0.5, s.basketIn ? 0.7 : 1.1); } // The hand: pointer projected onto the pot's mouth. this.ray.setFromCamera(inp.ndc, this.app.camera); const on = this.ray.ray.intersectPlane(this.plane, this.hit); const handOverPot = !!on && Math.hypot(this.hit.x, this.hit.z) < POT_R * 1.25; fryerPotStep(s, dt, handOverPot); // Spit events arrive from the sim as counters — flash and sting here. if (s.spits > this.lastSpits) { this.lastSpits = s.spits; audio.clatter(0.9, 2.2); const fl = new THREE.Mesh( new THREE.SphereGeometry(0.05, 6, 5), new THREE.MeshBasicMaterial({ color: 0xffd27a }), ); const a = Math.random() * Math.PI * 2; fl.position.set(Math.cos(a) * POT_R * 0.7, POT_Y + 0.3, Math.sin(a) * POT_R * 0.7); this.root.add(fl); this.spitFlashes.push({ m: fl, t: 0.5 }); } if (s.burns > this.lastBurns) { this.lastBurns = s.burns; audio.fart(s.burns * 3 + 1); // the yelp, in the house voice eye.mood('horrified', 3); this.wordEl.textContent = 'it CAUGHT you — the hand was over the pot'; this.wordEl.style.color = '#e2603a'; } // Foley: the oil's own bed — bubbles rise with temp, frying adds sizzle. const oilN = s.oil / 10; audio.bed('oil', 0.015 + oilN * 0.05, 220 + oilN * 420, 0.6, 0.5 + oilN * 0.4); if (s.basketIn) audio.bed('sizzle', 0.05 + oilN * 0.09, 4600, 0.8, 0.9); else audio.bed('sizzle', 0, 4600); if (s.oil > SPIT_AT && Math.random() < 3 * dt) audio.blip(oilN); this.syncVisuals(dt); this.updateHud(); if (inp.justPressed('Enter') && !s.basketIn && s.fries > 0) { audio.bed('oil', 0, 300); audio.bed('sizzle', 0, 4600); const cb = this.onDone; this.onDone = null; cb?.(fryerPotResult(s)); } } private syncVisuals(dt: number): void { const s = this.session!; const oilN = s.oil / 10; const fm = this.flame.material as THREE.MeshBasicMaterial; fm.opacity = Math.min(0.85, s.knob / 9); this.flame.scale.y = 0.5 + (s.knob / 9) * 1.1; // The oil: pale gold cold, darker and livelier hot; shimmer past blanch. const om = this.oilSurf.material as THREE.MeshStandardMaterial; om.color.setHex(0xc9a23a).lerp(new THREE.Color(0x7a5a18), oilN); this.oilSurf.position.y = POT_Y + 0.12 + (s.oil > BLANCH_LO ? Math.sin(s.seconds * (s.oil > GOLD_AT ? 14 : 7)) * 0.008 : 0); // The basket travels: rack (out) ⇄ pot (in). const target = s.basketIn ? { x: 0, y: POT_Y + 0.05 } : { x: -2.5, y: 0.62 }; this.basketGrp.position.x += (target.x - this.basketGrp.position.x) * (1 - Math.exp(-dt / 0.08)); this.basketGrp.position.y += (target.y - this.basketGrp.position.y) * (1 - Math.exp(-dt / 0.08)); this.basketGrp.position.z = s.basketIn ? 0 : 0.6; for (let i = 0; i < this.chipMeshes.length; i++) { const c = s.chips[i]; const m = this.chipMeshes[i]; const ang = (i / this.chipMeshes.length) * Math.PI * 2; const r = 0.3 + (i % 3) * 0.09; m.position.set( this.basketGrp.position.x + Math.cos(ang) * r, this.basketGrp.position.y + 0.1 + (i % 2) * 0.06, this.basketGrp.position.z + Math.sin(ang) * r, ); m.rotation.y = ang * 2; const col = new THREE.Color(0xf2e6c4).lerp(new THREE.Color(0x8a5a1e), Math.min(1, c.gold * 1.2)); if (c.gold > 0.85) col.lerp(new THREE.Color(0x2a1c10), (c.gold - 0.85) * 5); col.lerp(new THREE.Color(0xb8a878), c.grease * 0.5); // greasy = sad sheen (m.material as THREE.MeshStandardMaterial).color = col; } for (const f of [...this.spitFlashes]) { f.t -= dt; f.m.position.y += dt * 2.2; (f.m.material as THREE.MeshBasicMaterial).color.setHex(f.t > 0.25 ? 0xffd27a : 0xa86a2a); if (f.t <= 0) { this.root.remove(f.m); this.spitFlashes.splice(this.spitFlashes.indexOf(f), 1); } } } private updateHud(): void { const s = this.session!; this.stateEl.textContent = `flame ${Math.round(s.knob)} · fry №${s.fries}${s.basketIn ? ' — basket DOWN' : s.fries > 0 ? ' — on the rack' : ''} · inside ${Math.round(meanCooked(s) * 100)}% · colour ${Math.round(meanGold(s) * 100)}%${s.spits ? ` · ${s.spits} spits` : ''}${s.burns ? ` · ${s.burns} CAUGHT you` : ''}`; if (!this.wordEl.textContent?.includes('CAUGHT')) { this.wordEl.textContent = `${oilWord(s)} · ${chipWord(s)}`; this.wordEl.style.color = ''; } } result(): ReturnType { return fryerPotResult(this.session!); } }