diff --git a/src/game/game.ts b/src/game/game.ts index 3ecb7ea..2509745 100644 --- a/src/game/game.ts +++ b/src/game/game.ts @@ -30,6 +30,7 @@ import { judge, type Grade } from './judging'; import { DAYS, proceduralOrder, nameBrowning, prepAsk, type Order, type PrepStep } from './orders'; import { el, Panel } from '../ui/hud'; import { Title } from '../ui/title'; +import { eye } from '../ui/eye'; const SAVE_KEY = 'toastsim.save'; @@ -228,6 +229,7 @@ export class Game { // gesture the browser wants before any audio is allowed to make a sound. this.kitchen.root.visible = false; this.ticket.hide(); + eye.hide(); // he doesn't watch you read the menu new Title(this.save, (day) => { audio.resume(); if (day === 'daily') { @@ -614,6 +616,7 @@ export class Game { } beginDay(): void { + eye.show(); // he watches you cook const day = this.save.day; // Procedural days are seeded BY the day, not by a running stream: a reload // re-deals the identical order (no reroll-scumming), and the harness can diff --git a/src/scenes/eggbench.ts b/src/scenes/eggbench.ts index 5eac6f7..d836d65 100644 --- a/src/scenes/eggbench.ts +++ b/src/scenes/eggbench.ts @@ -1,6 +1,7 @@ import * as THREE from 'three'; import type { App, View } from '../core/app'; import { audio } from '../core/audio'; +import { eye } from '../ui/eye'; import { type EggSession, newEggSession, @@ -230,10 +231,12 @@ export class EggBenchView implements View { this.wordEl.style.color = '#e2603a'; (this.bowlMix.material as THREE.MeshStandardMaterial).color.setHex(0xf0d060); (this.bowlMix.material as THREE.MeshStandardMaterial).opacity = 0.9; + eye.mood('disappointed', 3); this.finishSeparation(false); } else if (r.done) { this.wordEl.textContent = `separated — ${eggResult(s).separated}/${s.need}`; this.wordEl.style.color = '#8fce8f'; + eye.mood('impressed', 2); this.finishSeparation(true); } }; @@ -311,6 +314,7 @@ export class EggBenchView implements View { this.puff.push(p); } audio.fart(s.eggs[i].index + s.rottenIn * 7); + eye.mood('horrified', 4); this.wordEl.textContent = 'ROTTEN. in the bowl. D dumps it — and he heard that.'; this.wordEl.style.color = '#e2603a'; return; diff --git a/src/scenes/grill.ts b/src/scenes/grill.ts index 75f9099..536370d 100644 --- a/src/scenes/grill.ts +++ b/src/scenes/grill.ts @@ -1,6 +1,7 @@ import * as THREE from 'three'; import type { App, View } from '../core/app'; import { audio } from '../core/audio'; +import { eye } from '../ui/eye'; import { type GrillSession, newGrillSession, @@ -202,7 +203,10 @@ export class GrillView implements View { s.env.sheltered = !s.env.sheltered; } + const flaresBefore = s.flares; grillStep(s, dt); + if (s.flares > flaresBefore) eye.mood('intrigued', 1.6); // he likes a little fire + // Open-air sizzle: the sum of heat actually UNDER the meat, so a piece // parked on the cool side goes quiet and one over fresh coals roars. const under = s.foods.reduce((a, f) => a + s.zone.sample(f.u, f.v), 0) / Math.max(1, s.foods.length); diff --git a/src/scenes/judge.ts b/src/scenes/judge.ts index 8a6b015..eee2903 100644 --- a/src/scenes/judge.ts +++ b/src/scenes/judge.ts @@ -7,6 +7,7 @@ import { judgeFace, verdictLines } from '../game/lines'; import { TOOLS, type ToolId } from '../sim/cutlery'; import { clear, el, Panel } from '../ui/hud'; import { assetUrl } from './assets'; +import { eye } from '../ui/eye'; /** * The scorecard. This is the screen the game is actually about: every number @@ -175,6 +176,7 @@ export class JudgeView implements View { } show(slice: Slice, verdict: Verdict, order: Order, tool: ToolId, rand: () => number): void { + eye.hide(); // the full portrait takes over from the corner glance this.slice = slice; this.spin = 0; this.heatmap = 0; diff --git a/src/scenes/pan.ts b/src/scenes/pan.ts index 0fbbed4..d00bb7b 100644 --- a/src/scenes/pan.ts +++ b/src/scenes/pan.ts @@ -1,6 +1,7 @@ import * as THREE from 'three'; import type { App, View } from '../core/app'; import { audio } from '../core/audio'; +import { eye } from '../ui/eye'; import { type PanSession, newPanSession, @@ -180,6 +181,7 @@ export class PanView implements View { flip(s); this.flipAnim = 1; audio.clunk(); + eye.mood('intrigued', 1.4); } // Drag over the food = baste. @@ -196,7 +198,11 @@ export class PanView implements View { const heat01 = Math.min(1, s.src.output / 9); const act = s.food ? 1 : s.hasButter && s.src.output > 1.5 ? 0.45 : 0; audio.bed('sizzle', act * heat01 * 0.13, 5600 - s.butter * 3200, 0.8, 0.85 + heat01 * 0.3); - if (s.zapped) audio.clatter(1, 2); + if (s.zapped) { + audio.clatter(1, 2); + eye.mood('horrified', 3); + } + if (butterState(s) === 'burnt') eye.mood('disappointed', 0.8); this.syncVisuals(dt); this.updateHud(); diff --git a/src/scenes/poach.ts b/src/scenes/poach.ts index c46f35e..c69f938 100644 --- a/src/scenes/poach.ts +++ b/src/scenes/poach.ts @@ -1,6 +1,7 @@ import * as THREE from 'three'; import type { App, View } from '../core/app'; import { audio } from '../core/audio'; +import { eye } from '../ui/eye'; import { type PoachSession, newPoachSession, @@ -227,6 +228,7 @@ export class PoachView implements View { this.eggWhite.visible = true; this.eggYolk.visible = true; audio.clatter(0.4, 0.9); + eye.mood(s.rags > 0.25 ? 'disappointed' : 'intrigued', 2); // Rags appear immediately if the drop was bad. this.spawnRags(s.rags); } @@ -245,6 +247,8 @@ export class PoachView implements View { audio.bed('water', 0.02 + t01 * 0.05, 260 + t01 * 480, 0.6, 0.45 + t01 * 0.2); const bps = s.src.output > BOIL_AT ? 13 : s.src.output >= SHIVER_LO ? 3 : s.src.output > 4 ? 1.2 : 0.15; if (Math.random() < bps * dt) audio.blip(t01); + // A rolling boil with an egg in it: he can see the white tearing from here. + if (s.dropped && !s.lifted && s.src.output > BOIL_AT) eye.mood('disappointed', 0.8); this.syncVisuals(dt); this.updateHud(); diff --git a/src/ui/eye.ts b/src/ui/eye.ts new file mode 100644 index 0000000..70f7f74 --- /dev/null +++ b/src/ui/eye.ts @@ -0,0 +1,62 @@ +import { assetUrl } from '../scenes/assets'; + +export type Mood = 'neutral' | 'intrigued' | 'impressed' | 'disappointed' | 'horrified'; + +/** + * The live judge: a small portrait in the corner that watches you cook. He is + * not a meter — he is a face, and faces are the oldest diegetic read there is. + * Scenes poke him at high-signal moments; he drifts back to neutral on his own. + */ +class Eye { + private el: HTMLImageElement | null = null; + private timer: number | null = null; + private current: Mood = 'neutral'; + + private ensure(): HTMLImageElement { + if (!this.el) { + const img = document.createElement('img'); + img.id = 'judge-eye'; + img.alt = ''; + img.style.cssText = [ + 'position:fixed', 'left:14px', 'bottom:14px', 'width:88px', 'height:88px', + 'object-fit:cover', 'border-radius:50%', 'border:3px solid #3a2c20', + 'box-shadow:0 4px 14px rgba(0,0,0,.5)', 'z-index:30', 'display:none', + 'transition:transform .15s', 'pointer-events:none', + ].join(';'); + document.body.appendChild(img); + this.el = img; + this.set('neutral'); + } + return this.el; + } + + private set(m: Mood): void { + this.current = m; + this.ensure().src = assetUrl(`/assets/img/judge_${m}.png`); + } + + show(): void { + this.ensure().style.display = 'block'; + this.set('neutral'); + } + + hide(): void { + if (this.el) this.el.style.display = 'none'; + if (this.timer !== null) window.clearTimeout(this.timer); + } + + /** React. He holds the face for `hold` seconds, then lets it go. */ + mood(m: Mood, hold = 2.4): void { + const el = this.ensure(); + if (el.style.display === 'none') return; + if (m !== this.current) { + this.set(m); + el.style.transform = 'scale(1.12)'; + window.setTimeout(() => { el.style.transform = 'scale(1)'; }, 150); + } + if (this.timer !== null) window.clearTimeout(this.timer); + if (m !== 'neutral') this.timer = window.setTimeout(() => this.set('neutral'), hold * 1000); + } +} + +export const eye = new Eye();