toastsim/src/scenes/kitchen.ts
type-two 6f74d31469 Kitchen panel no longer leaks over station scenes
It was born visible — a day starting at a station (prep/juice/grate)
never passes through kitchen.enter()/exit(), so the BROWNING panel sat
over every station HUD. Hidden at construction like every other scene;
verified hidden at bench, shown after handoff, hidden at juice.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 15:18:06 +10:00

612 lines
21 KiB
TypeScript

import * as THREE from 'three';
import type { App, View } from '../core/app';
import { Rng } from '../core/rng';
import { Slice } from '../sim/slice';
import { BREADS, brandOf, type BreadId } from '../sim/bread';
import { buildHeatMap, readToast, smellCue, toastStep } from '../sim/toasting';
import {
SPREADS,
SCRAPE_ANGLE,
amountClassOf,
effectiveYield,
pressureFromAngle,
type AmountClass,
type SpreadDef,
type SpreadId,
} from '../sim/spreads';
import { COVER_FLOOR } from '../game/judging';
import { dip, newKnife, stroke, type Knife, type StrokeFx } from '../sim/spreading';
import type { SliceCut } from '../sim/slicing';
import { TOOLS, type ToolId } from '../sim/cutlery';
import { makeBench, makePlate, makeToaster, type Toaster } from './props';
import { KnifeRig, makeSpreadPot } from './spreadrig';
import { el, Panel } from '../ui/hud';
import { audio } from '../core/audio';
import type { Order } from '../game/orders';
const TOASTER_X = -1.5;
const PLATE_X = 1.45;
const PLATE_Z = 0.55;
const PLATE_Y = 0.05;
const POT_X = 3.15;
const POT_Z = 0.45;
export type Phase = 'ready' | 'toasting' | 'flying' | 'landed' | 'spreading';
interface CamShot {
pos: THREE.Vector3;
look: THREE.Vector3;
}
const SHOTS: Record<string, CamShot> = {
toast: { pos: new THREE.Vector3(-1.1, 3.0, 4.9), look: new THREE.Vector3(-1.3, 0.85, -0.2) },
spread: { pos: new THREE.Vector3(2.05, 2.4, 3.0), look: new THREE.Vector3(2.05, 0.1, 0.35) },
};
/** The bench: toaster, plate, pot, knife. Runs the toasting and spreading phases. */
export class KitchenView implements View {
readonly root = new THREE.Group();
private toaster: Toaster;
private slice!: Slice;
private heat!: Float32Array;
private rng: Rng;
phase: Phase = 'ready';
power = 6;
/** 0 = straight from the fridge, 1 = left out on the bench all morning. */
butterSoftness = 0.15;
toolId: ToolId = 'butter_knife';
spreadId: SpreadId = 'butter';
private leverT = 0;
private vel = new THREE.Vector3();
private spin = 0;
private toastSeconds = 0;
knife: Knife = newKnife();
/** Brand quality for the current order's bread. */
private quality = 0.7;
/** The cut, when this order's slice came off a loaf. */
private cut: SliceCut | null = null;
/** How badly the jar has separated right now. 0 = mixed, 1 = oil on top. */
strat = 0;
private dipsSinceStir = 0;
private stirPrev: number | null = null;
private wasDipping = false;
private knifeRig: KnifeRig;
private pot: THREE.Group | null = null;
private potHit: THREE.Mesh | null = null;
private ray = new THREE.Raycaster();
private hitPoint = new THREE.Vector3();
private uv = new THREE.Vector2();
private plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
lastFx: StrokeFx | null = null;
private camPos = new THREE.Vector3();
private camLook = new THREE.Vector3();
private shot: CamShot = SHOTS.toast;
private panel: Panel;
private cueEl!: HTMLElement;
private hintEl!: HTMLElement;
private dialRow!: HTMLElement;
private gaugeFill!: HTMLElement;
private gaugeYield!: HTMLElement;
private gaugeScrape!: HTMLElement;
private modeEl!: HTMLElement;
private loadFill!: HTMLElement;
private jarRow!: HTMLElement;
private jarFill!: HTMLElement;
private amountEl!: HTMLElement;
/** What the current order asked for — drives the live readout. */
private askedAmount: AmountClass | null = null;
private amountTick = 0;
private breadSel!: HTMLSelectElement;
private spreadSel!: HTMLSelectElement;
private toolSel!: HTMLSelectElement;
private softInput!: HTMLInputElement;
private softVal!: HTMLElement;
/** Fired when the player hands the plate over. */
onServed: (() => void) | null = null;
/** Fired once the slice has landed on the plate — the drawer opens here. */
onPopped: (() => void) | null = null;
constructor(
private app: App,
seed = 1,
) {
this.rng = new Rng(seed);
this.root.add(makeBench());
this.toaster = makeToaster();
this.toaster.root.position.set(TOASTER_X, 0, -0.35);
this.root.add(this.toaster.root);
const plate = makePlate();
plate.position.set(PLATE_X, 0, PLATE_Z);
this.root.add(plate);
this.knifeRig = new KnifeRig(TOOLS[this.toolId]);
this.knifeRig.root.visible = false;
this.root.add(this.knifeRig.root);
this.camPos.copy(SHOTS.toast.pos);
this.camLook.copy(SHOTS.toast.look);
this.panel = new Panel('toast-panel');
this.buildUi();
// Hidden until enter() — a day that starts at a station (prep/juice/grate)
// never passes through the kitchen, and a born-visible panel leaks over it.
this.panel.hide();
this.loadBread('white');
}
private buildUi(): void {
const p = this.panel.root;
this.dialRow = el('div', 'row', p);
el('label', 'lbl', this.dialRow, 'BROWNING');
const dial = el('input', 'dial', this.dialRow);
dial.type = 'range';
dial.min = '1';
dial.max = '10';
dial.step = '1';
dial.value = String(this.power);
const val = el('span', 'val', this.dialRow, String(this.power));
dial.addEventListener('input', () => {
this.power = +dial.value;
val.textContent = dial.value;
this.toaster.dial.rotation.x = -((this.power - 1) / 9) * 2.4;
});
const row2 = el('div', 'row', p);
el('label', 'lbl', row2, 'BREAD');
const breadSel = el('select', 'sel', row2);
this.breadSel = breadSel;
for (const id of Object.keys(BREADS)) {
const o = el('option', undefined, breadSel, BREADS[id as BreadId].name);
o.value = id;
}
breadSel.addEventListener('change', () => this.loadBread(breadSel.value as BreadId));
const row3 = el('div', 'row', p);
el('label', 'lbl', row3, 'SPREAD');
const spreadSel = el('select', 'sel', row3);
this.spreadSel = spreadSel;
for (const id of Object.keys(SPREADS)) {
const o = el('option', undefined, spreadSel, SPREADS[id as SpreadId].name);
o.value = id;
}
spreadSel.addEventListener('change', () => this.setSpread(spreadSel.value as SpreadId));
const row4 = el('div', 'row', p);
el('label', 'lbl', row4, 'TOOL');
const toolSel = el('select', 'sel', row4);
this.toolSel = toolSel;
for (const id of Object.keys(TOOLS)) {
const o = el('option', undefined, toolSel, TOOLS[id as ToolId].name);
o.value = id;
}
toolSel.addEventListener('change', () => {
this.toolId = toolSel.value as ToolId;
this.knifeRig.setTool(TOOLS[this.toolId]);
});
const row5 = el('div', 'row', p);
el('label', 'lbl', row5, 'BUTTER');
const soft = el('input', 'dial', row5);
this.softInput = soft;
soft.type = 'range';
soft.min = '0';
soft.max = '100';
soft.value = String(this.butterSoftness * 100);
const softVal = el('span', 'val', row5, 'hard');
this.softVal = softVal;
soft.addEventListener('input', () => {
this.butterSoftness = +soft.value / 100;
softVal.textContent =
this.butterSoftness > 0.66 ? 'soft' : this.butterSoftness > 0.33 ? 'ok' : 'hard';
});
this.jarRow = el('div', 'row', p);
el('label', 'lbl', this.jarRow, 'JAR');
const jarBar = el('div', 'load', this.jarRow);
jarBar.style.flex = '1';
this.jarFill = el('div', 'load-fill', jarBar);
// The pressure gauge — the mechanic made legible. The fill is the pressure
// your wrist angle is producing; the gold mark is what this spread needs in
// order to flow; the red mark is where the knife stops spreading and starts
// scraping. When gold sits past red, no angle can win, and the answer isn't
// technique — it's warmer toast.
const gauge = el('div', 'gauge', p);
this.gaugeFill = el('div', 'gauge-fill', gauge);
this.gaugeYield = el('div', 'gauge-mark yield', gauge);
this.gaugeScrape = el('div', 'gauge-mark scrape', gauge);
this.modeEl = el('div', 'mode', p, '');
const loadBar = el('div', 'load', p);
this.loadFill = el('div', 'load-fill', loadBar);
this.amountEl = el('div', 'amount', p, '');
this.cueEl = el('div', 'cue', p, 'smells like bread');
this.hintEl = el('div', 'hint', p, 'SPACE — push the lever down');
this.setSpread(this.spreadId);
}
setSpread(id: SpreadId): void {
this.spreadId = id;
const def = SPREADS[id];
this.strat = def.separates ?? 0;
this.dipsSinceStir = 0;
this.stirPrev = null;
this.wasDipping = false;
this.slice?.setSpread(def);
this.knifeRig.setSpread(def);
if (this.pot) this.root.remove(this.pot);
const { root, hit } = makeSpreadPot(def);
root.position.set(POT_X, 0, POT_Z);
this.pot = root;
this.potHit = hit;
this.pot.visible = this.phase === 'spreading';
this.root.add(root);
}
loadBread(id: BreadId): void {
if (this.slice) {
this.root.remove(this.slice.mesh);
this.slice.dispose();
}
this.slice = new Slice(BREADS[id], new Rng(this.rng.int(1, 1e6)), {
quality: this.quality,
cut: this.cut,
});
this.slice.setSpread(SPREADS[this.spreadId]);
this.heat = buildHeatMap(this.slice, new Rng(this.rng.int(1, 1e6)));
this.root.add(this.slice.mesh);
this.phase = 'ready';
this.leverT = 0;
this.toastSeconds = 0;
this.slice.warmth = 0;
this.knife = newKnife();
this.knifeRig.root.visible = false;
if (this.pot) this.pot.visible = false;
this.shot = SHOTS.toast;
this.dialRow.style.opacity = '1';
this.placeInSlot(0);
this.hintEl.textContent = 'SPACE — push the lever down';
}
get currentSlice(): Slice {
return this.slice;
}
/** Set the bench up for one order. The tool comes from the drawer (M4). */
applyOrder(o: Order): void {
// These four are the ORDER's facts, and the tool is whatever the drawer
// hands back. Leaving them editable was an open cheat: re-pick the butter
// knife after fetching a soup spoon, soften the butter, swap the bread.
this.breadSel.disabled = true;
this.spreadSel.disabled = true;
this.toolSel.disabled = true;
this.softInput.disabled = true;
this.askedAmount = o.amount;
this.quality = brandOf(o.bread, o.brand).quality;
this.cut = null;
this.butterSoftness = o.butterSoftness;
this.toolId = o.tool;
this.knifeRig.setTool(TOOLS[this.toolId]);
this.setSpread(o.spread);
this.loadBread(o.bread);
this.breadSel.value = o.bread;
this.spreadSel.value = o.spread;
this.toolSel.value = o.tool;
this.softInput.value = String(Math.round(o.butterSoftness * 100));
this.softVal.textContent =
o.butterSoftness > 0.66 ? 'soft' : o.butterSoftness > 0.33 ? 'ok' : 'hard';
}
serve(): void {
if (this.phase !== 'spreading') return;
this.onServed?.();
}
/**
* What the next scoop out of the jar is like. The first dip off a split jar
* takes the oil slick; the ones after it dig into what the oil left behind.
* Stirred (strat 0), every dip is 0.5 — exactly right.
*/
private jarOil(): number {
if (this.strat <= 0.001) return 0.5;
const swing = 0.5 * this.strat;
// dip 0: +swing (the slick) · dip 1: about even · dip 2+: increasingly dry
const t = 1 - this.dipsSinceStir * 0.55;
return 0.5 + swing * Math.max(-1, Math.min(1, t));
}
/** Whatever you managed to pull out of the drawer is what you're spreading with. */
/** The slice you just cut arrives at the toaster. */
applyCut(cut: SliceCut): void {
this.cut = cut;
this.loadBread(this.breadSel.value as BreadId);
}
setTool(id: ToolId): void {
this.toolId = id;
this.toolSel.value = id;
this.knifeRig.setTool(TOOLS[id]);
}
flash(msg: string): void {
this.hintEl.textContent = msg;
this.hintEl.style.color = '#e2603a';
window.setTimeout(() => {
this.hintEl.style.color = '';
this.hintEl.textContent = 'dip · drag · WHEEL tilts the knife · ENTER serves it';
}, 2600);
}
private placeInSlot(t: number): void {
const y = this.toaster.slotY + 0.16 - t * 0.74;
this.slice.mesh.position.set(TOASTER_X, y, -0.35);
this.slice.mesh.rotation.set(Math.PI / 2, 0, 0);
}
enter(): void {
this.panel.show();
}
exit(): void {
this.panel.hide();
}
private pushDown(): void {
if (this.phase !== 'ready') return;
audio.resume();
audio.clunk();
this.phase = 'toasting';
this.hintEl.textContent = 'SPACE — pop it';
}
private pop(): void {
if (this.phase !== 'toasting') return;
this.phase = 'flying';
this.slice.warmth = 1;
this.toaster.setGlow(0);
const p0 = this.slice.mesh.position.clone();
const y1 = PLATE_Y + 0.02;
const vy = 4.2;
const g = 9.8;
const disc = Math.max(0.01, vy * vy - 2 * g * (y1 - p0.y));
const T = (vy + Math.sqrt(disc)) / g;
this.vel.set((PLATE_X - p0.x) / T, vy, (PLATE_Z - p0.z) / T);
this.spin = Math.PI / 2 / T;
this.app.shake = 0.045;
this.hintEl.textContent = '';
}
beginSpreading(): void {
this.phase = 'spreading';
this.shot = SHOTS.spread;
this.knifeRig.root.visible = true;
if (this.pot) this.pot.visible = true;
this.dialRow.style.opacity = '0.35';
this.hintEl.textContent = 'dip · drag · WHEEL tilts the knife · ENTER serves it';
}
update(dt: number): void {
const inp = this.app.input;
if (inp.justPressed('Space')) {
if (this.phase === 'ready') this.pushDown();
else if (this.phase === 'toasting') this.pop();
}
if (inp.justPressed('Enter') && this.phase === 'spreading') this.serve();
const target = this.phase === 'ready' ? 0 : this.phase === 'toasting' ? 1 : 0;
this.leverT += (target - this.leverT) * Math.min(1, dt * 14);
this.toaster.lever.position.y = 1.62 * 0.72 - this.leverT * 0.62;
if (this.phase === 'toasting') {
this.placeInSlot(this.leverT);
this.toastSeconds += dt;
toastStep(this.slice, this.heat, this.power, dt);
this.toaster.setGlow(
0.35 + 0.65 * (this.power / 10) * (0.92 + Math.sin(this.toastSeconds * 9) * 0.08),
);
this.slice.setHeatGlow(0.55 + 0.45 * Math.sin(this.toastSeconds * 7) * 0.3);
} else if (this.phase === 'ready') {
this.placeInSlot(this.leverT);
} else if (this.phase === 'flying') {
this.vel.y -= 9.8 * dt;
this.slice.mesh.position.addScaledVector(this.vel, dt);
this.slice.mesh.rotation.x = Math.max(0, this.slice.mesh.rotation.x - this.spin * dt);
this.slice.mesh.rotation.z += dt * 1.1;
this.slice.setHeatGlow(0);
if (this.slice.mesh.position.y <= PLATE_Y + 0.02 && this.vel.y < 0) {
this.slice.mesh.position.set(PLATE_X, PLATE_Y + 0.02, PLATE_Z);
this.slice.mesh.rotation.set(0, 0, 0);
this.app.shake = 0.02;
this.phase = 'landed';
audio.clunk(true);
if (this.onPopped) this.onPopped();
else this.beginSpreading();
}
} else if (this.phase === 'spreading') {
this.updateSpreading(dt);
}
this.updateCamera(dt);
if (this.phase !== 'spreading') {
const t = readToast(this.slice);
this.cueEl.textContent = smellCue(t);
this.cueEl.style.color = t.smoke > 0.45 ? '#e2603a' : '';
}
this.slice.sync();
}
private updateSpreading(dt: number): void {
const inp = this.app.input;
const def = SPREADS[this.spreadId];
const tool = TOOLS[this.toolId];
if (inp.wheel !== 0) {
this.knife.angle = Math.max(0, Math.min(1, this.knife.angle + inp.wheel * 0.055));
}
this.ray.setFromCamera(inp.ndc, this.app.camera);
// Dipping wins over spreading: if the cursor is over the pot you're loading up.
let overPot = false;
if (this.potHit) {
const hits = this.ray.intersectObject(this.potHit, false);
if (hits.length) {
overPot = true;
this.knifeRig.update(hits[0].point, this.knife.angle * 0.3, this.knife.load, hits[0].point.y);
if (inp.down) {
// Swirling: angular travel of the knife around the jar's centre. Only
// sweep counts — holding still in the jar stirs nothing.
if (def.separates && this.strat > 0.001) {
const ang = Math.atan2(hits[0].point.z - POT_Z, hits[0].point.x - POT_X);
if (this.stirPrev !== null) {
let d = ang - this.stirPrev;
if (d > Math.PI) d -= Math.PI * 2;
if (d < -Math.PI) d += Math.PI * 2;
// ~2.5 full circles takes the jar from fully split to mixed.
const sweep = Math.abs(d);
if (sweep > 0.002) {
this.strat = Math.max(0, this.strat - sweep / (5 * Math.PI));
this.dipsSinceStir = 0;
if (sweep > 0.02) audio.scrape(0.25, sweep * 6);
}
}
this.stirPrev = ang;
}
// A dip is an event, not a state: the oil you get depends on how many
// scoops have already come off the top since the last stir.
if (!this.wasDipping) {
dip(this.knife, def, this.jarOil());
this.dipsSinceStir++;
this.wasDipping = true;
} else {
// Holding the blade in the jar keeps it topped up at the same blend.
dip(this.knife, def, this.knife.oil);
}
} else {
this.stirPrev = null;
this.wasDipping = false;
}
this.knife.hasPrev = false;
}
}
if (!overPot) {
this.stirPrev = null;
this.wasDipping = false;
}
if (!overPot) {
this.plane.constant = -this.slice.topY;
if (this.ray.ray.intersectPlane(this.plane, this.hitPoint)) {
this.knifeRig.update(this.hitPoint, this.knife.angle, this.knife.load, this.slice.topY);
this.slice.uvAt(this.hitPoint, this.uv);
const onBread = this.slice.mask.sample(this.uv.x, this.uv.y) > 0.5;
if (inp.down && onBread) {
this.lastFx = stroke(
this.slice,
def,
tool,
this.knife,
this.uv.x,
this.uv.y,
dt,
this.butterSoftness,
);
if (this.lastFx.tore > 0.004 || this.lastFx.gouged > 0.004) this.app.shake = 0.012;
if (this.lastFx.mode !== 'idle' && this.lastFx.speed > 0.15) {
audio.scrape(this.lastFx.pressure, this.lastFx.speed);
}
} else {
this.knife.hasPrev = false;
this.lastFx = null;
}
}
}
this.slice.uniforms.uOil.value = def.separates ? this.slice.oilMean : 0.5;
this.updateGauge(def);
}
private updateGauge(def: SpreadDef): void {
const tool = TOOLS[this.toolId];
const pressure = Math.min(1.4, pressureFromAngle(this.knife.angle) / tool.contactScale);
const yieldP = effectiveYield(def, this.butterSoftness, this.slice.warmth);
const scrapeP = Math.min(1.4, pressureFromAngle(SCRAPE_ANGLE) / tool.contactScale);
const pct = (v: number) => `${Math.min(100, (v / 1.4) * 100)}%`;
this.gaugeFill.style.width = pct(pressure);
this.gaugeYield.style.left = pct(yieldP);
this.gaugeScrape.style.left = pct(scrapeP);
const scraping = this.knife.angle >= SCRAPE_ANGLE;
this.gaugeFill.style.background = scraping
? '#c0392b'
: pressure >= yieldP
? '#6cbf6c'
: '#8a7f70';
this.jarRow.style.display = def.separates ? '' : 'none';
if (def.separates) {
const mixed = 1 - this.strat;
this.jarFill.style.width = `${Math.round(mixed * 100)}%`;
this.jarFill.style.background = mixed > 0.75 ? '#6cbf6c' : mixed > 0.4 ? '#e8a53a' : '#c0392b';
}
// Live readout of the two things the judge will actually measure, from the
// same functions he uses — so "thin" at serve time is never a surprise.
if (++this.amountTick % 12 === 0) {
const mean = this.slice.spread.stats(this.slice.mask).mean;
const cls = amountClassOf(def, mean);
const cov = this.slice.spread.fraction(this.slice.mask, (v) => v >= COVER_FLOOR);
const askTxt = this.askedAmount ? `${this.askedAmount} asked` : '';
this.amountEl.textContent =
cls === 'none' && cov < 0.02
? 'nothing on the toast yet'
: `on the toast: ${cls === 'none' ? 'barely anything' : cls}${askTxt} · ${Math.round(cov * 100)}% covered`;
this.amountEl.style.color =
this.askedAmount && cls === this.askedAmount && cov > 0.85 ? '#8fce8f' : '';
}
const fx = this.lastFx;
let msg = scraping ? 'scraping' : 'spreading';
if (fx) {
if (fx.mode === 'tear') msg = 'TEARING — the butter is too hard for this';
else if (fx.mode === 'gouge') msg = 'GOUGING — there is nothing left to scrape';
else if (fx.mode === 'char') msg = 'lifting the burnt bits';
else if (fx.mode === 'scrape') msg = 'taking it back off';
else if (fx.mode === 'spread') msg = 'spreading nicely';
} else if (this.knife.load <= 0.01) {
msg =
def.separates && this.strat > 0.45
? 'the oil has risen — swirl the knife in the jar first'
: 'the knife is empty — go and dip';
}
this.modeEl.textContent = msg;
this.modeEl.style.color =
fx?.mode === 'tear' || fx?.mode === 'gouge'
? '#e2603a'
: fx?.mode === 'spread'
? '#8fce8f'
: '';
this.loadFill.style.width = `${Math.min(100, (this.knife.load / def.pickup) * 100)}%`;
this.loadFill.style.background = `rgb(${def.color.map((c) => Math.round(c * 255)).join(',')})`;
}
private updateCamera(dt: number): void {
const k = 1 - Math.pow(0.0035, dt);
this.camPos.lerp(this.shot.pos, k);
this.camLook.lerp(this.shot.look, k);
this.app.camera.position.copy(this.camPos);
this.app.camera.lookAt(this.camLook);
}
dispose(): void {
this.panel.dispose();
this.slice.dispose();
}
}