guts/web/js/combat/pickups.js
jing 4ed3433e55 [lane B] Round 2 WIP (session cut off): surf speed-lock, hazards + pickups modules, rho-fraction spawns
Landed before the cut: surf as a speed-lock onto world.crestSpeed(s) with surf.authority
(ruling #1) — riding beats throttle and holding a crest is throttle discipline; tuning.js
header corrected to the real wallRho frame (C's flag); enemies.js reads rho as a FRACTION
of safe radius (C's placement law); balance.js hazard + pickup constants; NEW hazards.js
(reflux_surge / aortic_squeeze / ring_gate, self-scheduling telegraphs, reset(fromS) built
for respawn) and NEW pickups.js (all five kinds, shape-coded).

Cut off before: wiring — nothing imports hazards/pickups yet; the fiction-id -> archetype
resolve (round-2 task #1) does NOT exist despite the enemies.js comment saying index.js
does it; player.kill/shove/refill are called but were never added; checkpoint/respawn and
gate -> level:complete not started; NOTES unwritten. F lands the glue next commit.
Committed by F to protect the shared tree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 16:26:15 +10:00

115 lines
5.2 KiB
JavaScript

// combat/pickups.js (Lane B) — the five pickup kinds C places in L2.
//
// Split of concerns (settled with C): C owns WHERE and HOW MANY (level JSON, and the design
// reasons are worth reading — "orb lines teach the racing line before they score anything",
// "the game pays you right before it charges you"). B owns what each one is WORTH
// (balance.js §pickups) and what touching it does. Placement law: `rho` is a FRACTION of the
// safe radius, exactly as for enemies — C pins sample 1 at rho 0.92 meaning "against the
// wall", and that has to mean the same thing in a 9-unit tube and a 6.8-unit one.
//
// ART_BIBLE: pickups are soft green, all five of them — the emissive code is a readability
// LAW keyed to meaning ("this is good for you"), not a palette to decorate with. So identity
// comes from SHAPE instead: orb = sphere, mucin = blob, B12 = octahedron, antacid = capsule,
// biopsy = the big slow diamond you can spot from 200 units away. One InstancedMesh per kind:
// 5 draws for the whole economy.
import * as THREE from 'three';
import { BALANCE as B } from './balance.js';
import { emissiveMat, EMISSIVE } from '../flight/emissive.js';
const _m = new THREE.Matrix4(), _q = new THREE.Quaternion(), _scale = new THREE.Vector3();
const _v = new THREE.Vector3(), _axis = new THREE.Vector3(0.3, 1, 0.2).normalize();
const KINDS = {
nutrient_orb: { geo: () => new THREE.SphereGeometry(0.8, 10, 8), pool: 48, scale: 1 },
mucin_glob: { geo: () => new THREE.IcosahedronGeometry(1.0, 0), pool: 16, scale: 1 },
b12_cell: { geo: () => new THREE.OctahedronGeometry(1.0, 0), pool: 8, scale: 1 },
antacid_ammo: { geo: () => new THREE.CapsuleGeometry(0.45, 0.9, 3, 8), pool: 12, scale: 1 },
biopsy_sample: { geo: () => new THREE.OctahedronGeometry(1.6, 0), pool: 6, scale: 1.15 },
};
export function createPickups({ scene, world, bus, rng, player, weapons }) {
const pools = {};
for (const [kind, def] of Object.entries(KINDS)) {
const geo = def.geo();
const mat = emissiveMat(EMISSIVE.pickup, { additive: kind === 'biopsy_sample' });
const mesh = new THREE.InstancedMesh(geo, mat, def.pool);
mesh.frustumCulled = false;
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
mesh.count = 0;
scene.add(mesh);
pools[kind] = { mesh, geo, mat, def, items: [] };
}
let time = 0;
const discToWorld = (out, frame, x, y) =>
out.copy(frame.pos).addScaledVector(frame.nor, y).addScaledVector(frame.bin, x);
function spawn(kind, { s = 0, theta = null, rho = null } = {}) {
const pool = pools[kind];
if (!pool) { console.warn(`[combat] unknown pickup kind "${kind}"`); return null; }
if (pool.items.length >= pool.def.pool) return null;
s = THREE.MathUtils.clamp(s, 0, world.length);
const th = theta ?? rng('pickups')() * Math.PI * 2;
const free = Math.max(0, world.wallRho(s, th) - B.pickups.radius);
// rho is C's fraction of the safe radius. Unauthored => near the racing line, because an
// unplaced pickup is a reward, not a puzzle.
const r = rho != null ? THREE.MathUtils.clamp(rho, 0, 1) * free : free * 0.3;
const p = { kind, s, x: Math.sin(th) * r, y: Math.cos(th) * r, pos: new THREE.Vector3(), alive: true };
pool.items.push(p);
return p;
}
// Applies the effect, then announces it. E renders the feedback off `pickup {kind}`.
function collect(p) {
p.alive = false;
const v = B.pickups[p.kind] ?? {};
if (v.coat || v.hull || v.boost) player.refill({ coat: v.coat ?? 0, hull: v.hull ?? 0, boost: v.boost ?? 0 });
if (v.ammo) weapons?.addAmmo(v.ammo);
bus.emit('pickup', { kind: p.kind, s: p.s, score: v.score ?? 0, sample: v.sample ?? 0 });
bus.emit('audio:cue', { name: p.kind === 'biopsy_sample' ? 'pickup_sample' : 'pickup' });
}
function update(dt) {
time += dt;
const ps = player.state;
const reach = B.pickups.radius + player.radius;
const reach2 = reach * reach;
for (const pool of Object.values(pools)) {
let n = 0;
for (let i = pool.items.length - 1; i >= 0; i--) {
const p = pool.items[i];
if (!p.alive) { pool.items.splice(i, 1); continue; }
discToWorld(p.pos, world.sample(p.s), p.x, p.y);
// cheap reject along s before the distance test — pickups outlive the player's window
if (ps.alive && Math.abs(p.s - ps.s) < reach + 2) {
const dx = p.x - ps.x, dy = p.y - ps.y, ds = p.s - ps.s;
if (dx * dx + dy * dy + ds * ds <= reach2) { collect(p); pool.items.splice(i, 1); continue; }
}
}
for (const p of pool.items) {
if (n >= pool.def.pool) break;
_q.setFromAxisAngle(_axis, time * B.pickups.spin);
pool.mesh.setMatrixAt(n++, _m.compose(p.pos, _q, _scale.setScalar(pool.def.scale)));
}
pool.mesh.count = n;
pool.mesh.instanceMatrix.needsUpdate = true;
}
}
function clear() { for (const pool of Object.values(pools)) pool.items.length = 0; }
return {
spawn, update, clear,
get count() { return Object.values(pools).reduce((a, p) => a + p.items.length, 0); },
dispose() {
for (const pool of Object.values(pools)) {
scene.remove(pool.mesh); pool.mesh.dispose(); pool.geo.dispose(); pool.mat.dispose();
}
},
};
}