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