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>
273 lines
12 KiB
JavaScript
273 lines
12 KiB
JavaScript
// combat/hazards.js (Lane B) — the three L2 hazards: reflux_surge, aortic_squeeze, ring_gate.
|
|
//
|
|
// C authors WHERE and HOW MUCH (level JSON); balance.js holds the mechanical constants; this
|
|
// file is the behaviour. C's params always win where they overlap.
|
|
//
|
|
// WHY THESE SCHEDULE THEMSELVES INSTEAD OF USING BOOT'S PUMP (→ Lane F in NOTES):
|
|
// every hazard carries a `warn` — seconds of telegraph before it bites — and TECH makes it a
|
|
// law (≥2 s for anything lethal). But the pump emits `level:event` when the player *crosses*
|
|
// `ev.s`, which is the moment the hazard arrives: zero lead time. A telegraph needs lookahead,
|
|
// so hazards read `world.level.events` directly and run their own timeline (idle → warned →
|
|
// active → done). We deliberately ignore `level:event` for `type:'hazard'` so nothing arms
|
|
// twice. This also makes respawn clean: the timeline is ours to rewind (see `reset`), which is
|
|
// the one piece of respawn that does NOT need F's pump.
|
|
//
|
|
// ART_BIBLE: acid/corrosive = sickly yellow-green; hostile = amber. The ring gate is the
|
|
// interesting case — it lerps violet (a gate you pass) → amber (a thing that will hurt you) as
|
|
// it closes, so the colour encodes what it currently MEANS, which is the actual law.
|
|
|
|
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(), _up = new THREE.Vector3(0, 0, 1);
|
|
|
|
const TAU = Math.PI * 2;
|
|
// shortest signed angular distance a → b
|
|
const angDelta = (a, b) => { let d = (b - a) % TAU; if (d > Math.PI) d -= TAU; if (d < -Math.PI) d += TAU; return d; };
|
|
|
|
export function createHazards({ scene, world, bus, rng, player }) {
|
|
const H = B.hazards;
|
|
let time = 0;
|
|
|
|
const discToWorld = (out, frame, x, y) =>
|
|
out.copy(frame.pos).addScaledVector(frame.nor, y).addScaledVector(frame.bin, x);
|
|
|
|
// --- visuals: one InstancedMesh per hazard family = 3 draws for the whole system --------
|
|
const surgeGeo = new THREE.CircleGeometry(1, 24); // a wall of acid across the lumen
|
|
const surgeMat = emissiveMat(EMISSIVE.acid, { additive: true, opacity: 0.55 });
|
|
surgeMat.side = THREE.DoubleSide;
|
|
const surgeMesh = new THREE.InstancedMesh(surgeGeo, surgeMat, 4);
|
|
|
|
const ridgeGeo = new THREE.SphereGeometry(1, 8, 6); // the aortic bulge
|
|
const ridgeMat = emissiveMat(EMISSIVE.hostile);
|
|
const ridgeMesh = new THREE.InstancedMesh(ridgeGeo, ridgeMat, 160);
|
|
|
|
const irisGeo = new THREE.SphereGeometry(1, 8, 6); // the peristaltic ring
|
|
const irisMat = emissiveMat(EMISSIVE.gate);
|
|
const irisMesh = new THREE.InstancedMesh(irisGeo, irisMat, 96);
|
|
|
|
const meshes = [surgeMesh, ridgeMesh, irisMesh];
|
|
for (const m of meshes) {
|
|
m.frustumCulled = false;
|
|
m.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
|
|
m.count = 0;
|
|
scene.add(m);
|
|
}
|
|
|
|
// --- timeline ---------------------------------------------------------------------------
|
|
const RIDGE_EVERY = 26; // u between bulge rings along a squeeze zone
|
|
const RIDGE_ARC = 5; // spheres per ring
|
|
const IRIS_BEADS = 16;
|
|
|
|
let specs = [];
|
|
function build() {
|
|
specs = (world.level?.events ?? [])
|
|
.filter((e) => e.type === 'hazard' && H && e.kind)
|
|
.map((e) => ({ ev: e, kind: e.kind, s: e.s ?? 0, warn: e.warn ?? 2.5, state: 'idle', surge: null }));
|
|
}
|
|
build();
|
|
|
|
// Rewind our own timeline (respawn). Anything at or ahead of `fromS` is armed again;
|
|
// anything behind stays done, so flying back over a spent zone doesn't re-telegraph it.
|
|
function reset(fromS = 0) {
|
|
for (const h of specs) {
|
|
h.surge = null;
|
|
h.state = (h.s + (h.ev.span ?? 0)) >= fromS ? 'idle' : 'done';
|
|
}
|
|
}
|
|
|
|
// --- antacid: B's own torpedo stalls a neutralizable surge (GDD dual-use) ---------------
|
|
// C's finale maths depends on this being a real stop, not a slow: "fired backward it stalls
|
|
// the surge ~10 s". We honour the torpedo's own duration from the bus, not a local number.
|
|
const offNeutralize = bus.on('level:neutralize', ({ s, radius, duration }) => {
|
|
for (const h of specs) {
|
|
if (!h.surge || h.kind !== 'reflux_surge' || !h.ev.neutralizable) continue;
|
|
if (Math.abs(h.surge.s - s) <= radius) {
|
|
h.surge.stall = Math.max(h.surge.stall, duration);
|
|
bus.emit('audio:cue', { name: 'surge_stall' });
|
|
}
|
|
}
|
|
});
|
|
|
|
function update(dt) {
|
|
time += dt;
|
|
const ps = player.state;
|
|
const speed = Math.max(1, ps.speed);
|
|
|
|
for (const h of specs) {
|
|
if (h.state === 'done') continue;
|
|
const ev = h.ev;
|
|
|
|
// --- telegraph: the one thing the pump structurally cannot do ---
|
|
if (h.state === 'idle') {
|
|
const eta = (h.s - ps.s) / speed;
|
|
if (eta <= h.warn && h.s >= ps.s) {
|
|
h.state = 'warned';
|
|
bus.emit('hazard:warn', { kind: h.kind, s: h.s, eta: Math.max(0, eta) });
|
|
bus.emit('audio:cue', { name: `warn_${h.kind}` });
|
|
} else if (ps.s >= h.s) {
|
|
h.state = 'warned'; // arrived without lead (teleport/respawn)
|
|
}
|
|
}
|
|
|
|
if (h.state === 'warned' && ps.s >= h.s) {
|
|
h.state = 'active';
|
|
if (h.kind === 'reflux_surge') {
|
|
// `from` is the spawn offset relative to the trigger s; negative = behind you
|
|
h.surge = { s: h.s + (ev.from ?? -40), stall: 0, endS: h.s + (ev.span ?? 600), grazeT: 0 };
|
|
bus.emit('audio:cue', { name: 'surge_start' });
|
|
}
|
|
}
|
|
|
|
if (h.state !== 'active') continue;
|
|
|
|
if (h.kind === 'reflux_surge') updateSurge(h, dt, ps);
|
|
else if (h.kind === 'aortic_squeeze') updateSqueeze(h, dt, ps);
|
|
else if (h.kind === 'ring_gate') updateGate(h, dt, ps);
|
|
}
|
|
|
|
render();
|
|
}
|
|
|
|
// --- reflux surge: the rear chaser ------------------------------------------------------
|
|
function updateSurge(h, dt, ps) {
|
|
const g = h.surge;
|
|
if (!g) { h.state = 'done'; return; }
|
|
|
|
if (g.stall > 0) g.stall = Math.max(0, g.stall - dt);
|
|
const factor = g.stall > 0 ? H.surge.stallFactor : 1;
|
|
g.s += (h.ev.speed ?? 20) * factor * dt;
|
|
|
|
const gap = ps.s - g.s; // >0 = you're ahead. This is the whole game.
|
|
if (Math.abs(gap) < H.surge.proximityRange) {
|
|
// C's most important UI dependency: the surge chases from behind in a forward-facing
|
|
// game, so the player can only feel the margin shrink if we say so every frame.
|
|
bus.emit('hazard:proximity', { kind: h.kind, distance: gap, stalled: g.stall > 0 });
|
|
}
|
|
|
|
if (gap <= H.surge.catchRadius && ps.alive) {
|
|
if (h.ev.lethal) {
|
|
player.kill('acid'); // C's finale: dawdling is fatal
|
|
} else {
|
|
g.grazeT -= dt; // the s=1700 "burp": teaches the verb, never kills
|
|
if (g.grazeT <= 0) { player.damage(H.surge.graze, 'acid'); g.grazeT = H.surge.grazeTick; }
|
|
}
|
|
}
|
|
|
|
if (g.s > g.endS || g.s > world.length) { h.surge = null; h.state = 'done'; bus.emit('audio:cue', { name: 'surge_end' }); }
|
|
}
|
|
|
|
// --- aortic squeeze: DIRECTIONAL, non-lethal ---------------------------------------------
|
|
// The arch presses from ONE side; the opposite arc is the answer. C: a symmetric squeeze
|
|
// would be an untimeable toll because a flow-locked player cannot stop and wait.
|
|
function bulgeAt(h, t) {
|
|
const period = h.ev.period ?? 2.4;
|
|
return 0.5 * (1 + Math.sin((t / period) * TAU)); // 0..1
|
|
}
|
|
|
|
function updateSqueeze(h, dt, ps) {
|
|
const span = h.ev.span ?? 400;
|
|
if (ps.s > h.s + span) { h.state = 'done'; return; }
|
|
if (ps.s < h.s || !ps.alive) return;
|
|
|
|
const theta = h.ev.theta ?? 0;
|
|
const amp = h.ev.amplitude ?? 0.45;
|
|
const pulse = bulgeAt(h, time);
|
|
const rho = Math.hypot(ps.x, ps.y);
|
|
if (rho < 1e-4) return;
|
|
const pTheta = Math.atan2(ps.x, ps.y);
|
|
// only the pressing arc bites; the far side stays clear and is the verb
|
|
if (Math.abs(angDelta(theta, pTheta)) > H.squeeze.arc * 0.5) return;
|
|
|
|
const safe = world.wallRho(ps.s, pTheta);
|
|
const limit = safe * (1 - amp * pulse);
|
|
if (rho <= limit) return;
|
|
|
|
h.grazeT = (h.grazeT ?? 0) - dt;
|
|
if (h.grazeT <= 0) { player.damage(H.squeeze.graze, 'squeeze'); h.grazeT = H.squeeze.grazeTick; }
|
|
player.shove(-(ps.x / rho) * H.squeeze.shove, -(ps.y / rho) * H.squeeze.shove);
|
|
}
|
|
|
|
// --- ring gate: a POINT, therefore timeable ----------------------------------------------
|
|
const gateOpen = (h, t) => ((t % (h.ev.period ?? 3.0)) < (h.ev.open ?? 1.5));
|
|
|
|
function updateGate(h, dt, ps) {
|
|
if (ps.s > h.s + 6) { h.state = 'done'; return; }
|
|
if (h.passed || !ps.alive) return;
|
|
// the beat you arrive on is the beat that counts
|
|
if (ps.s >= h.s - 0.5) {
|
|
h.passed = true;
|
|
if (!gateOpen(h, time)) {
|
|
player.damage(H.gate.hit, 'gate');
|
|
const rho = Math.hypot(ps.x, ps.y) || 1e-4;
|
|
player.shove(-(ps.x / rho) * H.gate.shove, -(ps.y / rho) * H.gate.shove);
|
|
bus.emit('audio:cue', { name: 'gate_hit' });
|
|
} else {
|
|
bus.emit('audio:cue', { name: 'gate_pass' });
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- render -----------------------------------------------------------------------------
|
|
function render() {
|
|
let sn = 0, rn = 0, gn = 0;
|
|
let hotGate = 0;
|
|
|
|
for (const h of specs) {
|
|
if (h.state !== 'active') continue;
|
|
|
|
if (h.kind === 'reflux_surge' && h.surge && sn < 4) {
|
|
const f = world.sample(THREE.MathUtils.clamp(h.surge.s, 0, world.length));
|
|
const r = world.wallRho(h.surge.s, 0) + 1.2;
|
|
_q.setFromUnitVectors(_up, f.tan);
|
|
surgeMesh.setMatrixAt(sn++, _m.compose(f.pos, _q, _scale.set(r, r, r)));
|
|
} else if (h.kind === 'aortic_squeeze') {
|
|
const span = h.ev.span ?? 400, theta = h.ev.theta ?? 0, amp = h.ev.amplitude ?? 0.45;
|
|
const pulse = bulgeAt(h, time);
|
|
for (let s = h.s; s <= h.s + span && rn + RIDGE_ARC <= 160; s += RIDGE_EVERY) {
|
|
const f = world.sample(s);
|
|
const safe = world.wallRho(s, theta);
|
|
for (let j = 0; j < RIDGE_ARC; j++) {
|
|
const a = theta + (j / (RIDGE_ARC - 1) - 0.5) * H.squeeze.arc;
|
|
const rr = safe * (1 - amp * pulse * 0.85);
|
|
discToWorld(_v, f, Math.sin(a) * rr, Math.cos(a) * rr);
|
|
ridgeMesh.setMatrixAt(rn++, _m.compose(_v, _q.identity(), _scale.setScalar(1.5 + pulse * 0.8)));
|
|
}
|
|
}
|
|
} else if (h.kind === 'ring_gate') {
|
|
const open = gateOpen(h, time);
|
|
if (!open) hotGate = 1;
|
|
const f = world.sample(h.s);
|
|
const safe = world.wallRho(h.s, 0);
|
|
const r = open ? safe : safe * H.gate.irisMin;
|
|
for (let j = 0; j < IRIS_BEADS && gn < 96; j++) {
|
|
const a = (j / IRIS_BEADS) * TAU;
|
|
discToWorld(_v, f, Math.sin(a) * r, Math.cos(a) * r);
|
|
irisMesh.setMatrixAt(gn++, _m.compose(_v, _q.identity(), _scale.setScalar(open ? 0.9 : 1.5)));
|
|
}
|
|
}
|
|
}
|
|
|
|
surgeMesh.count = sn; ridgeMesh.count = rn; irisMesh.count = gn;
|
|
for (const m of meshes) m.instanceMatrix.needsUpdate = true;
|
|
// violet (a gate you pass) → amber (a thing that will hurt you). The colour IS the tell.
|
|
irisMat.uniforms.uFlash.value = hotGate;
|
|
}
|
|
|
|
return {
|
|
update, reset,
|
|
rebuild() { build(); },
|
|
get specs() { return specs; },
|
|
get activeSurge() { return specs.find((h) => h.surge)?.surge ?? null; },
|
|
dispose() {
|
|
offNeutralize();
|
|
for (const m of meshes) { scene.remove(m); m.dispose(); }
|
|
surgeGeo.dispose(); surgeMat.dispose();
|
|
ridgeGeo.dispose(); ridgeMat.dispose();
|
|
irisGeo.dispose(); irisMat.dispose();
|
|
},
|
|
};
|
|
}
|