// 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(); } }, }; }