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>
149 lines
6.1 KiB
JavaScript
149 lines
6.1 KiB
JavaScript
// flight/input.js (Lane B) — raw devices -> one normalized intent object. No game logic
|
||
// here; player.js reads `intent` and never touches an event listener.
|
||
//
|
||
// The scheme is twin-stick, which is what makes the charter's "mouse-aim + WASD" and
|
||
// "gamepad twin-stick" the same game rather than two:
|
||
// left hand (WASD / left stick) -> move the ship inside the cross-section disc
|
||
// right hand (mouse / right stick) -> aim the reticle (the ship's nose follows it, lagged)
|
||
// Shift/Ctrl (or LT/RT) -> throttle, springs back to neutral when released
|
||
// Space / A / RB -> boost
|
||
// LMB / RT / X -> fire
|
||
// WASD is therefore NOT throttle. Throttle is a modifier because it lives in [0.6,1.4] and
|
||
// spends most of its life at 1.0 — it's a lean, not a stick.
|
||
|
||
const DEADZONE = 0.18;
|
||
const dz = (v) => (Math.abs(v) < DEADZONE ? 0 : (v - Math.sign(v) * DEADZONE) / (1 - DEADZONE));
|
||
|
||
export function createInput(dom = document.body) {
|
||
const keys = new Set();
|
||
|
||
const intent = {
|
||
disc: { x: 0, y: 0 }, // −1..1 desired disc movement (x = lateral/bin, y = up/nor)
|
||
aimDelta: { x: 0, y: 0 }, // mouse pixels accumulated since the last read (consumed)
|
||
aimStick: { x: 0, y: 0 }, // −1..1 right stick
|
||
throttle: 0, // −1..1 (Ctrl .. Shift)
|
||
boost: false, // edge-triggered: true for exactly one read
|
||
fire: false, // held
|
||
fire2: false, // edge-triggered
|
||
locked: false, // pointer lock state (the "am I flying" signal)
|
||
};
|
||
|
||
// Held inputs are tracked per-device and OR'd each sample. Sharing one flag would latch:
|
||
// the trigger sets it true, and only mouse-up ever sets it false again.
|
||
let mouseFire = false, padFire = false;
|
||
let boostEdge = false, fire2Edge = false, padBoostWas = false, padFire2Was = false;
|
||
|
||
const onKeyDown = (e) => {
|
||
keys.add(e.code);
|
||
if (e.code === 'Space') { boostEdge = true; e.preventDefault(); }
|
||
if (e.code === 'KeyF') fire2Edge = true;
|
||
};
|
||
const onKeyUp = (e) => keys.delete(e.code);
|
||
const onBlur = () => keys.clear(); // alt-tab must not leave a key stuck down
|
||
|
||
const onMouseMove = (e) => {
|
||
if (!intent.locked) return;
|
||
intent.aimDelta.x += e.movementX;
|
||
intent.aimDelta.y += e.movementY;
|
||
};
|
||
const onMouseDown = (e) => {
|
||
if (!intent.locked) { dom.requestPointerLock?.(); return; }
|
||
if (e.button === 0) mouseFire = true;
|
||
if (e.button === 2) fire2Edge = true;
|
||
};
|
||
const onMouseUp = (e) => { if (e.button === 0) mouseFire = false; };
|
||
const onLockChange = () => {
|
||
intent.locked = document.pointerLockElement === dom;
|
||
if (!intent.locked) { mouseFire = false; keys.clear(); }
|
||
};
|
||
const onContext = (e) => e.preventDefault(); // RMB is the torpedo, not a menu
|
||
|
||
addEventListener('keydown', onKeyDown);
|
||
addEventListener('keyup', onKeyUp);
|
||
addEventListener('blur', onBlur);
|
||
addEventListener('mousemove', onMouseMove);
|
||
dom.addEventListener('mousedown', onMouseDown);
|
||
addEventListener('mouseup', onMouseUp);
|
||
document.addEventListener('pointerlockchange', onLockChange);
|
||
dom.addEventListener('contextmenu', onContext);
|
||
|
||
function readPad() {
|
||
padFire = false;
|
||
const pads = navigator.getGamepads?.() || [];
|
||
for (const p of pads) {
|
||
if (!p?.connected) continue;
|
||
const [lx, ly, rx, ry] = p.axes;
|
||
// left stick wins over WASD only when actually deflected, so both can coexist
|
||
const dx = dz(lx ?? 0), dy = dz(ly ?? 0);
|
||
if (dx || dy) { intent.disc.x = dx; intent.disc.y = -dy; }
|
||
intent.aimStick.x = dz(rx ?? 0);
|
||
intent.aimStick.y = -dz(ry ?? 0);
|
||
|
||
const lt = p.buttons[6]?.value ?? 0, rt = p.buttons[7]?.value ?? 0;
|
||
if (lt > 0.05 || rt > 0.05) intent.throttle = rt - lt;
|
||
padFire = rt > 0.5;
|
||
|
||
const padBoost = !!(p.buttons[0]?.pressed || p.buttons[5]?.pressed);
|
||
if (padBoost && !padBoostWas) boostEdge = true;
|
||
padBoostWas = padBoost;
|
||
|
||
const padFire2 = !!p.buttons[2]?.pressed;
|
||
if (padFire2 && !padFire2Was) fire2Edge = true;
|
||
padFire2Was = padFire2;
|
||
return;
|
||
}
|
||
}
|
||
|
||
return {
|
||
intent,
|
||
|
||
// Called once per frame by player.js, before it reads `intent`.
|
||
sample() {
|
||
intent.disc.x = (keys.has('KeyD') ? 1 : 0) - (keys.has('KeyA') ? 1 : 0);
|
||
intent.disc.y = (keys.has('KeyW') ? 1 : 0) - (keys.has('KeyS') ? 1 : 0);
|
||
intent.aimStick.x = 0; intent.aimStick.y = 0;
|
||
intent.throttle = (keys.has('ShiftLeft') || keys.has('ShiftRight') ? 1 : 0) -
|
||
(keys.has('ControlLeft') || keys.has('ControlRight') ? 1 : 0);
|
||
readPad();
|
||
intent.fire = mouseFire || padFire;
|
||
|
||
// normalize diagonal so corner-dodging isn't 41% faster than a cardinal one
|
||
const m = Math.hypot(intent.disc.x, intent.disc.y);
|
||
if (m > 1) { intent.disc.x /= m; intent.disc.y /= m; }
|
||
|
||
intent.boost = boostEdge; boostEdge = false;
|
||
intent.fire2 = fire2Edge; fire2Edge = false;
|
||
return intent;
|
||
},
|
||
|
||
// player.js consumes the accumulated mouse delta exactly once per frame
|
||
takeAim() {
|
||
const d = { x: intent.aimDelta.x, y: intent.aimDelta.y };
|
||
intent.aimDelta.x = 0; intent.aimDelta.y = 0;
|
||
return d;
|
||
},
|
||
|
||
// Harness/test hook (flight/dev.html). Keyboard intents can be driven with real
|
||
// KeyboardEvents, but firing is gated behind pointer lock, which an automated session
|
||
// can't be granted — so the trigger needs a door. Dev-only; nothing ships against it.
|
||
_debug: {
|
||
setFire(v) { mouseFire = !!v; },
|
||
pulseFire2() { fire2Edge = true; },
|
||
pulseBoost() { boostEdge = true; },
|
||
key(code, down = true) { down ? keys.add(code) : keys.delete(code); },
|
||
},
|
||
|
||
dispose() {
|
||
removeEventListener('keydown', onKeyDown);
|
||
removeEventListener('keyup', onKeyUp);
|
||
removeEventListener('blur', onBlur);
|
||
removeEventListener('mousemove', onMouseMove);
|
||
dom.removeEventListener('mousedown', onMouseDown);
|
||
removeEventListener('mouseup', onMouseUp);
|
||
document.removeEventListener('pointerlockchange', onLockChange);
|
||
dom.removeEventListener('contextmenu', onContext);
|
||
if (document.pointerLockElement === dom) document.exitPointerLock?.();
|
||
},
|
||
};
|
||
}
|