Merge branch 'worktree-agent-a994a2ac1eff4f7dc'
This commit is contained in:
commit
8e249b4dba
188
src/dev-juice.ts
Normal file
188
src/dev-juice.ts
Normal file
@ -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;
|
||||
}
|
||||
@ -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();
|
||||
|
||||
533
src/scenes/juice.ts
Normal file
533
src/scenes/juice.ts
Normal file
@ -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<typeof juiceTwist>, 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();
|
||||
}
|
||||
}
|
||||
443
src/sim/juicing.ts
Normal file
443
src/sim/juicing.ts
Normal file
@ -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<JuicerId, Juicer> = {
|
||||
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.',
|
||||
],
|
||||
},
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user