THE AIR FRYER: day 23 — air is the ingredient
John's spec from the atlas, honored verbatim: the basket is a small 2D
tray and ARRANGEMENT is the skill. Six wings arrive DUMPED in a pile;
every piece wants a clear gap to breathe (Clark–Evans inverted — the
same spacing statistics that reward an even topping spread here reward
margins). Smothered wings brown slow and keep a PALE face the judge can
see; steam is a LOCAL sin — a corner pile in an empty tray still steams
(the first cut used global fill fraction, which six wings could never
trip; the adversarial pass caught the dead mechanic and the smother
statistic replaced it). The drawer pauses the cook and bleeds the heat —
hovering costs. The SHAKE is the egg-crack grammar: hold F, release; a
sane charge turns the pale faces to the wind, a full-send puts five
wings on his floor and the corner eye goes horrified.
New station, full rails: sim/airfryer.ts (pure, seeded), the top-down
scene with the sliding basket (snap-out on reset — a stale docked tray
was eating the demo's opening drags), judgeAirfryer (THE CRISP golden
band / THE TURN pale faces / THE AIR steam / THE COUNT floor wings),
day 23 on the card with the cursed recipe card, a procedural endless
arm, fan-whir foley that dies when the drawer opens, ghost demo slot 23,
and the wing man's portrait (high-five not coming).
Verified: honest lattice play 9.5 twice back-to-back; the lazy pile
3.6 ('a steamer wearing a fryer badge', raw, never shaken); full-charge
shake loses 5 of 6 with the horrified eye.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
d0fc805705
commit
29f6b25c5b
BIN
public/assets/img/hero_fryer.png
Normal file
BIN
public/assets/img/hero_fryer.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 515 KiB |
BIN
public/assets/img/who_the_wing_man.png
Normal file
BIN
public/assets/img/who_the_wing_man.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 708 KiB |
@ -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"
|
||||
|
||||
43
src/dev.ts
43
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) => {
|
||||
|
||||
@ -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<AirFryerView['result']>): 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();
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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 = <T,>(rand: () => number, a: T[]): T => a[Math.floor(rand() * a.length)];
|
||||
|
||||
/** The toast fields every Order carries; station days never read most of them. */
|
||||
|
||||
277
src/scenes/airfryer.ts
Normal file
277
src/scenes/airfryer.ts
Normal file
@ -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<typeof fryerResult>) => 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<typeof fryerResult> {
|
||||
return fryerResult(this.session!);
|
||||
}
|
||||
}
|
||||
@ -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<string, string> = {
|
||||
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<string, number> = { 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<string, number> = { 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
|
||||
|
||||
196
src/sim/airfryer.ts
Normal file
196
src/sim/airfryer.ts
Normal file
@ -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';
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user