toastsim/src/scenes/fryerpot.ts
type-two 47f6c39e2b THE MANDOLINE: day 28 — yield against skin
P8's machine, and the one station where evenness is FREE. A knife makes
you earn every slice; the mandoline hands you a perfect one every
stroke. So the game is not the cut. The game is the last third of the
vegetable, where your knuckles get close to the blade and the only
thing between them is a guard that wastes what it protects.

The trade, and it is the whole station: bare-handed shaves further down
for a better yield and is COMPLETELY SAFE while the thing is long — the
risk is zero above a comfortable stub and climbs steeply below it, so
careful play never bleeds and greed does. The guard is not a probability,
it is a fact: with it on you cannot be cut. It also cannot grip a stub,
so it stops you early and leaves waste in the bin.

Bleeding is not a bad row, it is a health inspector: the plate caps at
3.5 and the detail reads 'that food does not leave my kitchen'.

Two fixes the verification forced:

- The first tuning asked for 14 slices, and a guarded cook tops out at
  about 13 — so the safe route could not satisfy the ticket and the
  player was choosing between failing the ask and bleeding. That breaks
  the house rule that careful play is reliably safe. The ask is 12 now
  (10..13 procedurally), reachable guarded, with bare-handed greed worth
  a couple of extra slices and nothing you could not otherwise earn.
- The waste scale punished the guard so hard that safe play was mediocre
  by construction. Re-centred on the guard floor: the guard costs a
  little yield, it does not sink the run.

And a bug the screenshot caught, which turned out to be three bugs: the
nick message is sticky by design (updateHud refuses to overwrite it), and
NOTHING cleared it on reset — so a fresh cook was told 'THE BLADE FOUND
YOU' in red before touching the machine. The fryer's 'it CAUGHT you' and
the air fryer's 'jumped the rack' had the identical trap. All three
cleared on reset.

Verified: careful 9.6 (13 slices, guarded, hands intact); greedy 15
slices — MORE yield — and 3.5 for bleeding on them; word clean on a
fresh board; curry 10.0, chips 10.0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 21:46:47 +10:00

325 lines
12 KiB
TypeScript

import * as THREE from 'three';
import type { App, View } from '../core/app';
import { audio } from '../core/audio';
import { eye } from '../ui/eye';
import {
type FryerPotSession,
newFryerPotSession,
setOilKnob,
basket,
fryerPotStep,
fryerPotResult,
oilWord,
chipWord,
meanCooked,
meanGold,
BLANCH_LO,
GOLD_AT,
SPIT_AT,
} from '../sim/fryer';
import { el, Panel } from '../ui/hud';
import { loadBackdrop } from './props';
import { loadProp } from './assets';
const POT_Y = 0.55;
const POT_R = 1.05;
/**
* THE FRYER — the pot of oil, the basket, the rack, and where your hand is.
*
* WHEEL sets the flame under the pot (the oil answers SLOWLY — thermal mass
* is the whole lesson). SPACE drops the basket in or lifts it to the rack.
* The pointer is your HAND: raw chips in screaming oil spit, and a spit only
* burns the hand parked over the pot. Stand back. ENTER serves.
*/
export class FryerPotView implements View {
readonly root = new THREE.Group();
private session: FryerPotSession | null = null;
private flame!: THREE.Mesh;
private oilSurf!: THREE.Mesh;
private basketGrp!: THREE.Group;
private chipMeshes: THREE.Mesh[] = [];
private rack!: THREE.Mesh;
private spitFlashes: { m: THREE.Mesh; t: number }[] = [];
private lastSpits = 0;
private lastBurns = 0;
private ray = new THREE.Raycaster();
private hit = new THREE.Vector3();
private plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -(POT_Y + 0.15));
private bgTex = loadBackdrop('/assets/img/bg_pot.png');
private prevBg: unknown = null;
private panel: Panel;
private askEl!: HTMLElement;
private cardEl!: HTMLElement;
private stateEl!: HTMLElement;
private wordEl!: HTMLElement;
private hintEl!: HTMLElement;
onDone: ((result: ReturnType<typeof fryerPotResult>) => void) | null = null;
constructor(private app: App) {
this.buildScenery();
this.panel = new Panel('fryerpot-panel');
this.buildUi();
this.panel.hide();
}
private buildUi(): void {
const p = this.panel.root;
const card = el('div', 'slicer-card', p);
el('div', 'drawer-lbl', card, 'FRY IT. TWICE.');
this.askEl = el('div', 'slicer-ask', card, '');
this.cardEl = el('div', 'fryer-card', card, '');
this.stateEl = el('div', 'slicer-thick', card, '');
this.wordEl = el('div', 'slicer-wobble', card, '');
this.hintEl = el('div', 'drawer-hint', p, '');
}
private buildScenery(): void {
const bench = new THREE.Mesh(
new THREE.BoxGeometry(9, 0.4, 6),
new THREE.MeshStandardMaterial({ color: 0x3a3a3e, roughness: 0.7, metalness: 0.2 }),
);
bench.position.y = -0.2;
bench.receiveShadow = true;
this.root.add(bench);
const burner = new THREE.Mesh(
new THREE.CylinderGeometry(0.8, 0.9, 0.12, 24),
new THREE.MeshStandardMaterial({ color: 0x151515, roughness: 0.8, metalness: 0.4 }),
);
burner.position.y = 0.06;
this.root.add(burner);
this.flame = new THREE.Mesh(
new THREE.ConeGeometry(0.6, 0.42, 20),
new THREE.MeshBasicMaterial({ color: 0x3a7bff, transparent: true, opacity: 0 }),
);
this.flame.position.y = 0.3;
this.root.add(this.flame);
// The pot — deeper and darker than the poach pot. It has opinions.
const pot = new THREE.Mesh(
new THREE.CylinderGeometry(POT_R, POT_R * 0.9, 0.85, 32, 1, true),
new THREE.MeshStandardMaterial({ color: 0x2e2e33, roughness: 0.45, metalness: 0.75, side: THREE.DoubleSide }),
);
pot.position.y = POT_Y - 0.05;
this.root.add(pot);
// The oil surface stays procedural — it is the thermometer, and it has to
// shimmer, darken and be readable. Only the vessel gets replaced.
void loadProp('/assets/models/fryer_pot.glb', 2.05)
.then((prop) => {
prop.position.set(0, 0.04, 0);
pot.visible = false;
this.root.add(prop);
})
.catch(() => undefined);
// Sized to the GLB pot's inner rim, not the understudy cylinder's — the
// oil poking out past the steel was the giveaway.
this.oilSurf = new THREE.Mesh(
new THREE.CylinderGeometry(POT_R * 0.64, POT_R * 0.64, 0.05, 32),
new THREE.MeshStandardMaterial({ color: 0xc9a23a, roughness: 0.15, metalness: 0.3, transparent: true, opacity: 0.9 }),
);
this.oilSurf.position.y = POT_Y + 0.2;
this.root.add(this.oilSurf);
// The basket: a wire cage on a long handle, chips inside.
this.basketGrp = new THREE.Group();
const cage = new THREE.Mesh(
new THREE.CylinderGeometry(0.62, 0.5, 0.42, 16, 1, true),
new THREE.MeshStandardMaterial({ color: 0x9aa0a6, roughness: 0.35, metalness: 0.8, side: THREE.DoubleSide, transparent: true, opacity: 0.55 }),
);
const handle = new THREE.Mesh(
new THREE.BoxGeometry(1.5, 0.05, 0.09),
new THREE.MeshStandardMaterial({ color: 0x1b1b1b, roughness: 0.6 }),
);
handle.position.set(1.35, 0.18, 0);
this.basketGrp.add(cage, handle);
this.root.add(this.basketGrp);
void loadProp('/assets/models/fryer_basket.glb', 1.7)
.then((prop) => {
// Sits proud of the oil line: a basket you cannot see is a basket you
// cannot judge. The handle stays procedural — it reads as the thing
// your hand is on.
prop.position.set(0, -0.02, 0);
cage.visible = false;
this.basketGrp.add(prop);
})
.catch(() => undefined);
// The rack, stage left — where the resting happens.
this.rack = new THREE.Mesh(
new THREE.BoxGeometry(1.5, 0.06, 1.1),
new THREE.MeshStandardMaterial({ color: 0x8a6a3a, roughness: 0.8 }),
);
this.rack.position.set(-2.5, 0.45, 0.6);
this.rack.receiveShadow = true;
this.root.add(this.rack);
void loadProp('/assets/models/drain_rack.glb', 1.8)
.then((prop) => {
prop.position.set(-2.5, 0.4, 0.6);
this.rack.visible = false;
this.root.add(prop);
})
.catch(() => undefined);
}
reset(count = 8, cardText = '', askText = 'chips — twice. once for cooked, once for golden'): void {
this.session = newFryerPotSession(count);
this.lastSpits = 0;
this.lastBurns = 0;
for (const m of this.chipMeshes) this.root.remove(m);
for (const f of this.spitFlashes) this.root.remove(f.m);
this.chipMeshes = [];
this.spitFlashes = [];
for (let i = 0; i < count; i++) {
const chip = new THREE.Mesh(
new THREE.BoxGeometry(0.34, 0.11, 0.11),
new THREE.MeshStandardMaterial({ color: 0xf2e6c4, roughness: 0.75 }),
);
chip.castShadow = true;
this.chipMeshes.push(chip);
this.root.add(chip);
}
// Same sticky-word trap as the mandoline: updateHud deliberately refuses
// to overwrite the accident line, so a fresh session must clear it or the
// next cook is told about an injury that has not happened.
this.wordEl.textContent = '';
this.wordEl.style.color = '';
this.askEl.textContent = askText;
this.cardEl.textContent = cardText;
this.hintEl.textContent = 'WHEEL — flame · SPACE — basket in / out to the rack · keep your HAND off the pot when it spits · ENTER serves';
}
enter(): void {
this.prevBg = this.app.scene.background;
this.app.scene.background = this.bgTex;
this.panel.show();
}
exit(): void {
this.app.scene.background = this.prevBg as never;
this.panel.hide();
}
update(dt: number): void {
this.app.camera.position.set(0, 3.3, 3.6);
this.app.camera.lookAt(-0.4, POT_Y, 0);
const s = this.session;
if (!s) return;
const inp = this.app.input;
if (inp.wheel !== 0) setOilKnob(s, Math.round(s.knob) + (inp.wheel > 0 ? 1 : -1));
if (inp.justPressed('Space')) {
basket(s, !s.basketIn);
audio.clatter(0.5, s.basketIn ? 0.7 : 1.1);
}
// The hand: pointer projected onto the pot's mouth.
this.ray.setFromCamera(inp.ndc, this.app.camera);
const on = this.ray.ray.intersectPlane(this.plane, this.hit);
const handOverPot = !!on && Math.hypot(this.hit.x, this.hit.z) < POT_R * 1.25;
fryerPotStep(s, dt, handOverPot);
// Spit events arrive from the sim as counters — flash and sting here.
if (s.spits > this.lastSpits) {
this.lastSpits = s.spits;
audio.clatter(0.9, 2.2);
const fl = new THREE.Mesh(
new THREE.SphereGeometry(0.05, 6, 5),
new THREE.MeshBasicMaterial({ color: 0xffd27a }),
);
const a = Math.random() * Math.PI * 2;
fl.position.set(Math.cos(a) * POT_R * 0.7, POT_Y + 0.3, Math.sin(a) * POT_R * 0.7);
this.root.add(fl);
this.spitFlashes.push({ m: fl, t: 0.5 });
}
if (s.burns > this.lastBurns) {
this.lastBurns = s.burns;
audio.fart(s.burns * 3 + 1); // the yelp, in the house voice
eye.mood('horrified', 3);
this.wordEl.textContent = 'it CAUGHT you — the hand was over the pot';
this.wordEl.style.color = '#e2603a';
}
// Foley: the oil's own bed — bubbles rise with temp, frying adds sizzle.
const oilN = s.oil / 10;
audio.bed('oil', 0.015 + oilN * 0.05, 220 + oilN * 420, 0.6, 0.5 + oilN * 0.4);
if (s.basketIn) audio.bed('sizzle', 0.05 + oilN * 0.09, 4600, 0.8, 0.9);
else audio.bed('sizzle', 0, 4600);
if (s.oil > SPIT_AT && Math.random() < 3 * dt) audio.blip(oilN);
this.syncVisuals(dt);
this.updateHud();
if (inp.justPressed('Enter') && !s.basketIn && s.fries > 0) {
audio.bed('oil', 0, 300);
audio.bed('sizzle', 0, 4600);
const cb = this.onDone;
this.onDone = null;
cb?.(fryerPotResult(s));
}
}
private syncVisuals(dt: number): void {
const s = this.session!;
const oilN = s.oil / 10;
const fm = this.flame.material as THREE.MeshBasicMaterial;
fm.opacity = Math.min(0.85, s.knob / 9);
this.flame.scale.y = 0.5 + (s.knob / 9) * 1.1;
// The oil: pale gold cold, darker and livelier hot; shimmer past blanch.
const om = this.oilSurf.material as THREE.MeshStandardMaterial;
om.color.setHex(0xc9a23a).lerp(new THREE.Color(0x7a5a18), oilN);
this.oilSurf.position.y = POT_Y + 0.12 + (s.oil > BLANCH_LO ? Math.sin(s.seconds * (s.oil > GOLD_AT ? 14 : 7)) * 0.008 : 0);
// The basket travels: rack (out) ⇄ pot (in).
const target = s.basketIn ? { x: 0, y: POT_Y + 0.05 } : { x: -2.5, y: 0.62 };
this.basketGrp.position.x += (target.x - this.basketGrp.position.x) * (1 - Math.exp(-dt / 0.08));
this.basketGrp.position.y += (target.y - this.basketGrp.position.y) * (1 - Math.exp(-dt / 0.08));
this.basketGrp.position.z = s.basketIn ? 0 : 0.6;
for (let i = 0; i < this.chipMeshes.length; i++) {
const c = s.chips[i];
const m = this.chipMeshes[i];
const ang = (i / this.chipMeshes.length) * Math.PI * 2;
const r = 0.3 + (i % 3) * 0.09;
m.position.set(
this.basketGrp.position.x + Math.cos(ang) * r,
this.basketGrp.position.y + 0.1 + (i % 2) * 0.06,
this.basketGrp.position.z + Math.sin(ang) * r,
);
m.rotation.y = ang * 2;
const col = new THREE.Color(0xf2e6c4).lerp(new THREE.Color(0x8a5a1e), Math.min(1, c.gold * 1.2));
if (c.gold > 0.85) col.lerp(new THREE.Color(0x2a1c10), (c.gold - 0.85) * 5);
col.lerp(new THREE.Color(0xb8a878), c.grease * 0.5); // greasy = sad sheen
(m.material as THREE.MeshStandardMaterial).color = col;
}
for (const f of [...this.spitFlashes]) {
f.t -= dt;
f.m.position.y += dt * 2.2;
(f.m.material as THREE.MeshBasicMaterial).color.setHex(f.t > 0.25 ? 0xffd27a : 0xa86a2a);
if (f.t <= 0) {
this.root.remove(f.m);
this.spitFlashes.splice(this.spitFlashes.indexOf(f), 1);
}
}
}
private updateHud(): void {
const s = this.session!;
this.stateEl.textContent = `flame ${Math.round(s.knob)} · fry №${s.fries}${s.basketIn ? ' — basket DOWN' : s.fries > 0 ? ' — on the rack' : ''} · inside ${Math.round(meanCooked(s) * 100)}% · colour ${Math.round(meanGold(s) * 100)}%${s.spits ? ` · ${s.spits} spits` : ''}${s.burns ? ` · ${s.burns} CAUGHT you` : ''}`;
if (!this.wordEl.textContent?.includes('CAUGHT')) {
this.wordEl.textContent = `${oilWord(s)} · ${chipWord(s)}`;
this.wordEl.style.color = '';
}
}
result(): ReturnType<typeof fryerPotResult> {
return fryerPotResult(this.session!);
}
}