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);
  }
}