Audit item 12.
THE WHOLE COHORT WAS BLINKING. Enemy flash was ONE uFlash uniform per type, and the comment
above it said so and deferred the fix: shoot one bolus chunk in a room of eight and all eight
blinked white. That is not weak feedback, it is WRONG feedback — the single signal that tells
you which body absorbed your shot was being broadcast to every body that did not, in a fogged
tube where telling them apart is the entire skill. Per-instance now: an `aFlash`
InstancedBufferAttribute written at the same compacted draw index as the matrix (they must
match, or the flash lands on somebody else's body once anything in the pool dies). Verified
with five floaters drawn and the middle one shot: aFlash reads [0, 0, 0.9, 0, 0].
emissive.js declares the attribute unconditionally, so a plain Mesh or a pool that never
attaches one gets WebGL's default 0 and renders exactly as before. uFlash stays for the one
effect that IS per-cohort — the boss's open-node tell, where every node opens at once.
THE RETICLE ONLY KNEW ABOUT THE TRIGGER. setHot was driven by `intent.fire`, so the instrument
in the middle of the screen reported whether the player's own finger was down and said nothing
about whether the shot connected. In a game where a foe can be hit-and-unharmed (armoured
plate), hit-and-shielded (the Guardian's shut iris) or simply missed in the fog, that is the
most useful thing it could have been telling you — and the shut-iris case is exactly the one
where a player without feedback concludes the gun is broken. enemies.js now announces
`enemy:hit {dmg, blocked, dead}` and the reticle pops for it: 0.35 blocked, 0.7 landed, 1.0
kill. The tell is a SIZE pop, not brightness alone, because firing already brightens it and one
signal cannot do two jobs — firing is dimmed to 0.55 to make the room.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
367 lines
17 KiB
JavaScript
367 lines
17 KiB
JavaScript
// flight/player.js (Lane B) — ENDO-1's controller, camera rig and durability.
|
||
//
|
||
// THE MODEL (GDD "flow-locked hybrid", tube mode). The player does not live in world space;
|
||
// it lives in the world's spline space and is rendered into world space each frame:
|
||
//
|
||
// s arclength down the canal. Peristalsis owns it: ds/dt = flow·(throttle+surf+boost).
|
||
// You can lean on it ±40%, burst past it, or surf it. You cannot stop or reverse.
|
||
// x, y the cross-section disc offset — x along frame.bin (lateral), y along frame.nor
|
||
// (vertical). This is where ALL your agency is, and it is fully analog.
|
||
//
|
||
// worldPos = frame.pos + frame.nor·y + frame.bin·x (frame = world.sample(s))
|
||
// theta = atan2(x, y) — matches world.project()'s atan2(rel·bin, rel·nor)
|
||
//
|
||
// Everything downstream falls out of this: collision is one radius compare against
|
||
// world.wallRho, the camera is the same three numbers lagged, and enemies chase in the same
|
||
// space for pennies. Arena/6DOF mode is round 2 (see NOTES); modeAt() is guarded below.
|
||
|
||
import * as THREE from 'three';
|
||
import { TUNING as T } from './tuning.js';
|
||
import { createInput } from './input.js';
|
||
import { buildShip, buildReticle } from './ship.js';
|
||
|
||
export function createPlayer({ scene, world, bus, rng, flags = {}, assets = null, camera, dom }) {
|
||
const input = createInput(dom);
|
||
const ship = buildShip(assets);
|
||
const reticle = buildReticle();
|
||
scene.add(ship.group, reticle.mesh);
|
||
|
||
// HITMARKER. combat/enemies.js announces every landed shot; the reticle pops for it. A
|
||
// blocked hit (shut iris, armoured plate) gets a deliberately smaller pop than a landed one
|
||
// and a kill gets the biggest — three readings from one instrument, which is what stops
|
||
// "hit but shielded" from feeling like "missed".
|
||
const HIT_DECAY = 7; // ~140 ms — long enough to see, short enough to stay per-shot
|
||
let hitMark = 0;
|
||
const offHit = bus?.on?.('enemy:hit', (e) => {
|
||
const v = e?.dead ? 1 : e?.blocked ? 0.35 : 0.7;
|
||
if (v > hitMark) hitMark = v; // a kill in the same frame as a graze reads as the kill
|
||
}) ?? null;
|
||
|
||
const st = {
|
||
s: 2, x: 0, y: 0, // spline-space position
|
||
vx: 0, vy: 0, // disc velocity (u/s)
|
||
vs: 0, // AXIAL velocity — arena mode only (tube mode: flow owns s)
|
||
ax: 0, ay: 0, // aim offset in the disc plane at s + aim.distance
|
||
throttle: 1,
|
||
boostT: 0, boostCd: 0, boostBlend: 0, iframeT: 0,
|
||
surfBlend: 0, onCrest: false,
|
||
coat: T.coat.max, hull: T.hull.max, sinceDamage: 99, wallCd: 0,
|
||
roll: 0, pitch: 0,
|
||
speed: 0, alive: true,
|
||
};
|
||
|
||
// scratch — this runs 60×/s; nothing in the loop allocates
|
||
const _pos = new THREE.Vector3(), _aim = new THREE.Vector3(), _look = new THREE.Vector3();
|
||
const _aimLag = new THREE.Vector3(), _camPos = new THREE.Vector3(), _tmp = new THREE.Vector3();
|
||
let aimLagInit = false;
|
||
|
||
const discToWorld = (out, frame, x, y) =>
|
||
out.copy(frame.pos).addScaledVector(frame.nor, y).addScaledVector(frame.bin, x);
|
||
|
||
const clampS = (s) => THREE.MathUtils.clamp(s, 0, world.length);
|
||
|
||
// --- durability -------------------------------------------------------------------
|
||
// Discrete hits emit player:damage. Continuous drain (biome coatDrain, enemy auras) does
|
||
// NOT — it would spam the bus 60×/s and E would render a permanent damage flash. Lane E
|
||
// reads continuous state off player:state instead. Auras tick discretely (combat/enemies).
|
||
function damage(amount, kind = 'hit') {
|
||
if (!st.alive || st.iframeT > 0 || amount <= 0) return false;
|
||
st.sinceDamage = 0;
|
||
const toCoat = Math.min(st.coat, amount);
|
||
st.coat -= toCoat;
|
||
const leak = amount - toCoat;
|
||
if (leak > 0) st.hull = Math.max(0, st.hull - leak);
|
||
// `hull` is the fact the audio cue below has always had and the bus event never carried:
|
||
// comms.js has two hull lines gated on `kind === 'hull_hit'`, but `kind` is the damage
|
||
// SOURCE ('dart'|'acid'|'gas'…), so that branch has never once been true and the crew has
|
||
// never mentioned hull damage in the game's life.
|
||
bus.emit('player:damage', { amount, kind, hull: leak > 0 });
|
||
bus.emit('audio:cue', { name: leak > 0 ? 'hull_hit' : 'coat_hit' });
|
||
if (st.hull <= 0 && st.alive) { st.alive = false; bus.emit('player:death', { s: st.s }); }
|
||
return true;
|
||
}
|
||
|
||
function drain(amount) { // continuous, silent — coat only, never kills
|
||
if (!st.alive || amount <= 0) return;
|
||
st.coat = Math.max(0, st.coat - amount);
|
||
}
|
||
|
||
// Unconditional death — lethal hazards (C's reflux finale) call this. Deliberately ignores
|
||
// iframes: boost-dodging THROUGH an acid wall isn't a read, it's a bug C's design forbids.
|
||
function kill(kind = 'hazard') {
|
||
if (!st.alive) return;
|
||
st.coat = 0; st.hull = 0; st.alive = false;
|
||
bus.emit('player:death', { s: st.s, kind });
|
||
bus.emit('audio:cue', { name: 'death' });
|
||
}
|
||
|
||
// Disc-velocity impulse (u/s) — hazards push the ship around with this (squeeze/gate).
|
||
function shove(dx, dy) {
|
||
if (!st.alive) return;
|
||
st.vx += dx; st.vy += dy;
|
||
}
|
||
|
||
// Pickup economy (balance.js §pickups): coat/hull points, boost = seconds shaved off the
|
||
// cooldown (0.6 off a 2.4s cd — a recharge nudge, not a free boost).
|
||
function refill({ coat = 0, hull = 0, boost = 0 } = {}) {
|
||
if (!st.alive) return;
|
||
st.coat = Math.min(T.coat.max, st.coat + coat);
|
||
st.hull = Math.min(T.hull.max, st.hull + hull);
|
||
st.boostCd = Math.max(0, st.boostCd - boost);
|
||
}
|
||
|
||
// --- the loop ---------------------------------------------------------------------
|
||
function update(dt) {
|
||
const intent = input.sample();
|
||
const mouse = input.takeAim();
|
||
const biome = world.biomeAt(st.s);
|
||
|
||
if (!st.alive) { updateCamera(dt, world.sample(st.s)); return; }
|
||
|
||
// throttle: a lean, springing back to neutral
|
||
if (intent.throttle) st.throttle += intent.throttle * T.flow.throttleRate * dt;
|
||
else st.throttle += (1 - st.throttle) * Math.min(1, T.flow.throttleReturn * dt * 4);
|
||
st.throttle = THREE.MathUtils.clamp(st.throttle, T.flow.throttleMin, T.flow.throttleMax);
|
||
|
||
// boost
|
||
st.wallCd = Math.max(0, st.wallCd - dt);
|
||
st.boostCd = Math.max(0, st.boostCd - dt);
|
||
st.boostT = Math.max(0, st.boostT - dt);
|
||
st.iframeT = Math.max(0, st.iframeT - dt);
|
||
if (intent.boost && st.boostCd <= 0) {
|
||
st.boostT = T.boost.duration;
|
||
st.boostCd = T.boost.cooldown;
|
||
st.iframeT = T.boost.iframes;
|
||
bus.emit('player:boost', { s: st.s });
|
||
bus.emit('audio:cue', { name: 'boost' });
|
||
}
|
||
const boostTarget = st.boostT > 0 ? 1 : 0;
|
||
st.boostBlend += (boostTarget - st.boostBlend) * Math.min(1, (boostTarget ? 14 : 3.5) * dt);
|
||
|
||
// Which mode is the anatomy in? 'arena' is an authored room (level.arenas) or a segment
|
||
// C marked `mode: "open"` — both opt-in, so a level that declares neither behaves exactly
|
||
// as it always has. This is the guard the header has promised since round 1.
|
||
const inArena = world.modeAt ? world.modeAt(st.s) === 'arena' : false;
|
||
|
||
// surf: ride the peristalsis crest. flowPulse is Lane A's JS mirror of the wall's vertex
|
||
// displacement — see NOTES, it's charter-specified but missing from the TECH contract.
|
||
// Never in a room: a chamber has no travelling wave to ride, and letting surfBlend hang on
|
||
// would keep speed-locking you to a crest that isn't there.
|
||
const pulse = (!inArena && world.flowPulse) ? world.flowPulse(st.s) : 0;
|
||
st.onCrest = !inArena && pulse > T.surf.threshold;
|
||
const surfTarget = st.onCrest ? 1 : 0;
|
||
if (Math.abs(surfTarget - st.surfBlend) > 0.001) {
|
||
const was = st.surfBlend > 0.5;
|
||
st.surfBlend += (surfTarget - st.surfBlend) * Math.min(1, T.surf.ramp * dt);
|
||
if (was !== st.surfBlend > 0.5) bus.emit('player:surf', { active: st.surfBlend > 0.5, s: st.s });
|
||
}
|
||
|
||
// --- forward motion: the flow carries you; you only ever scale it ---
|
||
// Surf is a SPEED-LOCK onto the crest, not a bonus on top of throttle (round-2 ruling #1;
|
||
// round 1 proved a flat bonus is self-cancelling). The wave now outruns you
|
||
// (crestSpeed = 1.6 × flow > throttleMax 1.4), so riding means being *carried at the
|
||
// wave's own speed* — which is also why you stay on it instead of shooting off the front.
|
||
if (inArena) {
|
||
// ARENA: you own the forward axis. `intent.throttle` is the raw lean (-1..1), so the same
|
||
// stick that trims your speed in a tube becomes bidirectional thrust in a room — hold back
|
||
// and you stop, hold further and you reverse. st.speed goes SIGNED here, which is honest:
|
||
// the HUD reading -4.2 u/s means you are backing out of the pylorus, and it is true.
|
||
st.vs += intent.throttle * T.arena.thrust * dt;
|
||
st.vs *= Math.exp(-T.arena.damping * dt);
|
||
const vmax = T.arena.maxSpeed * (1 + T.boost.gain * st.boostBlend);
|
||
st.vs = THREE.MathUtils.clamp(st.vs, -vmax, vmax);
|
||
st.speed = st.vs;
|
||
st.s = clampS(st.s + st.vs * dt);
|
||
} else {
|
||
const flowSpeed = biome.flow * st.throttle;
|
||
const crest = world.crestSpeed(st.s); // contract v1.1 law: CREST_FACTOR × flow(s)
|
||
// Only ever accelerates. If you're already faster than the crest (boosting, or a fast
|
||
// segment), the wave must not brake you — it just stops being relevant.
|
||
const carried = crest > flowSpeed
|
||
? flowSpeed + (crest - flowSpeed) * st.surfBlend * T.surf.authority
|
||
: flowSpeed;
|
||
st.speed = carried + biome.flow * T.boost.gain * st.boostBlend; // boost punches out of a crest
|
||
st.s = clampS(st.s + st.speed * dt);
|
||
st.vs = st.speed; // so leaving a room into a tube doesn't inherit a stale axial velocity
|
||
}
|
||
|
||
// disc movement
|
||
st.vx += intent.disc.x * T.disc.accel * dt;
|
||
st.vy += intent.disc.y * T.disc.accel * dt;
|
||
const damp = Math.exp(-T.disc.damping * dt);
|
||
st.vx *= damp; st.vy *= damp;
|
||
const sp = Math.hypot(st.vx, st.vy);
|
||
if (sp > T.disc.maxSpeed) { st.vx *= T.disc.maxSpeed / sp; st.vy *= T.disc.maxSpeed / sp; }
|
||
st.x += st.vx * dt;
|
||
st.y += st.vy * dt;
|
||
|
||
const frame = world.sample(st.s);
|
||
discToWorld(_pos, frame, st.x, st.y);
|
||
collideWall(frame);
|
||
|
||
// aim
|
||
const aimIn = {
|
||
x: mouse.x * T.aim.sensitivity + intent.aimStick.x * T.aim.padSpeed * dt,
|
||
y: -mouse.y * T.aim.sensitivity + intent.aimStick.y * T.aim.padSpeed * dt,
|
||
};
|
||
st.ax += aimIn.x; st.ay += aimIn.y;
|
||
if (!aimIn.x && !aimIn.y) { // hands off: drift back to centre
|
||
const k = Math.min(1, T.aim.recenter * dt);
|
||
st.ax -= st.ax * k; st.ay -= st.ay * k;
|
||
}
|
||
const ar = Math.hypot(st.ax, st.ay);
|
||
if (ar > T.aim.range) { st.ax *= T.aim.range / ar; st.ay *= T.aim.range / ar; }
|
||
|
||
const aimFrame = world.sample(clampS(st.s + T.aim.distance));
|
||
discToWorld(_aim, aimFrame, st.ax, st.ay);
|
||
reticle.mesh.position.copy(_aim);
|
||
if (camera) reticle.mesh.lookAt(camera.position);
|
||
reticle.setHot(intent.fire ? 1 : 0);
|
||
if (hitMark > 0) { hitMark = Math.max(0, hitMark - dt * HIT_DECAY); reticle.setHit(hitMark); }
|
||
|
||
// the ship's nose chases the reticle rather than snapping to it (Star Fox lag)
|
||
if (!aimLagInit) { _aimLag.copy(_aim); aimLagInit = true; }
|
||
_aimLag.lerp(_aim, Math.min(1, T.aim.lag * dt));
|
||
|
||
// coat: biome ambient drain, then delayed regen
|
||
st.sinceDamage += dt;
|
||
// pH: inside an acid sea's span, ambient drain is scaled by SUBMERSION (Lane A's own B-hook,
|
||
// world/acid.js depthAt > 0 = under the surface). Gated on the spec's s-span and not on
|
||
// depthAt alone, because depthAt returns a bare -1 outside the span and that is
|
||
// indistinguishable from "1 unit above the surface" — reading it as the latter would quietly
|
||
// scale the drain on every level that has no sea at all.
|
||
const sea = world.acid;
|
||
const inSea = !!sea && st.s >= sea.spec.from && st.s <= sea.spec.to;
|
||
st.submerged = inSea && sea.depthAt(_pos) > 0;
|
||
const phMult = !inSea ? 1 : (st.submerged ? T.coat.acidMult : T.coat.surfaceMult);
|
||
drain((biome.coatDrain ?? 0) * phMult * dt);
|
||
if (st.sinceDamage > T.coat.regenDelay)
|
||
st.coat = Math.min(T.coat.max, st.coat + T.coat.regen * dt);
|
||
|
||
poseShip(dt, frame);
|
||
updateCamera(dt, frame);
|
||
emitState(biome, inArena);
|
||
}
|
||
|
||
// Arcade wall response: shove + graze damage, never a hard stop. Forward motion is the
|
||
// flow's and is untouched — scraping a fold costs coat and control, not your run.
|
||
function collideWall(frame) {
|
||
const hit = world.collide(_pos, T.ship.radius);
|
||
if (!hit) return;
|
||
|
||
const rho = Math.hypot(st.x, st.y);
|
||
if (rho < 1e-5) return;
|
||
const ux = st.x / rho, uy = st.y / rho; // outward radial, in disc coords
|
||
|
||
st.x -= ux * hit.push.length(); // back inside the wall
|
||
st.y -= uy * hit.push.length();
|
||
|
||
const vr = st.vx * ux + st.vy * uy; // >0 = travelling into the wall
|
||
if (vr > 0) {
|
||
st.vx -= (1 + T.wall.restitution) * vr * ux;
|
||
st.vy -= (1 + T.wall.restitution) * vr * uy;
|
||
}
|
||
st.vx -= ux * T.wall.shoveOut;
|
||
st.vy -= uy * T.wall.shoveOut;
|
||
|
||
if (vr > T.wall.grazeFloor && st.wallCd <= 0) {
|
||
damage(Math.min(T.wall.maxHit, (vr - T.wall.grazeFloor) * T.wall.damagePerSpeed), hit.kind);
|
||
st.wallCd = T.wall.cooldown;
|
||
bus.emit('audio:cue', { name: 'wall_scrape' });
|
||
}
|
||
discToWorld(_pos, frame, st.x, st.y);
|
||
}
|
||
|
||
function poseShip(dt, frame) {
|
||
const rollTarget = THREE.MathUtils.clamp(-st.vx * T.bank.gain, -T.bank.max, T.bank.max);
|
||
st.roll += (rollTarget - st.roll) * Math.min(1, T.bank.rate * dt);
|
||
st.pitch += (st.vy * T.bank.pitchGain - st.pitch) * Math.min(1, T.bank.rate * dt);
|
||
|
||
ship.group.position.copy(_pos);
|
||
ship.group.up.copy(frame.nor);
|
||
ship.group.lookAt(_aimLag); // hull is authored nose-toward −Z, so this aims it
|
||
ship.group.rotateZ(st.roll); // roll about the view axis = bank
|
||
ship.group.rotateX(st.pitch);
|
||
ship.setThrust(THREE.MathUtils.clamp((st.throttle - 0.6) / 0.8, 0, 1) * 0.6 + st.boostBlend * 0.4);
|
||
}
|
||
|
||
// Chase cam on the parallel-transport frame. up = frame.nor, ALWAYS — never world-up.
|
||
// If this ever rolls or snaps, the frame is the suspect, not this function (charter: file
|
||
// it to Lane A rather than patching here).
|
||
const cam = { s: 0, x: 0, y: 0, fov: T.cam.fov, init: false };
|
||
function updateCamera(dt, frame) {
|
||
if (!camera) return;
|
||
if (!cam.init) { cam.s = st.s - T.cam.back; cam.x = st.x; cam.y = st.y; cam.init = true; }
|
||
|
||
cam.s = clampS(st.s - T.cam.back);
|
||
const k = Math.min(1, T.cam.discLag * dt);
|
||
cam.x += (st.x * T.cam.discFollow - cam.x) * k;
|
||
cam.y += (st.y * T.cam.discFollow - cam.y) * k;
|
||
|
||
const cf = world.sample(cam.s);
|
||
discToWorld(_camPos, cf, cam.x, cam.y + T.cam.up);
|
||
camera.position.copy(_camPos);
|
||
|
||
const lf = world.sample(clampS(st.s + T.cam.lookAhead));
|
||
discToWorld(_look, lf, st.x * 0.5, st.y * 0.5);
|
||
_look.lerp(_tmp.copy(_aimLag), T.cam.aimInfluence);
|
||
camera.up.copy(frame.nor);
|
||
camera.lookAt(_look);
|
||
|
||
const fovTarget = T.cam.fov + st.boostBlend * T.boost.fovKick;
|
||
if (Math.abs(fovTarget - cam.fov) > 0.01) {
|
||
cam.fov += (fovTarget - cam.fov) * Math.min(1, T.cam.fovRate * dt);
|
||
camera.fov = cam.fov;
|
||
camera.updateProjectionMatrix();
|
||
}
|
||
}
|
||
|
||
// Lane E's HUD is bus-driven only, so continuous state has to arrive as an event. One
|
||
// small object per frame; treat it as read-only, don't retain it.
|
||
function emitState(biome, inArena = false) {
|
||
bus.emit('player:state', {
|
||
coat: st.coat, coatMax: T.coat.max,
|
||
hull: st.hull, hullMax: T.hull.max,
|
||
// `flow` reads 0 in a room, because in a room there is none — printing the biome's
|
||
// current next to a throttle that is really thrust would be a lie on the instrument.
|
||
speed: st.speed, flow: inArena ? 0 : biome.flow, throttle: st.throttle,
|
||
mode: inArena ? 'arena' : 'tube',
|
||
boostReady: st.boostCd <= 0, boostCd: st.boostCd, boostCdMax: T.boost.cooldown,
|
||
surfing: st.surfBlend > 0.5, iframes: st.iframeT > 0,
|
||
s: st.s, length: world.length, progress: world.length ? st.s / world.length : 0,
|
||
biome: biome.id, alive: st.alive,
|
||
});
|
||
}
|
||
|
||
bus.emit('player:spawn', { s: st.s });
|
||
|
||
return {
|
||
update,
|
||
state: st,
|
||
// combat/ reads this for fire/fire2. Safe because input.sample() is called exactly once
|
||
// per frame, here, and boot runs player.update() before combat.update() — so the
|
||
// edge-triggered flags (fire2) are still live when the weapons read them this frame.
|
||
get intent() { return input.intent; },
|
||
get _input() { return input; }, // harness only (flight/dev.js) — see input.js._debug
|
||
get position() { return _pos; },
|
||
get aimPoint() { return _aim; },
|
||
get radius() { return T.ship.radius; },
|
||
get forward() { return world.sample(st.s).tan; },
|
||
damage,
|
||
drain,
|
||
kill,
|
||
shove,
|
||
refill,
|
||
respawn(s = 0) {
|
||
Object.assign(st, { s, x: 0, y: 0, vx: 0, vy: 0, vs: 0, coat: T.coat.max, hull: T.hull.max, alive: true });
|
||
cam.init = false; aimLagInit = false;
|
||
bus.emit('player:spawn', { s });
|
||
},
|
||
dispose() {
|
||
scene.remove(ship.group, reticle.mesh);
|
||
offHit?.();
|
||
ship.dispose(); reticle.dispose(); input.dispose();
|
||
},
|
||
};
|
||
}
|