It was born visible — a day starting at a station (prep/juice/grate) never passes through kitchen.enter()/exit(), so the BROWNING panel sat over every station HUD. Hidden at construction like every other scene; verified hidden at bench, shown after handoff, hidden at juice. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
612 lines
21 KiB
TypeScript
612 lines
21 KiB
TypeScript
import * as THREE from 'three';
|
|
import type { App, View } from '../core/app';
|
|
import { Rng } from '../core/rng';
|
|
import { Slice } from '../sim/slice';
|
|
import { BREADS, brandOf, type BreadId } from '../sim/bread';
|
|
import { buildHeatMap, readToast, smellCue, toastStep } from '../sim/toasting';
|
|
import {
|
|
SPREADS,
|
|
SCRAPE_ANGLE,
|
|
amountClassOf,
|
|
effectiveYield,
|
|
pressureFromAngle,
|
|
type AmountClass,
|
|
type SpreadDef,
|
|
type SpreadId,
|
|
} from '../sim/spreads';
|
|
import { COVER_FLOOR } from '../game/judging';
|
|
import { dip, newKnife, stroke, type Knife, type StrokeFx } from '../sim/spreading';
|
|
import type { SliceCut } from '../sim/slicing';
|
|
import { TOOLS, type ToolId } from '../sim/cutlery';
|
|
import { makeBench, makePlate, makeToaster, type Toaster } from './props';
|
|
import { KnifeRig, makeSpreadPot } from './spreadrig';
|
|
import { el, Panel } from '../ui/hud';
|
|
import { audio } from '../core/audio';
|
|
import type { Order } from '../game/orders';
|
|
|
|
const TOASTER_X = -1.5;
|
|
const PLATE_X = 1.45;
|
|
const PLATE_Z = 0.55;
|
|
const PLATE_Y = 0.05;
|
|
const POT_X = 3.15;
|
|
const POT_Z = 0.45;
|
|
|
|
export type Phase = 'ready' | 'toasting' | 'flying' | 'landed' | 'spreading';
|
|
|
|
interface CamShot {
|
|
pos: THREE.Vector3;
|
|
look: THREE.Vector3;
|
|
}
|
|
|
|
const SHOTS: Record<string, CamShot> = {
|
|
toast: { pos: new THREE.Vector3(-1.1, 3.0, 4.9), look: new THREE.Vector3(-1.3, 0.85, -0.2) },
|
|
spread: { pos: new THREE.Vector3(2.05, 2.4, 3.0), look: new THREE.Vector3(2.05, 0.1, 0.35) },
|
|
};
|
|
|
|
/** The bench: toaster, plate, pot, knife. Runs the toasting and spreading phases. */
|
|
export class KitchenView implements View {
|
|
readonly root = new THREE.Group();
|
|
private toaster: Toaster;
|
|
private slice!: Slice;
|
|
private heat!: Float32Array;
|
|
private rng: Rng;
|
|
|
|
phase: Phase = 'ready';
|
|
power = 6;
|
|
/** 0 = straight from the fridge, 1 = left out on the bench all morning. */
|
|
butterSoftness = 0.15;
|
|
toolId: ToolId = 'butter_knife';
|
|
spreadId: SpreadId = 'butter';
|
|
|
|
private leverT = 0;
|
|
private vel = new THREE.Vector3();
|
|
private spin = 0;
|
|
private toastSeconds = 0;
|
|
|
|
knife: Knife = newKnife();
|
|
/** Brand quality for the current order's bread. */
|
|
private quality = 0.7;
|
|
/** The cut, when this order's slice came off a loaf. */
|
|
private cut: SliceCut | null = null;
|
|
/** How badly the jar has separated right now. 0 = mixed, 1 = oil on top. */
|
|
strat = 0;
|
|
private dipsSinceStir = 0;
|
|
private stirPrev: number | null = null;
|
|
private wasDipping = false;
|
|
private knifeRig: KnifeRig;
|
|
private pot: THREE.Group | null = null;
|
|
private potHit: THREE.Mesh | null = null;
|
|
private ray = new THREE.Raycaster();
|
|
private hitPoint = new THREE.Vector3();
|
|
private uv = new THREE.Vector2();
|
|
private plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
|
|
lastFx: StrokeFx | null = null;
|
|
|
|
private camPos = new THREE.Vector3();
|
|
private camLook = new THREE.Vector3();
|
|
private shot: CamShot = SHOTS.toast;
|
|
|
|
private panel: Panel;
|
|
private cueEl!: HTMLElement;
|
|
private hintEl!: HTMLElement;
|
|
private dialRow!: HTMLElement;
|
|
private gaugeFill!: HTMLElement;
|
|
private gaugeYield!: HTMLElement;
|
|
private gaugeScrape!: HTMLElement;
|
|
private modeEl!: HTMLElement;
|
|
private loadFill!: HTMLElement;
|
|
private jarRow!: HTMLElement;
|
|
private jarFill!: HTMLElement;
|
|
private amountEl!: HTMLElement;
|
|
/** What the current order asked for — drives the live readout. */
|
|
private askedAmount: AmountClass | null = null;
|
|
private amountTick = 0;
|
|
private breadSel!: HTMLSelectElement;
|
|
private spreadSel!: HTMLSelectElement;
|
|
private toolSel!: HTMLSelectElement;
|
|
private softInput!: HTMLInputElement;
|
|
private softVal!: HTMLElement;
|
|
|
|
/** Fired when the player hands the plate over. */
|
|
onServed: (() => void) | null = null;
|
|
/** Fired once the slice has landed on the plate — the drawer opens here. */
|
|
onPopped: (() => void) | null = null;
|
|
|
|
constructor(
|
|
private app: App,
|
|
seed = 1,
|
|
) {
|
|
this.rng = new Rng(seed);
|
|
this.root.add(makeBench());
|
|
this.toaster = makeToaster();
|
|
this.toaster.root.position.set(TOASTER_X, 0, -0.35);
|
|
this.root.add(this.toaster.root);
|
|
const plate = makePlate();
|
|
plate.position.set(PLATE_X, 0, PLATE_Z);
|
|
this.root.add(plate);
|
|
|
|
this.knifeRig = new KnifeRig(TOOLS[this.toolId]);
|
|
this.knifeRig.root.visible = false;
|
|
this.root.add(this.knifeRig.root);
|
|
|
|
this.camPos.copy(SHOTS.toast.pos);
|
|
this.camLook.copy(SHOTS.toast.look);
|
|
|
|
this.panel = new Panel('toast-panel');
|
|
this.buildUi();
|
|
// Hidden until enter() — a day that starts at a station (prep/juice/grate)
|
|
// never passes through the kitchen, and a born-visible panel leaks over it.
|
|
this.panel.hide();
|
|
this.loadBread('white');
|
|
}
|
|
|
|
private buildUi(): void {
|
|
const p = this.panel.root;
|
|
this.dialRow = el('div', 'row', p);
|
|
el('label', 'lbl', this.dialRow, 'BROWNING');
|
|
const dial = el('input', 'dial', this.dialRow);
|
|
dial.type = 'range';
|
|
dial.min = '1';
|
|
dial.max = '10';
|
|
dial.step = '1';
|
|
dial.value = String(this.power);
|
|
const val = el('span', 'val', this.dialRow, String(this.power));
|
|
dial.addEventListener('input', () => {
|
|
this.power = +dial.value;
|
|
val.textContent = dial.value;
|
|
this.toaster.dial.rotation.x = -((this.power - 1) / 9) * 2.4;
|
|
});
|
|
|
|
const row2 = el('div', 'row', p);
|
|
el('label', 'lbl', row2, 'BREAD');
|
|
const breadSel = el('select', 'sel', row2);
|
|
this.breadSel = breadSel;
|
|
for (const id of Object.keys(BREADS)) {
|
|
const o = el('option', undefined, breadSel, BREADS[id as BreadId].name);
|
|
o.value = id;
|
|
}
|
|
breadSel.addEventListener('change', () => this.loadBread(breadSel.value as BreadId));
|
|
|
|
const row3 = el('div', 'row', p);
|
|
el('label', 'lbl', row3, 'SPREAD');
|
|
const spreadSel = el('select', 'sel', row3);
|
|
this.spreadSel = spreadSel;
|
|
for (const id of Object.keys(SPREADS)) {
|
|
const o = el('option', undefined, spreadSel, SPREADS[id as SpreadId].name);
|
|
o.value = id;
|
|
}
|
|
spreadSel.addEventListener('change', () => this.setSpread(spreadSel.value as SpreadId));
|
|
|
|
const row4 = el('div', 'row', p);
|
|
el('label', 'lbl', row4, 'TOOL');
|
|
const toolSel = el('select', 'sel', row4);
|
|
this.toolSel = toolSel;
|
|
for (const id of Object.keys(TOOLS)) {
|
|
const o = el('option', undefined, toolSel, TOOLS[id as ToolId].name);
|
|
o.value = id;
|
|
}
|
|
toolSel.addEventListener('change', () => {
|
|
this.toolId = toolSel.value as ToolId;
|
|
this.knifeRig.setTool(TOOLS[this.toolId]);
|
|
});
|
|
|
|
const row5 = el('div', 'row', p);
|
|
el('label', 'lbl', row5, 'BUTTER');
|
|
const soft = el('input', 'dial', row5);
|
|
this.softInput = soft;
|
|
soft.type = 'range';
|
|
soft.min = '0';
|
|
soft.max = '100';
|
|
soft.value = String(this.butterSoftness * 100);
|
|
const softVal = el('span', 'val', row5, 'hard');
|
|
this.softVal = softVal;
|
|
soft.addEventListener('input', () => {
|
|
this.butterSoftness = +soft.value / 100;
|
|
softVal.textContent =
|
|
this.butterSoftness > 0.66 ? 'soft' : this.butterSoftness > 0.33 ? 'ok' : 'hard';
|
|
});
|
|
|
|
this.jarRow = el('div', 'row', p);
|
|
el('label', 'lbl', this.jarRow, 'JAR');
|
|
const jarBar = el('div', 'load', this.jarRow);
|
|
jarBar.style.flex = '1';
|
|
this.jarFill = el('div', 'load-fill', jarBar);
|
|
|
|
// The pressure gauge — the mechanic made legible. The fill is the pressure
|
|
// your wrist angle is producing; the gold mark is what this spread needs in
|
|
// order to flow; the red mark is where the knife stops spreading and starts
|
|
// scraping. When gold sits past red, no angle can win, and the answer isn't
|
|
// technique — it's warmer toast.
|
|
const gauge = el('div', 'gauge', p);
|
|
this.gaugeFill = el('div', 'gauge-fill', gauge);
|
|
this.gaugeYield = el('div', 'gauge-mark yield', gauge);
|
|
this.gaugeScrape = el('div', 'gauge-mark scrape', gauge);
|
|
this.modeEl = el('div', 'mode', p, '');
|
|
|
|
const loadBar = el('div', 'load', p);
|
|
this.loadFill = el('div', 'load-fill', loadBar);
|
|
this.amountEl = el('div', 'amount', p, '');
|
|
|
|
this.cueEl = el('div', 'cue', p, 'smells like bread');
|
|
this.hintEl = el('div', 'hint', p, 'SPACE — push the lever down');
|
|
this.setSpread(this.spreadId);
|
|
}
|
|
|
|
setSpread(id: SpreadId): void {
|
|
this.spreadId = id;
|
|
const def = SPREADS[id];
|
|
this.strat = def.separates ?? 0;
|
|
this.dipsSinceStir = 0;
|
|
this.stirPrev = null;
|
|
this.wasDipping = false;
|
|
this.slice?.setSpread(def);
|
|
this.knifeRig.setSpread(def);
|
|
if (this.pot) this.root.remove(this.pot);
|
|
const { root, hit } = makeSpreadPot(def);
|
|
root.position.set(POT_X, 0, POT_Z);
|
|
this.pot = root;
|
|
this.potHit = hit;
|
|
this.pot.visible = this.phase === 'spreading';
|
|
this.root.add(root);
|
|
}
|
|
|
|
loadBread(id: BreadId): void {
|
|
if (this.slice) {
|
|
this.root.remove(this.slice.mesh);
|
|
this.slice.dispose();
|
|
}
|
|
this.slice = new Slice(BREADS[id], new Rng(this.rng.int(1, 1e6)), {
|
|
quality: this.quality,
|
|
cut: this.cut,
|
|
});
|
|
this.slice.setSpread(SPREADS[this.spreadId]);
|
|
this.heat = buildHeatMap(this.slice, new Rng(this.rng.int(1, 1e6)));
|
|
this.root.add(this.slice.mesh);
|
|
this.phase = 'ready';
|
|
this.leverT = 0;
|
|
this.toastSeconds = 0;
|
|
this.slice.warmth = 0;
|
|
this.knife = newKnife();
|
|
this.knifeRig.root.visible = false;
|
|
if (this.pot) this.pot.visible = false;
|
|
this.shot = SHOTS.toast;
|
|
this.dialRow.style.opacity = '1';
|
|
this.placeInSlot(0);
|
|
this.hintEl.textContent = 'SPACE — push the lever down';
|
|
}
|
|
|
|
get currentSlice(): Slice {
|
|
return this.slice;
|
|
}
|
|
|
|
/** Set the bench up for one order. The tool comes from the drawer (M4). */
|
|
applyOrder(o: Order): void {
|
|
// These four are the ORDER's facts, and the tool is whatever the drawer
|
|
// hands back. Leaving them editable was an open cheat: re-pick the butter
|
|
// knife after fetching a soup spoon, soften the butter, swap the bread.
|
|
this.breadSel.disabled = true;
|
|
this.spreadSel.disabled = true;
|
|
this.toolSel.disabled = true;
|
|
this.softInput.disabled = true;
|
|
this.askedAmount = o.amount;
|
|
this.quality = brandOf(o.bread, o.brand).quality;
|
|
this.cut = null;
|
|
this.butterSoftness = o.butterSoftness;
|
|
this.toolId = o.tool;
|
|
this.knifeRig.setTool(TOOLS[this.toolId]);
|
|
this.setSpread(o.spread);
|
|
this.loadBread(o.bread);
|
|
this.breadSel.value = o.bread;
|
|
this.spreadSel.value = o.spread;
|
|
this.toolSel.value = o.tool;
|
|
this.softInput.value = String(Math.round(o.butterSoftness * 100));
|
|
this.softVal.textContent =
|
|
o.butterSoftness > 0.66 ? 'soft' : o.butterSoftness > 0.33 ? 'ok' : 'hard';
|
|
}
|
|
|
|
serve(): void {
|
|
if (this.phase !== 'spreading') return;
|
|
this.onServed?.();
|
|
}
|
|
|
|
/**
|
|
* What the next scoop out of the jar is like. The first dip off a split jar
|
|
* takes the oil slick; the ones after it dig into what the oil left behind.
|
|
* Stirred (strat 0), every dip is 0.5 — exactly right.
|
|
*/
|
|
private jarOil(): number {
|
|
if (this.strat <= 0.001) return 0.5;
|
|
const swing = 0.5 * this.strat;
|
|
// dip 0: +swing (the slick) · dip 1: about even · dip 2+: increasingly dry
|
|
const t = 1 - this.dipsSinceStir * 0.55;
|
|
return 0.5 + swing * Math.max(-1, Math.min(1, t));
|
|
}
|
|
|
|
/** Whatever you managed to pull out of the drawer is what you're spreading with. */
|
|
/** The slice you just cut arrives at the toaster. */
|
|
applyCut(cut: SliceCut): void {
|
|
this.cut = cut;
|
|
this.loadBread(this.breadSel.value as BreadId);
|
|
}
|
|
|
|
setTool(id: ToolId): void {
|
|
this.toolId = id;
|
|
this.toolSel.value = id;
|
|
this.knifeRig.setTool(TOOLS[id]);
|
|
}
|
|
|
|
flash(msg: string): void {
|
|
this.hintEl.textContent = msg;
|
|
this.hintEl.style.color = '#e2603a';
|
|
window.setTimeout(() => {
|
|
this.hintEl.style.color = '';
|
|
this.hintEl.textContent = 'dip · drag · WHEEL tilts the knife · ENTER serves it';
|
|
}, 2600);
|
|
}
|
|
|
|
private placeInSlot(t: number): void {
|
|
const y = this.toaster.slotY + 0.16 - t * 0.74;
|
|
this.slice.mesh.position.set(TOASTER_X, y, -0.35);
|
|
this.slice.mesh.rotation.set(Math.PI / 2, 0, 0);
|
|
}
|
|
|
|
enter(): void {
|
|
this.panel.show();
|
|
}
|
|
exit(): void {
|
|
this.panel.hide();
|
|
}
|
|
|
|
private pushDown(): void {
|
|
if (this.phase !== 'ready') return;
|
|
audio.resume();
|
|
audio.clunk();
|
|
this.phase = 'toasting';
|
|
this.hintEl.textContent = 'SPACE — pop it';
|
|
}
|
|
|
|
private pop(): void {
|
|
if (this.phase !== 'toasting') return;
|
|
this.phase = 'flying';
|
|
this.slice.warmth = 1;
|
|
this.toaster.setGlow(0);
|
|
const p0 = this.slice.mesh.position.clone();
|
|
const y1 = PLATE_Y + 0.02;
|
|
const vy = 4.2;
|
|
const g = 9.8;
|
|
const disc = Math.max(0.01, vy * vy - 2 * g * (y1 - p0.y));
|
|
const T = (vy + Math.sqrt(disc)) / g;
|
|
this.vel.set((PLATE_X - p0.x) / T, vy, (PLATE_Z - p0.z) / T);
|
|
this.spin = Math.PI / 2 / T;
|
|
this.app.shake = 0.045;
|
|
this.hintEl.textContent = '';
|
|
}
|
|
|
|
beginSpreading(): void {
|
|
this.phase = 'spreading';
|
|
this.shot = SHOTS.spread;
|
|
this.knifeRig.root.visible = true;
|
|
if (this.pot) this.pot.visible = true;
|
|
this.dialRow.style.opacity = '0.35';
|
|
this.hintEl.textContent = 'dip · drag · WHEEL tilts the knife · ENTER serves it';
|
|
}
|
|
|
|
update(dt: number): void {
|
|
const inp = this.app.input;
|
|
if (inp.justPressed('Space')) {
|
|
if (this.phase === 'ready') this.pushDown();
|
|
else if (this.phase === 'toasting') this.pop();
|
|
}
|
|
if (inp.justPressed('Enter') && this.phase === 'spreading') this.serve();
|
|
|
|
const target = this.phase === 'ready' ? 0 : this.phase === 'toasting' ? 1 : 0;
|
|
this.leverT += (target - this.leverT) * Math.min(1, dt * 14);
|
|
this.toaster.lever.position.y = 1.62 * 0.72 - this.leverT * 0.62;
|
|
|
|
if (this.phase === 'toasting') {
|
|
this.placeInSlot(this.leverT);
|
|
this.toastSeconds += dt;
|
|
toastStep(this.slice, this.heat, this.power, dt);
|
|
this.toaster.setGlow(
|
|
0.35 + 0.65 * (this.power / 10) * (0.92 + Math.sin(this.toastSeconds * 9) * 0.08),
|
|
);
|
|
this.slice.setHeatGlow(0.55 + 0.45 * Math.sin(this.toastSeconds * 7) * 0.3);
|
|
} else if (this.phase === 'ready') {
|
|
this.placeInSlot(this.leverT);
|
|
} else if (this.phase === 'flying') {
|
|
this.vel.y -= 9.8 * dt;
|
|
this.slice.mesh.position.addScaledVector(this.vel, dt);
|
|
this.slice.mesh.rotation.x = Math.max(0, this.slice.mesh.rotation.x - this.spin * dt);
|
|
this.slice.mesh.rotation.z += dt * 1.1;
|
|
this.slice.setHeatGlow(0);
|
|
if (this.slice.mesh.position.y <= PLATE_Y + 0.02 && this.vel.y < 0) {
|
|
this.slice.mesh.position.set(PLATE_X, PLATE_Y + 0.02, PLATE_Z);
|
|
this.slice.mesh.rotation.set(0, 0, 0);
|
|
this.app.shake = 0.02;
|
|
this.phase = 'landed';
|
|
audio.clunk(true);
|
|
if (this.onPopped) this.onPopped();
|
|
else this.beginSpreading();
|
|
}
|
|
} else if (this.phase === 'spreading') {
|
|
this.updateSpreading(dt);
|
|
}
|
|
|
|
this.updateCamera(dt);
|
|
|
|
if (this.phase !== 'spreading') {
|
|
const t = readToast(this.slice);
|
|
this.cueEl.textContent = smellCue(t);
|
|
this.cueEl.style.color = t.smoke > 0.45 ? '#e2603a' : '';
|
|
}
|
|
this.slice.sync();
|
|
}
|
|
|
|
private updateSpreading(dt: number): void {
|
|
const inp = this.app.input;
|
|
const def = SPREADS[this.spreadId];
|
|
const tool = TOOLS[this.toolId];
|
|
|
|
if (inp.wheel !== 0) {
|
|
this.knife.angle = Math.max(0, Math.min(1, this.knife.angle + inp.wheel * 0.055));
|
|
}
|
|
|
|
this.ray.setFromCamera(inp.ndc, this.app.camera);
|
|
|
|
// Dipping wins over spreading: if the cursor is over the pot you're loading up.
|
|
let overPot = false;
|
|
if (this.potHit) {
|
|
const hits = this.ray.intersectObject(this.potHit, false);
|
|
if (hits.length) {
|
|
overPot = true;
|
|
this.knifeRig.update(hits[0].point, this.knife.angle * 0.3, this.knife.load, hits[0].point.y);
|
|
if (inp.down) {
|
|
// Swirling: angular travel of the knife around the jar's centre. Only
|
|
// sweep counts — holding still in the jar stirs nothing.
|
|
if (def.separates && this.strat > 0.001) {
|
|
const ang = Math.atan2(hits[0].point.z - POT_Z, hits[0].point.x - POT_X);
|
|
if (this.stirPrev !== null) {
|
|
let d = ang - this.stirPrev;
|
|
if (d > Math.PI) d -= Math.PI * 2;
|
|
if (d < -Math.PI) d += Math.PI * 2;
|
|
// ~2.5 full circles takes the jar from fully split to mixed.
|
|
const sweep = Math.abs(d);
|
|
if (sweep > 0.002) {
|
|
this.strat = Math.max(0, this.strat - sweep / (5 * Math.PI));
|
|
this.dipsSinceStir = 0;
|
|
if (sweep > 0.02) audio.scrape(0.25, sweep * 6);
|
|
}
|
|
}
|
|
this.stirPrev = ang;
|
|
}
|
|
// A dip is an event, not a state: the oil you get depends on how many
|
|
// scoops have already come off the top since the last stir.
|
|
if (!this.wasDipping) {
|
|
dip(this.knife, def, this.jarOil());
|
|
this.dipsSinceStir++;
|
|
this.wasDipping = true;
|
|
} else {
|
|
// Holding the blade in the jar keeps it topped up at the same blend.
|
|
dip(this.knife, def, this.knife.oil);
|
|
}
|
|
} else {
|
|
this.stirPrev = null;
|
|
this.wasDipping = false;
|
|
}
|
|
this.knife.hasPrev = false;
|
|
}
|
|
}
|
|
if (!overPot) {
|
|
this.stirPrev = null;
|
|
this.wasDipping = false;
|
|
}
|
|
|
|
if (!overPot) {
|
|
this.plane.constant = -this.slice.topY;
|
|
if (this.ray.ray.intersectPlane(this.plane, this.hitPoint)) {
|
|
this.knifeRig.update(this.hitPoint, this.knife.angle, this.knife.load, this.slice.topY);
|
|
this.slice.uvAt(this.hitPoint, this.uv);
|
|
const onBread = this.slice.mask.sample(this.uv.x, this.uv.y) > 0.5;
|
|
if (inp.down && onBread) {
|
|
this.lastFx = stroke(
|
|
this.slice,
|
|
def,
|
|
tool,
|
|
this.knife,
|
|
this.uv.x,
|
|
this.uv.y,
|
|
dt,
|
|
this.butterSoftness,
|
|
);
|
|
if (this.lastFx.tore > 0.004 || this.lastFx.gouged > 0.004) this.app.shake = 0.012;
|
|
if (this.lastFx.mode !== 'idle' && this.lastFx.speed > 0.15) {
|
|
audio.scrape(this.lastFx.pressure, this.lastFx.speed);
|
|
}
|
|
} else {
|
|
this.knife.hasPrev = false;
|
|
this.lastFx = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
this.slice.uniforms.uOil.value = def.separates ? this.slice.oilMean : 0.5;
|
|
this.updateGauge(def);
|
|
}
|
|
|
|
private updateGauge(def: SpreadDef): void {
|
|
const tool = TOOLS[this.toolId];
|
|
const pressure = Math.min(1.4, pressureFromAngle(this.knife.angle) / tool.contactScale);
|
|
const yieldP = effectiveYield(def, this.butterSoftness, this.slice.warmth);
|
|
const scrapeP = Math.min(1.4, pressureFromAngle(SCRAPE_ANGLE) / tool.contactScale);
|
|
const pct = (v: number) => `${Math.min(100, (v / 1.4) * 100)}%`;
|
|
this.gaugeFill.style.width = pct(pressure);
|
|
this.gaugeYield.style.left = pct(yieldP);
|
|
this.gaugeScrape.style.left = pct(scrapeP);
|
|
const scraping = this.knife.angle >= SCRAPE_ANGLE;
|
|
this.gaugeFill.style.background = scraping
|
|
? '#c0392b'
|
|
: pressure >= yieldP
|
|
? '#6cbf6c'
|
|
: '#8a7f70';
|
|
|
|
this.jarRow.style.display = def.separates ? '' : 'none';
|
|
if (def.separates) {
|
|
const mixed = 1 - this.strat;
|
|
this.jarFill.style.width = `${Math.round(mixed * 100)}%`;
|
|
this.jarFill.style.background = mixed > 0.75 ? '#6cbf6c' : mixed > 0.4 ? '#e8a53a' : '#c0392b';
|
|
}
|
|
|
|
// Live readout of the two things the judge will actually measure, from the
|
|
// same functions he uses — so "thin" at serve time is never a surprise.
|
|
if (++this.amountTick % 12 === 0) {
|
|
const mean = this.slice.spread.stats(this.slice.mask).mean;
|
|
const cls = amountClassOf(def, mean);
|
|
const cov = this.slice.spread.fraction(this.slice.mask, (v) => v >= COVER_FLOOR);
|
|
const askTxt = this.askedAmount ? ` — ${this.askedAmount} asked` : '';
|
|
this.amountEl.textContent =
|
|
cls === 'none' && cov < 0.02
|
|
? 'nothing on the toast yet'
|
|
: `on the toast: ${cls === 'none' ? 'barely anything' : cls}${askTxt} · ${Math.round(cov * 100)}% covered`;
|
|
this.amountEl.style.color =
|
|
this.askedAmount && cls === this.askedAmount && cov > 0.85 ? '#8fce8f' : '';
|
|
}
|
|
|
|
const fx = this.lastFx;
|
|
let msg = scraping ? 'scraping' : 'spreading';
|
|
if (fx) {
|
|
if (fx.mode === 'tear') msg = 'TEARING — the butter is too hard for this';
|
|
else if (fx.mode === 'gouge') msg = 'GOUGING — there is nothing left to scrape';
|
|
else if (fx.mode === 'char') msg = 'lifting the burnt bits';
|
|
else if (fx.mode === 'scrape') msg = 'taking it back off';
|
|
else if (fx.mode === 'spread') msg = 'spreading nicely';
|
|
} else if (this.knife.load <= 0.01) {
|
|
msg =
|
|
def.separates && this.strat > 0.45
|
|
? 'the oil has risen — swirl the knife in the jar first'
|
|
: 'the knife is empty — go and dip';
|
|
}
|
|
this.modeEl.textContent = msg;
|
|
this.modeEl.style.color =
|
|
fx?.mode === 'tear' || fx?.mode === 'gouge'
|
|
? '#e2603a'
|
|
: fx?.mode === 'spread'
|
|
? '#8fce8f'
|
|
: '';
|
|
this.loadFill.style.width = `${Math.min(100, (this.knife.load / def.pickup) * 100)}%`;
|
|
this.loadFill.style.background = `rgb(${def.color.map((c) => Math.round(c * 255)).join(',')})`;
|
|
}
|
|
|
|
private updateCamera(dt: number): void {
|
|
const k = 1 - Math.pow(0.0035, dt);
|
|
this.camPos.lerp(this.shot.pos, k);
|
|
this.camLook.lerp(this.shot.look, k);
|
|
this.app.camera.position.copy(this.camPos);
|
|
this.app.camera.lookAt(this.camLook);
|
|
}
|
|
|
|
dispose(): void {
|
|
this.panel.dispose();
|
|
this.slice.dispose();
|
|
}
|
|
}
|