From 2a01a6e3a0310bbc3b2a523a68f7b6dacedd50aa Mon Sep 17 00:00:00 2001 From: type-two Date: Mon, 20 Jul 2026 17:41:49 +1000 Subject: [PATCH] THE EYE: he watches you cook now MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A small portrait in the corner of every service — not a meter, a FACE, which is the oldest diegetic read there is. He drifts neutral, and the kitchen pokes him at the moments that matter: a flip earns an intrigued lean-in, burnt butter a flat disappointment, the smoke alarm and the rotten egg get the full horrified stare (held four seconds for the egg — he heard that), a clean separation impresses him, a flare-up on the coals gets the little fire he pretends not to enjoy, and an egg left in rolling boil is watched with open disappointment until you fix it. He hides for the title (he doesn't watch you read the menu) and hands over to the full portrait on the scorecard. One widget (src/ui/eye.ts), fed one-liners from four scenes; moods decay back to neutral on their own, same starvation doctrine as the foley beds. Verified live: neutral through service, intrigued on flip, hidden at title and scorecard, portrait swap confirmed in the real click-through. Co-Authored-By: Claude Fable 5 --- src/game/game.ts | 3 ++ src/scenes/eggbench.ts | 4 +++ src/scenes/grill.ts | 4 +++ src/scenes/judge.ts | 2 ++ src/scenes/pan.ts | 8 +++++- src/scenes/poach.ts | 4 +++ src/ui/eye.ts | 62 ++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 src/ui/eye.ts 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();