From 97e39c5b23fe35554bf861619245e1c26a9a83c9 Mon Sep 17 00:00:00 2001 From: SandroMaglione Date: Fri, 23 Oct 2026 10:36:35 +0900 Subject: [PATCH] Bind Pointer actions like Keyboard and Gamepad, and group devices in Controls Pointer.actions(target, blurHost, bindings) reports bound actions, raw buttons (with back and forward), position, and used, with rebind and parseRebinding, so the mouse merges into Controls. actions.group makes devices used together (keyboard and mouse) one merge entry for lastUsed and available, with devices and rebind keyed by device name. Gamepad and Pointer share one button tracker. The top-down integration groups a mouse click with the keyboard. Co-Authored-By: Claude Opus 5.5 --- .changeset/consume-once.md | 2 +- .changeset/pointer-actions.md | 12 + integration/top-down/debug.ts | 7 +- integration/top-down/runtime.ts | 6 +- integration/top-down/simulation.ts | 11 +- integration/top-down/test/simulation.test.ts | 29 +- integration/top-down/types.ts | 10 +- packages/browser/README.md | 2 +- packages/browser/dtslint/Controls.tst.ts | 66 +++- packages/browser/dtslint/Pointer.tst.ts | 63 ++++ packages/browser/src/Controls.ts | 158 +++++++--- packages/browser/src/Gamepad.ts | 130 ++------ packages/browser/src/Pointer.ts | 313 +++++++++++-------- packages/browser/src/internal/buttons.ts | 148 +++++++++ packages/browser/test/Controls.test.ts | 73 ++++- packages/browser/test/Pointer.test.ts | 108 +++++-- packages/browser/test/Touch.test.ts | 6 +- 17 files changed, 816 insertions(+), 328 deletions(-) create mode 100644 .changeset/pointer-actions.md create mode 100644 packages/browser/dtslint/Pointer.tst.ts create mode 100644 packages/browser/src/internal/buttons.ts diff --git a/.changeset/consume-once.md b/.changeset/consume-once.md index 4842cb7e..0f4ce510 100644 --- a/.changeset/consume-once.md +++ b/.changeset/consume-once.md @@ -6,6 +6,6 @@ **Draining a service is declared, checked at compile time, and observed at run time.** - **`System.Consumes<"snapshot">`** marks the methods of a service type that drain it. A system that declares the service with `Game.System.service` sees those methods as a compile error naming the fix (`snapshot() drains Game/Controls: declare it with Game.System.consumeService, or read the resource its capture writes`); `Game.System.consumeService` calls them. Its other methods stay available through `service` (a settings screen can still `rebind`). -- Every input source is marked: `Keyboard.Actions`, `Keyboard.Presses`, `Gamepad.Actions`, `Touch.Touch`, `Pointer.Pointer`, `Controls.Device`, `Controls.Controls`, and `InputCapture.Source`. **Breaking:** a system that called `snapshot()` through `Game.System.service` no longer compiles; read the resource `InputCapture.system` writes instead. +- Every input source is marked: `Keyboard.Actions`, `Keyboard.Presses`, `Gamepad.Actions`, `Touch.Touch`, `Pointer.Actions`, `Controls.Device`, `Controls.Controls`, and `InputCapture.Source`. **Breaking:** a system that called `snapshot()` through `Game.System.service` no longer compiles; read the resource `InputCapture.system` writes instead. - `Game.System.service` returns `ServiceRead` and `consumeService` returns `ServiceRead`. - The `service-consumed-twice` lint also reports what a debug runtime saw: a consumed service whose consumers ran at more than one placement in the session, in unnamed schedules too (`Game/Drain (schedule#2, step 0, unnamed)`). Named schedules are reported once, by the static lint. diff --git a/.changeset/pointer-actions.md b/.changeset/pointer-actions.md new file mode 100644 index 00000000..5022d07c --- /dev/null +++ b/.changeset/pointer-actions.md @@ -0,0 +1,12 @@ +--- +"@typeonce/ritmo-browser": minor +--- + +**`Pointer` binds actions like `Keyboard` and `Gamepad` and merges into `Controls`, and `Controls.group` makes devices used together count as one.** + +- **Breaking: `Pointer.track(target, blurHost, options?)` is `Pointer.actions(target, blurHost, bindings, options?)`.** Bindings map actions to buttons (`{ fire: ["primary"] }`); a misspelled button or an empty list does not compile. Pass `{}` for position and raw buttons only. The `Pointer.Pointer` type is `Pointer.Actions` and `TrackOptions` is `ActionsOptions`. +- **Breaking: the snapshot is `{ actions, buttons, position, over, used }`.** Read `input.buttons.primary` (or a bound action) instead of `input.primary`. `used` is a press, a hold, or movement over the element; releases do not count. +- **The `back` and `forward` side buttons** (`PointerEvent.button` 3 and 4) are reported and bindable. `Pointer.buttonNames` and `Pointer.isButton` list and check them. +- `rebind(...)` swaps the buttons of the same actions; `Pointer.parseRebinding(bindings, data)` validates saved ones. +- **Breaking: `Pointer.idle(bindings, position?)` and `Pointer.scripted(bindings, timeline, start?)`** take the bindings first. Timelines still name buttons, so a replay follows the bindings as live play does. +- **`actions.group({ keyboard, mouse })`** groups devices into one merge entry: it binds what its devices bind between them, and `lastUsed` and `available` follow it as a whole, so walking with the keyboard while aiming with the mouse stays on one device. Grouped devices keep their own names in `devices`, `rebind`, and `idle`; `lastUsed` names the group. Pass the group to `lacking(...)` for actions none of its devices binds. A group of fewer than two devices, a `lacking` device or a group inside a group, a device binding an undeclared action, and a name used twice across devices and groups do not compile. diff --git a/integration/top-down/debug.ts b/integration/top-down/debug.ts index 690d833f..5d0362f7 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 { Gamepad, Keyboard } from "@typeonce/ritmo-browser" +import { Gamepad, Keyboard, Pointer } 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, padBindings } from "./types.ts" +import { inputBindings, mouseBindings, 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. @@ -32,7 +32,8 @@ const keyboard = Keyboard.scripted(inputBindings, [ const hostClock = HostClock.test() // No controller in this run; script one the same way, by button names. const gamepad = Gamepad.scripted(padBindings, []) -const simulation = createTopDownSimulation({ keyboard, gamepad, hostClock }) +const mouse = Pointer.scripted(mouseBindings, []) +const simulation = createTopDownSimulation({ keyboard, mouse, gamepad, hostClock }) if (!simulation.ok) throw new Error(simulation.error.message) const { runtime, setup, update } = simulation.value diff --git a/integration/top-down/runtime.ts b/integration/top-down/runtime.ts index a2bc29cb..3c39487f 100644 --- a/integration/top-down/runtime.ts +++ b/integration/top-down/runtime.ts @@ -1,8 +1,8 @@ -import { Gamepad, Keyboard } from "@typeonce/ritmo-browser" +import { Gamepad, Keyboard, Pointer } from "@typeonce/ritmo-browser" import { pickupLayout } from "./content.ts" import { Facing, Game, Locomotion } from "./schema.ts" -import { Actions, inputBindings, padBindings } from "./types.ts" +import { Actions, inputBindings, mouseBindings, padBindings } from "./types.ts" import type { FrameContextValue } from "./types.ts" export const makeEmptyFocusedCollectable = () => ({ @@ -13,7 +13,7 @@ export const makeEmptyFocusedCollectable = () => ({ export const initialResources = (frame: FrameContextValue) => ({ DeltaTime: frame.deltaSeconds, - InputState: Actions.idle({ keyboard: Keyboard.idle(inputBindings), gamepad: Gamepad.idle(padBindings) }), + InputState: Actions.idle({ keyboard: Keyboard.idle(inputBindings), mouse: Pointer.idle(mouseBindings), gamepad: Gamepad.idle(padBindings) }), FocusedCollectable: makeEmptyFocusedCollectable(), CollectedCount: 0, TotalCollectables: pickupLayout.length diff --git a/integration/top-down/simulation.ts b/integration/top-down/simulation.ts index e0d3df22..e3f19662 100644 --- a/integration/top-down/simulation.ts +++ b/integration/top-down/simulation.ts @@ -1,14 +1,15 @@ /** * The top-down game without a browser: the simulation schedules on a - * runtime that needs only a keyboard, a gamepad, and a frame context, with + * runtime that needs only a keyboard, a mouse, 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 mouse = Pointer.scripted(mouseBindings, []) * const gamepad = Gamepad.scripted(padBindings, []) - * const simulation = createTopDownSimulation({ keyboard, gamepad, hostClock: HostClock.test() }) + * const simulation = createTopDownSimulation({ keyboard, mouse, gamepad, hostClock: HostClock.test() }) * if (simulation.ok) { * simulation.value.runtime.tick(simulation.value.setup) * simulation.value.runtime.tick(simulation.value.update) @@ -23,10 +24,12 @@ import { initialMachines, initialResources } from "./runtime.ts" import { ControlsInput, FrameContext, Game } from "./schema.ts" import { setupWorldSchedule, simulationSchedule } from "./schedules.ts" import { makeControls } from "./types.ts" -import type { FrameContextValue, GamepadInput as GamepadInputValue, KeyboardInput as KeyboardInputValue } from "./types.ts" +import type { FrameContextValue, GamepadInput as GamepadInputValue, KeyboardInput as KeyboardInputValue, MouseInput as MouseInputValue } from "./types.ts" export interface TopDownSimulationOptions { readonly keyboard: KeyboardInputValue + /** `Pointer.actions(canvas, window, mouseBindings)` in a browser, `Pointer.scripted(mouseBindings, timeline)` headless. */ + readonly mouse: MouseInputValue /** `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`. */ @@ -43,7 +46,7 @@ export const createTopDownSimulation = (options: TopDownSimulationOptions) => { const frame = options.frame ?? defaultFrameContext const made = Game.Runtime.make({ services: Game.Runtime.services( - Game.Runtime.service(ControlsInput, makeControls({ keyboard: options.keyboard, gamepad: options.gamepad })), + Game.Runtime.service(ControlsInput, makeControls({ keyboard: options.keyboard, mouse: options.mouse, gamepad: options.gamepad })), Game.Runtime.service(FrameContext, frame) ), resources: initialResources(frame), diff --git a/integration/top-down/test/simulation.test.ts b/integration/top-down/test/simulation.test.ts index 10fb36b6..426b6e49 100644 --- a/integration/top-down/test/simulation.test.ts +++ b/integration/top-down/test/simulation.test.ts @@ -1,16 +1,17 @@ import { HostClock } from "@typeonce/ritmo" -import { Gamepad, Keyboard } from "@typeonce/ritmo-browser" +import { Gamepad, Keyboard, Pointer } 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, padBindings } from "../types.ts" +import { CollectedCount, Game, InputState } from "../schema.ts" +import { inputBindings, mouseBindings, padBindings } from "../types.ts" -const start = (timeline: Keyboard.Timeline, pad: Gamepad.Timeline = []) => { +const start = (timeline: Keyboard.Timeline, pad: Gamepad.Timeline = [], clicks: Pointer.Timeline = []) => { const simulation = createTopDownSimulation({ keyboard: Keyboard.scripted(inputBindings, timeline), + mouse: Pointer.scripted(mouseBindings, clicks), gamepad: Gamepad.scripted(padBindings, pad), hostClock: HostClock.test() }) @@ -23,6 +24,9 @@ const start = (timeline: Keyboard.Timeline, pad: Gamepad.T const PlayerPosition = Game.Inspector("Test/PlayerPosition", { queries: { player: PlayerPositionQuery } }, ({ queries }) => queries.player.each().map((match) => match.data.position.get())) +const LastUsed = Game.Inspector("Test/LastUsed", { resources: { input: Game.System.readResource(InputState) } }, ({ resources }) => + resources.input.get().lastUsed) + const Collected = Game.Inspector("Test/Collected", { resources: { count: Game.System.readResource(CollectedCount) } }, ({ resources }) => resources.count.get()) @@ -64,6 +68,21 @@ describe("top-down simulation", () => { expect(runtime.inspect(Collected)).toBe(1) }) + it("collects a pickup with a click while walking, keyboard and mouse as one device", () => { + const { runtime, update } = start( + [{ frame: 0, press: ["left"] }, { frame: 85, release: ["left"] }], + [], + [{ frame: 40, move: { x: 100, y: 100 } }, { frame: 88, press: ["primary"], release: ["primary"] }] + ) + const lastUsed = new Set() + for (let frame = 0; frame < 120; frame++) { + expect(runtime.tick(update).ok).toBe(true) + lastUsed.add(runtime.inspect(LastUsed)) + } + expect(runtime.inspect(Collected)).toBe(1) + expect([...lastUsed]).toEqual(["desktop"]) + }) + it("describes the simulation schedules without lint warnings", () => { const { runtime } = start([]) const description = runtime.debug.describe() @@ -80,7 +99,7 @@ describe("top-down debug session", () => { { frame: 88, press: ["interact"], release: ["interact"] } ]) const hostClock = HostClock.test() - const simulation = createTopDownSimulation({ keyboard, gamepad: Gamepad.scripted(padBindings, []), hostClock }) + const simulation = createTopDownSimulation({ keyboard, mouse: Pointer.scripted(mouseBindings, []), 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 83b2c566..ef9fef04 100644 --- a/integration/top-down/types.ts +++ b/integration/top-down/types.ts @@ -1,5 +1,5 @@ import { Controls } from "@typeonce/ritmo-browser" -import type { Gamepad, Keyboard } from "@typeonce/ritmo-browser" +import type { Gamepad, Keyboard, Pointer } from "@typeonce/ritmo-browser" import type * as Vector2Value from "@typeonce/ritmo-math/Vector2" export type Vector2 = Vector2Value.Vector2 @@ -22,8 +22,12 @@ export const padBindings = { interact: ["south"] } as const export type GamepadInput = Gamepad.Actions -/** The one input source: both devices merged, built the same way in the browser and in tests. */ -export const makeControls = (devices: { readonly keyboard: KeyboardInput; readonly gamepad: GamepadInput }) => Actions.merge(devices) +/** A click interacts too; the mouse is grouped with the keyboard, so using both is one device in use. */ +export const mouseBindings = { interact: ["primary"] } as const +export type MouseInput = Pointer.Actions +/** The one input source: keyboard and mouse as one group, and the gamepad, built the same way in the browser and in tests. */ +export const makeControls = (devices: { readonly keyboard: KeyboardInput; readonly mouse: MouseInput; readonly gamepad: GamepadInput }) => + Actions.merge({ desktop: Actions.group({ keyboard: devices.keyboard, mouse: devices.mouse }), gamepad: devices.gamepad }) export type ControlsValue = ReturnType export type InputStateValue = ReturnType /** What one fixed update needs from the host: the step length. */ diff --git a/packages/browser/README.md b/packages/browser/README.md index dc152316..0e34490a 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`), 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, one input source merged from several devices, with the actions declared once (`Controls`), 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 actions (rebindable too), raw buttons, and position 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, one input source merged from several devices, with the actions declared once and devices used together (keyboard and mouse) grouped as one (`Controls`), and a system that copies input into a resource (`InputCapture`). ```sh pnpm add @typeonce/ritmo-browser @typeonce/ritmo diff --git a/packages/browser/dtslint/Controls.tst.ts b/packages/browser/dtslint/Controls.tst.ts index 88723b0e..6500af9c 100644 --- a/packages/browser/dtslint/Controls.tst.ts +++ b/packages/browser/dtslint/Controls.tst.ts @@ -1,9 +1,10 @@ import { Descriptor, Schema } from "@typeonce/ritmo" -import { Controls, Gamepad, InputCapture, Keyboard, Touch } from "@typeonce/ritmo-browser" +import { Controls, Gamepad, InputCapture, Keyboard, Pointer, Touch } from "@typeonce/ritmo-browser" import { describe, expect, it } from "tstyche" declare const host: Keyboard.KeyboardHost & Gamepad.GamepadHost declare const touch: Touch.Touch<"jump"> +declare const canvas: Pointer.PointerTarget const keyBindings = { jump: [" "], pause: ["Escape"] } as const const padBindings = { jump: ["south"], pause: ["start"] } as const @@ -11,6 +12,9 @@ const Actions = Controls.actions(["jump", "pause"]) const keyboard = Keyboard.actions(host, keyBindings) const gamepad = Gamepad.actions(host, padBindings) +const mouseBindings = { jump: ["primary"] } as const +const mouse = Pointer.actions(canvas, window, mouseBindings) +const keysWithoutPause = Keyboard.actions(host, { jump: [" "] }) describe("Controls", () => { it("types merged actions by the declared names", () => { @@ -118,4 +122,64 @@ describe("Controls", () => { // @ts-expect-error Argument of type '[]' is not assignable Controls.actions([]) }) + + it("groups devices into one entry: lastUsed by entry, devices and rebind by device", () => { + const controls = Actions.merge({ desktop: Actions.group({ keyboard, mouse }), gamepad, touch: Actions.lacking(touch, ["pause"]) }) + const input = controls.snapshot() + expect(input.lastUsed).type.toBe<"desktop" | "gamepad" | "touch" | null>() + expect(input.devices.mouse).type.toBe>() + expect(input.devices.mouse.position).type.toBe() + expect(input.devices.keyboard).type.toBe>() + expect(controls.rebind.mouse).type.toBeCallableWith({ jump: ["back"] }) + expect(controls.rebind.mouse).type.not.toBeCallableWith({ jump: ["left"] }) + // @ts-expect-error Property 'desktop' does not exist + input.devices.desktop + // @ts-expect-error Property 'desktop' does not exist + controls.rebind.desktop + }) + + it("checks a group as one device in the merge", () => { + // @ts-expect-error desktop has no action pause: bind it on desktop, or pass lacking(desktop, [...]) from the action set + Actions.merge({ desktop: Actions.group({ keyboard: keysWithoutPause, mouse }), gamepad }) + const desktop = Actions.lacking(Actions.group({ keyboard: keysWithoutPause, mouse }), ["pause"]) + expect(Actions.merge({ desktop, gamepad }).snapshot().lastUsed).type.toBe<"desktop" | "gamepad" | null>() + // @ts-expect-error This device also lacks pause: bind it, or list it here + Actions.lacking(Actions.group({ keyboard: keysWithoutPause, mouse }), []) + }) + + it("rejects groups that are not two or more plain devices of the declared actions", () => { + // @ts-expect-error a group needs at least two devices: pass keyboard to merge directly + Actions.group({ keyboard }) + // @ts-expect-error lacking applies to the whole group: pass lacking(group(...), [...]) + Actions.group({ keyboard, touch: Actions.lacking(touch, ["pause"]) }) + // @ts-expect-error groups do not nest: list every device in one group + Actions.group({ desktop: Actions.group({ keyboard, mouse }), gamepad }) + const extra = Pointer.actions(canvas, window, { jump: ["primary"], fire: ["secondary"] }) + // @ts-expect-error mouse binds fire, which is not one of the actions + Actions.group({ keyboard, mouse: extra }) + }) + + it("rejects a name used twice across groups and devices", () => { + const fullMouse = Pointer.actions(canvas, window, { jump: ["primary"], pause: ["middle"] }) + // @ts-expect-error the name mouse is used twice: give every device and group its own name + Actions.merge({ desktop: Actions.group({ keyboard, mouse }), mouse: fullMouse }) + // @ts-expect-error the name keyboard is used twice + Actions.merge({ keyboard: Actions.group({ keyboard, mouse }), gamepad }) + }) + + it("initializes a grouped merge by device names", () => { + const makeControls = (devices: { readonly keyboard: Keyboard.Actions; readonly mouse: Pointer.Actions }) => + Actions.merge({ desktop: Actions.group(devices), gamepad }) + const ControlsInput = Descriptor.Service>()("ControlsTypes/Grouped") + const Input = Descriptor.TransientResource>()("ControlsTypes/GroupedInput") + const Game = Schema.bind(Schema.fragment({ resources: { Input } })) + const services = Game.Runtime.services(Game.Runtime.service(ControlsInput, makeControls({ keyboard, mouse }))) + const idle = Actions.idle({ keyboard: Keyboard.idle(keyBindings), mouse: Pointer.idle(mouseBindings), gamepad: Gamepad.idle(padBindings) }) + Game.Runtime.make({ services, resources: { Input: idle } }) + Game.Runtime.make({ + services, + // @ts-expect-error Property 'mouse' is missing + resources: { Input: Actions.idle({ keyboard: Keyboard.idle(keyBindings), gamepad: Gamepad.idle(padBindings) }) } + }) + }) }) diff --git a/packages/browser/dtslint/Pointer.tst.ts b/packages/browser/dtslint/Pointer.tst.ts new file mode 100644 index 00000000..04d7a0b7 --- /dev/null +++ b/packages/browser/dtslint/Pointer.tst.ts @@ -0,0 +1,63 @@ +import { Descriptor, Result, Schema } from "@typeonce/ritmo" +import { Pointer } from "@typeonce/ritmo-browser" +import { describe, expect, it } from "tstyche" + +declare const canvas: Pointer.PointerTarget +declare const blurHost: Pointer.BlurHost + +const bindings = { fire: ["primary"], aim: ["secondary", "back"] } as const + +describe("Pointer", () => { + it("types snapshot actions by the bound action names, with raw buttons and the position", () => { + const mouse = Pointer.actions(canvas, blurHost, bindings) + const input = mouse.snapshot() + expect(input).type.toBe>() + expect(input.actions.fire.pressed).type.toBe() + expect(input.buttons.forward.held).type.toBe() + expect(input.position).type.toBe() + expect(input.used).type.toBe() + // @ts-expect-error Property 'fier' does not exist + input.actions.fier + expect(Pointer.buttonNames).type.toBe() + }) + + it("binds only pointer buttons, at least one per action", () => { + // @ts-expect-error Type '"primay"' is not assignable to type + Pointer.actions(canvas, blurHost, { fire: ["primay"] }) + // @ts-expect-error Type '[]' is not assignable to type 'readonly [ + Pointer.actions(canvas, blurHost, { fire: [] }) + expect(Pointer.actions(canvas, blurHost, {}).snapshot().actions).type.toBe<{}>() + }) + + it("rebinds the same actions only", () => { + const mouse = Pointer.actions(canvas, blurHost, bindings) + expect(mouse.rebind).type.toBeCallableWith({ fire: ["middle"], aim: ["primary"] }) + expect(mouse.rebind).type.not.toBeCallableWith({ fire: ["middle"] }) + // @ts-expect-error jump is not an action of these bindings + mouse.rebind({ fire: ["middle"], aim: ["primary"], jump: ["back"] }) + }) + + it("parses saved bindings and timelines into typed values", () => { + expect(Pointer.parseRebinding(bindings, {})).type.toBe, Pointer.InvalidRebinding>>() + expect(Pointer.parseTimeline([])).type.toBe>() + }) + + it("idles, scripts, and records with the same bindings", () => { + expect(Pointer.idle(bindings)).type.toBe>() + const scripted = Pointer.scripted(bindings, [{ frame: 0, press: ["back"] }]) + expect(scripted).type.toBe>() + expect(Pointer.recording(scripted)).type.toBe>() + // @ts-expect-error Type '"left"' is not assignable to type + Pointer.scripted(bindings, [{ frame: 0, press: ["left"] }]) + }) + + it("takes snapshots only through consumeService", () => { + const MouseInput = Descriptor.Service>()("PointerTypes/Mouse") + const Game = Schema.bind(Schema.fragment({})) + Game.System("PointerTypes/Settings", { services: { mouse: Game.System.service(MouseInput) } }, ({ services }) => { + expect(services.mouse.rebind).type.toBeCallableWith({ fire: ["middle"], aim: ["primary"] }) + // @ts-expect-error snapshot() drains PointerTypes/Mouse + services.mouse.snapshot() + }) + }) +}) diff --git a/packages/browser/src/Controls.ts b/packages/browser/src/Controls.ts index 59c98b07..6e816a00 100644 --- a/packages/browser/src/Controls.ts +++ b/packages/browser/src/Controls.ts @@ -3,8 +3,9 @@ * action once whatever the player uses. * * Declare the game's actions once with `Controls.actions([...])`, bind them - * on every device (`Keyboard.actions`, `Gamepad.actions`, `Touch.track`), - * and merge the devices with the declaration's `merge`. Merged devices + * on every device (`Keyboard.actions`, `Gamepad.actions`, `Pointer.actions`, + * `Touch.track`), and merge the devices with the declaration's `merge`. + * Merged devices * behave like one device with all their bindings: an action is `held` while * any device holds it, `pressed` when any device pressed it, and `released` * when a device released it and none holds it now. Device-specific data @@ -12,7 +13,13 @@ * `merge`; `lastUsed` names the device the player used last, for button * prompts. * - * Every device binds every action. A device that cannot (a touch layout + * Devices the player uses together (keyboard and mouse) go in one + * `group(...)`: the group is one entry of the merge, so walking with the + * keyboard while aiming with the mouse keeps `lastUsed` on the group, and + * `available` follows what the group binds between its devices. Grouped + * devices keep their own names in `devices` and `rebind`. + * + * Every entry binds every action. A device that cannot (a touch layout * without a pause button) is passed as `actions.lacking(device, [...])`, * which names exactly the actions it lacks. Reading a lacking action on that * device does not compile; `available` reports whether the last used device @@ -70,36 +77,92 @@ export interface DeviceSnapshot { readonly used: boolean } -/** A device `Controls` can merge: `Keyboard.actions`, `Gamepad.actions`, `Touch.track`, or their scripted forms. */ +/** A device `Controls` can merge: `Keyboard.actions`, `Gamepad.actions`, `Pointer.actions`, `Touch.track`, or their scripted forms. */ export interface Device extends System.Consumes<"snapshot"> { snapshot(): DeviceSnapshot dispose(): void } +/** + * Devices the player uses together as one (keyboard and mouse), built with + * {@link ActionSet.group}. The group is one entry of the merge: it binds + * what its devices bind between them, and `lastUsed` and `available` follow + * it as a whole. Its devices keep their own names in `devices` and `rebind`. + */ +export interface Group { + readonly _tag: "Group" + readonly members: Members +} + /** A device passed with the actions it lacks; build it with {@link ActionSet.lacking}. */ -export interface Lacking { +export interface Lacking { readonly _tag: "Lacking" readonly device: D readonly lacking: ReadonlyArray } -type Entry = Device | Lacking +/** One entry of a merge before `lacking`: a device or a group of devices. */ +type Unit = Device | Group>> -/** The actions a device binds. */ -type ActionsOf = D extends { snapshot(): { readonly actions: infer Actions } } ? keyof Actions & string : never +type Entry = Unit | Lacking + +/** The actions a device, or the devices of a group, bind. */ +type ActionsOf = D extends Group ? { [K in keyof M]: ActionsOf }[keyof M] + : D extends { snapshot(): { readonly actions: infer Actions } } ? keyof Actions & string + : never type DeviceOf = E extends Lacking ? D : E type SnapshotOf = D extends { snapshot(): infer S } ? S : never -/** Each device's full snapshot, by the name it was merged under. */ -type DevicesOf = { readonly [K in keyof R]: SnapshotOf> } +/** The devices one merge entry contributes, by name: a group's members, or the device under the entry's name. */ +type MembersOf = U extends Group ? M : { readonly [P in K & string]: U } + +/** Every device name of a merge, with groups flattened. */ +type DeviceNames = { [K in keyof R]: keyof MembersOf> }[keyof R] & string + +/** The device a merge names `N`, wherever it is. */ +type DeviceNamed = { + [K in keyof R]: MembersOf> extends infer M ? N extends keyof M ? M[N] : never : never +}[keyof R] -/** The `rebind` of each merged device that has one, by the name it was merged under. */ +/** Each device's full snapshot, by device name (group members under their own names). */ +type DevicesOf = { readonly [N in DeviceNames]: SnapshotOf> } + +/** The `rebind` of each merged device that has one, by device name. */ type RebindOf = { - readonly [K in keyof R as DeviceOf extends { readonly rebind: unknown } ? K : never]: DeviceOf extends { readonly rebind: infer Rebind } ? Rebind : never + readonly [N in DeviceNames as DeviceNamed extends { readonly rebind: unknown } ? N : never]: DeviceNamed extends { readonly rebind: infer Rebind } ? Rebind : never } +/** The names one entry takes: its own, and its members' for a group. */ +type NamesOf = U extends Group ? K | keyof M : K + +/** A device or group name the merge uses twice. */ +type Repeated = { + [K in keyof R]: + | (NamesOf> & { [J in Exclude]: NamesOf> }[Exclude]) + | (DeviceOf extends Group ? K & keyof M : never) +}[keyof R] & string + +/** Rejects a merge that uses a name twice: `devices`, `rebind`, and `lastUsed` need one meaning per name. */ +type UniqueNames = [Repeated] extends [never] + ? unknown + : `the name ${Repeated} is used twice: give every device and group its own name` + +/** A group member when it is a plain device binding only declared actions, otherwise an error naming the fix. */ +type CheckMember = D extends Lacking + ? "lacking applies to the whole group: pass lacking(group(...), [...])" + : D extends Group ? "groups do not nest: list every device in one group" + : [Exclude, A>] extends [never] ? D + : `${Name} binds ${Exclude, A>}, which is not one of the actions` + +/** Rejects a group of fewer than two devices. */ +type TwoOrMore = [keyof M] extends [never] + ? "a group needs at least two devices" + : { [K in keyof M]: [Exclude] extends [never] ? K : never }[keyof M] extends infer Alone extends string + ? [Alone] extends [never] ? unknown : `a group needs at least two devices: pass ${Alone} to merge directly` + : unknown + /** The entry when it covers exactly the actions `A`, otherwise an error naming the device and the fix. */ type CheckEntry = E extends Lacking ? [Exclude, A>] extends [never] @@ -125,7 +188,7 @@ type EveryActionBound = [Exclude>] extends [n * The merged input. `actions` merges every device; `devices` holds each * device's own snapshot under the name it was merged under. */ -export interface Snapshot { +export interface Snapshot { readonly actions: { readonly [K in A]: ActionState } /** * Whether the last used device binds each action: `false` for the actions @@ -136,17 +199,18 @@ export interface Snapshot { readonly available: { readonly [K in A]: boolean } readonly devices: Devices /** - * The device that reported input most recently, or `null` before any did. - * When several report input in one window it stays on the current one if - * it is among them, otherwise it takes the first in merge order. + * The device (or group) that reported input most recently, or `null` + * before any did. When several report input in one window it stays on the + * current one if it is among them, otherwise it takes the first in merge + * order. */ - readonly lastUsed: (keyof Devices & string) | null + readonly lastUsed: Used | null } /** The merged source, for `InputCapture.system(...)`. */ export interface Controls extends System.Consumes<"snapshot"> { /** Takes every device's snapshot, starting a new press window on each, and merges them. */ - snapshot(): Snapshot> + snapshot(): Snapshot, keyof R & string> /** Each merged device's `rebind`, for a settings screen; devices without one (touch) are not listed. */ readonly rebind: RebindOf /** Disposes every merged device. */ @@ -165,7 +229,7 @@ export interface ActionSet { * exactly the declared actions it does not bind: autocomplete offers them, * and a missing or extra name does not compile. */ - lacking>>>( + lacking>>>( device: D & ( [Exclude, A>] extends [never] ? [Exclude>] extends [never] ? "This device binds every action: pass it to merge without lacking" : unknown @@ -174,24 +238,46 @@ export interface ActionSet { lacking: Missing & ([Exclude | Missing[number]>] extends [never] ? unknown : `This device also lacks ${Exclude | Missing[number]>}: bind it, or list it here`) ): Lacking /** - * Merges `devices` into one source. Each binds every declared action, or - * is passed through {@link lacking}; every action is bound by at least one - * device. The names given here key `devices`, `lastUsed`, and `rebind`. + * Groups devices the player uses together (keyboard and mouse), for + * {@link merge}: the group binds what its devices bind between them, and + * `lastUsed` and `available` follow it as a whole, so walking with the + * keyboard while aiming with the mouse is one device in use. A group needs + * two or more plain devices, each binding only declared actions; pass the + * group to {@link lacking} for the actions none of them binds. + */ + group>>>( + members: { readonly [K in keyof M]: CheckMember } & TwoOrMore + ): Group + /** + * Merges `devices` into one source. Each entry (a device or a + * {@link group}) binds every declared action, or is passed through + * {@link lacking}; every action is bound by at least one device. Entry + * names key `lastUsed`; device names (a group's members under their own) + * key `devices` and `rebind`. */ merge>>( - devices: { readonly [K in keyof R]: CheckEntry } & EveryActionBound + devices: { readonly [K in keyof R]: CheckEntry } & EveryActionBound & UniqueNames ): Controls /** * A merged snapshot with nothing held and no device used yet, for * initializing the input resource: pass each device's idle snapshot - * (`Keyboard.idle(...)`, ...) under its merge name. + * (`Keyboard.idle(...)`, ...) under its device name. */ - idle>>>(devices: Devices): Snapshot + idle>>>(devices: Devices): Snapshot } const idleState: ActionState = { held: false, pressed: false, released: false } -const isLacking = (entry: Entry): entry is Lacking => (entry as Partial>)._tag === "Lacking" +const isLacking = (entry: Entry): entry is Lacking => (entry as Partial>)._tag === "Lacking" + +const isGroup = (unit: Unit): unit is Group>> => + (unit as Partial>)._tag === "Group" + +/** A merge entry's devices by name: a group's members, or the device under the entry's name. */ +const membersOf = (name: string, unit: Unit): ReadonlyArray<{ readonly name: string; readonly device: Device }> => + isGroup(unit) + ? Object.entries(unit.members).map(([member, device]) => ({ name: member, device })) + : [{ name, device: unit }] /** * Declares the game's actions. Build the merged source with the returned @@ -203,22 +289,24 @@ export const actions = ]>(nam return { names: list, lacking: (device, lacking) => ({ _tag: "Lacking", device, lacking }), + group: (members) => ({ _tag: "Group", members }) as never, merge(devices) { - const entries = Object.entries(devices as Readonly>).map(([name, entry]) => - isLacking(entry) - ? { name, device: entry.device, lacking: new Set(entry.lacking) } - : { name, device: entry, lacking: new Set() }) + const entries = Object.entries(devices as Readonly>).map(([name, entry]) => { + const unit = isLacking(entry) ? entry.device : entry + return { name, members: membersOf(name, unit), lacking: new Set(isLacking(entry) ? entry.lacking : []) } + }) + const all = entries.flatMap((entry) => entry.members) let lastUsed: string | null = null const rebind: Record void> = {} - for (const { name, device } of entries) { + for (const { name, device } of all) { const candidate = (device as Device & { readonly rebind?: unknown }).rebind if (typeof candidate === "function") rebind[name] = (bindings) => candidate.call(device, bindings) } const controls = { snapshot() { const snapshots: Record> = {} - for (const { name, device } of entries) snapshots[name] = device.snapshot() - const used = entries.filter(({ name }) => snapshots[name]!.used) + for (const { name, device } of all) snapshots[name] = device.snapshot() + const used = entries.filter(({ members }) => members.some(({ name }) => snapshots[name]!.used)) if (used.length > 0 && !used.some(({ name }) => name === lastUsed)) lastUsed = used[0]!.name const current = entries.find(({ name }) => name === lastUsed) const merged: Record = {} @@ -227,7 +315,7 @@ export const actions = ]>(nam let held = false let pressed = false let released = false - for (const { name } of entries) { + for (const { name } of all) { const state = (snapshots[name]!.actions as Readonly>)[action] if (state === undefined) continue held ||= state.held @@ -241,7 +329,7 @@ export const actions = ]>(nam }, rebind, dispose() { - for (const { device } of entries) device.dispose() + for (const { device } of all) device.dispose() }, [Debug.serviceLints]: () => entries.flatMap(({ name, lacking }) => diff --git a/packages/browser/src/Gamepad.ts b/packages/browser/src/Gamepad.ts index 7f549e0d..d26b4502 100644 --- a/packages/browser/src/Gamepad.ts +++ b/packages/browser/src/Gamepad.ts @@ -55,6 +55,8 @@ import * as Result from "@typeonce/ritmo/Result" import type * as BrowserTickSource from "./BrowserTickSource.ts" import type { ActionState } from "./Keyboard.ts" +import * as ButtonsInternal from "./internal/buttons.ts" +import { buttonTracker, idleActions, idleButtons } from "./internal/buttons.ts" import { anyUsed } from "./internal/used.ts" /** @@ -185,23 +187,15 @@ export interface ActionsOptions { readonly triggerThreshold?: number } -const idleState: ActionState = { held: false, pressed: false, released: false } const zero: Vector = { x: 0, y: 0 } -const idleButtons = (): Snapshot["buttons"] => { - const buttons = {} as Record - for (const name of buttonNames) buttons[name] = idleState - return buttons -} /** * A snapshot with nothing held and no pad, for initializing an input * resource before the first capture. */ export const idle = (bindings: B): Snapshot => { - const actions = {} as Record - for (const name of Object.keys(bindings) as Array) actions[name] = idleState - return { actions, buttons: idleButtons() as Snapshot["buttons"], left: zero, right: zero, leftTrigger: 0, rightTrigger: 0, used: false, connected: false, id: null } + return { actions: idleActions(bindings), buttons: idleButtons(buttonNames), left: zero, right: zero, leftTrigger: 0, rightTrigger: 0, used: false, connected: false, id: null } } const sameVector = (a: Vector, b: Vector): boolean => a.x === b.x && a.y === b.y @@ -214,87 +208,19 @@ const stick = (x: number, y: number, deadZone: number): Vector => { return { x: (x / length) * scaled, y: (y / length) * scaled } } -/** - * The held buttons and the edges since the previous snapshot, shared by live - * and scripted actions so both build snapshots the same way. - */ +/** The button tracker with the pad's analog state, shared by live and scripted actions. */ const tracker = (initial: B) => { - // Fixed at creation: a rebind swaps buttons, never the actions. - const names = Object.keys(initial) as Array - let bindings: Rebinding = initial - const actionsByButton = new Map>() - const index = (): void => { - actionsByButton.clear() - for (const name of names) { - for (const button of bindings[name]!) { - const bound = actionsByButton.get(button) - if (bound) bound.push(name) - else actionsByButton.set(button, [name]) - } - } - } - index() - const held = new Set