import * as THREE from 'three'; import type { App, View } from '../core/app'; import type { Slice } from '../sim/slice'; import type { Order } from '../game/orders'; import type { Verdict } from '../game/judging'; import { judgeFace, verdictLines } from '../game/lines'; import { TOOLS, type ToolId } from '../sim/cutlery'; import { clear, el, Panel } from '../ui/hud'; import { assetUrl } from './assets'; /** * The scorecard. This is the screen the game is actually about: every number * points at something the player did, and the toast turns slowly under a light * so they can see it for themselves. */ export class JudgeView implements View { readonly root = new THREE.Group(); private pedestal: THREE.Group; private slice: Slice | null = null; private spin = 0; private heatmap: 0 | 1 | 2 = 0; private panel: Panel; private cardEl!: HTMLElement; private linesEl!: HTMLElement; private stampEl!: HTMLElement; private faceEl!: HTMLImageElement; private orderEl!: HTMLElement; private nextBtn!: HTMLButtonElement; private viewBtn!: HTMLButtonElement; onNext: (() => void) | null = null; /** One-shot lines the game adds to this verdict (streaks, regulars' memory). */ extraLines: string[] = []; constructor(private app: App) { this.pedestal = new THREE.Group(); const top = new THREE.Mesh( new THREE.CylinderGeometry(1.05, 1.05, 0.12, 48), new THREE.MeshStandardMaterial({ color: 0x2a2320, roughness: 0.55 }), ); top.receiveShadow = true; this.pedestal.add(top); const stem = new THREE.Mesh( new THREE.CylinderGeometry(0.5, 0.72, 0.9, 32), new THREE.MeshStandardMaterial({ color: 0x1d1815, roughness: 0.7 }), ); stem.position.y = -0.5; this.pedestal.add(stem); this.pedestal.position.set(0, 0.9, 0); this.root.add(this.pedestal); const spot = new THREE.SpotLight(0xfff0d8, 90, 12, 0.5, 0.55, 1.6); spot.position.set(1.4, 4.2, 2.2); spot.target = this.pedestal; spot.castShadow = true; spot.shadow.mapSize.set(1024, 1024); this.root.add(spot); const rim = new THREE.SpotLight(0x88a8ff, 40, 12, 0.6, 0.7, 1.6); rim.position.set(-2.6, 2.4, -2.2); rim.target = this.pedestal; this.root.add(rim); this.panel = new Panel('judge-panel'); this.buildUi(); this.panel.hide(); } private buildUi(): void { const p = this.panel.root; const left = el('div', 'judge-left', p); this.faceEl = el('img', 'judge-face', left); this.faceEl.alt = ''; this.linesEl = el('div', 'judge-lines', left); const right = el('div', 'judge-right', p); this.orderEl = el('div', 'judge-order', right); this.cardEl = el('div', 'judge-card', right); const foot = el('div', 'judge-foot', right); this.stampEl = el('div', 'judge-stamp', foot); const btns = el('div', 'judge-btns', foot); this.viewBtn = el('button', 'btn ghost', btns, 'HEATMAP'); this.viewBtn.addEventListener('click', () => this.cycleHeatmap()); this.nextBtn = el('button', 'btn', btns, 'NEXT ORDER'); this.nextBtn.addEventListener('click', () => this.onNext?.()); } private cycleHeatmap(): void { this.heatmap = ((this.heatmap + 1) % 3) as 0 | 1 | 2; this.slice?.setHeatmap(this.heatmap); this.viewBtn.textContent = ['HEATMAP', 'BROWNING', 'SPREAD'][this.heatmap]; } show(slice: Slice, verdict: Verdict, order: Order, tool: ToolId, rand: () => number): void { this.slice = slice; this.spin = 0; this.heatmap = 0; slice.setHeatmap(0); slice.setPresentation(true); this.viewBtn.textContent = 'HEATMAP'; // Take the toast off the bench and put it under the light. this.pedestal.add(slice.mesh); slice.mesh.position.set(0, 0.06 + slice.halfThickness, 0); slice.mesh.rotation.set(0, 0, 0); slice.mesh.scale.setScalar(1.55); // Grill and bruschetta days don't have a browning/spread to name — show who // asked and what for, not the unread toast defaults. this.orderEl.textContent = order.grill ? `Day ${order.day} · ${order.who} asked for the barbie` : order.benedict ? `Day ${order.day} · ${order.who} asked for the full benedict` : order.poach ? `Day ${order.day} · ${order.who} asked for the poached egg` : order.eggs ? `Day ${order.day} · ${order.who} asked for the eggs` : order.steak ? `Day ${order.day} · ${order.who} asked for the rib eye` : order.pan ? `Day ${order.day} · ${order.who} asked for the pan-sear` : order.fridge ? `Day ${order.day} · the fridge, three days on` : order.bruschetta ? `Day ${order.day} · ${order.who} asked for the bruschetta` : `Day ${order.day} · ${order.who} asked for ${order.browningName}, ${order.amount} ${order.spread === 'mitey' ? 'MITEY' : order.spread}`; clear(this.cardEl); // On prep orders the card is long enough to need signposts: group rows by // station. Ordinary toast orders keep the flat card they always had. // Station orders (prep, and M15's bruschetta trio) get the grouped card; // a plain toast order keeps the flat card it always had. const grouped = verdict.criteria.some( (c) => c.group === 'prep' || c.group === 'bread' || c.group === 'topping' || c.group === 'bench' || c.group === 'grill' || c.group === 'pan' || c.group === 'cold' || c.group === 'steak' || c.group === 'eggs' || c.group === 'poach', ); const headers: Record = { prep: 'THE PREP', toast: 'THE TOAST', spread: 'THE SPREAD', bread: 'THE BREAD', topping: 'THE TOPPING', grill: 'THE GRILL', pan: 'THE PAN', cold: 'THE FRIDGE', steak: 'THE STEAK', eggs: 'THE EGGS', poach: 'THE POACH', bench: 'THE BENCH', }; const groupRank: Record = { prep: 0, bread: 1, toast: 2, topping: 3, grill: 3, pan: 3, cold: 3, steak: 3, eggs: 3, poach: 3, spread: 4, bench: 5 }; let lastGroup: string | undefined; const rank = (g?: string) => (g && g in groupRank ? groupRank[g] : 9); const ordered = grouped ? [...verdict.criteria].sort((a, b) => rank(a.group) - rank(b.group)) : verdict.criteria; for (const c of ordered) { if (grouped && c.group !== lastGroup) { lastGroup = c.group; if (c.group) el('div', 'crit-group', this.cardEl, headers[c.group]); } const row = el('div', 'crit', this.cardEl); el('span', 'crit-name', row, c.label); const bar = el('span', 'crit-bar', row); const fill = el('span', 'crit-fill', bar); fill.style.width = `${Math.round(c.score * 100)}%`; fill.style.background = c.score > 0.8 ? '#6cbf6c' : c.score > 0.5 ? '#e8a53a' : '#c0392b'; el('span', 'crit-detail', row, c.detail); } const totalRow = el('div', 'crit total', this.cardEl); el('span', 'crit-name', totalRow, 'TOTAL'); el('span', 'crit-bar', totalRow); el('span', 'crit-detail', totalRow, `${verdict.total.toFixed(1)} / 10`); clear(this.linesEl); const lines = verdictLines(verdict, order, TOOLS[tool].name, rand); for (const l of lines) el('p', undefined, this.linesEl, l); for (const l of this.extraLines) el('p', 'judge-extra', this.linesEl, l); this.extraLines = []; this.faceEl.src = assetUrl(`/assets/img/judge_${judgeFace(verdict.grade)}.png`); this.stampEl.textContent = verdict.grade; this.stampEl.className = `judge-stamp grade-${verdict.grade}`; // re-trigger the stamp animation this.stampEl.style.animation = 'none'; void this.stampEl.offsetHeight; this.stampEl.style.animation = ''; } /** Hand the toast back so the kitchen can bin it. */ release(): Slice | null { const s = this.slice; if (s) { s.mesh.scale.setScalar(1); s.setHeatmap(0); s.setPresentation(false); this.pedestal.remove(s.mesh); } this.slice = null; return s; } enter(): void { this.panel.show(); this.app.camera.position.set(0.15, 2.35, 3.5); this.app.camera.lookAt(0, 1.15, 0); this.app.shake = 0.05; } exit(): void { this.panel.hide(); } update(dt: number): void { this.spin += dt * 0.42; if (this.slice) this.slice.mesh.rotation.y = this.spin; this.app.camera.position.set(0.15, 2.35, 3.5); this.app.camera.lookAt(0, 1.15, 0); } dispose(): void { this.panel.dispose(); } }