THE BENEDICT — the first toast+poach composition: day 22 toasts the
bread in the kitchen, then onPopped routes to the poach pot instead of
the drawer (the bruschetta's oven trick, generalized); judgeBenedict
reads THE BREAD off the slice (browning/evenness/char) and THE POACH off
the egg (white/wobble/form), and The Plate sogs BOTH if you serve the
egg undrained — 'You put a wet egg on good toast. Now it is neither.'
Also in the endless rotation (electric pot past day 28).
THE KITCHEN REMEMBERS — recordVerdict on every serve path:
- Streaks: consecutive S grades tracked in the save; the judge notices
at 3 ('I am beginning to suspect competence.'), 5 ('I have started a
file.'), every 5 after, and eulogizes a broken one ('It was 7. We do
not speak of it.').
- The regulars: per-customer visits/last/best in the save. Return
tickets read 'visit №3 — last time: S' (or '...F. they came BACK.'),
and the judge compares: worse-than-last-time and redemption both get
a line. Delivered via JudgeView.extraLines, one-shot per verdict.
Verified live: day 22 starts at the kitchen, poaches after the pop,
combined card 9.1/10 with the architecture line; streak 1->2->3 fires
the line at three; day-20 rebooking shows the visit note.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
224 lines
8.3 KiB
TypeScript
224 lines
8.3 KiB
TypeScript
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<string, string> = {
|
|
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<string, number> = { 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();
|
|
}
|
|
}
|