Compare commits
5 Commits
10adb268a0
...
5b09f23c7e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b09f23c7e | ||
|
|
1ba66f0cdf | ||
|
|
b07d57e9e9 | ||
|
|
eb74170011 | ||
|
|
67cfe56bb9 |
127
README.md
Normal file
@ -0,0 +1,127 @@
|
||||
# TOASTSIM
|
||||
|
||||
Bread goes in. You are judged.
|
||||
|
||||
A browser game about making toast, where the inputs are analog and the output is
|
||||
scored by a man who has been doing this for thirty-one years.
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # http://localhost:5173
|
||||
```
|
||||
|
||||
## The loop
|
||||
|
||||
**Order → toast → drawer → spread → verdict → next day.**
|
||||
|
||||
1. **The ticket** tells you what someone wants: how dark, which spread, how much,
|
||||
which utensil, and whether they'll tolerate burnt bits.
|
||||
2. **The toaster** has a dial and a lever and no timer. You go by smell.
|
||||
3. **The drawer** makes you find the right piece of cutlery in a tangle of
|
||||
similar ones — while your toast goes cold.
|
||||
4. **The bench** is where the game actually lives. See below.
|
||||
5. **The judge** scores nine criteria and has something to say about the worst one.
|
||||
|
||||
## The mechanic
|
||||
|
||||
One control — **the knife's angle** (mouse wheel) — and three behaviours fall out
|
||||
of it:
|
||||
|
||||
| angle | contact | what happens |
|
||||
|---|---|---|
|
||||
| flat | wide, low pressure | spreads — *or tears the bread, if the spread is stiffer than the pressure you're allowed to make* |
|
||||
| steep | narrow, high pressure | scrapes spread back off, or lifts char off burnt toast |
|
||||
| steep + fast, on bread with nothing left to take | | **gouges** |
|
||||
|
||||
The trap: pressure comes from steepness, but past the scrape threshold a steep
|
||||
knife stops spreading. So a spread can demand more pressure than spreading mode
|
||||
can physically give:
|
||||
|
||||
| situation | yield | angle needed | scrape cliff at 0.62 |
|
||||
|---|---|---|---|
|
||||
| fridge butter / cold toast | 0.72 | 0.75 | **impossible** |
|
||||
| bench butter / cold toast | 0.54 | 0.57 | one click from disaster |
|
||||
| fridge butter / fresh toast | 0.36 | 0.38 | fine |
|
||||
| soft butter / fresh toast | 0.16 | 0.12 | dream mode |
|
||||
|
||||
**Cold toast with hard butter cannot be spread at any angle.** The way out isn't
|
||||
technique — it's not dawdling in the drawer. That's why the drawer is there. The
|
||||
pressure gauge draws both marks so you can watch the gold sit past the red and
|
||||
understand exactly why you're losing.
|
||||
|
||||
Scraping is the other tension: it rescues burnt toast and wrecks evenness doing
|
||||
it (char 99.9% → 54.5%, evenness 0.029 → 0.226). The judge notices both.
|
||||
|
||||
And MITEY is its own skill: spread it on, then take almost all of it back off. He
|
||||
wants to see the toast *through* it.
|
||||
|
||||
## The bread
|
||||
|
||||
Same dial, same 18 seconds, four breads:
|
||||
|
||||
| bread | 4s | 9s | 18s | char |
|
||||
|---|---|---|---|---|
|
||||
| white | 0.22 | 0.54 | 1.08 | 93% |
|
||||
| multigrain | 0.20 | 0.52 | 1.06 | 92% |
|
||||
| raisin | 0.30 | 0.76 | 1.14 | **99.8%** |
|
||||
| sourdough | 0.11 | 0.33 | 0.74 | **14%** |
|
||||
|
||||
Sourdough's water has to boil off before the crust can brown, so it stalls and
|
||||
then accelerates hardest. Raisin bread is sugar: it browns early and burns early.
|
||||
|
||||
## How it's built
|
||||
|
||||
- **Vite + TypeScript + three.js**, **Rapier** for the drawer. No framework; the
|
||||
UI is a DOM overlay over one WebGL canvas.
|
||||
- The slice is the whole game's canvas: an extruded loaf silhouette with four
|
||||
128×128 scalar fields — `browning`, `dryness`, `spread`, `damage` — packed into
|
||||
one RGBA8 texture per frame and composited by a custom shader. Everything the
|
||||
player does writes a field; everything the judge reads is a statistic over one.
|
||||
- Cutlery is procedural on purpose. The drawer asks you to tell a dessert fork
|
||||
from a dinner fork, and that's only fair if the silhouettes are authored.
|
||||
- Physics: compound boxes, never trimeshes. Grabbing is a capped PD spring, so a
|
||||
snagged piece fights what's lying on it instead of tunnelling through.
|
||||
|
||||
### Layout
|
||||
|
||||
```
|
||||
src/
|
||||
core/ app + loop, input, seeded rng, 2D field, synthesised audio
|
||||
sim/ bread, slice (geometry/fields/shader), toasting, spreads,
|
||||
spreading (the star mechanic), cutlery
|
||||
scenes/ kitchen (toast + spread), drawer (rapier), judge, props
|
||||
game/ game loop, orders, judging rubric, the judge's lines
|
||||
ui/ hud, title, cutlery silhouettes
|
||||
dev.ts dev-only harness — drives real gestures deterministically
|
||||
```
|
||||
|
||||
### Dev harness
|
||||
|
||||
The game is driven by mouse gestures over a 3D scene, which makes "does it work"
|
||||
hard to answer by inspection. In a dev build, `window.__t` drives the real code
|
||||
paths at a chosen `dt`:
|
||||
|
||||
```js
|
||||
__t.toast(10, 6) // lever down, 10s at power 6, pop, land
|
||||
__t.spread(9, 0.55) // dip and raster the slice at 0.55 units/sec
|
||||
__t.stats() // every field statistic the judge will read
|
||||
```
|
||||
|
||||
It's also the escape hatch for headless browsers that never fire rAF —
|
||||
everything calls `app.step(dt)` directly.
|
||||
|
||||
## Assets
|
||||
|
||||
Every asset was generated on-device and free via
|
||||
[MODELBEAST](../MODELBEAST/AGENTS.md), and is reproducible:
|
||||
|
||||
```bash
|
||||
./scripts/gen-assets.sh # fixed seeds; skips anything already present
|
||||
```
|
||||
|
||||
`flux_local` → `bg_remove_local` → `hunyuan3d_mlx`. The judge's five expressions
|
||||
are one fixed seed and one fixed description with only the expression phrase
|
||||
varying, which is why he stays the same man while his face falls.
|
||||
|
||||
The toaster is procedural rather than the generated GLB — it needs a lever we can
|
||||
actually drive, and a dial that turns.
|
||||
BIN
public/assets/img/bench_wood.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/assets/img/judge_disappointed.png
Normal file
|
After Width: | Height: | Size: 882 KiB |
BIN
public/assets/img/judge_horrified.png
Normal file
|
After Width: | Height: | Size: 886 KiB |
BIN
public/assets/img/judge_impressed.png
Normal file
|
After Width: | Height: | Size: 883 KiB |
BIN
public/assets/img/judge_intrigued.png
Normal file
|
After Width: | Height: | Size: 887 KiB |
BIN
public/assets/img/judge_neutral.png
Normal file
|
After Width: | Height: | Size: 878 KiB |
BIN
public/assets/img/kitchen_backdrop.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/assets/img/ticket_paper.png
Normal file
|
After Width: | Height: | Size: 342 KiB |
BIN
public/assets/img/title_art.png
Normal file
|
After Width: | Height: | Size: 971 KiB |
BIN
public/assets/models/mitey_jar.glb
Normal file
BIN
public/assets/models/pb_jar.glb
Normal file
BIN
public/assets/models/plate.glb
Normal file
@ -1,4 +1,5 @@
|
||||
import * as THREE from 'three';
|
||||
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
|
||||
|
||||
/** Shared key light direction — the slice shader rolls its own lighting, so it
|
||||
* has to be told the same thing the real lights are doing. */
|
||||
@ -104,8 +105,11 @@ export class App {
|
||||
this.renderer.toneMappingExposure = 1.05;
|
||||
this.camera = new THREE.PerspectiveCamera(42, 1, 0.05, 100);
|
||||
this.input = new Input(canvas);
|
||||
// Pauses the clock while the tab is hidden, so you don't come back to charcoal.
|
||||
this.timer.connect(document);
|
||||
// Deliberately NOT timer.connect(document): it zeroes the delta whenever
|
||||
// document.hidden is true, which some embedded/preview browsers report
|
||||
// permanently — the sim then freezes with no error. Browsers already pause
|
||||
// rAF for hidden tabs, so the toast can't burn while you're away regardless,
|
||||
// and the dt clamp below covers the hitch on resume.
|
||||
this.scene.fog = new THREE.Fog(0x1b1512, 8, 22);
|
||||
window.addEventListener('resize', () => this.resize());
|
||||
this.resize();
|
||||
@ -135,40 +139,77 @@ export class App {
|
||||
}
|
||||
|
||||
resize(): void {
|
||||
const w = window.innerWidth;
|
||||
const h = window.innerHeight;
|
||||
// Guard the zero case: a collapsed or hidden container reports 0, and 0/0
|
||||
// makes the aspect NaN, which poisons the projection matrix for good. Every
|
||||
// raycast then silently returns nothing — no error, the game just stops
|
||||
// responding to the mouse.
|
||||
const w = Math.max(1, window.innerWidth);
|
||||
const h = Math.max(1, window.innerHeight);
|
||||
this.renderer.setSize(w, h, false);
|
||||
this.camera.aspect = w / h;
|
||||
this.camera.updateProjectionMatrix();
|
||||
this.view?.resize?.(w, h);
|
||||
}
|
||||
|
||||
/**
|
||||
* One simulation + render step. Normally driven by rAF; exposed so tests (and
|
||||
* headless browsers that never fire rAF) can advance the game deterministically.
|
||||
*/
|
||||
step(dt: number): void {
|
||||
for (const t of this.tickers) t(dt);
|
||||
this.view?.update(dt);
|
||||
this.renderFrame(dt);
|
||||
this.input.endFrame();
|
||||
}
|
||||
|
||||
private savedPos = new THREE.Vector3();
|
||||
private savedQuat = new THREE.Quaternion();
|
||||
|
||||
private renderFrame(dt: number): void {
|
||||
// Shake is an offset applied for the render only — baking it into the
|
||||
// camera would let it accumulate and walk the view off the bench.
|
||||
if (this.shake > 0.0001) {
|
||||
const s = this.shake;
|
||||
this.savedPos.copy(this.camera.position);
|
||||
this.savedQuat.copy(this.camera.quaternion);
|
||||
this.camera.position.x += (Math.random() - 0.5) * s;
|
||||
this.camera.position.y += (Math.random() - 0.5) * s;
|
||||
this.camera.rotateZ((Math.random() - 0.5) * s * 0.35);
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
this.camera.position.copy(this.savedPos);
|
||||
this.camera.quaternion.copy(this.savedQuat);
|
||||
this.shake *= Math.pow(0.0015, dt);
|
||||
if (this.shake < 0.0005) this.shake = 0;
|
||||
} else {
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
}
|
||||
}
|
||||
|
||||
start(): void {
|
||||
const loop = (ts?: number) => {
|
||||
requestAnimationFrame(loop);
|
||||
this.timer.update(ts);
|
||||
// Clamp so a single frame hitch can't teleport the sim forward.
|
||||
const dt = Math.min(this.timer.getDelta(), 1 / 20);
|
||||
for (const t of this.tickers) t(dt);
|
||||
this.view?.update(dt);
|
||||
if (this.shake > 0.0001) {
|
||||
const s = this.shake;
|
||||
this.camera.position.x += (Math.random() - 0.5) * s;
|
||||
this.camera.position.y += (Math.random() - 0.5) * s;
|
||||
this.camera.rotateZ((Math.random() - 0.5) * s * 0.35);
|
||||
this.shake *= Math.pow(0.0015, dt);
|
||||
if (this.shake < 0.0005) this.shake = 0;
|
||||
}
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
this.input.endFrame();
|
||||
this.step(Math.min(this.timer.getDelta(), 1 / 20));
|
||||
};
|
||||
loop();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cutlery is the point of half this game, and polished metal with nothing to
|
||||
* reflect renders black. An environment map is not optional here.
|
||||
*/
|
||||
export function makeEnvironment(app: App): void {
|
||||
const pmrem = new THREE.PMREMGenerator(app.renderer);
|
||||
app.scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
|
||||
app.scene.environmentIntensity = 0.4;
|
||||
pmrem.dispose();
|
||||
}
|
||||
|
||||
/** Standard kitchen lighting rig, shared by every view. */
|
||||
export function makeLights(scene: THREE.Scene): THREE.DirectionalLight {
|
||||
const key = new THREE.DirectionalLight(0xfff2dd, 2.6);
|
||||
const key = new THREE.DirectionalLight(0xfff2dd, 1.5);
|
||||
key.position.copy(LIGHT_DIR).multiplyScalar(6);
|
||||
key.castShadow = true;
|
||||
key.shadow.mapSize.set(2048, 2048);
|
||||
@ -182,8 +223,8 @@ export function makeLights(scene: THREE.Scene): THREE.DirectionalLight {
|
||||
key.shadow.bias = -0.0016;
|
||||
key.shadow.normalBias = 0.02;
|
||||
scene.add(key);
|
||||
scene.add(new THREE.HemisphereLight(0x9fb4d6, 0x3a2a20, 0.75));
|
||||
const fill = new THREE.DirectionalLight(0xbdd6ff, 0.45);
|
||||
scene.add(new THREE.HemisphereLight(0xbfc6d2, 0x4a3526, 0.22));
|
||||
const fill = new THREE.DirectionalLight(0xbdd6ff, 0.18);
|
||||
fill.position.set(-3, 2.2, -2.5);
|
||||
scene.add(fill);
|
||||
return key;
|
||||
|
||||
141
src/core/audio.ts
Normal file
@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Synthesised sound. No samples — the whole point of a drawer full of cutlery is
|
||||
* the noise it makes, and that noise is velocity-dependent, so it has to be
|
||||
* generated per collision rather than picked from a list.
|
||||
*/
|
||||
export class Audio {
|
||||
private ctx: AudioContext | null = null;
|
||||
private master: GainNode | null = null;
|
||||
private noise: AudioBuffer | null = null;
|
||||
private lastClatter = 0;
|
||||
|
||||
/** Browsers won't let us make noise until the user has done something. */
|
||||
resume(): void {
|
||||
if (!this.ctx) {
|
||||
const Ctor = window.AudioContext ?? (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
|
||||
if (!Ctor) return;
|
||||
this.ctx = new Ctor();
|
||||
this.master = this.ctx.createGain();
|
||||
this.master.gain.value = 0.5;
|
||||
this.master.connect(this.ctx.destination);
|
||||
this.noise = this.makeNoise();
|
||||
}
|
||||
void this.ctx.resume();
|
||||
}
|
||||
|
||||
private makeNoise(): AudioBuffer {
|
||||
const ctx = this.ctx!;
|
||||
const len = ctx.sampleRate * 0.5;
|
||||
const buf = ctx.createBuffer(1, len, ctx.sampleRate);
|
||||
const d = buf.getChannelData(0);
|
||||
for (let i = 0; i < len; i++) d[i] = Math.random() * 2 - 1;
|
||||
return buf;
|
||||
}
|
||||
|
||||
private now(): number {
|
||||
return this.ctx?.currentTime ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Metal on metal. A bright noise burst through a resonant band, plus a couple
|
||||
* of inharmonic partials — that ring is what makes it read as cutlery rather
|
||||
* than a click.
|
||||
*/
|
||||
clatter(strength: number, pitch = 1): void {
|
||||
const ctx = this.ctx;
|
||||
if (!ctx || !this.master || !this.noise) return;
|
||||
const t = this.now();
|
||||
// Dozens of contacts can resolve in one step; don't stack them all.
|
||||
if (t - this.lastClatter < 0.02) return;
|
||||
this.lastClatter = t;
|
||||
const v = Math.min(1, strength);
|
||||
|
||||
const src = ctx.createBufferSource();
|
||||
src.buffer = this.noise;
|
||||
src.playbackRate.value = 0.8 + Math.random() * 0.6;
|
||||
const bp = ctx.createBiquadFilter();
|
||||
bp.type = 'bandpass';
|
||||
bp.frequency.value = (2600 + Math.random() * 2600) * pitch;
|
||||
bp.Q.value = 2.5;
|
||||
const g = ctx.createGain();
|
||||
g.gain.setValueAtTime(0.28 * v, t);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.06 + v * 0.05);
|
||||
src.connect(bp).connect(g).connect(this.master);
|
||||
src.start(t);
|
||||
src.stop(t + 0.2);
|
||||
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const o = ctx.createOscillator();
|
||||
o.type = 'triangle';
|
||||
o.frequency.value = (1900 + Math.random() * 3200) * pitch;
|
||||
const og = ctx.createGain();
|
||||
og.gain.setValueAtTime(0.06 * v, t);
|
||||
og.gain.exponentialRampToValueAtTime(0.0001, t + 0.12 + v * 0.3);
|
||||
o.connect(og).connect(this.master);
|
||||
o.start(t);
|
||||
o.stop(t + 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
/** The lever going down, and the pop coming back. */
|
||||
clunk(up = false): void {
|
||||
const ctx = this.ctx;
|
||||
if (!ctx || !this.master) return;
|
||||
const t = this.now();
|
||||
const o = ctx.createOscillator();
|
||||
o.type = 'square';
|
||||
o.frequency.setValueAtTime(up ? 180 : 120, t);
|
||||
o.frequency.exponentialRampToValueAtTime(up ? 90 : 55, t + 0.09);
|
||||
const g = ctx.createGain();
|
||||
g.gain.setValueAtTime(0.22, t);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.16);
|
||||
const lp = ctx.createBiquadFilter();
|
||||
lp.type = 'lowpass';
|
||||
lp.frequency.value = 900;
|
||||
o.connect(lp).connect(g).connect(this.master);
|
||||
o.start(t);
|
||||
o.stop(t + 0.25);
|
||||
}
|
||||
|
||||
/** Knife dragging over toast. Pitch rises with pressure. */
|
||||
scrape(pressure: number, speed: number): void {
|
||||
const ctx = this.ctx;
|
||||
if (!ctx || !this.master || !this.noise) return;
|
||||
const t = this.now();
|
||||
if (t - this.lastClatter < 0.045) return;
|
||||
this.lastClatter = t;
|
||||
const src = ctx.createBufferSource();
|
||||
src.buffer = this.noise;
|
||||
src.playbackRate.value = 0.35 + pressure * 0.5;
|
||||
const bp = ctx.createBiquadFilter();
|
||||
bp.type = 'bandpass';
|
||||
bp.frequency.value = 600 + pressure * 2600;
|
||||
bp.Q.value = 1.1;
|
||||
const g = ctx.createGain();
|
||||
const v = Math.min(0.14, 0.03 + speed * 0.05);
|
||||
g.gain.setValueAtTime(v, t);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.1);
|
||||
src.connect(bp).connect(g).connect(this.master);
|
||||
src.start(t);
|
||||
src.stop(t + 0.2);
|
||||
}
|
||||
|
||||
/** The judge's stamp. */
|
||||
stamp(): void {
|
||||
const ctx = this.ctx;
|
||||
if (!ctx || !this.master) return;
|
||||
const t = this.now();
|
||||
const o = ctx.createOscillator();
|
||||
o.type = 'sine';
|
||||
o.frequency.setValueAtTime(150, t);
|
||||
o.frequency.exponentialRampToValueAtTime(48, t + 0.11);
|
||||
const g = ctx.createGain();
|
||||
g.gain.setValueAtTime(0.4, t);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.22);
|
||||
o.connect(g).connect(this.master);
|
||||
o.start(t);
|
||||
o.stop(t + 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
export const audio = new Audio();
|
||||
@ -44,7 +44,11 @@ export function valueNoise2D(rng: Rng, w: number, h: number, octaves = 3): Float
|
||||
const ty = fy - y0;
|
||||
const sx = tx * tx * (3 - 2 * tx);
|
||||
const sy = ty * ty * (3 - 2 * ty);
|
||||
const g = (gx: number, gy: number) => grid[gy * (cells + 1) + gx];
|
||||
// Clamp: at x === w-1, fx lands exactly on `cells`, so the x0+1 lookup
|
||||
// runs one past the end of the grid. A typed array returns undefined
|
||||
// there, which silently turns the whole field into NaN.
|
||||
const g = (gx: number, gy: number) =>
|
||||
grid[Math.min(gy, cells) * (cells + 1) + Math.min(gx, cells)];
|
||||
const a = g(x0, y0) * (1 - sx) + g(x0 + 1, y0) * sx;
|
||||
const b = g(x0, y0 + 1) * (1 - sx) + g(x0 + 1, y0 + 1) * sx;
|
||||
out[y * w + x] += (a * (1 - sy) + b * sy) * amp;
|
||||
|
||||
122
src/dev.ts
Normal file
@ -0,0 +1,122 @@
|
||||
import * as THREE from 'three';
|
||||
import type { App } from './core/app';
|
||||
import type { Game } from './game/game';
|
||||
|
||||
/**
|
||||
* Dev harness. The game is driven by mouse gestures over a 3D scene, which makes
|
||||
* "does it actually work" hard to answer by inspection — so this drives real
|
||||
* input through the real code paths, deterministically, at a chosen dt.
|
||||
*
|
||||
* Also the escape hatch for headless/embedded browsers that never fire rAF:
|
||||
* everything here calls app.step() directly.
|
||||
*
|
||||
* Dev builds only.
|
||||
*/
|
||||
export function installDevHarness(app: App, game: Game): void {
|
||||
const kitchen = game.kitchenView;
|
||||
const v = new THREE.Vector3();
|
||||
const toNdc = (x: number, y: number, z: number): [number, number] => {
|
||||
v.set(x, y, z).project(app.camera);
|
||||
return [v.x, v.y];
|
||||
};
|
||||
const run = (frames: number, dt = 1 / 60) => {
|
||||
for (let i = 0; i < frames; i++) app.step(dt);
|
||||
};
|
||||
const tap = (code: string) => {
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { code }));
|
||||
app.step(1 / 60);
|
||||
window.dispatchEvent(new KeyboardEvent('keyup', { code }));
|
||||
};
|
||||
const point = (x: number, y: number, z: number, down: boolean) => {
|
||||
const n = toNdc(x, y, z);
|
||||
app.input.ndc.set(n[0], n[1]);
|
||||
app.input.down = down;
|
||||
};
|
||||
|
||||
const harness = {
|
||||
app,
|
||||
game,
|
||||
kitchen,
|
||||
THREE,
|
||||
run,
|
||||
tap,
|
||||
point,
|
||||
toNdc,
|
||||
|
||||
/** Drop the lever, brown for `seconds`, pop, and wait for it to land. */
|
||||
toast(seconds = 10, power?: number) {
|
||||
if (power !== undefined) kitchen.power = power;
|
||||
tap('Space');
|
||||
run(Math.round(seconds * 60));
|
||||
tap('Space');
|
||||
run(200);
|
||||
return harness.stats();
|
||||
},
|
||||
|
||||
dip() {
|
||||
point(3.15, 0.29, 0.45, true);
|
||||
run(4);
|
||||
app.input.down = false;
|
||||
app.step(1 / 60);
|
||||
},
|
||||
|
||||
/**
|
||||
* Raster the whole slice. `speed` is world-units/sec — the thing that
|
||||
* actually decides how thick the spread goes on.
|
||||
*/
|
||||
spread(passes = 9, speed = 0.55, angle?: number) {
|
||||
const sl = kitchen.currentSlice;
|
||||
if (angle !== undefined) kitchen.knife.angle = angle;
|
||||
harness.dip();
|
||||
const stepDist = speed / 60;
|
||||
for (let p = 0; p < passes; p++) {
|
||||
const z = 0.55 - 0.44 + (p / Math.max(1, passes - 1)) * 0.88;
|
||||
const steps = Math.ceil(0.96 / stepDist);
|
||||
for (let s = 0; s <= steps; s++) {
|
||||
if (kitchen.knife.load < 0.02) harness.dip();
|
||||
const x = 1.45 + (p % 2 ? 1 : -1) * (0.48 - s * stepDist);
|
||||
point(x, sl.topY, z, true);
|
||||
app.step(1 / 60);
|
||||
}
|
||||
}
|
||||
app.input.down = false;
|
||||
harness.park();
|
||||
return harness.stats();
|
||||
},
|
||||
|
||||
/** Park the cursor off the toast so a screenshot isn't full of knife. */
|
||||
park() {
|
||||
point(2.5, kitchen.currentSlice.topY, 1.3, false);
|
||||
app.step(1 / 60);
|
||||
},
|
||||
|
||||
stats() {
|
||||
const sl = kitchen.currentSlice;
|
||||
const b = sl.browning.stats(sl.mask);
|
||||
const s = sl.spread.stats(sl.mask);
|
||||
const d = sl.damage.stats(sl.mask);
|
||||
const r = (n: number) => +n.toFixed(3);
|
||||
return {
|
||||
phase: kitchen.phase,
|
||||
warmth: r(sl.warmth),
|
||||
browning: { mean: r(b.mean), stdev: r(b.stdev), max: r(b.max) },
|
||||
char: r(sl.browning.fraction(sl.mask, (x) => x > 0.85)),
|
||||
spread: { mean: r(s.mean), stdev: r(s.stdev), max: r(s.max) },
|
||||
coverage: r(sl.spread.fraction(sl.mask, (x) => x > 0.02)),
|
||||
damage: r(d.mean),
|
||||
load: r(kitchen.knife.load),
|
||||
angle: r(kitchen.knife.angle),
|
||||
mode: kitchen.lastFx?.mode ?? 'idle',
|
||||
};
|
||||
},
|
||||
|
||||
/** Move the camera somewhere for a screenshot without disturbing the sim. */
|
||||
look(pos: [number, number, number], at: [number, number, number]) {
|
||||
app.camera.position.set(...pos);
|
||||
app.camera.lookAt(new THREE.Vector3(...at));
|
||||
app.renderer.render(app.scene, app.camera);
|
||||
},
|
||||
};
|
||||
|
||||
(window as unknown as { __t: unknown }).__t = harness;
|
||||
}
|
||||
211
src/game/game.ts
Normal file
@ -0,0 +1,211 @@
|
||||
import type { App } from '../core/app';
|
||||
import { Rng } from '../core/rng';
|
||||
import { KitchenView } from '../scenes/kitchen';
|
||||
import { JudgeView } from '../scenes/judge';
|
||||
import { DrawerView } from '../scenes/drawer';
|
||||
import { TOOLS, type ToolId } from '../sim/cutlery';
|
||||
import { audio } from '../core/audio';
|
||||
import { coolStep } from '../sim/toasting';
|
||||
import { judge, type Grade } from './judging';
|
||||
import { DAYS, proceduralOrder, nameBrowning, type Order } from './orders';
|
||||
import { el, Panel } from '../ui/hud';
|
||||
import { Title } from '../ui/title';
|
||||
|
||||
const SAVE_KEY = 'toastsim.save';
|
||||
|
||||
interface Save {
|
||||
day: number;
|
||||
total: number;
|
||||
best: Record<string, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The arcade loop: an order, a slice, a verdict, the next order. The day counter
|
||||
* is the whole progression — everything else is the orders getting nastier.
|
||||
*/
|
||||
export class Game {
|
||||
private kitchen: KitchenView;
|
||||
private judgeView: JudgeView;
|
||||
private drawer: DrawerView;
|
||||
private rng = new Rng(20260716);
|
||||
private order!: Order;
|
||||
private startedAt = 0;
|
||||
private save: Save;
|
||||
private ticket: Panel;
|
||||
private ticketEl!: HTMLElement;
|
||||
|
||||
constructor(private app: App) {
|
||||
this.save = loadSave();
|
||||
this.kitchen = new KitchenView(app, 3);
|
||||
this.judgeView = new JudgeView(app);
|
||||
this.drawer = new DrawerView(app, 7);
|
||||
app.scene.add(this.kitchen.root);
|
||||
app.scene.add(this.judgeView.root);
|
||||
app.scene.add(this.drawer.root);
|
||||
this.judgeView.root.visible = false;
|
||||
this.drawer.root.visible = false;
|
||||
|
||||
this.ticket = new Panel('ticket');
|
||||
this.ticketEl = el('div', 'ticket-body', this.ticket.root);
|
||||
|
||||
// toast -> drawer -> spread. The drawer sits AFTER the toaster on purpose:
|
||||
// its cost is your toast going cold, and cold toast is what makes butter
|
||||
// impossible. Dawdling here is the punishment.
|
||||
this.kitchen.onPopped = () => this.openDrawer();
|
||||
this.kitchen.onServed = () => this.serve();
|
||||
this.drawer.onPicked = (tool) => this.closeDrawer(tool);
|
||||
this.judgeView.onNext = () => {
|
||||
this.save.day++;
|
||||
writeSave(this.save);
|
||||
this.beginDay();
|
||||
};
|
||||
|
||||
// Cooling runs at the app level, not in the kitchen's update: the kitchen
|
||||
// stops updating the moment the drawer opens, and "your toast goes cold
|
||||
// while you rummage" is the entire reason the drawer exists.
|
||||
app.onTick((dt) => {
|
||||
const k = this.kitchen;
|
||||
if (k.phase === 'ready' || k.phase === 'toasting') return;
|
||||
coolStep(k.currentSlice, dt);
|
||||
this.drawer.warmth = k.currentSlice.warmth;
|
||||
});
|
||||
|
||||
// Sit on the title until the player clicks — which also gives us the user
|
||||
// gesture the browser wants before any audio is allowed to make a sound.
|
||||
this.kitchen.root.visible = false;
|
||||
this.ticket.hide();
|
||||
new Title(this.save.day, () => {
|
||||
audio.resume();
|
||||
this.beginDay();
|
||||
});
|
||||
}
|
||||
|
||||
get currentOrder(): Order {
|
||||
return this.order;
|
||||
}
|
||||
get kitchenView(): KitchenView {
|
||||
return this.kitchen;
|
||||
}
|
||||
get judgeScreen(): JudgeView {
|
||||
return this.judgeView;
|
||||
}
|
||||
get drawerView(): DrawerView {
|
||||
return this.drawer;
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
await this.drawer.init();
|
||||
}
|
||||
|
||||
/** Fresh out of the toaster, and now you need something to spread with. */
|
||||
private openDrawer(): void {
|
||||
this.drawer.reset(this.order.tool);
|
||||
this.drawer.warmth = this.kitchen.currentSlice.warmth;
|
||||
this.kitchen.root.visible = false;
|
||||
this.drawer.root.visible = true;
|
||||
this.app.setView(this.drawer);
|
||||
this.ticket.hide();
|
||||
}
|
||||
|
||||
private closeDrawer(tool: ToolId): void {
|
||||
this.kitchen.setTool(tool);
|
||||
this.drawer.root.visible = false;
|
||||
this.kitchen.root.visible = true;
|
||||
this.app.setView(this.kitchen);
|
||||
this.ticket.show();
|
||||
this.kitchen.beginSpreading();
|
||||
if (tool !== this.order.tool) {
|
||||
this.kitchen.flash(`you brought a ${TOOLS[tool].name.toLowerCase()}`);
|
||||
}
|
||||
}
|
||||
|
||||
beginDay(): void {
|
||||
const day = this.save.day;
|
||||
const o =
|
||||
day <= DAYS.length
|
||||
? DAYS[day - 1]
|
||||
: proceduralOrder(day, () => this.rng.next());
|
||||
if (!o.text) o.text = `${o.browningName}, ${o.amount} ${o.spread === 'mitey' ? 'MITEY' : o.spread}. ${o.noChar ? 'No burnt bits.' : ''}`;
|
||||
this.order = o;
|
||||
this.startedAt = performance.now();
|
||||
|
||||
const s = this.judgeView.release();
|
||||
if (s) this.kitchen.root.add(s.mesh);
|
||||
|
||||
this.kitchen.applyOrder(o);
|
||||
this.drawer.root.visible = false;
|
||||
this.app.setView(this.kitchen);
|
||||
this.judgeView.root.visible = false;
|
||||
this.kitchen.root.visible = true;
|
||||
this.ticket.show();
|
||||
this.renderTicket();
|
||||
}
|
||||
|
||||
private renderTicket(): void {
|
||||
const o = this.order;
|
||||
this.ticketEl.innerHTML = '';
|
||||
el('div', 'ticket-day', this.ticketEl, `DAY ${o.day}`);
|
||||
el('div', 'ticket-who', this.ticketEl, o.who);
|
||||
el('div', 'ticket-text', this.ticketEl, `“${o.text}”`);
|
||||
const spec = el('div', 'ticket-spec', this.ticketEl);
|
||||
el('span', 'chip', spec, o.browningName);
|
||||
el('span', 'chip', spec, `${o.amount} ${o.spread === 'mitey' ? 'MITEY' : o.spread}`);
|
||||
if (o.noChar) el('span', 'chip warn', spec, 'no burnt bits');
|
||||
el('span', 'chip tool', spec, `use the ${o.tool.replace(/_/g, ' ')}`);
|
||||
el(
|
||||
'div',
|
||||
'ticket-hint',
|
||||
this.ticketEl,
|
||||
`butter is ${o.butterSoftness > 0.6 ? 'soft' : o.butterSoftness > 0.3 ? 'firm' : 'fridge-hard'} today`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Called when the player hands the plate over. */
|
||||
serve(): void {
|
||||
const seconds = (performance.now() - this.startedAt) / 1000;
|
||||
const slice = this.kitchen.currentSlice;
|
||||
const v = judge(slice, this.order, this.kitchen.toolId, seconds);
|
||||
this.save.total += v.total;
|
||||
const key = `day${this.order.day}`;
|
||||
this.save.best[key] = Math.max(this.save.best[key] ?? 0, v.total);
|
||||
writeSave(this.save);
|
||||
|
||||
this.kitchen.root.visible = false;
|
||||
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();
|
||||
}
|
||||
|
||||
/** Test hook. */
|
||||
gradeNow(): { total: number; grade: Grade } {
|
||||
const v = judge(this.kitchen.currentSlice, this.order, this.kitchen.toolId, 60);
|
||||
return { total: v.total, grade: v.grade };
|
||||
}
|
||||
}
|
||||
|
||||
function loadSave(): Save {
|
||||
try {
|
||||
const raw = localStorage.getItem(SAVE_KEY);
|
||||
if (raw) {
|
||||
const s = JSON.parse(raw) as Partial<Save>;
|
||||
if (typeof s.day === 'number' && s.day >= 1) {
|
||||
return { day: s.day, total: s.total ?? 0, best: s.best ?? {} };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* a corrupt save is not worth a crash */
|
||||
}
|
||||
return { day: 1, total: 0, best: {} };
|
||||
}
|
||||
|
||||
function writeSave(s: Save): void {
|
||||
try {
|
||||
localStorage.setItem(SAVE_KEY, JSON.stringify(s));
|
||||
} catch {
|
||||
/* private mode; the day counter just won't persist */
|
||||
}
|
||||
}
|
||||
|
||||
export { nameBrowning };
|
||||
182
src/game/judging.ts
Normal file
@ -0,0 +1,182 @@
|
||||
import type { Slice } from '../sim/slice';
|
||||
import { CHAR_THRESHOLD } from '../sim/slice';
|
||||
import { SPREADS, amountClassOf } from '../sim/spreads';
|
||||
import { TOOLS, type ToolId } from '../sim/cutlery';
|
||||
import type { Order } from './orders';
|
||||
|
||||
export interface Criterion {
|
||||
key: string;
|
||||
label: string;
|
||||
/** 0..1 */
|
||||
score: number;
|
||||
weight: number;
|
||||
/** What the number actually was, in words. */
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export type Grade = 'S' | 'A' | 'B' | 'C' | 'F';
|
||||
|
||||
export interface Verdict {
|
||||
criteria: Criterion[];
|
||||
/** 0..10 */
|
||||
total: number;
|
||||
grade: Grade;
|
||||
best: Criterion;
|
||||
worst: Criterion;
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v);
|
||||
|
||||
/**
|
||||
* Every number here is read off the same fields the player was pushing around
|
||||
* with the knife, so the scorecard can always point at something real.
|
||||
*/
|
||||
export function judge(slice: Slice, order: Order, usedTool: ToolId, seconds: number): Verdict {
|
||||
const b = slice.browning.stats(slice.mask);
|
||||
const s = slice.spread.stats(slice.mask);
|
||||
const d = slice.damage.stats(slice.mask);
|
||||
const def = SPREADS[order.spread];
|
||||
const [lo, hi] = def.amounts[order.amount];
|
||||
const target = (lo + hi) / 2;
|
||||
|
||||
const charFrac = slice.browning.fraction(slice.mask, (v) => v > CHAR_THRESHOLD);
|
||||
const coverage = slice.spread.fraction(slice.mask, (v) => v >= lo * 0.75);
|
||||
// Uniformity only over the bits you actually covered — punishing the variance
|
||||
// of a half-spread slice twice (here and in coverage) isn't fair.
|
||||
const covered = spreadStdevOverCovered(slice, lo * 0.75);
|
||||
|
||||
const criteria: Criterion[] = [
|
||||
{
|
||||
key: 'browning',
|
||||
label: 'Browning',
|
||||
score: clamp01(1 - Math.abs(b.mean - order.browning) / 0.3),
|
||||
weight: 1.5,
|
||||
detail: `${b.mean.toFixed(2)} against ${order.browning.toFixed(2)} asked`,
|
||||
},
|
||||
{
|
||||
key: 'evenness',
|
||||
label: 'Evenness',
|
||||
score: clamp01(1 - b.stdev / 0.2),
|
||||
weight: 1.1,
|
||||
detail: `variation ${b.stdev.toFixed(3)}`,
|
||||
},
|
||||
{
|
||||
key: 'coverage',
|
||||
label: 'Coverage',
|
||||
score: clamp01((coverage - 0.15) / 0.75),
|
||||
weight: 1.3,
|
||||
detail: `${Math.round(coverage * 100)}% of the slice`,
|
||||
},
|
||||
{
|
||||
key: 'uniformity',
|
||||
label: 'Uniformity',
|
||||
score: clamp01(1 - covered / 0.22),
|
||||
weight: 0.9,
|
||||
detail: `variation ${covered.toFixed(3)}`,
|
||||
},
|
||||
{
|
||||
key: 'char',
|
||||
label: 'Char',
|
||||
score: order.noChar ? clamp01(1 - charFrac / 0.12) : clamp01(1 - charFrac / 0.45),
|
||||
weight: order.noChar ? 1.3 : 0.5,
|
||||
detail: charFrac < 0.005 ? 'none' : `${Math.round(charFrac * 100)}% burnt`,
|
||||
},
|
||||
{
|
||||
key: 'integrity',
|
||||
label: 'Integrity',
|
||||
score: clamp01(1 - d.mean / 0.06),
|
||||
weight: 1.2,
|
||||
detail: d.mean < 0.002 ? 'intact' : `torn over ${Math.round(slice.damage.fraction(slice.mask, (v) => v > 0.02) * 100)}%`,
|
||||
},
|
||||
{
|
||||
key: 'amount',
|
||||
label: 'The Right Amount',
|
||||
score: amountScore(s.mean, lo, hi, target),
|
||||
weight: 1.4,
|
||||
detail: `${amountWord(slice, order)} — ${order.amount} asked`,
|
||||
},
|
||||
{
|
||||
key: 'tool',
|
||||
label: 'Utensil',
|
||||
score: usedTool === order.tool ? 1 : TOOLS[usedTool].ideal ? 0.6 : 0.25,
|
||||
weight: 0.4,
|
||||
detail: usedTool === order.tool ? TOOLS[usedTool].name : `${TOOLS[usedTool].name}, not the ${TOOLS[order.tool].name}`,
|
||||
},
|
||||
{
|
||||
key: 'time',
|
||||
label: 'Service',
|
||||
score: clamp01(1 - (seconds - 45) / 90),
|
||||
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;
|
||||
|
||||
// Best/worst ignore Service — nobody wants a verdict about the clock.
|
||||
const rankable = criteria.filter((c) => c.key !== 'time');
|
||||
const sorted = [...rankable].sort((a, b2) => a.score - b2.score);
|
||||
const worst = sorted[0];
|
||||
const best = sorted[sorted.length - 1];
|
||||
|
||||
return {
|
||||
criteria,
|
||||
total,
|
||||
grade: gradeOf(total),
|
||||
best,
|
||||
worst,
|
||||
lines: [],
|
||||
};
|
||||
}
|
||||
|
||||
function spreadStdevOverCovered(slice: Slice, floor: number): number {
|
||||
const sp = slice.spread.data;
|
||||
const m = slice.mask.data;
|
||||
let sum = 0;
|
||||
let n = 0;
|
||||
for (let i = 0; i < sp.length; i++) {
|
||||
if (m[i] < 0.5 || sp[i] < floor) continue;
|
||||
sum += sp[i];
|
||||
n++;
|
||||
}
|
||||
if (n < 8) return 1;
|
||||
const mean = sum / n;
|
||||
let acc = 0;
|
||||
for (let i = 0; i < sp.length; i++) {
|
||||
if (m[i] < 0.5 || sp[i] < floor) continue;
|
||||
const d = sp[i] - mean;
|
||||
acc += d * d;
|
||||
}
|
||||
return Math.sqrt(acc / n);
|
||||
}
|
||||
|
||||
/** Full marks anywhere inside the band, falling away outside it. */
|
||||
function amountScore(mean: number, lo: number, hi: number, target: number): number {
|
||||
if (mean >= lo && mean <= hi) return 1;
|
||||
const dist = mean < lo ? lo - mean : mean - hi;
|
||||
return clamp01(1 - dist / (target * 1.5));
|
||||
}
|
||||
|
||||
function amountWord(slice: Slice, order: Order): string {
|
||||
const def = SPREADS[order.spread];
|
||||
const mean = slice.spread.stats(slice.mask).mean;
|
||||
const cls = amountClassOf(def, mean);
|
||||
if (cls === 'none') return 'essentially none';
|
||||
if (cls === 'obscene') return 'an obscene amount';
|
||||
return cls;
|
||||
}
|
||||
|
||||
export function gradeOf(total: number): Grade {
|
||||
if (total >= 9.2) return 'S';
|
||||
if (total >= 7.6) return 'A';
|
||||
if (total >= 5.8) return 'B';
|
||||
if (total >= 3.8) return 'C';
|
||||
return 'F';
|
||||
}
|
||||
203
src/game/lines.ts
Normal file
@ -0,0 +1,203 @@
|
||||
import type { Criterion, Grade, Verdict } from './judging';
|
||||
import type { Order } from './orders';
|
||||
import { SPREADS } from '../sim/spreads';
|
||||
|
||||
/**
|
||||
* The judge. Deadpan, specific, never cruel about anything except MITEY, about
|
||||
* which he is unwell.
|
||||
*
|
||||
* Lines are keyed to the criterion that actually decided the score, so the
|
||||
* verdict always tells you the same thing the scorecard does — just meaner.
|
||||
*/
|
||||
|
||||
type Bank = Record<string, { bad: string[]; good: string[] }>;
|
||||
|
||||
const BANK: Bank = {
|
||||
browning: {
|
||||
bad: [
|
||||
'I asked for {asked}. This is {got}. These are different words.',
|
||||
'You have made something adjacent to toast.',
|
||||
'The colour is a decision. You appear not to have made one.',
|
||||
'This is bread that has been near a toaster. Not the same thing.',
|
||||
'Somewhere in this kitchen is a dial. It has numbers on it.',
|
||||
],
|
||||
good: [
|
||||
'The colour is correct. I want that on the record.',
|
||||
'That is the shade I asked for. Precisely the shade.',
|
||||
'Colour: no notes.',
|
||||
],
|
||||
},
|
||||
evenness: {
|
||||
bad: [
|
||||
'One half of this has had a completely different morning to the other.',
|
||||
'It is striped. Toast should not be striped.',
|
||||
'I can see where you rescued it. So can everyone.',
|
||||
'This slice has weather.',
|
||||
'There are three separate climates on this toast.',
|
||||
],
|
||||
good: [
|
||||
'Even, corner to corner. That is harder than it looks.',
|
||||
'No hot spots, no pale spots. Good.',
|
||||
'Uniform. Genuinely uniform.',
|
||||
],
|
||||
},
|
||||
coverage: {
|
||||
bad: [
|
||||
'You have buttered an island and left a continent.',
|
||||
'The corners are not decorative. They are toast.',
|
||||
'There is a border of bare toast. Why is there a border.',
|
||||
'You got most of it. Most.',
|
||||
'A generous interpretation of the word "spread".',
|
||||
],
|
||||
good: [
|
||||
'Edge to edge. Somebody was raised properly.',
|
||||
'Full coverage. Not a bare corner on it.',
|
||||
'You went to the crusts. I noticed.',
|
||||
],
|
||||
},
|
||||
uniformity: {
|
||||
bad: [
|
||||
'It has a thick end and a thin end, like a bad argument.',
|
||||
'Lumpy. I can feel it from here.',
|
||||
'The {spread} is doing whatever it likes.',
|
||||
'This is not a film. It is terrain.',
|
||||
],
|
||||
good: [
|
||||
'Flat, even, no ridges. Very tidy.',
|
||||
'Consistent all the way across.',
|
||||
'A properly level spread. Rare.',
|
||||
],
|
||||
},
|
||||
char: {
|
||||
bad: [
|
||||
'I said no burnt bits. This is a burnt bit convention.',
|
||||
'There is carbon on this. Actual carbon.',
|
||||
'You burnt it. Then you served it. The second decision is the interesting one.',
|
||||
'I asked for toast, not evidence.',
|
||||
],
|
||||
good: [
|
||||
'Not a scorch on it.',
|
||||
'No char. Well caught.',
|
||||
'Clean. Nothing burnt.',
|
||||
],
|
||||
},
|
||||
integrity: {
|
||||
bad: [
|
||||
'You have not spread this. You have assaulted it.',
|
||||
'The surface is gone. You dragged it off and served the wound.',
|
||||
'Cold butter. Warm toast. One of these was your job.',
|
||||
'There is a crater. The coverage is admirable. The crater is not.',
|
||||
'This bread has been through something and would rather not discuss it.',
|
||||
],
|
||||
good: [
|
||||
'Not a tear on it. That is the whole trick, and you did it.',
|
||||
'The surface is perfect. No drag, no gouges.',
|
||||
'Intact. You let something be warm first, didn\'t you.',
|
||||
],
|
||||
},
|
||||
amount: {
|
||||
bad: [
|
||||
'I said {asked}. You have applied {got}.',
|
||||
'That is not {asked}. That is a statement.',
|
||||
'Somebody has never been told no.',
|
||||
],
|
||||
good: [
|
||||
'Exactly the right amount. Exactly.',
|
||||
'The quantity is correct. Thank you.',
|
||||
],
|
||||
},
|
||||
tool: {
|
||||
bad: [
|
||||
'You did this with a {tool}. I can tell. Everyone can tell.',
|
||||
'The {tool} was a choice.',
|
||||
'There was a whole drawer. You picked the {tool}.',
|
||||
],
|
||||
good: [
|
||||
'Right tool. It shows.',
|
||||
'Correct utensil. Small thing. Not nothing.',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/** MITEY gets its own vocabulary. */
|
||||
const MITEY_CRIMES = [
|
||||
'This is not a scrape of MITEY. This is a hate crime.',
|
||||
'You have applied MITEY the way one applies paint.',
|
||||
'I asked to see the toast THROUGH it. I can see nothing. There is only night.',
|
||||
'That is a decade of MITEY on one slice. Some of us have to live here.',
|
||||
];
|
||||
|
||||
const GRADE_OPENERS: Record<Grade, string[]> = {
|
||||
S: ['Well.', 'I have been doing this for thirty-one years.', 'Hm.'],
|
||||
A: ['Close.', 'Nearly.', 'Good.'],
|
||||
B: ['Adequate.', 'It is toast.', 'Fine.'],
|
||||
C: ['Hm.', 'Right.', 'Well, it exists.'],
|
||||
F: ['No.', 'Absolutely not.', 'Take it away.'],
|
||||
};
|
||||
|
||||
const GRADE_CLOSERS: Record<Grade, string[]> = {
|
||||
S: [
|
||||
'That is the toast. Do it again tomorrow and I will start to worry.',
|
||||
'I have no notes. I dislike having no notes.',
|
||||
'Perfect. Irritatingly perfect.',
|
||||
],
|
||||
A: ['Very nearly the toast.', 'One thing away from excellent.', 'Good work. Not finished work.'],
|
||||
B: ['It will be eaten. That is the bar it cleared.', 'Serviceable.', 'Nobody will complain. Nobody will remember.'],
|
||||
C: ['I have eaten worse. Not recently.', 'Try again.', 'This is why we have a scorecard.'],
|
||||
F: ['I am writing this down.', 'Start again. Properly.', 'The bread deserved better.'],
|
||||
};
|
||||
|
||||
function fill(s: string, order: Order, v: Verdict, tool: string): string {
|
||||
const spread = SPREADS[order.spread];
|
||||
return s
|
||||
.replace('{asked}', order.amount === 'thin' ? 'a scrape' : order.amount)
|
||||
.replace('{got}', v.criteria.find((c) => c.key === 'amount')?.detail.split(' — ')[0] ?? 'that')
|
||||
// Mid-sentence, so lowercase — except MITEY, which is shouted on principle.
|
||||
.replace('{spread}', spread.id === 'mitey' ? 'MITEY' : spread.name.toLowerCase())
|
||||
.replace('{tool}', tool.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Two lines: what went worst, and what went best. Which is what the scorecard
|
||||
* says too — this just says it with feeling.
|
||||
*/
|
||||
export function verdictLines(v: Verdict, order: Order, tool: string, rand: () => number): string[] {
|
||||
const pick = <T>(a: T[]): T => a[Math.floor(rand() * a.length)];
|
||||
const out: string[] = [];
|
||||
out.push(pick(GRADE_OPENERS[v.grade]));
|
||||
|
||||
const mitey = order.spread === 'mitey';
|
||||
const amountBad = (v.criteria.find((c) => c.key === 'amount')?.score ?? 1) < 0.4;
|
||||
if (mitey && amountBad) {
|
||||
out.push(pick(MITEY_CRIMES));
|
||||
} else if (v.worst.score < 0.72) {
|
||||
out.push(fill(pick(BANK[v.worst.key]?.bad ?? ['Hm.']), order, v, tool));
|
||||
}
|
||||
|
||||
if (v.best.score > 0.85 && v.best.key !== v.worst.key) {
|
||||
out.push(fill(pick(BANK[v.best.key]?.good ?? []), order, v, tool));
|
||||
}
|
||||
|
||||
out.push(pick(GRADE_CLOSERS[v.grade]));
|
||||
return out.filter(Boolean);
|
||||
}
|
||||
|
||||
/** Which portrait to show. */
|
||||
export function judgeFace(grade: Grade): string {
|
||||
switch (grade) {
|
||||
case 'S':
|
||||
return 'impressed';
|
||||
case 'A':
|
||||
return 'intrigued';
|
||||
case 'B':
|
||||
return 'neutral';
|
||||
case 'C':
|
||||
return 'disappointed';
|
||||
default:
|
||||
return 'horrified';
|
||||
}
|
||||
}
|
||||
|
||||
export function describeCriterion(c: Criterion): string {
|
||||
return c.detail;
|
||||
}
|
||||
174
src/game/orders.ts
Normal file
@ -0,0 +1,174 @@
|
||||
import type { BreadId } from '../sim/bread';
|
||||
import type { AmountClass, SpreadId } from '../sim/spreads';
|
||||
import type { ToolId } from '../sim/cutlery';
|
||||
|
||||
export interface Order {
|
||||
day: number;
|
||||
bread: BreadId;
|
||||
spread: SpreadId;
|
||||
amount: AmountClass;
|
||||
/** Target mean browning, 0..1. */
|
||||
browning: number;
|
||||
browningName: string;
|
||||
/** The customer specifically does not want char. Some don't care. */
|
||||
noChar: boolean;
|
||||
/** What the drawer will make you find. */
|
||||
tool: ToolId;
|
||||
/** 0 = fridge-hard, 1 = soft. The single cruellest dial in the game. */
|
||||
butterSoftness: number;
|
||||
/** How the customer says it. */
|
||||
text: string;
|
||||
/** Who's asking. */
|
||||
who: string;
|
||||
}
|
||||
|
||||
export const BROWNING_NAMES: [number, string][] = [
|
||||
[0.18, 'barely warmed through'],
|
||||
[0.34, 'light'],
|
||||
[0.5, 'golden'],
|
||||
[0.66, 'well done'],
|
||||
[0.8, 'dark'],
|
||||
];
|
||||
|
||||
export function nameBrowning(v: number): string {
|
||||
let best = BROWNING_NAMES[0];
|
||||
for (const b of BROWNING_NAMES) if (Math.abs(b[0] - v) < Math.abs(best[0] - v)) best = b;
|
||||
return best[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* The handwritten first week. The curve is deliberate: day 1 is butter on white
|
||||
* with a butter knife and butter that's been out all morning — every dial set to
|
||||
* "forgiving". By day 7 you're doing a translucent film of MITEY on thick wet
|
||||
* sourdough with fridge-hard butter... and a steak knife, because the drawer is
|
||||
* the drawer.
|
||||
*/
|
||||
export const DAYS: Order[] = [
|
||||
{
|
||||
day: 1,
|
||||
bread: 'white',
|
||||
spread: 'butter',
|
||||
amount: 'normal',
|
||||
browning: 0.5,
|
||||
browningName: 'golden',
|
||||
noChar: true,
|
||||
tool: 'butter_knife',
|
||||
butterSoftness: 0.85,
|
||||
who: 'Deidre',
|
||||
text: 'Just golden, love. Butter. Normal amount of butter.',
|
||||
},
|
||||
{
|
||||
day: 2,
|
||||
bread: 'white',
|
||||
spread: 'butter',
|
||||
amount: 'thin',
|
||||
browning: 0.66,
|
||||
browningName: 'well done',
|
||||
noChar: true,
|
||||
tool: 'dinner_knife',
|
||||
butterSoftness: 0.55,
|
||||
who: 'Ray',
|
||||
text: "Well done. Scrape of butter — I said a scrape, not a shovel.",
|
||||
},
|
||||
{
|
||||
day: 3,
|
||||
bread: 'multigrain',
|
||||
spread: 'peanut',
|
||||
amount: 'thick',
|
||||
browning: 0.42,
|
||||
browningName: 'light',
|
||||
noChar: true,
|
||||
tool: 'dessert_spoon',
|
||||
butterSoftness: 0.5,
|
||||
who: 'a child',
|
||||
text: 'PEANUT BUTTER. LOTS. Bread barely toasted please.',
|
||||
},
|
||||
{
|
||||
day: 4,
|
||||
bread: 'raisin',
|
||||
spread: 'butter',
|
||||
amount: 'normal',
|
||||
browning: 0.44,
|
||||
browningName: 'light',
|
||||
noChar: true,
|
||||
tool: 'butter_knife',
|
||||
butterSoftness: 0.3,
|
||||
who: 'Deidre',
|
||||
text: "Raisin loaf. Careful — it catches. I'll know if it catches.",
|
||||
},
|
||||
{
|
||||
day: 5,
|
||||
bread: 'white',
|
||||
spread: 'mitey',
|
||||
amount: 'normal',
|
||||
browning: 0.62,
|
||||
browningName: 'well done',
|
||||
noChar: true,
|
||||
tool: 'teaspoon',
|
||||
butterSoftness: 0.4,
|
||||
who: 'Ray',
|
||||
text: 'MITEY. Proper amount. You know what proper means.',
|
||||
},
|
||||
{
|
||||
day: 6,
|
||||
bread: 'sourdough',
|
||||
spread: 'peanut',
|
||||
amount: 'normal',
|
||||
browning: 0.72,
|
||||
browningName: 'dark',
|
||||
noChar: false,
|
||||
tool: 'steak_knife',
|
||||
butterSoftness: 0.25,
|
||||
who: 'a man in a hurry',
|
||||
text: "Dark. Sourdough. Don't care if it catches a bit. Peanut butter, even.",
|
||||
},
|
||||
{
|
||||
day: 7,
|
||||
bread: 'sourdough',
|
||||
spread: 'mitey',
|
||||
amount: 'thin',
|
||||
browning: 0.68,
|
||||
browningName: 'well done',
|
||||
noChar: true,
|
||||
tool: 'steak_knife',
|
||||
butterSoftness: 0.0,
|
||||
who: 'the inspector himself',
|
||||
text: "Thick sourdough, well done, and a *scrape* of MITEY. I want to see the toast through it.",
|
||||
},
|
||||
];
|
||||
|
||||
/** Days 8+ — keep going, with everything cranked. */
|
||||
export function proceduralOrder(day: number, rand: () => number): Order {
|
||||
const breads: BreadId[] = ['white', 'multigrain', 'raisin', 'sourdough'];
|
||||
const spreads: SpreadId[] = ['butter', 'peanut', 'mitey'];
|
||||
const amounts: AmountClass[] = ['thin', 'normal', 'thick'];
|
||||
const tools: ToolId[] = [
|
||||
'butter_knife',
|
||||
'dinner_knife',
|
||||
'steak_knife',
|
||||
'spreader',
|
||||
'dinner_fork',
|
||||
'dessert_fork',
|
||||
'teaspoon',
|
||||
'dessert_spoon',
|
||||
'soup_spoon',
|
||||
];
|
||||
const pick = <T>(a: T[]): T => a[Math.floor(rand() * a.length)];
|
||||
const spread = pick(spreads);
|
||||
const amount: AmountClass = spread === 'mitey' ? (rand() < 0.75 ? 'thin' : 'normal') : pick(amounts);
|
||||
const browning = 0.3 + rand() * 0.5;
|
||||
return {
|
||||
day,
|
||||
bread: pick(breads),
|
||||
spread,
|
||||
amount,
|
||||
browning,
|
||||
browningName: nameBrowning(browning),
|
||||
noChar: rand() < 0.8,
|
||||
tool: pick(tools),
|
||||
// It only gets colder from here.
|
||||
butterSoftness: Math.max(0, 0.4 - (day - 8) * 0.08) * rand(),
|
||||
who: pick(['Deidre', 'Ray', 'a regular', 'someone new', 'the inspector himself']),
|
||||
text: '',
|
||||
};
|
||||
}
|
||||
63
src/main.ts
@ -1,64 +1,21 @@
|
||||
import * as THREE from 'three';
|
||||
import { App, makeLights } from './core/app';
|
||||
import { Slice } from './sim/slice';
|
||||
import { BREADS } from './sim/bread';
|
||||
import { Rng } from './core/rng';
|
||||
import { App, makeEnvironment, makeLights } from './core/app';
|
||||
import { Game } from './game/game';
|
||||
import './style.css';
|
||||
|
||||
const canvas = document.getElementById('c') as HTMLCanvasElement;
|
||||
const app = new App(canvas);
|
||||
makeLights(app.scene);
|
||||
app.scene.background = new THREE.Color(0x1d1613);
|
||||
makeEnvironment(app);
|
||||
app.scene.background = new THREE.Color(0x241a15);
|
||||
|
||||
const bench = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(9, 0.3, 5),
|
||||
new THREE.MeshStandardMaterial({ color: 0x8a5a33, roughness: 0.85 }),
|
||||
);
|
||||
bench.position.y = -0.15;
|
||||
bench.receiveShadow = true;
|
||||
app.scene.add(bench);
|
||||
const game = new Game(app);
|
||||
// Rapier's wasm has to be up before the drawer can be opened, but the toaster
|
||||
// works without it — so boot the game now and let physics catch up.
|
||||
void game.init();
|
||||
|
||||
const slice = new Slice(BREADS.white, new Rng(7));
|
||||
slice.mesh.position.y = 0.12;
|
||||
app.scene.add(slice.mesh);
|
||||
|
||||
// M0 smoke test: a browning gradient + a smear of butter, so the shader gets exercised.
|
||||
for (let y = 0; y < slice.browning.n; y++) {
|
||||
for (let x = 0; x < slice.browning.n; x++) {
|
||||
const i = y * slice.browning.n + x;
|
||||
slice.browning.data[i] = (x / slice.browning.n) * 1.0;
|
||||
const dx = x / slice.spread.n - 0.5;
|
||||
const dy = y / slice.spread.n - 0.55;
|
||||
slice.spread.data[i] = Math.max(0, 0.45 - Math.hypot(dx, dy) * 1.2);
|
||||
}
|
||||
if (import.meta.env.DEV) {
|
||||
void import('./dev').then((m) => m.installDevHarness(app, game));
|
||||
}
|
||||
slice.setSpread({
|
||||
id: 'butter',
|
||||
name: 'Butter',
|
||||
blurb: '',
|
||||
color: [0.97, 0.72, 0.19],
|
||||
gloss: 0.85,
|
||||
opaqueAt: 0.5,
|
||||
bump: 0.1,
|
||||
baseYield: 0.72,
|
||||
tempSoftening: 0.78,
|
||||
viscosity: 0.72,
|
||||
pickup: 0.55,
|
||||
transferRate: 1.5,
|
||||
drag: 0.5,
|
||||
scrapeEase: 1,
|
||||
amounts: { thin: [0.1, 0.22], normal: [0.25, 0.5], thick: [0.55, 0.9] },
|
||||
});
|
||||
slice.touch();
|
||||
slice.sync();
|
||||
|
||||
(window as unknown as { __t: unknown }).__t = { app, slice, THREE };
|
||||
|
||||
app.camera.position.set(0, 1.5, 1.6);
|
||||
app.camera.lookAt(0, 0.1, 0);
|
||||
|
||||
app.onTick((dt) => {
|
||||
slice.mesh.rotation.y += dt * 0.4;
|
||||
});
|
||||
|
||||
app.start();
|
||||
|
||||
49
src/scenes/assets.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import * as THREE from 'three';
|
||||
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
||||
|
||||
const loader = new GLTFLoader();
|
||||
const cache = new Map<string, Promise<THREE.Group>>();
|
||||
|
||||
/**
|
||||
* Load a generated GLB and make it usable without touching the file.
|
||||
*
|
||||
* Meshes that came out of an image-to-3D pipeline arrive at an arbitrary scale,
|
||||
* with an arbitrary pivot, sitting wherever the mesher felt like — so every one
|
||||
* is normalised on load: recentre on its own bounding box, scale so the longest
|
||||
* axis is `size`, and drop it so it sits on y=0. Hand-fixing the assets instead
|
||||
* would mean re-fixing them every time they're regenerated.
|
||||
*/
|
||||
export function loadProp(url: string, size: number): Promise<THREE.Group> {
|
||||
const key = `${url}@${size}`;
|
||||
let p = cache.get(key);
|
||||
if (!p) {
|
||||
p = loader.loadAsync(url).then((gltf) => {
|
||||
const root = gltf.scene;
|
||||
const box = new THREE.Box3().setFromObject(root);
|
||||
const dim = box.getSize(new THREE.Vector3());
|
||||
const centre = box.getCenter(new THREE.Vector3());
|
||||
const longest = Math.max(dim.x, dim.y, dim.z) || 1;
|
||||
const s = size / longest;
|
||||
|
||||
// Wrap it: scaling the loaded scene directly fights whatever transforms
|
||||
// the exporter already baked into it.
|
||||
const wrap = new THREE.Group();
|
||||
root.position.set(-centre.x, -box.min.y, -centre.z);
|
||||
root.scale.setScalar(1);
|
||||
const inner = new THREE.Group();
|
||||
inner.add(root);
|
||||
inner.scale.setScalar(s);
|
||||
wrap.add(inner);
|
||||
|
||||
root.traverse((o) => {
|
||||
if ((o as THREE.Mesh).isMesh) {
|
||||
o.castShadow = true;
|
||||
o.receiveShadow = true;
|
||||
}
|
||||
});
|
||||
return wrap;
|
||||
});
|
||||
cache.set(key, p);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
328
src/scenes/drawer.ts
Normal file
@ -0,0 +1,328 @@
|
||||
import * as THREE from 'three';
|
||||
import RAPIER from '@dimforge/rapier3d-compat';
|
||||
import type { App, View } from '../core/app';
|
||||
import { Rng } from '../core/rng';
|
||||
import { audio } from '../core/audio';
|
||||
import { TOOLS, TOOL_IDS, colliderParts, makeCutleryMesh, type ToolId } from '../sim/cutlery';
|
||||
import { el, Panel } from '../ui/hud';
|
||||
import { toolSilhouette } from '../ui/silhouette';
|
||||
|
||||
const W = 3.1;
|
||||
const D = 2.2;
|
||||
const H = 1.15;
|
||||
const WALL = 0.12;
|
||||
/** Above this you've got it clear of the drawer. */
|
||||
const RIM_Y = H + 0.32;
|
||||
|
||||
interface Piece {
|
||||
id: ToolId;
|
||||
body: RAPIER.RigidBody;
|
||||
mesh: THREE.Group;
|
||||
}
|
||||
|
||||
/**
|
||||
* The cutlery drawer.
|
||||
*
|
||||
* Rules that keep a dozen tangled steel objects stable: compound boxes rather
|
||||
* than trimeshes (a trimesh fork catching another trimesh fork is a solver
|
||||
* nightmare and slow with it), and grabbing pulls with a spring rather than a
|
||||
* fixed joint — so a snagged piece drags its neighbours instead of teleporting
|
||||
* through them, which is the entire feel we're after.
|
||||
*/
|
||||
export class DrawerView implements View {
|
||||
readonly root = new THREE.Group();
|
||||
private world!: RAPIER.World;
|
||||
private pieces: Piece[] = [];
|
||||
private eventQueue!: RAPIER.EventQueue;
|
||||
private rng: Rng;
|
||||
|
||||
private grabbed: Piece | null = null;
|
||||
private grabLocal = new THREE.Vector3();
|
||||
private grabPlane = new THREE.Plane();
|
||||
private target = new THREE.Vector3();
|
||||
private ray = new THREE.Raycaster();
|
||||
private tmp = new THREE.Vector3();
|
||||
private tmpQ = new THREE.Quaternion();
|
||||
|
||||
private accum = 0;
|
||||
|
||||
private panel: Panel;
|
||||
private cardEl!: HTMLElement;
|
||||
private timerFill!: HTMLElement;
|
||||
private hintEl!: HTMLElement;
|
||||
|
||||
/** Called with whatever you actually pulled out. */
|
||||
onPicked: ((tool: ToolId) => void) | null = null;
|
||||
/** How warm the toast still is, 0..1 — drawn as the timer. */
|
||||
warmth = 1;
|
||||
|
||||
constructor(
|
||||
private app: App,
|
||||
seed = 7,
|
||||
) {
|
||||
this.rng = new Rng(seed);
|
||||
this.buildScenery();
|
||||
this.panel = new Panel('drawer-panel');
|
||||
this.buildUi();
|
||||
this.panel.hide();
|
||||
}
|
||||
|
||||
private buildUi(): void {
|
||||
const p = this.panel.root;
|
||||
const card = el('div', 'drawer-card', p);
|
||||
el('div', 'drawer-lbl', card, 'FIND THE');
|
||||
this.cardEl = el('div', 'drawer-sil', card);
|
||||
const timer = el('div', 'drawer-timer', card);
|
||||
const bar = el('div', 'timer-bar', timer);
|
||||
this.timerFill = el('div', 'timer-fill', bar);
|
||||
el('div', 'timer-lbl', timer, 'your toast is going cold');
|
||||
this.hintEl = el('div', 'drawer-hint', p, 'drag it out of the drawer');
|
||||
}
|
||||
|
||||
private buildScenery(): void {
|
||||
const wood = new THREE.MeshStandardMaterial({ color: 0x6f4c2e, roughness: 0.8 });
|
||||
const inner = new THREE.MeshStandardMaterial({ color: 0x8a6740, roughness: 0.9 });
|
||||
const floor = new THREE.Mesh(new THREE.BoxGeometry(W, WALL, D), inner);
|
||||
floor.position.y = -WALL / 2;
|
||||
floor.receiveShadow = true;
|
||||
this.root.add(floor);
|
||||
const walls: [number, number, number, number, number, number][] = [
|
||||
[W, H, WALL, 0, H / 2, -D / 2],
|
||||
[W, H, WALL, 0, H / 2, D / 2],
|
||||
[WALL, H, D, -W / 2, H / 2, 0],
|
||||
[WALL, H, D, W / 2, H / 2, 0],
|
||||
];
|
||||
for (const [w, h, d, x, y, z] of walls) {
|
||||
const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), wood);
|
||||
m.position.set(x, y, z);
|
||||
m.receiveShadow = true;
|
||||
m.castShadow = true;
|
||||
this.root.add(m);
|
||||
}
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
await RAPIER.init();
|
||||
this.world = new RAPIER.World({ x: 0, y: -9.81, z: 0 });
|
||||
this.eventQueue = new RAPIER.EventQueue(true);
|
||||
|
||||
const fixed = (hx: number, hy: number, hz: number, x: number, y: number, z: number) => {
|
||||
const rb = this.world.createRigidBody(RAPIER.RigidBodyDesc.fixed().setTranslation(x, y, z));
|
||||
this.world.createCollider(
|
||||
RAPIER.ColliderDesc.cuboid(hx, hy, hz).setFriction(0.7).setRestitution(0.04),
|
||||
rb,
|
||||
);
|
||||
};
|
||||
fixed(W / 2, WALL / 2, D / 2, 0, -WALL / 2, 0);
|
||||
fixed(W / 2, H, WALL / 2, 0, H, -D / 2 - WALL / 2);
|
||||
fixed(W / 2, H, WALL / 2, 0, H, D / 2 + WALL / 2);
|
||||
fixed(WALL / 2, H, D / 2, -W / 2 - WALL / 2, H, 0);
|
||||
fixed(WALL / 2, H, D / 2, W / 2 + WALL / 2, H, 0);
|
||||
}
|
||||
|
||||
/** Fill the drawer and let it settle into a mess. */
|
||||
reset(want: ToolId, extras = 7): void {
|
||||
for (const p of this.pieces) {
|
||||
this.world.removeRigidBody(p.body);
|
||||
this.root.remove(p.mesh);
|
||||
}
|
||||
this.pieces = [];
|
||||
this.grabbed = null;
|
||||
|
||||
// The wanted piece, plus a cast chosen to be confusably similar to it.
|
||||
const ids: ToolId[] = [want];
|
||||
const kin = TOOL_IDS.filter((t) => t !== want && TOOLS[t].kind === TOOLS[want].kind);
|
||||
for (const k of kin) ids.push(k);
|
||||
const rest = TOOL_IDS.filter((t) => !ids.includes(t));
|
||||
while (ids.length < extras + 1 && rest.length) {
|
||||
ids.push(rest.splice(Math.floor(this.rng.next() * rest.length), 1)[0]);
|
||||
}
|
||||
|
||||
ids.forEach((id, i) => this.spawn(id, i));
|
||||
this.cardEl.innerHTML = toolSilhouette(TOOLS[want]);
|
||||
el('div', 'sil-name', this.cardEl, TOOLS[want].name);
|
||||
|
||||
// Settle offline so the player opens the drawer on an existing mess rather
|
||||
// than watching the cutlery rain in.
|
||||
for (let i = 0; i < 300; i++) this.world.step();
|
||||
this.syncMeshes();
|
||||
}
|
||||
|
||||
private spawn(id: ToolId, i: number): void {
|
||||
const tool = TOOLS[id];
|
||||
// Pass the quaternion itself, not .toArray(): Rapier wants {x,y,z,w}, and an
|
||||
// array reads back as undefined on every axis, which is NaN in the solver and
|
||||
// a wasm panic one step later.
|
||||
const q = new THREE.Quaternion().setFromEuler(
|
||||
new THREE.Euler(
|
||||
this.rng.range(-0.5, 0.5),
|
||||
this.rng.range(0, Math.PI * 2),
|
||||
this.rng.range(-2.6, 2.6),
|
||||
),
|
||||
);
|
||||
const body = this.world.createRigidBody(
|
||||
RAPIER.RigidBodyDesc.dynamic()
|
||||
// Drop them down a narrow column in the middle so they land on top of
|
||||
// one another rather than politely finding their own patch of floor.
|
||||
.setTranslation(
|
||||
this.rng.range(-0.5, 0.5),
|
||||
0.7 + i * 0.5,
|
||||
this.rng.range(-0.35, 0.35),
|
||||
)
|
||||
.setRotation({ x: q.x, y: q.y, z: q.z, w: q.w })
|
||||
.setLinearDamping(0.35)
|
||||
.setAngularDamping(0.45),
|
||||
);
|
||||
for (const part of colliderParts(tool)) {
|
||||
this.world.createCollider(
|
||||
RAPIER.ColliderDesc.cuboid(...part.half)
|
||||
.setTranslation(...part.pos)
|
||||
// Steel. Heavy, slippery, and it rings.
|
||||
.setDensity(7.8)
|
||||
.setFriction(0.28)
|
||||
.setRestitution(0.12)
|
||||
.setActiveEvents(RAPIER.ActiveEvents.CONTACT_FORCE_EVENTS)
|
||||
.setContactForceEventThreshold(2.5),
|
||||
body,
|
||||
);
|
||||
}
|
||||
const mesh = makeCutleryMesh(tool);
|
||||
this.root.add(mesh);
|
||||
this.pieces.push({ id, body, mesh });
|
||||
}
|
||||
|
||||
private syncMeshes(): void {
|
||||
for (const p of this.pieces) {
|
||||
const t = p.body.translation();
|
||||
const r = p.body.rotation();
|
||||
p.mesh.position.set(t.x, t.y, t.z);
|
||||
p.mesh.quaternion.set(r.x, r.y, r.z, r.w);
|
||||
}
|
||||
}
|
||||
|
||||
enter(): void {
|
||||
this.panel.show();
|
||||
this.app.camera.position.set(0, 3.3, 2.35);
|
||||
this.app.camera.lookAt(0, 0.15, 0.05);
|
||||
this.hintEl.textContent = 'drag a piece up and out · ENTER takes what you\'re holding';
|
||||
}
|
||||
|
||||
exit(): void {
|
||||
this.panel.hide();
|
||||
this.grabbed = null;
|
||||
}
|
||||
|
||||
update(dt: number): void {
|
||||
const inp = this.app.input;
|
||||
this.app.camera.position.set(0, 3.3, 2.35);
|
||||
this.app.camera.lookAt(0, 0.15, 0.05);
|
||||
|
||||
this.ray.setFromCamera(inp.ndc, this.app.camera);
|
||||
|
||||
if (inp.down && !this.grabbed) this.tryGrab();
|
||||
if (!inp.down) this.grabbed = null;
|
||||
if (this.grabbed) this.dragGrabbed();
|
||||
|
||||
// Fixed-step the solver so a frame hitch can't explode the tangle.
|
||||
this.accum += dt;
|
||||
let steps = 0;
|
||||
while (this.accum >= 1 / 60 && steps < 4) {
|
||||
this.world.step(this.eventQueue);
|
||||
this.accum -= 1 / 60;
|
||||
steps++;
|
||||
this.drainContacts();
|
||||
}
|
||||
this.syncMeshes();
|
||||
|
||||
this.timerFill.style.width = `${Math.max(0, this.warmth * 100)}%`;
|
||||
this.timerFill.style.background = this.warmth > 0.5 ? '#e8a53a' : this.warmth > 0.25 ? '#d2703a' : '#c0392b';
|
||||
|
||||
// Lifted clear of the rim? That's your pick.
|
||||
if (this.grabbed && this.grabbed.body.translation().y > RIM_Y) {
|
||||
const got = this.grabbed.id;
|
||||
this.grabbed = null;
|
||||
this.onPicked?.(got);
|
||||
return;
|
||||
}
|
||||
if (inp.justPressed('Enter') && this.grabbed) {
|
||||
const got = this.grabbed.id;
|
||||
this.grabbed = null;
|
||||
this.onPicked?.(got);
|
||||
}
|
||||
}
|
||||
|
||||
private drainContacts(): void {
|
||||
this.eventQueue.drainContactForceEvents((e) => {
|
||||
const mag = e.totalForceMagnitude();
|
||||
if (mag > 3) audio.clatter(Math.min(1, mag / 260), 0.8 + this.rng.next() * 0.5);
|
||||
});
|
||||
}
|
||||
|
||||
private tryGrab(): void {
|
||||
// Raycast the visual meshes: they're what the player can see, and the
|
||||
// colliders are a coarse stand-in for them.
|
||||
const hits = this.ray.intersectObjects(
|
||||
this.pieces.map((p) => p.mesh),
|
||||
true,
|
||||
);
|
||||
if (!hits.length) return;
|
||||
let obj: THREE.Object3D | null = hits[0].object;
|
||||
let piece: Piece | undefined;
|
||||
while (obj && !piece) {
|
||||
piece = this.pieces.find((p) => p.mesh === obj);
|
||||
obj = obj.parent;
|
||||
}
|
||||
if (!piece) return;
|
||||
this.grabbed = piece;
|
||||
piece.body.wakeUp();
|
||||
// Remember where on the piece we took hold, in its own frame.
|
||||
this.grabLocal.copy(hits[0].point);
|
||||
piece.mesh.worldToLocal(this.grabLocal);
|
||||
// Drag on a VERTICAL plane through the grab point, facing the camera
|
||||
// horizontally. A camera-facing plane sounds right but the camera looks down
|
||||
// into the drawer, so dragging to the top of the screen only lifts a little
|
||||
// and you can't physically get anything over the rim. Killing the Y of the
|
||||
// normal makes up-screen mean up.
|
||||
this.app.camera.getWorldDirection(this.tmp);
|
||||
this.tmp.y = 0;
|
||||
this.tmp.normalize();
|
||||
this.grabPlane.setFromNormalAndCoplanarPoint(this.tmp, hits[0].point);
|
||||
audio.clatter(0.25, 1.2);
|
||||
}
|
||||
|
||||
private dragGrabbed(): void {
|
||||
const p = this.grabbed!;
|
||||
if (!this.ray.ray.intersectPlane(this.grabPlane, this.target)) return;
|
||||
|
||||
// Where the grab point is right now, in world space.
|
||||
const t = p.body.translation();
|
||||
const r = p.body.rotation();
|
||||
this.tmp.copy(this.grabLocal).applyQuaternion(this.tmpQ.set(r.x, r.y, r.z, r.w));
|
||||
const px = t.x + this.tmp.x;
|
||||
const py = t.y + this.tmp.y;
|
||||
const pz = t.z + this.tmp.z;
|
||||
|
||||
// A spring, not a joint: a snagged piece has to fight the ones on top of it
|
||||
// and lose, rather than tunnel through them.
|
||||
const v = p.body.linvel();
|
||||
const m = p.body.mass();
|
||||
const K = 165 * m;
|
||||
const C = 13 * m;
|
||||
const fx = (this.target.x - px) * K - v.x * C;
|
||||
const fy = (this.target.y - py) * K - v.y * C;
|
||||
const fz = (this.target.z - pz) * K - v.z * C;
|
||||
const cap = 90 * m;
|
||||
const mag = Math.hypot(fx, fy, fz);
|
||||
const s = mag > cap ? cap / mag : 1;
|
||||
p.body.wakeUp();
|
||||
p.body.addForceAtPoint(
|
||||
{ x: fx * s, y: fy * s, z: fz * s },
|
||||
{ x: px, y: py, z: pz },
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.panel.dispose();
|
||||
}
|
||||
}
|
||||
169
src/scenes/judge.ts
Normal file
@ -0,0 +1,169 @@
|
||||
import * as THREE from 'three';
|
||||
import type { App, View } from '../core/app';
|
||||
import type { Slice } from '../sim/slice';
|
||||
import type { Order } from '../game/orders';
|
||||
import type { Verdict } from '../game/judging';
|
||||
import { judgeFace, verdictLines } from '../game/lines';
|
||||
import { TOOLS, type ToolId } from '../sim/cutlery';
|
||||
import { clear, el, Panel } from '../ui/hud';
|
||||
|
||||
/**
|
||||
* The scorecard. This is the screen the game is actually about: every number
|
||||
* points at something the player did, and the toast turns slowly under a light
|
||||
* so they can see it for themselves.
|
||||
*/
|
||||
export class JudgeView implements View {
|
||||
readonly root = new THREE.Group();
|
||||
private pedestal: THREE.Group;
|
||||
private slice: Slice | null = null;
|
||||
private spin = 0;
|
||||
private heatmap: 0 | 1 | 2 = 0;
|
||||
|
||||
private panel: Panel;
|
||||
private cardEl!: HTMLElement;
|
||||
private linesEl!: HTMLElement;
|
||||
private stampEl!: HTMLElement;
|
||||
private faceEl!: HTMLImageElement;
|
||||
private orderEl!: HTMLElement;
|
||||
private nextBtn!: HTMLButtonElement;
|
||||
private viewBtn!: HTMLButtonElement;
|
||||
|
||||
onNext: (() => void) | null = null;
|
||||
|
||||
constructor(private app: App) {
|
||||
this.pedestal = new THREE.Group();
|
||||
const top = new THREE.Mesh(
|
||||
new THREE.CylinderGeometry(1.05, 1.05, 0.12, 48),
|
||||
new THREE.MeshStandardMaterial({ color: 0x2a2320, roughness: 0.55 }),
|
||||
);
|
||||
top.receiveShadow = true;
|
||||
this.pedestal.add(top);
|
||||
const stem = new THREE.Mesh(
|
||||
new THREE.CylinderGeometry(0.5, 0.72, 0.9, 32),
|
||||
new THREE.MeshStandardMaterial({ color: 0x1d1815, roughness: 0.7 }),
|
||||
);
|
||||
stem.position.y = -0.5;
|
||||
this.pedestal.add(stem);
|
||||
this.pedestal.position.set(0, 0.9, 0);
|
||||
this.root.add(this.pedestal);
|
||||
|
||||
const spot = new THREE.SpotLight(0xfff0d8, 90, 12, 0.5, 0.55, 1.6);
|
||||
spot.position.set(1.4, 4.2, 2.2);
|
||||
spot.target = this.pedestal;
|
||||
spot.castShadow = true;
|
||||
spot.shadow.mapSize.set(1024, 1024);
|
||||
this.root.add(spot);
|
||||
const rim = new THREE.SpotLight(0x88a8ff, 40, 12, 0.6, 0.7, 1.6);
|
||||
rim.position.set(-2.6, 2.4, -2.2);
|
||||
rim.target = this.pedestal;
|
||||
this.root.add(rim);
|
||||
|
||||
this.panel = new Panel('judge-panel');
|
||||
this.buildUi();
|
||||
this.panel.hide();
|
||||
}
|
||||
|
||||
private buildUi(): void {
|
||||
const p = this.panel.root;
|
||||
const left = el('div', 'judge-left', p);
|
||||
this.faceEl = el('img', 'judge-face', left);
|
||||
this.faceEl.alt = '';
|
||||
this.linesEl = el('div', 'judge-lines', left);
|
||||
|
||||
const right = el('div', 'judge-right', p);
|
||||
this.orderEl = el('div', 'judge-order', right);
|
||||
this.cardEl = el('div', 'judge-card', right);
|
||||
const foot = el('div', 'judge-foot', right);
|
||||
this.stampEl = el('div', 'judge-stamp', foot);
|
||||
const btns = el('div', 'judge-btns', foot);
|
||||
this.viewBtn = el('button', 'btn ghost', btns, 'HEATMAP');
|
||||
this.viewBtn.addEventListener('click', () => this.cycleHeatmap());
|
||||
this.nextBtn = el('button', 'btn', btns, 'NEXT ORDER');
|
||||
this.nextBtn.addEventListener('click', () => this.onNext?.());
|
||||
}
|
||||
|
||||
private cycleHeatmap(): void {
|
||||
this.heatmap = ((this.heatmap + 1) % 3) as 0 | 1 | 2;
|
||||
this.slice?.setHeatmap(this.heatmap);
|
||||
this.viewBtn.textContent = ['HEATMAP', 'BROWNING', 'SPREAD'][this.heatmap];
|
||||
}
|
||||
|
||||
show(slice: Slice, verdict: Verdict, order: Order, tool: ToolId, rand: () => number): void {
|
||||
this.slice = slice;
|
||||
this.spin = 0;
|
||||
this.heatmap = 0;
|
||||
slice.setHeatmap(0);
|
||||
slice.setPresentation(true);
|
||||
this.viewBtn.textContent = 'HEATMAP';
|
||||
|
||||
// Take the toast off the bench and put it under the light.
|
||||
this.pedestal.add(slice.mesh);
|
||||
slice.mesh.position.set(0, 0.06 + slice.halfThickness, 0);
|
||||
slice.mesh.rotation.set(0, 0, 0);
|
||||
slice.mesh.scale.setScalar(1.55);
|
||||
|
||||
this.orderEl.textContent = `Day ${order.day} · ${order.who} asked for ${order.browningName}, ${order.amount} ${order.spread === 'mitey' ? 'MITEY' : order.spread}`;
|
||||
|
||||
clear(this.cardEl);
|
||||
for (const c of verdict.criteria) {
|
||||
const row = el('div', 'crit', this.cardEl);
|
||||
el('span', 'crit-name', row, c.label);
|
||||
const bar = el('span', 'crit-bar', row);
|
||||
const fill = el('span', 'crit-fill', bar);
|
||||
fill.style.width = `${Math.round(c.score * 100)}%`;
|
||||
fill.style.background = c.score > 0.8 ? '#6cbf6c' : c.score > 0.5 ? '#e8a53a' : '#c0392b';
|
||||
el('span', 'crit-detail', row, c.detail);
|
||||
}
|
||||
const totalRow = el('div', 'crit total', this.cardEl);
|
||||
el('span', 'crit-name', totalRow, 'TOTAL');
|
||||
el('span', 'crit-bar', totalRow);
|
||||
el('span', 'crit-detail', totalRow, `${verdict.total.toFixed(1)} / 10`);
|
||||
|
||||
clear(this.linesEl);
|
||||
const lines = verdictLines(verdict, order, TOOLS[tool].name, rand);
|
||||
for (const l of lines) el('p', undefined, this.linesEl, l);
|
||||
|
||||
this.faceEl.src = `/assets/img/judge_${judgeFace(verdict.grade)}.png`;
|
||||
this.stampEl.textContent = verdict.grade;
|
||||
this.stampEl.className = `judge-stamp grade-${verdict.grade}`;
|
||||
// re-trigger the stamp animation
|
||||
this.stampEl.style.animation = 'none';
|
||||
void this.stampEl.offsetHeight;
|
||||
this.stampEl.style.animation = '';
|
||||
}
|
||||
|
||||
/** Hand the toast back so the kitchen can bin it. */
|
||||
release(): Slice | null {
|
||||
const s = this.slice;
|
||||
if (s) {
|
||||
s.mesh.scale.setScalar(1);
|
||||
s.setHeatmap(0);
|
||||
s.setPresentation(false);
|
||||
this.pedestal.remove(s.mesh);
|
||||
}
|
||||
this.slice = null;
|
||||
return s;
|
||||
}
|
||||
|
||||
enter(): void {
|
||||
this.panel.show();
|
||||
this.app.camera.position.set(0.15, 2.35, 3.5);
|
||||
this.app.camera.lookAt(0, 1.15, 0);
|
||||
this.app.shake = 0.05;
|
||||
}
|
||||
|
||||
exit(): void {
|
||||
this.panel.hide();
|
||||
}
|
||||
|
||||
update(dt: number): void {
|
||||
this.spin += dt * 0.42;
|
||||
if (this.slice) this.slice.mesh.rotation.y = this.spin;
|
||||
this.app.camera.position.set(0.15, 2.35, 3.5);
|
||||
this.app.camera.lookAt(0, 1.15, 0);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.panel.dispose();
|
||||
}
|
||||
}
|
||||
484
src/scenes/kitchen.ts
Normal file
@ -0,0 +1,484 @@
|
||||
import * as THREE from 'three';
|
||||
import type { App, View } from '../core/app';
|
||||
import { Rng } from '../core/rng';
|
||||
import { Slice } from '../sim/slice';
|
||||
import { BREADS, type BreadId } from '../sim/bread';
|
||||
import { buildHeatMap, readToast, smellCue, toastStep } from '../sim/toasting';
|
||||
import {
|
||||
SPREADS,
|
||||
SCRAPE_ANGLE,
|
||||
effectiveYield,
|
||||
pressureFromAngle,
|
||||
type SpreadDef,
|
||||
type SpreadId,
|
||||
} from '../sim/spreads';
|
||||
import { dip, newKnife, stroke, type Knife, type StrokeFx } from '../sim/spreading';
|
||||
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();
|
||||
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 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();
|
||||
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';
|
||||
});
|
||||
|
||||
// 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.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.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)));
|
||||
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 {
|
||||
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?.();
|
||||
}
|
||||
|
||||
/** Whatever you managed to pull out of the drawer is what you're spreading with. */
|
||||
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) dip(this.knife, def);
|
||||
this.knife.hasPrev = 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.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';
|
||||
|
||||
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 = '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();
|
||||
}
|
||||
}
|
||||
197
src/scenes/props.ts
Normal file
@ -0,0 +1,197 @@
|
||||
import * as THREE from 'three';
|
||||
|
||||
export function roundedRect(w: number, h: number, r: number): THREE.Shape {
|
||||
const hw = w / 2;
|
||||
const hh = h / 2;
|
||||
const s = new THREE.Shape();
|
||||
s.moveTo(-hw + r, -hh);
|
||||
s.lineTo(hw - r, -hh);
|
||||
s.quadraticCurveTo(hw, -hh, hw, -hh + r);
|
||||
s.lineTo(hw, hh - r);
|
||||
s.quadraticCurveTo(hw, hh, hw - r, hh);
|
||||
s.lineTo(-hw + r, hh);
|
||||
s.quadraticCurveTo(-hw, hh, -hw, hh - r);
|
||||
s.lineTo(-hw, -hh + r);
|
||||
s.quadraticCurveTo(-hw, -hh, -hw + r, -hh);
|
||||
s.closePath();
|
||||
return s;
|
||||
}
|
||||
|
||||
const CREAM = 0xefdfbc;
|
||||
const CHROME = 0xc9ced6;
|
||||
const DARK = 0x0d0b0a;
|
||||
|
||||
export interface Toaster {
|
||||
root: THREE.Group;
|
||||
lever: THREE.Group;
|
||||
dial: THREE.Group;
|
||||
/** World position of the slot mouth. */
|
||||
slotY: number;
|
||||
slotZ: number;
|
||||
/** Glowing elements inside; intensity 0..1. */
|
||||
setGlow(v: number): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A retro two-slot toaster. Procedural for now: the generated GLB is prettier
|
||||
* but this one has a lever we can actually drive, and the shape is the hero of
|
||||
* the toasting scene either way.
|
||||
*/
|
||||
export function makeToaster(): Toaster {
|
||||
const root = new THREE.Group();
|
||||
|
||||
const bodyMat = new THREE.MeshStandardMaterial({ color: CREAM, roughness: 0.62, metalness: 0.06 });
|
||||
const chromeMat = new THREE.MeshStandardMaterial({ color: CHROME, roughness: 0.22, metalness: 0.9 });
|
||||
const darkMat = new THREE.MeshStandardMaterial({ color: DARK, roughness: 0.95 });
|
||||
|
||||
// A slice of bread is ~11cm and this game measures in slices: 1 unit ~ 11cm.
|
||||
// So a 28x18x15cm toaster is this big, and it dwarfs the bread. It should.
|
||||
const W = 2.52;
|
||||
const H = 1.62;
|
||||
const D = 1.36;
|
||||
const body = new THREE.Mesh(
|
||||
new THREE.ExtrudeGeometry(roundedRect(W, H, 0.42), {
|
||||
depth: D,
|
||||
bevelEnabled: true,
|
||||
bevelThickness: 0.05,
|
||||
bevelSize: 0.05,
|
||||
bevelSegments: 3,
|
||||
curveSegments: 20,
|
||||
}),
|
||||
bodyMat,
|
||||
);
|
||||
body.geometry.center();
|
||||
body.position.y = H / 2 + 0.04;
|
||||
body.castShadow = true;
|
||||
body.receiveShadow = true;
|
||||
root.add(body);
|
||||
|
||||
// chrome cap with the slot cut into it
|
||||
const cap = new THREE.Mesh(new THREE.BoxGeometry(W * 0.84, 0.07, D * 0.88), chromeMat);
|
||||
cap.position.set(0, H + 0.04, 0);
|
||||
cap.castShadow = true;
|
||||
root.add(cap);
|
||||
|
||||
const slot = new THREE.Mesh(new THREE.BoxGeometry(W * 0.52, 0.2, 0.3), darkMat);
|
||||
slot.position.set(0, H + 0.02, 0);
|
||||
root.add(slot);
|
||||
|
||||
// elements: a few vertical bars either side of the slot, they glow when hot
|
||||
const glowMat = new THREE.MeshStandardMaterial({
|
||||
color: 0x3a1508,
|
||||
emissive: new THREE.Color(0xff3a08),
|
||||
emissiveIntensity: 0,
|
||||
roughness: 0.7,
|
||||
});
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const x = (i / 4 - 0.5) * W * 0.46;
|
||||
for (const z of [-0.15, 0.15]) {
|
||||
const bar = new THREE.Mesh(new THREE.BoxGeometry(0.05, 0.9, 0.028), glowMat);
|
||||
bar.position.set(x, H - 0.52, z);
|
||||
root.add(bar);
|
||||
}
|
||||
}
|
||||
|
||||
// feet
|
||||
const footMat = new THREE.MeshStandardMaterial({ color: 0x1a1a1a, roughness: 0.9 });
|
||||
for (const sx of [-1, 1]) {
|
||||
for (const sz of [-1, 1]) {
|
||||
const foot = new THREE.Mesh(new THREE.CylinderGeometry(0.1, 0.11, 0.09, 12), footMat);
|
||||
foot.position.set(sx * W * 0.36, 0.045, sz * D * 0.3);
|
||||
root.add(foot);
|
||||
}
|
||||
}
|
||||
|
||||
// browning dial on the right cheek
|
||||
const dial = new THREE.Group();
|
||||
const knob = new THREE.Mesh(new THREE.CylinderGeometry(0.19, 0.19, 0.09, 24), chromeMat);
|
||||
knob.rotation.z = Math.PI / 2;
|
||||
knob.castShadow = true;
|
||||
dial.add(knob);
|
||||
const pointer = new THREE.Mesh(new THREE.BoxGeometry(0.035, 0.16, 0.05), darkMat);
|
||||
pointer.position.set(0.05, 0.08, 0);
|
||||
dial.add(pointer);
|
||||
dial.position.set(W / 2 + 0.015, H * 0.42, 0.34);
|
||||
root.add(dial);
|
||||
|
||||
// lever on a track
|
||||
const lever = new THREE.Group();
|
||||
const stem = new THREE.Mesh(new THREE.BoxGeometry(0.11, 0.11, 0.26), chromeMat);
|
||||
stem.position.z = 0.12;
|
||||
lever.add(stem);
|
||||
const grip = new THREE.Mesh(new THREE.BoxGeometry(0.18, 0.34, 0.13), bodyMat);
|
||||
grip.position.z = 0.3;
|
||||
grip.castShadow = true;
|
||||
lever.add(grip);
|
||||
lever.position.set(-W / 2 + 0.05, H * 0.72, D / 2 - 0.05);
|
||||
root.add(lever);
|
||||
|
||||
const track = new THREE.Mesh(new THREE.BoxGeometry(0.05, 0.82, 0.07), darkMat);
|
||||
track.position.set(-W / 2 + 0.012, H * 0.55, D / 2 + 0.05);
|
||||
root.add(track);
|
||||
|
||||
return {
|
||||
root,
|
||||
lever,
|
||||
dial,
|
||||
slotY: H + 0.04,
|
||||
slotZ: 0,
|
||||
setGlow(v: number) {
|
||||
glowMat.emissiveIntensity = v * 3.2;
|
||||
glowMat.color.setRGB(0.23 + v * 0.3, 0.08, 0.03);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Built from primitives rather than a lathe: a lathe profile that touches the
|
||||
* axis collapses a whole ring of vertices onto one point, and the degenerate
|
||||
* triangles there render as a starburst of garbage normals.
|
||||
*/
|
||||
export function makePlate(): THREE.Group {
|
||||
const g = new THREE.Group();
|
||||
const mat = new THREE.MeshStandardMaterial({ color: 0xf4f2ec, roughness: 0.3, metalness: 0.02 });
|
||||
const well = new THREE.Mesh(new THREE.CylinderGeometry(0.84, 0.74, 0.045, 56), mat);
|
||||
well.position.y = 0.022;
|
||||
well.castShadow = true;
|
||||
well.receiveShadow = true;
|
||||
g.add(well);
|
||||
const rim = new THREE.Mesh(new THREE.TorusGeometry(0.84, 0.036, 10, 56), mat);
|
||||
rim.rotation.x = Math.PI / 2;
|
||||
rim.position.y = 0.05;
|
||||
rim.castShadow = true;
|
||||
rim.receiveShadow = true;
|
||||
g.add(rim);
|
||||
return g;
|
||||
}
|
||||
|
||||
export function makeBench(): THREE.Group {
|
||||
const g = new THREE.Group();
|
||||
const loader = new THREE.TextureLoader();
|
||||
|
||||
const wood = loader.load('/assets/img/bench_wood.png');
|
||||
wood.wrapS = wood.wrapT = THREE.RepeatWrapping;
|
||||
wood.repeat.set(4, 2);
|
||||
// The texture was generated as artwork, i.e. sRGB. Saying so is what keeps it
|
||||
// from washing out once it's lit.
|
||||
wood.colorSpace = THREE.SRGBColorSpace;
|
||||
wood.anisotropy = 8;
|
||||
const top = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(20, 0.5, 9),
|
||||
new THREE.MeshStandardMaterial({ map: wood, color: 0xbb9068, roughness: 0.68 }),
|
||||
);
|
||||
top.position.y = -0.25;
|
||||
top.receiveShadow = true;
|
||||
g.add(top);
|
||||
|
||||
// Splashback: a blurred kitchen so the scene has a horizon instead of fog.
|
||||
const back = loader.load('/assets/img/kitchen_backdrop.png');
|
||||
back.colorSpace = THREE.SRGBColorSpace;
|
||||
const wall = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(22, 8),
|
||||
new THREE.MeshBasicMaterial({ map: back, color: 0x6a6258 }),
|
||||
);
|
||||
wall.position.set(0, 3.2, -4.4);
|
||||
g.add(wall);
|
||||
return g;
|
||||
}
|
||||
93
src/scenes/spreadrig.ts
Normal file
@ -0,0 +1,93 @@
|
||||
import * as THREE from 'three';
|
||||
import { makeCutleryMesh, type Tool } from '../sim/cutlery';
|
||||
import { loadProp } from './assets';
|
||||
import type { SpreadDef } from '../sim/spreads';
|
||||
|
||||
/**
|
||||
* The knife you actually hold. Two nested groups so the tilt is about the blade,
|
||||
* not about the mouse cursor: `yaw` sets how the knife is held, `tilt` is the
|
||||
* one control the whole mechanic hangs off.
|
||||
*/
|
||||
export class KnifeRig {
|
||||
readonly root = new THREE.Group();
|
||||
private yaw = new THREE.Group();
|
||||
private tilt = new THREE.Group();
|
||||
private mesh: THREE.Group;
|
||||
private blob: THREE.Mesh;
|
||||
private blobMat: THREE.MeshStandardMaterial;
|
||||
private bladeCentre: number;
|
||||
|
||||
constructor(tool: Tool) {
|
||||
this.bladeCentre = 0.12 + tool.headL * 0.5;
|
||||
this.mesh = makeCutleryMesh(tool);
|
||||
// Slide the piece so the middle of its head sits at the rig's origin — that
|
||||
// point is the contact patch, and it's what we rotate about.
|
||||
this.mesh.position.z = -this.bladeCentre;
|
||||
this.tilt.add(this.mesh);
|
||||
this.yaw.add(this.tilt);
|
||||
this.root.add(this.yaw);
|
||||
// Held like a right-handed person spreading: handle down toward the player.
|
||||
this.yaw.rotation.y = -0.62;
|
||||
|
||||
// The load on the blade, so you can see when you've run out.
|
||||
const blobGeo = new THREE.SphereGeometry(1, 14, 10);
|
||||
blobGeo.scale(tool.headW * 0.42, 0.035, tool.headL * 0.34);
|
||||
this.blobMat = new THREE.MeshStandardMaterial({ color: 0xf0c040, roughness: 0.32 });
|
||||
this.blob = new THREE.Mesh(blobGeo, this.blobMat);
|
||||
this.blob.position.set(0, 0.028, 0);
|
||||
this.tilt.add(this.blob);
|
||||
}
|
||||
|
||||
setTool(tool: Tool): void {
|
||||
this.tilt.remove(this.mesh);
|
||||
this.mesh = makeCutleryMesh(tool);
|
||||
this.bladeCentre = 0.12 + tool.headL * 0.5;
|
||||
this.mesh.position.z = -this.bladeCentre;
|
||||
this.tilt.add(this.mesh);
|
||||
}
|
||||
|
||||
setSpread(def: SpreadDef | null): void {
|
||||
if (def) this.blobMat.color.setRGB(def.color[0], def.color[1], def.color[2]);
|
||||
}
|
||||
|
||||
/** angle 0..1; load 0..1 */
|
||||
update(pos: THREE.Vector3, angle: number, load: number, topY: number): void {
|
||||
// Tilt lifts the handle and drops the blade onto its edge. At angle 1 the
|
||||
// knife is all but vertical, which is exactly how you scrape burnt toast.
|
||||
const tiltRad = angle * 1.38;
|
||||
this.tilt.rotation.x = -tiltRad;
|
||||
// Ride the contact patch just above the toast whatever the tilt is doing.
|
||||
const lift = Math.sin(tiltRad) * 0.045 + 0.012;
|
||||
this.root.position.set(pos.x, Math.max(topY, pos.y) + lift, pos.z);
|
||||
this.blob.visible = load > 0.01;
|
||||
const s = 0.35 + Math.min(1, load / 0.6) * 0.75;
|
||||
this.blob.scale.set(s, Math.min(1.6, s * (0.6 + load)), s);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The pot you dip into — one per order, because you only ever get one spread.
|
||||
*
|
||||
* The vessel is a generated GLB; the thing you actually dip into is an invisible
|
||||
* box at its mouth. Raycasting the generated mesh directly would be at the mercy
|
||||
* of whatever the mesher produced, and the dip target needs to be exactly where
|
||||
* the player expects it.
|
||||
*/
|
||||
export function makeSpreadPot(def: SpreadDef): { root: THREE.Group; hit: THREE.Mesh } {
|
||||
const g = new THREE.Group();
|
||||
const isButter = def.id === 'butter';
|
||||
|
||||
// material.visible = false, not object.visible = false: the renderer skips it
|
||||
// either way, but the raycaster still needs a live object to hit.
|
||||
const hit = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(isButter ? 0.8 : 0.62, 0.08, isButter ? 0.5 : 0.62),
|
||||
new THREE.MeshBasicMaterial({ visible: false }),
|
||||
);
|
||||
hit.position.y = isButter ? 0.34 : 0.56;
|
||||
g.add(hit);
|
||||
|
||||
const file = isButter ? 'butter_dish' : def.id === 'peanut' ? 'pb_jar' : 'mitey_jar';
|
||||
void loadProp(`/assets/models/${file}.glb`, isButter ? 1.5 : 1.15).then((prop) => g.add(prop));
|
||||
|
||||
return { root: g, hit };
|
||||
}
|
||||
353
src/sim/cutlery.ts
Normal file
@ -0,0 +1,353 @@
|
||||
import * as THREE from 'three';
|
||||
|
||||
/**
|
||||
* The cutlery cast. Deliberately procedural rather than generated: these
|
||||
* silhouettes are gameplay — the drawer asks you to find "the dessert fork"
|
||||
* among things that are almost dessert forks, and that's only fair if the
|
||||
* differences are authored. It also keeps the tines thin without a mesher
|
||||
* mangling them, and lets each piece carry its own physics colliders.
|
||||
*/
|
||||
|
||||
export type ToolId =
|
||||
| 'butter_knife'
|
||||
| 'dinner_knife'
|
||||
| 'steak_knife'
|
||||
| 'spreader'
|
||||
| 'dinner_fork'
|
||||
| 'dessert_fork'
|
||||
| 'teaspoon'
|
||||
| 'dessert_spoon'
|
||||
| 'soup_spoon';
|
||||
|
||||
export type ToolKind = 'knife' | 'fork' | 'spoon';
|
||||
|
||||
export interface Tool {
|
||||
id: ToolId;
|
||||
name: string;
|
||||
kind: ToolKind;
|
||||
/** Overall length in slice-units (1 unit ~ 11cm). */
|
||||
length: number;
|
||||
/** Width of the business end. */
|
||||
headW: number;
|
||||
headL: number;
|
||||
/** Multiplies the knife's contact patch — a spreader is wide, a steak knife isn't. */
|
||||
contactScale: number;
|
||||
/** How well it moves spread at all. */
|
||||
transferScale: number;
|
||||
/** Multiplier on gouge risk. Serrated things are bad news. */
|
||||
gougeProne: number;
|
||||
/** 0..1 — how blotchy the deposit is. A spoon can't lay a flat film. */
|
||||
blotch: number;
|
||||
/** Multiplier on tearing when the spread won't yield. */
|
||||
tearProne: number;
|
||||
/** The right tool for spreading. */
|
||||
ideal: boolean;
|
||||
blurb: string;
|
||||
}
|
||||
|
||||
export const TOOLS: Record<ToolId, Tool> = {
|
||||
butter_knife: {
|
||||
id: 'butter_knife',
|
||||
name: 'Butter Knife',
|
||||
kind: 'knife',
|
||||
length: 1.75,
|
||||
headW: 0.2,
|
||||
headL: 0.72,
|
||||
contactScale: 1,
|
||||
transferScale: 1,
|
||||
gougeProne: 1,
|
||||
blotch: 0,
|
||||
tearProne: 1,
|
||||
ideal: true,
|
||||
blurb: 'Round-tipped, wide, dull. The correct answer.',
|
||||
},
|
||||
dinner_knife: {
|
||||
id: 'dinner_knife',
|
||||
name: 'Dinner Knife',
|
||||
kind: 'knife',
|
||||
length: 2.0,
|
||||
headW: 0.16,
|
||||
headL: 0.85,
|
||||
contactScale: 0.86,
|
||||
transferScale: 0.95,
|
||||
gougeProne: 1.35,
|
||||
blotch: 0.05,
|
||||
tearProne: 1.1,
|
||||
ideal: false,
|
||||
blurb: 'Longer, narrower, and it has opinions about the crumb.',
|
||||
},
|
||||
steak_knife: {
|
||||
id: 'steak_knife',
|
||||
name: 'Steak Knife',
|
||||
kind: 'knife',
|
||||
length: 1.95,
|
||||
headW: 0.13,
|
||||
headL: 0.88,
|
||||
contactScale: 0.62,
|
||||
transferScale: 0.8,
|
||||
gougeProne: 3.2,
|
||||
blotch: 0.12,
|
||||
tearProne: 1.6,
|
||||
ideal: false,
|
||||
blurb: 'Serrated. Every stroke is a small act of violence.',
|
||||
},
|
||||
spreader: {
|
||||
id: 'spreader',
|
||||
name: 'Pâté Spreader',
|
||||
kind: 'knife',
|
||||
length: 1.4,
|
||||
headW: 0.3,
|
||||
headL: 0.5,
|
||||
contactScale: 1.35,
|
||||
transferScale: 1.15,
|
||||
gougeProne: 0.55,
|
||||
blotch: 0,
|
||||
tearProne: 0.7,
|
||||
ideal: true,
|
||||
blurb: 'Stubby, wide, blameless. Somehow always at the back.',
|
||||
},
|
||||
dinner_fork: {
|
||||
id: 'dinner_fork',
|
||||
name: 'Dinner Fork',
|
||||
kind: 'fork',
|
||||
length: 1.85,
|
||||
headW: 0.26,
|
||||
headL: 0.42,
|
||||
contactScale: 0.7,
|
||||
transferScale: 0.55,
|
||||
gougeProne: 2.4,
|
||||
blotch: 0.75,
|
||||
tearProne: 3.0,
|
||||
ideal: false,
|
||||
blurb: 'Four tines. Four furrows.',
|
||||
},
|
||||
dessert_fork: {
|
||||
id: 'dessert_fork',
|
||||
name: 'Dessert Fork',
|
||||
kind: 'fork',
|
||||
length: 1.5,
|
||||
headW: 0.23,
|
||||
headL: 0.34,
|
||||
contactScale: 0.6,
|
||||
transferScale: 0.5,
|
||||
gougeProne: 2.2,
|
||||
blotch: 0.78,
|
||||
tearProne: 2.8,
|
||||
ideal: false,
|
||||
blurb: 'Like a dinner fork, but smaller. That is the entire difference.',
|
||||
},
|
||||
teaspoon: {
|
||||
id: 'teaspoon',
|
||||
name: 'Teaspoon',
|
||||
kind: 'spoon',
|
||||
length: 1.3,
|
||||
headW: 0.24,
|
||||
headL: 0.34,
|
||||
contactScale: 0.75,
|
||||
transferScale: 0.7,
|
||||
gougeProne: 0.5,
|
||||
blotch: 0.6,
|
||||
tearProne: 1.2,
|
||||
ideal: false,
|
||||
blurb: 'You can, technically. It will show.',
|
||||
},
|
||||
dessert_spoon: {
|
||||
id: 'dessert_spoon',
|
||||
name: 'Dessert Spoon',
|
||||
kind: 'spoon',
|
||||
length: 1.65,
|
||||
headW: 0.3,
|
||||
headL: 0.44,
|
||||
contactScale: 0.85,
|
||||
transferScale: 0.75,
|
||||
gougeProne: 0.45,
|
||||
blotch: 0.55,
|
||||
tearProne: 1.15,
|
||||
ideal: false,
|
||||
blurb: 'A teaspoon that has been to the gym.',
|
||||
},
|
||||
soup_spoon: {
|
||||
id: 'soup_spoon',
|
||||
name: 'Soup Spoon',
|
||||
kind: 'spoon',
|
||||
length: 1.6,
|
||||
headW: 0.38,
|
||||
headL: 0.4,
|
||||
contactScale: 0.9,
|
||||
transferScale: 0.7,
|
||||
gougeProne: 0.4,
|
||||
blotch: 0.62,
|
||||
tearProne: 1.1,
|
||||
ideal: false,
|
||||
blurb: 'Round. Deep. Utterly wrong, but confidently so.',
|
||||
},
|
||||
};
|
||||
|
||||
export const TOOL_IDS = Object.keys(TOOLS) as ToolId[];
|
||||
|
||||
const STEEL = new THREE.MeshStandardMaterial({
|
||||
color: 0xd2d7dd,
|
||||
roughness: 0.24,
|
||||
metalness: 0.95,
|
||||
});
|
||||
|
||||
/**
|
||||
* Extrude a profile drawn in shape-space (x = width, y = length) into a part
|
||||
* lying in the XZ plane: shape +y becomes +z, and the extrusion thickness ends
|
||||
* up centred on y=0.
|
||||
*
|
||||
* Done at the geometry level on purpose. Setting mesh.rotation.x = -PI/2 instead
|
||||
* sends a profile drawn toward +y to -z and one drawn toward -y to +z — which
|
||||
* silently lays the handle and the blade out in opposite directions, on top of
|
||||
* each other, and the piece is nowhere near where the code says it is.
|
||||
*/
|
||||
function extrudeFlat(shape: THREE.Shape, depth: number, bevel: number): THREE.BufferGeometry {
|
||||
const geo = new THREE.ExtrudeGeometry(shape, {
|
||||
depth,
|
||||
bevelEnabled: bevel > 0,
|
||||
bevelThickness: bevel,
|
||||
bevelSize: bevel,
|
||||
bevelSegments: 2,
|
||||
curveSegments: 12,
|
||||
});
|
||||
geo.rotateX(Math.PI / 2); // shape +y -> +z, extrusion depth -> -y
|
||||
geo.translate(0, depth / 2, 0);
|
||||
return geo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a piece of cutlery lying in the XZ plane, handle at -Z, head at +Z.
|
||||
* Y is thickness.
|
||||
*/
|
||||
export function makeCutleryMesh(tool: Tool): THREE.Group {
|
||||
const g = new THREE.Group();
|
||||
const L = tool.length;
|
||||
const handleL = L - tool.headL - 0.12;
|
||||
|
||||
// handle: a tapered, slightly domed bar
|
||||
const handleShape = new THREE.Shape();
|
||||
const hw0 = 0.052; // at the neck
|
||||
const hw1 = 0.085; // at the butt
|
||||
handleShape.moveTo(-hw0, 0);
|
||||
handleShape.lineTo(-hw1 * 0.92, -handleL * 0.55);
|
||||
handleShape.quadraticCurveTo(-hw1, -handleL, 0, -handleL);
|
||||
handleShape.quadraticCurveTo(hw1, -handleL, hw1 * 0.92, -handleL * 0.55);
|
||||
handleShape.lineTo(hw0, 0);
|
||||
handleShape.closePath();
|
||||
const handle = new THREE.Mesh(extrudeFlat(handleShape, 0.036, 0.014), STEEL);
|
||||
handle.position.z = -0.02;
|
||||
g.add(handle);
|
||||
|
||||
// neck
|
||||
const neck = new THREE.Mesh(new THREE.BoxGeometry(0.055, 0.028, 0.16), STEEL);
|
||||
neck.position.z = 0.06;
|
||||
g.add(neck);
|
||||
|
||||
if (tool.kind === 'knife') g.add(makeBlade(tool));
|
||||
else if (tool.kind === 'fork') g.add(makeForkHead(tool));
|
||||
else g.add(makeSpoonBowl(tool));
|
||||
|
||||
for (const c of g.children) {
|
||||
c.castShadow = true;
|
||||
c.receiveShadow = true;
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
function makeBlade(tool: Tool): THREE.Mesh {
|
||||
const w = tool.headW / 2;
|
||||
const l = tool.headL;
|
||||
const s = new THREE.Shape();
|
||||
s.moveTo(-0.028, 0);
|
||||
s.lineTo(-w * 0.8, l * 0.22);
|
||||
s.lineTo(-w, l * 0.55);
|
||||
// rounded tip for a butter knife, a point for the aggressive ones
|
||||
if (tool.id === 'butter_knife' || tool.id === 'spreader') {
|
||||
s.quadraticCurveTo(-w, l, 0, l);
|
||||
s.quadraticCurveTo(w, l, w, l * 0.55);
|
||||
} else {
|
||||
s.lineTo(-w * 0.55, l * 0.93);
|
||||
s.quadraticCurveTo(0, l * 1.02, w * 0.72, l * 0.86);
|
||||
s.lineTo(w, l * 0.55);
|
||||
}
|
||||
s.lineTo(w * 0.8, l * 0.22);
|
||||
s.lineTo(0.028, 0);
|
||||
s.closePath();
|
||||
|
||||
const blade = new THREE.Mesh(extrudeFlat(s, 0.014, 0.006), STEEL);
|
||||
blade.position.set(0, 0, 0.12);
|
||||
return blade;
|
||||
}
|
||||
|
||||
function makeForkHead(tool: Tool): THREE.Group {
|
||||
const g = new THREE.Group();
|
||||
const w = tool.headW / 2;
|
||||
const l = tool.headL;
|
||||
// the shoulder the tines grow out of
|
||||
const base = new THREE.Shape();
|
||||
base.moveTo(-0.03, 0);
|
||||
base.lineTo(-w, l * 0.34);
|
||||
base.lineTo(w, l * 0.34);
|
||||
base.lineTo(0.03, 0);
|
||||
base.closePath();
|
||||
const shoulder = new THREE.Mesh(extrudeFlat(base, 0.016, 0), STEEL);
|
||||
shoulder.position.set(0, 0, 0.12);
|
||||
g.add(shoulder);
|
||||
|
||||
// four tines
|
||||
const tineL = l * 0.66;
|
||||
const tineW = (w * 2) / 7;
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const x = (i - 1.5) * (w * 2) / 4;
|
||||
const tine = new THREE.Mesh(new THREE.BoxGeometry(tineW, 0.014, tineL), STEEL);
|
||||
tine.position.set(x, 0, 0.12 + l * 0.34 + tineL / 2);
|
||||
g.add(tine);
|
||||
const tip = new THREE.Mesh(new THREE.ConeGeometry(tineW * 0.5, 0.05, 6), STEEL);
|
||||
tip.rotation.x = Math.PI / 2;
|
||||
tip.position.set(x, 0, 0.12 + l * 0.34 + tineL + 0.02);
|
||||
g.add(tip);
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
function makeSpoonBowl(tool: Tool): THREE.Mesh {
|
||||
const geo = new THREE.SphereGeometry(0.5, 20, 14, 0, Math.PI * 2, 0, Math.PI * 0.52);
|
||||
geo.scale(tool.headW * 0.5, 0.11, tool.headL * 0.6);
|
||||
geo.rotateX(Math.PI); // open side up
|
||||
const bowl = new THREE.Mesh(geo, STEEL);
|
||||
bowl.position.set(0, 0.005, 0.12 + tool.headL * 0.42);
|
||||
return bowl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compound collider primitives for the drawer, in the mesh's local space.
|
||||
* Boxes only, and few of them: a trimesh of a fork is both slow and a stability
|
||||
* nightmare when a dozen of them are tangled together.
|
||||
*/
|
||||
export interface ColliderPart {
|
||||
half: [number, number, number];
|
||||
pos: [number, number, number];
|
||||
}
|
||||
|
||||
export function colliderParts(tool: Tool): ColliderPart[] {
|
||||
const L = tool.length;
|
||||
const handleL = L - tool.headL - 0.12;
|
||||
const parts: ColliderPart[] = [
|
||||
{ half: [0.075, 0.03, handleL / 2], pos: [0, 0, -0.02 - handleL / 2] },
|
||||
{ half: [0.03, 0.016, 0.08], pos: [0, 0, 0.06] },
|
||||
];
|
||||
if (tool.kind === 'spoon') {
|
||||
parts.push({
|
||||
half: [tool.headW * 0.5, 0.055, tool.headL * 0.32],
|
||||
pos: [0, 0, 0.12 + tool.headL * 0.42],
|
||||
});
|
||||
} else {
|
||||
// one slab for a blade; for a fork this is the tine envelope, which is what
|
||||
// actually matters — individual tines catching each other is a physics trap.
|
||||
parts.push({
|
||||
half: [tool.headW * 0.5, 0.012, tool.headL * 0.5],
|
||||
pos: [0, 0, 0.12 + tool.headL * 0.5],
|
||||
});
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
@ -39,6 +39,10 @@ export class Slice {
|
||||
warmth = 0;
|
||||
/** Which spread is currently on the slice (for judging + shading). */
|
||||
spreadDef: SpreadDef | null = null;
|
||||
/** Extent of the UV projection, so world hits can be turned back into texels. */
|
||||
readonly sizeX: number;
|
||||
readonly sizeZ: number;
|
||||
readonly halfThickness: number;
|
||||
|
||||
private tex: THREE.DataTexture;
|
||||
private texData: Uint8Array;
|
||||
@ -65,6 +69,10 @@ export class Slice {
|
||||
this.tex.needsUpdate = true;
|
||||
|
||||
const geo = buildGeometry(shape, bread);
|
||||
const bb = geo.boundingBox!;
|
||||
this.sizeX = bb.max.x - bb.min.x;
|
||||
this.sizeZ = bb.max.z - bb.min.z;
|
||||
this.halfThickness = bb.max.y;
|
||||
this.material = buildMaterial(bread, this.tex);
|
||||
this.mesh = new THREE.Mesh(geo, this.material);
|
||||
this.mesh.castShadow = true;
|
||||
@ -72,6 +80,21 @@ export class Slice {
|
||||
this.sync();
|
||||
}
|
||||
|
||||
/**
|
||||
* World point -> field UV. The UVs were planar-projected from the shape's own
|
||||
* XY before the slice was laid flat, which makes shape +y become world -z —
|
||||
* hence the flip on v.
|
||||
*/
|
||||
uvAt(worldPoint: THREE.Vector3, out: THREE.Vector2): THREE.Vector2 {
|
||||
const p = this.mesh.worldToLocal(worldPoint.clone());
|
||||
return out.set(p.x / this.sizeX + 0.5, 0.5 - p.z / this.sizeZ);
|
||||
}
|
||||
|
||||
/** Height of the top face in world space (the slice lies flat when spreading). */
|
||||
get topY(): number {
|
||||
return this.mesh.position.y + this.halfThickness;
|
||||
}
|
||||
|
||||
get uniforms() {
|
||||
return this.material.uniforms;
|
||||
}
|
||||
@ -119,6 +142,26 @@ export class Slice {
|
||||
this.material.uniforms.uHeatmap.value = mode;
|
||||
}
|
||||
|
||||
/**
|
||||
* The slice rolls its own lighting, so scene lights don't touch it — which
|
||||
* means the judge's spotlight would do nothing at all. Swap the shader's own
|
||||
* rig instead: hard key, near-black ambient.
|
||||
*/
|
||||
setPresentation(on: boolean): void {
|
||||
const u = this.material.uniforms;
|
||||
if (on) {
|
||||
u.uLightDir.value.set(0.4, 0.86, 0.52).normalize();
|
||||
u.uLightColor.value.setRGB(1.35, 1.24, 1.05);
|
||||
u.uAmbientSky.value.setRGB(0.1, 0.11, 0.15);
|
||||
u.uAmbientGround.value.setRGB(0.035, 0.03, 0.03);
|
||||
} else {
|
||||
u.uLightDir.value.set(0.5, 0.9, 0.42).normalize();
|
||||
u.uLightColor.value.setRGB(1.0, 0.95, 0.86);
|
||||
u.uAmbientSky.value.setRGB(0.26, 0.27, 0.31);
|
||||
u.uAmbientGround.value.setRGB(0.14, 0.11, 0.09);
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.mesh.geometry.dispose();
|
||||
this.material.dispose();
|
||||
@ -182,6 +225,7 @@ function buildGeometry(shape: THREE.Shape, bread: Bread): THREE.BufferGeometry {
|
||||
geo.rotateX(-Math.PI / 2);
|
||||
geo.center();
|
||||
geo.computeVertexNormals();
|
||||
geo.computeBoundingBox();
|
||||
return geo;
|
||||
}
|
||||
|
||||
@ -230,19 +274,13 @@ function pointInPoly(x: number, y: number, pts: THREE.Vector2[]): boolean {
|
||||
function erode(mask: Field): void {
|
||||
const n = mask.n;
|
||||
const src = mask.data.slice();
|
||||
// Anything off the grid counts as outside, so texels on the very border erode
|
||||
// away too — clamping the lookups instead would let them survive.
|
||||
const at = (x: number, y: number) => (x < 0 || y < 0 || x >= n || y >= n ? 0 : src[y * n + x]);
|
||||
for (let y = 0; y < n; y++) {
|
||||
for (let x = 0; x < n; x++) {
|
||||
if (src[y * n + x] < 0.5) continue;
|
||||
const xm = Math.max(0, x - 1);
|
||||
const xp = Math.min(n - 1, x + 1);
|
||||
const ym = Math.max(0, y - 1);
|
||||
const yp = Math.min(n - 1, y + 1);
|
||||
if (
|
||||
src[y * n + xm] < 0.5 ||
|
||||
src[y * n + xp] < 0.5 ||
|
||||
src[ym * n + x] < 0.5 ||
|
||||
src[yp * n + x] < 0.5
|
||||
) {
|
||||
if (at(x - 1, y) < 0.5 || at(x + 1, y) < 0.5 || at(x, y - 1) < 0.5 || at(x, y + 1) < 0.5) {
|
||||
mask.data[y * n + x] = 0;
|
||||
}
|
||||
}
|
||||
@ -419,6 +457,14 @@ void main() {
|
||||
// torn bread is rough
|
||||
N = normalize(N + vec3(dmgN - 0.5, 0.0, fbm(vUv * 60.0 + 44.0) - 0.5) * dmg * 0.9);
|
||||
|
||||
// Every colour above — the ramp, the crumb, the crust, the spread — is authored
|
||||
// the way a human picks colours: as sRGB. The lighting below is linear. Without
|
||||
// this line those values are read as if they were already linear, which lifts
|
||||
// everything: near-black MITEY renders as tan (linear 0.14 encodes back out to
|
||||
// sRGB 0.4) and saturated butter washes to pale cream. Mixing happens in sRGB
|
||||
// on purpose — that's the space the palette was chosen in.
|
||||
albedo = pow(albedo, vec3(2.2));
|
||||
|
||||
// --- lighting ---
|
||||
vec3 L = normalize(uLightDir);
|
||||
vec3 V = normalize(cameraPosition - vPosW);
|
||||
@ -432,10 +478,13 @@ void main() {
|
||||
// so pair a broad sheen with a fresnel rim — that's the cue that says "wet".
|
||||
float gloss = uSpreadGloss * smoothstep(0.008, 0.09, spread) * topFace;
|
||||
gloss *= 1.0 + uWarmth * 0.6;
|
||||
// Keep the lobe tight: a broad one just washes the whole slice white and drowns
|
||||
// the colour. Tight + the thickness ridges = glints along the knife marks.
|
||||
float shin = mix(18.0, 52.0, uSpreadGloss);
|
||||
float spec = pow(max(dot(N, H), 0.0), shin) * gloss * 0.6;
|
||||
// The lobe has to be TIGHT. The slice is flat and both the light and the camera
|
||||
// are above it, so dot(N,H) is ~0.98 across the whole surface — any broad lobe
|
||||
// blankets it in white and lifts near-black MITEY to tan. Tight, and the only
|
||||
// thing that catches is a ridge tilted into the light, which is the actual look
|
||||
// of a spread: dark film, bright knife marks.
|
||||
float shin = mix(40.0, 170.0, uSpreadGloss);
|
||||
float spec = pow(max(dot(N, H), 0.0), shin) * gloss * 0.9;
|
||||
float fres = pow(1.0 - max(dot(N, V), 0.0), 3.0);
|
||||
spec += fres * gloss * 0.22;
|
||||
spec *= 1.0 - char * 0.7;
|
||||
|
||||
234
src/sim/spreading.ts
Normal file
@ -0,0 +1,234 @@
|
||||
import type { Slice } from './slice';
|
||||
import type { SpreadDef } from './spreads';
|
||||
import { SCRAPE_ANGLE, contactRadius, effectiveYield, pressureFromAngle } from './spreads';
|
||||
import type { Tool } from './cutlery';
|
||||
|
||||
/**
|
||||
* The star mechanic.
|
||||
*
|
||||
* One control — knife angle — and three behaviours fall out of it:
|
||||
*
|
||||
* flat -> wide contact, low pressure -> spreads (or tears, if the spread is
|
||||
* stiffer than the pressure you're allowed to apply)
|
||||
* steep -> narrow contact, high pressure -> scrapes spread back off, or scrapes
|
||||
* char off toast that's burnt
|
||||
* steep and fast on bread that has nothing left to remove -> gouges
|
||||
*
|
||||
* The trap: pressure comes from steepness, but steepness past SCRAPE_ANGLE stops
|
||||
* spreading. So cold butter can demand more pressure than the spread mode can
|
||||
* physically give — and the answer isn't technique, it's warmer toast.
|
||||
*/
|
||||
|
||||
/** Scraping lightens toast down to here; past that you're into the crumb. */
|
||||
export const BROWN_FLOOR = 0.62;
|
||||
|
||||
const TEAR_RATE = 1.15;
|
||||
const SCRAPE_RATE = 2.4;
|
||||
/**
|
||||
* Lifting char has to feel like progress. A steep knife has a narrow contact
|
||||
* patch (~0.05 UV), so a pass only dwells on any given texel for ~0.2s — at a
|
||||
* lower rate than this you scrape a burnt slice for a minute and it stays burnt.
|
||||
*/
|
||||
const CHAR_SCRAPE_RATE = 4.0;
|
||||
const GOUGE_RATE = 0.85;
|
||||
/** Below this pressure you're just wiping; you can't gouge with a flat knife. */
|
||||
const GOUGE_PRESSURE = 0.5;
|
||||
/** Gouging and tearing both need the knife to be moving. */
|
||||
const MOVE_REF = 0.55;
|
||||
|
||||
export interface Knife {
|
||||
/** 0 = flat on the toast, 1 = up on its edge. */
|
||||
angle: number;
|
||||
/** Spread carried on the blade, in mean-thickness units (same as the field). */
|
||||
load: number;
|
||||
u: number;
|
||||
v: number;
|
||||
hasPrev: boolean;
|
||||
}
|
||||
|
||||
export type StrokeMode = 'idle' | 'spread' | 'tear' | 'scrape' | 'char' | 'gouge';
|
||||
|
||||
export interface StrokeFx {
|
||||
mode: StrokeMode;
|
||||
deposited: number;
|
||||
tore: number;
|
||||
gouged: number;
|
||||
scrapedSpread: number;
|
||||
scrapedChar: number;
|
||||
speed: number;
|
||||
pressure: number;
|
||||
/** How far short of the yield pressure we are: 0 = flowing, 1 = hopeless. */
|
||||
deficit: number;
|
||||
crumbs: number;
|
||||
}
|
||||
|
||||
export function newKnife(): Knife {
|
||||
// Starts inside the window where warm-toast butter actually flows: the very
|
||||
// first stroke of the game shouldn't be a mysterious failure.
|
||||
return { angle: 0.45, load: 0, u: 0.5, v: 0.5, hasPrev: false };
|
||||
}
|
||||
|
||||
export function dip(knife: Knife, def: SpreadDef): void {
|
||||
knife.load = def.pickup;
|
||||
}
|
||||
|
||||
/** Cheap per-texel hash, for blotchy tools. */
|
||||
function blotchAt(i: number): number {
|
||||
const x = Math.sin(i * 12.9898) * 43758.5453;
|
||||
return x - Math.floor(x);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag the knife from where it was to (toU,toV). Substeps along the path so a
|
||||
* fast flick can't skip over the middle of the slice.
|
||||
*/
|
||||
export function stroke(
|
||||
slice: Slice,
|
||||
def: SpreadDef,
|
||||
tool: Tool,
|
||||
knife: Knife,
|
||||
toU: number,
|
||||
toV: number,
|
||||
dt: number,
|
||||
softness: number,
|
||||
): StrokeFx {
|
||||
const fx: StrokeFx = {
|
||||
mode: 'idle',
|
||||
deposited: 0,
|
||||
tore: 0,
|
||||
gouged: 0,
|
||||
scrapedSpread: 0,
|
||||
scrapedChar: 0,
|
||||
speed: 0,
|
||||
pressure: 0,
|
||||
deficit: 0,
|
||||
crumbs: 0,
|
||||
};
|
||||
if (!knife.hasPrev) {
|
||||
knife.u = toU;
|
||||
knife.v = toV;
|
||||
knife.hasPrev = true;
|
||||
return fx;
|
||||
}
|
||||
const du = toU - knife.u;
|
||||
const dv = toV - knife.v;
|
||||
const dist = Math.hypot(du, dv);
|
||||
const speed = dt > 0 ? dist / dt : 0;
|
||||
fx.speed = speed;
|
||||
|
||||
const radius = Math.max(0.012, contactRadius(knife.angle) * tool.contactScale);
|
||||
// Pressure is force over area: the same wrist angle through a narrow blade
|
||||
// presses harder than through a wide one.
|
||||
const pressure = Math.min(1.4, pressureFromAngle(knife.angle) / tool.contactScale);
|
||||
fx.pressure = pressure;
|
||||
|
||||
const steps = Math.max(1, Math.min(24, Math.ceil(dist / (radius * 0.45))));
|
||||
const stepDt = dt / steps;
|
||||
for (let s = 1; s <= steps; s++) {
|
||||
const t = s / steps;
|
||||
contact(slice, def, tool, knife, knife.u + du * t, knife.v + dv * t, pressure, radius, speed, stepDt, softness, fx);
|
||||
}
|
||||
knife.u = toU;
|
||||
knife.v = toV;
|
||||
|
||||
// Low-viscosity spreads level themselves out; peanut butter never does.
|
||||
const relax = (1 - def.viscosity) * dt * 3.2;
|
||||
if (relax > 0.001) slice.spread.relax(Math.min(0.5, relax), slice.mask);
|
||||
|
||||
slice.touch();
|
||||
return fx;
|
||||
}
|
||||
|
||||
function contact(
|
||||
slice: Slice,
|
||||
def: SpreadDef,
|
||||
tool: Tool,
|
||||
knife: Knife,
|
||||
u: number,
|
||||
v: number,
|
||||
pressure: number,
|
||||
radius: number,
|
||||
speed: number,
|
||||
dt: number,
|
||||
softness: number,
|
||||
fx: StrokeFx,
|
||||
): void {
|
||||
const mask = slice.mask.data;
|
||||
const spread = slice.spread.data;
|
||||
const brown = slice.browning.data;
|
||||
const damage = slice.damage.data;
|
||||
const moving = Math.min(1.6, speed / MOVE_REF);
|
||||
|
||||
if (knife.angle < SCRAPE_ANGLE) {
|
||||
if (knife.load <= 0.0005) return;
|
||||
const yieldP = effectiveYield(def, softness, slice.warmth);
|
||||
const flowing = pressure >= yieldP;
|
||||
const deficit = flowing ? 0 : Math.min(1, (yieldP - pressure) / Math.max(yieldP, 0.001));
|
||||
fx.deficit = Math.max(fx.deficit, deficit);
|
||||
fx.mode = flowing ? 'spread' : 'tear';
|
||||
|
||||
// Below the yield pressure the spread won't flow, so the blade grabs the
|
||||
// crumb and takes it with it. Some still smears on, badly.
|
||||
const transfer = flowing ? 1 : 0.25;
|
||||
const rate = def.transferRate * tool.transferScale * dt * transfer;
|
||||
let massLeft = knife.load * slice.mask.n * slice.mask.n;
|
||||
|
||||
slice.spread.brush(u, v, radius, (i, w) => {
|
||||
if (mask[i] < 0.5 || massLeft <= 0) return;
|
||||
const blot = 1 - tool.blotch * blotchAt(i);
|
||||
const add = Math.min(rate * w * blot, massLeft);
|
||||
spread[i] += add;
|
||||
massLeft -= add;
|
||||
fx.deposited += add;
|
||||
});
|
||||
knife.load = Math.max(0, massLeft / (slice.mask.n * slice.mask.n));
|
||||
|
||||
if (!flowing && moving > 0.05) {
|
||||
const tear = TEAR_RATE * deficit * moving * tool.tearProne * dt;
|
||||
slice.damage.brush(u, v, radius, (i, w) => {
|
||||
if (mask[i] < 0.5) return;
|
||||
const add = tear * w;
|
||||
damage[i] = Math.min(1, damage[i] + add);
|
||||
fx.tore += add;
|
||||
});
|
||||
fx.crumbs += tear * 26;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// --- scrape ---
|
||||
let removedSpread = 0;
|
||||
let removedChar = 0;
|
||||
let gouged = 0;
|
||||
slice.spread.brush(u, v, radius, (i, w) => {
|
||||
if (mask[i] < 0.5) return;
|
||||
if (spread[i] > 0.002) {
|
||||
const off = Math.min(spread[i], SCRAPE_RATE * pressure * def.scrapeEase * dt * w);
|
||||
spread[i] -= off;
|
||||
removedSpread += off;
|
||||
return;
|
||||
}
|
||||
if (brown[i] > BROWN_FLOOR) {
|
||||
// Lifting char off. Cheap in browning, expensive in evenness — the saved
|
||||
// patch ends up lighter than everything around it.
|
||||
const off = Math.min(brown[i] - BROWN_FLOOR, CHAR_SCRAPE_RATE * pressure * dt * w);
|
||||
brown[i] -= off;
|
||||
removedChar += off;
|
||||
return;
|
||||
}
|
||||
// Nothing left to take but the bread itself.
|
||||
if (pressure > GOUGE_PRESSURE && moving > 0.25) {
|
||||
const add = GOUGE_RATE * (pressure - GOUGE_PRESSURE) * moving * tool.gougeProne * dt * w;
|
||||
damage[i] = Math.min(1, damage[i] + add);
|
||||
gouged += add;
|
||||
}
|
||||
});
|
||||
|
||||
// What comes off the toast mostly stays on the blade.
|
||||
knife.load += (removedSpread * 0.6) / (slice.mask.n * slice.mask.n);
|
||||
fx.scrapedSpread += removedSpread;
|
||||
fx.scrapedChar += removedChar;
|
||||
fx.gouged += gouged;
|
||||
fx.crumbs += removedChar * 900 + gouged * 30;
|
||||
fx.mode = gouged > 1e-6 ? 'gouge' : removedChar > 1e-6 ? 'char' : removedSpread > 1e-6 ? 'scrape' : 'idle';
|
||||
}
|
||||
@ -53,7 +53,7 @@ export const SPREADS: Record<SpreadId, SpreadDef> = {
|
||||
tempSoftening: 0.78,
|
||||
viscosity: 0.72,
|
||||
pickup: 0.55,
|
||||
transferRate: 1.5,
|
||||
transferRate: 3.0,
|
||||
drag: 0.5,
|
||||
scrapeEase: 1.0,
|
||||
amounts: { thin: [0.1, 0.22], normal: [0.25, 0.5], thick: [0.55, 0.9] },
|
||||
@ -70,7 +70,7 @@ export const SPREADS: Record<SpreadId, SpreadDef> = {
|
||||
tempSoftening: 0.3,
|
||||
viscosity: 0.96,
|
||||
pickup: 0.95,
|
||||
transferRate: 2.2,
|
||||
transferRate: 4.5,
|
||||
drag: 1.0,
|
||||
scrapeEase: 0.75,
|
||||
amounts: { thin: [0.18, 0.32], normal: [0.38, 0.68], thick: [0.75, 1.1] },
|
||||
@ -87,7 +87,7 @@ export const SPREADS: Record<SpreadId, SpreadDef> = {
|
||||
tempSoftening: 0.5,
|
||||
viscosity: 0.34,
|
||||
pickup: 0.5,
|
||||
transferRate: 2.6,
|
||||
transferRate: 3.5,
|
||||
drag: 0.25,
|
||||
scrapeEase: 1.25,
|
||||
amounts: { thin: [0.03, 0.11], normal: [0.14, 0.28], thick: [0.32, 0.6] },
|
||||
|
||||
103
src/sim/toasting.ts
Normal file
@ -0,0 +1,103 @@
|
||||
import type { Slice } from './slice';
|
||||
import { Rng } from '../core/rng';
|
||||
|
||||
/**
|
||||
* Browning rate. Measured, not guessed: the heat map's mean is ~0.77 (coils,
|
||||
* edge falloff and the cool top all bite), so this is set so power 6 reaches a
|
||||
* golden 0.5 in ~10s, power 10 is nearly black by then, and power 3 takes ~20s.
|
||||
*/
|
||||
const RATE = 0.11;
|
||||
|
||||
/** Where the elements actually put their heat. */
|
||||
export function buildHeatMap(slice: Slice, rng: Rng): Float32Array {
|
||||
const n = slice.mask.n;
|
||||
const out = new Float32Array(n * n);
|
||||
// Real toasters have vertical element wires spaced across the slot, so the
|
||||
// hot spots are stripes, not a wash. Jitter the phase per run.
|
||||
const coils = 4 + rng.int(0, 2);
|
||||
const phase = rng.range(0, Math.PI * 2);
|
||||
for (let y = 0; y < n; y++) {
|
||||
for (let x = 0; x < n; x++) {
|
||||
const u = x / (n - 1);
|
||||
const v = y / (n - 1);
|
||||
// Subtle: at much more than this the slice reads as a grill plate, not toast.
|
||||
const coil = 0.92 + 0.08 * Math.sin(u * Math.PI * 2 * coils + phase);
|
||||
// The top of the slice sticks up out of the slot and stays pale — the
|
||||
// single most recognisable thing about real toast.
|
||||
const vert = v > 0.72 ? 1 - 0.5 * Math.pow((v - 0.72) / 0.28, 1.5) : 1;
|
||||
// and the very bottom sits on the rack, slightly shaded
|
||||
const base = v < 0.06 ? 0.82 + (v / 0.06) * 0.18 : 1;
|
||||
// heat escapes at the left/right edges
|
||||
const edge = 0.82 + 0.18 * Math.sin(Math.min(1, Math.max(0, u)) * Math.PI);
|
||||
const bias = 0.88 + slice.heatBias[y * n + x] * 0.24;
|
||||
out[y * n + x] = coil * vert * base * edge * bias;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface ToastTick {
|
||||
/** Mean browning over the slice — drives the smell cues. */
|
||||
mean: number;
|
||||
/** How much of the slice is past char. */
|
||||
charFrac: number;
|
||||
/** 0..1, how smoky it is right now. */
|
||||
smoke: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance the browning one tick.
|
||||
*
|
||||
* Two things make this more than a timer:
|
||||
* - moisture has to boil off before the surface can brown at full rate, so a wet
|
||||
* crumb stalls and then accelerates once it's dry (sourdough's whole trick);
|
||||
* - sugar browns early and burns early (raisin bread's whole trick).
|
||||
*/
|
||||
export function toastStep(slice: Slice, heat: Float32Array, power: number, dt: number): void {
|
||||
const b = slice.bread;
|
||||
const p = Math.max(0, Math.min(10, power)) / 10;
|
||||
const moistureCap = 0.4 + b.moisture * 2.2;
|
||||
const thick = 0.6 + b.thickness * 3;
|
||||
const sugar = 1 + b.sugar * 0.8;
|
||||
const br = slice.browning.data;
|
||||
const dry = slice.dryness.data;
|
||||
const mask = slice.mask.data;
|
||||
|
||||
for (let i = 0; i < br.length; i++) {
|
||||
if (mask[i] < 0.5) continue;
|
||||
const h = heat[i] * p;
|
||||
if (h <= 0) continue;
|
||||
dry[i] = Math.min(1, dry[i] + (h * dt) / moistureCap);
|
||||
const rate = (RATE * h * (0.25 + 0.75 * dry[i]) * sugar) / thick;
|
||||
br[i] = Math.min(1.15, br[i] + rate * dt);
|
||||
}
|
||||
slice.touch();
|
||||
}
|
||||
|
||||
/** Warmth decays in real time. Thick slices hold heat longer. */
|
||||
export function coolStep(slice: Slice, dt: number): void {
|
||||
const tau = 18 + slice.bread.thickness * 90;
|
||||
slice.warmth *= Math.exp(-dt / tau);
|
||||
slice.material.uniforms.uWarmth.value = slice.warmth;
|
||||
}
|
||||
|
||||
export function readToast(slice: Slice): ToastTick {
|
||||
const s = slice.browning.stats(slice.mask);
|
||||
const charFrac = slice.browning.fraction(slice.mask, (v) => v > 0.85);
|
||||
const smoke = Math.max(0, Math.min(1, (s.max - 0.78) / 0.3));
|
||||
return { mean: s.mean, charFrac, smoke };
|
||||
}
|
||||
|
||||
/**
|
||||
* No timer, no numbers — you go by smell. Deliberately vague until it isn't.
|
||||
*/
|
||||
export function smellCue(t: ToastTick): string {
|
||||
if (t.smoke > 0.75) return 'SMOKE. ACTUAL SMOKE.';
|
||||
if (t.smoke > 0.45) return 'something is burning';
|
||||
if (t.mean > 0.62) return 'dark, and getting darker';
|
||||
if (t.mean > 0.42) return 'smells like toast';
|
||||
if (t.mean > 0.22) return 'smells toasty';
|
||||
if (t.mean > 0.08) return 'smells warm';
|
||||
if (t.mean > 0.01) return 'a faint warmth';
|
||||
return 'smells like bread';
|
||||
}
|
||||
512
src/style.css
@ -49,3 +49,515 @@ canvas#c {
|
||||
#ui > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.toast-panel {
|
||||
position: absolute;
|
||||
left: 24px;
|
||||
bottom: 24px;
|
||||
width: 290px;
|
||||
padding: 16px 18px;
|
||||
background: rgba(12, 9, 8, 0.72);
|
||||
border: 1px solid rgba(243, 233, 220, 0.14);
|
||||
border-radius: 10px;
|
||||
backdrop-filter: blur(9px);
|
||||
}
|
||||
|
||||
.toast-panel .row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.toast-panel .lbl {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.14em;
|
||||
color: var(--ink-dim);
|
||||
width: 74px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.toast-panel .dial {
|
||||
flex: 1;
|
||||
accent-color: var(--gold);
|
||||
}
|
||||
|
||||
.toast-panel .val {
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
color: var(--gold);
|
||||
width: 18px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.toast-panel .sel {
|
||||
flex: 1;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
color: var(--ink);
|
||||
border: 1px solid rgba(243, 233, 220, 0.18);
|
||||
border-radius: 5px;
|
||||
padding: 4px 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.toast-panel .cue {
|
||||
margin-top: 14px;
|
||||
font-size: 15px;
|
||||
font-style: italic;
|
||||
color: var(--ink);
|
||||
min-height: 20px;
|
||||
transition: color 0.4s;
|
||||
}
|
||||
|
||||
.toast-panel .hint {
|
||||
margin-top: 6px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--ink-dim);
|
||||
min-height: 14px;
|
||||
}
|
||||
|
||||
/* Pressure gauge: fill = what your wrist is making, gold = what the spread
|
||||
needs to flow, red = where spreading becomes scraping. */
|
||||
.toast-panel .gauge {
|
||||
position: relative;
|
||||
height: 9px;
|
||||
margin-top: 14px;
|
||||
border-radius: 5px;
|
||||
background: rgba(255, 255, 255, 0.09);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toast-panel .gauge-fill {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: 0;
|
||||
background: #8a7f70;
|
||||
border-radius: 5px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.toast-panel .gauge-mark {
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
bottom: -3px;
|
||||
width: 2px;
|
||||
margin-left: -1px;
|
||||
}
|
||||
|
||||
.toast-panel .gauge-mark.yield {
|
||||
background: var(--gold);
|
||||
}
|
||||
|
||||
.toast-panel .gauge-mark.scrape {
|
||||
background: #e2603a;
|
||||
}
|
||||
|
||||
.toast-panel .mode {
|
||||
margin-top: 8px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.09em;
|
||||
color: var(--ink-dim);
|
||||
min-height: 14px;
|
||||
}
|
||||
|
||||
.toast-panel .load {
|
||||
height: 5px;
|
||||
margin-top: 8px;
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 255, 255, 0.09);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toast-panel .load-fill {
|
||||
height: 100%;
|
||||
width: 0;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* ---- the order ticket ---- */
|
||||
.ticket {
|
||||
position: absolute;
|
||||
top: 22px;
|
||||
left: 24px;
|
||||
width: 268px;
|
||||
padding: 16px 18px 14px;
|
||||
color: #2b2118;
|
||||
background: #efe4cf;
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 10px 26px rgba(0, 0, 0, 0.5);
|
||||
transform: rotate(-0.7deg);
|
||||
}
|
||||
|
||||
.ticket-day {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.22em;
|
||||
color: #9b8b74;
|
||||
}
|
||||
|
||||
.ticket-who {
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
color: #7d6d58;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.ticket-text {
|
||||
margin-top: 8px;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.ticket-spec {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
margin-top: 11px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
padding: 3px 7px;
|
||||
border-radius: 3px;
|
||||
background: rgba(43, 33, 24, 0.1);
|
||||
border: 1px solid rgba(43, 33, 24, 0.16);
|
||||
}
|
||||
|
||||
.chip.warn {
|
||||
background: rgba(192, 57, 43, 0.14);
|
||||
border-color: rgba(192, 57, 43, 0.4);
|
||||
color: #8e2c21;
|
||||
}
|
||||
|
||||
.chip.tool {
|
||||
background: rgba(40, 80, 130, 0.12);
|
||||
border-color: rgba(40, 80, 130, 0.3);
|
||||
color: #2d5480;
|
||||
}
|
||||
|
||||
.ticket-hint {
|
||||
margin-top: 9px;
|
||||
font-size: 11px;
|
||||
color: #8a7963;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ---- the scorecard ---- */
|
||||
.judge-panel {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: stretch;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.judge-panel > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.judge-left {
|
||||
width: 33%;
|
||||
max-width: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
padding: 0 0 26px 26px;
|
||||
}
|
||||
|
||||
.judge-face {
|
||||
width: 210px;
|
||||
align-self: flex-start;
|
||||
filter: drop-shadow(0 12px 26px rgba(0, 0, 0, 0.6));
|
||||
}
|
||||
|
||||
.judge-lines {
|
||||
margin-top: 8px;
|
||||
max-width: 380px;
|
||||
}
|
||||
|
||||
.judge-lines p {
|
||||
font-size: 17px;
|
||||
line-height: 1.42;
|
||||
margin-bottom: 7px;
|
||||
color: var(--ink);
|
||||
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
|
||||
.judge-lines p:first-child {
|
||||
color: var(--ink-dim);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.judge-right {
|
||||
width: 380px;
|
||||
padding: 26px 26px 26px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.judge-order {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.09em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-dim);
|
||||
margin-bottom: 12px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.judge-card {
|
||||
background: rgba(12, 9, 8, 0.76);
|
||||
border: 1px solid rgba(243, 233, 220, 0.14);
|
||||
border-radius: 9px;
|
||||
padding: 14px 16px;
|
||||
backdrop-filter: blur(9px);
|
||||
}
|
||||
|
||||
.crit {
|
||||
display: grid;
|
||||
grid-template-columns: 96px 1fr auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.crit-name {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
|
||||
.crit-bar {
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 255, 255, 0.09);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.crit-fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.crit-detail {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
color: var(--ink-dim);
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.crit.total {
|
||||
border-top: 1px solid rgba(243, 233, 220, 0.16);
|
||||
margin-top: 7px;
|
||||
padding-top: 9px;
|
||||
}
|
||||
|
||||
.crit.total .crit-name,
|
||||
.crit.total .crit-detail {
|
||||
color: var(--gold);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.judge-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.judge-stamp {
|
||||
font-size: 56px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
padding: 4px 18px;
|
||||
border: 4px solid currentColor;
|
||||
border-radius: 7px;
|
||||
transform: rotate(-9deg);
|
||||
animation: stamp 0.42s cubic-bezier(0.2, 1.7, 0.4, 1);
|
||||
}
|
||||
|
||||
@keyframes stamp {
|
||||
0% {
|
||||
transform: rotate(-9deg) scale(3.2);
|
||||
opacity: 0;
|
||||
}
|
||||
60% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: rotate(-9deg) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.grade-S { color: #ffd76a; }
|
||||
.grade-A { color: #7fd07f; }
|
||||
.grade-B { color: #cfc4b2; }
|
||||
.grade-C { color: #d99a4e; }
|
||||
.grade-F { color: #d0483a; }
|
||||
|
||||
.judge-btns {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
font-family: var(--font);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.13em;
|
||||
padding: 10px 16px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--gold);
|
||||
background: var(--gold);
|
||||
color: #211a12;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
filter: brightness(1.12);
|
||||
}
|
||||
|
||||
.btn.ghost {
|
||||
background: transparent;
|
||||
color: var(--ink-dim);
|
||||
border-color: rgba(243, 233, 220, 0.24);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn.ghost:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* ---- the drawer ---- */
|
||||
.drawer-panel {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.drawer-card {
|
||||
position: absolute;
|
||||
top: 26px;
|
||||
left: 26px;
|
||||
width: 168px;
|
||||
padding: 14px 14px 12px;
|
||||
background: rgba(12, 9, 8, 0.78);
|
||||
border: 1px solid rgba(243, 233, 220, 0.16);
|
||||
border-radius: 9px;
|
||||
backdrop-filter: blur(9px);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.drawer-lbl {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.22em;
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
|
||||
.drawer-sil {
|
||||
color: var(--gold);
|
||||
margin: 6px 0 2px;
|
||||
}
|
||||
|
||||
.drawer-sil .sil {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.sil-name {
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--ink);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.drawer-timer {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.timer-bar {
|
||||
height: 5px;
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.timer-fill {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
border-radius: 3px;
|
||||
transition: width 0.2s linear;
|
||||
}
|
||||
|
||||
.timer-lbl {
|
||||
margin-top: 5px;
|
||||
font-size: 10px;
|
||||
font-style: italic;
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
|
||||
.drawer-hint {
|
||||
position: absolute;
|
||||
bottom: 26px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--ink-dim);
|
||||
text-shadow: 0 2px 10px #000;
|
||||
}
|
||||
|
||||
/* ---- title ---- */
|
||||
.title {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: radial-gradient(ellipse at 50% 45%, #241a15 0%, #0d0a08 75%);
|
||||
}
|
||||
|
||||
.title-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 40px;
|
||||
max-width: 900px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.title-art {
|
||||
width: 380px;
|
||||
max-width: 44vw;
|
||||
border-radius: 12px;
|
||||
filter: drop-shadow(0 20px 44px rgba(0, 0, 0, 0.7));
|
||||
}
|
||||
|
||||
.title-txt h1 {
|
||||
font-size: 62px;
|
||||
letter-spacing: 0.05em;
|
||||
line-height: 1;
|
||||
color: var(--paper);
|
||||
}
|
||||
|
||||
.title-sub {
|
||||
margin: 10px 0 26px;
|
||||
font-size: 16px;
|
||||
font-style: italic;
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
|
||||
.title-keys {
|
||||
margin-top: 26px;
|
||||
font-size: 11px;
|
||||
line-height: 1.9;
|
||||
letter-spacing: 0.08em;
|
||||
color: #6d6155;
|
||||
}
|
||||
|
||||
37
src/ui/hud.ts
Normal file
@ -0,0 +1,37 @@
|
||||
/** Tiny DOM helpers. The UI is an overlay; the game is the 3D scene. */
|
||||
|
||||
export function el<K extends keyof HTMLElementTagNameMap>(
|
||||
tag: K,
|
||||
cls?: string,
|
||||
parent?: HTMLElement,
|
||||
text?: string,
|
||||
): HTMLElementTagNameMap[K] {
|
||||
const e = document.createElement(tag);
|
||||
if (cls) e.className = cls;
|
||||
if (text !== undefined) e.textContent = text;
|
||||
if (parent) parent.appendChild(e);
|
||||
return e;
|
||||
}
|
||||
|
||||
export function clear(e: HTMLElement): void {
|
||||
while (e.firstChild) e.removeChild(e.firstChild);
|
||||
}
|
||||
|
||||
export const ui = document.getElementById('ui') as HTMLElement;
|
||||
|
||||
/** A panel that owns a chunk of the overlay and can be shown/hidden as a unit. */
|
||||
export class Panel {
|
||||
readonly root: HTMLElement;
|
||||
constructor(cls: string) {
|
||||
this.root = el('div', cls, ui);
|
||||
}
|
||||
show(v = true): void {
|
||||
this.root.style.display = v ? '' : 'none';
|
||||
}
|
||||
hide(): void {
|
||||
this.show(false);
|
||||
}
|
||||
dispose(): void {
|
||||
this.root.remove();
|
||||
}
|
||||
}
|
||||
55
src/ui/silhouette.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import type { Tool } from '../sim/cutlery';
|
||||
|
||||
/**
|
||||
* A schematic silhouette of a piece of cutlery, drawn from the same numbers the
|
||||
* mesh and the colliders use. That matters: the drawer's whole challenge is
|
||||
* telling a dessert fork from a dinner fork, and the card has to be honest about
|
||||
* the difference or the puzzle is a lie. Everything is drawn to a shared scale,
|
||||
* so a shorter piece really does look shorter.
|
||||
*/
|
||||
export function toolSilhouette(tool: Tool): string {
|
||||
const SCALE = 62; // px per world unit; shared across tools so sizes compare
|
||||
const L = tool.length * SCALE;
|
||||
const hw = (tool.headW / 2) * SCALE;
|
||||
const hl = tool.headL * SCALE;
|
||||
const handleL = L - hl - 0.12 * SCALE;
|
||||
const w = 132;
|
||||
const h = 150;
|
||||
const cx = w / 2;
|
||||
const top = (h - L) / 2;
|
||||
|
||||
const parts: string[] = [];
|
||||
// handle: tapered, butt at the bottom
|
||||
const bw = 0.085 * SCALE;
|
||||
const nw = 0.052 * SCALE;
|
||||
const y0 = top + hl + 0.12 * SCALE;
|
||||
parts.push(
|
||||
`<path d="M ${cx - nw} ${y0} L ${cx - bw} ${y0 + handleL * 0.55} Q ${cx - bw} ${y0 + handleL} ${cx} ${y0 + handleL} Q ${cx + bw} ${y0 + handleL} ${cx + bw} ${y0 + handleL * 0.55} L ${cx + nw} ${y0} Z"/>`,
|
||||
);
|
||||
|
||||
if (tool.kind === 'knife') {
|
||||
const round = tool.id === 'butter_knife' || tool.id === 'spreader';
|
||||
parts.push(
|
||||
round
|
||||
? `<path d="M ${cx - nw} ${y0} L ${cx - hw} ${top + hl * 0.45} Q ${cx - hw} ${top} ${cx} ${top} Q ${cx + hw} ${top} ${cx + hw} ${top + hl * 0.45} L ${cx + nw} ${y0} Z"/>`
|
||||
: `<path d="M ${cx - nw} ${y0} L ${cx - hw} ${top + hl * 0.45} L ${cx - hw * 0.5} ${top + hl * 0.07} Q ${cx} ${top - 2} ${cx + hw * 0.7} ${top + hl * 0.14} L ${cx + hw} ${top + hl * 0.45} L ${cx + nw} ${y0} Z"/>`,
|
||||
);
|
||||
} else if (tool.kind === 'fork') {
|
||||
const shoulderY = top + hl * 0.66;
|
||||
parts.push(
|
||||
`<path d="M ${cx - nw} ${y0} L ${cx - hw} ${shoulderY} L ${cx + hw} ${shoulderY} L ${cx + nw} ${y0} Z"/>`,
|
||||
);
|
||||
const tw = (hw * 2) / 7;
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const x = cx + (i - 1.5) * ((hw * 2) / 4);
|
||||
parts.push(`<rect x="${x - tw / 2}" y="${top}" width="${tw}" height="${shoulderY - top}" rx="${tw / 2}"/>`);
|
||||
}
|
||||
} else {
|
||||
parts.push(`<ellipse cx="${cx}" cy="${top + hl * 0.42}" rx="${hw}" ry="${hl * 0.46}"/>`);
|
||||
parts.push(
|
||||
`<path d="M ${cx - nw} ${y0} L ${cx - nw * 1.2} ${top + hl * 0.7} L ${cx + nw * 1.2} ${top + hl * 0.7} L ${cx + nw} ${y0} Z"/>`,
|
||||
);
|
||||
}
|
||||
|
||||
return `<svg class="sil" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" aria-hidden="true"><g fill="currentColor">${parts.join('')}</g></svg>`;
|
||||
}
|
||||
26
src/ui/title.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { el, Panel } from './hud';
|
||||
|
||||
/** The front door. One button, one piece of art, one joke. */
|
||||
export class Title {
|
||||
private panel: Panel;
|
||||
|
||||
constructor(day: number, onStart: () => void) {
|
||||
this.panel = new Panel('title');
|
||||
const wrap = el('div', 'title-wrap', this.panel.root);
|
||||
const art = el('img', 'title-art', wrap);
|
||||
art.src = '/assets/img/title_art.png';
|
||||
art.alt = '';
|
||||
const txt = el('div', 'title-txt', wrap);
|
||||
el('h1', undefined, txt, 'TOASTSIM');
|
||||
el('p', 'title-sub', txt, 'Bread goes in. You are judged.');
|
||||
const btn = el('button', 'btn', txt, day > 1 ? `RESUME — DAY ${day}` : 'START DAY 1');
|
||||
btn.addEventListener('click', () => {
|
||||
this.panel.dispose();
|
||||
onStart();
|
||||
});
|
||||
const keys = el('div', 'title-keys', txt);
|
||||
el('div', undefined, keys, 'SPACE — lever down, then pop');
|
||||
el('div', undefined, keys, 'DRAG — spread · WHEEL — tilt the knife');
|
||||
el('div', undefined, keys, 'ENTER — serve it to him');
|
||||
}
|
||||
}
|
||||