Compare commits
No commits in common. "57a68ad4cf20baaf6e4533bbd147b6e3989715a7" and "32d1cced69cfe34ce9375abbc2c3898c1300cbc7" have entirely different histories.
57a68ad4cf
...
32d1cced69
Binary file not shown.
195
src/dev-grate.ts
195
src/dev-grate.ts
@ -1,195 +0,0 @@
|
|||||||
import * as THREE from 'three';
|
|
||||||
import type { App } from './core/app';
|
|
||||||
import { GrateView } from './scenes/grate';
|
|
||||||
import type { IngredientId } from './sim/ingredients';
|
|
||||||
import type { GraterId } from './sim/grating';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Grate-station dev harness — a SEPARATE installer from dev.ts and dev-juice.ts
|
|
||||||
* so the three lanes don't collide. It owns its own GrateView (the game doesn't
|
|
||||||
* route orders here yet — that's the game lane's job), adds it to the scene, and
|
|
||||||
* drives the REAL input path: real vertical pointer drags down the grater face
|
|
||||||
* for the strokes, `press` injected the way dev.ts injects kitchen.power.
|
|
||||||
*
|
|
||||||
* The reviewer verifies with a handful of calls, e.g.
|
|
||||||
* __tg.enter({ ingredient: 'parmesan' }); __tg.stroke(8, 0.7, 0.9); __tg.stats()
|
|
||||||
* → a good cold-parmesan pile (~0.5 shred), no knuckles, clean bench.
|
|
||||||
* __tg.enter({ ingredient: 'parmesan' }); __tg.stroke(10, 0.6, 0.25); __tg.stats()
|
|
||||||
* → scrubby short strokes: much of it strays off the board, bench dirtied.
|
|
||||||
* __tg.enter({ ingredient: 'parmesan', softness: 1 }); __tg.stroke(8, 0.8, 0.9); __tg.stats()
|
|
||||||
* → warm cheese: smear + clog climb, gratings drop.
|
|
||||||
* __tg.enter({ ingredient: 'parmesan', block: 0.25 }); __tg.stroke(8, 0.95, 0.9); __tg.stats()
|
|
||||||
* → small block + hard press: knuckleHits > 0 (the tense end).
|
|
||||||
* __tg.enter({ ingredient: 'orange' }); __tg.stroke(3, 0.7, 0.8); __tg.stats()
|
|
||||||
* → clean zest, low pith (rotate before it whitens).
|
|
||||||
* __tg.enter({ ingredient: 'orange' }); __tg.stroke(8, 0.9, 0.9); __tg.stats()
|
|
||||||
* → no rotation: pithFrac climbs, "The Pith" tanks.
|
|
||||||
*
|
|
||||||
* Dev builds only.
|
|
||||||
*/
|
|
||||||
export function installGrateHarness(app: App): void {
|
|
||||||
const view = new GrateView(app);
|
|
||||||
app.scene.add(view.root);
|
|
||||||
// Hidden until __tg.enter() makes it the active view.
|
|
||||||
view.root.visible = false;
|
|
||||||
|
|
||||||
const sessionOf = () =>
|
|
||||||
(view as unknown as {
|
|
||||||
session: {
|
|
||||||
placed: boolean;
|
|
||||||
mode: 'cheese' | 'zest';
|
|
||||||
blockMass: number;
|
|
||||||
initialBlock: number;
|
|
||||||
shred: number;
|
|
||||||
softness: number;
|
|
||||||
clog: number;
|
|
||||||
smearTotal: number;
|
|
||||||
zestShed: number;
|
|
||||||
pithShed: number;
|
|
||||||
faces: number[];
|
|
||||||
faceIndex: number;
|
|
||||||
strayTotal: number;
|
|
||||||
knuckleHits: number;
|
|
||||||
bloodied: boolean;
|
|
||||||
strokeCount: number;
|
|
||||||
totalTravel: number;
|
|
||||||
} | null;
|
|
||||||
}).session;
|
|
||||||
|
|
||||||
const v = new THREE.Vector3();
|
|
||||||
const point = (x: number, y: number, z: number, down: boolean) => {
|
|
||||||
v.set(x, y, z).project(app.camera);
|
|
||||||
app.input.ndc.set(v.x, v.y);
|
|
||||||
app.input.down = down;
|
|
||||||
};
|
|
||||||
const run = (frames: number, dt = 1 / 60) => {
|
|
||||||
for (let i = 0; i < frames; i++) app.step(dt);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Must match the face geometry in grate.ts.
|
|
||||||
const FACE_TOP = 1.15;
|
|
||||||
const FACE_BOT = 0.28;
|
|
||||||
const FACE_H = FACE_TOP - FACE_BOT;
|
|
||||||
const FACE_Z = 0.1;
|
|
||||||
|
|
||||||
const harness = {
|
|
||||||
app,
|
|
||||||
view,
|
|
||||||
THREE,
|
|
||||||
run,
|
|
||||||
point,
|
|
||||||
|
|
||||||
/** Reset the station and make it the active view. */
|
|
||||||
enter(
|
|
||||||
opts: {
|
|
||||||
ingredient?: IngredientId;
|
|
||||||
grater?: GraterId;
|
|
||||||
block?: number;
|
|
||||||
softness?: number;
|
|
||||||
} = {},
|
|
||||||
) {
|
|
||||||
const { ingredient = 'parmesan', grater = 'box_grater', block, softness } = opts;
|
|
||||||
view.reset(grater, ingredient, `grate the ${ingredient} (${grater})`, {
|
|
||||||
blockMass: block,
|
|
||||||
softness,
|
|
||||||
});
|
|
||||||
app.setView(view);
|
|
||||||
run(3); // let the view take the camera before we project points
|
|
||||||
return harness.stats();
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `n` strokes DOWN the face at a fixed `pressure` (0..1) and `length` (0..1,
|
|
||||||
* fraction of the full face). The press is injected; the stroke itself is
|
|
||||||
* real pointer motion down the face, so the sim sees exactly what a hand
|
|
||||||
* would produce — length is measured from the travel, not passed in.
|
|
||||||
*/
|
|
||||||
stroke(n = 8, pressure = 0.7, length = 0.9) {
|
|
||||||
view.autoPress = false;
|
|
||||||
view.press = pressure;
|
|
||||||
const travel = Math.max(0.05, Math.min(1, length)) * FACE_H;
|
|
||||||
const startY = FACE_TOP - 0.05;
|
|
||||||
const steps = 10;
|
|
||||||
for (let k = 0; k < n; k++) {
|
|
||||||
// Drag down the face.
|
|
||||||
for (let i = 0; i <= steps; i++) {
|
|
||||||
const y = startY - (i / steps) * travel;
|
|
||||||
point(0, y, FACE_Z, true);
|
|
||||||
run(1);
|
|
||||||
}
|
|
||||||
// Lift → the stroke commits.
|
|
||||||
point(0, startY - travel, FACE_Z, false);
|
|
||||||
run(1);
|
|
||||||
}
|
|
||||||
return harness.stats();
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Rotate the orange to a fresh region (scroll in play). Zest only. */
|
|
||||||
rotate(times = 1, dir: 1 | -1 = 1) {
|
|
||||||
const s = sessionOf();
|
|
||||||
for (let i = 0; i < times; i++) {
|
|
||||||
app.input.wheel = dir;
|
|
||||||
run(1);
|
|
||||||
}
|
|
||||||
return { faceIndex: s?.faceIndex ?? 0 };
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Advance the bench temperature clock — warm the cheese `seconds` in place. */
|
|
||||||
warm(seconds = 5) {
|
|
||||||
app.input.down = false;
|
|
||||||
run(Math.round(seconds * 60));
|
|
||||||
return harness.stats();
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Clear the clogged teeth (costs the player service time in play). */
|
|
||||||
clear() {
|
|
||||||
view.clearTeeth();
|
|
||||||
run(1);
|
|
||||||
return harness.stats();
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Everything the judge and the readouts will read. */
|
|
||||||
stats() {
|
|
||||||
const s = sessionOf();
|
|
||||||
const r = view.result();
|
|
||||||
const round = (n: number) => +n.toFixed(3);
|
|
||||||
return {
|
|
||||||
placed: s?.placed ?? false,
|
|
||||||
mode: r.mode,
|
|
||||||
// The pile the judge scores as an amount band.
|
|
||||||
gratings: round(r.gratings),
|
|
||||||
shred: round(s?.shred ?? 0),
|
|
||||||
zest: round(s?.zestShed ?? 0),
|
|
||||||
pith: round(r.pith),
|
|
||||||
pithFrac: round(r.pithFrac),
|
|
||||||
blockLeft: round(r.blockLeft),
|
|
||||||
blockMass: round(s?.blockMass ?? 0),
|
|
||||||
softness: round(s?.softness ?? 0),
|
|
||||||
clog: round(r.clog),
|
|
||||||
smear: round(r.smear),
|
|
||||||
stray: round(s?.strayTotal ?? 0),
|
|
||||||
knuckleHits: r.knuckleHits,
|
|
||||||
bloodied: r.bloodied,
|
|
||||||
strokeCount: s?.strokeCount ?? 0,
|
|
||||||
totalTravel: round(s?.totalTravel ?? 0),
|
|
||||||
faceIndex: s?.faceIndex ?? 0,
|
|
||||||
bench: {
|
|
||||||
mass: round(r.bench?.mass ?? 0),
|
|
||||||
spreadFrac: round(r.bench?.spreadFrac ?? 0),
|
|
||||||
solids: r.bench?.solids ?? 0,
|
|
||||||
},
|
|
||||||
benchWord: view.mess.benchWord(),
|
|
||||||
grater: r.graterName,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Move the camera for a screenshot without disturbing the sim. */
|
|
||||||
look(pos: [number, number, number], at: [number, number, number]) {
|
|
||||||
app.camera.position.set(...pos);
|
|
||||||
app.camera.lookAt(new THREE.Vector3(...at));
|
|
||||||
app.renderer.render(app.scene, app.camera);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
(window as unknown as { __tg: unknown }).__tg = harness;
|
|
||||||
}
|
|
||||||
@ -17,7 +17,6 @@ void game.init();
|
|||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
void import('./dev').then((m) => m.installDevHarness(app, game));
|
void import('./dev').then((m) => m.installDevHarness(app, game));
|
||||||
void import('./dev-juice').then((m) => m.installJuiceHarness(app));
|
void import('./dev-juice').then((m) => m.installJuiceHarness(app));
|
||||||
void import('./dev-grate').then((m) => m.installGrateHarness(app));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
app.start();
|
app.start();
|
||||||
|
|||||||
@ -1,569 +0,0 @@
|
|||||||
import * as THREE from 'three';
|
|
||||||
import type { App, View } from '../core/app';
|
|
||||||
import { audio } from '../core/audio';
|
|
||||||
import { INGREDIENTS, type IngredientId } from '../sim/ingredients';
|
|
||||||
import {
|
|
||||||
clearClog,
|
|
||||||
grateResult,
|
|
||||||
grateStroke,
|
|
||||||
GRATERS,
|
|
||||||
newGrateSession,
|
|
||||||
placeBlock,
|
|
||||||
rotateOrange,
|
|
||||||
warmOnBench,
|
|
||||||
amountWord,
|
|
||||||
type GrateResult,
|
|
||||||
type GrateSession,
|
|
||||||
type GraterId,
|
|
||||||
} from '../sim/grating';
|
|
||||||
import { Mess } from '../sim/mess';
|
|
||||||
import { Rng } from '../core/rng';
|
|
||||||
import { el, Panel } from '../ui/hud';
|
|
||||||
import { loadProp } from './assets';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The grate station. One box grater, one block (or orange), one growing pile.
|
|
||||||
*
|
|
||||||
* It mirrors the juice bench on purpose: procedural stand-in silhouettes (the
|
|
||||||
* rasped face IS gameplay — the teeth are why a stroke bites), a mess field
|
|
||||||
* that remembers the shreds that sprayed off the board, a GLB prop that drops
|
|
||||||
* in over the stand-in if it renders, and a HUD that reads the same numbers the
|
|
||||||
* judge will.
|
|
||||||
*
|
|
||||||
* The one verb is a STROKE: press the block onto the face (hold to lean in —
|
|
||||||
* the dwell IS the press) and drag DOWN. The vertical travel of the pointer on
|
|
||||||
* the face is the stroke length; long confident strokes yield and land on the
|
|
||||||
* pile, short scrubby ones shear off the board. As the block shrinks your
|
|
||||||
* knuckles near the teeth — press hard on a small block and you'll find out.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const TRAY_Y = 0.06;
|
|
||||||
const TRAY_W = 3.2;
|
|
||||||
const TRAY_D = 2.4;
|
|
||||||
/** The grater face is a vertical plane in front of the camera; the stroke is
|
|
||||||
* measured as vertical travel of the pointer hit on it. */
|
|
||||||
const FACE_Z = 0.1;
|
|
||||||
const FACE_TOP = 1.15;
|
|
||||||
const FACE_BOT = 0.28;
|
|
||||||
const FACE_H = FACE_TOP - FACE_BOT;
|
|
||||||
/** Where the pile of shreds mounds up, in front of the grater's foot. */
|
|
||||||
const PILE_Z = 0.7;
|
|
||||||
|
|
||||||
/** Press ramps in while you hold the block against the face. */
|
|
||||||
const PRESS_RAMP = 1.8;
|
|
||||||
|
|
||||||
export class GrateView implements View {
|
|
||||||
readonly root = new THREE.Group();
|
|
||||||
readonly mess = new Mess(96);
|
|
||||||
|
|
||||||
private session: GrateSession | null = null;
|
|
||||||
private ingId: IngredientId = 'parmesan';
|
|
||||||
|
|
||||||
private grater!: THREE.Group;
|
|
||||||
private block: THREE.Group | null = null;
|
|
||||||
private pile!: THREE.InstancedMesh;
|
|
||||||
private pileFlakes: { u: number; v: number; s: number; pith: boolean }[] = [];
|
|
||||||
private pileGeo = new THREE.BoxGeometry(1, 1, 1);
|
|
||||||
private pileMat: THREE.MeshStandardMaterial;
|
|
||||||
private pithMat: THREE.MeshStandardMaterial;
|
|
||||||
|
|
||||||
private messTexData: Uint8Array;
|
|
||||||
private messTex: THREE.DataTexture;
|
|
||||||
private messVersion = -1;
|
|
||||||
private solidsMesh: THREE.InstancedMesh;
|
|
||||||
|
|
||||||
private ray = new THREE.Raycaster();
|
|
||||||
private facePlane = new THREE.Plane(new THREE.Vector3(0, 0, 1), -FACE_Z);
|
|
||||||
private hitPt = new THREE.Vector3();
|
|
||||||
|
|
||||||
// Stroke state: track the pointer's vertical travel down the face.
|
|
||||||
private stroking = false;
|
|
||||||
private strokeLastY = 0;
|
|
||||||
private strokeTravel = 0;
|
|
||||||
private holdT = 0;
|
|
||||||
private flash = 0;
|
|
||||||
|
|
||||||
/** Downforce 0..1. Set by the pointer hold in play; the harness writes it. */
|
|
||||||
press = 0;
|
|
||||||
/** When false, the harness owns `press` and the hold-ramp is skipped. */
|
|
||||||
autoPress = true;
|
|
||||||
|
|
||||||
private rng = new Rng(0x6ac0f);
|
|
||||||
private panel: Panel;
|
|
||||||
private flashEl!: HTMLElement;
|
|
||||||
private askEl!: HTMLElement;
|
|
||||||
private amountEl!: HTMLElement;
|
|
||||||
private amountBar!: HTMLElement;
|
|
||||||
private amountFill!: HTMLElement;
|
|
||||||
private stateEl!: HTMLElement;
|
|
||||||
private hintEl!: HTMLElement;
|
|
||||||
|
|
||||||
onDone: ((result: GrateResult) => void) | null = null;
|
|
||||||
|
|
||||||
constructor(private app: App) {
|
|
||||||
this.pileMat = new THREE.MeshStandardMaterial({ color: 0xede0b0, roughness: 0.7 });
|
|
||||||
this.pithMat = new THREE.MeshStandardMaterial({ color: 0xf4f1e6, roughness: 0.8 });
|
|
||||||
this.buildScenery();
|
|
||||||
|
|
||||||
const n = this.mess.field.n;
|
|
||||||
this.messTexData = new Uint8Array(n * n * 4);
|
|
||||||
this.messTex = new THREE.DataTexture(this.messTexData, n, n, THREE.RGBAFormat);
|
|
||||||
this.messTex.minFilter = THREE.LinearFilter;
|
|
||||||
this.messTex.magFilter = THREE.LinearFilter;
|
|
||||||
const stain = new THREE.Mesh(
|
|
||||||
new THREE.PlaneGeometry(TRAY_W, TRAY_D),
|
|
||||||
new THREE.MeshBasicMaterial({ map: this.messTex, transparent: true, depthWrite: false }),
|
|
||||||
);
|
|
||||||
stain.rotation.x = -Math.PI / 2;
|
|
||||||
stain.position.set(0, TRAY_Y * 2 + 0.004, 0);
|
|
||||||
this.root.add(stain);
|
|
||||||
|
|
||||||
// The pile: little instanced flakes mounding in front of the grater.
|
|
||||||
this.pile = new THREE.InstancedMesh(this.pileGeo, this.pileMat, 512);
|
|
||||||
this.pile.count = 0;
|
|
||||||
this.pile.castShadow = true;
|
|
||||||
this.pile.instanceColor = new THREE.InstancedBufferAttribute(new Float32Array(512 * 3), 3);
|
|
||||||
this.root.add(this.pile);
|
|
||||||
|
|
||||||
const seedGeo = new THREE.SphereGeometry(0.02, 6, 5);
|
|
||||||
seedGeo.scale(1.4, 0.5, 0.9);
|
|
||||||
this.solidsMesh = new THREE.InstancedMesh(
|
|
||||||
seedGeo,
|
|
||||||
new THREE.MeshStandardMaterial({ color: 0xe6d488, roughness: 0.7 }),
|
|
||||||
256,
|
|
||||||
);
|
|
||||||
this.solidsMesh.count = 0;
|
|
||||||
this.solidsMesh.castShadow = true;
|
|
||||||
this.root.add(this.solidsMesh);
|
|
||||||
|
|
||||||
this.panel = new Panel('grate-panel');
|
|
||||||
this.buildUi();
|
|
||||||
this.panel.hide();
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildUi(): void {
|
|
||||||
const p = this.panel.root;
|
|
||||||
// A red flash overlay for the knuckle graze — brief, full-panel.
|
|
||||||
this.flashEl = el('div', 'grate-flash', p);
|
|
||||||
this.flashEl.style.cssText =
|
|
||||||
'position:fixed;inset:0;background:#c01818;opacity:0;pointer-events:none;transition:opacity 0.08s;';
|
|
||||||
const card = el('div', 'slicer-card', p);
|
|
||||||
el('div', 'drawer-lbl', card, 'GRATE IT');
|
|
||||||
this.askEl = el('div', 'slicer-ask', card, '');
|
|
||||||
this.amountEl = el('div', 'slicer-thick', card, '');
|
|
||||||
this.amountBar = el('div', 'timer-bar', card);
|
|
||||||
this.amountFill = el('div', 'timer-fill', this.amountBar);
|
|
||||||
this.stateEl = el('div', 'slicer-wobble', card, '');
|
|
||||||
this.hintEl = el('div', 'drawer-hint', p, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildScenery(): void {
|
|
||||||
const bench = new THREE.Mesh(
|
|
||||||
new THREE.BoxGeometry(24, 0.4, 12),
|
|
||||||
new THREE.MeshStandardMaterial({ color: 0x4a3524, roughness: 0.85 }),
|
|
||||||
);
|
|
||||||
bench.position.y = -0.2;
|
|
||||||
bench.receiveShadow = true;
|
|
||||||
this.root.add(bench);
|
|
||||||
|
|
||||||
this.grater = this.buildGrater();
|
|
||||||
this.root.add(this.grater);
|
|
||||||
void loadProp('/assets/models/box_grater.glb', 1.3)
|
|
||||||
.then((prop) => {
|
|
||||||
prop.position.set(0, TRAY_Y * 2, 0);
|
|
||||||
// Keep the procedural face as the stroke target; hide only the shell.
|
|
||||||
const shell = this.grater.getObjectByName('shell');
|
|
||||||
if (shell) shell.visible = false;
|
|
||||||
const face = this.grater.getObjectByName('face');
|
|
||||||
if (face) face.visible = false;
|
|
||||||
this.root.add(prop);
|
|
||||||
})
|
|
||||||
.catch(() => undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The box grater, procedural: a tapered four-sided shell with a rasped face
|
|
||||||
* turned toward the camera, and a handle on top. */
|
|
||||||
private buildGrater(): THREE.Group {
|
|
||||||
const g = new THREE.Group();
|
|
||||||
const metal = new THREE.MeshStandardMaterial({
|
|
||||||
color: 0xb9c0c8,
|
|
||||||
roughness: 0.35,
|
|
||||||
metalness: 0.8,
|
|
||||||
});
|
|
||||||
// Shell: a gently tapered box, hollow-looking from the coarse-teeth face.
|
|
||||||
const shell = new THREE.Mesh(new THREE.CylinderGeometry(0.34, 0.42, FACE_TOP - 0.12, 4), metal);
|
|
||||||
shell.name = 'shell';
|
|
||||||
shell.rotation.y = Math.PI / 4;
|
|
||||||
shell.position.y = (FACE_TOP - 0.12) / 2 + TRAY_Y * 2;
|
|
||||||
shell.castShadow = true;
|
|
||||||
shell.receiveShadow = true;
|
|
||||||
g.add(shell);
|
|
||||||
|
|
||||||
// The rasped face turned toward the camera (+z): a punched-hole grid that
|
|
||||||
// reads as teeth. This is the stroke target.
|
|
||||||
const face = new THREE.Group();
|
|
||||||
face.name = 'face';
|
|
||||||
const faceMat = new THREE.MeshStandardMaterial({
|
|
||||||
color: 0xd6dbe0,
|
|
||||||
roughness: 0.4,
|
|
||||||
metalness: 0.7,
|
|
||||||
side: THREE.DoubleSide,
|
|
||||||
});
|
|
||||||
const plate = new THREE.Mesh(new THREE.PlaneGeometry(0.5, FACE_H), faceMat);
|
|
||||||
plate.position.set(0, (FACE_TOP + FACE_BOT) / 2, 0.30);
|
|
||||||
face.add(plate);
|
|
||||||
// Teeth: rows of tiny raised nibs, purely to read as a grating surface.
|
|
||||||
const toothMat = new THREE.MeshStandardMaterial({ color: 0xf0f3f6, roughness: 0.3, metalness: 0.6 });
|
|
||||||
const cols = 6;
|
|
||||||
const rows = 9;
|
|
||||||
const teeth = new THREE.InstancedMesh(new THREE.ConeGeometry(0.018, 0.03, 4), toothMat, cols * rows);
|
|
||||||
const m = new THREE.Matrix4();
|
|
||||||
const q = new THREE.Quaternion().setFromEuler(new THREE.Euler(Math.PI / 2, 0, 0));
|
|
||||||
let ti = 0;
|
|
||||||
for (let r = 0; r < rows; r++) {
|
|
||||||
for (let c = 0; c < cols; c++) {
|
|
||||||
const x = (c / (cols - 1) - 0.5) * 0.44;
|
|
||||||
const y = FACE_BOT + 0.05 + (r / (rows - 1)) * (FACE_H - 0.1);
|
|
||||||
m.compose(new THREE.Vector3(x, y, 0.315), q, new THREE.Vector3(1, 1, 1));
|
|
||||||
teeth.setMatrixAt(ti++, m);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
teeth.instanceMatrix.needsUpdate = true;
|
|
||||||
face.add(teeth);
|
|
||||||
g.add(face);
|
|
||||||
|
|
||||||
// Handle across the top.
|
|
||||||
const handle = new THREE.Mesh(new THREE.TorusGeometry(0.16, 0.03, 8, 16, Math.PI), metal);
|
|
||||||
handle.position.set(0, FACE_TOP - 0.06, 0);
|
|
||||||
handle.rotation.x = Math.PI / 2;
|
|
||||||
g.add(handle);
|
|
||||||
return g;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A fresh block/orange on a chosen grater. `blockMass` sets a fresh corner;
|
|
||||||
* `softness` (0 cold .. 1 soft) is where the temperature clock starts.
|
|
||||||
*/
|
|
||||||
reset(
|
|
||||||
graterId: GraterId,
|
|
||||||
ingId: IngredientId,
|
|
||||||
askText: string,
|
|
||||||
opts: { blockMass?: number; softness?: number } = {},
|
|
||||||
): void {
|
|
||||||
this.ingId = ingId;
|
|
||||||
this.session = newGrateSession(INGREDIENTS[ingId], GRATERS[graterId], opts);
|
|
||||||
this.mess.reset();
|
|
||||||
this.press = 0;
|
|
||||||
this.holdT = 0;
|
|
||||||
this.stroking = false;
|
|
||||||
this.strokeTravel = 0;
|
|
||||||
this.autoPress = true;
|
|
||||||
this.flash = 0;
|
|
||||||
|
|
||||||
this.pileFlakes = [];
|
|
||||||
this.pile.count = 0;
|
|
||||||
|
|
||||||
if (this.block) this.root.remove(this.block);
|
|
||||||
this.block = this.buildBlock();
|
|
||||||
this.seatBlock();
|
|
||||||
this.root.add(this.block);
|
|
||||||
|
|
||||||
this.askEl.textContent = askText;
|
|
||||||
this.hintEl.textContent =
|
|
||||||
this.session.mode === 'zest'
|
|
||||||
? 'hold to press · stroke DOWN the face · scroll to rotate the orange · ENTER serves'
|
|
||||||
: 'hold to press · long strokes DOWN the face · ease off as it shrinks · ENTER serves';
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildBlock(): THREE.Group {
|
|
||||||
const g = new THREE.Group();
|
|
||||||
const ing = INGREDIENTS[this.ingId];
|
|
||||||
if (this.session?.mode === 'zest') {
|
|
||||||
const r = ing.size / 2;
|
|
||||||
const skin = new THREE.MeshStandardMaterial({
|
|
||||||
color: new THREE.Color().setRGB(...ing.colors.skin, THREE.SRGBColorSpace),
|
|
||||||
roughness: 0.6,
|
|
||||||
});
|
|
||||||
const ball = new THREE.Mesh(new THREE.SphereGeometry(r, 24, 16), skin);
|
|
||||||
ball.scale.set(1.05, 0.94, 1.05);
|
|
||||||
ball.castShadow = true;
|
|
||||||
g.add(ball);
|
|
||||||
} else {
|
|
||||||
const skin = new THREE.MeshStandardMaterial({
|
|
||||||
color: new THREE.Color().setRGB(...ing.colors.flesh, THREE.SRGBColorSpace),
|
|
||||||
roughness: 0.75,
|
|
||||||
});
|
|
||||||
const blk = new THREE.Mesh(new THREE.BoxGeometry(ing.size, ing.size * 0.7, ing.size * 0.7), skin);
|
|
||||||
blk.castShadow = true;
|
|
||||||
g.add(blk);
|
|
||||||
// A darker rind on one end, for parmesan.
|
|
||||||
const rind = new THREE.Mesh(
|
|
||||||
new THREE.BoxGeometry(ing.size * 0.12, ing.size * 0.72, ing.size * 0.72),
|
|
||||||
new THREE.MeshStandardMaterial({
|
|
||||||
color: new THREE.Color().setRGB(...ing.colors.skin, THREE.SRGBColorSpace),
|
|
||||||
roughness: 0.9,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
rind.position.x = -ing.size * 0.5;
|
|
||||||
g.add(rind);
|
|
||||||
}
|
|
||||||
return g;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Rest the block against the face at a height that tracks how much is left. */
|
|
||||||
private seatBlock(): void {
|
|
||||||
if (!this.block || !this.session) return;
|
|
||||||
const left = this.session.blockMass / this.session.initialBlock;
|
|
||||||
// As it shrinks, the block sits lower and smaller — knuckles near the teeth.
|
|
||||||
const y = FACE_BOT + 0.12 + left * (FACE_H * 0.5);
|
|
||||||
this.block.position.set(0, y, FACE_Z + 0.12);
|
|
||||||
if (this.session.mode === 'cheese') {
|
|
||||||
const sc = 0.35 + left * 0.65;
|
|
||||||
this.block.scale.set(sc, 1, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enter(): void {
|
|
||||||
this.panel.show();
|
|
||||||
}
|
|
||||||
exit(): void {
|
|
||||||
this.panel.hide();
|
|
||||||
}
|
|
||||||
|
|
||||||
update(dt: number): void {
|
|
||||||
const inp = this.app.input;
|
|
||||||
this.app.camera.position.set(1.15, 1.75, 3.4);
|
|
||||||
this.app.camera.lookAt(0.2, 0.7, 0.1);
|
|
||||||
const s = this.session;
|
|
||||||
if (!s) return;
|
|
||||||
|
|
||||||
// The bench warms the cheese in real time — the temperature clock.
|
|
||||||
warmOnBench(s, dt);
|
|
||||||
|
|
||||||
// Scroll rotates the orange to a fresh region (zest only).
|
|
||||||
if (s.mode === 'zest' && Math.abs(inp.wheel) > 0.01) {
|
|
||||||
rotateOrange(s, inp.wheel > 0 ? 1 : -1);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.ray.setFromCamera(inp.ndc, this.app.camera);
|
|
||||||
this.handleStroke(dt, inp.down);
|
|
||||||
|
|
||||||
// Clear the clogged teeth (costs service time in the game loop).
|
|
||||||
if (inp.justPressed('KeyC')) this.clearTeeth();
|
|
||||||
|
|
||||||
if (inp.justPressed('Enter') && this.onDone && s.placed) {
|
|
||||||
const cb = this.onDone;
|
|
||||||
this.onDone = null;
|
|
||||||
cb(this.result());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fade the knuckle flash.
|
|
||||||
if (this.flash > 0) {
|
|
||||||
this.flash = Math.max(0, this.flash - dt * 3);
|
|
||||||
this.flashEl.style.opacity = `${Math.min(0.5, this.flash)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.syncMess();
|
|
||||||
this.updateHud();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The face is where the pointer must be to grate; measured on FACE_Z plane. */
|
|
||||||
private overFace(): { y: number } | null {
|
|
||||||
if (!this.ray.ray.intersectPlane(this.facePlane, this.hitPt)) return null;
|
|
||||||
if (Math.abs(this.hitPt.x) > 0.34) return null;
|
|
||||||
if (this.hitPt.y < FACE_BOT - 0.1 || this.hitPt.y > FACE_TOP + 0.1) return null;
|
|
||||||
return { y: this.hitPt.y };
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleStroke(dt: number, down: boolean): void {
|
|
||||||
const s = this.session!;
|
|
||||||
if (!s.placed) placeBlock(s); // the block rests on the face from the start
|
|
||||||
|
|
||||||
const on = this.overFace();
|
|
||||||
|
|
||||||
// Press: hold on the face and lean in (the dwell is the downforce).
|
|
||||||
if (this.autoPress) {
|
|
||||||
if (down && on) {
|
|
||||||
this.holdT += dt;
|
|
||||||
this.press = Math.min(1, this.holdT * PRESS_RAMP);
|
|
||||||
} else {
|
|
||||||
this.holdT = 0;
|
|
||||||
this.press = Math.max(0, this.press - dt * 3);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stroke tracking: pointer pressed on the face and dragged DOWN.
|
|
||||||
if (down && on) {
|
|
||||||
if (!this.stroking) {
|
|
||||||
this.stroking = true;
|
|
||||||
this.strokeLastY = on.y;
|
|
||||||
this.strokeTravel = 0;
|
|
||||||
} else {
|
|
||||||
// Downward motion accumulates travel; upward motion ends the stroke.
|
|
||||||
const dy = this.strokeLastY - on.y; // positive = moving down
|
|
||||||
if (dy > 0) this.strokeTravel += dy;
|
|
||||||
this.strokeLastY = on.y;
|
|
||||||
}
|
|
||||||
} else if (this.stroking) {
|
|
||||||
// Lifted (or left the face): commit the stroke.
|
|
||||||
this.commitStroke();
|
|
||||||
this.stroking = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private commitStroke(): void {
|
|
||||||
const s = this.session!;
|
|
||||||
const length = Math.min(1, this.strokeTravel / FACE_H);
|
|
||||||
if (length < 0.04) return; // a tap, not a stroke
|
|
||||||
const f = grateStroke(s, length, this.press, () => this.rng.next());
|
|
||||||
this.applyStroke(f, length);
|
|
||||||
this.seatBlock();
|
|
||||||
}
|
|
||||||
|
|
||||||
private applyStroke(f: ReturnType<typeof grateStroke>, length: number): void {
|
|
||||||
const s = this.session!;
|
|
||||||
// Sound: the rasp scales with how much came off.
|
|
||||||
const yield_ = f.shred + f.zest + f.pith;
|
|
||||||
if (yield_ > 0.001) audio.scrape(Math.min(1, this.press), Math.min(6, 2 + length * 4));
|
|
||||||
|
|
||||||
// Good gratings mound onto the pile.
|
|
||||||
this.addToPile(f.shred + f.zest, false);
|
|
||||||
this.addToPile(f.pith, true);
|
|
||||||
|
|
||||||
// Stray shreds shear off the board (→ mess solids + a light stain).
|
|
||||||
if (f.stray > 0.002) {
|
|
||||||
const a = this.rng.next() * Math.PI * 2;
|
|
||||||
const u = 0.5 + Math.cos(a) * (0.15 + this.rng.next() * 0.2);
|
|
||||||
const v = 0.62 + Math.sin(a) * 0.12;
|
|
||||||
this.mess.addJuice(u, v, f.stray * 0.4, Math.cos(a), Math.sin(a));
|
|
||||||
this.mess.addSolid(u, v, s.mode === 'zest' ? 'zest' : 'crumb');
|
|
||||||
}
|
|
||||||
// Smeared cheese is a wet mess on the board.
|
|
||||||
if (f.smear > 0.002) {
|
|
||||||
this.mess.addJuice(0.5, 0.68, f.smear * 0.6, 0, 1);
|
|
||||||
}
|
|
||||||
if (f.clogged) {
|
|
||||||
this.hintEl.textContent = 'the teeth are clogging — press C to clear, or it smears';
|
|
||||||
}
|
|
||||||
if (f.knuckle) {
|
|
||||||
this.flash = 0.5;
|
|
||||||
this.app.shake = 0.02;
|
|
||||||
audio.clatter(0.6, 1.2);
|
|
||||||
this.mess.addSolid(0.5 + (this.rng.next() - 0.5) * 0.1, 0.66, 'pulp');
|
|
||||||
this.hintEl.textContent = "that's your knuckle — ease off, the block is small";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private addToPile(mass: number, pith: boolean): void {
|
|
||||||
if (mass <= 0) return;
|
|
||||||
// One flake per ~0.02 mass, clustered in a mound in front of the grater.
|
|
||||||
const flakes = Math.max(1, Math.round(mass / 0.02));
|
|
||||||
for (let i = 0; i < flakes && this.pileFlakes.length < 512; i++) {
|
|
||||||
const a = this.rng.next() * Math.PI * 2;
|
|
||||||
const r = Math.sqrt(this.rng.next()) * 0.26;
|
|
||||||
const u = 0.5 + Math.cos(a) * r;
|
|
||||||
const v = (PILE_Z / TRAY_D + 0.5) + Math.sin(a) * r * 0.6;
|
|
||||||
this.pileFlakes.push({ u, v, s: 0.02 + this.rng.next() * 0.02, pith });
|
|
||||||
}
|
|
||||||
this.syncPile();
|
|
||||||
}
|
|
||||||
|
|
||||||
private syncPile(): void {
|
|
||||||
const m = new THREE.Matrix4();
|
|
||||||
const q = new THREE.Quaternion();
|
|
||||||
const eu = new THREE.Euler();
|
|
||||||
const col = new THREE.Color();
|
|
||||||
let n = 0;
|
|
||||||
// Count flakes per cell to mound them up a little.
|
|
||||||
const heights = new Map<string, number>();
|
|
||||||
for (const fl of this.pileFlakes) {
|
|
||||||
if (n >= 512) break;
|
|
||||||
const key = `${Math.round(fl.u * 40)},${Math.round(fl.v * 40)}`;
|
|
||||||
const h = heights.get(key) ?? 0;
|
|
||||||
heights.set(key, h + 1);
|
|
||||||
const x = fl.u * TRAY_W - TRAY_W / 2;
|
|
||||||
const z = fl.v * TRAY_D - TRAY_D / 2;
|
|
||||||
const y = TRAY_Y * 2 + 0.01 + h * fl.s * 0.5;
|
|
||||||
eu.set(this.rng.next() * 0.5, this.rng.next() * Math.PI, this.rng.next() * 0.5);
|
|
||||||
q.setFromEuler(eu);
|
|
||||||
m.compose(new THREE.Vector3(x, y, z), q, new THREE.Vector3(fl.s * 3, fl.s * 0.6, fl.s));
|
|
||||||
this.pile.setMatrixAt(n, m);
|
|
||||||
col.copy(fl.pith ? this.pithMat.color : this.pileMat.color);
|
|
||||||
this.pile.setColorAt(n, col);
|
|
||||||
n++;
|
|
||||||
}
|
|
||||||
this.pile.count = n;
|
|
||||||
this.pile.instanceMatrix.needsUpdate = true;
|
|
||||||
if (this.pile.instanceColor) this.pile.instanceColor.needsUpdate = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private syncMess(): void {
|
|
||||||
if (this.mess.version === this.messVersion) return;
|
|
||||||
this.messVersion = this.mess.version;
|
|
||||||
const d = this.mess.field.data;
|
|
||||||
const px = this.messTexData;
|
|
||||||
for (let i = 0; i < d.length; i++) {
|
|
||||||
const m = Math.min(1, d[i] * 2.2);
|
|
||||||
px[i * 4] = 226;
|
|
||||||
px[i * 4 + 1] = 210;
|
|
||||||
px[i * 4 + 2] = 150;
|
|
||||||
px[i * 4 + 3] = Math.round(Math.min(0.8, m) * 255);
|
|
||||||
}
|
|
||||||
this.messTex.needsUpdate = true;
|
|
||||||
const solids = this.mess.solids.filter((sd) => !sd.collected);
|
|
||||||
const mat = new THREE.Matrix4();
|
|
||||||
let n = 0;
|
|
||||||
for (const sd of solids) {
|
|
||||||
if (n >= 256) break;
|
|
||||||
mat.setPosition(sd.u * TRAY_W - TRAY_W / 2, TRAY_Y * 2 + 0.02, sd.v * TRAY_D - TRAY_D / 2);
|
|
||||||
this.solidsMesh.setMatrixAt(n++, mat);
|
|
||||||
}
|
|
||||||
this.solidsMesh.count = n;
|
|
||||||
this.solidsMesh.instanceMatrix.needsUpdate = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateHud(): void {
|
|
||||||
const s = this.session;
|
|
||||||
if (!s) return;
|
|
||||||
const r = grateResult(s);
|
|
||||||
this.amountEl.textContent = `${amountWord(r.mode, r.gratings)} · ${Math.round(r.gratings * 100)}g`;
|
|
||||||
// The bar fills toward the top of the ideal band.
|
|
||||||
const target = r.mode === 'cheese' ? 0.7 : 0.2;
|
|
||||||
this.amountFill.style.width = `${Math.min(100, Math.round((r.gratings / target) * 100))}%`;
|
|
||||||
this.amountFill.style.background = r.gratings > target * 1.15 ? '#c88a2a' : '#e8c23a';
|
|
||||||
|
|
||||||
const bench = this.mess.benchWord();
|
|
||||||
let state = '';
|
|
||||||
if (r.mode === 'zest') {
|
|
||||||
const pith = r.pithFrac > 0.05 ? ` · ${Math.round(r.pithFrac * 100)}% pith` : '';
|
|
||||||
state = `region ${s.faceIndex + 1}/${s.faces.length}${pith}`;
|
|
||||||
if (r.pithFrac > 0.2) state += ' — rotate the orange';
|
|
||||||
} else {
|
|
||||||
const block = `block ${Math.round(r.blockLeft * 100)}%`;
|
|
||||||
const soft = s.softness > 0.5 ? ' · soft — it will smear' : s.softness > 0.15 ? ' · warming' : ' · cold';
|
|
||||||
const clog = r.clog > 0.6 ? ' · CLOGGED' : r.clog > 0.2 ? ' · clogging' : '';
|
|
||||||
state = `${block}${soft}${clog}`;
|
|
||||||
}
|
|
||||||
if (r.knuckleHits > 0) state += ` · ${r.knuckleHits}× knuckle`;
|
|
||||||
state += ` · bench: ${bench}`;
|
|
||||||
this.stateEl.textContent = state;
|
|
||||||
this.stateEl.style.color =
|
|
||||||
r.knuckleHits > 0 || r.pithFrac > 0.2 || r.clog > 0.6 || bench !== 'clean' ? '#e2603a' : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Clear the clogged teeth — a double-click in play, a call in the harness. */
|
|
||||||
clearTeeth(): void {
|
|
||||||
if (this.session) clearClog(this.session);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** What the judge reads. Snapshot any time; final at serve. */
|
|
||||||
result(): GrateResult {
|
|
||||||
const s = this.session!;
|
|
||||||
return { ...grateResult(s), bench: this.mess.stats() };
|
|
||||||
}
|
|
||||||
|
|
||||||
dispose(): void {
|
|
||||||
this.panel.dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,564 +0,0 @@
|
|||||||
import type { Ingredient } from './ingredients';
|
|
||||||
import type { Criterion } from '../game/judging';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Grating, on the same bones as everything else.
|
|
||||||
*
|
|
||||||
* The verb is a STROKE — drag the block (or the orange) down the box grater's
|
|
||||||
* rasped face and lift. One stroke is length × pressure, and the sim does the
|
|
||||||
* honest things with it:
|
|
||||||
*
|
|
||||||
* yield — a long confident stroke at good pressure sheds the most onto the
|
|
||||||
* pile. That is the feel that has to be right: length is the lever.
|
|
||||||
* spray — a short scrubby stroke shears shreds sideways off the board
|
|
||||||
* instead of down onto the pile (→ mess field). Scrub and you make
|
|
||||||
* a mess and little cheese.
|
|
||||||
* knuckle — the danger. As the block SHRINKS your knuckles get closer to the
|
|
||||||
* teeth; press hard on a small block and you roll a graze. Ease off
|
|
||||||
* near the end or you're grating protein. This is block-proximity ×
|
|
||||||
* pressure, so it only bites when the block is genuinely small —
|
|
||||||
* the tense last third.
|
|
||||||
*
|
|
||||||
* Two customers on one prop:
|
|
||||||
*
|
|
||||||
* cheese (parmesan) — grates clean IF cold. It softens on the bench in real
|
|
||||||
* time (the same temperature clock as butter, run backwards: butter
|
|
||||||
* wants warm, cheese wants cold). Soft cheese SMEARS: output drops,
|
|
||||||
* the teeth CLOG (a meter; clearing it costs service time), mess
|
|
||||||
* accrues. The block shrinks as you take mass off it.
|
|
||||||
* zest (orange) — each surface region has its own depth. Grate a region past
|
|
||||||
* the pith line (`gratable.pithDepth`) and it sheds white PITH into
|
|
||||||
* the pile instead of clean zest — visible, and bitter. Rotating the
|
|
||||||
* orange (scroll) exposes a fresh region. The skill is rotation
|
|
||||||
* discipline: move on before you hit the pith.
|
|
||||||
*
|
|
||||||
* Nothing here imports three.js — it runs on numbers the scene hands it, which
|
|
||||||
* is what lets the harness grate a block headless. It deliberately does NOT
|
|
||||||
* import juicing; the pith idea is re-derived here from the ingredient's own
|
|
||||||
* pithDepth so the two stations stay independent lanes.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type GraterId = 'box_grater' | 'microplane';
|
|
||||||
|
|
||||||
export interface Grater {
|
|
||||||
id: GraterId;
|
|
||||||
name: string;
|
|
||||||
/** Rasp aggression — mass shed per unit of (stroke length × press). */
|
|
||||||
rasp: number;
|
|
||||||
/** How fast a zest stroke eats into the skin toward the pith line. Coarse
|
|
||||||
* teeth bite deeper per stroke (hit pith sooner); a fine face is forgiving. */
|
|
||||||
pithBite: number;
|
|
||||||
/** How readily soft cheese packs the teeth. Open coarse teeth clog less. */
|
|
||||||
clogProne: number;
|
|
||||||
blurb: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const GRATERS: Record<GraterId, Grater> = {
|
|
||||||
box_grater: {
|
|
||||||
id: 'box_grater',
|
|
||||||
// Coarse: lots of cheese per stroke, forgiving to clog, but it bites deep
|
|
||||||
// on citrus so the pith line comes up fast — rotate often.
|
|
||||||
name: 'Box Grater',
|
|
||||||
rasp: 0.12,
|
|
||||||
pithBite: 1.0,
|
|
||||||
clogProne: 0.7,
|
|
||||||
blurb: 'Four faces, coarse teeth. The workhorse.',
|
|
||||||
},
|
|
||||||
microplane: {
|
|
||||||
id: 'microplane',
|
|
||||||
// Fine: less mass per stroke, shallow bite (kind to zest), but soft cheese
|
|
||||||
// packs it solid. The zest specialist. (Scene ships the box; this is spec.)
|
|
||||||
name: 'Microplane',
|
|
||||||
rasp: 0.08,
|
|
||||||
pithBite: 0.6,
|
|
||||||
clogProne: 1.3,
|
|
||||||
blurb: 'Razor teeth. Snow, not shreds. Clogs if you let it soften.',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/** How the ingredient wants to be grated, decided by its profile. Citrus (a
|
|
||||||
* pithDepth) is zest; a hard gratable block (parmesan) is cheese. */
|
|
||||||
export type GrateMode = 'cheese' | 'zest';
|
|
||||||
|
|
||||||
export function grateMode(ing: Ingredient): GrateMode {
|
|
||||||
return ing.gratable?.pithDepth != null ? 'zest' : 'cheese';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** What one stroke did — the scene turns these into pile, mess, clog, a flinch. */
|
|
||||||
export interface StrokeResult {
|
|
||||||
/** Good cheese shred onto the pile this stroke. */
|
|
||||||
shred: number;
|
|
||||||
/** Clean orange zest onto the pile this stroke. */
|
|
||||||
zest: number;
|
|
||||||
/** White pith shed into the pile this stroke (over-grating a region). */
|
|
||||||
pith: number;
|
|
||||||
/** Shred/zest sheared off the board this stroke (→ mess field solids). */
|
|
||||||
stray: number;
|
|
||||||
/** Cheese smeared onto the teeth/board from clogging (→ mess field mass). */
|
|
||||||
smear: number;
|
|
||||||
/** A knuckle graze happened — flinch, red flash, judged incident. */
|
|
||||||
knuckle: boolean;
|
|
||||||
/** Clog crossed the "the teeth are packed" line this stroke. */
|
|
||||||
clogged: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** How many rotational regions an orange presents to the grater. Rotate (scroll)
|
|
||||||
* to bring a fresh one to the teeth; each remembers its own depth. */
|
|
||||||
export const ZEST_REGIONS = 8;
|
|
||||||
|
|
||||||
export interface GrateSession {
|
|
||||||
ing: Ingredient;
|
|
||||||
grater: Grater;
|
|
||||||
mode: GrateMode;
|
|
||||||
placed: boolean;
|
|
||||||
|
|
||||||
// --- Cheese / the block ---
|
|
||||||
/** Remaining block, 1.0 = a fresh corner. Shrinks as you shred it. */
|
|
||||||
blockMass: number;
|
|
||||||
initialBlock: number;
|
|
||||||
shred: number;
|
|
||||||
/** 0 = fridge-cold and clean, 1 = fully soft and smeary. The temp clock. */
|
|
||||||
softness: number;
|
|
||||||
/** 0..1 teeth packed with smeared cheese; output falls as it climbs. */
|
|
||||||
clog: number;
|
|
||||||
smearTotal: number;
|
|
||||||
|
|
||||||
// --- Zest / the orange ---
|
|
||||||
zestShed: number;
|
|
||||||
pithShed: number;
|
|
||||||
/** Accumulated grate depth per region; past pithDepth it sheds pith. */
|
|
||||||
faces: number[];
|
|
||||||
faceIndex: number;
|
|
||||||
|
|
||||||
// --- Shared ---
|
|
||||||
strayTotal: number;
|
|
||||||
knuckleHits: number;
|
|
||||||
bloodied: boolean;
|
|
||||||
|
|
||||||
// --- Gesture state (rhythm) ---
|
|
||||||
/** Consecutive-long-stroke confidence, small multiplier on yield. */
|
|
||||||
rhythm: number;
|
|
||||||
strokeCount: number;
|
|
||||||
totalTravel: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Tuning. First principles, then measured on the bench (see commit). ---
|
|
||||||
/** A fresh block, in the same "1 unit" mass currency the pile and bands use. */
|
|
||||||
const INITIAL_BLOCK = 1.0;
|
|
||||||
/** Zest sheds far less mass than cheese per stroke — it's a whisper of skin. */
|
|
||||||
const ZEST_MASS_FRAC = 0.4;
|
|
||||||
/** Depth a zest stroke adds to its region, before length×press. Sized so a
|
|
||||||
* region hits an orange's 0.35 pith line in ~3 hard strokes → rotate by then. */
|
|
||||||
const ZEST_DEPTH_GAIN = 0.18;
|
|
||||||
/** Spray rises steeply as strokes get short: (1-length)^2. Scrub = mess. */
|
|
||||||
const SPRAY_GAIN = 0.9;
|
|
||||||
/** Long strokes build confidence; this caps the rhythm bonus. */
|
|
||||||
const RHYTHM_MAX = 0.15;
|
|
||||||
const RHYTHM_LONG = 0.75; // a stroke this long or longer counts as "confident"
|
|
||||||
/** Soft cheese: fraction of a stroke's output lost to smear at full softness. */
|
|
||||||
const SMEAR_LOSS = 0.8;
|
|
||||||
/** Clog gained per stroke = softness × press × this × grater.clogProne. */
|
|
||||||
const CLOG_GAIN = 0.35;
|
|
||||||
/** --- Knuckles --- */
|
|
||||||
/** Above this remaining block you're safe; below it, proximity ramps in. */
|
|
||||||
const KNUCKLE_SAFE_BLOCK = 0.35;
|
|
||||||
/** Graze chance = pressure × (0.4 + 0.6·length) × proximity × this. */
|
|
||||||
const KNUCKLE_GAIN = 0.6;
|
|
||||||
|
|
||||||
const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v);
|
|
||||||
|
|
||||||
export function newGrateSession(
|
|
||||||
ing: Ingredient,
|
|
||||||
grater: Grater,
|
|
||||||
opts: { blockMass?: number; softness?: number } = {},
|
|
||||||
): GrateSession {
|
|
||||||
const mode = grateMode(ing);
|
|
||||||
const initialBlock = opts.blockMass ?? INITIAL_BLOCK;
|
|
||||||
return {
|
|
||||||
ing,
|
|
||||||
grater,
|
|
||||||
mode,
|
|
||||||
placed: false,
|
|
||||||
blockMass: initialBlock,
|
|
||||||
initialBlock,
|
|
||||||
shred: 0,
|
|
||||||
// Cheese starts as cold as you left it; zest ignores softness entirely.
|
|
||||||
softness: mode === 'cheese' ? clamp01(opts.softness ?? 0) : 0,
|
|
||||||
clog: 0,
|
|
||||||
smearTotal: 0,
|
|
||||||
zestShed: 0,
|
|
||||||
pithShed: 0,
|
|
||||||
faces: new Array(ZEST_REGIONS).fill(0),
|
|
||||||
faceIndex: 0,
|
|
||||||
strayTotal: 0,
|
|
||||||
knuckleHits: 0,
|
|
||||||
bloodied: false,
|
|
||||||
rhythm: 0,
|
|
||||||
strokeCount: 0,
|
|
||||||
totalTravel: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Press the ingredient against the face. Nothing grates until it's placed. */
|
|
||||||
export function placeBlock(s: GrateSession): void {
|
|
||||||
s.placed = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Advance the bench temperature clock for a cheese that wants to stay cold.
|
|
||||||
* `softensInSec` from the ingredient sets the pace; call it with the frame dt
|
|
||||||
* while the block sits out. A no-op for zest and for cheeses with no temp need.
|
|
||||||
*/
|
|
||||||
export function warmOnBench(s: GrateSession, dt: number): void {
|
|
||||||
if (s.mode !== 'cheese' || dt <= 0) return;
|
|
||||||
const secs = s.ing.temp?.softensInSec;
|
|
||||||
if (!secs || secs <= 0) return;
|
|
||||||
s.softness = clamp01(s.softness + dt / secs);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Clear the packed teeth. Costs the player service time in the scene. */
|
|
||||||
export function clearClog(s: GrateSession): void {
|
|
||||||
s.clog = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Rotate the orange to bring a fresh region to the teeth (scroll in play). */
|
|
||||||
export function rotateOrange(s: GrateSession, dir = 1): void {
|
|
||||||
if (s.mode !== 'zest') return;
|
|
||||||
const n = s.faces.length;
|
|
||||||
s.faceIndex = ((s.faceIndex + (dir >= 0 ? 1 : -1)) % n + n) % n;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One stroke down the face. `length` 0..1 is how far the stroke travelled
|
|
||||||
* (1 = the full face), `press` 0..1 is downforce. `rand` decides only the
|
|
||||||
* knuckle-graze roll — everything else is deterministic, which is what lets
|
|
||||||
* the harness check it.
|
|
||||||
*/
|
|
||||||
export function grateStroke(
|
|
||||||
s: GrateSession,
|
|
||||||
length: number,
|
|
||||||
press: number,
|
|
||||||
rand: () => number,
|
|
||||||
): StrokeResult {
|
|
||||||
const out: StrokeResult = {
|
|
||||||
shred: 0,
|
|
||||||
zest: 0,
|
|
||||||
pith: 0,
|
|
||||||
stray: 0,
|
|
||||||
smear: 0,
|
|
||||||
knuckle: false,
|
|
||||||
clogged: false,
|
|
||||||
};
|
|
||||||
if (!s.placed) return out;
|
|
||||||
|
|
||||||
const len = clamp01(length);
|
|
||||||
const p = clamp01(press);
|
|
||||||
const effort = len * p;
|
|
||||||
if (effort <= 0) return out;
|
|
||||||
|
|
||||||
s.strokeCount++;
|
|
||||||
s.totalTravel += len;
|
|
||||||
|
|
||||||
// Rhythm: confident long strokes build a small yield bonus; a short scrub
|
|
||||||
// resets it. This is what makes a steady rhythm feel better than a flail.
|
|
||||||
if (len >= RHYTHM_LONG) s.rhythm = Math.min(RHYTHM_MAX, s.rhythm + RHYTHM_MAX / 3);
|
|
||||||
else s.rhythm = Math.max(0, s.rhythm - RHYTHM_MAX / 2);
|
|
||||||
const rhythmMult = 1 + s.rhythm;
|
|
||||||
|
|
||||||
// --- The knuckle roll: only real when the block is small, worse under
|
|
||||||
// pressure and on a long stroke that runs off the end of the block. ---
|
|
||||||
const proximity =
|
|
||||||
s.blockMass < KNUCKLE_SAFE_BLOCK
|
|
||||||
? clamp01((KNUCKLE_SAFE_BLOCK - s.blockMass) / KNUCKLE_SAFE_BLOCK)
|
|
||||||
: 0;
|
|
||||||
if (proximity > 0) {
|
|
||||||
// Below ~0.4 pressure you're deliberately careful — no roll at all.
|
|
||||||
// Easing off must be a real defense, not a smaller lottery ticket.
|
|
||||||
const risk = Math.max(0, p - 0.4) * 1.6 * (0.4 + 0.6 * len) * proximity;
|
|
||||||
if (rand() < risk * KNUCKLE_GAIN) {
|
|
||||||
out.knuckle = true;
|
|
||||||
s.knuckleHits++;
|
|
||||||
s.bloodied = true;
|
|
||||||
// You flinch: the stroke's yield is mostly lost and a little sprays.
|
|
||||||
const flinch = effort * s.grater.rasp * 0.3;
|
|
||||||
out.stray += flinch;
|
|
||||||
s.strayTotal += flinch;
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (s.mode === 'cheese') gratecheese(s, len, p, effort, rhythmMult, out);
|
|
||||||
else gratezest(s, len, p, effort, rhythmMult, out);
|
|
||||||
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function gratecheese(
|
|
||||||
s: GrateSession,
|
|
||||||
len: number,
|
|
||||||
p: number,
|
|
||||||
effort: number,
|
|
||||||
rhythmMult: number,
|
|
||||||
out: StrokeResult,
|
|
||||||
): void {
|
|
||||||
// Base rasp yield this stroke, gated by how much block is left (a nub gives
|
|
||||||
// less), rasped by the grater, softened output lost to smear, choked by clog.
|
|
||||||
const blockGate = clamp01(s.blockMass / 0.15); // the last crumb rasps poorly
|
|
||||||
const gross = effort * s.grater.rasp * (s.ing.gratable?.yield ?? 1) * blockGate * rhythmMult;
|
|
||||||
|
|
||||||
// Soft cheese smears instead of shredding; cold cheese is all clean output.
|
|
||||||
const smearFrac = s.softness * SMEAR_LOSS;
|
|
||||||
const clogChoke = 1 - s.clog;
|
|
||||||
const clean = gross * (1 - smearFrac) * clogChoke;
|
|
||||||
const smeared = gross * smearFrac + gross * (1 - smearFrac) * (1 - clogChoke) * 0.5;
|
|
||||||
|
|
||||||
// Short scrubby strokes shear shreds off the board instead of onto the pile.
|
|
||||||
const sprayFrac = clamp01((1 - len) * (1 - len) * SPRAY_GAIN);
|
|
||||||
const toPile = clean * (1 - sprayFrac);
|
|
||||||
const stray = clean * sprayFrac;
|
|
||||||
|
|
||||||
s.shred += toPile;
|
|
||||||
out.shred += toPile;
|
|
||||||
out.stray += stray;
|
|
||||||
s.strayTotal += stray;
|
|
||||||
out.smear += smeared;
|
|
||||||
s.smearTotal += smeared;
|
|
||||||
|
|
||||||
// Everything that left the block — pile, spray, smear — came off the block.
|
|
||||||
s.blockMass = Math.max(0, s.blockMass - (toPile + stray + smeared));
|
|
||||||
|
|
||||||
// Clog climbs when soft cheese packs the teeth.
|
|
||||||
if (s.softness > 0.05) {
|
|
||||||
const before = s.clog;
|
|
||||||
s.clog = clamp01(s.clog + s.softness * p * CLOG_GAIN * s.grater.clogProne);
|
|
||||||
if (before < 0.6 && s.clog >= 0.6) out.clogged = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function gratezest(
|
|
||||||
s: GrateSession,
|
|
||||||
len: number,
|
|
||||||
p: number,
|
|
||||||
effort: number,
|
|
||||||
rhythmMult: number,
|
|
||||||
out: StrokeResult,
|
|
||||||
): void {
|
|
||||||
const pithDepth = s.ing.gratable?.pithDepth ?? 0.35;
|
|
||||||
const i = s.faceIndex;
|
|
||||||
const before = s.faces[i];
|
|
||||||
const inc = len * p * ZEST_DEPTH_GAIN * s.grater.pithBite;
|
|
||||||
const after = before + inc;
|
|
||||||
s.faces[i] = after;
|
|
||||||
|
|
||||||
// The portion of this stroke that stayed in clean skin (above the pith line)
|
|
||||||
// is zest; whatever crossed into the pith is bitter white.
|
|
||||||
const cleanDepth = Math.max(0, Math.min(after, pithDepth) - Math.min(before, pithDepth));
|
|
||||||
const cleanFrac = inc > 1e-6 ? clamp01(cleanDepth / inc) : 1;
|
|
||||||
|
|
||||||
const gross = effort * s.grater.rasp * (s.ing.gratable?.yield ?? 1) * ZEST_MASS_FRAC * rhythmMult;
|
|
||||||
const sprayFrac = clamp01((1 - len) * (1 - len) * SPRAY_GAIN);
|
|
||||||
|
|
||||||
const cleanMass = gross * cleanFrac;
|
|
||||||
const pithMass = gross * (1 - cleanFrac);
|
|
||||||
|
|
||||||
const zestToPile = cleanMass * (1 - sprayFrac);
|
|
||||||
const zestStray = cleanMass * sprayFrac;
|
|
||||||
const pithToPile = pithMass * (1 - sprayFrac);
|
|
||||||
const pithStray = pithMass * sprayFrac;
|
|
||||||
|
|
||||||
s.zestShed += zestToPile;
|
|
||||||
out.zest += zestToPile;
|
|
||||||
s.pithShed += pithToPile;
|
|
||||||
out.pith += pithToPile;
|
|
||||||
const stray = zestStray + pithStray;
|
|
||||||
out.stray += stray;
|
|
||||||
s.strayTotal += stray;
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// The judge's side. Kept here so the game has ONE place to consume grating:
|
|
||||||
// import { GrateResult, grateResult, grateCriteria, GRATE_LINES } from
|
|
||||||
// '../sim/grating';
|
|
||||||
// The scene assembles GrateResult (grateResult(session) + the bench stats it
|
|
||||||
// owns) and hands it to the game; the game calls grateCriteria(result) and
|
|
||||||
// merges GRATE_LINES into its verdict bank. Bench itself is scored by the
|
|
||||||
// existing benchCriterion via result.bench — the same path prep/juice orders
|
|
||||||
// use. knuckleLine() slots in like juicerRecognitionLine() when it fires.
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** The number-bag the prep bench hands the judge for a grate order. */
|
|
||||||
export interface GrateResult {
|
|
||||||
graterId: GraterId;
|
|
||||||
graterName: string;
|
|
||||||
mode: GrateMode;
|
|
||||||
/** Total good gratings on the pile (cheese shred OR clean zest). */
|
|
||||||
gratings: number;
|
|
||||||
/** Pith on the pile (zest only) — the bitter white. */
|
|
||||||
pith: number;
|
|
||||||
/** pith / (zest + pith) — the ratio the Pith criterion scores. */
|
|
||||||
pithFrac: number;
|
|
||||||
/** Remaining block, 0..1 of a fresh corner (cheese). */
|
|
||||||
blockLeft: number;
|
|
||||||
clog: number;
|
|
||||||
smear: number;
|
|
||||||
knuckleHits: number;
|
|
||||||
bloodied: boolean;
|
|
||||||
/** Filled in by the scene from its mess field, same shape prep/juice use. */
|
|
||||||
bench?: { mass: number; spreadFrac: number; solids: number };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function grateResult(s: GrateSession): GrateResult {
|
|
||||||
const gratings = s.mode === 'cheese' ? s.shred : s.zestShed;
|
|
||||||
const pith = s.pithShed;
|
|
||||||
const denom = s.zestShed + s.pithShed;
|
|
||||||
return {
|
|
||||||
graterId: s.grater.id,
|
|
||||||
graterName: s.grater.name,
|
|
||||||
mode: s.mode,
|
|
||||||
gratings,
|
|
||||||
pith,
|
|
||||||
pithFrac: denom > 1e-6 ? pith / denom : 0,
|
|
||||||
blockLeft: clamp01(s.blockMass / s.initialBlock),
|
|
||||||
clog: s.clog,
|
|
||||||
smear: s.smearTotal,
|
|
||||||
knuckleHits: s.knuckleHits,
|
|
||||||
bloodied: s.bloodied,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Score a value against an ideal band: 1 inside [lo,hi], falling off outside. */
|
|
||||||
function bandScore(v: number, lo: number, hi: number, underK: number, overK: number): number {
|
|
||||||
if (v < lo) return clamp01(1 - (lo - v) / underK);
|
|
||||||
if (v > hi) return clamp01(1 - (v - hi) / overK);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ideal serving bands per mode — cheese is a real pile, zest a fine scatter. */
|
|
||||||
const BAND = {
|
|
||||||
cheese: { lo: 0.4, hi: 0.7, under: 0.4, over: 0.5 },
|
|
||||||
zest: { lo: 0.09, hi: 0.2, under: 0.08, over: 0.12 },
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/** Live-readout word for the amount, in the judge's own thresholds. */
|
|
||||||
export function amountWord(mode: GrateMode, gratings: number): string {
|
|
||||||
const b = BAND[mode];
|
|
||||||
if (gratings < b.lo * 0.5) return mode === 'cheese' ? 'a dusting' : 'barely a fleck';
|
|
||||||
if (gratings < b.lo) return 'a little short';
|
|
||||||
if (gratings <= b.hi) return mode === 'cheese' ? 'a good pile' : 'a proper scatter';
|
|
||||||
return mode === 'cheese' ? 'a mountain' : 'a snowdrift';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The Gratings, the Pith and the Clog. Amount is a band (like spread amount);
|
|
||||||
* a knuckle graze docks it and gets its own line, because he can see it. Pith
|
|
||||||
* scores the white ratio (zest only). Clog scores the smeary mess a warm block
|
|
||||||
* makes. Bench is scored separately via result.bench, same as juice.
|
|
||||||
*/
|
|
||||||
export function grateCriteria(result: GrateResult): Criterion[] {
|
|
||||||
const b = BAND[result.mode];
|
|
||||||
const amount = bandScore(result.gratings, b.lo, b.hi, b.under, b.over);
|
|
||||||
// A knuckle in the pile is contamination; each graze docks the amount hard.
|
|
||||||
const knucklePenalty = Math.min(0.6, result.knuckleHits * 0.35);
|
|
||||||
const amountScore = clamp01(amount - knucklePenalty);
|
|
||||||
const knuckleWord = result.knuckleHits > 0 ? `, ${result.knuckleHits}× knuckle` : '';
|
|
||||||
|
|
||||||
const out: Criterion[] = [
|
|
||||||
{
|
|
||||||
key: 'gratings',
|
|
||||||
group: 'prep',
|
|
||||||
label: result.mode === 'cheese' ? 'The Gratings' : 'The Zest',
|
|
||||||
score: amountScore,
|
|
||||||
weight: 1.0,
|
|
||||||
detail: `${amountWord(result.mode, result.gratings)}${knuckleWord}`,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (result.mode === 'zest') {
|
|
||||||
// Pith is unforgiving: a tenth pith already reads, half is a headache.
|
|
||||||
const pithScore = clamp01(1 - result.pithFrac / 0.4);
|
|
||||||
out.push({
|
|
||||||
key: 'pith',
|
|
||||||
group: 'prep',
|
|
||||||
label: 'The Pith',
|
|
||||||
score: pithScore,
|
|
||||||
weight: 1.1,
|
|
||||||
detail:
|
|
||||||
result.pithFrac < 0.05
|
|
||||||
? 'clean zest, no white'
|
|
||||||
: `${Math.round(result.pithFrac * 100)}% pith`,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Clog/smear only matters for cheese — a warm block ruins the yield twice.
|
|
||||||
const clogScore = clamp01(1 - result.clog * 0.8 - Math.min(0.5, result.smear * 1.5));
|
|
||||||
out.push({
|
|
||||||
key: 'clog',
|
|
||||||
group: 'prep',
|
|
||||||
label: 'The Grate',
|
|
||||||
score: clogScore,
|
|
||||||
weight: 0.9,
|
|
||||||
detail:
|
|
||||||
result.clog < 0.15 && result.smear < 0.05
|
|
||||||
? 'clean teeth, dry shred'
|
|
||||||
: result.clog > 0.6
|
|
||||||
? 'clogged solid'
|
|
||||||
: 'smeary — it was too soft',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The knuckle line, slotted in as an opener when a graze happened (like the
|
|
||||||
* Juicernaut recognition line). */
|
|
||||||
export function knuckleLine(result: GrateResult): string | null {
|
|
||||||
return result.knuckleHits > 0
|
|
||||||
? "That's your knuckle in there. I'm not scoring the protein."
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Judge lines for the grate criteria — merged into lines.ts's BANK by the
|
|
||||||
* game. Five bad, three good, in his voice: dry, specific, never cruel. */
|
|
||||||
export const GRATE_LINES: Record<'gratings' | 'pith' | 'clog', { bad: string[]; good: string[] }> = {
|
|
||||||
gratings: {
|
|
||||||
bad: [
|
|
||||||
'A dusting. Were you seasoning it or introducing them?',
|
|
||||||
'You grated half the block. It was a garnish, not a duvet.',
|
|
||||||
'This is a mountain. I asked for cheese, not a foothill.',
|
|
||||||
'You scrubbed at it. Half of it is on the board, not the plate.',
|
|
||||||
'Short little strokes. Short little pile.',
|
|
||||||
],
|
|
||||||
good: [
|
|
||||||
'A good pile. Long strokes, all of it on the plate. That is technique.',
|
|
||||||
'Exactly the amount. You stopped when it was right, which nobody does.',
|
|
||||||
'Even, generous, tidy. I have nothing to say, which is a compliment.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
pith: {
|
|
||||||
bad: [
|
|
||||||
'This zest is half pith. It will taste like a headache.',
|
|
||||||
'You stayed on one spot until it went white. Rotate the fruit.',
|
|
||||||
'I can see the pith from here. Bitter. All that work for bitter.',
|
|
||||||
'White among the orange. You went past the skin and kept going.',
|
|
||||||
'You reamed the peel. There is more pith than perfume in this.',
|
|
||||||
],
|
|
||||||
good: [
|
|
||||||
'Bright zest, no white. You turned the fruit before it turned on you.',
|
|
||||||
'All perfume, no pith. Rotation discipline. I noticed.',
|
|
||||||
'Not a fleck of white in it. That is a zest.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
clog: {
|
|
||||||
bad: [
|
|
||||||
'It smeared. You let the parmesan sweat before you grated it.',
|
|
||||||
'The teeth are packed solid. That is a warm-cheese problem.',
|
|
||||||
'This is paste, not shred. It came out of the fridge too early.',
|
|
||||||
'Clogged. You spent longer clearing the grater than grating.',
|
|
||||||
'Soft cheese, coarse teeth, no plan. It shows.',
|
|
||||||
],
|
|
||||||
good: [
|
|
||||||
'Dry, loose shred. That block was cold and you kept it that way.',
|
|
||||||
'Clean teeth, clean pile. Cold cheese, honest work.',
|
|
||||||
'Not a smear on it. You grated it before it could betray you.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
Loading…
Reference in New Issue
Block a user