diff --git a/src/dev.ts b/src/dev.ts index e2f08ce..c3cd9f6 100644 --- a/src/dev.ts +++ b/src/dev.ts @@ -621,6 +621,36 @@ export function installDevHarness(app: App, game: Game): void { return { indoorFires: fires, outdoorFires: outFires, finalSmoke: +env.smoke.toFixed(2) }; }, + /** Drive the live grill scene: bank a hot-left/cool-right bed, sear a piece + * over the coals then shuffle it to the lee, hold another on the cool side. + * For a screenshot + an end-to-end check of the real scene path. */ + grillDemo() { + const view = game.grillView as unknown as { + reset(ids: string[], ask?: string): void; + result(): { mean: number; stdev: number; searedFrac: number; charFrac: number; flares: number }; + }; + const sess = () => (game.grillView as unknown as { session: import('./sim/charcoal').GrillSession }).session; + view.reset(['steak', 'snag']); + app.setView(game.grillView); + run(3); + const s = sess(); + // Bank the coals hot on the left, cool on the right. + for (let i = 0; i < 30; i++) { + s.zone.brush(0.28, 0.5, 0.2, (idx: number, w: number) => { + s.zone.data[idx] = Math.min(1.8, s.zone.data[idx] + 0.12 * w); + }); + } + // Place the steak over the coals, the snag on the lee. + s.foods[0].u = 0.28; s.foods[0].v = 0.5; + s.foods[1].u = 0.8; s.foods[1].v = 0.5; + run(60 * 14); // sear + s.foods[0].u = 0.8; // shuffle the steak to the lee before it flares out + run(60 * 4); + const res = view.result(); + const r = (n: number) => +n.toFixed(3); + return { mean: r(res.mean), stdev: r(res.stdev), searedFrac: r(res.searedFrac), charFrac: r(res.charFrac), flares: res.flares }; + }, + /** Put the ACTUAL oven scene on screen wearing a fuel, and roast it — for a * live screenshot of the gas-oven vs fan-oven HUD. */ ovenDemo(fuel = 'oven_gas', seconds = 24, power = 8) { diff --git a/src/game/game.ts b/src/game/game.ts index 50531fb..9fbcd2b 100644 --- a/src/game/game.ts +++ b/src/game/game.ts @@ -5,6 +5,7 @@ import { SlicerView } from '../scenes/slicer'; import { PrepView } from '../scenes/prep'; import { JuiceView } from '../scenes/juice'; import { OvenView } from '../scenes/oven'; +import { GrillView } from '../scenes/grill'; import { AssemblyView } from '../scenes/assembly'; import type { CutPattern } from '../sim/cutting'; import type { IngredientId } from '../sim/ingredients'; @@ -47,6 +48,7 @@ export class Game { private prep: PrepView; private juice: JuiceView; private oven: OvenView; + private grill: GrillView; private assembly: AssemblyView; /** What the prep bench reported for the current order, if it ran. */ private prepResult: PrepResult | null = null; @@ -81,6 +83,7 @@ export class Game { this.prep = new PrepView(app); this.juice = new JuiceView(app); this.oven = new OvenView(app); + this.grill = new GrillView(app); this.assembly = new AssemblyView(app); app.scene.add(this.kitchen.root); app.scene.add(this.judgeView.root); @@ -89,6 +92,7 @@ export class Game { app.scene.add(this.prep.root); app.scene.add(this.juice.root); app.scene.add(this.oven.root); + app.scene.add(this.grill.root); app.scene.add(this.assembly.root); this.judgeView.root.visible = false; this.drawer.root.visible = false; @@ -96,6 +100,7 @@ export class Game { this.prep.root.visible = false; this.juice.root.visible = false; this.oven.root.visible = false; + this.grill.root.visible = false; this.assembly.root.visible = false; this.ticket = new Panel('ticket'); @@ -186,6 +191,9 @@ export class Game { get ovenView(): OvenView { return this.oven; } + get grillView(): GrillView { + return this.grill; + } get assemblyView(): AssemblyView { return this.assembly; } @@ -352,6 +360,7 @@ export class Game { this.prep.root.visible = false; this.juice.root.visible = false; this.oven.root.visible = false; + this.grill.root.visible = false; this.assembly.root.visible = false; } diff --git a/src/scenes/grill.ts b/src/scenes/grill.ts new file mode 100644 index 0000000..3e38c38 --- /dev/null +++ b/src/scenes/grill.ts @@ -0,0 +1,283 @@ +import * as THREE from 'three'; +import type { App, View } from '../core/app'; +import { audio } from '../core/audio'; +import { + type GrillSession, + newGrillSession, + bankCoals, + placeFood, + stoke, + grillStep, + grillResult, + grillWord, + bedCeiling, + RENDER_AT, + CHAR_AT, + BED_MAX, +} from '../sim/charcoal'; +import { newOutdoorEnv } from '../sim/heatsource'; +import { el, Panel } from '../ui/hud'; + +const GRATE_Y = 0.5; +const GRATE_W = 3.6; +const GRATE_D = 2.2; + +/** + * THE CHARCOAL GRILL — the fuel with no knob, made playable. + * + * Drag on the grate to BANK coals (pile heat where you drag — build a screaming + * zone and a cool lee). Drag a piece of food to move it across that gradient: + * sear it over the coals, then shuffle it to the lee before the dripping fat + * flares and chars it. The bed only cools. Sear early, hold late. Move it or + * lose it. ENTER serves. + */ +export class GrillView implements View { + readonly root = new THREE.Group(); + + private session: GrillSession | null = null; + private grate!: THREE.Mesh; + private bedTexData: Uint8Array; + private bedTex: THREE.DataTexture; + private foodMeshes: THREE.Mesh[] = []; + private flameMeshes: THREE.Mesh[] = []; + + private ray = new THREE.Raycaster(); + private hit = new THREE.Vector3(); + private gratePlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -(GRATE_Y + 0.02)); + private grabbed = -1; + private wasDown = false; + + private panel: Panel; + private askEl!: HTMLElement; + private stateEl!: HTMLElement; + private wordEl!: HTMLElement; + private hintEl!: HTMLElement; + + onDone: ((result: ReturnType) => void) | null = null; + + constructor(private app: App) { + this.buildScenery(); + const n = 48; + this.bedTexData = new Uint8Array(n * n * 4); + this.bedTex = new THREE.DataTexture(this.bedTexData, n, n, THREE.RGBAFormat); + this.bedTex.minFilter = THREE.LinearFilter; + this.bedTex.magFilter = THREE.LinearFilter; + const glow = new THREE.Mesh( + new THREE.PlaneGeometry(GRATE_W, GRATE_D), + new THREE.MeshBasicMaterial({ map: this.bedTex, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending }), + ); + glow.rotation.x = -Math.PI / 2; + // Above the grate so the top-down camera sees the coals glowing through it. + glow.position.y = GRATE_Y + 0.03; + this.root.add(glow); + + this.panel = new Panel('grill-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, 'GRILL 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 ground = new THREE.Mesh( + new THREE.BoxGeometry(30, 0.4, 20), + new THREE.MeshStandardMaterial({ color: 0x5a6b3e, roughness: 0.95 }), + ); + ground.position.y = -0.2; + ground.receiveShadow = true; + this.root.add(ground); + + // The grate: dark bars the food sits on, over the coal bed. + this.grate = new THREE.Mesh( + new THREE.BoxGeometry(GRATE_W, 0.03, GRATE_D), + new THREE.MeshStandardMaterial({ color: 0x181818, roughness: 0.6, metalness: 0.5 }), + ); + this.grate.position.y = GRATE_Y; + this.grate.receiveShadow = true; + this.root.add(this.grate); + + // A low open bowl to hold the coals — we cook top-down with the lid off, so + // a shallow rim reads better than the full kettle GLB (which is a dome that + // occludes the grate from above; kept for a future side-on shot). + const bowl = new THREE.Mesh( + new THREE.CylinderGeometry(GRATE_W * 0.6, GRATE_W * 0.42, 0.36, 28, 1, true), + new THREE.MeshStandardMaterial({ color: 0x1a1a1a, roughness: 0.8, metalness: 0.3, side: THREE.DoubleSide }), + ); + bowl.position.y = GRATE_Y - 0.2; + bowl.receiveShadow = true; + this.root.add(bowl); + const legs = new THREE.Mesh( + new THREE.CylinderGeometry(GRATE_W * 0.5, GRATE_W * 0.5, 0.5, 3, 1, true), + new THREE.MeshStandardMaterial({ color: 0x141414, roughness: 0.9 }), + ); + legs.position.y = 0.0; + this.root.add(legs); + } + + /** A grill of `foodIds` and a fresh cold bed you build yourself. */ + reset(foodIds = ['steak', 'sausage', 'corn'], askText = 'grill it — sear then move it to the lee'): void { + this.session = newGrillSession(foodIds, 20260719, newOutdoorEnv(0.35)); + this.grabbed = -1; + + for (const m of this.foodMeshes) this.root.remove(m); + for (const m of this.flameMeshes) this.root.remove(m); + this.foodMeshes = []; + this.flameMeshes = []; + for (const f of this.session.foods) { + const mesh = new THREE.Mesh( + new THREE.BoxGeometry(0.34, 0.14, 0.24), + new THREE.MeshStandardMaterial({ color: 0xc98a6a, roughness: 0.7 }), + ); + mesh.castShadow = true; + // Start off to the side, on the cool rail — you drag them onto the coals. + f.u = 0.5; + f.v = 1.15; // off the grate (v>1) until placed + this.root.add(mesh); + this.foodMeshes.push(mesh); + const flame = new THREE.Mesh( + new THREE.ConeGeometry(0.12, 0.4, 8), + new THREE.MeshBasicMaterial({ color: 0xff7a1a, transparent: true, opacity: 0 }), + ); + this.root.add(flame); + this.flameMeshes.push(flame); + } + this.askEl.textContent = askText; + this.hintEl.textContent = 'DRAG the grate to bank coals · DRAG food across the heat · ENTER serves'; + } + + enter(): void { + this.panel.show(); + } + exit(): void { + this.panel.hide(); + } + + private foodWorld(u: number, v: number): THREE.Vector3 { + return new THREE.Vector3(u * GRATE_W - GRATE_W / 2, GRATE_Y + 0.09, v * GRATE_D - GRATE_D / 2); + } + private uvFromWorld(p: THREE.Vector3): { u: number; v: number } { + return { u: (p.x + GRATE_W / 2) / GRATE_W, v: (p.z + GRATE_D / 2) / GRATE_D }; + } + + update(dt: number): void { + // High and a touch forward — you look down INTO the open grill at the coals. + this.app.camera.position.set(0, 4.3, 1.9); + this.app.camera.lookAt(0, GRATE_Y, 0.05); + const s = this.session; + if (!s) return; + const inp = this.app.input; + + this.ray.setFromCamera(inp.ndc, this.app.camera); + const onPlane = this.ray.ray.intersectPlane(this.gratePlane, this.hit); + + if (inp.down && onPlane) { + const { u, v } = this.uvFromWorld(this.hit); + if (!this.wasDown) { + // Start of a drag: grab the nearest food if we're on one, else bank coals. + this.grabbed = this.nearestFood(u, v, 0.12); + } + if (this.grabbed >= 0) { + placeFood(s, this.grabbed, u, v); + } else { + // Bank coals: pile heat where you drag (clamped by the sim). + bankCoals(s, u, v, 0.16, 2.4 * dt * 6); + } + } + if (!inp.down) this.grabbed = -1; + this.wasDown = inp.down; + + if (inp.justPressed('KeyF')) { + stoke(s); + audio.clatter(0.4, 1.4); + } + if (inp.justPressed('KeyL')) { + s.env.sheltered = !s.env.sheltered; + } + + grillStep(s, dt); + this.syncBed(); + this.syncFood(); + this.updateHud(); + + if (inp.justPressed('Enter')) { + const cb = this.onDone; + this.onDone = null; + cb?.(grillResult(s)); + } + } + + private nearestFood(u: number, v: number, r: number): number { + const s = this.session!; + let best = -1; + let bd = r; + for (let i = 0; i < s.foods.length; i++) { + const d = Math.hypot(s.foods[i].u - u, s.foods[i].v - v); + if (d < bd) { + bd = d; + best = i; + } + } + return best; + } + + /** Paint the coal bed into the glow texture — dark ember to white-hot. */ + private syncBed(): void { + const d = this.session!.zone.data; + const px = this.bedTexData; + for (let i = 0; i < d.length; i++) { + const h = Math.min(1, d[i] / BED_MAX); + // ember → orange → yellow-white + px[i * 4] = Math.round(60 + 195 * h); + px[i * 4 + 1] = Math.round(10 + 150 * h * h); + px[i * 4 + 2] = Math.round(10 + 120 * h * h * h); + px[i * 4 + 3] = Math.round(Math.min(1, h * 1.3) * 235); + } + this.bedTex.needsUpdate = true; + } + + private syncFood(): void { + const s = this.session!; + for (let i = 0; i < s.foods.length; i++) { + const f = s.foods[i]; + const mesh = this.foodMeshes[i]; + mesh.position.copy(this.foodWorld(f.u, f.v)); + // Colour by doneness: raw pink → seared brown → black char. + const done = Math.min(1, f.sear + f.flareChar); + const c = new THREE.Color(); + if (done < RENDER_AT) c.setRGB(0.79, 0.54, 0.42); + else if (done < CHAR_AT) c.lerpColors(new THREE.Color(0.5, 0.32, 0.2), new THREE.Color(0.25, 0.15, 0.09), (done - RENDER_AT) / (CHAR_AT - RENDER_AT)); + else c.setRGB(0.08, 0.07, 0.06); + (mesh.material as THREE.MeshStandardMaterial).color.copy(c); + // A flame licks up when the fat's flaring. + const flame = this.flameMeshes[i]; + flame.position.copy(mesh.position).setY(GRATE_Y + 0.28); + (flame.material as THREE.MeshBasicMaterial).opacity = f.fat > 0.4 && this.session!.zone.sample(f.u, f.v) > 0.8 ? Math.min(0.85, f.fat) : 0; + flame.scale.setScalar(0.7 + f.fat * 0.6); + } + } + + private updateHud(): void { + const s = this.session!; + const r = grillResult(s); + this.stateEl.textContent = `coals ${bedCeiling(s).toFixed(1)}${s.env.sheltered ? ' · lid on' : ''} · ${Math.round(s.seconds)}s${s.flares > 0 ? ` · ${s.flares} flare${s.flares > 1 ? 's' : ''}` : ''}`; + const word = grillWord(r); + this.wordEl.textContent = `the grill: ${word}`; + this.wordEl.style.color = r.charFrac > 0.2 ? '#e2603a' : r.searedFrac > 0.6 ? '#8fce8f' : ''; + } + + result() { + return this.session ? grillResult(this.session) : { mean: 0, stdev: 0, searedFrac: 0, charFrac: 0, flares: 0, ash: 0 }; + } + + dispose(): void { + this.panel.dispose(); + } +}