M3: judging — the scorecard, the judge, and a colour-space bug

The loop closes: ticket -> toast -> spread -> ENTER -> verdict -> next day.

- judging.ts: 9 weighted criteria, each reading the same fields the knife was
  pushing around, so every line of the scorecard points at something real
  ("0.78 against 0.68 asked", "thick — thin asked", "28% burnt").
- lines.ts: ~45 lines keyed to whichever criterion actually decided the score,
  so the verdict and the scorecard always agree — the verdict just has feelings
  about it. MITEY has its own vocabulary.
- judge.ts: the toast turns on a pedestal under a spotlight, grade stamps in,
  heatmap toggle for browning/spread. The generated inspector reacts by grade.
- orders.ts: seven handwritten days, then procedural. Day 1 is soft butter on
  white; day 7 is a translucent film of MITEY on wet sourdough with fridge-hard
  butter and a steak knife.

The find: every art colour was authored as sRGB and fed straight into a linear
lighting pipeline. Linear 0.14 encodes back out to sRGB ~0.4, so "near-black"
MITEY rendered as TAN and saturated butter washed to pale cream. This was the
root cause of the legibility fights in M0 and M2 — I'd been treating the symptom
by pushing the specular around. One line (albedo = pow(albedo, 2.2), mixing
stays in sRGB because that's the space the palette was picked in) and the whole
art direction landed: MITEY is genuinely black, butter is butter, the crust went
from cream to a rich golden brown.

Also: the specular lobe was far too broad. The slice is flat with the light and
camera both above it, so dot(N,H) ~0.98 everywhere and a wide lobe blankets the
whole slice in white. Tightened, so only ridges tilted into the light catch —
which is what a spread actually looks like.

The slice rolls its own lighting and ignores scene lights, so the judge's
spotlight did nothing to it; it now swaps its own rig for presentation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
monster 2026-07-16 22:04:22 +10:00
parent eb74170011
commit b07d57e9e9
10 changed files with 1216 additions and 12 deletions

View File

@ -1,6 +1,6 @@
import * as THREE from 'three';
import type { App } from './core/app';
import type { KitchenView } from './scenes/kitchen';
import type { Game } from './game/game';
/**
* Dev harness. The game is driven by mouse gestures over a 3D scene, which makes
@ -12,7 +12,8 @@ import type { KitchenView } from './scenes/kitchen';
*
* Dev builds only.
*/
export function installDevHarness(app: App, kitchen: KitchenView): void {
export function installDevHarness(app: App, game: Game): void {
const kitchen = game.kitchenView;
const v = new THREE.Vector3();
const toNdc = (x: number, y: number, z: number): [number, number] => {
v.set(x, y, z).project(app.camera);
@ -34,6 +35,7 @@ export function installDevHarness(app: App, kitchen: KitchenView): void {
const harness = {
app,
game,
kitchen,
THREE,
run,

149
src/game/game.ts Normal file
View File

@ -0,0 +1,149 @@
import type { App } from '../core/app';
import { Rng } from '../core/rng';
import { KitchenView } from '../scenes/kitchen';
import { JudgeView } from '../scenes/judge';
import { judge, type Grade } from './judging';
import { DAYS, proceduralOrder, nameBrowning, type Order } from './orders';
import { el, Panel } from '../ui/hud';
const SAVE_KEY = 'toastsim.save';
interface Save {
day: number;
total: number;
best: Record<string, number>;
}
/**
* The arcade loop: an order, a slice, a verdict, the next order. The day counter
* is the whole progression everything else is the orders getting nastier.
*/
export class Game {
private kitchen: KitchenView;
private judgeView: JudgeView;
private rng = new Rng(20260716);
private order!: Order;
private startedAt = 0;
private save: Save;
private ticket: Panel;
private ticketEl!: HTMLElement;
constructor(private app: App) {
this.save = loadSave();
this.kitchen = new KitchenView(app, 3);
this.judgeView = new JudgeView(app);
app.scene.add(this.kitchen.root);
app.scene.add(this.judgeView.root);
this.judgeView.root.visible = false;
this.ticket = new Panel('ticket');
this.ticketEl = el('div', 'ticket-body', this.ticket.root);
this.kitchen.onServed = () => this.serve();
this.judgeView.onNext = () => {
this.save.day++;
writeSave(this.save);
this.beginDay();
};
this.beginDay();
}
get currentOrder(): Order {
return this.order;
}
get kitchenView(): KitchenView {
return this.kitchen;
}
get judgeScreen(): JudgeView {
return this.judgeView;
}
beginDay(): void {
const day = this.save.day;
const o =
day <= DAYS.length
? DAYS[day - 1]
: proceduralOrder(day, () => this.rng.next());
if (!o.text) o.text = `${o.browningName}, ${o.amount} ${o.spread === 'mitey' ? 'MITEY' : o.spread}. ${o.noChar ? 'No burnt bits.' : ''}`;
this.order = o;
this.startedAt = performance.now();
const s = this.judgeView.release();
if (s) this.kitchen.root.add(s.mesh);
this.kitchen.applyOrder(o);
this.app.setView(this.kitchen);
this.judgeView.root.visible = false;
this.kitchen.root.visible = true;
this.ticket.show();
this.renderTicket();
}
private renderTicket(): void {
const o = this.order;
this.ticketEl.innerHTML = '';
el('div', 'ticket-day', this.ticketEl, `DAY ${o.day}`);
el('div', 'ticket-who', this.ticketEl, o.who);
el('div', 'ticket-text', this.ticketEl, `${o.text}`);
const spec = el('div', 'ticket-spec', this.ticketEl);
el('span', 'chip', spec, o.browningName);
el('span', 'chip', spec, `${o.amount} ${o.spread === 'mitey' ? 'MITEY' : o.spread}`);
if (o.noChar) el('span', 'chip warn', spec, 'no burnt bits');
el('span', 'chip tool', spec, `use the ${o.tool.replace(/_/g, ' ')}`);
el(
'div',
'ticket-hint',
this.ticketEl,
`butter is ${o.butterSoftness > 0.6 ? 'soft' : o.butterSoftness > 0.3 ? 'firm' : 'fridge-hard'} today`,
);
}
/** Called when the player hands the plate over. */
serve(): void {
const seconds = (performance.now() - this.startedAt) / 1000;
const slice = this.kitchen.currentSlice;
const v = judge(slice, this.order, this.kitchen.toolId, seconds);
this.save.total += v.total;
const key = `day${this.order.day}`;
this.save.best[key] = Math.max(this.save.best[key] ?? 0, v.total);
writeSave(this.save);
this.kitchen.root.visible = false;
this.judgeView.root.visible = true;
this.judgeView.show(slice, v, this.order, this.kitchen.toolId, () => this.rng.next());
this.app.setView(this.judgeView);
this.ticket.hide();
}
/** Test hook. */
gradeNow(): { total: number; grade: Grade } {
const v = judge(this.kitchen.currentSlice, this.order, this.kitchen.toolId, 60);
return { total: v.total, grade: v.grade };
}
}
function loadSave(): Save {
try {
const raw = localStorage.getItem(SAVE_KEY);
if (raw) {
const s = JSON.parse(raw) as Partial<Save>;
if (typeof s.day === 'number' && s.day >= 1) {
return { day: s.day, total: s.total ?? 0, best: s.best ?? {} };
}
}
} catch {
/* a corrupt save is not worth a crash */
}
return { day: 1, total: 0, best: {} };
}
function writeSave(s: Save): void {
try {
localStorage.setItem(SAVE_KEY, JSON.stringify(s));
} catch {
/* private mode; the day counter just won't persist */
}
}
export { nameBrowning };

182
src/game/judging.ts Normal file
View File

@ -0,0 +1,182 @@
import type { Slice } from '../sim/slice';
import { CHAR_THRESHOLD } from '../sim/slice';
import { SPREADS, amountClassOf } from '../sim/spreads';
import { TOOLS, type ToolId } from '../sim/cutlery';
import type { Order } from './orders';
export interface Criterion {
key: string;
label: string;
/** 0..1 */
score: number;
weight: number;
/** What the number actually was, in words. */
detail: string;
}
export type Grade = 'S' | 'A' | 'B' | 'C' | 'F';
export interface Verdict {
criteria: Criterion[];
/** 0..10 */
total: number;
grade: Grade;
best: Criterion;
worst: Criterion;
lines: string[];
}
const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v);
/**
* Every number here is read off the same fields the player was pushing around
* with the knife, so the scorecard can always point at something real.
*/
export function judge(slice: Slice, order: Order, usedTool: ToolId, seconds: number): Verdict {
const b = slice.browning.stats(slice.mask);
const s = slice.spread.stats(slice.mask);
const d = slice.damage.stats(slice.mask);
const def = SPREADS[order.spread];
const [lo, hi] = def.amounts[order.amount];
const target = (lo + hi) / 2;
const charFrac = slice.browning.fraction(slice.mask, (v) => v > CHAR_THRESHOLD);
const coverage = slice.spread.fraction(slice.mask, (v) => v >= lo * 0.75);
// Uniformity only over the bits you actually covered — punishing the variance
// of a half-spread slice twice (here and in coverage) isn't fair.
const covered = spreadStdevOverCovered(slice, lo * 0.75);
const criteria: Criterion[] = [
{
key: 'browning',
label: 'Browning',
score: clamp01(1 - Math.abs(b.mean - order.browning) / 0.3),
weight: 1.5,
detail: `${b.mean.toFixed(2)} against ${order.browning.toFixed(2)} asked`,
},
{
key: 'evenness',
label: 'Evenness',
score: clamp01(1 - b.stdev / 0.2),
weight: 1.1,
detail: `variation ${b.stdev.toFixed(3)}`,
},
{
key: 'coverage',
label: 'Coverage',
score: clamp01((coverage - 0.15) / 0.75),
weight: 1.3,
detail: `${Math.round(coverage * 100)}% of the slice`,
},
{
key: 'uniformity',
label: 'Uniformity',
score: clamp01(1 - covered / 0.22),
weight: 0.9,
detail: `variation ${covered.toFixed(3)}`,
},
{
key: 'char',
label: 'Char',
score: order.noChar ? clamp01(1 - charFrac / 0.12) : clamp01(1 - charFrac / 0.45),
weight: order.noChar ? 1.3 : 0.5,
detail: charFrac < 0.005 ? 'none' : `${Math.round(charFrac * 100)}% burnt`,
},
{
key: 'integrity',
label: 'Integrity',
score: clamp01(1 - d.mean / 0.06),
weight: 1.2,
detail: d.mean < 0.002 ? 'intact' : `torn over ${Math.round(slice.damage.fraction(slice.mask, (v) => v > 0.02) * 100)}%`,
},
{
key: 'amount',
label: 'The Right Amount',
score: amountScore(s.mean, lo, hi, target),
weight: 1.4,
detail: `${amountWord(slice, order)}${order.amount} asked`,
},
{
key: 'tool',
label: 'Utensil',
score: usedTool === order.tool ? 1 : TOOLS[usedTool].ideal ? 0.6 : 0.25,
weight: 0.4,
detail: usedTool === order.tool ? TOOLS[usedTool].name : `${TOOLS[usedTool].name}, not the ${TOOLS[order.tool].name}`,
},
{
key: 'time',
label: 'Service',
score: clamp01(1 - (seconds - 45) / 90),
weight: 0.3,
detail: `${Math.round(seconds)}s`,
},
];
let sum = 0;
let wsum = 0;
for (const c of criteria) {
sum += c.score * c.weight;
wsum += c.weight;
}
const total = (sum / wsum) * 10;
// Best/worst ignore Service — nobody wants a verdict about the clock.
const rankable = criteria.filter((c) => c.key !== 'time');
const sorted = [...rankable].sort((a, b2) => a.score - b2.score);
const worst = sorted[0];
const best = sorted[sorted.length - 1];
return {
criteria,
total,
grade: gradeOf(total),
best,
worst,
lines: [],
};
}
function spreadStdevOverCovered(slice: Slice, floor: number): number {
const sp = slice.spread.data;
const m = slice.mask.data;
let sum = 0;
let n = 0;
for (let i = 0; i < sp.length; i++) {
if (m[i] < 0.5 || sp[i] < floor) continue;
sum += sp[i];
n++;
}
if (n < 8) return 1;
const mean = sum / n;
let acc = 0;
for (let i = 0; i < sp.length; i++) {
if (m[i] < 0.5 || sp[i] < floor) continue;
const d = sp[i] - mean;
acc += d * d;
}
return Math.sqrt(acc / n);
}
/** Full marks anywhere inside the band, falling away outside it. */
function amountScore(mean: number, lo: number, hi: number, target: number): number {
if (mean >= lo && mean <= hi) return 1;
const dist = mean < lo ? lo - mean : mean - hi;
return clamp01(1 - dist / (target * 1.5));
}
function amountWord(slice: Slice, order: Order): string {
const def = SPREADS[order.spread];
const mean = slice.spread.stats(slice.mask).mean;
const cls = amountClassOf(def, mean);
if (cls === 'none') return 'essentially none';
if (cls === 'obscene') return 'an obscene amount';
return cls;
}
export function gradeOf(total: number): Grade {
if (total >= 9.2) return 'S';
if (total >= 7.6) return 'A';
if (total >= 5.8) return 'B';
if (total >= 3.8) return 'C';
return 'F';
}

203
src/game/lines.ts Normal file
View File

@ -0,0 +1,203 @@
import type { Criterion, Grade, Verdict } from './judging';
import type { Order } from './orders';
import { SPREADS } from '../sim/spreads';
/**
* The judge. Deadpan, specific, never cruel about anything except MITEY, about
* which he is unwell.
*
* Lines are keyed to the criterion that actually decided the score, so the
* verdict always tells you the same thing the scorecard does just meaner.
*/
type Bank = Record<string, { bad: string[]; good: string[] }>;
const BANK: Bank = {
browning: {
bad: [
'I asked for {asked}. This is {got}. These are different words.',
'You have made something adjacent to toast.',
'The colour is a decision. You appear not to have made one.',
'This is bread that has been near a toaster. Not the same thing.',
'Somewhere in this kitchen is a dial. It has numbers on it.',
],
good: [
'The colour is correct. I want that on the record.',
'That is the shade I asked for. Precisely the shade.',
'Colour: no notes.',
],
},
evenness: {
bad: [
'One half of this has had a completely different morning to the other.',
'It is striped. Toast should not be striped.',
'I can see where you rescued it. So can everyone.',
'This slice has weather.',
'There are three separate climates on this toast.',
],
good: [
'Even, corner to corner. That is harder than it looks.',
'No hot spots, no pale spots. Good.',
'Uniform. Genuinely uniform.',
],
},
coverage: {
bad: [
'You have buttered an island and left a continent.',
'The corners are not decorative. They are toast.',
'There is a border of bare toast. Why is there a border.',
'You got most of it. Most.',
'A generous interpretation of the word "spread".',
],
good: [
'Edge to edge. Somebody was raised properly.',
'Full coverage. Not a bare corner on it.',
'You went to the crusts. I noticed.',
],
},
uniformity: {
bad: [
'It has a thick end and a thin end, like a bad argument.',
'Lumpy. I can feel it from here.',
'The {spread} is doing whatever it likes.',
'This is not a film. It is terrain.',
],
good: [
'Flat, even, no ridges. Very tidy.',
'Consistent all the way across.',
'A properly level spread. Rare.',
],
},
char: {
bad: [
'I said no burnt bits. This is a burnt bit convention.',
'There is carbon on this. Actual carbon.',
'You burnt it. Then you served it. The second decision is the interesting one.',
'I asked for toast, not evidence.',
],
good: [
'Not a scorch on it.',
'No char. Well caught.',
'Clean. Nothing burnt.',
],
},
integrity: {
bad: [
'You have not spread this. You have assaulted it.',
'The surface is gone. You dragged it off and served the wound.',
'Cold butter. Warm toast. One of these was your job.',
'There is a crater. The coverage is admirable. The crater is not.',
'This bread has been through something and would rather not discuss it.',
],
good: [
'Not a tear on it. That is the whole trick, and you did it.',
'The surface is perfect. No drag, no gouges.',
'Intact. You let something be warm first, didn\'t you.',
],
},
amount: {
bad: [
'I said {asked}. You have applied {got}.',
'That is not {asked}. That is a statement.',
'Somebody has never been told no.',
],
good: [
'Exactly the right amount. Exactly.',
'The quantity is correct. Thank you.',
],
},
tool: {
bad: [
'You did this with a {tool}. I can tell. Everyone can tell.',
'The {tool} was a choice.',
'There was a whole drawer. You picked the {tool}.',
],
good: [
'Right tool. It shows.',
'Correct utensil. Small thing. Not nothing.',
],
},
};
/** MITEY gets its own vocabulary. */
const MITEY_CRIMES = [
'This is not a scrape of MITEY. This is a hate crime.',
'You have applied MITEY the way one applies paint.',
'I asked to see the toast THROUGH it. I can see nothing. There is only night.',
'That is a decade of MITEY on one slice. Some of us have to live here.',
];
const GRADE_OPENERS: Record<Grade, string[]> = {
S: ['Well.', 'I have been doing this for thirty-one years.', 'Hm.'],
A: ['Close.', 'Nearly.', 'Good.'],
B: ['Adequate.', 'It is toast.', 'Fine.'],
C: ['Hm.', 'Right.', 'Well, it exists.'],
F: ['No.', 'Absolutely not.', 'Take it away.'],
};
const GRADE_CLOSERS: Record<Grade, string[]> = {
S: [
'That is the toast. Do it again tomorrow and I will start to worry.',
'I have no notes. I dislike having no notes.',
'Perfect. Irritatingly perfect.',
],
A: ['Very nearly the toast.', 'One thing away from excellent.', 'Good work. Not finished work.'],
B: ['It will be eaten. That is the bar it cleared.', 'Serviceable.', 'Nobody will complain. Nobody will remember.'],
C: ['I have eaten worse. Not recently.', 'Try again.', 'This is why we have a scorecard.'],
F: ['I am writing this down.', 'Start again. Properly.', 'The bread deserved better.'],
};
function fill(s: string, order: Order, v: Verdict, tool: string): string {
const spread = SPREADS[order.spread];
return s
.replace('{asked}', order.amount === 'thin' ? 'a scrape' : order.amount)
.replace('{got}', v.criteria.find((c) => c.key === 'amount')?.detail.split(' — ')[0] ?? 'that')
// Mid-sentence, so lowercase — except MITEY, which is shouted on principle.
.replace('{spread}', spread.id === 'mitey' ? 'MITEY' : spread.name.toLowerCase())
.replace('{tool}', tool.toLowerCase());
}
/**
* Two lines: what went worst, and what went best. Which is what the scorecard
* says too this just says it with feeling.
*/
export function verdictLines(v: Verdict, order: Order, tool: string, rand: () => number): string[] {
const pick = <T>(a: T[]): T => a[Math.floor(rand() * a.length)];
const out: string[] = [];
out.push(pick(GRADE_OPENERS[v.grade]));
const mitey = order.spread === 'mitey';
const amountBad = (v.criteria.find((c) => c.key === 'amount')?.score ?? 1) < 0.4;
if (mitey && amountBad) {
out.push(pick(MITEY_CRIMES));
} else if (v.worst.score < 0.72) {
out.push(fill(pick(BANK[v.worst.key]?.bad ?? ['Hm.']), order, v, tool));
}
if (v.best.score > 0.85 && v.best.key !== v.worst.key) {
out.push(fill(pick(BANK[v.best.key]?.good ?? []), order, v, tool));
}
out.push(pick(GRADE_CLOSERS[v.grade]));
return out.filter(Boolean);
}
/** Which portrait to show. */
export function judgeFace(grade: Grade): string {
switch (grade) {
case 'S':
return 'impressed';
case 'A':
return 'intrigued';
case 'B':
return 'neutral';
case 'C':
return 'disappointed';
default:
return 'horrified';
}
}
export function describeCriterion(c: Criterion): string {
return c.detail;
}

174
src/game/orders.ts Normal file
View File

@ -0,0 +1,174 @@
import type { BreadId } from '../sim/bread';
import type { AmountClass, SpreadId } from '../sim/spreads';
import type { ToolId } from '../sim/cutlery';
export interface Order {
day: number;
bread: BreadId;
spread: SpreadId;
amount: AmountClass;
/** Target mean browning, 0..1. */
browning: number;
browningName: string;
/** The customer specifically does not want char. Some don't care. */
noChar: boolean;
/** What the drawer will make you find. */
tool: ToolId;
/** 0 = fridge-hard, 1 = soft. The single cruellest dial in the game. */
butterSoftness: number;
/** How the customer says it. */
text: string;
/** Who's asking. */
who: string;
}
export const BROWNING_NAMES: [number, string][] = [
[0.18, 'barely warmed through'],
[0.34, 'light'],
[0.5, 'golden'],
[0.66, 'well done'],
[0.8, 'dark'],
];
export function nameBrowning(v: number): string {
let best = BROWNING_NAMES[0];
for (const b of BROWNING_NAMES) if (Math.abs(b[0] - v) < Math.abs(best[0] - v)) best = b;
return best[1];
}
/**
* The handwritten first week. The curve is deliberate: day 1 is butter on white
* with a butter knife and butter that's been out all morning every dial set to
* "forgiving". By day 7 you're doing a translucent film of MITEY on thick wet
* sourdough with fridge-hard butter... and a steak knife, because the drawer is
* the drawer.
*/
export const DAYS: Order[] = [
{
day: 1,
bread: 'white',
spread: 'butter',
amount: 'normal',
browning: 0.5,
browningName: 'golden',
noChar: true,
tool: 'butter_knife',
butterSoftness: 0.85,
who: 'Deidre',
text: 'Just golden, love. Butter. Normal amount of butter.',
},
{
day: 2,
bread: 'white',
spread: 'butter',
amount: 'thin',
browning: 0.66,
browningName: 'well done',
noChar: true,
tool: 'dinner_knife',
butterSoftness: 0.55,
who: 'Ray',
text: "Well done. Scrape of butter — I said a scrape, not a shovel.",
},
{
day: 3,
bread: 'multigrain',
spread: 'peanut',
amount: 'thick',
browning: 0.42,
browningName: 'light',
noChar: true,
tool: 'dessert_spoon',
butterSoftness: 0.5,
who: 'a child',
text: 'PEANUT BUTTER. LOTS. Bread barely toasted please.',
},
{
day: 4,
bread: 'raisin',
spread: 'butter',
amount: 'normal',
browning: 0.44,
browningName: 'light',
noChar: true,
tool: 'butter_knife',
butterSoftness: 0.3,
who: 'Deidre',
text: "Raisin loaf. Careful — it catches. I'll know if it catches.",
},
{
day: 5,
bread: 'white',
spread: 'mitey',
amount: 'normal',
browning: 0.62,
browningName: 'well done',
noChar: true,
tool: 'teaspoon',
butterSoftness: 0.4,
who: 'Ray',
text: 'MITEY. Proper amount. You know what proper means.',
},
{
day: 6,
bread: 'sourdough',
spread: 'peanut',
amount: 'normal',
browning: 0.72,
browningName: 'dark',
noChar: false,
tool: 'steak_knife',
butterSoftness: 0.25,
who: 'a man in a hurry',
text: "Dark. Sourdough. Don't care if it catches a bit. Peanut butter, even.",
},
{
day: 7,
bread: 'sourdough',
spread: 'mitey',
amount: 'thin',
browning: 0.68,
browningName: 'well done',
noChar: true,
tool: 'steak_knife',
butterSoftness: 0.0,
who: 'the inspector himself',
text: "Thick sourdough, well done, and a *scrape* of MITEY. I want to see the toast through it.",
},
];
/** Days 8+ — keep going, with everything cranked. */
export function proceduralOrder(day: number, rand: () => number): Order {
const breads: BreadId[] = ['white', 'multigrain', 'raisin', 'sourdough'];
const spreads: SpreadId[] = ['butter', 'peanut', 'mitey'];
const amounts: AmountClass[] = ['thin', 'normal', 'thick'];
const tools: ToolId[] = [
'butter_knife',
'dinner_knife',
'steak_knife',
'spreader',
'dinner_fork',
'dessert_fork',
'teaspoon',
'dessert_spoon',
'soup_spoon',
];
const pick = <T>(a: T[]): T => a[Math.floor(rand() * a.length)];
const spread = pick(spreads);
const amount: AmountClass = spread === 'mitey' ? (rand() < 0.75 ? 'thin' : 'normal') : pick(amounts);
const browning = 0.3 + rand() * 0.5;
return {
day,
bread: pick(breads),
spread,
amount,
browning,
browningName: nameBrowning(browning),
noChar: rand() < 0.8,
tool: pick(tools),
// It only gets colder from here.
butterSoftness: Math.max(0, 0.4 - (day - 8) * 0.08) * rand(),
who: pick(['Deidre', 'Ray', 'a regular', 'someone new', 'the inspector himself']),
text: '',
};
}

View File

@ -1,6 +1,6 @@
import * as THREE from 'three';
import { App, makeEnvironment, makeLights } from './core/app';
import { KitchenView } from './scenes/kitchen';
import { Game } from './game/game';
import './style.css';
const canvas = document.getElementById('c') as HTMLCanvasElement;
@ -9,12 +9,10 @@ makeLights(app.scene);
makeEnvironment(app);
app.scene.background = new THREE.Color(0x241a15);
const kitchen = new KitchenView(app, 3);
app.scene.add(kitchen.root);
app.setView(kitchen);
const game = new Game(app);
if (import.meta.env.DEV) {
void import('./dev').then((m) => m.installDevHarness(app, kitchen));
void import('./dev').then((m) => m.installDevHarness(app, game));
}
app.start();

169
src/scenes/judge.ts Normal file
View File

@ -0,0 +1,169 @@
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';
/**
* 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;
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);
this.orderEl.textContent = `Day ${order.day} · ${order.who} asked for ${order.browningName}, ${order.amount} ${order.spread === 'mitey' ? 'MITEY' : order.spread}`;
clear(this.cardEl);
for (const c of verdict.criteria) {
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);
this.faceEl.src = `/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();
}
}

View File

@ -17,6 +17,7 @@ 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 type { Order } from '../game/orders';
const TOASTER_X = -1.5;
const PLATE_X = 1.45;
@ -80,6 +81,14 @@ export class KitchenView implements View {
private gaugeScrape!: HTMLElement;
private modeEl!: HTMLElement;
private loadFill!: HTMLElement;
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;
constructor(
private app: App,
@ -126,6 +135,7 @@ export class KitchenView implements View {
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;
@ -135,6 +145,7 @@ export class KitchenView implements View {
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;
@ -144,6 +155,7 @@ export class KitchenView implements View {
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;
@ -156,11 +168,13 @@ export class KitchenView implements View {
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 =
@ -226,6 +240,26 @@ export class KitchenView implements View {
return this.slice;
}
/** Set the bench up for one order. The tool comes from the drawer (M4). */
applyOrder(o: Order): void {
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?.();
}
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);
@ -268,7 +302,7 @@ export class KitchenView implements View {
this.knifeRig.root.visible = true;
if (this.pot) this.pot.visible = true;
this.dialRow.style.opacity = '0.35';
this.hintEl.textContent = 'dip in the pot · drag on the toast · WHEEL tilts the knife';
this.hintEl.textContent = 'dip · drag · WHEEL tilts the knife · ENTER serves it';
}
update(dt: number): void {
@ -277,6 +311,7 @@ export class KitchenView implements View {
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);

View File

@ -142,6 +142,26 @@ export class Slice {
this.material.uniforms.uHeatmap.value = mode;
}
/**
* The slice rolls its own lighting, so scene lights don't touch it which
* means the judge's spotlight would do nothing at all. Swap the shader's own
* rig instead: hard key, near-black ambient.
*/
setPresentation(on: boolean): void {
const u = this.material.uniforms;
if (on) {
u.uLightDir.value.set(0.4, 0.86, 0.52).normalize();
u.uLightColor.value.setRGB(1.35, 1.24, 1.05);
u.uAmbientSky.value.setRGB(0.1, 0.11, 0.15);
u.uAmbientGround.value.setRGB(0.035, 0.03, 0.03);
} else {
u.uLightDir.value.set(0.5, 0.9, 0.42).normalize();
u.uLightColor.value.setRGB(1.0, 0.95, 0.86);
u.uAmbientSky.value.setRGB(0.26, 0.27, 0.31);
u.uAmbientGround.value.setRGB(0.14, 0.11, 0.09);
}
}
dispose(): void {
this.mesh.geometry.dispose();
this.material.dispose();
@ -437,6 +457,14 @@ void main() {
// torn bread is rough
N = normalize(N + vec3(dmgN - 0.5, 0.0, fbm(vUv * 60.0 + 44.0) - 0.5) * dmg * 0.9);
// Every colour above — the ramp, the crumb, the crust, the spread — is authored
// the way a human picks colours: as sRGB. The lighting below is linear. Without
// this line those values are read as if they were already linear, which lifts
// everything: near-black MITEY renders as tan (linear 0.14 encodes back out to
// sRGB 0.4) and saturated butter washes to pale cream. Mixing happens in sRGB
// on purpose — that's the space the palette was chosen in.
albedo = pow(albedo, vec3(2.2));
// --- lighting ---
vec3 L = normalize(uLightDir);
vec3 V = normalize(cameraPosition - vPosW);
@ -450,10 +478,13 @@ void main() {
// so pair a broad sheen with a fresnel rim — that's the cue that says "wet".
float gloss = uSpreadGloss * smoothstep(0.008, 0.09, spread) * topFace;
gloss *= 1.0 + uWarmth * 0.6;
// Keep the lobe tight: a broad one just washes the whole slice white and drowns
// the colour. Tight + the thickness ridges = glints along the knife marks.
float shin = mix(18.0, 52.0, uSpreadGloss);
float spec = pow(max(dot(N, H), 0.0), shin) * gloss * 0.6;
// The lobe has to be TIGHT. The slice is flat and both the light and the camera
// are above it, so dot(N,H) is ~0.98 across the whole surface — any broad lobe
// blankets it in white and lifts near-black MITEY to tan. Tight, and the only
// thing that catches is a ridge tilted into the light, which is the actual look
// of a spread: dark film, bright knife marks.
float shin = mix(40.0, 170.0, uSpreadGloss);
float spec = pow(max(dot(N, H), 0.0), shin) * gloss * 0.9;
float fres = pow(1.0 - max(dot(N, V), 0.0), 3.0);
spec += fres * gloss * 0.22;
spec *= 1.0 - char * 0.7;

View File

@ -174,3 +174,264 @@ canvas#c {
width: 0;
border-radius: 3px;
}
/* ---- the order ticket ---- */
.ticket {
position: absolute;
top: 22px;
left: 24px;
width: 268px;
padding: 16px 18px 14px;
color: #2b2118;
background: #efe4cf;
border-radius: 3px;
box-shadow: 0 10px 26px rgba(0, 0, 0, 0.5);
transform: rotate(-0.7deg);
}
.ticket-day {
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.22em;
color: #9b8b74;
}
.ticket-who {
font-size: 12px;
font-style: italic;
color: #7d6d58;
margin-top: 2px;
}
.ticket-text {
margin-top: 8px;
font-size: 14px;
line-height: 1.4;
}
.ticket-spec {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-top: 11px;
}
.chip {
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 3px 7px;
border-radius: 3px;
background: rgba(43, 33, 24, 0.1);
border: 1px solid rgba(43, 33, 24, 0.16);
}
.chip.warn {
background: rgba(192, 57, 43, 0.14);
border-color: rgba(192, 57, 43, 0.4);
color: #8e2c21;
}
.chip.tool {
background: rgba(40, 80, 130, 0.12);
border-color: rgba(40, 80, 130, 0.3);
color: #2d5480;
}
.ticket-hint {
margin-top: 9px;
font-size: 11px;
color: #8a7963;
font-style: italic;
}
/* ---- the scorecard ---- */
.judge-panel {
position: absolute;
inset: 0;
display: flex;
justify-content: space-between;
align-items: stretch;
pointer-events: none;
}
.judge-panel > * {
pointer-events: auto;
}
.judge-left {
width: 33%;
max-width: 400px;
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: 0 0 26px 26px;
}
.judge-face {
width: 210px;
align-self: flex-start;
filter: drop-shadow(0 12px 26px rgba(0, 0, 0, 0.6));
}
.judge-lines {
margin-top: 8px;
max-width: 380px;
}
.judge-lines p {
font-size: 17px;
line-height: 1.42;
margin-bottom: 7px;
color: var(--ink);
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.9);
}
.judge-lines p:first-child {
color: var(--ink-dim);
font-size: 14px;
}
.judge-right {
width: 380px;
padding: 26px 26px 26px 0;
display: flex;
flex-direction: column;
justify-content: center;
}
.judge-order {
font-size: 11px;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--ink-dim);
margin-bottom: 12px;
text-align: right;
}
.judge-card {
background: rgba(12, 9, 8, 0.76);
border: 1px solid rgba(243, 233, 220, 0.14);
border-radius: 9px;
padding: 14px 16px;
backdrop-filter: blur(9px);
}
.crit {
display: grid;
grid-template-columns: 96px 1fr auto;
align-items: center;
gap: 10px;
padding: 4px 0;
}
.crit-name {
font-size: 10px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--ink-dim);
}
.crit-bar {
height: 6px;
border-radius: 3px;
background: rgba(255, 255, 255, 0.09);
overflow: hidden;
}
.crit-fill {
display: block;
height: 100%;
border-radius: 3px;
}
.crit-detail {
font-family: var(--mono);
font-size: 10px;
color: var(--ink-dim);
text-align: right;
white-space: nowrap;
}
.crit.total {
border-top: 1px solid rgba(243, 233, 220, 0.16);
margin-top: 7px;
padding-top: 9px;
}
.crit.total .crit-name,
.crit.total .crit-detail {
color: var(--gold);
font-size: 13px;
}
.judge-foot {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 16px;
}
.judge-stamp {
font-size: 56px;
font-weight: 800;
line-height: 1;
padding: 4px 18px;
border: 4px solid currentColor;
border-radius: 7px;
transform: rotate(-9deg);
animation: stamp 0.42s cubic-bezier(0.2, 1.7, 0.4, 1);
}
@keyframes stamp {
0% {
transform: rotate(-9deg) scale(3.2);
opacity: 0;
}
60% {
opacity: 1;
}
100% {
transform: rotate(-9deg) scale(1);
opacity: 1;
}
}
.grade-S { color: #ffd76a; }
.grade-A { color: #7fd07f; }
.grade-B { color: #cfc4b2; }
.grade-C { color: #d99a4e; }
.grade-F { color: #d0483a; }
.judge-btns {
display: flex;
gap: 8px;
}
.btn {
font-family: var(--font);
font-size: 11px;
letter-spacing: 0.13em;
padding: 10px 16px;
border-radius: 6px;
border: 1px solid var(--gold);
background: var(--gold);
color: #211a12;
cursor: pointer;
font-weight: 700;
}
.btn:hover {
filter: brightness(1.12);
}
.btn.ghost {
background: transparent;
color: var(--ink-dim);
border-color: rgba(243, 233, 220, 0.24);
font-weight: 500;
}
.btn.ghost:hover {
color: var(--ink);
}