guts/web/js/combat/weapons.js
jing 0a038582a7 [lane B] Round 1: flight controller, cannon, enemy framework on the stub
Flight (js/flight/): tube-mode controller in spline space (s,x,y) — flow-locked
forward motion, throttle as a spring-back lean, boost + i-frames, banking chase
cam on the parallel-transport frame, arcade wall response (shove + graze damage,
never a hard stop). Twin-stick input: WASD/left-stick moves the ship in the disc,
mouse/right-stick aims, Shift/Ctrl is throttle. Gamepad supported.

Combat (js/combat/): lysozyme cannon (pooled, instanced, heat-limited with
hysteresis lockout) + antacid torpedo emitting level:neutralize. Enemy framework
with floater/seeker/turret and turn-rate-limited homing darts, spawned from C's
level:event. Coat/hull model and the full bus surface E builds against.

Measured (deterministic stepper): flow-lock exact (3s at flow 14 -> s=44.0);
boost peak 30.8 u/s; heat 4s fire -> 2s lockout; wall slam 13.3 coat at 11.3 u/s
with forward speed untouched; 10 combat draws at 55 live enemies (budget <=80);
0.10 ms/frame CPU; 0 leaks over 20 create/dispose cycles. fps NOT claimed — rAF
does not run in the automated pane. Evidence: docs/shots/laneB/.

Three findings escalated in LANE_B_NOTES:
- qa.sh gate #1 is a no-op: node --check silently exits 0 on ESM, so the syntax
  gate has never checked anything. I shipped a real SyntaxError past a GREEN qa.
  Fix + verification handed to F.
- Surf is structurally broken: the peristalsis wave (13.64 u/s) is slower than
  the player (19.6 u/s), so level 2's signature mechanic loses to mashing
  throttle. Not tunable from this lane; escalated to A/F with options.
- Custom ShaderMaterials need #include <colorspace_fragment>, else ART_BIBLE's
  hostile amber reaches the display as rgb(255,26,6). Fixed here; stub/walls
  inherit it.

js/flight/dev.html is a dev harness (boot.js has no player wiring yet); it
retires once F pastes the snippet in LANE_B_NOTES.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 08:56:32 +10:00

217 lines
8.3 KiB
JavaScript

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