guts/web/js/flight/player.js
jing 4ed3433e55 [lane B] Round 2 WIP (session cut off): surf speed-lock, hazards + pickups modules, rho-fraction spawns
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>
2026-07-16 16:26:15 +10:00

297 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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);
const st = {
s: 2, x: 0, y: 0, // spline-space position
vx: 0, vy: 0, // disc velocity (u/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);
// `world.crestSpeed(s)` is world contract v1.1, but Lane A's world had not exposed it yet
// when this was written (their round-2 task #1, in flight — the stub already complies). The
// fallback mirrors TECH §Crest-speed law rather than inventing a number, so surf behaves
// identically the moment A lands theirs, and this guard can then be deleted.
const CREST_FACTOR = 1.6;
const crestSpeedAt = (s, biome) =>
(world.crestSpeed ? world.crestSpeed(s) : biome.flow * CREST_FACTOR);
// --- 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);
bus.emit('player:damage', { amount, kind });
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);
}
// --- 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);
// 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.
const pulse = world.flowPulse ? world.flowPulse(st.s) : 0;
st.onCrest = 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.
const flowSpeed = biome.flow * st.throttle;
const crest = crestSpeedAt(st.s, biome);
// 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);
// 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);
// 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;
drain((biome.coatDrain ?? 0) * 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);
}
// 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) {
bus.emit('player:state', {
coat: st.coat, coatMax: T.coat.max,
hull: st.hull, hullMax: T.hull.max,
speed: st.speed, flow: biome.flow, throttle: st.throttle,
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,
respawn(s = 0) {
Object.assign(st, { s, x: 0, y: 0, vx: 0, vy: 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);
ship.dispose(); reticle.dispose(); input.dispose();
},
};
}