cat / goq
clone

turns a linux box into a private steam streaming appliance: plug in, tap your key, play from another computer

1-n-sigls
goq / portal / controller-state.mjs
controller-state.mjs 11.1K
1export const STANDARD_BUTTON_COUNT = 17;2export const STANDARD_AXIS_COUNT = 4;3export const STICK_NAV_THRESHOLD = 0.55;4export const NAV_REPEAT_DELAY_MS = 360;5export const NAV_REPEAT_INTERVAL_MS = 110;67const BUTTON_A = 0;8const BUTTON_B = 1;9const BUTTON_DPAD_UP = 12;10const BUTTON_DPAD_DOWN = 13;11const BUTTON_DPAD_LEFT = 14;12const BUTTON_DPAD_RIGHT = 15;1314const PROTOCOL_BUTTONS = Object.freeze({15 a: 0,16 b: 1,17 x: 2,18 y: 3,19 left_shoulder: 4,20 right_shoulder: 5,21 back: 8,22 start: 9,23 left_stick: 10,24 right_stick: 11,25 dpad_up: 12,26 dpad_down: 13,27 dpad_left: 14,28 dpad_right: 15,29 guide: 16,30});3132function finiteUnit(value) {33 if (!Number.isFinite(value)) return 0;34 return Math.max(-1, Math.min(1, value));35}3637function buttonValue(button) {38 if (button == null) return 0;39 if (typeof button === 'number') return Math.max(0, Math.min(1, button));40 const value = Math.max(0, Math.min(1, Number(button.value) || 0));41 // Preserve analog trigger travel. `pressed` is only a fallback for digital42 // implementations that omit the expected value=1 sample.43 return button.pressed === true && value === 0 ? 1 : value;44}4546export function disconnectedControllerState(sequence = 0) {47 return Object.freeze({48 connected: false,49 index: null,50 id: '',51 mapping: '',52 sequence,53 timestamp: 0,54 axes: Object.freeze([0, 0, 0, 0]),55 buttons: Object.freeze(Array(STANDARD_BUTTON_COUNT).fill(0)),56 });57}5859/**60 * Convert a browser Gamepad into one fixed-size, immutable state. Truncating to61 * the standard mapping prevents unusual devices from growing transport state.62 */63export function normalizeGamepad(gamepad, sequence = 0) {64 if (!gamepad || gamepad.connected === false) return disconnectedControllerState(sequence);65 const axes = Array.from(66 { length: STANDARD_AXIS_COUNT },67 (_, index) => finiteUnit(Number(gamepad.axes?.[index]) || 0),68 );69 const buttons = Array.from(70 { length: STANDARD_BUTTON_COUNT },71 (_, index) => buttonValue(gamepad.buttons?.[index]),72 );73 return Object.freeze({74 connected: true,75 index: Number.isInteger(gamepad.index) ? gamepad.index : 0,76 id: String(gamepad.id || '').slice(0, 256),77 mapping: String(gamepad.mapping || '').slice(0, 32),78 sequence,79 timestamp: Number.isFinite(gamepad.timestamp) ? gamepad.timestamp : 0,80 axes: Object.freeze(axes),81 buttons: Object.freeze(buttons),82 });83}8485export function controllerStateSignature(state) {86 return [87 state.connected ? 1 : 0,88 state.index ?? -1,89 state.id,90 state.mapping,91 ...state.axes,92 ...state.buttons,93 ].join('|');94}9596export function neutralGamepadInputState() {97 return {98 a: false,99 b: false,100 x: false,101 y: false,102 left_shoulder: false,103 right_shoulder: false,104 back: false,105 start: false,106 guide: false,107 left_stick: false,108 right_stick: false,109 dpad_up: false,110 dpad_down: false,111 dpad_left: false,112 dpad_right: false,113 left_x: 0,114 left_y: 0,115 right_x: 0,116 right_y: 0,117 left_trigger: 0,118 right_trigger: 0,119 };120}121122/**123 * Prevent the A press used to cross the local control boundary from also124 * activating the remote game. Only A release opens the gate; harmless analog125 * stick or trigger drift must not keep controller forwarding disabled.126 */127export class ControllerActivationGate {128 constructor() {129 this.waitingForARelease = false;130 }131132 arm() {133 this.waitingForARelease = true;134 }135136 reset() {137 this.waitingForARelease = false;138 }139140 accepts(inputState) {141 if (!this.waitingForARelease) return true;142 if (inputState?.a === true) return false;143 this.waitingForARelease = false;144 return true;145 }146147 get active() {148 return this.waitingForARelease;149 }150}151152function signedAxis(value) {153 return Math.round(finiteUnit(value) * 32767);154}155156function unsignedTrigger(value) {157 return Math.round(Math.max(0, Math.min(1, Number(value) || 0)) * 32767);158}159160/** Map the browser standard layout to the bounded Sigil input protocol. */161export function toGamepadInputState(state) {162 const output = neutralGamepadInputState();163 if (!state?.connected || state.mapping !== 'standard') return output;164 const pressed = (name) => (state.buttons[PROTOCOL_BUTTONS[name]] || 0) >= 0.5;165 for (const name of Object.keys(PROTOCOL_BUTTONS)) output[name] = pressed(name);166 // Browsers expose LT/RT as standard buttons 6/7, with value in [0, 1].167 output.left_trigger = unsignedTrigger(state.buttons[6]);168 output.right_trigger = unsignedTrigger(state.buttons[7]);169 output.left_x = signedAxis(state.axes[0]);170 output.left_y = signedAxis(state.axes[1]);171 output.right_x = signedAxis(state.axes[2]);172 output.right_y = signedAxis(state.axes[3]);173 // A malformed or unusual browser sample cannot assert opposites together.174 if (output.dpad_up && output.dpad_down) {175 output.dpad_up = false;176 output.dpad_down = false;177 }178 if (output.dpad_left && output.dpad_right) {179 output.dpad_left = false;180 output.dpad_right = false;181 }182 return output;183}184185/** Reserve the local Back+Start escape chord without consuming either button alone. */186export function maskGamepadEscapeChord(inputState) {187 if (inputState?.back !== true || inputState?.start !== true) return inputState;188 return {189 ...inputState,190 back: false,191 start: false,192 };193}194195/**196 * Keep an already selected standard controller, otherwise prefer the first197 * connected standard mapping. With no standard mapping, retain the selected198 * device when possible and finally fall back to stable browser array order.199 */200export function selectPreferredController(gamepads, selectedIndex = null) {201 if (!Array.isArray(gamepads)) return null;202 const connected = gamepads.filter((gamepad) => gamepad && gamepad.connected !== false);203 const selected = connected.find((gamepad) => gamepad.index === selectedIndex) ?? null;204 if (selected?.mapping === 'standard') return selected;205 return connected.find((gamepad) => gamepad.mapping === 'standard') ?? selected ?? connected[0] ?? null;206}207208export class GamepadEscapeHold {209 constructor(holdMs = 1000) {210 this.holdMs = holdMs;211 this.startedAt = null;212 this.triggered = false;213 }214215 update(state, nowMs) {216 const held = state?.connected217 && (state.buttons[PROTOCOL_BUTTONS.back] || 0) >= 0.5218 && (state.buttons[PROTOCOL_BUTTONS.start] || 0) >= 0.5;219 if (!held) {220 this.startedAt = null;221 this.triggered = false;222 return false;223 }224 if (this.startedAt === null) this.startedAt = nowMs;225 if (!this.triggered && nowMs - this.startedAt >= this.holdMs) {226 this.triggered = true;227 return true;228 }229 return false;230 }231232 reset() {233 this.startedAt = null;234 this.triggered = false;235 }236}237238export function navigationDirection(state, threshold = STICK_NAV_THRESHOLD) {239 if (!state?.connected) return null;240 const pressed = (index) => (state.buttons[index] || 0) >= 0.5;241 if (pressed(BUTTON_DPAD_UP)) return 'up';242 if (pressed(BUTTON_DPAD_DOWN)) return 'down';243 if (pressed(BUTTON_DPAD_LEFT)) return 'left';244 if (pressed(BUTTON_DPAD_RIGHT)) return 'right';245246 const x = state.axes[0] || 0;247 const y = state.axes[1] || 0;248 if (Math.abs(x) < threshold && Math.abs(y) < threshold) return null;249 if (Math.abs(x) > Math.abs(y)) return x < 0 ? 'left' : 'right';250 return y < 0 ? 'up' : 'down';251}252253/** Turns sampled controller state into edge-triggered actions plus bounded nav repeat. */254export class ControllerActionRepeater {255 constructor({256 repeatDelayMs = NAV_REPEAT_DELAY_MS,257 repeatIntervalMs = NAV_REPEAT_INTERVAL_MS,258 } = {}) {259 this.repeatDelayMs = repeatDelayMs;260 this.repeatIntervalMs = repeatIntervalMs;261 this.reset();262 }263264 reset() {265 this.direction = null;266 this.nextRepeatAt = 0;267 this.aPressed = false;268 this.bPressed = false;269 }270271 update(state, nowMs) {272 if (!state?.connected) {273 this.reset();274 return [];275 }276 const actions = [];277 const direction = navigationDirection(state);278 if (direction !== this.direction) {279 this.direction = direction;280 this.nextRepeatAt = direction === null ? 0 : nowMs + this.repeatDelayMs;281 if (direction !== null) actions.push({ type: 'navigate', direction });282 } else if (direction !== null && nowMs >= this.nextRepeatAt) {283 actions.push({ type: 'navigate', direction });284 this.nextRepeatAt = nowMs + this.repeatIntervalMs;285 }286287 const aPressed = (state.buttons[BUTTON_A] || 0) >= 0.5;288 const bPressed = (state.buttons[BUTTON_B] || 0) >= 0.5;289 if (aPressed && !this.aPressed) actions.push({ type: 'activate' });290 if (bPressed && !this.bPressed) actions.push({ type: 'back' });291 this.aPressed = aPressed;292 this.bPressed = bPressed;293 return actions;294 }295}296297function center(rect) {298 return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };299}300301/**302 * Pick the nearest element in a visual direction. The large cross-axis penalty303 * makes rows and columns feel stable while still allowing irregular layouts.304 */305export function chooseDirectionalIndex(rects, currentIndex, direction) {306 if (!Array.isArray(rects) || rects.length === 0) return -1;307 if (!Number.isInteger(currentIndex) || currentIndex < 0 || currentIndex >= rects.length) return 0;308 const origin = center(rects[currentIndex]);309 let bestIndex = currentIndex;310 let bestScore = Infinity;311 for (let index = 0; index < rects.length; index++) {312 if (index === currentIndex) continue;313 const candidate = center(rects[index]);314 const dx = candidate.x - origin.x;315 const dy = candidate.y - origin.y;316 let primary;317 let cross;318 if (direction === 'left') { primary = -dx; cross = Math.abs(dy); }319 else if (direction === 'right') { primary = dx; cross = Math.abs(dy); }320 else if (direction === 'up') { primary = -dy; cross = Math.abs(dx); }321 else if (direction === 'down') { primary = dy; cross = Math.abs(dx); }322 else return currentIndex;323 if (primary <= 1) continue;324 const score = primary + cross * 2.5;325 if (score < bestScore) {326 bestScore = score;327 bestIndex = index;328 }329 }330 return bestIndex;331}332333/**334 * A single-slot async publisher. If transport is slow, intermediate samples are335 * replaced and only the latest controller state is delivered after it catches up.336 */337export class LatestControllerStatePublisher {338 constructor() {339 this.latest = disconnectedControllerState();340 this.handler = null;341 this.revision = 0;342 this.deliveredRevision = 0;343 this.inFlight = false;344 }345346 setHandler(handler) {347 if (handler !== null && typeof handler !== 'function') {348 throw new TypeError('controller state handler must be a function or null');349 }350 this.handler = handler;351 if (handler) this.#pump();352 }353354 publish(state) {355 this.latest = state;356 this.revision += 1;357 this.#pump();358 }359360 #pump() {361 if (this.inFlight || !this.handler || this.deliveredRevision === this.revision) return;362 const revision = this.revision;363 const state = this.latest;364 const handler = this.handler;365 this.inFlight = true;366 Promise.resolve()367 .then(() => handler(state))368 .catch((error) => console.warn('controller state handler failed:', error))369 .finally(() => {370 this.inFlight = false;371 this.deliveredRevision = revision;372 this.#pump();373 });374 }375}