// combat/weapons.js (Lane B) — the lysozyme cannon and the antacid torpedo. // // Pooling law: nothing is allocated after construction. Each weapon owns a fixed-capacity // InstancedMesh; firing wakes a slot, expiry sleeps it, and `count` is set to the live // prefix each frame. One draw call per projectile type, forever, no matter the fight. // // Projectiles carry an `s` that is advanced LINEARLY (never re-projected). It is only a // broad-phase hint for the s-bucket lookup — the actual hit test is a world-space distance // compare, so a few units of drift over a pellet's 1.15 s life costs nothing and saves us a // world.project() per pellet per frame. import * as THREE from 'three'; import { BALANCE as B } from './balance.js'; import { emissiveMat, EMISSIVE } from '../flight/emissive.js'; const _v = new THREE.Vector3(), _q = new THREE.Quaternion(), _m = new THREE.Matrix4(); const _scale = new THREE.Vector3(), _dir = new THREE.Vector3(); const FORWARD = new THREE.Vector3(0, 0, 1); // A fixed-capacity instanced pool. Every visual in combat/ is one of these. function makePool(scene, geo, mat, cap, makeSlot) { const mesh = new THREE.InstancedMesh(geo, mat, cap); mesh.frustumCulled = false; // projectiles live around the camera anyway mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage); mesh.count = 0; scene.add(mesh); const slots = Array.from({ length: cap }, makeSlot); return { mesh, slots, take() { for (const s of slots) if (!s.alive) { s.alive = true; return s; } return null; }, // Pack live slots into the front of the instance buffer; count is the live prefix. flush(write) { let n = 0; for (const s of slots) if (s.alive) write(s, n++); mesh.count = n; mesh.instanceMatrix.needsUpdate = true; return n; }, dispose() { scene.remove(mesh); mesh.dispose(); geo.dispose(); mat.dispose(); }, }; } export function createWeapons({ scene, world, bus, rng, player, targets }) { const rand = rng ? rng('weapons') : () => 0.5; // --- lysozyme cannon -------------------------------------------------------------- const pelletGeo = new THREE.SphereGeometry(B.cannon.radius, 6, 4); const cannon = makePool(scene, pelletGeo, emissiveMat(EMISSIVE.neutral, { additive: true }), B.cannon.pool, () => ({ alive: false, pos: new THREE.Vector3(), vel: new THREE.Vector3(), s: 0, sSpeed: 0, life: 0 })); // --- antacid torpedo -------------------------------------------------------------- const torpGeo = new THREE.SphereGeometry(B.torpedo.radius, 8, 6); const torps = makePool(scene, torpGeo, emissiveMat(EMISSIVE.acid, { additive: true }), B.torpedo.pool, () => ({ alive: false, pos: new THREE.Vector3(), vel: new THREE.Vector3(), s: 0, sSpeed: 0, life: 0 })); // --- blasts (kills + torpedo detonations) ----------------------------------------- const blastGeo = new THREE.SphereGeometry(1, 10, 8); const blasts = makePool(scene, blastGeo, emissiveMat(EMISSIVE.hostileShot, { additive: true, opacity: 0.7 }), 24, () => ({ alive: false, pos: new THREE.Vector3(), t: 0, dur: 0.35, size: 1 })); const heat = { value: 0, locked: false }; let fireTimer = 0, torpTimer = 0; let ammo = B.torpedo.ammo; function spawnBlast(pos, size, dur = 0.35) { const b = blasts.take(); if (!b) return; b.pos.copy(pos); b.t = 0; b.size = size; b.dur = dur; } function fireCannon() { const p = cannon.take(); if (!p) return; _dir.copy(player.aimPoint).sub(player.position).normalize(); _dir.x += (rand() - 0.5) * B.cannon.spread; _dir.y += (rand() - 0.5) * B.cannon.spread; _dir.normalize(); p.pos.copy(player.position); p.vel.copy(_dir).multiplyScalar(B.cannon.speed); p.life = B.cannon.life; p.s = player.state.s; p.sSpeed = p.vel.dot(world.sample(p.s).tan); heat.value += B.cannon.heatPerShot; bus.emit('audio:cue', { name: 'cannon' }); } function fireTorpedo() { const t = torps.take(); if (!t) return; _dir.copy(player.aimPoint).sub(player.position).normalize(); t.pos.copy(player.position); t.vel.copy(_dir).multiplyScalar(B.torpedo.speed); t.life = B.torpedo.life; t.s = player.state.s; t.sSpeed = t.vel.dot(world.sample(t.s).tan); ammo--; bus.emit('audio:cue', { name: 'torpedo' }); } // The dual-use bit (GDD): a torpedo both damages and neutralizes acid for ~10 s. // Lanes A and C consume this; we emit it faithfully whether or not anyone listens yet. function detonate(t) { spawnBlast(t.pos, B.torpedo.splash, 0.5); const s = THREE.MathUtils.clamp(t.s, 0, world.length); bus.emit('level:neutralize', { s, radius: B.torpedo.neutralizeRadius, duration: B.torpedo.neutralizeDuration }); bus.emit('audio:cue', { name: 'torpedo_blast' }); targets.forEachNear(s, B.torpedo.splash, (e) => { const d = e.pos.distanceTo(t.pos); if (d > B.torpedo.splash + e.radius) return; const falloff = 1 - d / (B.torpedo.splash + e.radius); targets.hurt(e, B.torpedo.damage * falloff, 'torpedo'); }); t.alive = false; } function stepProjectile(p, dt) { p.pos.addScaledVector(p.vel, dt); p.s += p.sSpeed * dt; // broad-phase hint only — see header p.life -= dt; } function writeProjectile(p, i, mesh, stretch) { _dir.copy(p.vel).normalize(); _q.setFromUnitVectors(FORWARD, _dir); _scale.set(1, 1, stretch); mesh.setMatrixAt(i, _m.compose(p.pos, _q, _scale)); } function update(dt) { const st = player.state; // heat: cool always, lock at overheatAt, unlock at resumeAt (hysteresis) heat.value = Math.max(0, heat.value - B.cannon.coolRate * dt); if (heat.value >= B.cannon.overheatAt && !heat.locked) { heat.locked = true; bus.emit('audio:cue', { name: 'overheat' }); } if (heat.locked && heat.value <= B.cannon.resumeAt) heat.locked = false; const intent = player.intent; fireTimer -= dt; torpTimer -= dt; if (st.alive && intent.fire && !heat.locked) { // catch up in fixed steps so rate of fire is frame-rate independent let guard = 8; while (fireTimer <= 0 && !heat.locked && guard--) { fireCannon(); fireTimer += 1 / B.cannon.rof; if (heat.value >= B.cannon.overheatAt) heat.locked = true; } } if (fireTimer < 0) fireTimer = 0; if (st.alive && intent.fire2 && torpTimer <= 0 && ammo > 0) { fireTorpedo(); torpTimer = 1 / B.torpedo.rof; } // --- cannon pellets: step, hit-test, expire --- for (const p of cannon.slots) { if (!p.alive) continue; stepProjectile(p, dt); let hit = false; targets.forEachNear(p.s, B.cannon.radius, (e) => { if (hit) return; const r = e.radius + B.cannon.radius; if (p.pos.distanceToSquared(e.pos) > r * r) return; hit = true; targets.hurt(e, B.cannon.damage, 'cannon'); spawnBlast(p.pos, 1.1, 0.16); }); if (hit || p.life <= 0) p.alive = false; } cannon.flush((p, i) => writeProjectile(p, i, cannon.mesh, 3.2)); // --- torpedoes: step, proximity-detonate, expire --- for (const t of torps.slots) { if (!t.alive) continue; stepProjectile(t, dt); let boom = t.life <= 0; if (!boom) { targets.forEachNear(t.s, B.torpedo.radius, (e) => { const r = e.radius + B.torpedo.radius; if (t.pos.distanceToSquared(e.pos) <= r * r) boom = true; }); } if (!boom && world.collide(t.pos, B.torpedo.radius)) boom = true; if (boom) detonate(t); } torps.flush((t, i) => writeProjectile(t, i, torps.mesh, 2.0)); // --- blasts: expand and fade --- for (const b of blasts.slots) { if (!b.alive) continue; b.t += dt; if (b.t >= b.dur) b.alive = false; } blasts.flush((b, i) => { const k = b.t / b.dur; const r = b.size * (0.35 + k * 0.9); blasts.mesh.setMatrixAt(i, _m.compose(b.pos, _q.identity(), _scale.setScalar(r))); }); } return { update, spawnBlast, get heat() { return heat.value; }, get heatMax() { return B.cannon.overheatAt; }, get overheated() { return heat.locked; }, get ammo() { return ammo; }, get ammoMax() { return B.torpedo.ammoMax; }, addAmmo(n) { ammo = Math.min(B.torpedo.ammoMax, ammo + n); }, get liveCount() { return cannon.mesh.count + torps.mesh.count; }, dispose() { cannon.dispose(); torps.dispose(); blasts.dispose(); }, }; }