Heat M-H6 scene: the charcoal grill, top-down and glowing
scenes/grill.ts (GrillView) — the coal bed rendered as an additive glow texture from the zone Field, food pieces you drag between the sear zone and the cool lee, flares that lick up when fat renders over hot coals. Registered in game.ts (hidden, in hideAllScenes, grillView getter). Harness t.grillDemo drives the real scene end-to-end. Verified in-browser: banked a bright hot-left bed, placed food across the gradient, cooked through the real View — bed glows, food browns, flares fire. Screenshot confirms the two-zone gradient reads at a glance. Framing fix: glow above the grate, camera overhead, low open bowl (the kettle GLB is a closed dome that occludes a top-down cook). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
81c0151975
commit
ff1c3deafc
30
src/dev.ts
30
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) {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
283
src/scenes/grill.ts
Normal file
283
src/scenes/grill.ts
Normal file
@ -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<typeof grillResult>) => 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();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user