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:
parent
eb74170011
commit
b07d57e9e9
@ -1,6 +1,6 @@
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import type { App } from './core/app';
|
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
|
* 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.
|
* 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 v = new THREE.Vector3();
|
||||||
const toNdc = (x: number, y: number, z: number): [number, number] => {
|
const toNdc = (x: number, y: number, z: number): [number, number] => {
|
||||||
v.set(x, y, z).project(app.camera);
|
v.set(x, y, z).project(app.camera);
|
||||||
@ -34,6 +35,7 @@ export function installDevHarness(app: App, kitchen: KitchenView): void {
|
|||||||
|
|
||||||
const harness = {
|
const harness = {
|
||||||
app,
|
app,
|
||||||
|
game,
|
||||||
kitchen,
|
kitchen,
|
||||||
THREE,
|
THREE,
|
||||||
run,
|
run,
|
||||||
|
|||||||
149
src/game/game.ts
Normal file
149
src/game/game.ts
Normal 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
182
src/game/judging.ts
Normal 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
203
src/game/lines.ts
Normal 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
174
src/game/orders.ts
Normal 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: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { App, makeEnvironment, makeLights } from './core/app';
|
import { App, makeEnvironment, makeLights } from './core/app';
|
||||||
import { KitchenView } from './scenes/kitchen';
|
import { Game } from './game/game';
|
||||||
import './style.css';
|
import './style.css';
|
||||||
|
|
||||||
const canvas = document.getElementById('c') as HTMLCanvasElement;
|
const canvas = document.getElementById('c') as HTMLCanvasElement;
|
||||||
@ -9,12 +9,10 @@ makeLights(app.scene);
|
|||||||
makeEnvironment(app);
|
makeEnvironment(app);
|
||||||
app.scene.background = new THREE.Color(0x241a15);
|
app.scene.background = new THREE.Color(0x241a15);
|
||||||
|
|
||||||
const kitchen = new KitchenView(app, 3);
|
const game = new Game(app);
|
||||||
app.scene.add(kitchen.root);
|
|
||||||
app.setView(kitchen);
|
|
||||||
|
|
||||||
if (import.meta.env.DEV) {
|
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();
|
app.start();
|
||||||
|
|||||||
169
src/scenes/judge.ts
Normal file
169
src/scenes/judge.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -17,6 +17,7 @@ import { TOOLS, type ToolId } from '../sim/cutlery';
|
|||||||
import { makeBench, makePlate, makeToaster, type Toaster } from './props';
|
import { makeBench, makePlate, makeToaster, type Toaster } from './props';
|
||||||
import { KnifeRig, makeSpreadPot } from './spreadrig';
|
import { KnifeRig, makeSpreadPot } from './spreadrig';
|
||||||
import { el, Panel } from '../ui/hud';
|
import { el, Panel } from '../ui/hud';
|
||||||
|
import type { Order } from '../game/orders';
|
||||||
|
|
||||||
const TOASTER_X = -1.5;
|
const TOASTER_X = -1.5;
|
||||||
const PLATE_X = 1.45;
|
const PLATE_X = 1.45;
|
||||||
@ -80,6 +81,14 @@ export class KitchenView implements View {
|
|||||||
private gaugeScrape!: HTMLElement;
|
private gaugeScrape!: HTMLElement;
|
||||||
private modeEl!: HTMLElement;
|
private modeEl!: HTMLElement;
|
||||||
private loadFill!: 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(
|
constructor(
|
||||||
private app: App,
|
private app: App,
|
||||||
@ -126,6 +135,7 @@ export class KitchenView implements View {
|
|||||||
const row2 = el('div', 'row', p);
|
const row2 = el('div', 'row', p);
|
||||||
el('label', 'lbl', row2, 'BREAD');
|
el('label', 'lbl', row2, 'BREAD');
|
||||||
const breadSel = el('select', 'sel', row2);
|
const breadSel = el('select', 'sel', row2);
|
||||||
|
this.breadSel = breadSel;
|
||||||
for (const id of Object.keys(BREADS)) {
|
for (const id of Object.keys(BREADS)) {
|
||||||
const o = el('option', undefined, breadSel, BREADS[id as BreadId].name);
|
const o = el('option', undefined, breadSel, BREADS[id as BreadId].name);
|
||||||
o.value = id;
|
o.value = id;
|
||||||
@ -135,6 +145,7 @@ export class KitchenView implements View {
|
|||||||
const row3 = el('div', 'row', p);
|
const row3 = el('div', 'row', p);
|
||||||
el('label', 'lbl', row3, 'SPREAD');
|
el('label', 'lbl', row3, 'SPREAD');
|
||||||
const spreadSel = el('select', 'sel', row3);
|
const spreadSel = el('select', 'sel', row3);
|
||||||
|
this.spreadSel = spreadSel;
|
||||||
for (const id of Object.keys(SPREADS)) {
|
for (const id of Object.keys(SPREADS)) {
|
||||||
const o = el('option', undefined, spreadSel, SPREADS[id as SpreadId].name);
|
const o = el('option', undefined, spreadSel, SPREADS[id as SpreadId].name);
|
||||||
o.value = id;
|
o.value = id;
|
||||||
@ -144,6 +155,7 @@ export class KitchenView implements View {
|
|||||||
const row4 = el('div', 'row', p);
|
const row4 = el('div', 'row', p);
|
||||||
el('label', 'lbl', row4, 'TOOL');
|
el('label', 'lbl', row4, 'TOOL');
|
||||||
const toolSel = el('select', 'sel', row4);
|
const toolSel = el('select', 'sel', row4);
|
||||||
|
this.toolSel = toolSel;
|
||||||
for (const id of Object.keys(TOOLS)) {
|
for (const id of Object.keys(TOOLS)) {
|
||||||
const o = el('option', undefined, toolSel, TOOLS[id as ToolId].name);
|
const o = el('option', undefined, toolSel, TOOLS[id as ToolId].name);
|
||||||
o.value = id;
|
o.value = id;
|
||||||
@ -156,11 +168,13 @@ export class KitchenView implements View {
|
|||||||
const row5 = el('div', 'row', p);
|
const row5 = el('div', 'row', p);
|
||||||
el('label', 'lbl', row5, 'BUTTER');
|
el('label', 'lbl', row5, 'BUTTER');
|
||||||
const soft = el('input', 'dial', row5);
|
const soft = el('input', 'dial', row5);
|
||||||
|
this.softInput = soft;
|
||||||
soft.type = 'range';
|
soft.type = 'range';
|
||||||
soft.min = '0';
|
soft.min = '0';
|
||||||
soft.max = '100';
|
soft.max = '100';
|
||||||
soft.value = String(this.butterSoftness * 100);
|
soft.value = String(this.butterSoftness * 100);
|
||||||
const softVal = el('span', 'val', row5, 'hard');
|
const softVal = el('span', 'val', row5, 'hard');
|
||||||
|
this.softVal = softVal;
|
||||||
soft.addEventListener('input', () => {
|
soft.addEventListener('input', () => {
|
||||||
this.butterSoftness = +soft.value / 100;
|
this.butterSoftness = +soft.value / 100;
|
||||||
softVal.textContent =
|
softVal.textContent =
|
||||||
@ -226,6 +240,26 @@ export class KitchenView implements View {
|
|||||||
return this.slice;
|
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 {
|
private placeInSlot(t: number): void {
|
||||||
const y = this.toaster.slotY + 0.16 - t * 0.74;
|
const y = this.toaster.slotY + 0.16 - t * 0.74;
|
||||||
this.slice.mesh.position.set(TOASTER_X, y, -0.35);
|
this.slice.mesh.position.set(TOASTER_X, y, -0.35);
|
||||||
@ -268,7 +302,7 @@ export class KitchenView implements View {
|
|||||||
this.knifeRig.root.visible = true;
|
this.knifeRig.root.visible = true;
|
||||||
if (this.pot) this.pot.visible = true;
|
if (this.pot) this.pot.visible = true;
|
||||||
this.dialRow.style.opacity = '0.35';
|
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 {
|
update(dt: number): void {
|
||||||
@ -277,6 +311,7 @@ export class KitchenView implements View {
|
|||||||
if (this.phase === 'ready') this.pushDown();
|
if (this.phase === 'ready') this.pushDown();
|
||||||
else if (this.phase === 'toasting') this.pop();
|
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;
|
const target = this.phase === 'ready' ? 0 : this.phase === 'toasting' ? 1 : 0;
|
||||||
this.leverT += (target - this.leverT) * Math.min(1, dt * 14);
|
this.leverT += (target - this.leverT) * Math.min(1, dt * 14);
|
||||||
|
|||||||
@ -142,6 +142,26 @@ export class Slice {
|
|||||||
this.material.uniforms.uHeatmap.value = mode;
|
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 {
|
dispose(): void {
|
||||||
this.mesh.geometry.dispose();
|
this.mesh.geometry.dispose();
|
||||||
this.material.dispose();
|
this.material.dispose();
|
||||||
@ -437,6 +457,14 @@ void main() {
|
|||||||
// torn bread is rough
|
// torn bread is rough
|
||||||
N = normalize(N + vec3(dmgN - 0.5, 0.0, fbm(vUv * 60.0 + 44.0) - 0.5) * dmg * 0.9);
|
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 ---
|
// --- lighting ---
|
||||||
vec3 L = normalize(uLightDir);
|
vec3 L = normalize(uLightDir);
|
||||||
vec3 V = normalize(cameraPosition - vPosW);
|
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".
|
// 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;
|
float gloss = uSpreadGloss * smoothstep(0.008, 0.09, spread) * topFace;
|
||||||
gloss *= 1.0 + uWarmth * 0.6;
|
gloss *= 1.0 + uWarmth * 0.6;
|
||||||
// Keep the lobe tight: a broad one just washes the whole slice white and drowns
|
// The lobe has to be TIGHT. The slice is flat and both the light and the camera
|
||||||
// the colour. Tight + the thickness ridges = glints along the knife marks.
|
// are above it, so dot(N,H) is ~0.98 across the whole surface — any broad lobe
|
||||||
float shin = mix(18.0, 52.0, uSpreadGloss);
|
// blankets it in white and lifts near-black MITEY to tan. Tight, and the only
|
||||||
float spec = pow(max(dot(N, H), 0.0), shin) * gloss * 0.6;
|
// 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);
|
float fres = pow(1.0 - max(dot(N, V), 0.0), 3.0);
|
||||||
spec += fres * gloss * 0.22;
|
spec += fres * gloss * 0.22;
|
||||||
spec *= 1.0 - char * 0.7;
|
spec *= 1.0 - char * 0.7;
|
||||||
|
|||||||
261
src/style.css
261
src/style.css
@ -174,3 +174,264 @@ canvas#c {
|
|||||||
width: 0;
|
width: 0;
|
||||||
border-radius: 3px;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user