diff --git a/src/dev.ts b/src/dev.ts index a8c9ad7..b0c9422 100644 --- a/src/dev.ts +++ b/src/dev.ts @@ -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, diff --git a/src/game/game.ts b/src/game/game.ts new file mode 100644 index 0000000..fbe7c16 --- /dev/null +++ b/src/game/game.ts @@ -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; +} + +/** + * 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; + 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 }; diff --git a/src/game/judging.ts b/src/game/judging.ts new file mode 100644 index 0000000..1a137c2 --- /dev/null +++ b/src/game/judging.ts @@ -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'; +} diff --git a/src/game/lines.ts b/src/game/lines.ts new file mode 100644 index 0000000..8df7320 --- /dev/null +++ b/src/game/lines.ts @@ -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; + +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 = { + 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 = { + 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 = (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; +} diff --git a/src/game/orders.ts b/src/game/orders.ts new file mode 100644 index 0000000..febc620 --- /dev/null +++ b/src/game/orders.ts @@ -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 = (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: '', + }; +} diff --git a/src/main.ts b/src/main.ts index 79b7d69..1712d1e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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(); diff --git a/src/scenes/judge.ts b/src/scenes/judge.ts new file mode 100644 index 0000000..d285d67 --- /dev/null +++ b/src/scenes/judge.ts @@ -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(); + } +} diff --git a/src/scenes/kitchen.ts b/src/scenes/kitchen.ts index 281bfd1..ff4b34a 100644 --- a/src/scenes/kitchen.ts +++ b/src/scenes/kitchen.ts @@ -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); diff --git a/src/sim/slice.ts b/src/sim/slice.ts index 21f2863..574bcc0 100644 --- a/src/sim/slice.ts +++ b/src/sim/slice.ts @@ -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; diff --git a/src/style.css b/src/style.css index 72251c6..859fe94 100644 --- a/src/style.css +++ b/src/style.css @@ -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); +}