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