toastsim/src/scenes/judge.ts
type-two f74ee61bbd THE POLAROID: a shareable verdict card from the judge screen
A POLAROID button on the scorecard re-renders the dish under the
spotlight, centre-crops it into a cream polaroid frame, and stamps the
day, the grade (coloured), the total, and the judge's line of the day
in italics — 'partly.party/toastsim' in the corner — then downloads as
toastsim-dayN-GRADE.png. The judge's lines are the most quotable thing
in the game; now they travel.

Verified: PNG downloads (real capture — frame cream, photo window lit,
not the black-buffer trap; explicit re-render before toDataURL).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 04:57:36 +10:00

298 lines
11 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[] = [];
/** What the polaroid prints under the photo. */
private lastCaption = { day: 0, grade: 'S', total: 0, line: '' };
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());
const polaroid = el('button', 'btn ghost', btns, 'POLAROID');
polaroid.addEventListener('click', () => this.takePolaroid());
this.nextBtn = el('button', 'btn', btns, 'NEXT ORDER');
this.nextBtn.addEventListener('click', () => this.onNext?.());
}
/**
* The polaroid: re-render the dish under the spotlight, frame it in white,
* stamp the day, the grade and the judge's line, and hand it over as a PNG.
* The judge's lines are the most quotable thing in the game — let them travel.
*/
private takePolaroid(): void {
// Force a fresh render so the drawing buffer is populated for capture.
this.app.renderer.render(this.app.scene, this.app.camera);
const src = this.app.renderer.domElement;
const c = this.lastCaption;
const W = 720;
const H = 860;
const out = document.createElement('canvas');
out.width = W;
out.height = H;
const g = out.getContext('2d')!;
// The frame.
g.fillStyle = '#f3ead9';
g.fillRect(0, 0, W, H);
// The photo: centre-crop the render into the window.
const win = { x: 30, y: 30, w: W - 60, h: 560 };
const srcAspect = src.width / src.height;
const winAspect = win.w / win.h;
let sw = src.width;
let sh = src.height;
if (srcAspect > winAspect) sw = Math.round(sh * winAspect);
else sh = Math.round(sw / winAspect);
const sx = Math.round((src.width - sw) / 2);
const sy = Math.round((src.height - sh) / 2);
g.drawImage(src, sx, sy, sw, sh, win.x, win.y, win.w, win.h);
g.strokeStyle = 'rgba(40,30,20,0.25)';
g.lineWidth = 2;
g.strokeRect(win.x, win.y, win.w, win.h);
// The caption, in the house hand.
g.fillStyle = '#2a2320';
g.font = 'bold 34px Georgia, serif';
g.fillText(`TOASTSIM — day ${c.day}`, 34, 648);
g.font = 'bold 58px Georgia, serif';
g.fillStyle = c.grade === 'S' ? '#3f7d3f' : c.grade === 'A' ? '#5b8f5b' : c.grade === 'B' ? '#a0762a' : '#9c3a2a';
g.fillText(c.grade, 34, 716);
g.font = '26px Georgia, serif';
g.fillStyle = '#2a2320';
g.fillText(`${c.total.toFixed(1)} / 10`, 100, 712);
g.font = 'italic 21px Georgia, serif';
g.fillStyle = '#4a4038';
// Wrap the judge's line to the frame.
const words = (c.line || '').split(' ');
let row = '';
let y = 762;
for (const w of words) {
if (g.measureText(row + w).width > W - 70) {
g.fillText(`${row.trim()}`.replace('““', '“'), 34, y);
row = '';
y += 28;
if (y > H - 40) break;
}
row += w + ' ';
}
if (row.trim() && y <= H - 40) g.fillText((y === 762 ? '“' : '') + row.trim() + '”', 34, y);
g.font = '16px Georgia, serif';
g.fillStyle = '#8a7f72';
g.fillText('partly.party/toastsim', W - 210, H - 22);
// Hand it over.
const a = document.createElement('a');
a.href = out.toDataURL('image/png');
a.download = `toastsim-day${c.day}-${c.grade}.png`;
a.click();
}
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.lastCaption = { day: order.day, grade: verdict.grade, total: verdict.total, line: lines[1] ?? lines[0] ?? '' };
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();
}
}