THE FRYER: day 24 — twice, love. once for cooked, once for golden
The pot of oil, from the atlas: oil is the fear mechanic and the double
fry is the physics, not the flavour text. Cold oil soaks (grease). Hot
oil seals — and the seal is REAL: past ~0.8 gold the crust shuts and the
inside stops listening, so one screaming-hot fry gives you 'firm in the
middle — the seal beat the cook'. The honest path is the card's path:
blanch in the shimmer until fluffy, REST on the rack while the steam
escapes (dryness is what golds — the oil spends it, the rack rebuilds
it), then back in HOT for the colour. The basket knocks the oil down a
full degree when it lands, because thermal mass does not care about
your schedule.
THE SPIT is the knuckle lesson with a floor: raw chips in oil past 8.4
throw it back, and a spit only burns the hand PARKED OVER THE POT — the
pointer is your hand, and standing back costs nothing. The judge's row
reads 'it spat 13 times; you did not flinch' or names exactly what the
hovering cost you.
First cut had two dead spots the adversarial pass caught: the seal was
too weak (a single hot fry still cooked through for 9.1) and the spit
window was unreachable (the basket-knock kept the oil under SPIT_AT for
the whole raw phase). Rebalanced: seal 1.25x, gold rate up, spit at 8.4.
Verified: double-fry 10.0 ('proper gold · fluffy inside · dry. drained.
correct · 2 fries — as the card says'); one-hot-fry-hand-hovering 7.6
with 13 burns. Day 24 on the card, procedural arm, ghost demo slot 24,
hero_chips on the scorecard, Ray's portrait already on the ticket.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
782971ee83
commit
d1645bae6e
BIN
public/assets/img/hero_chips.png
Normal file
BIN
public/assets/img/hero_chips.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 429 KiB |
@ -56,3 +56,4 @@ gen bg_coldroom 406 "$BSTYLE, stainless steel commercial kitchen cold room, soft
|
||||
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"
|
||||
gen hero_chips 308 "$JSTYLE, paper cone of golden double-fried chips with flaky salt, single cone centered"
|
||||
|
||||
33
src/dev.ts
33
src/dev.ts
@ -678,6 +678,38 @@ 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-24 chips with REAL input: blanch in the shimmer, rest on
|
||||
* the rack till dry, back in HOT for the gold, drain, serve. Twice, love. */
|
||||
async chipDay() {
|
||||
game.setDay(24);
|
||||
await F(6);
|
||||
const fv = game.fryerPotView as unknown as { session: import('./sim/fryer').FryerPotSession };
|
||||
const s = fv.session;
|
||||
if (!s) return { error: 'not at fryer pot' };
|
||||
point(2.8, 0.7, 2.4, false); // hand OFF the pot, always
|
||||
s.knob = 7; // the shimmer
|
||||
let g = 0;
|
||||
while (s.oil < 6.9 && g++ < 60 * 60) await F(1);
|
||||
tap('Space'); // basket in — the blanch
|
||||
g = 0;
|
||||
while (s.chips.reduce((a, c) => a + c.cooked, 0) / s.chips.length < 0.86 && g++ < 60 * 90) await F(1);
|
||||
tap('Space'); // out to the rack
|
||||
g = 0;
|
||||
while (s.dry < 0.95 && g++ < 60 * 40) await F(1);
|
||||
s.knob = 9; // send it back HOT
|
||||
g = 0;
|
||||
while (s.oil < 8.7 && g++ < 60 * 40) await F(1);
|
||||
tap('Space'); // the second fry — the gold
|
||||
g = 0;
|
||||
while (s.chips.reduce((a, c) => a + c.gold, 0) / s.chips.length < 0.7 && g++ < 60 * 90) await F(1);
|
||||
tap('Space'); // out — drain
|
||||
await F(60 * 4);
|
||||
tap('Enter');
|
||||
await F(6);
|
||||
const rows = [...document.querySelectorAll('.crit')].map((e) => e.textContent);
|
||||
return { fries: s.fries, spits: s.spits, burns: s.burns, 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() {
|
||||
@ -1453,6 +1485,7 @@ export function installDevHarness(app: App, game: Game): void {
|
||||
21: () => harness.poachDay(true),
|
||||
22: () => harness.benedictDay(),
|
||||
23: () => harness.fryerDay(),
|
||||
24: () => harness.chipDay(),
|
||||
};
|
||||
(harness as unknown as { demos: typeof demos }).demos = demos;
|
||||
(harness as unknown as { setDemoPlayback: (on: boolean) => void }).setDemoPlayback = (on: boolean) => {
|
||||
|
||||
@ -12,6 +12,7 @@ import { SteakBoardView } from '../scenes/steakboard';
|
||||
import { EggBenchView } from '../scenes/eggbench';
|
||||
import { PoachView } from '../scenes/poach';
|
||||
import { AirFryerView } from '../scenes/airfryer';
|
||||
import { FryerPotView } from '../scenes/fryerpot';
|
||||
import { serializeStore, restoreStore, fetch as fetchStock, usableQuality, type StoredItemSave } from '../sim/coldchain';
|
||||
import { AssemblyView } from '../scenes/assembly';
|
||||
import type { CutPattern } from '../sim/cutting';
|
||||
@ -20,7 +21,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, judgeAirfryer, type BruschettaResult, type PrepResult, type Verdict } from './judging';
|
||||
import { judgeBruschetta, judgeGrill, judgePan, judgeFridge, judgeSteak, judgeEggs, judgePoach, judgeBenedict, judgeAirfryer, judgeFryerPot, 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';
|
||||
@ -82,6 +83,7 @@ export class Game {
|
||||
private eggBench: EggBenchView;
|
||||
private poach: PoachView;
|
||||
private airFryer: AirFryerView;
|
||||
private fryerPot: FryerPotView;
|
||||
private assembly: AssemblyView;
|
||||
/** What the prep bench reported for the current order, if it ran. */
|
||||
private prepResult: PrepResult | null = null;
|
||||
@ -132,6 +134,7 @@ export class Game {
|
||||
this.eggBench = new EggBenchView(app);
|
||||
this.poach = new PoachView(app);
|
||||
this.airFryer = new AirFryerView(app);
|
||||
this.fryerPot = new FryerPotView(app);
|
||||
this.assembly = new AssemblyView(app);
|
||||
app.scene.add(this.kitchen.root);
|
||||
app.scene.add(this.judgeView.root);
|
||||
@ -147,6 +150,7 @@ export class Game {
|
||||
app.scene.add(this.eggBench.root);
|
||||
app.scene.add(this.poach.root);
|
||||
app.scene.add(this.airFryer.root);
|
||||
app.scene.add(this.fryerPot.root);
|
||||
app.scene.add(this.assembly.root);
|
||||
this.judgeView.root.visible = false;
|
||||
this.drawer.root.visible = false;
|
||||
@ -161,6 +165,7 @@ export class Game {
|
||||
this.eggBench.root.visible = false;
|
||||
this.poach.root.visible = false;
|
||||
this.airFryer.root.visible = false;
|
||||
this.fryerPot.root.visible = false;
|
||||
this.assembly.root.visible = false;
|
||||
|
||||
this.ticket = new Panel('ticket');
|
||||
@ -318,6 +323,9 @@ export class Game {
|
||||
get eggBenchView(): EggBenchView {
|
||||
return this.eggBench;
|
||||
}
|
||||
get fryerPotView(): FryerPotView {
|
||||
return this.fryerPot;
|
||||
}
|
||||
get airFryerView(): AirFryerView {
|
||||
return this.airFryer;
|
||||
}
|
||||
@ -494,6 +502,7 @@ export class Game {
|
||||
this.eggBench.root.visible = false;
|
||||
this.poach.root.visible = false;
|
||||
this.airFryer.root.visible = false;
|
||||
this.fryerPot.root.visible = false;
|
||||
this.assembly.root.visible = false;
|
||||
}
|
||||
|
||||
@ -640,13 +649,14 @@ export class Game {
|
||||
if (o.eggs) return this.enterEggs();
|
||||
if (o.poach) return this.enterPoach();
|
||||
if (o.airfryer) return this.enterAirFryer();
|
||||
if (o.fryerpot) return this.enterFryerPot();
|
||||
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.airfryer ? '🍗' : o.fridge ? '🧊' : o.prep ? '🔪' : '🍞';
|
||||
const emoji = o.steak ? '🥩' : o.grill ? '🔥' : o.pan ? '🍳' : o.benedict ? '🍞🍳' : o.poach ? '🥚' : o.eggs ? '🥚' : o.airfryer ? '🍗' : o.fryerpot ? '🍟' : 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`;
|
||||
}
|
||||
@ -733,6 +743,11 @@ export class Game {
|
||||
this.enterAirFryer();
|
||||
return;
|
||||
}
|
||||
// A chip day: the pot, the blanch, the rest, the hot fry, the spit.
|
||||
if (this.order.fryerpot) {
|
||||
this.enterFryerPot();
|
||||
return;
|
||||
}
|
||||
// A poach day: the shiver, the vortex, the drop, the wobble.
|
||||
if (this.order.poach) {
|
||||
this.enterPoach();
|
||||
@ -941,6 +956,34 @@ export class Game {
|
||||
}
|
||||
|
||||
/** The charcoal grill day (M-H6): arrange the coals, cook on the gradient, serve. */
|
||||
private enterFryerPot(): void {
|
||||
const f = this.order.fryerpot!;
|
||||
this.fryerPot.reset(f.count, 'THE CARD: blanch in the shimmer \u00b7 REST till dry \u00b7 back in HOT \u00b7 stand back when it spits', `${f.count} chips \u2014 golden out, fluffy in, dry`);
|
||||
this.hideAllScenes();
|
||||
this.fryerPot.root.visible = true;
|
||||
this.app.setView(this.fryerPot);
|
||||
this.ticket.show();
|
||||
this.fryerPot.onDone = (result) => {
|
||||
this.fryerPot.root.visible = false;
|
||||
this.serveFryerPot(result);
|
||||
};
|
||||
}
|
||||
|
||||
private serveFryerPot(result: ReturnType<FryerPotView['result']>): void {
|
||||
this.timing = false;
|
||||
const seconds = this.orderSeconds;
|
||||
const slice = this.kitchen.currentSlice;
|
||||
const v = judgeFryerPot(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 enterAirFryer(): void {
|
||||
const f = this.order.airfryer!;
|
||||
this.airFryer.reset(f.count, f.card, `${f.count} wings — golden, breathing, all present`);
|
||||
|
||||
@ -616,6 +616,73 @@ 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 fryer — the double-fry doctrine, judged. The gold is the postcard, the
|
||||
// inside is the truth, the grease is the confession, the burns are on you.
|
||||
|
||||
export function judgeFryerPot(r: import('../sim/fryer').FryerPotResult, order: Order, seconds: number): Verdict {
|
||||
const criteria: Criterion[] = [
|
||||
{
|
||||
key: 'potgold',
|
||||
group: 'fryer',
|
||||
label: 'The Gold',
|
||||
score: r.gold < 0.6 ? clamp01(r.gold / 0.6) : r.gold <= 0.85 ? 1 : clamp01(1 - (r.gold - 0.85) / 0.15),
|
||||
weight: 1.2,
|
||||
detail: r.gold < 0.3 ? 'pale as an apology' : r.gold < 0.6 ? 'blond. chips should not be blond' : r.gold <= 0.85 ? 'proper gold' : 'past gold into mahogany',
|
||||
},
|
||||
{
|
||||
key: 'potinside',
|
||||
group: 'fryer',
|
||||
label: 'The Inside',
|
||||
score: clamp01(r.cooked / 0.85),
|
||||
weight: 1.2,
|
||||
detail: r.cooked >= 0.85 ? 'fluffy inside' : r.cooked >= 0.5 ? 'firm in the middle — the seal beat the cook' : 'raw in the middle. golden outside means nothing now',
|
||||
},
|
||||
{
|
||||
key: 'potgrease',
|
||||
group: 'fryer',
|
||||
label: 'The Grease',
|
||||
score: clamp01(1 - r.grease * 2.2),
|
||||
weight: 0.9,
|
||||
detail: r.grease > 0.35 ? 'a paper bag of regret — the oil was cold' : r.grease > 0.12 ? 'a little heavy' : 'dry. drained. correct',
|
||||
},
|
||||
{
|
||||
key: 'pottwice',
|
||||
group: 'fryer',
|
||||
label: 'The Twice',
|
||||
score: r.fries >= 2 ? 1 : 0.35,
|
||||
weight: 0.7,
|
||||
detail: r.fries >= 2 ? `${r.fries} fries — as the card says` : 'once. it shows',
|
||||
},
|
||||
{
|
||||
key: 'potspit',
|
||||
group: 'bench',
|
||||
label: 'The Spit',
|
||||
score: r.burns === 0 ? 1 : clamp01(1 - r.burns * 0.4),
|
||||
weight: 0.6,
|
||||
detail: r.burns > 0 ? `it caught you ${r.burns > 1 ? r.burns + ' times' : 'once'} — the hand was over the pot` : r.spits > 0 ? `it spat ${r.spits} times; you did not flinch` : 'the oil kept its opinions in the pot',
|
||||
},
|
||||
{
|
||||
key: 'time',
|
||||
label: 'Service',
|
||||
score: clamp01(1 - (seconds - 100) / 140),
|
||||
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);
|
||||
void order;
|
||||
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.
|
||||
|
||||
@ -122,6 +122,8 @@ export interface Order {
|
||||
/** THE AIR FRYER: arrangement is the skill. The card is the LAW — two
|
||||
* temperature phases with a shake between, and the judge checks the ledger. */
|
||||
airfryer?: { count: number; card: string; deg1: number; deg2: number };
|
||||
/** THE FRYER: the pot of oil. Twice, love — once for cooked, once for golden. */
|
||||
fryerpot?: { count: number };
|
||||
}
|
||||
|
||||
export const BROWNING_NAMES: [number, string][] = [
|
||||
@ -508,6 +510,21 @@ export const DAYS: Order[] = [
|
||||
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, deg1: 180, deg2: 190, card: 'THE CARD: 180\u00b0 until they colour \u00b7 drawer OUT, SHAKE \u00b7 190\u00b0 to golden \u00b7 all six present' },
|
||||
},
|
||||
{
|
||||
day: 24,
|
||||
brand: 'WONDERSLICE',
|
||||
bread: 'white',
|
||||
spread: 'butter',
|
||||
amount: 'normal',
|
||||
browning: 0.55,
|
||||
browningName: 'golden',
|
||||
noChar: true,
|
||||
tool: 'butter_knife',
|
||||
butterSoftness: 0.5,
|
||||
who: 'Ray',
|
||||
text: 'Chips, love. Twice — once for cooked, once for golden. Blanch them soft in the shimmer, rest them on the rack till they stop steaming, then send them back HOT. And when it spits, you stand BACK.',
|
||||
fryerpot: { count: 8 },
|
||||
},
|
||||
];
|
||||
|
||||
/** Days beyond the script — keep going, with everything cranked. */
|
||||
@ -527,8 +544,9 @@ export function proceduralOrder(day: number, rand: () => number): Order {
|
||||
if (roll < 0.75) return proceduralPan(day, rand);
|
||||
if (roll < 0.84) return proceduralSteak(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.915) return proceduralAirfryer(day, rand);
|
||||
if (roll < 0.93) return proceduralFryerPot(day, rand);
|
||||
if (roll < 0.945) return proceduralPoach(day, rand);
|
||||
if (roll < 0.97) return proceduralBenedict(day, rand);
|
||||
return proceduralFridge(day, rand);
|
||||
}
|
||||
@ -546,6 +564,16 @@ function proceduralAirfryer(day: number, rand: () => number): Order {
|
||||
return o;
|
||||
}
|
||||
|
||||
function proceduralFryerPot(day: number, rand: () => number): Order {
|
||||
const o = baseOrder(
|
||||
day,
|
||||
pickOf(rand, ['Ray', 'the lunch rush', 'a regular', 'the bloke from two doors down']),
|
||||
'Chips, done properly: blanch, rest, then the hot fry. Golden, fluffy, dry — and nothing on your hands but salt.',
|
||||
);
|
||||
o.fryerpot = { count: 8 };
|
||||
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. */
|
||||
|
||||
290
src/scenes/fryerpot.ts
Normal file
290
src/scenes/fryerpot.ts
Normal file
@ -0,0 +1,290 @@
|
||||
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';
|
||||
|
||||
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);
|
||||
this.oilSurf = new THREE.Mesh(
|
||||
new THREE.CylinderGeometry(POT_R * 0.92, POT_R * 0.92, 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.12;
|
||||
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);
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
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!);
|
||||
}
|
||||
}
|
||||
@ -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' : order.airfryer ? 'fryer' : null;
|
||||
const heroKey = order.benedict ? 'benedict' : order.poach ? 'poach' : order.eggs ? 'eggs' : order.steak ? 'steak' : order.grill ? 'grill' : order.pan ? 'pan' : order.airfryer ? 'fryer' : order.fryerpot ? 'chips' : 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.airfryer
|
||||
this.orderEl.textContent = order.fryerpot
|
||||
? `Day ${order.day} · ${order.who} asked for the chips`
|
||||
: order.airfryer
|
||||
? `Day ${order.day} · ${order.who} asked for the wings`
|
||||
: order.grill
|
||||
? `Day ${order.day} · ${order.who} asked for the barbie`
|
||||
|
||||
173
src/sim/fryer.ts
Normal file
173
src/sim/fryer.ts
Normal file
@ -0,0 +1,173 @@
|
||||
import { Rng } from '../core/rng';
|
||||
|
||||
/**
|
||||
* THE FRYER — oil is the fear mechanic.
|
||||
*
|
||||
* One pot, one basket, one truth: chips need TWO fries. Cold oil soaks
|
||||
* (grease); hot oil seals — gold rises but the sealed surface stops the
|
||||
* inside cooking. So: blanch low until the inside is done, REST on the rack
|
||||
* (steam escapes, the surface dries — dry surface is what golds), then send
|
||||
* them back hot for the colour. "Twice, love. once for cooked, once for
|
||||
* golden."
|
||||
*
|
||||
* The basket dropping in KNOCKS the oil temperature down (thermal mass is
|
||||
* real), and raw wet chips in screaming oil SPIT. A spit only burns you if
|
||||
* your hand is over the pot when it goes — the knuckle lesson's floor:
|
||||
* careful play never burns. Stand back.
|
||||
*/
|
||||
|
||||
/** Oil scale 0..10. The bands, in words the pot says out loud. */
|
||||
export const BLANCH_LO = 6.5; // below this the oil soaks instead of cooking
|
||||
export const GOLD_AT = 7.8; // surface colour only happens above this
|
||||
export const SPIT_AT = 8.4; // raw+wet chips in oil this hot will spit
|
||||
export const SMOKE_AT = 9.4; // the oil itself starts to go
|
||||
|
||||
const OIL_TAU = 5.5; // big thermal mass — the knob is a suggestion, slowly
|
||||
const BASKET_KNOCK = 1.0; // dropping a cold basket in knocks the oil down
|
||||
const COOK_RATE = 0.05; // inside doneness per second in working oil
|
||||
const GOLD_RATE = 0.12; // surface colour per second above GOLD_AT (×dryness)
|
||||
const GREASE_RATE = 0.06; // soak per second in cold oil
|
||||
const DRY_REST_TAU = 6; // seconds on the rack to dry a blanched chip
|
||||
|
||||
export interface Chip {
|
||||
/** Inside doneness 0 raw .. 1 fluffy. */
|
||||
cooked: number;
|
||||
/** Surface colour 0 pale .. 1 carbon. 0.6–0.85 is the postcard. */
|
||||
gold: number;
|
||||
/** Oil soaked into it — cold-oil sin, drains a little on the rack. */
|
||||
grease: number;
|
||||
}
|
||||
|
||||
export interface FryerPotSession {
|
||||
chips: Chip[];
|
||||
knob: number;
|
||||
/** The oil, chasing the knob through its own enormous thermal mass. */
|
||||
oil: number;
|
||||
/** True while the basket hangs in the oil. */
|
||||
basketIn: boolean;
|
||||
/** Surface dryness 0..1 — rest builds it, the oil spends it. */
|
||||
dry: number;
|
||||
/** Rest seconds accumulated since the last fry (for the words). */
|
||||
rested: number;
|
||||
/** How many times the basket has been down. The card wants two. */
|
||||
fries: number;
|
||||
/** Spits thrown, and how many found a hand hovering over the pot. */
|
||||
spits: number;
|
||||
burns: number;
|
||||
seconds: number;
|
||||
rng: Rng;
|
||||
}
|
||||
|
||||
export function newFryerPotSession(count = 8, seed = 20260722): FryerPotSession {
|
||||
const rng = new Rng(seed);
|
||||
const chips: Chip[] = [];
|
||||
for (let i = 0; i < count; i++) chips.push({ cooked: 0, gold: 0, grease: 0 });
|
||||
return { chips, knob: 0, oil: 0, basketIn: false, dry: 0.3, rested: 0, fries: 0, spits: 0, burns: 0, seconds: 0, rng };
|
||||
}
|
||||
|
||||
export function setOilKnob(s: FryerPotSession, k: number): void {
|
||||
s.knob = Math.max(0, Math.min(9, Math.round(k)));
|
||||
}
|
||||
|
||||
/** Drop or lift the basket. Dropping knocks the oil and counts a fry. */
|
||||
export function basket(s: FryerPotSession, down: boolean): void {
|
||||
if (down === s.basketIn) return;
|
||||
s.basketIn = down;
|
||||
if (down) {
|
||||
s.oil = Math.max(0, s.oil - BASKET_KNOCK);
|
||||
s.fries++;
|
||||
s.rested = 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function meanCooked(s: FryerPotSession): number {
|
||||
return s.chips.reduce((a, c) => a + c.cooked, 0) / s.chips.length;
|
||||
}
|
||||
export function meanGold(s: FryerPotSession): number {
|
||||
return s.chips.reduce((a, c) => a + c.gold, 0) / s.chips.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* One step. `handOverPot` is the fear: while raw chips spit in screaming
|
||||
* oil, a hand parked over the pot collects what comes up.
|
||||
*/
|
||||
export function fryerPotStep(s: FryerPotSession, dt: number, handOverPot = false): void {
|
||||
s.seconds += dt;
|
||||
s.oil += (s.knob - s.oil) * (1 - Math.exp(-dt / OIL_TAU));
|
||||
const cookedNow = meanCooked(s);
|
||||
if (s.basketIn) {
|
||||
for (const c of s.chips) {
|
||||
// The seal: gold closes the surface; the inside stops listening.
|
||||
// The seal is REAL: past ~0.8 gold the crust is shut and the inside
|
||||
// stops listening entirely — the whole reason the blanch comes first.
|
||||
if (s.oil >= BLANCH_LO) c.cooked = Math.min(1, c.cooked + COOK_RATE * (s.oil / 10) * Math.max(0, 1 - c.gold * 1.25) * dt);
|
||||
// The soak has a little mercy: only properly cold oil drinks in. The
|
||||
// basket-knock dip below the band costs time, not grease.
|
||||
else if (s.oil < BLANCH_LO - 0.5) c.grease = Math.min(1, c.grease + GREASE_RATE * dt);
|
||||
if (s.oil >= GOLD_AT) c.gold = Math.min(1, c.gold + GOLD_RATE * ((s.oil - GOLD_AT) / (10 - GOLD_AT)) * (0.25 + 0.75 * s.dry) * dt);
|
||||
if (s.oil >= SMOKE_AT) c.gold = Math.min(1, c.gold + 0.06 * dt); // the oil itself is angry
|
||||
}
|
||||
// The oil spends the dryness it was given.
|
||||
s.dry = Math.max(0.3, s.dry - 0.12 * dt);
|
||||
// THE SPIT: raw, wet chips in oil past SPIT_AT throw oil back.
|
||||
if (cookedNow < 0.55 && s.oil >= SPIT_AT && s.rng.next() < 0.8 * dt) {
|
||||
s.spits++;
|
||||
if (handOverPot) s.burns++;
|
||||
}
|
||||
} else {
|
||||
// The rack: steam escapes a blanched chip, grease drains a little.
|
||||
s.rested += dt;
|
||||
if (cookedNow > 0.3) s.dry = Math.min(1, s.dry + dt / DRY_REST_TAU);
|
||||
for (const c of s.chips) c.grease = Math.max(0, c.grease - 0.012 * dt);
|
||||
}
|
||||
}
|
||||
|
||||
export interface FryerPotResult {
|
||||
cooked: number;
|
||||
gold: number;
|
||||
grease: number;
|
||||
fries: number;
|
||||
spits: number;
|
||||
burns: number;
|
||||
seconds: number;
|
||||
}
|
||||
|
||||
export function fryerPotResult(s: FryerPotSession): FryerPotResult {
|
||||
return {
|
||||
cooked: meanCooked(s),
|
||||
gold: meanGold(s),
|
||||
grease: s.chips.reduce((a, c) => a + c.grease, 0) / s.chips.length,
|
||||
fries: s.fries,
|
||||
spits: s.spits,
|
||||
burns: s.burns,
|
||||
seconds: s.seconds,
|
||||
};
|
||||
}
|
||||
|
||||
/** The pot, out loud. */
|
||||
export function oilWord(s: FryerPotSession): string {
|
||||
const t = s.oil;
|
||||
if (t < 2) return 'cold oil. patience';
|
||||
if (t < BLANCH_LO) return 'warming — chips in now would SOAK';
|
||||
if (t < GOLD_AT) return 'shimmering — the blanch band';
|
||||
if (t < SPIT_AT) return 'ripples chasing the edge — frying heat';
|
||||
if (t < SMOKE_AT) return 'screaming hot. it will SPIT at anything wet';
|
||||
return 'the oil is starting to smoke. that is the oil talking';
|
||||
}
|
||||
|
||||
export function chipWord(s: FryerPotSession): string {
|
||||
const c = meanCooked(s);
|
||||
const g = meanGold(s);
|
||||
if (s.basketIn) {
|
||||
if (c < 0.4) return 'pale and stiffening';
|
||||
if (c < 0.85) return 'softening inside';
|
||||
if (g < 0.3) return 'cooked through, still pale — they want the rack, then the heat';
|
||||
if (g < 0.6) return 'taking colour';
|
||||
if (g <= 0.85) return 'GOLDEN — lift it';
|
||||
return 'past golden — OUT, now';
|
||||
}
|
||||
if (c < 0.05) return 'raw, cut, waiting';
|
||||
if (c > 0.3 && g < 0.3) return s.dry > 0.8 ? 'rested and dry — ready for the hot fry' : `steaming off on the rack (${Math.round(s.rested)}s)`;
|
||||
if (g > 0.5) return 'draining on the paper';
|
||||
return 'resting';
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user