diff --git a/src/dev-juice.ts b/src/dev-juice.ts new file mode 100644 index 0000000..8843a63 --- /dev/null +++ b/src/dev-juice.ts @@ -0,0 +1,188 @@ +import * as THREE from 'three'; +import type { App } from './core/app'; +import { JuiceView } from './scenes/juice'; +import type { JuicerId } from './sim/juicing'; + +/** + * Juice-station dev harness — a SEPARATE installer from dev.ts so the two lanes + * don't collide. It owns its own JuiceView (the game doesn't route orders here + * yet — that's the game lane's job), adds it to the scene, and drives the real + * input path: real pointer circles around the reamer for the twist, `press` + * injected the way dev.ts injects kitchen.power. + * + * The reviewer verifies with a couple of calls, e.g. + * __tj.enter({ seeds: 5 }); __tj.twist(3, 0.7); __tj.stats() + * __tj.enter({ juicer: 'ribbed_deluxe', seeds: 5 }); __tj.twist(3, 0.95); __tj.stats() + * __tj.enter({ offset: 0.2 }); __tj.twist(6, 0.7); __tj.stats() // capped yield + * __tj.enter(); __tj.mash(1.0, 0.9); __tj.stats() // tearing + spray + * + * Dev builds only. + */ +export function installJuiceHarness(app: App): void { + const view = new JuiceView(app); + app.scene.add(view.root); + // Hidden until __tj.enter() makes it the active view — otherwise it renders + // on top of whatever the game is showing at startup. + view.root.visible = false; + const sessionOf = () => + (view as unknown as { + session: { + placed: boolean; + extracted: number; + theoretical: number; + pulp: number; + bitterness: number; + seedsTotal: number; + seedsReleased: number; + seedsCaught: number; + seedsInGlass: number; + revolutions: number; + } | null; + }).session; + + const v = new THREE.Vector3(); + const point = (x: number, y: number, z: number, down: boolean) => { + v.set(x, y, z).project(app.camera); + app.input.ndc.set(v.x, v.y); + app.input.down = down; + }; + const run = (frames: number, dt = 1 / 60) => { + for (let i = 0; i < frames; i++) app.step(dt); + }; + + const CONE_TOP = 0.92; + const R = 0.3; + + const harness = { + app, + view, + THREE, + run, + point, + + /** Reset the station and make it the active view. */ + enter( + opts: { juicer?: JuicerId; offset?: number; wedge?: number; seeds?: number } = {}, + ) { + const { juicer = 'pyramid_classic', offset = 0, wedge = 0, seeds } = opts; + view.reset(juicer, { offset, wedge }, `juice the orange (${juicer})`, seeds); + app.setView(view); + run(3); // let the view take the camera before we project points + return harness.place(); + }, + + /** Seat the half on the cone (the drag's commit). */ + place() { + view.placeHalf(); + run(1); + return harness.stats(); + }, + + /** + * Press-and-twist for `revolutions` turns at a fixed `pressure` (0..1). + * `dir` +1/-1 sets the twist direction. Press is injected; the twist itself + * is real pointer motion around the cone, so the sim sees exactly what a + * hand would produce. + */ + twist(revolutions = 3, pressure = 0.7, dir: 1 | -1 = 1) { + view.autoPress = false; + view.press = pressure; + const steps = Math.max(1, Math.round(revolutions * 48)); + for (let i = 0; i <= steps; i++) { + const a = dir * (i / 48) * Math.PI * 2; + point(Math.cos(a) * R, CONE_TOP, Math.sin(a) * R, true); + run(1); + } + app.input.down = false; + run(1); + return harness.stats(); + }, + + /** + * Work the wrist: `reversals` back-and-forth half-turns. This is what earns + * the reversal bonus bites — the number to watch is extracted climbing a + * touch faster than the same sweep in one direction. + */ + wristle(reversals = 6, pressure = 0.7) { + view.autoPress = false; + view.press = pressure; + // One running angle so each reversal continues smoothly from the last — + // no position jump, so the sim sees a clean back-and-forth wrist. + let ang = 0; + let dir: 1 | -1 = 1; + for (let r = 0; r < reversals; r++) { + for (let i = 0; i < 24; i++) { + ang += dir * (Math.PI / 24); // a smooth half-turn + point(Math.cos(ang) * R, CONE_TOP, Math.sin(ang) * R, true); + run(1); + } + dir = (dir === 1 ? -1 : 1) as 1 | -1; + } + app.input.down = false; + run(1); + return harness.stats(); + }, + + /** + * Lean without twisting — hold `pressure` on the cone for `seconds` and go + * nowhere. This is the mash: it should tear pulp and spray the bench, not + * fill the glass. + */ + mash(seconds = 1, pressure = 0.9) { + view.autoPress = false; + view.press = pressure; + const frames = Math.round(seconds * 60); + for (let i = 0; i < frames; i++) { + point(R, CONE_TOP, 0, true); // fixed point → zero sweep + run(1); + } + app.input.down = false; + run(1); + return harness.stats(); + }, + + /** Fish one pip out of the glass (costs the player service time in play). */ + fish() { + view.fishOnePip(); + run(1); + return harness.stats(); + }, + + /** Everything the judge and the readouts will read. */ + stats() { + const s = sessionOf(); + const r = view.result(); + const round = (n: number) => +n.toFixed(3); + return { + placed: s?.placed ?? false, + yieldOfOrange: round(r.yieldOfOrange), + yieldOfReachable: round(r.yieldOfReachable), + theoretical: round(r.theoretical), + extracted: round(r.extracted), + pips: r.pips, + seedsCaught: r.seedsCaught, + seedsReleased: s?.seedsReleased ?? 0, + seedsTotal: s?.seedsTotal ?? 0, + bitterness: round(r.bitterness), + pulp: round(r.pulp), + revolutions: round(s?.revolutions ?? 0), + bench: { + mass: round(r.bench?.mass ?? 0), + spreadFrac: round(r.bench?.spreadFrac ?? 0), + solids: r.bench?.solids ?? 0, + }, + benchWord: view.mess.benchWord(), + juicer: r.juicerName, + }; + }, + + /** Move the camera 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 { __tj: unknown }).__tj = harness; +} diff --git a/src/main.ts b/src/main.ts index f5c1aae..80bf34f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -16,6 +16,7 @@ void game.init(); if (import.meta.env.DEV) { void import('./dev').then((m) => m.installDevHarness(app, game)); + void import('./dev-juice').then((m) => m.installJuiceHarness(app)); } app.start(); diff --git a/src/scenes/juice.ts b/src/scenes/juice.ts new file mode 100644 index 0000000..711a04a --- /dev/null +++ b/src/scenes/juice.ts @@ -0,0 +1,533 @@ +import * as THREE from 'three'; +import type { App, View } from '../core/app'; +import { audio } from '../core/audio'; +import { INGREDIENTS } from '../sim/ingredients'; +import { + fishPip, + juiceResult, + juiceTwist, + JUICERS, + newJuiceSession, + placeHalf, + theoreticalYield, + yieldWord, + type JuiceResult, + type JuiceSession, + type JuicerId, +} from '../sim/juicing'; +import { Mess } from '../sim/mess'; +import { Rng } from '../core/rng'; +import { el, Panel } from '../ui/hud'; +import { loadProp } from './assets'; + +/** + * The juice station. One reamer, one orange half, one glass. + * + * It mirrors the prep bench on purpose: procedural stand-in silhouettes (the + * ribbed pyramid IS gameplay — the ribs are why the twist bites), a mess field + * that remembers the spray, a GLB prop that drops in over the stand-in if it + * ever renders, and a HUD that reads the same numbers the judge will. + * + * The one verb is the jar's swirl, pointed down: press the half onto the cone + * (hold to lean in — the dwell IS the press) and twist. Angular sweep of the + * pointer around the cone becomes juice, one frame at a time. Twist rate is the + * juice rate; that is the whole feel. + */ + +const TRAY_Y = 0.06; +const TRAY_W = 3.2; +const TRAY_D = 2.4; +/** Reamer tip height above the tray — the plane the twist angle is measured on. */ +const CONE_TOP = 0.92; +const GLASS_X = 1.9; + +/** Press ramps in while you hold the half down against the cone. */ +const PRESS_RAMP = 1.6; + +export class JuiceView implements View { + readonly root = new THREE.Group(); + readonly mess = new Mess(96); + + private session: JuiceSession | null = null; + private orangeColor = new THREE.Color().setRGB(...INGREDIENTS.orange.colors.flesh, THREE.SRGBColorSpace); + + private reamer!: THREE.Group; + private half: THREE.Group | null = null; + private glass!: THREE.Group; + private juiceCol!: THREE.Mesh; + private pipMeshes: THREE.Mesh[] = []; + private pipGeo = new THREE.SphereGeometry(0.02, 8, 6); + private pipMat = new THREE.MeshStandardMaterial({ color: 0xe8d9a6, roughness: 0.5 }); + + private messTexData: Uint8Array; + private messTex: THREE.DataTexture; + private messVersion = -1; + private solidsMesh: THREE.InstancedMesh; + + private ray = new THREE.Raycaster(); + private twistPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -CONE_TOP); + private trayPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -(TRAY_Y * 2)); + private hitPt = new THREE.Vector3(); + private prevAngle: number | null = null; + private wipePrev: THREE.Vector3 | null = null; + private holdT = 0; + private grabbingHalf = false; + private wasDown = false; + + /** Downforce 0..1. Set by the pointer hold in play; the harness writes it. */ + press = 0; + /** When false, the harness owns `press` and the hold-ramp is skipped. */ + autoPress = true; + + private rng = new Rng(0x0ea9e); + private panel: Panel; + private askEl!: HTMLElement; + private yieldEl!: HTMLElement; + private yieldBar!: HTMLElement; + private yieldFill!: HTMLElement; + private yieldCeil!: HTMLElement; + private pipsEl!: HTMLElement; + private hintEl!: HTMLElement; + + onDone: ((result: JuiceResult) => void) | null = null; + + constructor(private app: App) { + this.buildScenery(); + const n = this.mess.field.n; + this.messTexData = new Uint8Array(n * n * 4); + this.messTex = new THREE.DataTexture(this.messTexData, n, n, THREE.RGBAFormat); + this.messTex.minFilter = THREE.LinearFilter; + this.messTex.magFilter = THREE.LinearFilter; + const stain = new THREE.Mesh( + new THREE.PlaneGeometry(TRAY_W, TRAY_D), + new THREE.MeshBasicMaterial({ map: this.messTex, transparent: true, depthWrite: false }), + ); + stain.rotation.x = -Math.PI / 2; + stain.position.set(0, TRAY_Y * 2 + 0.004, 0); + this.root.add(stain); + + const seedGeo = new THREE.SphereGeometry(0.024, 8, 6); + seedGeo.scale(1, 0.5, 0.7); + this.solidsMesh = new THREE.InstancedMesh( + seedGeo, + new THREE.MeshStandardMaterial({ color: 0xe8d9a6, roughness: 0.6 }), + 256, + ); + this.solidsMesh.count = 0; + this.solidsMesh.castShadow = true; + this.root.add(this.solidsMesh); + + this.panel = new Panel('juice-panel'); + this.buildUi(); + this.panel.hide(); + } + + private buildUi(): void { + const p = this.panel.root; + const card = el('div', 'slicer-card', p); + el('div', 'drawer-lbl', card, 'JUICE IT'); + this.askEl = el('div', 'slicer-ask', card, ''); + this.yieldEl = el('div', 'slicer-thick', card, ''); + this.yieldBar = el('div', 'timer-bar', card); + this.yieldFill = el('div', 'timer-fill', this.yieldBar); + // The ceiling the halve left — the bar physically can't fill past it. + this.yieldCeil = el('div', 'juice-ceil', this.yieldBar); + this.yieldCeil.style.cssText = + 'position:absolute;top:0;bottom:0;width:2px;background:#e2603a;pointer-events:none;'; + this.yieldBar.style.position = 'relative'; + this.pipsEl = el('div', 'slicer-wobble', card, ''); + this.hintEl = el('div', 'drawer-hint', p, ''); + } + + private buildScenery(): void { + const bench = new THREE.Mesh( + new THREE.BoxGeometry(24, 0.4, 12), + new THREE.MeshStandardMaterial({ color: 0x4a3524, roughness: 0.85 }), + ); + bench.position.y = -0.2; + bench.receiveShadow = true; + this.root.add(bench); + + this.reamer = this.buildReamer(); + this.root.add(this.reamer); + void loadProp('/assets/models/juicer_pyramid.glb', 1.1) + .then((prop) => { + prop.position.set(0, TRAY_Y * 2, 0); + // Keep the procedural cone as the twist target; hide only the visible dish. + const dish = this.reamer.getObjectByName('dish'); + if (dish) dish.visible = false; + this.root.add(prop); + }) + .catch(() => undefined); + + this.glass = this.buildGlass(); + this.glass.position.set(GLASS_X, TRAY_Y * 2, 0.2); + this.root.add(this.glass); + void loadProp('/assets/models/juice_glass.glb', 0.7) + .then((prop) => { + prop.position.set(GLASS_X, TRAY_Y * 2, 0.2); + this.root.add(prop); + }) + .catch(() => undefined); + } + + /** The ribbed pyramid, procedural: a fluted cone in a shallow moat dish. */ + private buildReamer(): THREE.Group { + const g = new THREE.Group(); + const glassMat = new THREE.MeshStandardMaterial({ + color: 0x9fb7c4, + roughness: 0.25, + metalness: 0.1, + transparent: true, + opacity: 0.55, + }); + // Dish / moat. + const dish = new THREE.Mesh(new THREE.CylinderGeometry(0.62, 0.5, 0.16, 32), glassMat); + dish.name = 'dish'; + dish.position.y = TRAY_Y * 2 + 0.08; + dish.castShadow = true; + dish.receiveShadow = true; + g.add(dish); + // The fluted cone: a low-segment cone reads faceted, plus raised ribs. + const coneH = CONE_TOP - (TRAY_Y * 2 + 0.16); + const cone = new THREE.Mesh(new THREE.ConeGeometry(0.42, coneH, 10), glassMat); + cone.position.y = TRAY_Y * 2 + 0.16 + coneH / 2; + cone.castShadow = true; + g.add(cone); + const ribMat = new THREE.MeshStandardMaterial({ + color: 0xb9cdd6, + roughness: 0.3, + transparent: true, + opacity: 0.7, + }); + for (let i = 0; i < 8; i++) { + const a = (i / 8) * Math.PI * 2; + const rib = new THREE.Mesh(new THREE.BoxGeometry(0.03, coneH * 0.9, 0.09), ribMat); + rib.position.set(Math.cos(a) * 0.22, cone.position.y, Math.sin(a) * 0.22); + rib.rotation.y = -a; + rib.rotation.z = 0.12; + g.add(rib); + } + return g; + } + + private buildGlass(): THREE.Group { + const g = new THREE.Group(); + const glassMat = new THREE.MeshStandardMaterial({ + color: 0xcfe0e8, + roughness: 0.12, + metalness: 0.05, + transparent: true, + opacity: 0.32, + }); + const wall = new THREE.Mesh(new THREE.CylinderGeometry(0.24, 0.2, 0.62, 28, 1, true), glassMat); + wall.position.y = 0.31; + g.add(wall); + const base = new THREE.Mesh(new THREE.CylinderGeometry(0.2, 0.2, 0.02, 28), glassMat); + base.position.y = 0.01; + g.add(base); + // The juice column: scaled up as the glass fills. + this.juiceCol = new THREE.Mesh( + new THREE.CylinderGeometry(0.22, 0.185, 1, 28), + new THREE.MeshStandardMaterial({ color: this.orangeColor, roughness: 0.35 }), + ); + this.juiceCol.position.y = 0.02; + this.juiceCol.scale.y = 0.0001; + g.add(this.juiceCol); + return g; + } + + /** + * A fresh half on a chosen reamer. `halve` is the cut quality from the prep + * bench (offset/wedge) — a butchered halve caps the reachable juice. + */ + reset( + juicerId: JuicerId, + halve: { offset: number; wedge: number }, + askText: string, + seeds?: number, + ): void { + const reachable = theoreticalYield(halve); + const seedCount = seeds ?? this.rng.int(3, 7); + this.session = newJuiceSession(INGREDIENTS.orange, JUICERS[juicerId], reachable, seedCount); + this.mess.reset(); + this.press = 0; + this.holdT = 0; + this.prevAngle = null; + this.wipePrev = null; + this.grabbingHalf = false; + this.autoPress = true; + + if (this.half) this.root.remove(this.half); + this.half = this.buildHalf(); + // Start it beside the reamer, waiting to be seated. + this.half.position.set(-1.1, TRAY_Y * 2 + 0.18, 0.4); + this.root.add(this.half); + for (const m of this.pipMeshes) this.glass.remove(m); + this.pipMeshes = []; + this.juiceCol.scale.y = 0.0001; + + this.askEl.textContent = askText; + this.yieldCeil.style.left = `${Math.round(this.session.theoretical * 100)}%`; + this.hintEl.textContent = 'drag the half onto the cone · hold to press, twist to juice · ENTER serves'; + } + + private buildHalf(): THREE.Group { + const g = new THREE.Group(); + const r = INGREDIENTS.orange.size / 2; + const skin = new THREE.MeshStandardMaterial({ + color: new THREE.Color().setRGB(...INGREDIENTS.orange.colors.skin, THREE.SRGBColorSpace), + roughness: 0.6, + }); + // Top hemisphere = the skin dome. + const dome = new THREE.Mesh(new THREE.SphereGeometry(r, 24, 14, 0, Math.PI * 2, 0, Math.PI / 2), skin); + dome.scale.set(1.05, 0.92, 1.05); + dome.castShadow = true; + g.add(dome); + // The cut face, segments radiating — flesh side, faces down onto the cone. + const face = new THREE.Mesh( + new THREE.CircleGeometry(r * 0.98, 20), + new THREE.MeshStandardMaterial({ color: this.orangeColor, roughness: 0.5 }), + ); + face.rotation.x = Math.PI / 2; + face.position.y = -0.002; + g.add(face); + return g; + } + + enter(): void { + this.panel.show(); + } + exit(): void { + this.panel.hide(); + } + + /** Seat the half on the cone — the drag's commit, also the harness hook. */ + placeHalf(): void { + const s = this.session; + if (!s || s.placed) return; + placeHalf(s); + if (this.half) this.half.position.set(0, CONE_TOP - 0.02, 0); + this.grabbingHalf = false; + this.hintEl.textContent = 'hold to press · twist round the cone · reverse for a bite · ENTER serves'; + } + + private trayUV(): { u: number; v: number } | null { + if (!this.ray.ray.intersectPlane(this.trayPlane, this.hitPt)) return null; + const u = (this.hitPt.x + TRAY_W / 2) / TRAY_W; + const v = (this.hitPt.z + TRAY_D / 2) / TRAY_D; + if (u < 0 || u > 1 || v < 0 || v > 1) return null; + return { u, v }; + } + + update(dt: number): void { + const inp = this.app.input; + this.app.camera.position.set(1.05, 1.85, 3.5); + this.app.camera.lookAt(0.35, 0.55, 0.05); + const s = this.session; + if (!s) return; + + this.ray.setFromCamera(inp.ndc, this.app.camera); + const justDown = inp.down && !this.wasDown; + + if (!s.placed) { + this.handlePlacement(inp.down); + } else if (justDown && this.clickPip()) { + // Clicked a pip in the glass — fished it out, no juicing this frame. + } else { + this.handleJuicing(dt, inp.down); + } + this.wasDown = inp.down; + + // Serve. + if (inp.justPressed('Enter') && this.onDone && s.placed) { + const cb = this.onDone; + this.onDone = null; + cb(this.result()); + return; + } + + this.syncGlass(); + this.syncMess(); + this.updateHud(); + } + + private handlePlacement(down: boolean): void { + if (!this.half) return; + if (down && this.ray.ray.intersectPlane(this.trayPlane, this.hitPt)) { + // Grab if near the half, then it follows the pointer on the tray. + const near = this.hitPt.distanceTo(this.half.position) < 0.6; + if (this.grabbingHalf || near) { + this.grabbingHalf = true; + this.half.position.set(this.hitPt.x, TRAY_Y * 2 + 0.18, this.hitPt.z); + } + } else if (this.grabbingHalf) { + // Released: seat it if it's over the cone, else it stays where dropped. + const overCone = Math.hypot(this.half.position.x, this.half.position.z) < 0.55; + if (overCone) this.placeHalf(); + else this.grabbingHalf = false; + } + } + + private handleJuicing(dt: number, down: boolean): void { + const s = this.session!; + + // Press: hold the half down and it leans in (the dwell is the downforce). + if (this.autoPress) { + const onCone = down && this.overCone(); + if (onCone) { + this.holdT += dt; + this.press = Math.min(1, this.holdT * PRESS_RAMP); + } else { + this.holdT = 0; + this.press = Math.max(0, this.press - dt * 3); + } + } + + // Twist: angular sweep of the pointer around the cone, on the tip plane. + let dTheta = 0; + if (down && this.ray.ray.intersectPlane(this.twistPlane, this.hitPt)) { + const ang = Math.atan2(this.hitPt.z, this.hitPt.x); + if (this.prevAngle !== null) { + let d = ang - this.prevAngle; + if (d > Math.PI) d -= Math.PI * 2; + if (d < -Math.PI) d += Math.PI * 2; + dTheta = d; + } + this.prevAngle = ang; + } else { + this.prevAngle = null; + } + + // Off the cone with the button down and no twist target: wipe the tray. + if (down && !this.overCone() && this.press < 0.05) { + const uv = this.trayUV(); + if (uv && this.wipePrev) this.mess.wipe(this.wipePrev.x, this.wipePrev.y, uv.u, uv.v); + if (uv) this.wipePrev = new THREE.Vector3(uv.u, uv.v, 0); + } else { + this.wipePrev = null; + } + + if (this.press > 0.01 || Math.abs(dTheta) > 0) { + const f = juiceTwist(s, dTheta, this.press, dt, () => this.rng.next()); + this.applyFrame(f, dt); + } + } + + /** A fresh click on a pip in the glass fishes it out. Returns true if it hit. */ + private clickPip(): boolean { + if (this.pipMeshes.length === 0) return false; + const hits = this.ray.intersectObjects(this.pipMeshes, false); + if (hits.length === 0) return false; + return this.fishOnePip(); + } + + private overCone(): boolean { + if (!this.ray.ray.intersectPlane(this.twistPlane, this.hitPt)) return false; + return Math.hypot(this.hitPt.x, this.hitPt.z) < 0.6; + } + + private applyFrame(f: ReturnType, dt: number): void { + if (f.juice > 0 && dt > 0) { + const rate = f.juice / dt; + if (rate > 0.02) audio.scrape(Math.min(1, this.press), Math.min(6, rate * 12)); + } + if (f.reversalBonus) audio.clunk(true); + if (f.spray > 0) { + // Sprayed off the cone toward a random rib gap onto the tray. + const a = this.rng.next() * Math.PI * 2; + const u = 0.5 + Math.cos(a) * 0.14; + const v = 0.5 + Math.sin(a) * 0.14; + this.mess.addJuice(u, v, f.spray, Math.cos(a), Math.sin(a)); + if (f.spray > 0.01) this.mess.addSolid(u, v, 'pulp'); + } + if (f.tearing) { + this.app.shake = 0.014; + audio.clatter(0.4, 0.8); + this.hintEl.textContent = 'that is a mash, not a juice — twist, don\'t just lean'; + } + for (let i = 0; i < f.seedsEscaped; i++) this.addPip(); + } + + private addPip(): void { + const pip = new THREE.Mesh(this.pipGeo, this.pipMat); + const a = this.rng.next() * Math.PI * 2; + const r = this.rng.next() * 0.14; + pip.position.set(Math.cos(a) * r, 0.05 + this.rng.next() * 0.03, Math.sin(a) * r); + this.glass.add(pip); + this.pipMeshes.push(pip); + } + + private syncGlass(): void { + const s = this.session; + if (!s) return; + // Column height maps 0..1 orange to a near-full glass at wrung-dry. + const h = Math.max(0.0001, Math.min(0.58, s.extracted * 0.62)); + this.juiceCol.scale.y = h; + this.juiceCol.position.y = 0.02 + h / 2; + // Murk from mashed pulp darkens the pour. + const mat = this.juiceCol.material as THREE.MeshStandardMaterial; + const murk = Math.min(0.4, s.pulp * 1.5); + mat.color.copy(this.orangeColor).multiplyScalar(1 - murk); + } + + private syncMess(): void { + if (this.mess.version === this.messVersion) return; + this.messVersion = this.mess.version; + const d = this.mess.field.data; + const px = this.messTexData; + for (let i = 0; i < d.length; i++) { + const m = Math.min(1, d[i] * 2.2); + px[i * 4] = 214; + px[i * 4 + 1] = 150; + px[i * 4 + 2] = 44; + px[i * 4 + 3] = Math.round(Math.min(0.85, m) * 255); + } + this.messTex.needsUpdate = true; + const solids = this.mess.solids.filter((sd) => !sd.collected); + const mat = new THREE.Matrix4(); + let n = 0; + for (const sd of solids) { + if (n >= 256) break; + mat.setPosition(sd.u * TRAY_W - TRAY_W / 2, TRAY_Y * 2 + 0.02, sd.v * TRAY_D - TRAY_D / 2); + this.solidsMesh.setMatrixAt(n++, mat); + } + this.solidsMesh.count = n; + this.solidsMesh.instanceMatrix.needsUpdate = true; + } + + private updateHud(): void { + const s = this.session; + if (!s) return; + const r = juiceResult(s); + const pct = Math.round(r.yieldOfOrange * 100); + this.yieldEl.textContent = `${pct}% of what this orange had — ${yieldWord(r.yieldOfReachable)}`; + // The fill can't pass the ceiling the halve left; show both. + this.yieldFill.style.width = `${Math.min(100, Math.round(r.yieldOfOrange * 100))}%`; + this.yieldFill.style.background = r.bitterness > 0.3 ? '#8a6a2a' : '#e8a53a'; + const pips = s.seedsInGlass; + const bench = this.mess.benchWord(); + const bit = s.bitterness > 0.3 ? ' · bitter — you reamed the pith' : s.bitterness > 0.1 ? ' · getting pithy' : ''; + this.pipsEl.textContent = `pips: ${pips}${pips ? ' (click to fish out)' : ''} · bench: ${bench}${bit}`; + this.pipsEl.style.color = pips > 0 || bench !== 'clean' ? '#e2603a' : ''; + } + + /** What the judge reads. Snapshot any time; final at serve. */ + result(): JuiceResult { + const s = this.session!; + return { ...juiceResult(s), bench: this.mess.stats() }; + } + + /** Fish one pip out of the glass (a click in play, a call in the harness). */ + fishOnePip(): boolean { + const s = this.session; + if (!s || !fishPip(s)) return false; + const m = this.pipMeshes.pop(); + if (m) this.glass.remove(m); + return true; + } + + dispose(): void { + this.panel.dispose(); + } +} diff --git a/src/sim/juicing.ts b/src/sim/juicing.ts new file mode 100644 index 0000000..d5c3e8d --- /dev/null +++ b/src/sim/juicing.ts @@ -0,0 +1,443 @@ +import type { Ingredient } from './ingredients'; +import type { Criterion } from '../game/judging'; + +/** + * Juicing, on the same bones as everything else. + * + * The orange half sits cut-side-down on a ribbed reamer, and the only verb is + * the one the jar taught us: press-and-twist. The swirl detector in kitchen.ts + * turns pointer motion around a centre into angular sweep; this consumes that + * same signed sweep, one frame at a time, and does three honest things with it: + * + * extract — the juice pulse this frame is sweep × press. Twist faster and + * more comes out THIS FRAME; stop twisting and it stops. That is + * the feel that has to be right — causal, not a fill-bar on a timer. + * reverse — flip the twist direction and the ribs bite a fresh face: a small + * bonus bite, exactly the way a real reamer rewards the back-and- + * forth wrist. + * punish — press without twisting and you don't juice, you MASH: pulp tears + * into the glass and juice sprays the bench. Press too hard and the + * seeds jump the moat. Grind on after the well runs dry and you're + * reaming pith — bitterness, which no amount of yield buys back. + * + * The cut you made upstream (the halve) sets the ceiling: a butchered 60/40 + * half leaves juice you physically cannot reach, and the yield bar can't lie + * about it. Nothing here imports three.js — it's driven by numbers the scene + * hands it, which is what lets the harness juice an orange headless. + */ + +export type JuicerId = 'pyramid_classic' | 'ribbed_deluxe' | 'juicernaut'; + +export interface Juicer { + id: JuicerId; + name: string; + /** Extraction efficiency — how well the ribs grip and tear the flesh. */ + ream: number; + /** 0..1 fraction of loosened seeds the moat catches before the glass. */ + seedCatch: number; + /** Extra reachable juice this reamer squeezes past a bare hand-half (0..). */ + yieldBonus: number; + blurb: string; +} + +export const JUICERS: Record = { + pyramid_classic: { + id: 'pyramid_classic', + name: 'Pyramid Classic', + ream: 0.92, + // No moat. Every pip it frees goes straight to the glass, and it knows. + seedCatch: 0, + yieldBonus: 0, + blurb: 'Ribbed glass, no strainer. Honest about the pips.', + }, + ribbed_deluxe: { + id: 'ribbed_deluxe', + name: 'Ribbed Deluxe', + ream: 1.06, + // A moat around the cone catches most of what comes loose. + seedCatch: 0.8, + yieldBonus: 0.04, + blurb: 'A moat for the pips and taller ribs. A real upgrade.', + }, + juicernaut: { + id: 'juicernaut', + name: 'The Juicernaut', + ream: 1.32, + // Catches everything, squeezes more, and the judge sits up. + seedCatch: 1, + yieldBonus: 0.1, + blurb: 'Legendary. It catches everything and asks for more.', + }, +}; + +/** What one frame of press-and-twist did — the scene turns these into juice + * in the glass, pips, spray on the bench, and audio. */ +export interface TwistFrame { + /** Juice extracted this frame: the pulse. Feeds the glass and the yield bar. */ + juice: number; + /** Seeds the moat caught this frame (never reach the glass). */ + seedsCaught: number; + /** Seeds that made it into the glass this frame — the pips. */ + seedsEscaped: number; + /** Juice/pulp thrown onto the bench this frame (→ mess field). */ + spray: number; + /** Pressed without twisting: pulp tore instead of juicing. */ + tearing: boolean; + /** A twist reversal bit a fresh face — a bonus pulse. */ + reversalBonus: boolean; + /** Pith bitterness added this frame (grinding a dry, over-reamed half). */ + bitterness: number; +} + +export interface JuiceSession { + ing: Ingredient; + juicer: Juicer; + /** Reachable juice, 0..1 of the whole orange — the ceiling the halve left, + * already lifted by this reamer's yieldBonus. */ + theoretical: number; + placed: boolean; + /** Absolute juice in the glass, 0..theoretical. */ + extracted: number; + /** Murk: pulp mashed into the juice by pressing without twisting. */ + pulp: number; + /** Pith bitterness, 0..1 — over-reaming past the dry point. */ + bitterness: number; + /** Total juice sprayed off the reamer onto the bench. */ + sprayTotal: number; + + seedsTotal: number; + seedsReleased: number; + seedsCaught: number; + seedsInGlass: number; + + // Gesture state — mirrors the cut sim's per-stroke accumulators. + lastDir: number; + sinceReversal: number; + /** Seconds of pressing with (almost) no twist — the mash timer. */ + mashTime: number; + /** Total angular travel, for the readout and audio rhythm. */ + revolutions: number; +} + +// --- Tuning. First principles, then measured on the bench (see commit). --- +/** Juice per unit of (sweep × press × ream). Measured so a clean half reaches + * ~73% at 3 revolutions/press 0.7 and wrings dry by ~5 firm revolutions on the + * Pyramid Classic — the twist rate is the juice rate, the count is the effort. */ +const EXTRACT_GAIN = 0.06; +/** A reversal's bonus bite, scaled by press×ream. Small — flavour, not a cheat. */ +const BONUS_BITE = 0.015; +/** Minimum sweep between reversals before another one counts (anti-jitter). */ +const REVERSAL_MIN = 0.25; +/** Remaining-juice below this reads as "the well is dry" → grinding = pith. */ +const DRY = 0.02; +/** Bitterness per unit of dry-grinding (sweep × press), before pith scaling. */ +const PITH_GAIN = 0.05; +/** Fraction of each juice pulse that sprays the bench. Reamers are messy. */ +const SPRAY_FRAC = 0.06; +/** Press above this with no twist is a mash, not a juice. */ +const TEAR_PRESS = 0.55; +const TEAR_SWEEP = 0.02; +/** Seconds of mashing before the pulp tears. */ +const TEAR_PATIENCE = 0.28; +const TEAR_PULP = 0.05; +const TEAR_SPRAY = 0.05; +/** Press above this crushes the pith and shoves seeds past the moat. */ +const CRUSH_KNEE = 0.85; +/** How much crush-pressure bypasses the moat (per unit of press over the knee). */ +const CRUSH_BYPASS = 2; + +const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v); + +/** + * The halve's gift to the juicer: how much of the orange is reachable at all. + * `yield = base × (1 − 2|offset|) × (1 − 0.5·wedge)` — a dead-centre cut leaves + * everything, a cut at the edge (offset 0.5) leaves nothing, and a wedged face + * bleeds a little more of it out of reach. Reads halveQuality() from cutting.ts. + */ +export function theoreticalYield(halve: { offset: number; wedge: number }, base = 1): number { + return clamp01(base * (1 - 2 * Math.abs(halve.offset)) * (1 - 0.5 * halve.wedge)); +} + +export function newJuiceSession( + ing: Ingredient, + juicer: Juicer, + reachable: number, + seedCount?: number, +): JuiceSession { + return { + ing, + juicer, + // The good reamer squeezes a touch past a bare half; cap at the whole orange. + theoretical: clamp01(reachable * (1 + juicer.yieldBonus)), + placed: false, + extracted: 0, + pulp: 0, + bitterness: 0, + sprayTotal: 0, + seedsTotal: seedCount ?? ing.seeds?.count ?? 0, + seedsReleased: 0, + seedsCaught: 0, + seedsInGlass: 0, + lastDir: 0, + sinceReversal: 0, + mashTime: 0, + revolutions: 0, + }; +} + +/** Drag the half onto the cone. Nothing juices until it's seated. */ +export function placeHalf(s: JuiceSession): void { + s.placed = true; +} + +/** + * One frame of press-and-twist. `dTheta` is the signed angular sweep of the + * pointer around the reamer this frame (radians); `press` is 0..1 downforce. + * `rand` decides only whether a freed seed clears the moat — everything else is + * deterministic, which is what makes the harness able to check it. + */ +export function juiceTwist( + s: JuiceSession, + dTheta: number, + press: number, + dt: number, + rand: () => number, +): TwistFrame { + const out: TwistFrame = { + juice: 0, + seedsCaught: 0, + seedsEscaped: 0, + spray: 0, + tearing: false, + reversalBonus: false, + bitterness: 0, + }; + if (!s.placed || dt <= 0) return out; + + const p = clamp01(press); + const sweep = Math.abs(dTheta); + const dir = Math.sign(dTheta); + s.revolutions += sweep; + const crush = Math.max(0, p - CRUSH_KNEE); + const remaining = Math.max(0, s.theoretical - s.extracted); + + // --- Extraction: the pulse is sweep × press, capped by what's reachable. --- + if (remaining > 0 && sweep > 0 && p > 0) { + const pulse = Math.min(remaining, sweep * p * s.juicer.ream * EXTRACT_GAIN); + s.extracted += pulse; + out.juice += pulse; + } + + // --- Reversal bonus: flip direction on a fresh face, get a bite. --- + if (dir !== 0) { + if (s.lastDir !== 0 && dir !== s.lastDir && s.sinceReversal > REVERSAL_MIN) { + const rem = Math.max(0, s.theoretical - s.extracted); + const bonus = Math.min(rem, BONUS_BITE * p * s.juicer.ream); + if (bonus > 0) { + s.extracted += bonus; + out.juice += bonus; + out.reversalBonus = true; + } + s.sinceReversal = 0; + } + s.sinceReversal += sweep; + s.lastDir = dir; + } + + // --- Mash: press without twist tears pulp and sprays, doesn't juice. --- + let forceSeed = false; + if (p > TEAR_PRESS && sweep < TEAR_SWEEP) { + s.mashTime += dt; + if (s.mashTime > TEAR_PATIENCE) { + out.tearing = true; + s.pulp += TEAR_PULP * p; + out.spray += TEAR_SPRAY * p; + forceSeed = true; + s.mashTime = 0; + } + } else { + s.mashTime = Math.max(0, s.mashTime - dt * 2); + } + + // --- Spray: a little of every honest pulse lands on the bench too. --- + if (out.juice > 0) { + const sp = out.juice * SPRAY_FRAC; + out.spray += sp; + } + s.sprayTotal += out.spray; + + // --- Pith: grinding a dry half reams the peel. Bitterness, no yield. --- + if (remaining < DRY && p > 0.2 && sweep > 0) { + const pith = 0.5 + (s.ing.gratable?.pithDepth ?? 0.3); + const db = sweep * p * PITH_GAIN * pith; + s.bitterness = clamp01(s.bitterness + db); + out.bitterness = db; + } + + // --- Seeds: freed by extraction progress (and by every tear), then the + // moat gets a chance to catch them — unless you crushed them past it. --- + if (s.theoretical > 1e-4) { + const frac = s.extracted / s.theoretical; + while ( + s.seedsReleased < s.seedsTotal && + frac >= (s.seedsReleased + 1) / (s.seedsTotal + 1) + ) { + releaseSeed(s, crush, rand, out); + } + } + if (forceSeed && s.seedsReleased < s.seedsTotal) releaseSeed(s, crush, rand, out); + + return out; +} + +function releaseSeed(s: JuiceSession, crush: number, rand: () => number, out: TwistFrame): void { + s.seedsReleased++; + const effCatch = clamp01(s.juicer.seedCatch - crush * CRUSH_BYPASS); + if (rand() < effCatch) { + s.seedsCaught++; + out.seedsCaught++; + } else { + s.seedsInGlass++; + out.seedsEscaped++; + } +} + +/** Fish a pip out of the glass — the scene charges service time for it. */ +export function fishPip(s: JuiceSession): boolean { + if (s.seedsInGlass <= 0) return false; + s.seedsInGlass--; + return true; +} + +// ------------------------------------------------------------------------- +// The judge's side. Kept here so the game has ONE place to consume juicing: +// import { JuiceResult, juiceResult, juiceCriteria, JUICE_LINES } from +// '../sim/juicing'; +// The scene assembles JuiceResult (juiceResult(session) + the bench stats it +// owns) and hands it to the game; the game calls juiceCriteria(result) and +// merges JUICE_LINES into its verdict bank. Bench itself is scored by the +// existing benchCriterion via result.bench — same path prep orders use. +// ------------------------------------------------------------------------- + +/** The number-bag the prep bench hands the judge for a juice order. */ +export interface JuiceResult { + juicerId: JuicerId; + juicerName: string; + /** Absolute juice in the glass, 0..1 of the whole orange. */ + extracted: number; + /** The reachable ceiling the halve (and reamer) left, 0..1. */ + theoretical: number; + /** extracted / theoretical — how much of the reachable you actually got. */ + yieldOfReachable: number; + /** extracted — the live readout's "% of what this orange had". */ + yieldOfOrange: number; + pips: number; + seedsCaught: number; + bitterness: number; + pulp: number; + /** Filled in by the scene from its mess field, same shape prep uses. */ + bench?: { mass: number; spreadFrac: number; solids: number }; +} + +export function juiceResult(s: JuiceSession): JuiceResult { + const yieldOfReachable = s.theoretical > 1e-4 ? s.extracted / s.theoretical : 0; + return { + juicerId: s.juicer.id, + juicerName: s.juicer.name, + extracted: s.extracted, + theoretical: s.theoretical, + yieldOfReachable: clamp01(yieldOfReachable), + yieldOfOrange: s.extracted, + pips: s.seedsInGlass, + seedsCaught: s.seedsCaught, + bitterness: s.bitterness, + pulp: s.pulp, + }; +} + +/** Live-readout word for the yield bar, in the judge's own thresholds. */ +export function yieldWord(yieldOfReachable: number): string { + return yieldOfReachable < 0.4 + ? 'barely wet' + : yieldOfReachable < 0.7 + ? 'half a glass' + : yieldOfReachable < 0.9 + ? 'a good pour' + : 'wrung dry'; +} + +/** + * The Juice and the Pips. The Juice bands yield against the reachable max, then + * docks the score for bitterness (over-reaming) and murk (mashing) — a bitter + * pulpy glass is not a good glass however full it is. The Pips criterion counts + * seeds in the glass, and it is unforgiving, because he can see them. + */ +export function juiceCriteria(result: JuiceResult): Criterion[] { + const yieldScore = clamp01((result.yieldOfReachable - 0.35) / 0.6); + const penalty = result.bitterness * 0.7 + Math.min(0.35, result.pulp * 1.2); + const juiceScore = clamp01(yieldScore - penalty); + const pct = Math.round(result.yieldOfOrange * 100); + const bitWord = + result.bitterness > 0.35 ? ', gone bitter' : result.bitterness > 0.12 ? ', a little pithy' : ''; + const murkWord = result.pulp > 0.12 ? ', pulpy' : ''; + + const pips = result.pips; + const pipScore = pips === 0 ? 1 : clamp01(1 - pips / 5); + + return [ + { + key: 'yield', + group: 'prep', + label: 'The Juice', + score: juiceScore, + weight: 1.3, + detail: `${pct}% of the orange${bitWord}${murkWord} (${Math.round(result.theoretical * 100)}% reachable)`, + }, + { + key: 'pips', + group: 'prep', + label: 'Pips', + score: pipScore, + weight: 1.1, + detail: pips === 0 ? 'not one pip' : `${pips} in the glass`, + }, + ]; +} + +/** The Juicernaut announces itself. Slotted in as an opener when recognised. */ +export function juicerRecognitionLine(id: JuicerId): string | null { + return id === 'juicernaut' ? 'Is that a Juicernaut. Sit down.' : null; +} + +/** Judge lines for the two juice criteria — merged into lines.ts's BANK by the + * game. Five bad, three good, in his voice: dry, specific, never cruel. */ +export const JUICE_LINES: Record<'yield' | 'pips', { bad: string[]; good: string[] }> = { + yield: { + bad: [ + 'There is more juice in the reamer than the glass. You lost the argument.', + 'You reamed the pith. I can taste the peel. It tastes like a headache.', + 'Half the orange is still in the orange.', + 'You mashed it. This is not juice, it is a suspension.', + 'A cut like that left juice you were never going to reach. It shows.', + ], + good: [ + 'Wrung dry. There is nothing left in that half and everything in the glass.', + 'Full pour, no pith, no pulp. That is the whole orange, respectfully.', + 'You worked the wrist, both directions. I could hear it was right.', + ], + }, + pips: { + bad: [ + 'There are three pips in this. I counted. I shouldn\'t have been able to.', + 'A pip. In the glass. Where I can see it.', + 'You pressed so hard the seeds jumped the moat. Ambitious.', + 'I found the seeds. They found me first.', + 'Fish them out or serve them proudly. You did neither, then both.', + ], + good: [ + 'Not a pip in it. Clear all the way down.', + 'Seedless. Somebody caught every one.', + 'I looked for a pip. I am almost disappointed.', + ], + }, +};