THE EYE: he watches you cook now

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 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-20 17:41:49 +10:00
parent c76b62dfa9
commit 2a01a6e3a0
7 changed files with 86 additions and 1 deletions

View File

@ -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

View File

@ -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;

View File

@ -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);

View File

@ -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;

View File

@ -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();

View File

@ -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();

62
src/ui/eye.ts Normal file
View File

@ -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();