M1: toasting — browning sim, toaster, lever, and the pop
Playable: pick a bread, set the dial, drop the lever, watch it brown, pop it onto the plate. No timer readout — you go by smell. - toasting.ts: heat map from real toaster behaviour (element stripes, cool top edge where the slice stands proud of the slot, edge falloff, per-run noise bias). Moisture must boil off before the crust browns at full rate, so sourdough stalls then catches up; sugar browns and burns early. Rate is measured against the heat map's actual ~0.77 mean, not guessed: power 6 -> golden 0.511 at 10s, verified in-browser. - Props on a real scale (1 unit ~ 11cm, one slice wide) — the toaster is meant to dwarf the bread. - The pop solves a genuine ballistic arc from slot to plate. Three bugs found by driving it rather than trusting the compile: - valueNoise2D read one past its grid at x=w-1, so heatBias was NaN along an edge, which poisoned mean browning to NaN — i.e. every judge score would have been NaN. Clamped. - erode() clamped at the field border, so border texels could never erode out of the mask and kept those NaNs in scope. Off-grid now counts as outside. - Timer.connect(document) zeroes dt whenever document.hidden is true, which froze the sim solid in an embedded browser. Dropped; rAF already pauses for hidden tabs. App.step(dt) is now exposed so the game can be driven deterministically for verification. Also: screen shake was mutating the camera permanently instead of offsetting it per-render, and the plate's lathe profile touched the axis (degenerate triangles -> starburst normals) — rebuilt from primitives. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
10adb268a0
commit
67cfe56bb9
BIN
public/assets/img/judge_neutral.png
Normal file
BIN
public/assets/img/judge_neutral.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 878 KiB |
BIN
public/assets/models/mitey_jar.glb
Normal file
BIN
public/assets/models/mitey_jar.glb
Normal file
Binary file not shown.
BIN
public/assets/models/pb_jar.glb
Normal file
BIN
public/assets/models/pb_jar.glb
Normal file
Binary file not shown.
BIN
public/assets/models/plate.glb
Normal file
BIN
public/assets/models/plate.glb
Normal file
Binary file not shown.
@ -104,8 +104,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();
|
||||
@ -143,24 +146,46 @@ export class App {
|
||||
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();
|
||||
}
|
||||
@ -182,7 +207,7 @@ 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));
|
||||
scene.add(new THREE.HemisphereLight(0xbfc6d2, 0x4a3526, 0.7));
|
||||
const fill = new THREE.DirectionalLight(0xbdd6ff, 0.45);
|
||||
fill.position.set(-3, 2.2, -2.5);
|
||||
scene.add(fill);
|
||||
|
||||
@ -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;
|
||||
|
||||
60
src/main.ts
60
src/main.ts
@ -1,64 +1,20 @@
|
||||
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 { KitchenView } from './scenes/kitchen';
|
||||
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);
|
||||
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 kitchen = new KitchenView(app, 3);
|
||||
app.scene.add(kitchen.root);
|
||||
app.setView(kitchen);
|
||||
|
||||
const slice = new Slice(BREADS.white, new Rng(7));
|
||||
slice.mesh.position.y = 0.12;
|
||||
app.scene.add(slice.mesh);
|
||||
app.camera.position.set(0.05, 3.15, 5.6);
|
||||
app.camera.lookAt(0.0, 0.85, -0.15);
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
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;
|
||||
});
|
||||
(window as unknown as { __t: unknown }).__t = { app, kitchen, THREE };
|
||||
|
||||
app.start();
|
||||
|
||||
200
src/scenes/kitchen.ts
Normal file
200
src/scenes/kitchen.ts
Normal file
@ -0,0 +1,200 @@
|
||||
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, coolStep, readToast, smellCue, toastStep } from '../sim/toasting';
|
||||
import { makeBench, makePlate, makeToaster, type Toaster } from './props';
|
||||
import { el, Panel } from '../ui/hud';
|
||||
|
||||
const TOASTER_X = -1.5;
|
||||
const PLATE_X = 1.45;
|
||||
const PLATE_Z = 0.55;
|
||||
const PLATE_Y = 0.05;
|
||||
|
||||
type Phase = 'ready' | 'toasting' | 'flying' | 'landed';
|
||||
|
||||
/**
|
||||
* The bench. Owns the toaster, the plate and the current slice, and runs the
|
||||
* toasting phase: dial, lever, browning, and the pop.
|
||||
*/
|
||||
export class KitchenView implements View {
|
||||
readonly root = new THREE.Group();
|
||||
private toaster: Toaster;
|
||||
private plate: THREE.Group;
|
||||
private slice!: Slice;
|
||||
private heat!: Float32Array;
|
||||
private rng: Rng;
|
||||
|
||||
phase: Phase = 'ready';
|
||||
power = 6;
|
||||
private leverT = 0; // 0 up, 1 down
|
||||
private vel = new THREE.Vector3();
|
||||
private spin = 0;
|
||||
private toastSeconds = 0;
|
||||
|
||||
private panel: Panel;
|
||||
private cueEl!: HTMLElement;
|
||||
private hintEl!: HTMLElement;
|
||||
private dialEl!: HTMLInputElement;
|
||||
private breadEl!: HTMLSelectElement;
|
||||
|
||||
/** Fired when the slice has landed on the plate — M2 picks up from here. */
|
||||
onLanded: ((slice: Slice) => 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);
|
||||
this.plate = makePlate();
|
||||
this.plate.position.set(PLATE_X, 0, PLATE_Z);
|
||||
this.root.add(this.plate);
|
||||
|
||||
this.panel = new Panel('toast-panel');
|
||||
this.buildUi();
|
||||
this.loadBread('white');
|
||||
}
|
||||
|
||||
private buildUi(): void {
|
||||
const p = this.panel.root;
|
||||
const row = el('div', 'row', p);
|
||||
el('label', 'lbl', row, 'BROWNING');
|
||||
this.dialEl = el('input', 'dial', row);
|
||||
this.dialEl.type = 'range';
|
||||
this.dialEl.min = '1';
|
||||
this.dialEl.max = '10';
|
||||
this.dialEl.step = '1';
|
||||
this.dialEl.value = String(this.power);
|
||||
const val = el('span', 'val', row, String(this.power));
|
||||
this.dialEl.addEventListener('input', () => {
|
||||
this.power = +this.dialEl.value;
|
||||
val.textContent = this.dialEl.value;
|
||||
this.toaster.dial.rotation.x = -((this.power - 1) / 9) * 2.4;
|
||||
});
|
||||
|
||||
const row2 = el('div', 'row', p);
|
||||
el('label', 'lbl', row2, 'BREAD');
|
||||
this.breadEl = el('select', 'sel', row2);
|
||||
for (const id of Object.keys(BREADS)) {
|
||||
const o = el('option', undefined, this.breadEl, BREADS[id as BreadId].name);
|
||||
o.value = id;
|
||||
}
|
||||
this.breadEl.addEventListener('change', () => this.loadBread(this.breadEl.value as BreadId));
|
||||
|
||||
this.cueEl = el('div', 'cue', p, 'smells like bread');
|
||||
this.hintEl = el('div', 'hint', p, 'SPACE — push the lever down');
|
||||
}
|
||||
|
||||
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.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.placeInSlot(0);
|
||||
this.hintEl.textContent = 'SPACE — push the lever down';
|
||||
}
|
||||
|
||||
get currentSlice(): Slice {
|
||||
return this.slice;
|
||||
}
|
||||
|
||||
/** t: 0 = sitting proud of the slot, 1 = fully down. */
|
||||
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); // standing, dome up
|
||||
}
|
||||
|
||||
enter(): void {
|
||||
this.panel.show();
|
||||
}
|
||||
exit(): void {
|
||||
this.panel.hide();
|
||||
}
|
||||
|
||||
private pushDown(): void {
|
||||
if (this.phase !== 'ready') return;
|
||||
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);
|
||||
|
||||
// Solve a real arc from the slot to the plate rather than tweening: the
|
||||
// slice should look thrown, because it is.
|
||||
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 - 0) / T;
|
||||
this.app.shake = 0.045;
|
||||
this.hintEl.textContent = '';
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
// lever + slice ride down together
|
||||
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.phase = 'landed';
|
||||
this.app.shake = 0.02;
|
||||
this.onLanded?.(this.slice);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.phase !== 'toasting') coolStep(this.slice, dt);
|
||||
|
||||
const t = readToast(this.slice);
|
||||
this.cueEl.textContent = smellCue(t);
|
||||
this.cueEl.style.color = t.smoke > 0.45 ? '#e2603a' : '';
|
||||
this.slice.sync();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.panel.dispose();
|
||||
this.slice.dispose();
|
||||
}
|
||||
}
|
||||
186
src/scenes/props.ts
Normal file
186
src/scenes/props.ts
Normal file
@ -0,0 +1,186 @@
|
||||
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 top = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(20, 0.5, 9),
|
||||
new THREE.MeshStandardMaterial({ color: 0x9a6134, roughness: 0.72 }),
|
||||
);
|
||||
top.position.y = -0.25;
|
||||
top.receiveShadow = true;
|
||||
g.add(top);
|
||||
// splashback, so the scene has a horizon instead of fog
|
||||
const wall = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(20, 7, 0.3),
|
||||
new THREE.MeshStandardMaterial({ color: 0x5d5348, roughness: 0.9 }),
|
||||
);
|
||||
wall.position.set(0, 3.5, -4.4);
|
||||
wall.receiveShadow = true;
|
||||
g.add(wall);
|
||||
return g;
|
||||
}
|
||||
@ -230,19 +230,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;
|
||||
}
|
||||
}
|
||||
|
||||
103
src/sim/toasting.ts
Normal file
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';
|
||||
}
|
||||
@ -49,3 +49,70 @@ 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;
|
||||
}
|
||||
|
||||
37
src/ui/hud.ts
Normal file
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();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user