File size: 9,084 Bytes
3570fe8 d5db286 3570fe8 89f5399 3570fe8 89f5399 3570fe8 d5db286 3570fe8 d5db286 3570fe8 d5db286 3570fe8 d5db286 3570fe8 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 | // Controller: the video-game-style input core. Aggregates pluggable input
// SOURCES (keyboard, gamepad, later touch) into one game-facing surface:
//
// - a continuous COMMAND: the [vx, vy, wz] twist the policy tracks, plus
// auxiliary analog AXES (jaw 0..1, camera-orbit rates -1..1);
// - discrete ACTIONS: edge-triggered events (roll, kicks, ball spawn...)
// dispatched to subscribers the moment a source fires them;
// - HUD support: per-source pressed-state snapshots and activity info so
// the hint keycaps can light up per physical control and per device.
//
// The game code (rl.js) instantiates the sources, registers them here,
// calls update(dt) once per render frame, reads getCommand()/getAxes()
// where it builds the policy observation, and subscribes its trigger
// functions with on(action, cb).
//
// ββ Source interface contract ββββββββββββββββββββββββββββββββββββββββββββ
// Every input source module (keyboard.js, gamepad.js, a future touch.js
// with a virtual joystick + buttons) implements:
//
// id Unique string ("keyboard", "gamepad", "touch"...). Action
// subscribers receive it as meta.source, so game code can
// attribute HUD flashes / keycap lighting to the right device.
// connected Boolean hardware presence. Keyboard: always true. Gamepad:
// a pad is currently reported by navigator.getGamepads().
// command Float32Array(3) [vx, vy, wz], ALREADY scaled to the game's
// velocity limits (sources take a getVelocityLimits() callback
// returning [fwd, back, ang], so limits can change at runtime,
// e.g. the legs <-> rollers switch). Must be a stable array
// reference updated in place: getCommand() hands it out
// without copying, and the control loop reads it between
// render frames.
// axes { jaw, orbitX, orbitY, ride } - auxiliary continuous
// channels. jaw in [0, 1] (mouth opening), orbit axes in
// [-1, 1] (camera orbit rate; the inertia/smoothing lives
// downstream in the camera code, sources report raw
// deflection), ride in [0, 1] (LT squeeze pressure, bends
// the wheee note's pitch). Stable object reference, updated
// in place. Channels a source does not drive may be omitted
// (merged as 0).
// pressed Plain object of booleans (stable reference) mirroring which
// physical controls are currently down. HUD highlighting
// only - never game logic.
// isActive() Whether the source currently claims authority over the
// continuous command. Keyboard: any move key held. Gamepad:
// stick deflected, and it keeps the claim until its smoothed
// command settles back to ~zero.
// init() Attach event listeners / hardware hooks. Called by
// Controller.init(), NOT at construction (rl.js constructs
// the sources early but arms the listeners at the same point
// in the boot where they historically went live).
// dispose() Detach everything init() attached.
// poll(dt) Per-frame tick (dt in seconds, clamped by the caller).
// Read the hardware, update command/axes/pressed in place,
// and fire edge-triggered actions via this.onAction(name).
// onAction (name, meta?) => void, assigned by the Controller at
// registration. Sources may call it from poll() (gamepad
// button edges) or straight from event handlers (keyboard:
// keeps the historical press-to-effect latency).
//
// ββ Arbitration ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// Continuous command: sources are registered in PRIORITY order (first =
// highest). Each frame the first source reporting isActive() owns the
// twist; when none is active the LAST registered source's command is used
// as the fallback (it reads zero when idle). With [gamepad, keyboard] this
// reproduces the historical `padActive ? padCmd : velCmd` exactly: live
// sticks win over held keys, and the keyboard takes back over once the
// pad's smoothed command has settled.
// Aux axes are merged across ALL sources regardless of who owns the twist
// (the pad triggers drive the jaw even while walking on the keyboard):
// jaw = max over sources, orbit = largest-magnitude value per axis.
//
// ββ Actions ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// roll one-shot roll (crouch-glide in roller mode; game decides)
// groundPick one-shot ground pick cycle (pad A / keyboard G)
// kickL, kickR one-shot kicks, explicit foot
// alternateKick one-shot kick, feet alternated by the game
// spawnBall pop / respawn the kickable ball (no bound key; game/API)
// headToggle HEAD mode on/off (pad Y): sticks drive the head
// sitToggle sit <-> stand (game gates it to legs mode)
// locoToggle legs <-> rollers switch
// chaseToggle chase camera on/off
// reset full sim reset (Space)
// walk back to the walk/run mode (pad DpadUp short press)
// quack chirp + jaw flap (pad RT edge, Schmitt-triggered)
//
// ββ Input lock βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// setLocked(true) zeroes getCommand() - the twist gate used while the
// entrance/respawn ceremony plays. Discrete actions still dispatch: each
// game trigger applies its own lock policy (e.g. Space-reset and the
// chase-cam toggle historically work while locked, kicks don't).
const ZERO_CMD = new Float32Array(3);
export class Controller {
#sources = [];
#listeners = new Map(); // action -> Set(cb)
#locked = false;
#axes = { jaw: 0, orbitX: 0, orbitY: 0, ride: 0 };
constructor({ sources = [] } = {}) {
for (const s of sources) this.addSource(s);
}
// Register in priority order (first registered wins arbitration ties).
addSource(source) {
source.onAction = (action, meta) =>
this.#dispatch(action, { source: source.id, ...meta });
this.#sources.push(source);
}
// Read-only source list, for advanced per-source queries the merged view
// can't answer (e.g. "is ANY source commanding a turn right now?").
get sources() {
return this.#sources;
}
// Arm every source's listeners/hardware hooks.
init() {
for (const s of this.#sources) s.init?.();
}
dispose() {
for (const s of this.#sources) s.dispose?.();
}
// Per-frame tick: poll every source (they fire their edge actions from
// inside poll), then merge the aux axes.
update(dt) {
for (const s of this.#sources) s.poll?.(dt);
let jaw = 0, ox = 0, oy = 0, ride = 0;
for (const s of this.#sources) {
const a = s.axes;
if (!a) continue;
jaw = Math.max(jaw, a.jaw ?? 0);
ride = Math.max(ride, a.ride ?? 0);
if (Math.abs(a.orbitX ?? 0) > Math.abs(ox)) ox = a.orbitX;
if (Math.abs(a.orbitY ?? 0) > Math.abs(oy)) oy = a.orbitY;
}
this.#axes.jaw = jaw;
this.#axes.orbitX = ox;
this.#axes.orbitY = oy;
this.#axes.ride = ride;
}
setLocked(v) {
this.#locked = !!v;
}
get locked() {
return this.#locked;
}
// Merged continuous twist [vx, vy, wz] (see the arbitration notes above).
// Returns live source arrays without copying - treat as read-only.
getCommand() {
if (this.#locked) return ZERO_CMD;
for (const s of this.#sources) if (s.isActive()) return s.command;
const fallback = this.#sources[this.#sources.length - 1];
return fallback ? fallback.command : ZERO_CMD;
}
// Merged aux axes { jaw, orbitX, orbitY }, refreshed by update().
getAxes() {
return this.#axes;
}
// Any source claiming twist authority (HUD "user is driving" signal).
anyActive() {
return this.#sources.some((s) => s.isActive());
}
// Per-source pressed snapshots for HUD keycap highlighting:
// { keyboard: {...}, gamepad: {...} }.
getPressed() {
const out = {};
for (const s of this.#sources) out[s.id] = s.pressed ?? {};
return out;
}
// Subscribe to a discrete action; cb(meta) with meta.source = source id.
// Returns an unsubscribe function.
on(action, cb) {
let set = this.#listeners.get(action);
if (!set) this.#listeners.set(action, (set = new Set()));
set.add(cb);
return () => set.delete(cb);
}
#dispatch(action, meta) {
const set = this.#listeners.get(action);
if (!set) return;
for (const cb of set) cb(meta);
}
}
|