From deaed5ad70e98287bc10de6f36f99d40b6b67ef9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 23:00:18 +0000 Subject: [PATCH 1/4] Add Gamepad actions, and rebinding for keyboard and gamepad Games had keyboard, pointer, and touch input but no controllers, and bindings were fixed when the actions were made, so players could not remap them. Gamepad follows Keyboard's shape (named actions, edges, idle/scripted/recording/parseTimeline) and adds the sticks, triggers, and every button by name for menus and settings screens. rebind() keeps the action names and swaps the keys or buttons. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015qkEJzyV5DVhoLVDMmoWJh --- .changeset/gamepad-actions.md | 9 + packages/browser/README.md | 2 +- packages/browser/dtslint/Gamepad.tst.ts | 38 ++ packages/browser/dtslint/Keyboard.tst.ts | 8 + packages/browser/package.json | 3 +- packages/browser/src/Gamepad.ts | 497 +++++++++++++++++++++++ packages/browser/src/Keyboard.ts | 51 ++- packages/browser/src/index.ts | 1 + packages/browser/test/Gamepad.test.ts | 247 +++++++++++ packages/browser/test/Keyboard.test.ts | 41 ++ 10 files changed, 883 insertions(+), 14 deletions(-) create mode 100644 .changeset/gamepad-actions.md create mode 100644 packages/browser/dtslint/Gamepad.tst.ts create mode 100644 packages/browser/src/Gamepad.ts create mode 100644 packages/browser/test/Gamepad.test.ts diff --git a/.changeset/gamepad-actions.md b/.changeset/gamepad-actions.md new file mode 100644 index 00000000..f66c886e --- /dev/null +++ b/.changeset/gamepad-actions.md @@ -0,0 +1,9 @@ +--- +"@typeonce/ritmo-browser": minor +--- + +**`Gamepad` reads controllers as named actions, and keyboard and gamepad bindings can change at runtime.** + +- **`Gamepad.actions(navigator, bindings, options)`** binds actions to buttons named by the browser's standard mapping (`"south"`, `"west"`, `"rightTrigger"`, `"dpadUp"`, `"start"`...). Each snapshot has `actions` (as `Keyboard`'s), every `buttons` state by name whatever the bindings (for menu navigation and for a settings screen waiting for the button to bind), the `left` and `right` sticks with a radial dead zone (`y` grows down, as `Touch`'s stick), how far each trigger is pulled, and the followed pad's `id`. It follows the pad used last and releases everything held when that pad disconnects. Call `poll()` each animation frame so taps shorter than an update count. `Gamepad.idle`, `scripted`, `recording`, `parseTimeline`, and `isButton` match the other devices; timelines name buttons, so a replay follows the bindings. +- **`rebind(bindings)`** on `Keyboard.actions(...)` and `Gamepad.actions(...)` binds the same actions to new keys or buttons, for players who remap their controls. An action held only through a key it lost reports `released`. Scripted keyboard actions ignore it (their timeline names actions). +- **Breaking for hand-written sources:** `Keyboard.Actions` now requires `rebind`. An object implementing it by hand adds `rebind() {}`. diff --git a/packages/browser/README.md b/packages/browser/README.md index fe9968ca..1aef15fd 100644 --- a/packages/browser/README.md +++ b/packages/browser/README.md @@ -1,6 +1,6 @@ # `@typeonce/ritmo-browser` -Browser integrations for ritmo: animation frames as a tick source for the core fixed-step loop (`BrowserTickSource`), keyboard actions with press/release edges, bound by character or by physical key (`Keyboard`), mouse and pen position and button edges over an element (`Pointer`), on-screen touch controls with a floating stick and drag-to-aim buttons (`Touch`), scripted and recorded keyboard and pointer timelines for tests and replays, and a system that copies input into a resource (`InputCapture`). +Browser integrations for ritmo: animation frames as a tick source for the core fixed-step loop (`BrowserTickSource`), keyboard actions with press/release edges, bound by character or by physical key (`Keyboard`), gamepad actions by standard-mapping button names plus the sticks and triggers (`Gamepad`), both rebindable at runtime for a controls settings screen, mouse and pen position and button edges over an element (`Pointer`), on-screen touch controls with a floating stick and drag-to-aim buttons (`Touch`), scripted and recorded keyboard, pointer, and gamepad timelines for tests and replays, and a system that copies input into a resource (`InputCapture`). ```sh pnpm add @typeonce/ritmo-browser @typeonce/ritmo diff --git a/packages/browser/dtslint/Gamepad.tst.ts b/packages/browser/dtslint/Gamepad.tst.ts new file mode 100644 index 00000000..e8db7962 --- /dev/null +++ b/packages/browser/dtslint/Gamepad.tst.ts @@ -0,0 +1,38 @@ +import { Gamepad } from "@typeonce/ritmo-browser" +import { describe, expect, it } from "tstyche" + +declare const host: Gamepad.GamepadHost + +describe("Gamepad", () => { + it("types snapshot actions by the bound action names", () => { + const input = Gamepad.actions(host, { attack: ["west"], dash: ["south", "rightBumper"] }) + expect(input.snapshot().actions.attack.pressed).type.toBe() + expect(input.snapshot().buttons.dpadUp.held).type.toBe() + expect(input.snapshot().left).type.toBe() + // @ts-expect-error Property 'jump' does not exist + input.snapshot().actions.jump + }) + + it("binds only standard mapping buttons, at least one per action", () => { + // @ts-expect-error Type '"A"' is not assignable to type + Gamepad.actions(host, { attack: ["A"] }) + // @ts-expect-error Type '[]' is not assignable to type 'readonly [ + Gamepad.actions(host, { attack: [] }) + }) + + it("rebinds the same actions only", () => { + const input = Gamepad.actions(host, { attack: ["west"], dash: ["south"] }) + expect(input.rebind).type.toBeCallableWith({ attack: ["north"], dash: ["east"] }) + expect(input.rebind).type.not.toBeCallableWith({ attack: ["north"] }) + expect(input.rebind).type.not.toBeCallableWith({ attack: ["north"], dash: ["east"], jump: ["south"] }) + }) + + it("accepts the browser's navigator as a host", () => { + expect().type.toBeAssignableTo() + }) + + it("narrows stored names with isButton", () => { + const name: unknown = "south" + if (Gamepad.isButton(name)) expect(name).type.toBe() + }) +}) diff --git a/packages/browser/dtslint/Keyboard.tst.ts b/packages/browser/dtslint/Keyboard.tst.ts index 1547cbbe..33cd53c7 100644 --- a/packages/browser/dtslint/Keyboard.tst.ts +++ b/packages/browser/dtslint/Keyboard.tst.ts @@ -28,3 +28,11 @@ describe("Keyboard", () => { Keyboard.actions(host, { jump: [] }) }) }) + +describe("Keyboard.rebind", () => { + it("rebinds the same actions only", () => { + const input = Keyboard.actions(host, { jump: [" "], left: ["a"] }) + expect(input.rebind).type.toBeCallableWith({ jump: [Keyboard.code("KeyJ")], left: ["ArrowLeft"] }) + expect(input.rebind).type.not.toBeCallableWith({ jump: ["j"] }) + }) +}) diff --git a/packages/browser/package.json b/packages/browser/package.json index f5cf2d45..a487ee8a 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -1,7 +1,7 @@ { "name": "@typeonce/ritmo-browser", "version": "0.10.0", - "description": "Browser fixed-step loop, keyboard actions, and input capture for Ritmo", + "description": "Browser fixed-step loop, keyboard and gamepad actions, and input capture for Ritmo", "author": "Sandro Maglione", "license": "MIT", "repository": { @@ -19,6 +19,7 @@ "ritmo", "browser", "keyboard", + "gamepad", "game-loop" ], "type": "module", diff --git a/packages/browser/src/Gamepad.ts b/packages/browser/src/Gamepad.ts new file mode 100644 index 00000000..31d9c8fa --- /dev/null +++ b/packages/browser/src/Gamepad.ts @@ -0,0 +1,497 @@ +/** + * Gamepad input as caller-named actions, plus the sticks and triggers. + * + * Games bind actions to buttons once and read one snapshot per update, as + * with `Keyboard`. The module owns the plumbing every game rewrites: polling + * (the Gamepad API has no button events), press/release edges, a radial dead + * zone on the sticks, following the pad that was used last when several are + * connected, and releasing everything held when a pad disconnects. It does + * not know about the ECS; `InputCapture.system(...)` copies snapshots from a + * service into a resource. + * + * Buttons are named by their place in the browser's standard mapping + * (`"south"` is A on Xbox, Cross on PlayStation, B on Nintendo), so bindings + * stay the same on every pad. A pad whose `mapping` is not `"standard"` is + * read by the same indices, which may not match its labels: let players + * remap (`rebind`). The snapshot also reports every `button` by name, + * whatever the bindings, for fixed menu navigation and for a settings screen + * that waits for the button to bind. + * + * Polling happens on every `snapshot()`. A press and release that both fall + * between two snapshots is missed unless the host also calls `poll()` more + * often (each animation frame, for example). Browsers expose a pad only after + * a button is pressed on it, and only in a secure context (HTTPS or + * localhost); elsewhere `navigator.getGamepads` is missing, and a game uses + * `scripted(bindings, [])` as an idle stand-in. + * + * @module Gamepad + * @docGroup browser + * + * @example + * ```ts + * const pad = Gamepad.actions(navigator, { + * attack: ["west"], + * dash: ["south"], + * pause: ["start"] + * }) + * + * // Each animation frame (optional), so taps shorter than an update count: + * pad.poll() + * + * // Once per update: + * const snapshot = pad.snapshot() + * move(snapshot.left) + * if (snapshot.actions.attack.pressed) attack() + * + * // In a settings screen, rebinding keeps the action names: + * pad.rebind({ attack: ["north"], dash: ["south"], pause: ["start"] }) + * ``` + * + * For tests, replays, and headless runs, `scripted(...)` plays a timeline of + * button presses, releases, and stick moves instead of polling a host, and + * `recording(...)` wraps live actions to capture the timeline a session + * produced. `parseTimeline(...)` validates a timeline loaded from JSON. + */ +import * as Result from "@typeonce/ritmo/Result" + +import type { ActionState } from "./Keyboard.ts" + +/** + * The buttons of the browser's standard mapping, in index order: face + * buttons by compass position, bumpers and triggers, the two small center + * buttons, the stick clicks, the d-pad, and the home button. + */ +export const buttonNames = [ + "south", + "east", + "west", + "north", + "leftBumper", + "rightBumper", + "leftTrigger", + "rightTrigger", + "select", + "start", + "leftStick", + "rightStick", + "dpadUp", + "dpadDown", + "dpadLeft", + "dpadRight", + "home" +] as const + +/** One button of the standard mapping, see {@link buttonNames}. */ +export type Button = (typeof buttonNames)[number] + +/** + * Action name to the buttons that trigger it. Every action needs at least + * one button. + */ +export type Bindings = Readonly]>> + +/** New buttons for the same actions, for {@link Actions.rebind}. */ +export type Rebinding = { readonly [Action in keyof B]: readonly [Button, ...Array