diff --git a/public/assets/img/judge_neutral.png b/public/assets/img/judge_neutral.png new file mode 100644 index 0000000..2c515a7 Binary files /dev/null and b/public/assets/img/judge_neutral.png differ diff --git a/public/assets/models/mitey_jar.glb b/public/assets/models/mitey_jar.glb new file mode 100644 index 0000000..030ef76 Binary files /dev/null and b/public/assets/models/mitey_jar.glb differ diff --git a/public/assets/models/pb_jar.glb b/public/assets/models/pb_jar.glb new file mode 100644 index 0000000..f759dd0 Binary files /dev/null and b/public/assets/models/pb_jar.glb differ diff --git a/public/assets/models/plate.glb b/public/assets/models/plate.glb new file mode 100644 index 0000000..8c0a067 Binary files /dev/null and b/public/assets/models/plate.glb differ diff --git a/src/core/app.ts b/src/core/app.ts index 948e293..ad0317e 100644 --- a/src/core/app.ts +++ b/src/core/app.ts @@ -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); diff --git a/src/core/rng.ts b/src/core/rng.ts index a983b40..7cbf9ba 100644 --- a/src/core/rng.ts +++ b/src/core/rng.ts @@ -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; diff --git a/src/main.ts b/src/main.ts index e79448b..44ba78c 100644 --- a/src/main.ts +++ b/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(); diff --git a/src/scenes/kitchen.ts b/src/scenes/kitchen.ts new file mode 100644 index 0000000..d16c315 --- /dev/null +++ b/src/scenes/kitchen.ts @@ -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(); + } +} diff --git a/src/scenes/props.ts b/src/scenes/props.ts new file mode 100644 index 0000000..9f1568b --- /dev/null +++ b/src/scenes/props.ts @@ -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; +} diff --git a/src/sim/slice.ts b/src/sim/slice.ts index 1f989e9..fa0df9d 100644 --- a/src/sim/slice.ts +++ b/src/sim/slice.ts @@ -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; } } diff --git a/src/sim/toasting.ts b/src/sim/toasting.ts new file mode 100644 index 0000000..3d8571a --- /dev/null +++ b/src/sim/toasting.ts @@ -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'; +} diff --git a/src/style.css b/src/style.css index 75fb721..30d0385 100644 --- a/src/style.css +++ b/src/style.css @@ -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; +} diff --git a/src/ui/hud.ts b/src/ui/hud.ts new file mode 100644 index 0000000..e2f6c4c --- /dev/null +++ b/src/ui/hud.ts @@ -0,0 +1,37 @@ +/** Tiny DOM helpers. The UI is an overlay; the game is the 3D scene. */ + +export function el( + 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(); + } +}