toastsim/src/scenes/prep.ts
type-two cebff4bd69 STAGE: culinary school — one board, one spec, one clock
The drills mode from P8, and the one John asked for by name. No
customer, no ticket, no day: a spec, a board and a stopwatch. Three to
start — brunoise the onion, bâtonnets off a bell pepper, twelve
cucumber rounds — each with four medal tiers that must be earned on
BOTH axes at once. You do not get gold for being fast and ragged, and
you do not get gold for being immaculate and late, and the card names
the single thing standing between this run and the next tier: "Even
enough for gold. 6s too slow for it."

A drill is a SPEC, not a separate game — it runs the real prep bench
through the real cutting sim, and pieceCV was already sitting there
doing exactly the measurement a spec needs.

Sealed off from the campaign on purpose: no order, no streak, no
starter, no regulars, no verdict. Checked rather than assumed — after a
full drill run, save.day, save.total and save.streak were byte-identical
and the ONLY thing written was the drill's own line in the medal book,
which keeps your best (a better medal, or the same medal faster).

Two things the build surfaced:

- The prep bench only accepts ENTER when the pattern is COMPLETE, which
  is right for service (the order wants the whole cut) but made "7 of 16.
  Finish the cut before you stop the clock" a verdict nothing could ever
  reach. Drills now allow the panic-stop via an explicit
  allowEarlyServe flag; service explicitly sets it false, so an order can
  never be half-served.
- The title was built in the constructor and nowhere else, so there was
  no way back from anything. Extracted showTitle().

Also: a recorded run with no medal was labelled "not attempted", which
is a small lie about the worst runs. It says "attempted — no medal" now.

Verified: S on the cucumber rounds books S·4s on the title; a short
brunoise books no medal and says why; service prep still refuses an
early ENTER; bruschetta 9.6, grill 9.4, eggs 10.0, curry 10.0, greens
10.0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 20:45:19 +10:00

597 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import * as THREE from 'three';
import type { App, View } from '../core/app';
import { audio } from '../core/audio';
import { INGREDIENTS, type Ingredient, type IngredientId } from '../sim/ingredients';
import {
aimCut,
beginBite,
cutFrame,
cvWord,
liftKnife,
newCutSession,
pieceCV,
pieceVolumes,
plannedCuts,
stemDiscipline,
stingStep,
type CutPattern,
type CutSession,
} from '../sim/cutting';
import { Mess } from '../sim/mess';
import { makeCutleryMesh, TOOLS, type ToolId } from '../sim/cutlery';
import type { PrepResult } from '../game/judging';
import { el, Panel } from '../ui/hud';
import { Rng } from '../core/rng';
import { loadProp } from './assets';
const BOARD_Y = 0.06;
const BOARD_W = 4.6;
const BOARD_D = 2.4;
/**
* The prep bench. One board, one knife, one ingredient at a time, and a mess
* field that remembers everything you did to all three.
*
* Controls mirror the slicer on purpose: horizontal mouse aims the cut, press
* bites, vertical strokes saw. The two new verbs are the ingredient's, not
* yours: slippery skin skates if you lean without sawing, and wet flesh
* juices the board. Drag on the empty board (off the ingredient) to wipe.
*/
export class PrepView implements View {
readonly root = new THREE.Group();
readonly mess = new Mess(96);
private session: CutSession | null = null;
private ing!: Ingredient;
private ingMesh: THREE.Group | null = null;
private pieces: THREE.Group | null = null;
private seams: THREE.Mesh[] = [];
private knifeMesh!: THREE.Group;
/** The onion's stop-line: a bright band the blade must not saw past. */
private stopLine: THREE.Mesh | null = null;
/** Full-screen sting overlay — the eyes welling up, cheap DOM. */
private stingOverlay: HTMLDivElement;
private rng = new Rng(20260717);
private messTexData: Uint8Array;
private messTex: THREE.DataTexture;
private messVersion = -1;
private solidsMesh: THREE.InstancedMesh;
private ray = new THREE.Raycaster();
private planePt = new THREE.Vector3();
private plane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0.2);
private boardPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -(BOARD_Y * 2));
private prev = new THREE.Vector2();
private hasPrev = false;
private wipePrev: THREE.Vector3 | null = null;
private sawPhase = 0;
private doneTimer = 0;
private panel: Panel;
private askEl!: HTMLElement;
private cutsEl!: HTMLElement;
private progFill!: HTMLElement;
private benchEl!: HTMLElement;
private hintEl!: HTMLElement;
onDone: ((result: PrepResult) => void) | null = null;
/** STAGE only: let ENTER stop the clock mid-pattern. In service the order
* demands the whole cut, but a timed drill has to allow the panic-stop —
* otherwise "6 of 16, finish the cut" is a verdict nothing can reach. */
allowEarlyServe = false;
constructor(private app: App) {
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(BOARD_W, BOARD_D),
new THREE.MeshBasicMaterial({ map: this.messTex, transparent: true, depthWrite: false }),
);
stain.rotation.x = -Math.PI / 2;
stain.position.y = BOARD_Y * 2 + 0.004;
this.root.add(stain);
// Solids: seeds and skins, instanced like the rind bits.
const seedGeo = new THREE.SphereGeometry(0.028, 8, 6);
seedGeo.scale(1, 0.45, 0.72);
this.solidsMesh = new THREE.InstancedMesh(
seedGeo,
new THREE.MeshStandardMaterial({ color: 0xf0e6c8, roughness: 0.6 }),
256,
);
this.solidsMesh.count = 0;
this.solidsMesh.castShadow = true;
this.root.add(this.solidsMesh);
this.panel = new Panel('prep-panel');
this.buildUi();
this.panel.hide();
// The sting overlay lives over everything, ignores the mouse, and does
// nothing until an onion is open. backdrop-filter blur is the "screen blurs"
// and the radial vignette is the eyes welling — both scale with sting.
this.stingOverlay = document.createElement('div');
this.stingOverlay.style.cssText =
'position:fixed;inset:0;pointer-events:none;z-index:40;opacity:0;transition:opacity 0.25s ease';
document.body.appendChild(this.stingOverlay);
}
private buildUi(): void {
const p = this.panel.root;
const card = el('div', 'slicer-card', p);
el('div', 'drawer-lbl', card, 'PREP IT');
this.askEl = el('div', 'slicer-ask', card, '');
this.cutsEl = el('div', 'slicer-thick', card, '');
const bar = el('div', 'timer-bar', card);
this.progFill = el('div', 'timer-fill', bar);
this.benchEl = 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);
const board = new THREE.Mesh(
new THREE.BoxGeometry(BOARD_W, BOARD_Y * 2, BOARD_D),
new THREE.MeshStandardMaterial({ color: 0xa87c4f, roughness: 0.7 }),
);
board.position.y = BOARD_Y;
board.receiveShadow = true;
board.castShadow = true;
this.root.add(board);
void loadProp('/assets/models/cutting_board.glb', 2.2)
.then((prop) => {
prop.position.y = BOARD_Y;
board.visible = false;
this.root.add(prop);
})
.catch(() => undefined);
}
/** Procedural stand-in silhouettes — the shapes carry the physics. */
private buildIngredientMesh(ing: Ingredient): THREE.Group {
const g = new THREE.Group();
const skin = new THREE.MeshStandardMaterial({
color: new THREE.Color().setRGB(...ing.colors.skin, THREE.SRGBColorSpace),
roughness: 0.55,
});
let mesh: THREE.Mesh;
const r = ing.size / 2;
switch (ing.shape) {
case 'ovoid': {
mesh = new THREE.Mesh(new THREE.SphereGeometry(r, 28, 20), skin);
mesh.scale.set(1.25, 0.92, 0.92);
break;
}
case 'block': {
mesh = new THREE.Mesh(new THREE.BoxGeometry(ing.size, ing.size * 0.6, ing.size * 0.7), skin);
break;
}
case 'wedge': {
mesh = new THREE.Mesh(new THREE.CylinderGeometry(r, r, ing.size * 0.5, 24, 1, false, 0, Math.PI / 2.2), skin);
mesh.rotation.y = Math.PI / 4;
break;
}
case 'bulb': {
mesh = new THREE.Mesh(new THREE.SphereGeometry(r, 20, 14), skin);
mesh.scale.set(1, 0.85, 1);
break;
}
case 'mushroom': {
// Cap on a stem; group extent stays ~size so the shared y-offset works.
mesh = new THREE.Mesh(new THREE.SphereGeometry(r, 24, 16), skin);
mesh.scale.set(1, 0.6, 1);
mesh.position.y = ing.size * 0.15;
const stemMat = new THREE.MeshStandardMaterial({
color: new THREE.Color().setRGB(...ing.colors.flesh, THREE.SRGBColorSpace),
roughness: 0.7,
});
const stem = new THREE.Mesh(
new THREE.CylinderGeometry(r * 0.35, r * 0.42, ing.size * 0.6, 16),
stemMat,
);
stem.position.y = -ing.size * 0.15;
stem.castShadow = true;
stem.receiveShadow = true;
g.add(stem);
break;
}
default:
mesh = new THREE.Mesh(new THREE.SphereGeometry(r, 28, 20), skin);
}
mesh.castShadow = true;
mesh.receiveShadow = true;
g.add(mesh);
g.position.y = BOARD_Y * 2 + (ing.shape === 'block' ? ing.size * 0.3 : r * (ing.shape === 'bulb' ? 0.85 : 0.92));
return g;
}
/** A fresh ingredient and a fresh session. */
reset(ingredientId: IngredientId, knife: ToolId, pattern: CutPattern, askText: string): void {
this.ing = INGREDIENTS[ingredientId];
this.session = newCutSession(this.ing, knife, pattern);
if (this.ingMesh) this.root.remove(this.ingMesh);
if (this.pieces) this.root.remove(this.pieces);
this.pieces = null;
for (const s of this.seams) this.root.remove(s);
this.seams = [];
this.ingMesh = this.buildIngredientMesh(this.ing);
this.root.add(this.ingMesh);
if (this.knifeMesh) this.root.remove(this.knifeMesh);
this.knifeMesh = makeCutleryMesh(TOOLS[knife]);
this.root.add(this.knifeMesh);
if (this.stopLine) {
this.root.remove(this.stopLine);
this.stopLine = null;
}
// The onion dice arms the stop-line: a bright band the blade must not pass.
if (pattern.kind === 'dice' && pattern.root) {
this.stopLine = new THREE.Mesh(
new THREE.BoxGeometry(this.ing.size * 1.04, 0.006, this.ing.size * 1.04),
new THREE.MeshBasicMaterial({ color: 0x3ad17a, transparent: true, opacity: 0.55 }),
);
this.stopLine.visible = false;
this.root.add(this.stopLine);
}
this.mess.reset();
this.askEl.textContent = askText;
this.doneTimer = 0;
this.hasPrev = false;
this.wipePrev = null;
this.hintEl.textContent = 'aim · press on it and saw up-down · drag the board to wipe';
}
enter(): void {
this.panel.show();
}
exit(): void {
this.panel.hide();
// Leaving the bench: the eyes clear the moment the onion is off the board.
this.stingOverlay.style.opacity = '0';
this.stingOverlay.style.backdropFilter = 'none';
(this.stingOverlay.style as unknown as { webkitBackdropFilter: string }).webkitBackdropFilter = 'none';
}
/** Board-local UV under the pointer, if the ray hits the board. */
private boardUV(): { u: number; v: number } | null {
const pt = new THREE.Vector3();
if (!this.ray.ray.intersectPlane(this.boardPlane, pt)) return null;
const u = (pt.x + BOARD_W / 2) / BOARD_W;
const v = (pt.z + BOARD_D / 2) / BOARD_D;
if (u < 0 || u > 1 || v < 0 || v > 1) return null;
return { u, v };
}
update(dt: number): void {
const inp = this.app.input;
this.app.camera.position.set(1.2, 2.3, 4.4);
this.app.camera.lookAt(0.6, 0.5, -0.1);
const s = this.session;
if (!s) return;
this.ray.setFromCamera(inp.ndc, this.app.camera);
this.plane.set(new THREE.Vector3(0, 0, 1), 0.2);
const hit = this.ray.ray.intersectPlane(this.plane, this.planePt);
// Cutting happens on the vertical plane near the ingredient; wiping on the
// board everywhere else. "Near" = within a size of the centre.
const nearIngredient = hit && Math.abs(this.planePt.x) < this.ing.size * 0.85 && s.phase !== 'done';
if (s.phase !== 'done' && nearIngredient && hit) {
if (s.phase === 'aim') {
aimCut(s, this.planePt.x / this.ing.size);
if (inp.down) {
beginBite(s);
audio.clatter(0.15, 1.8);
}
} else if (inp.down && this.hasPrev) {
const dy = this.planePt.y - this.prev.y;
const dx = this.planePt.x - this.prev.x;
const f = cutFrame(s, dy, dx, dt, () => this.rng.next());
if (Math.abs(dy) > 0.004) {
this.sawPhase += Math.abs(dy) * 3;
audio.scrape(0.4, (Math.abs(dy) / Math.max(dt, 1e-3)) * 0.22);
}
this.applyFrame(f, dy);
} else if (!inp.down) {
// Release. On the onion's root pass this COMMITS a stop-short cut, so
// apply the frame it returns (seam, clunk) exactly like a through-cut.
const f = liftKnife(s);
if (f.cutDone) this.applyFrame(f, 0);
}
} else if (inp.down) {
// Off the ingredient with the button down: the cloth.
const uv = this.boardUV();
if (uv && this.wipePrev) {
this.mess.wipe(this.wipePrev.x, this.wipePrev.y, uv.u, uv.v);
}
if (uv) this.wipePrev = new THREE.Vector3(uv.u, uv.v, 0);
}
if (!inp.down) this.wipePrev = null;
if (this.allowEarlyServe && s.phase !== 'done' && inp.justPressed('Enter') && this.onDone) {
const result = this.result();
const cb = this.onDone;
this.onDone = null;
cb(result);
return;
}
if (s.phase === 'done') {
this.doneTimer += dt;
// No auto-exit: the bench is judged at serve, so the moment after the
// last cut is exactly when you'd want the cloth. ENTER hands it over.
this.hintEl.textContent = 'done — wipe the board if you dare care · ENTER takes it to the kitchen';
if (inp.justPressed('Enter') && this.onDone) {
const result = this.result();
const cb = this.onDone;
this.onDone = null;
cb(result);
return;
}
}
// The eyes keep stinging whether you're cutting or the onion's just sitting
// there open while you dither — that's the whole point of the clock.
stingStep(s, dt);
this.prev.set(this.planePt.x, this.planePt.y);
this.hasPrev = !!hit;
this.positionKnife(s);
this.updateStopLine(s);
this.syncMess();
this.updateHud(s);
}
/** Park the stop-line at the depth the blade must not saw past, on pass one. */
private updateStopLine(s: CutSession): void {
if (!this.stopLine) return;
const show = s.dicePhase === 0 && s.phase !== 'done';
this.stopLine.visible = show;
if (show && this.ingMesh) {
const topY = this.ingMesh.position.y + this.ing.size * 0.55;
// The knife tip at progress p sits at topY + 0.22 p·size·0.9 (positionKnife);
// the line marks p ≈ 0.82, the middle of the "stopped short" band.
this.stopLine.position.set(0, topY + 0.22 - 0.82 * this.ing.size * 0.9, 0);
}
}
/** What the judge reads. Snapshot at any time; final after onDone. */
result(): PrepResult {
const s = this.session!;
const technique = s.pattern.kind === 'dice' && s.pattern.root === true;
return {
cv: pieceCV(s),
pieces: pieceVolumes(s).length,
patternName: s.pattern.kind === 'halve' ? 'halves' : s.pattern.kind,
slips: s.slips,
bench: this.mess.stats(),
// Onions carry their sting whatever the pattern; only the technique dice
// turns it (and the stop-line) into scored rows.
...(this.ing.sting ? { sting: s.sting } : {}),
...(technique ? { technique: true, stem: stemDiscipline(s) } : {}),
};
}
private applyFrame(f: ReturnType<typeof cutFrame>, dy: number): void {
const s = this.session!;
const cutWorldX = s.aimPos * this.ing.size;
const u = (cutWorldX + BOARD_W / 2) / BOARD_W;
const v = 0.5;
if (f.juice > 0) {
// Sprayed along the blade (board Z), the side picked by the stroke.
const side = dy > 0 ? 1 : -1;
this.mess.addJuice(u, v, f.juice, (this.rng.next() - 0.5) * 0.3, side * (0.6 + this.rng.next() * 0.4));
}
if (f.slipped) {
audio.clatter(0.5, 0.9);
this.app.shake = 0.015;
this.hintEl.textContent = 'it skated. saw as you press — the blade needs to bite';
}
if (f.cutDone) {
audio.clunk(false);
this.addSeam(s.cuts[s.cuts.length - 1]);
for (let i = 0; i < f.seedsSpilled; i++) {
this.mess.addSolid(u + (this.rng.next() - 0.5) * 0.12, v + (this.rng.next() - 0.5) * 0.3, 'seed');
}
}
if (f.sessionDone) {
this.showPieces();
}
}
/** Committed cuts read as dark seams on the ingredient. */
private addSeam(pos: number): void {
const seam = new THREE.Mesh(
new THREE.BoxGeometry(0.016, this.ing.size * 0.95, this.ing.size * 0.95),
new THREE.MeshStandardMaterial({
color: new THREE.Color().setRGB(
this.ing.colors.flesh[0] * 0.45,
this.ing.colors.flesh[1] * 0.45,
this.ing.colors.flesh[2] * 0.45,
THREE.SRGBColorSpace,
),
roughness: 1,
}),
);
seam.position.set(pos * this.ing.size, this.ingMesh!.position.y, 0);
this.root.add(seam);
this.seams.push(seam);
}
/** Session done: the whole becomes pieces, fanned apart so the CV is visible. */
private showPieces(): void {
if (!this.session || this.pieces) return;
const s = this.session;
if (this.ingMesh) this.ingMesh.visible = false;
for (const seam of this.seams) this.root.remove(seam);
this.seams = [];
this.pieces = new THREE.Group();
const flesh = new THREE.MeshStandardMaterial({
color: new THREE.Color().setRGB(...this.ing.colors.flesh, THREE.SRGBColorSpace),
roughness: 0.5,
});
if (s.pattern.kind === 'dice') {
// A real grid of chunks: pass-A cuts × pass-B cuts, jittered like a board.
// Drawn ~1.6x true size and spread wide — honest proportions, readable
// at game camera distance (true-scale cubes vanish against the board).
const CHUNK = 1.6;
const ea = [-0.5, ...[...s.diceCutsA].sort((a, b) => a - b), 0.5];
const eb = [-0.5, ...[...s.cuts].sort((a, b) => a - b), 0.5];
for (let i = 0; i + 1 < ea.length; i++) {
for (let j = 0; j + 1 < eb.length; j++) {
const wa = (ea[i + 1] - ea[i]) * this.ing.size * CHUNK;
const wb = (eb[j + 1] - eb[j]) * this.ing.size * CHUNK;
const h = Math.min(wa, wb, this.ing.size * 0.55);
const piece = new THREE.Mesh(new THREE.BoxGeometry(wa * 0.85, h, wb * 0.85), flesh);
piece.castShadow = true;
piece.position.set(
((ea[i] + ea[i + 1]) / 2) * this.ing.size * CHUNK * 1.1 + (this.rng.next() - 0.5) * 0.06,
BOARD_Y * 2 + h / 2,
((eb[j] + eb[j + 1]) / 2) * this.ing.size * CHUNK * 1.1 + (this.rng.next() - 0.5) * 0.06,
);
piece.rotation.y = (this.rng.next() - 0.5) * 0.4;
this.pieces.add(piece);
}
}
} else if (s.pattern.kind === 'wedges') {
// Diametral cuts: each angle makes two wedges. Sectors exploded slightly.
const angs = [...s.cuts].sort((a, b) => a - b).flatMap((c) => [c * Math.PI, c * Math.PI + Math.PI]);
const r = this.ing.size * 0.5;
const h = this.ing.size * 0.35;
for (let i = 0; i < angs.length; i++) {
const a0 = angs[i];
const a1 = i + 1 < angs.length ? angs[i + 1] : angs[0] + Math.PI * 2;
const mid = (a0 + a1) / 2;
const piece = new THREE.Mesh(
new THREE.CylinderGeometry(r, r, h, 16, 1, false, a0, a1 - a0),
flesh,
);
piece.castShadow = true;
// Exploded well apart so the gaps (and any uneven angles) read.
piece.position.set(Math.cos(mid) * 0.28, BOARD_Y * 2 + h / 2, -Math.sin(mid) * 0.28);
piece.rotation.y = (this.rng.next() - 0.5) * 0.15;
this.pieces.add(piece);
}
} else {
const edges = [-0.5, ...[...s.cuts].sort((a, b) => a - b), 0.5];
let x = -this.ing.size * 0.55 - edges.length * 0.02;
for (let i = 0; i + 1 < edges.length; i++) {
const w = (edges[i + 1] - edges[i]) * this.ing.size;
const mid = (edges[i] + edges[i + 1]) / 2;
const rHere = Math.sqrt(Math.max(0.05, 0.25 - mid * mid)) * this.ing.size;
const piece = new THREE.Mesh(new THREE.CylinderGeometry(rHere, rHere, Math.max(0.02, w * 0.92), 20), flesh);
piece.rotation.z = Math.PI / 2;
piece.castShadow = true;
piece.position.set(x + w / 2, BOARD_Y * 2 + rHere, 0.1);
this.pieces.add(piece);
x += w + 0.05;
}
}
this.root.add(this.pieces);
}
private positionKnife(s: CutSession): void {
if (!this.knifeMesh) return;
const topY = this.ingMesh ? this.ingMesh.position.y + this.ing.size * 0.55 : 1;
this.knifeMesh.rotation.set(0, Math.PI / 2, Math.PI / 2);
const sawZ = s.phase === 'saw' ? Math.sin(this.sawPhase * 6) * 0.3 : 0.15;
const sink = s.phase === 'aim' || s.phase === 'done' ? 0 : s.progress * this.ing.size * 0.9;
this.knifeMesh.visible = s.phase !== 'done';
this.knifeMesh.position.set(s.aimPos * this.ing.size, topY + 0.22 - sink, sawZ + 0.35);
}
/** Push the mess field into the stain texture when it changed. */
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] = 190;
px[i * 4 + 1] = 68;
px[i * 4 + 2] = 36;
px[i * 4 + 3] = Math.round(Math.min(0.85, m) * 255);
}
this.messTex.needsUpdate = true;
const solids = this.mess.solids.filter((s) => !s.collected);
const mat = new THREE.Matrix4();
let n = 0;
for (const s of solids) {
if (n >= 256) break;
mat.setPosition(s.u * BOARD_W - BOARD_W / 2, BOARD_Y * 2 + 0.02, s.v * BOARD_D - BOARD_D / 2);
this.solidsMesh.setMatrixAt(n++, mat);
}
this.solidsMesh.count = n;
this.solidsMesh.instanceMatrix.needsUpdate = true;
}
private updateHud(s: CutSession): void {
const total = plannedCuts(s.pattern);
const done = s.cuts.length + s.diceCutsA.length;
if (s.phase === 'done') {
const cv = pieceCV(s);
this.cutsEl.textContent = `${pieceVolumes(s).length} pieces — CV ${cv.toFixed(3)}, ${cvWord(cv)}`;
} else {
this.cutsEl.textContent = `cut ${Math.min(done + 1, total)} of ${total}${s.phase === 'aim' ? ' (aiming)' : ''}`;
}
this.progFill.style.width = `${Math.round(s.progress * 100)}%`;
// On the root pass the bar turns red past the stop-line — a live warning
// that another stroke puts the blade through the root.
const rootPass = s.pattern.kind === 'dice' && s.pattern.root && s.dicePhase === 0;
this.progFill.style.background = rootPass && s.progress > 0.9 ? '#e2603a' : '#e8a53a';
const bench = this.mess.benchWord();
const wobble =
s.phase === 'saw' && s.wedge > 0.12 ? (s.wedge > 0.3 ? ' · that cut is wandering' : ' · drifting…') : '';
const eyes = this.ing.sting ? ` · ${stingWord(s.sting)}` : '';
this.benchEl.textContent = `bench: ${bench}${wobble}${s.slips ? ` · slips: ${s.slips}` : ''}${eyes}`;
this.benchEl.style.color = bench === 'clean' && s.sting < 0.4 ? '' : bench === 'smeared' || s.sting < 0.75 ? '#e8a53a' : '#e2603a';
if (rootPass && s.phase !== 'done') {
this.hintEl.textContent =
'saw toward the root and STOP at the green line — release to commit · through the root and it falls to confetti';
}
this.syncSting(s);
}
/** The eyes welling up — vignette + blur that grow with sting, cheap DOM. */
private syncSting(s: CutSession): void {
if (!this.ing.sting || s.sting <= 0.01) {
this.stingOverlay.style.opacity = '0';
this.stingOverlay.style.backdropFilter = 'none';
(this.stingOverlay.style as unknown as { webkitBackdropFilter: string }).webkitBackdropFilter = 'none';
return;
}
const st = s.sting;
const vig = Math.min(0.6, st * 0.62);
const blur = (st * 5).toFixed(1);
this.stingOverlay.style.opacity = '1';
this.stingOverlay.style.background = `radial-gradient(ellipse at 50% 46%, transparent ${Math.round(48 - st * 22)}%, rgba(150,172,120,${vig.toFixed(2)}) 100%)`;
this.stingOverlay.style.backdropFilter = `blur(${blur}px)`;
(this.stingOverlay.style as unknown as { webkitBackdropFilter: string }).webkitBackdropFilter = `blur(${blur}px)`;
}
dispose(): void {
this.panel.dispose();
this.stingOverlay.remove();
}
}
/** The eyes readout, in the judge's own thresholds. */
function stingWord(sting: number): string {
return sting < 0.25 ? 'eyes fine' : sting < 0.55 ? 'eyes watering' : sting < 0.8 ? 'eyes streaming' : 'can barely see';
}