diff --git a/public/assets/img/hero_fryer.png b/public/assets/img/hero_fryer.png new file mode 100644 index 0000000..b6f1bf7 Binary files /dev/null and b/public/assets/img/hero_fryer.png differ diff --git a/public/assets/img/who_the_wing_man.png b/public/assets/img/who_the_wing_man.png new file mode 100644 index 0000000..be46b22 Binary files /dev/null and b/public/assets/img/who_the_wing_man.png differ diff --git a/scripts/gen-art-batch.sh b/scripts/gen-art-batch.sh index c0059e4..3a364ae 100755 --- a/scripts/gen-art-batch.sh +++ b/scripts/gen-art-batch.sh @@ -54,3 +54,5 @@ gen bg_pantry 405 "$BSTYLE, warm pantry shelves with egg cartons, flour sacks gen bg_coldroom 406 "$BSTYLE, stainless steel commercial kitchen cold room, soft blue-grey light" echo done +gen hero_fryer 307 "$JSTYLE, air fryer basket full of golden crispy chicken wings with space between them, seen from above, single basket centered" +gen who_the_wing_man 114 "$STYLE, cheerful heavyset man in a sports jersey and backwards cap, licking sauce off one thumb, other hand raised for a high-five that is not coming" diff --git a/src/dev.ts b/src/dev.ts index f313513..32c6be9 100644 --- a/src/dev.ts +++ b/src/dev.ts @@ -678,6 +678,48 @@ export function installDevHarness(app: App, game: Game): void { return { floats: s.eggs.map((e) => e.floatWord), rottenIn: s.rottenIn, dumped: s.bowlsDumped, rows }; }, + /** Play the day-23 wings with REAL input: arrange on a lattice, cook at + * 180, shake at half-time (a sane 0.5 charge), finish at 190, serve. */ + async fryerDay() { + game.setDay(23); + await F(6); + const fv = game.airFryerView as unknown as { session: import('./sim/airfryer').FryerSession }; + const s = fv.session; + if (!s) return { error: 'not at fryer' }; + const W = (x: number, y: number) => ({ wx: (x - 0.5) * 3.2, wz: (y - 0.5) * 3.2 + 0.35 }); + const dragTo = async (i: number, x: number, y: number) => { + const from = W(s.pieces[i].x, s.pieces[i].y); + const to = W(x, y); + point(from.wx, 0.52, from.wz, false); await F(2); + point(from.wx, 0.52, from.wz, true); await F(2); + point((from.wx + to.wx) / 2, 0.52, (from.wz + to.wz) / 2, true); await F(2); + point(to.wx, 0.52, to.wz, true); await F(2); + point(to.wx, 0.52, to.wz, false); await F(2); + }; + // A 3x2 lattice with margins β€” every wing gets its air. + const spots: [number, number][] = [[0.18, 0.2], [0.5, 0.2], [0.82, 0.2], [0.18, 0.66], [0.5, 0.66], [0.82, 0.66]]; + for (let i = 0; i < Math.min(s.pieces.length, spots.length); i++) await dragTo(i, spots[i][0], spots[i][1]); + s.knob = 6; // 180 on the dial + tap('Space'); // drawer in β€” cook + const mean = () => s.pieces.filter((p) => !p.lost).reduce((a, p) => a + p.browning, 0) / Math.max(1, s.pieces.filter((p) => !p.lost).length); + let g = 0; + while (mean() < 0.3 && g++ < 60 * 90) await F(1); + tap('Space'); // drawer out β€” the shake + await F(4); + window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyF' })); + await F(33); // ~0.5 charge: firm, nothing jumps + window.dispatchEvent(new KeyboardEvent('keyup', { code: 'KeyF' })); + await F(4); + s.knob = 8; // 190 to golden + tap('Space'); // back in + g = 0; + while (mean() < 0.66 && g++ < 60 * 90) await F(1); + tap('Enter'); + await F(6); + const rows = [...document.querySelectorAll('.crit')].map((e) => e.textContent); + return { crisp: mean().toFixed(2), lost: s.lostCount, steamed: s.steamed.toFixed(2), rows }; + }, + /** Play the day-22 benedict: toast the bread, then poach the egg for it. */ async benedictDay() { game.setDay(22); @@ -1410,6 +1452,7 @@ export function installDevHarness(app: App, game: Game): void { 20: () => harness.eggDay(true), 21: () => harness.poachDay(true), 22: () => harness.benedictDay(), + 23: () => harness.fryerDay(), }; (harness as unknown as { demos: typeof demos }).demos = demos; (harness as unknown as { setDemoPlayback: (on: boolean) => void }).setDemoPlayback = (on: boolean) => { diff --git a/src/game/game.ts b/src/game/game.ts index aaeb198..3ec3d44 100644 --- a/src/game/game.ts +++ b/src/game/game.ts @@ -11,6 +11,7 @@ import { FridgeView } from '../scenes/fridge'; import { SteakBoardView } from '../scenes/steakboard'; import { EggBenchView } from '../scenes/eggbench'; import { PoachView } from '../scenes/poach'; +import { AirFryerView } from '../scenes/airfryer'; import { serializeStore, restoreStore, fetch as fetchStock, usableQuality, type StoredItemSave } from '../sim/coldchain'; import { AssemblyView } from '../scenes/assembly'; import type { CutPattern } from '../sim/cutting'; @@ -19,7 +20,7 @@ import { juiceCriteria, type JuiceResult } from '../sim/juicing'; import { TempClock } from '../sim/tempclock'; import type { RoastResult } from '../sim/roasting'; import type { AssemblyResult } from '../sim/assembly'; -import { judgeBruschetta, judgeGrill, judgePan, judgeFridge, judgeSteak, judgeEggs, judgePoach, judgeBenedict, type BruschettaResult, type PrepResult, type Verdict } from './judging'; +import { judgeBruschetta, judgeGrill, judgePan, judgeFridge, judgeSteak, judgeEggs, judgePoach, judgeBenedict, judgeAirfryer, type BruschettaResult, type PrepResult, type Verdict } from './judging'; import { JudgeView } from '../scenes/judge'; import { DrawerView } from '../scenes/drawer'; import { TOOLS, type ToolId } from '../sim/cutlery'; @@ -80,6 +81,7 @@ export class Game { private steakBoard: SteakBoardView; private eggBench: EggBenchView; private poach: PoachView; + private airFryer: AirFryerView; private assembly: AssemblyView; /** What the prep bench reported for the current order, if it ran. */ private prepResult: PrepResult | null = null; @@ -129,6 +131,7 @@ export class Game { this.steakBoard = new SteakBoardView(app); this.eggBench = new EggBenchView(app); this.poach = new PoachView(app); + this.airFryer = new AirFryerView(app); this.assembly = new AssemblyView(app); app.scene.add(this.kitchen.root); app.scene.add(this.judgeView.root); @@ -143,6 +146,7 @@ export class Game { app.scene.add(this.steakBoard.root); app.scene.add(this.eggBench.root); app.scene.add(this.poach.root); + app.scene.add(this.airFryer.root); app.scene.add(this.assembly.root); this.judgeView.root.visible = false; this.drawer.root.visible = false; @@ -156,6 +160,7 @@ export class Game { this.steakBoard.root.visible = false; this.eggBench.root.visible = false; this.poach.root.visible = false; + this.airFryer.root.visible = false; this.assembly.root.visible = false; this.ticket = new Panel('ticket'); @@ -313,6 +318,9 @@ export class Game { get eggBenchView(): EggBenchView { return this.eggBench; } + get airFryerView(): AirFryerView { + return this.airFryer; + } get poachView(): PoachView { return this.poach; } @@ -485,6 +493,7 @@ export class Game { this.steakBoard.root.visible = false; this.eggBench.root.visible = false; this.poach.root.visible = false; + this.airFryer.root.visible = false; this.assembly.root.visible = false; } @@ -630,13 +639,14 @@ export class Game { if (o.steak) return this.enterSteak(); if (o.eggs) return this.enterEggs(); if (o.poach) return this.enterPoach(); + if (o.airfryer) return this.enterAirFryer(); this.enterToastFlow(); } /** The one-line shareable result for the current order's verdict. */ private shareLine(v: Verdict): string { const o = this.order; - const emoji = o.steak ? 'πŸ₯©' : o.grill ? 'πŸ”₯' : o.pan ? '🍳' : o.benedict ? '🍞🍳' : o.poach ? 'πŸ₯š' : o.eggs ? 'πŸ₯š' : o.fridge ? '🧊' : o.prep ? 'πŸ”ͺ' : '🍞'; + const emoji = o.steak ? 'πŸ₯©' : o.grill ? 'πŸ”₯' : o.pan ? '🍳' : o.benedict ? '🍞🍳' : o.poach ? 'πŸ₯š' : o.eggs ? 'πŸ₯š' : o.airfryer ? 'πŸ—' : o.fridge ? '🧊' : o.prep ? 'πŸ”ͺ' : '🍞'; const tag = this.dailyDate ? `daily ${this.dailyDate}` : `day ${o.day}`; return `TOASTSIM ${tag} β€” ${emoji} ${v.grade} ${v.total.toFixed(1)}/10 Β· partly.party/toastsim`; } @@ -718,6 +728,11 @@ export class Game { this.enterEggs(); return; } + // A wing day: the basket, the air, the shake, the count. + if (this.order.airfryer) { + this.enterAirFryer(); + return; + } // A poach day: the shiver, the vortex, the drop, the wobble. if (this.order.poach) { this.enterPoach(); @@ -926,6 +941,34 @@ export class Game { } /** The charcoal grill day (M-H6): arrange the coals, cook on the gradient, serve. */ + private enterAirFryer(): void { + const f = this.order.airfryer!; + this.airFryer.reset(f.count, f.card, `${f.count} wings β€” golden, breathing, all present`); + this.hideAllScenes(); + this.airFryer.root.visible = true; + this.app.setView(this.airFryer); + this.ticket.show(); + this.airFryer.onDone = (result) => { + this.airFryer.root.visible = false; + this.serveAirFryer(result); + }; + } + + private serveAirFryer(result: ReturnType): void { + this.timing = false; + const seconds = this.orderSeconds; + const slice = this.kitchen.currentSlice; + const v = judgeAirfryer(result, this.order, seconds); + this.recordVerdict(v); + + this.hideAllScenes(); + this.judgeView.root.visible = true; + this.judgeView.show(slice, v, this.order, this.kitchen.toolId, () => this.rng.next()); + audio.stamp(); + this.app.setView(this.judgeView); + this.ticket.hide(); + } + private enterGrill(): void { this.grill.reset(this.order.grill!); this.hideAllScenes(); diff --git a/src/game/judging.ts b/src/game/judging.ts index f721eeb..4c8af79 100644 --- a/src/game/judging.ts +++ b/src/game/judging.ts @@ -26,7 +26,7 @@ export interface Criterion { /** Which station earned it β€” the scorecard groups by this on prep orders. * M15 adds the bruschetta trio: bread (cut+toast+rub), topping (roast, * distribution, balance, sog), bench (temperature + mess). */ - group?: 'prep' | 'toast' | 'spread' | 'bread' | 'topping' | 'bench' | 'grill' | 'pan' | 'cold' | 'steak' | 'eggs' | 'poach'; + group?: 'prep' | 'toast' | 'spread' | 'bread' | 'topping' | 'bench' | 'grill' | 'pan' | 'cold' | 'steak' | 'eggs' | 'poach' | 'fryer'; } /** What the prep bench hands the judge, when the order had prep on it. */ @@ -616,6 +616,65 @@ export function judgePoach(r: PoachResult, order: Order, seconds: number): Verdi return { criteria, total, grade: gradeOf(total), best: sorted[sorted.length - 1], worst: sorted[0], lines: [] }; } +// ===================================================================== +// The air fryer β€” arrangement judged as air. The crisp is the browning band; +// the turn reads the pale shadowed faces a lazy shake never showed the wind. + +export function judgeAirfryer(r: import('../sim/airfryer').FryerResult, order: Order, seconds: number): Verdict { + const asked = order.airfryer?.count ?? 6; + const criteria: Criterion[] = [ + { + key: 'frycrisp', + group: 'fryer', + label: 'The Crisp', + score: r.crisp < 0.55 ? clamp01(r.crisp / 0.55) : r.crisp <= 0.8 ? 1 : clamp01(1 - (r.crisp - 0.8) / 0.2), + weight: 1.3, + detail: r.crisp < 0.3 ? 'raw in the middle of a MACHINE' : r.crisp < 0.55 ? 'pale β€” warm is not fried' : r.crisp <= 0.8 ? 'golden, audibly' : 'past golden into regret', + }, + { + key: 'fryturn', + group: 'fryer', + label: 'The Turn', + score: clamp01(r.evenness - r.pale * 0.6), + weight: 1.0, + detail: r.pale > 0.4 ? 'pale faces β€” they were never shaken' : r.pale > 0.18 ? 'one side saw more wind than the other' : 'turned like he taught you', + }, + { + key: 'fryair', + group: 'fryer', + label: 'The Air', + score: clamp01(1 - r.steamed / 5), + weight: 1.1, + detail: r.steamed > 2.5 ? 'that is a steamer wearing a fryer badge' : r.steamed > 0.6 ? 'crowded for a while β€” it shows' : 'every wing breathed', + }, + { + key: 'frycount', + group: 'bench', + label: 'The Count', + score: clamp01(r.served / asked) * (r.lost > 0 ? Math.max(0.3, 1 - r.lost * 0.35) : 1), + weight: 0.8, + detail: r.lost > 0 ? `${r.lost} on the floor. His floor.` : r.served < asked ? `${r.served} of ${asked}` : 'all present', + }, + { + key: 'time', + label: 'Service', + score: clamp01(1 - (seconds - 90) / 120), + weight: 0.3, + detail: `${Math.round(seconds)}s`, + }, + ]; + let sum = 0; + let wsum = 0; + for (const c of criteria) { + sum += c.score * c.weight; + wsum += c.weight; + } + const total = (sum / wsum) * 10; + const rankable = criteria.filter((c) => c.key !== 'time'); + const sorted = [...rankable].sort((a, b) => a.score - b.score); + return { criteria, total, grade: gradeOf(total), best: sorted[sorted.length - 1], worst: sorted[0], lines: [] }; +} + // ===================================================================== // The benedict β€” two crafts, one plate. The Bread rows read the slice the // toaster made; The Poach rows read the egg; the cling sogs the toast beneath. diff --git a/src/game/orders.ts b/src/game/orders.ts index 95bf5cf..5b96888 100644 --- a/src/game/orders.ts +++ b/src/game/orders.ts @@ -119,6 +119,8 @@ export interface Order { /** Eggs benedict: toast the bread, then poach the egg and land it ON the * toast β€” one plate, two crafts, one verdict. */ benedict?: { fuel: string }; + /** THE AIR FRYER: arrangement is the skill. The card is the law. */ + airfryer?: { count: number; card: string }; } export const BROWNING_NAMES: [number, string][] = [ @@ -490,6 +492,21 @@ export const DAYS: Order[] = [ text: 'The full benedict. Golden toast β€” THEN the egg: shiver, vortex, drop, lift it trembling, drain it, and land it on the bread. Two crafts, one plate. Ruin either and the other dies with it.', benedict: { fuel: 'gas' }, }, + { + day: 23, + brand: 'WONDERSLICE', + bread: 'white', + spread: 'butter', + amount: 'normal', + browning: 0.55, + browningName: 'golden', + noChar: true, + tool: 'butter_knife', + butterSoftness: 0.5, + who: 'the wing man', + text: 'Six wings in the air fryer. If they touch, they steam β€” and warm is not fried. Give every wing its air, SHAKE at half-time for the pale faces, and count them out: what jumps the rack is mine to look at.', + airfryer: { count: 6, card: 'THE CARD: 180\u00b0 until they colour \u00b7 drawer OUT, SHAKE \u00b7 190\u00b0 to golden \u00b7 all six present' }, + }, ]; /** Days beyond the script β€” keep going, with everything cranked. */ @@ -508,12 +525,24 @@ export function proceduralOrder(day: number, rand: () => number): Order { if (roll < 0.64) return proceduralGrill(day, rand); if (roll < 0.75) return proceduralPan(day, rand); if (roll < 0.84) return proceduralSteak(day, rand); - if (roll < 0.90) return proceduralEggs(day, rand); + if (roll < 0.89) return proceduralEggs(day, rand); + if (roll < 0.92) return proceduralAirfryer(day, rand); if (roll < 0.94) return proceduralPoach(day, rand); if (roll < 0.97) return proceduralBenedict(day, rand); return proceduralFridge(day, rand); } +function proceduralAirfryer(day: number, rand: () => number): Order { + const count = 5 + Math.floor(rand() * 3); // 5..7 β€” seven is a lesson in restraint + const o = baseOrder( + day, + pickOf(rand, ['the wing man', 'the lunch rush', 'a regular', 'someone new']), + `${count} wings, air-fried. Air between every piece, a proper SHAKE at half-time, nothing on my floor.`, + ); + o.airfryer = { count, card: `THE CARD: 180\u00b0 until colour \u00b7 SHAKE \u00b7 190\u00b0 to golden \u00b7 all ${count} present` }; + return o; +} + const pickOf = (rand: () => number, a: T[]): T => a[Math.floor(rand() * a.length)]; /** The toast fields every Order carries; station days never read most of them. */ diff --git a/src/scenes/airfryer.ts b/src/scenes/airfryer.ts new file mode 100644 index 0000000..41b6c6d --- /dev/null +++ b/src/scenes/airfryer.ts @@ -0,0 +1,277 @@ +import * as THREE from 'three'; +import type { App, View } from '../core/app'; +import { audio } from '../core/audio'; +import { eye } from '../ui/eye'; +import { + type FryerSession, + newFryerSession, + setFryerKnob, + fryStep, + shake, + fryerResult, + fryerWord, + airflowOf, + fillFrac, + knobDegrees, + PIECE_R, +} from '../sim/airfryer'; +import { el, Panel } from '../ui/hud'; +import { loadBackdrop } from './props'; + +const TRAY_Y = 0.5; +const TRAY_W = 3.2; // world size of the 1Γ—1 tray space + +/** + * THE AIR FRYER β€” the basket seen from above, like the grill, because both + * games are ABOUT the 2D arrangement. DRAG wings to give them air. WHEEL sets + * the knob. SPACE slams the drawer in (cook) or pulls it out (arrange β€” and + * the heat leaves). HOLD F and release to SHAKE: charge it like the egg crack; + * too hard and he watches a wing land on his shoe. ENTER serves. + */ +export class AirFryerView implements View { + readonly root = new THREE.Group(); + + private session: FryerSession | null = null; + private tray!: THREE.Mesh; + private body!: THREE.Mesh; + private wingMeshes: THREE.Mesh[] = []; + + private ray = new THREE.Raycaster(); + private hit = new THREE.Vector3(); + private trayPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -(TRAY_Y + 0.02)); + private grabbed = -1; + private wasDown = false; + private shakeCharge = 0; + private charging = false; + private shakeAnim = 0; + + private bgTex = loadBackdrop('/assets/img/bg_stove.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) => void) | null = null; + + constructor(private app: App) { + this.buildScenery(); + this.panel = new Panel('fryer-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, 'AIR FRY IT'); + 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); + + // The machine: a chunky black drum the basket slides out of. + this.body = new THREE.Mesh( + new THREE.CylinderGeometry(1.9, 2.1, 1.4, 8), + new THREE.MeshStandardMaterial({ color: 0x161616, roughness: 0.55, metalness: 0.4 }), + ); + this.body.position.set(0, 0.5, -2.6); + this.body.castShadow = true; + this.root.add(this.body); + + // The basket tray: a dark wire square, seen from above. + this.tray = new THREE.Mesh( + new THREE.BoxGeometry(TRAY_W, 0.06, TRAY_W), + new THREE.MeshStandardMaterial({ color: 0x222226, roughness: 0.5, metalness: 0.6 }), + ); + this.tray.position.y = TRAY_Y; + this.tray.receiveShadow = true; + this.root.add(this.tray); + // Wire rack lines. + for (let i = 0; i < 7; i++) { + const bar = new THREE.Mesh( + new THREE.BoxGeometry(TRAY_W - 0.1, 0.02, 0.02), + new THREE.MeshStandardMaterial({ color: 0x3c3c42, roughness: 0.4, metalness: 0.7 }), + ); + bar.position.set(0, TRAY_Y + 0.045, -TRAY_W / 2 + 0.25 + (i * (TRAY_W - 0.5)) / 6); + this.root.add(bar); + } + } + + reset(count = 6, cardText = '', askText = 'wings β€” golden, breathing, all present'): void { + this.session = newFryerSession(count); + this.grabbed = -1; + this.shakeCharge = 0; + this.charging = false; + this.tray.position.set(0, TRAY_Y, 0.35); // fresh basket arrives OUT, instantly + for (const m of this.wingMeshes) this.root.remove(m); + this.wingMeshes = []; + for (let i = 0; i < count; i++) { + const wing = new THREE.Mesh( + new THREE.SphereGeometry(PIECE_R * TRAY_W, 12, 9), + new THREE.MeshStandardMaterial({ color: 0xe8cfae, roughness: 0.7 }), + ); + wing.scale.set(1.15, 0.6, 0.85); + wing.rotation.y = (i * 1.7) % Math.PI; + wing.castShadow = true; + this.root.add(wing); + this.wingMeshes.push(wing); + } + this.askEl.textContent = askText; + this.cardEl.textContent = cardText; + this.hintEl.textContent = 'DRAG β€” give them AIR Β· WHEEL β€” knob Β· SPACE β€” drawer in/out Β· hold F, release β€” SHAKE Β· 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, 4.4, 2.3); + this.app.camera.lookAt(0, TRAY_Y, 0.1); + const s = this.session; + if (!s) return; + const inp = this.app.input; + + if (inp.wheel !== 0) setFryerKnob(s, Math.round(s.knob) + (inp.wheel > 0 ? 1 : -1)); + + // The drawer: out to arrange and shake, in to cook. + if (inp.justPressed('Space')) { + s.drawerOpen = !s.drawerOpen; + audio.clunk(s.drawerOpen); + } + + // Drag wings β€” only with the drawer out. He has seen people reach into + // a running fryer. He has notes about those people. + this.ray.setFromCamera(inp.ndc, this.app.camera); + const on = this.ray.ray.intersectPlane(this.trayPlane, this.hit); + if (s.drawerOpen && inp.down && on) { + const tx = (this.hit.x - this.tray.position.x) / TRAY_W + 0.5; + const ty = (this.hit.z - this.tray.position.z) / TRAY_W + 0.5; + if (!this.wasDown) { + this.grabbed = -1; + let best = 0.09; + for (let i = 0; i < s.pieces.length; i++) { + if (s.pieces[i].lost) continue; + const d = Math.hypot(s.pieces[i].x - tx, s.pieces[i].y - ty); + if (d < best) { + best = d; + this.grabbed = i; + } + } + } + if (this.grabbed >= 0) { + s.pieces[this.grabbed].x = Math.max(PIECE_R, Math.min(1 - PIECE_R, tx)); + s.pieces[this.grabbed].y = Math.max(PIECE_R, Math.min(1 - PIECE_R, ty)); + } + } + if (!inp.down) this.grabbed = -1; + this.wasDown = inp.down; + + // The shake: hold F to wind up, release to let it go. + if (inp.held('KeyF') && s.drawerOpen) { + this.charging = true; + this.shakeCharge = Math.min(1, this.shakeCharge + dt / 1.1); + } else if (this.charging) { + const r = shake(s, this.shakeCharge); + if (r.redistributed) { + audio.clatter(0.3 + this.shakeCharge * 0.5, 0.7); + this.shakeAnim = 1; + if (r.jumped > 0) { + audio.clatter(0.8, 1.6); + eye.mood('horrified', 2.5); + this.wordEl.textContent = `${r.jumped} wing${r.jumped > 1 ? 's' : ''} jumped the rack. He is looking at his shoe.`; + this.wordEl.style.color = '#e2603a'; + } + } + this.charging = false; + this.shakeCharge = 0; + } + + fryStep(s, dt); + // The fan IS the thermometer: the whir climbs with the chamber and dies + // the moment the drawer comes out. + const heatN = s.heat / 9; + audio.bed('fan', s.drawerOpen ? 0 : 0.02 + heatN * 0.07, 260 + heatN * 260, 0.5, 0.6 + heatN * 0.5); + const b = s.pieces.filter((p) => !p.lost).reduce((a, p) => a + p.browning, 0) / Math.max(1, s.pieces.filter((p) => !p.lost).length); + audio.bed('sizzle', s.drawerOpen ? 0 : Math.min(0.1, b * 0.25) * heatN, 5200, 0.8, 0.9); + + this.syncVisuals(dt); + this.updateHud(); + + if (inp.justPressed('Enter')) { + audio.bed('fan', 0, 300); + audio.bed('sizzle', 0, 5200); + const cb = this.onDone; + this.onDone = null; + cb?.(fryerResult(this.session!)); + } + } + + private syncVisuals(dt: number): void { + const s = this.session!; + this.shakeAnim = Math.max(0, this.shakeAnim - dt * 3); + const wob = Math.sin(this.shakeAnim * 30) * this.shakeAnim * 0.06; + // Drawer out = tray forward on the bench; in = docked under the drum. + const targetZ = s.drawerOpen ? 0.35 : -1.7; + this.tray.position.z += (targetZ - this.tray.position.z) * (1 - Math.exp(-dt / 0.09)); + this.tray.position.x = wob; + for (let i = 0; i < s.pieces.length; i++) { + const p = s.pieces[i]; + const m = this.wingMeshes[i]; + m.visible = !p.lost; + if (p.lost) continue; + m.position.set( + this.tray.position.x + (p.x - 0.5) * TRAY_W, + TRAY_Y + 0.12, + this.tray.position.z + (p.y - 0.5) * TRAY_W, + ); + // Colour: raw cream β†’ golden β†’ carbon; pale face lightens the mix back. + const g = Math.min(1, p.browning); + const mat = m.material as THREE.MeshStandardMaterial; + const golden = new THREE.Color(0xe8cfae).lerp(new THREE.Color(0x7a4416), Math.min(1, g * 1.15)); + if (g > 0.82) golden.lerp(new THREE.Color(0x241a12), (g - 0.82) * 4); + golden.lerp(new THREE.Color(0xead9bb), p.pale * 0.55); + mat.color = golden; + // Charging shake: the whole rack trembles with intent. + if (this.charging) m.position.y += Math.sin(s.seconds * 40 + i) * 0.02 * this.shakeCharge; + } + // The charging tray leans β€” you can SEE the wind-up. + this.tray.rotation.z = this.charging ? Math.sin(s.seconds * 30) * 0.03 * this.shakeCharge : 0; + } + + private updateHud(): void { + const s = this.session!; + const live = s.pieces.filter((p) => !p.lost); + const touching = live.filter((_, i) => airflowOf(s, s.pieces.indexOf(live[i])) < 0.35).length; + this.stateEl.textContent = `${knobDegrees(s.knob)}Β° set Β· ${live.length} in the basket Β· ${touching > 0 ? `${touching} smothered` : 'all breathing'} Β· fill ${(fillFrac(s) * 100).toFixed(0)}% Β· ${Math.round(s.seconds)}s`; + if (!this.wordEl.style.color || this.wordEl.textContent === '' || !this.wordEl.textContent.includes('shoe')) { + this.wordEl.textContent = fryerWord(s); + this.wordEl.style.color = ''; + } + } + + result(): ReturnType { + return fryerResult(this.session!); + } +} diff --git a/src/scenes/judge.ts b/src/scenes/judge.ts index 001bdba..1ff3a25 100644 --- a/src/scenes/judge.ts +++ b/src/scenes/judge.ts @@ -195,7 +195,7 @@ export class JudgeView implements View { // Grill and bruschetta days don't have a browning/spread to name β€” show who // asked and what for, not the unread toast defaults. // The dish itself, in miniature, beside the ask. - const heroKey = order.benedict ? 'benedict' : order.poach ? 'poach' : order.eggs ? 'eggs' : order.steak ? 'steak' : order.grill ? 'grill' : order.pan ? 'pan' : null; + const heroKey = order.benedict ? 'benedict' : order.poach ? 'poach' : order.eggs ? 'eggs' : order.steak ? 'steak' : order.grill ? 'grill' : order.pan ? 'pan' : order.airfryer ? 'fryer' : null; this.orderEl.parentElement?.querySelector('.judge-hero')?.remove(); if (heroKey) { const hero = el('img', 'judge-hero', this.orderEl.parentElement ?? this.orderEl) as HTMLImageElement; @@ -203,7 +203,9 @@ export class JudgeView implements View { hero.src = assetUrl(`/assets/img/hero_${heroKey}.png`); this.orderEl.parentElement?.insertBefore(hero, this.orderEl); } - this.orderEl.textContent = order.grill + this.orderEl.textContent = order.airfryer + ? `Day ${order.day} Β· ${order.who} asked for the wings` + : order.grill ? `Day ${order.day} Β· ${order.who} asked for the barbie` : order.benedict ? `Day ${order.day} Β· ${order.who} asked for the full benedict` @@ -227,7 +229,7 @@ export class JudgeView implements View { // Station orders (prep, and M15's bruschetta trio) get the grouped card; // a plain toast order keeps the flat card it always had. const grouped = verdict.criteria.some( - (c) => c.group === 'prep' || c.group === 'bread' || c.group === 'topping' || c.group === 'bench' || c.group === 'grill' || c.group === 'pan' || c.group === 'cold' || c.group === 'steak' || c.group === 'eggs' || c.group === 'poach', + (c) => c.group === 'prep' || c.group === 'bread' || c.group === 'topping' || c.group === 'bench' || c.group === 'grill' || c.group === 'pan' || c.group === 'cold' || c.group === 'steak' || c.group === 'eggs' || c.group === 'poach' || c.group === 'fryer', ); const headers: Record = { prep: 'THE PREP', @@ -241,9 +243,10 @@ export class JudgeView implements View { steak: 'THE STEAK', eggs: 'THE EGGS', poach: 'THE POACH', + fryer: 'THE FRYER', bench: 'THE BENCH', }; - const groupRank: Record = { prep: 0, bread: 1, toast: 2, topping: 3, grill: 3, pan: 3, cold: 3, steak: 3, eggs: 3, poach: 3, spread: 4, bench: 5 }; + const groupRank: Record = { prep: 0, bread: 1, toast: 2, topping: 3, grill: 3, pan: 3, cold: 3, steak: 3, eggs: 3, poach: 3, fryer: 3, spread: 4, bench: 5 }; let lastGroup: string | undefined; const rank = (g?: string) => (g && g in groupRank ? groupRank[g] : 9); const ordered = grouped diff --git a/src/sim/airfryer.ts b/src/sim/airfryer.ts new file mode 100644 index 0000000..c27aaac --- /dev/null +++ b/src/sim/airfryer.ts @@ -0,0 +1,196 @@ +import { Rng } from '../core/rng'; + +/** + * THE AIR FRYER β€” arrangement is the skill (John's spec, honored verbatim). + * + * The basket is a small 2D tray and the whole game is AIR: every piece wants + * a gap around it. Clark–Evans inverted β€” the same spacing statistics that + * reward an even topping spread here reward margins. Pieces touching shadow + * each other and the shadowed faces stay pale; overfill the tray and nothing + * fries, everything steams ("that's not fried, that's WARM"). The drawer + * pauses the cook and bleeds the heat β€” hovering costs, exactly like life. + * The SHAKE is its own mini-skill: too soft does nothing, too hard and the + * wings jump the rack. + */ + +export const TRAY = 1; // tray is 1Γ—1 in tray-space +export const PIECE_R = 0.105; +/** A piece with at least this much clear edge-gap gets full airflow. */ +const AIR_GAP = 0.055; +/** Mean smother (1 - airflow) past which the wet heat takes over. */ +const SMOTHER_AT = 0.25; +/** Browning per second at full heat with full airflow. */ +const BROWN_RATE = 0.021; +/** The knob maps 0..9 β†’ 120..210 Β°C for the ticket's language. */ +export const knobDegrees = (k: number): number => 120 + Math.round(k) * 10; + +export interface Piece { + x: number; + y: number; + /** Cooked colour, 0 raw .. 1 carbon. ~0.55–0.8 is golden. */ + browning: number; + /** EMA of starved airflow β€” the pale shadowed face the judge can see. */ + pale: number; + /** Shaken clean out of the basket. On the floor. His floor. */ + lost: boolean; +} + +export interface FryerSession { + pieces: Piece[]; + /** Knob target 0..9. */ + knob: number; + /** Chamber heat 0..9 β€” chases the knob, bleeds out the open drawer. */ + heat: number; + drawerOpen: boolean; + seconds: number; + /** Accumulated steam-sog from overfilled cooking. */ + steamed: number; + /** Shakes that went badly (pieces out). */ + lostCount: number; + rng: Rng; +} + +export function newFryerSession(count = 6, seed = 20260721): FryerSession { + const rng = new Rng(seed); + const pieces: Piece[] = []; + // They arrive DUMPED β€” a careless pile in the middle. Arranging is the job. + for (let i = 0; i < count; i++) { + pieces.push({ + x: 0.5 + (rng.next() - 0.5) * 0.28, + y: 0.5 + (rng.next() - 0.5) * 0.28, + browning: 0, + pale: 0, + lost: false, + }); + } + return { pieces, knob: 0, heat: 0, drawerOpen: true, seconds: 0, steamed: 0, lostCount: 0, rng }; +} + +export function setFryerKnob(s: FryerSession, k: number): void { + s.knob = Math.max(0, Math.min(9, Math.round(k))); +} + +/** Clear gap from this piece's edge to the nearest neighbour edge or wall. */ +export function gapOf(s: FryerSession, i: number): number { + const p = s.pieces[i]; + if (p.lost) return AIR_GAP; + let gap = Math.min(p.x, p.y, TRAY - p.x, TRAY - p.y) - PIECE_R; // wall margin + for (let j = 0; j < s.pieces.length; j++) { + if (j === i || s.pieces[j].lost) continue; + const q = s.pieces[j]; + gap = Math.min(gap, Math.hypot(p.x - q.x, p.y - q.y) - 2 * PIECE_R); + } + return gap; +} + +/** 0 = smothered, 1 = breathing. The whole minigame reduced to one number. */ +export function airflowOf(s: FryerSession, i: number): number { + const g = gapOf(s, i); + return Math.max(0, Math.min(1, (g + 0.02) / (AIR_GAP + 0.02))); +} + +/** Piece-area over tray-area, only what's actually in the basket. */ +export function fillFrac(s: FryerSession): number { + const n = s.pieces.filter((p) => !p.lost).length; + return (n * Math.PI * PIECE_R * PIECE_R) / (TRAY * TRAY); +} + +export function fryStep(s: FryerSession, dt: number): void { + s.seconds += dt; + // The chamber chases the knob β€” quick little machine β€” but an open drawer + // dumps its heat and stops the cook. Hover if you must; it costs. + if (s.drawerOpen) { + s.heat = Math.max(0, s.heat - 1.6 * dt); + return; + } + s.heat += (s.knob - s.heat) * (1 - Math.exp(-dt / 2.5)); + const heatN = s.heat / 9; + if (heatN <= 0.02) return; + // Steam is a LOCAL sin: it doesn't matter how empty the tray is if the + // wings are piled in one corner breathing each other's breath. + const live = s.pieces.filter((p) => !p.lost); + const smother = live.length ? live.reduce((a, p) => a + (1 - airflowOf(s, s.pieces.indexOf(p))), 0) / live.length : 0; + const over = Math.max(0, smother - SMOTHER_AT) / (1 - SMOTHER_AT); + const steamFactor = 1 - over * 0.85; // crowded = wet heat, no crisp + if (over > 0) s.steamed += over * dt * heatN * 2; + for (let i = 0; i < s.pieces.length; i++) { + const p = s.pieces[i]; + if (p.lost) continue; + const air = airflowOf(s, i); + p.browning += BROWN_RATE * heatN * heatN * (0.3 + 0.7 * air) * steamFactor * dt; + // The starved face: what the airflow never touched stays pale, and only + // a SHAKE (new neighbours, new faces) can pay that debt down. + p.pale += ((1 - air) - p.pale) * (1 - Math.exp(-dt / 6)); + } +} + +/** + * The shake β€” drawer OUT, both hands, one sharp motion. `strength` 0..1. + * Soft barely moves them; right redistributes; past ~0.75 the wings start + * jumping the rack, and he counts what hits the floor. + */ +export function shake(s: FryerSession, strength: number): { redistributed: boolean; jumped: number } { + if (!s.drawerOpen) return { redistributed: false, jumped: 0 }; + const st = Math.max(0, Math.min(1, strength)); + if (st < 0.15) return { redistributed: false, jumped: 0 }; + let jumped = 0; + for (const p of s.pieces) { + if (p.lost) continue; + if (st > 0.75 && s.rng.next() < (st - 0.75) * 2.2) { + p.lost = true; + s.lostCount++; + jumped++; + continue; + } + // Jitter scaled by strength; clamped to the tray. New faces to the air. + p.x = Math.max(PIECE_R, Math.min(TRAY - PIECE_R, p.x + (s.rng.next() - 0.5) * 0.3 * st)); + p.y = Math.max(PIECE_R, Math.min(TRAY - PIECE_R, p.y + (s.rng.next() - 0.5) * 0.3 * st)); + p.pale *= 1 - st * 0.8; // the pale face turns to the wind + } + s.seconds += 2; + return { redistributed: true, jumped }; +} + +export interface FryerResult { + crisp: number; + evenness: number; + pale: number; + steamed: number; + lost: number; + served: number; + seconds: number; +} + +export function fryerResult(s: FryerSession): FryerResult { + const live = s.pieces.filter((p) => !p.lost); + const n = Math.max(1, live.length); + const mean = live.reduce((a, p) => a + p.browning, 0) / n; + const sd = Math.sqrt(live.reduce((a, p) => a + (p.browning - mean) ** 2, 0) / n); + const pale = live.reduce((a, p) => a + p.pale, 0) / n; + return { + crisp: mean, + evenness: Math.max(0, 1 - sd * 3.5), + pale, + steamed: s.steamed, + lost: s.lostCount, + served: live.length, + seconds: s.seconds, + }; +} + +/** The tray, in words. No meters. */ +export function fryerWord(s: FryerSession): string { + if (s.drawerOpen) return s.heat > 2 ? 'drawer out β€” the heat is LEAVING' : 'drawer out. arrange them. air is the ingredient'; + const liveW = s.pieces.filter((p) => !p.lost); + const over = liveW.length > 0 && liveW.reduce((a, p) => a + (1 - airflowOf(s, s.pieces.indexOf(p))), 0) / liveW.length > SMOTHER_AT; + const touching = s.pieces.some((p, i) => !p.lost && gapOf(s, i) < 0); + if (over) return 'crowded β€” that is a steamer now'; + if (touching) return 'they are TOUCHING. touching wings steam'; + if (s.heat < s.knob - 1.5) return 'coming up to heat…'; + const b = s.pieces.filter((p) => !p.lost).reduce((a, p) => a + p.browning, 0) / Math.max(1, s.pieces.filter((p) => !p.lost).length); + if (b < 0.2) return 'the fan is doing its work'; + if (b < 0.45) return 'starting to colour'; + if (b < 0.55) return 'nearly β€” hold your nerve'; + if (b < 0.8) return 'GOLDEN. this is the window'; + return 'past it β€” get them OUT'; +} diff --git a/src/style.css b/src/style.css index 8a2d36a..74397a5 100644 --- a/src/style.css +++ b/src/style.css @@ -836,3 +836,13 @@ body:has(#ui > .title) #touchpad { display: none; } float: right; margin: 0 0 6px 10px; } + +.fryer-card { + font: 600 11px ui-monospace, monospace; + color: #8a6a3a; + background: #efe4cf; + border-radius: 3px; + padding: 5px 8px; + margin: 6px 0; + letter-spacing: 0.02em; +}