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>
115 lines
5.2 KiB
JavaScript
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();
|
|
}
|
|
},
|
|
};
|
|
}
|