diff --git a/.changeset/gamepad-actions.md b/.changeset/gamepad-actions.md new file mode 100644 index 00000000..84f8e2ef --- /dev/null +++ b/.changeset/gamepad-actions.md @@ -0,0 +1,13 @@ +--- +"@typeonce/ritmo-browser": minor +--- + +**`Gamepad` reads controllers as named actions, and keyboard and gamepad bindings can change at runtime.** + +- **`Gamepad.actions(window, 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. It reads the pads on every animation frame (and on every snapshot) until `dispose()`, so taps shorter than an update count with nothing to call; the lifecycle is `snapshot` / `rebind` / `dispose`, as for `Keyboard`. `Gamepad.idle`, `scripted`, `recording`, `parseTimeline`, and `isButton` match the other devices; timelines name buttons, so a replay follows the bindings, and an entry's `connected` records a disconnect or reconnect, so a replay drops and regains the pad on the same frames. +- **`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`; a key held across the rebind holds a newly bound action only after a fresh press, so an action never turns held without a press. It takes exactly the actions the source was made with: an extra action fails to compile with ` is not an action of these bindings`, even from an object not written inline. Scripted keyboard actions ignore it (their timeline names actions). +- **`Keyboard.presses(window)`** reports every key that went down since the previous snapshot, bound or not, as `{ key, code }`, so a settings screen can wait for the key to bind (the gamepad equivalent is the snapshot's `buttons`). +- **`Keyboard.parseRebinding(bindings, data)`** and **`Gamepad.parseRebinding(bindings, data)`** validate saved bindings (for example from `localStorage`) as a `Result` ready for `rebind`, with the offending `path` on failure. +- **Timelines replay a release and press inside one update.** An action (keyboard) or button (gamepad) in both `press` and `release` of one entry is now a release then a new press when it was held, and still a tap when it was up, so `recording(...)` captures a quick double tap and the replay matches. +- **Breaking: `InputCapture.system` requires the resource's value type to be exactly the source's snapshot type.** A resource that only matched both ways (for example one with an extra optional field) now fails with a message naming the fix: declare it as `Descriptor.TransientResource>()`. +- **Breaking for hand-written sources:** `Keyboard.Actions` now requires `rebind`. An object implementing it by hand adds `rebind() {}`. diff --git a/integration/top-down/debug.ts b/integration/top-down/debug.ts index b5d67f76..690d833f 100644 --- a/integration/top-down/debug.ts +++ b/integration/top-down/debug.ts @@ -11,14 +11,14 @@ * the timeline, the invariants, and the questions. */ import { HostClock } from "@typeonce/ritmo" -import { Keyboard } from "@typeonce/ritmo-browser" +import { Gamepad, Keyboard } from "@typeonce/ritmo-browser" import { Invariant, Session } from "@typeonce/ritmo-devtools" import { WORLD_HEIGHT, WORLD_WIDTH } from "./constants.ts" import { PlayerPositionQuery } from "./queries.ts" import { Game, Player, Position } from "./schema.ts" import { createTopDownSimulation } from "./simulation.ts" -import { inputBindings } from "./types.ts" +import { inputBindings, padBindings } from "./types.ts" // Frame 0 is the first update. Press and release are applied before that // frame's input capture; a press and release in one frame is a tap. @@ -30,7 +30,9 @@ const keyboard = Keyboard.scripted(inputBindings, [ // A test clock keeps every timing at 0, so the output is the same on every run. const hostClock = HostClock.test() -const simulation = createTopDownSimulation({ keyboard, hostClock }) +// No controller in this run; script one the same way, by button names. +const gamepad = Gamepad.scripted(padBindings, []) +const simulation = createTopDownSimulation({ keyboard, gamepad, hostClock }) if (!simulation.ok) throw new Error(simulation.error.message) const { runtime, setup, update } = simulation.value diff --git a/integration/top-down/math.ts b/integration/top-down/math.ts index 00cf728c..da257514 100644 --- a/integration/top-down/math.ts +++ b/integration/top-down/math.ts @@ -3,7 +3,8 @@ import * as InputAxis from "@typeonce/ritmo-math/InputAxis" import * as Scalar from "@typeonce/ritmo-math/Scalar" import * as Vector2Module from "@typeonce/ritmo-math/Vector2" import { WORLD_HEIGHT, WORLD_WIDTH } from "./constants.ts" -import type { InputStateValue, Vector2 } from "./types.ts" +import type { Gamepad } from "@typeonce/ritmo-browser" +import type { InputStateValue, padBindings, Vector2 } from "./types.ts" export const clamp = (value: number, min: number, max: number): number => { const nextValue = Scalar.Finite.option(value) @@ -18,13 +19,21 @@ export const clamp = (value: number, min: number, max: number): number => { export const lengthSquared = (vector: Vector2): number => Vector2Module.lengthSquared(vector) -export const normalizeMovement = (input: InputStateValue): Vector2 => - InputAxis.vectorFromAxes({ - up: input.up.held, - down: input.down.held, - left: input.left.held, - right: input.right.held +/** + * The walk direction from both devices: a tilted left stick wins and keeps + * its analog length (a half tilt walks at half speed); otherwise the keys + * and the d-pad give a unit direction. + */ +export const normalizeMovement = (keys: InputStateValue, pad: Gamepad.Snapshot): Vector2 => { + const stick = Vector2Module.option(pad.left) + if (stick !== null && Vector2Module.lengthSquared(stick) > 0) return stick + return InputAxis.vectorFromAxes({ + up: keys.up.held || pad.actions.up.held, + down: keys.down.held || pad.actions.down.held, + left: keys.left.held || pad.actions.left.held, + right: keys.right.held || pad.actions.right.held }) +} export const intersects = ( firstPosition: Vector2, diff --git a/integration/top-down/runtime.ts b/integration/top-down/runtime.ts index 1876a5de..9ecece93 100644 --- a/integration/top-down/runtime.ts +++ b/integration/top-down/runtime.ts @@ -1,8 +1,8 @@ -import { Keyboard } from "@typeonce/ritmo-browser" +import { Gamepad, Keyboard } from "@typeonce/ritmo-browser" import { pickupLayout } from "./content.ts" import { Facing, Game, Locomotion } from "./schema.ts" -import { inputBindings } from "./types.ts" +import { inputBindings, padBindings } from "./types.ts" import type { FrameContextValue } from "./types.ts" export const makeEmptyFocusedCollectable = () => ({ @@ -14,6 +14,7 @@ export const makeEmptyFocusedCollectable = () => ({ export const initialResources = (frame: FrameContextValue) => ({ DeltaTime: frame.deltaSeconds, InputState: Keyboard.idle(inputBindings), + PadState: Gamepad.idle(padBindings), FocusedCollectable: makeEmptyFocusedCollectable(), CollectedCount: 0, TotalCollectables: pickupLayout.length diff --git a/integration/top-down/schedules.ts b/integration/top-down/schedules.ts index 3fac4f12..ede03839 100644 --- a/integration/top-down/schedules.ts +++ b/integration/top-down/schedules.ts @@ -1,6 +1,7 @@ import { CaptureFrameContextSystem, CaptureInputSystem, + CapturePadSystem, CollectFocusedCollectableSystem, MovePlayerSystem, PlanPlayerVelocitySystem, @@ -17,10 +18,11 @@ export const setupWorldSchedule = Game.Schedule( Game.Schedule.applyDeferred() ) -/** One fixed update. It needs only the keyboard and the frame context, so it runs headless (see `simulation.ts`). */ +/** One fixed update. It needs only the keyboard, the gamepad, and the frame context, so it runs headless (see `simulation.ts`). */ export const simulationSchedule = Game.Schedule( CaptureFrameContextSystem, CaptureInputSystem, + CapturePadSystem, PlanPlayerVelocitySystem, MovePlayerSystem, UpdateFocusedCollectableSystem, diff --git a/integration/top-down/schema.ts b/integration/top-down/schema.ts index 87221595..b86dd21a 100644 --- a/integration/top-down/schema.ts +++ b/integration/top-down/schema.ts @@ -1,9 +1,11 @@ import { Descriptor, Entity, Schema } from "@typeonce/ritmo" +import type { InputCapture } from "@typeonce/ritmo-browser" import * as Size2 from "@typeonce/ritmo-math/Size2" import * as Vector2 from "@typeonce/ritmo-math/Vector2" import type { FrameContextValue, + GamepadInput as GamepadInputValue, InputStateValue, KeyboardInput as KeyboardInputValue } from "./types.ts" @@ -34,6 +36,9 @@ export const CollectedCount = Descriptor.Resource()("TopDown/CollectedCo export const TotalCollectables = Descriptor.Resource()("TopDown/TotalCollectables") export const KeyboardInput = Descriptor.Service()("TopDown/KeyboardInput") +export const GamepadInput = Descriptor.Service()("TopDown/GamepadInput") +// Captured from the gamepad at the start of each update; its type is exactly what the source produces. +export const PadState = Descriptor.TransientResource>()("TopDown/PadState") export const FrameContext = Descriptor.Service()("TopDown/FrameContext") export const Game = Schema.bind( @@ -56,6 +61,7 @@ export const Game = Schema.bind( resources: { DeltaTime, InputState, + PadState, FocusedCollectable, CollectedCount, TotalCollectables diff --git a/integration/top-down/simulation.ts b/integration/top-down/simulation.ts index ece317a8..2ba85c10 100644 --- a/integration/top-down/simulation.ts +++ b/integration/top-down/simulation.ts @@ -1,13 +1,14 @@ /** * The top-down game without a browser: the simulation schedules on a - * runtime that needs only a keyboard and a frame context, with the debug - * handle attached. + * runtime that needs only a keyboard, a gamepad, and a frame context, with + * the debug handle attached. * * Tests, benchmarks, and coding agents drive it directly: * * ```ts * const keyboard = Keyboard.scripted(inputBindings, [{ frame: 0, press: ["right"] }]) - * const simulation = createTopDownSimulation({ keyboard, hostClock: HostClock.test() }) + * const gamepad = Gamepad.scripted(padBindings, []) + * const simulation = createTopDownSimulation({ keyboard, gamepad, hostClock: HostClock.test() }) * if (simulation.ok) { * simulation.value.runtime.tick(simulation.value.setup) * simulation.value.runtime.tick(simulation.value.update) @@ -19,12 +20,14 @@ import * as Result from "@typeonce/ritmo/Result" import { FIXED_STEP_SECONDS } from "./constants.ts" import { initialMachines, initialResources } from "./runtime.ts" -import { FrameContext, Game, KeyboardInput } from "./schema.ts" +import { FrameContext, Game, GamepadInput, KeyboardInput } from "./schema.ts" import { setupWorldSchedule, simulationSchedule } from "./schedules.ts" -import type { FrameContextValue, KeyboardInput as KeyboardInputValue } from "./types.ts" +import type { FrameContextValue, GamepadInput as GamepadInputValue, KeyboardInput as KeyboardInputValue } from "./types.ts" export interface TopDownSimulationOptions { readonly keyboard: KeyboardInputValue + /** `Gamepad.actions(window, padBindings)` in a browser, `Gamepad.scripted(padBindings, timeline)` headless. */ + readonly gamepad: GamepadInputValue /** Times the debug traces; `HostClock.test()` keeps every timing at 0. Pass the same clock to `Session.make`. */ readonly hostClock: HostClock.HostClock /** Defaults to one fixed step at 60 Hz. */ @@ -40,6 +43,7 @@ export const createTopDownSimulation = (options: TopDownSimulationOptions) => { const made = Game.Runtime.make({ services: Game.Runtime.services( Game.Runtime.service(KeyboardInput, options.keyboard), + Game.Runtime.service(GamepadInput, options.gamepad), Game.Runtime.service(FrameContext, frame) ), resources: initialResources(frame), diff --git a/integration/top-down/systems/index.ts b/integration/top-down/systems/index.ts index 3facef3e..f34d0e87 100644 --- a/integration/top-down/systems/index.ts +++ b/integration/top-down/systems/index.ts @@ -1,5 +1,5 @@ export { SetupWorldSystem } from "./setup.ts" -export { CaptureFrameContextSystem, CaptureInputSystem } from "./input.ts" +export { CaptureFrameContextSystem, CaptureInputSystem, CapturePadSystem } from "./input.ts" export { PlanPlayerVelocitySystem, MovePlayerSystem } from "./movement.ts" export { UpdateFocusedCollectableSystem, CollectFocusedCollectableSystem } from "./interaction.ts" export { ResolveFacingSystem, ResolveLocomotionSystem } from "./facing.ts" diff --git a/integration/top-down/systems/input.ts b/integration/top-down/systems/input.ts index ab9e7c7a..741d5639 100644 --- a/integration/top-down/systems/input.ts +++ b/integration/top-down/systems/input.ts @@ -3,8 +3,10 @@ import { DeltaTime, FrameContext, Game, + GamepadInput, InputState, - KeyboardInput + KeyboardInput, + PadState } from "../schema.ts" export const CaptureInputSystem = InputCapture.system(Game, { @@ -13,6 +15,12 @@ export const CaptureInputSystem = InputCapture.system(Game, { resource: InputState }) +export const CapturePadSystem = InputCapture.system(Game, { + name: "TopDown/CapturePad", + source: GamepadInput, + resource: PadState +}) + export const CaptureFrameContextSystem = Game.System( "TopDown/CaptureFrameContext", { diff --git a/integration/top-down/systems/interaction.ts b/integration/top-down/systems/interaction.ts index aef691ec..e6c89f99 100644 --- a/integration/top-down/systems/interaction.ts +++ b/integration/top-down/systems/interaction.ts @@ -5,7 +5,8 @@ import { Collectable, FocusedCollectable, Game, - InputState + InputState, + PadState } from "../schema.ts" import { makeEmptyFocusedCollectable } from "../runtime.ts" @@ -64,6 +65,7 @@ export const CollectFocusedCollectableSystem = Game.System( }, resources: { input: Game.System.readResource(InputState), + pad: Game.System.readResource(PadState), focused: Game.System.writeResource(FocusedCollectable), collectedCount: Game.System.writeResource(CollectedCount) }, @@ -71,7 +73,7 @@ export const CollectFocusedCollectableSystem = Game.System( }, ({ queries, resources, commands }) => { - if (!resources.input.get().interact.pressed) { + if (!resources.input.get().interact.pressed && !resources.pad.get().actions.interact.pressed) { return } diff --git a/integration/top-down/systems/movement.ts b/integration/top-down/systems/movement.ts index f5ae466e..11030eb9 100644 --- a/integration/top-down/systems/movement.ts +++ b/integration/top-down/systems/movement.ts @@ -3,7 +3,7 @@ import * as Vector2 from "@typeonce/ritmo-math/Vector2" import { PLAYER_SPEED } from "../constants.ts" import { normalizeMovement, resolveHorizontalMovement, resolveVerticalMovement } from "../math.ts" import { PlayerMovementQuery, WallCollisionQuery } from "../queries.ts" -import { DeltaTime, Game, InputState } from "../schema.ts" +import { DeltaTime, Game, InputState, PadState } from "../schema.ts" export const PlanPlayerVelocitySystem = Game.System( "TopDown/PlanPlayerVelocity", @@ -12,7 +12,8 @@ export const PlanPlayerVelocitySystem = Game.System( player: PlayerMovementQuery }, resources: { - input: Game.System.readResource(InputState) + input: Game.System.readResource(InputState), + pad: Game.System.readResource(PadState) } }, ({ queries, resources }) => @@ -22,7 +23,7 @@ export const PlanPlayerVelocitySystem = Game.System( return } - const direction = normalizeMovement(resources.input.get()) + const direction = normalizeMovement(resources.input.get(), resources.pad.get()) player.value.data.velocity.updateRaw(() => ({ x: direction.x * PLAYER_SPEED, diff --git a/integration/top-down/test/simulation.test.ts b/integration/top-down/test/simulation.test.ts index d915c911..10fb36b6 100644 --- a/integration/top-down/test/simulation.test.ts +++ b/integration/top-down/test/simulation.test.ts @@ -1,15 +1,19 @@ import { HostClock } from "@typeonce/ritmo" -import { Keyboard } from "@typeonce/ritmo-browser" +import { Gamepad, Keyboard } from "@typeonce/ritmo-browser" import { Session } from "@typeonce/ritmo-devtools" import { describe, expect, it } from "vitest" import { createTopDownSimulation } from "../simulation.ts" import { PlayerPositionQuery } from "../queries.ts" import { CollectedCount, Game } from "../schema.ts" -import { inputBindings } from "../types.ts" +import { inputBindings, padBindings } from "../types.ts" -const start = (timeline: Keyboard.Timeline) => { - const simulation = createTopDownSimulation({ keyboard: Keyboard.scripted(inputBindings, timeline), hostClock: HostClock.test() }) +const start = (timeline: Keyboard.Timeline, pad: Gamepad.Timeline = []) => { + const simulation = createTopDownSimulation({ + keyboard: Keyboard.scripted(inputBindings, timeline), + gamepad: Gamepad.scripted(padBindings, pad), + hostClock: HostClock.test() + }) if (!simulation.ok) throw new Error(simulation.error.message) const { runtime, setup, update } = simulation.value expect(runtime.tick(setup).ok).toBe(true) @@ -19,6 +23,9 @@ const start = (timeline: Keyboard.Timeline) => { const PlayerPosition = Game.Inspector("Test/PlayerPosition", { queries: { player: PlayerPositionQuery } }, ({ queries }) => queries.player.each().map((match) => match.data.position.get())) +const Collected = Game.Inspector("Test/Collected", { resources: { count: Game.System.readResource(CollectedCount) } }, ({ resources }) => + resources.count.get()) + describe("top-down simulation", () => { it("runs headless and moves the player with scripted input", () => { const { runtime, update } = start([{ frame: 0, press: ["right"] }, { frame: 30, release: ["right"] }]) @@ -32,6 +39,31 @@ describe("top-down simulation", () => { }) }) + it("moves the player with the left stick, at its analog length", () => { + const { runtime, update } = start([], [{ frame: 0, left: { x: 0.5, y: 0 } }, { frame: 30, left: { x: 0, y: 0 } }]) + for (let frame = 0; frame < 60; frame++) expect(runtime.tick(update).ok).toBe(true) + const [position] = runtime.inspect(PlayerPosition) + expect(position?.x).toBeCloseTo(640 + 0.5 * 280 * 30 / 60) + expect(position?.y).toBe(260) + }) + + it("moves the player with the d-pad like the arrow keys", () => { + const { runtime, update } = start([], [{ frame: 0, press: ["dpadRight"] }, { frame: 30, release: ["dpadRight"] }]) + for (let frame = 0; frame < 60; frame++) expect(runtime.tick(update).ok).toBe(true) + const [position] = runtime.inspect(PlayerPosition) + expect(position?.x).toBeCloseTo(640 + 280 * 30 / 60) + }) + + it("collects a pickup with the south button", () => { + const { runtime, update } = start([], [ + { frame: 0, press: ["dpadLeft"] }, + { frame: 85, release: ["dpadLeft"] }, + { frame: 88, press: ["south"], release: ["south"] } + ]) + for (let frame = 0; frame < 120; frame++) expect(runtime.tick(update).ok).toBe(true) + expect(runtime.inspect(Collected)).toBe(1) + }) + it("describes the simulation schedules without lint warnings", () => { const { runtime } = start([]) const description = runtime.debug.describe() @@ -48,7 +80,7 @@ describe("top-down debug session", () => { { frame: 88, press: ["interact"], release: ["interact"] } ]) const hostClock = HostClock.test() - const simulation = createTopDownSimulation({ keyboard, hostClock }) + const simulation = createTopDownSimulation({ keyboard, gamepad: Gamepad.scripted(padBindings, []), hostClock }) if (!simulation.ok) throw new Error(simulation.error.message) const { runtime, setup, update } = simulation.value const session = Session.make(runtime, { hostClock, schedules: { setup, update } }) diff --git a/integration/top-down/types.ts b/integration/top-down/types.ts index 011f63b9..246028e4 100644 --- a/integration/top-down/types.ts +++ b/integration/top-down/types.ts @@ -1,4 +1,4 @@ -import type { Keyboard } from "@typeonce/ritmo-browser" +import type { Gamepad, Keyboard } from "@typeonce/ritmo-browser" import type * as Vector2Value from "@typeonce/ritmo-math/Vector2" export type Vector2 = Vector2Value.Vector2 @@ -11,6 +11,15 @@ export const inputBindings = { } as const export type InputStateValue = Keyboard.Snapshot export type KeyboardInput = Keyboard.Actions +/** The same actions on a controller: the d-pad walks (the left stick too, see `normalizeMovement`), south interacts. */ +export const padBindings = { + up: ["dpadUp"], + down: ["dpadDown"], + left: ["dpadLeft"], + right: ["dpadRight"], + interact: ["south"] +} as const +export type GamepadInput = Gamepad.Actions /** What one fixed update needs from the host: the step length. */ export type FrameContextValue = { readonly deltaSeconds: number diff --git a/packages/browser/README.md b/packages/browser/README.md index fe9968ca..967b059d 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 (capture the key or button to bind, validate saved bindings with `parseRebinding`), 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..5eed96be --- /dev/null +++ b/packages/browser/dtslint/Gamepad.tst.ts @@ -0,0 +1,78 @@ +import { Result } from "@typeonce/ritmo" +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("rejects extra actions even from an object that is not written inline", () => { + const input = Gamepad.actions(host, { attack: ["west"], dash: ["south"] }) + const next = { attack: ["north"], dash: ["east"], jump: ["south"] } as const + // @ts-expect-error jump is not an action of these bindings + input.rebind(next) + const wide: Record = {} + expect(input.rebind).type.not.toBeCallableWith(wide) + let current: Gamepad.Rebinding<{ readonly attack: readonly ["west"]; readonly dash: readonly ["south"] }> = { attack: ["west"], dash: ["south"] } + current = { ...current, attack: ["north"] } + expect(input.rebind).type.toBeCallableWith(current) + expect(Gamepad.recording(input).rebind).type.not.toBeCallableWith(next) + }) + + it("accepts the browser's window as a host", () => { + expect().type.toBeAssignableTo() + // @ts-expect-error is missing the following properties from type 'GamepadHost': navigator + Gamepad.actions(navigator, { attack: ["west"] }) + }) + + it("has no poll(): the pads are read on every animation frame", () => { + // @ts-expect-error Property 'poll' does not exist + Gamepad.actions(host, { attack: ["west"] }).poll() + }) + + it("narrows stored names with isButton", () => { + const name: unknown = "south" + if (Gamepad.isButton(name)) expect(name).type.toBe() + }) +}) + +describe("Gamepad.parseRebinding", () => { + it("parses into a rebinding for the same actions", () => { + const bindings = { attack: ["west"], dash: ["south"] } as const + expect(Gamepad.parseRebinding(bindings, null)).type.toBe< + Result.Result, Gamepad.InvalidRebinding> + >() + const parsed = Gamepad.parseRebinding(bindings, null) + if (parsed.ok) Gamepad.actions(host, bindings).rebind(parsed.value) + }) + + it("rejects a rebinding parsed for other actions", () => { + const parsed = Gamepad.parseRebinding({ attack: ["west"] }, null) + if (parsed.ok) { + // @ts-expect-error Property 'dash' is missing + Gamepad.actions(host, { attack: ["west"], dash: ["south"] }).rebind(parsed.value) + } + }) +}) diff --git a/packages/browser/dtslint/InputCapture.tst.ts b/packages/browser/dtslint/InputCapture.tst.ts index 32f4342f..3db0a2a1 100644 --- a/packages/browser/dtslint/InputCapture.tst.ts +++ b/packages/browser/dtslint/InputCapture.tst.ts @@ -8,8 +8,12 @@ type Snapshot = Keyboard.Snapshot const KeyboardInput = Descriptor.Service>()("CaptureTypes/Keyboard") const Input = Descriptor.TransientResource()("CaptureTypes/Input") const Wrong = Descriptor.Resource<{ readonly jump: boolean }>()("CaptureTypes/Wrong") +const Fewer = Descriptor.TransientResource>()("CaptureTypes/Fewer") +const Optional = Descriptor.TransientResource<{ readonly jump: Keyboard.ActionState; readonly left?: Keyboard.ActionState }>()("CaptureTypes/Optional") +const Wide = Descriptor.TransientResource()("CaptureTypes/Wide") +const Declared = Descriptor.TransientResource>()("CaptureTypes/Declared") const Clock = Descriptor.Service<{ readonly now: () => number }>()("CaptureTypes/Clock") -const Game = Schema.bind(Schema.fragment({ resources: { Input, Wrong } })) +const Game = Schema.bind(Schema.fragment({ resources: { Input, Wrong, Fewer, Optional, Wide, Declared } })) describe("InputCapture", () => { it("requires the service and resource at tick time", () => { @@ -45,9 +49,19 @@ describe("InputCapture", () => { expect(ready.tick(Game.Schedule(Capture)).ok).type.toBe() }) - it("rejects resources whose value is not the snapshot type", () => { - // @ts-expect-error Type 'ActionState' is not assignable to type 'boolean'. + it("rejects resources whose value is not exactly the snapshot type", () => { + // @ts-expect-error must be exactly the source's snapshot type InputCapture.system(Game, { name: "CaptureTypes/Wrong", source: KeyboardInput, resource: Wrong }) + // @ts-expect-error must be exactly the source's snapshot type + InputCapture.system(Game, { name: "CaptureTypes/Fewer", source: KeyboardInput, resource: Fewer }) + // @ts-expect-error must be exactly the source's snapshot type + InputCapture.system(Game, { name: "CaptureTypes/Optional", source: KeyboardInput, resource: Optional }) + // @ts-expect-error must be exactly the source's snapshot type + InputCapture.system(Game, { name: "CaptureTypes/Wide", source: KeyboardInput, resource: Wide }) + }) + + it("accepts a resource declared with SnapshotOf", () => { + expect(InputCapture.system).type.toBeCallableWith(Game, { name: "CaptureTypes/Declared", source: KeyboardInput, resource: Declared }) }) it("rejects sources without snapshot()", () => { diff --git a/packages/browser/dtslint/Keyboard.tst.ts b/packages/browser/dtslint/Keyboard.tst.ts index 1547cbbe..6eb6a332 100644 --- a/packages/browser/dtslint/Keyboard.tst.ts +++ b/packages/browser/dtslint/Keyboard.tst.ts @@ -1,3 +1,4 @@ +import { Result } from "@typeonce/ritmo" import { Keyboard } from "@typeonce/ritmo-browser" import { describe, expect, it } from "tstyche" @@ -28,3 +29,50 @@ 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"] }) + }) + + it("rejects extra actions even from an object that is not written inline", () => { + const input = Keyboard.actions(host, { jump: [" "], left: ["a"] }) + const next = { jump: ["j"], left: ["q"], extra: ["x"] } as const + // @ts-expect-error extra is not an action of these bindings + input.rebind(next) + const wide: Record = {} + expect(input.rebind).type.not.toBeCallableWith(wide) + let current: Keyboard.Rebinding<{ readonly jump: readonly [" "]; readonly left: readonly ["a"] }> = { jump: [" "], left: ["a"] } + current = { ...current, jump: [Keyboard.code("KeyJ")] } + expect(input.rebind).type.toBeCallableWith(current) + expect(Keyboard.recording(input).rebind).type.not.toBeCallableWith(next) + }) +}) + +describe("Keyboard.presses", () => { + it("returns the keys pressed since the previous snapshot", () => { + expect(Keyboard.presses(host).snapshot()).type.toBe>() + expect(Keyboard.presses(host).snapshot()[0]?.code).type.toBe() + }) +}) + +describe("Keyboard.parseRebinding", () => { + it("parses into a rebinding for the same actions", () => { + const bindings = { jump: [" "], left: ["a"] } as const + expect(Keyboard.parseRebinding(bindings, null)).type.toBe< + Result.Result, Keyboard.InvalidRebinding> + >() + const parsed = Keyboard.parseRebinding(bindings, null) + if (parsed.ok) Keyboard.actions(host, bindings).rebind(parsed.value) + }) + + it("rejects a rebinding parsed for other actions", () => { + const parsed = Keyboard.parseRebinding({ jump: [" "] }, null) + if (parsed.ok) { + // @ts-expect-error Property 'left' is missing + Keyboard.actions(host, { jump: [" "], left: ["a"] }).rebind(parsed.value) + } + }) +}) 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..b77fa49b --- /dev/null +++ b/packages/browser/src/Gamepad.ts @@ -0,0 +1,627 @@ +/** + * 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; `parseRebinding(...)` validates + * bindings saved as JSON. + * + * The Gamepad API has no button events, so the pads are read on every + * animation frame and on every `snapshot()`; a tap that starts and ends + * between two updates still counts. 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(window, { + * attack: ["west"], + * dash: ["south"], + * pause: ["start"] + * }) + * + * // 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 * as BrowserTickSource from "./BrowserTickSource.ts" +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}. Load saved + * ones with {@link parseRebinding}. + */ +export type Rebinding = { readonly [Action in keyof B]: readonly [Button, ...Array