diff --git a/.changeset/controls-merge.md b/.changeset/controls-merge.md index 261986dc..78e77a5f 100644 --- a/.changeset/controls-merge.md +++ b/.changeset/controls-merge.md @@ -5,7 +5,7 @@ **`Controls` merges several input devices into one source, every device snapshot shares one shape, and capturing input is checked at compile time and by `describe()`.** -- **`Controls.actions([...])`** declares the game's actions once. Its `merge({ keyboard, gamepad, touch })` builds one input source: 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 (the same rules as one device with all the bindings). Each device binds every declared action; a device that cannot is passed as `lacking(device, ["pause"])`, which must name exactly the actions it lacks (autocomplete offers them). A device binding an undeclared action, a device missing an action, and an action no device binds do not compile. +- **`Controls.actions({ buttons: [...] })`** declares the game's actions once. Its `merge({ keyboard, gamepad, touch })` builds one input source: 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 (the same rules as one device with all the bindings). Each device binds every declared action; a device that cannot is passed as `lacking(device, ["pause"])`, which must name exactly the actions it lacks (autocomplete offers them). A device binding an undeclared action, a device missing an action, and an action no device binds do not compile. - The merged snapshot has `actions`, `available` (whether the last used device binds each action, so a game can offer a lacking action another way), `devices` (each device's own snapshot, keyed by the merge names: sticks, triggers, touch aim), and `lastUsed` (`null` until a device is used). The merged source exposes `rebind.` for devices that can rebind, and `dispose()`. `idle({ keyboard: Keyboard.idle(...), ... })` initializes the input resource. Reading an action on a device that lacks it does not compile. - **Breaking: `Keyboard.Snapshot` is `{ actions, used }`.** Read `input.actions.jump.pressed` instead of `input.jump.pressed`. Hand-written timelines and bindings are unchanged. - **Breaking: `Touch.Snapshot` renames `buttons` to `actions`** (each still a `ButtonState` with `aim` and `cancelled`) **and replaces `touched` with `used`.** `Layout.buttons` and `View.buttons` are unchanged. diff --git a/.changeset/vector-actions.md b/.changeset/vector-actions.md new file mode 100644 index 00000000..6a12e410 --- /dev/null +++ b/.changeset/vector-actions.md @@ -0,0 +1,13 @@ +--- +"@typeonce/ritmo-browser": minor +--- + +**Movement is one vector action: declared once, bound per device, merged by `Controls`, and checked like every other action.** + +- **Breaking: `Controls.actions({ buttons: [...], vectors: [...] })`** replaces `Controls.actions([...])`. A button action is pressed and held (jump); a vector action is a direction with length at most 1, `y` growing down (move). A name declared as both kinds does not compile. `ActionSet.names` is now `buttons` and `vectors`. +- **`Keyboard.vector({ up, down, left, right })`** binds a vector to keys: opposite keys cancel and a diagonal has length 1. **`Gamepad.vector({ stick, up, down, left, right })`** binds it to a stick, the d-pad, or both (the longer wins). **A `Touch` layout's `stick.vector`** names the vector the stick drives. Every device snapshot has `vectors` (empty for `Pointer`, which reports `position` and `movement`). +- `Controls` merges a vector as the longest any device reports, so devices never add up past 1 or cancel each other. Vectors work with `lacking`, `available`, `idle`, and `lastUsed` (a vector in use counts as use). Binding a vector action as a button (or the reverse), missing a vector, and reading `actions.move` or `vectors.jump` do not compile, each with an error naming the fix. +- `rebind` and `parseRebinding` take vector bindings (`Gamepad` can swap sticks). +- **Breaking: keyboard timelines name a vector by direction**, `press: ["move.right"]`; `parseTimeline` validates it. Gamepad and touch timelines are unchanged (buttons, sticks). +- **Breaking: `Touch.idle({ buttons, vector? })` and `Touch.scripted({ buttons, vector? }, timeline)`** take the names as an object. +- Directions that step one tile at a time (a grid game reading taps) stay button actions. diff --git a/integration/top-down/debug.ts b/integration/top-down/debug.ts index 5d0362f7..58fe9823 100644 --- a/integration/top-down/debug.ts +++ b/integration/top-down/debug.ts @@ -23,8 +23,8 @@ 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. const keyboard = Keyboard.scripted(inputBindings, [ - { frame: 0, press: ["up", "left"] }, - { frame: 40, release: ["up"] }, + { frame: 0, press: ["move.up", "move.left"] }, + { frame: 40, release: ["move.up"] }, { frame: 50, press: ["interact"], release: ["interact"] } ]) diff --git a/integration/top-down/math.ts b/integration/top-down/math.ts index 983a0f0d..f5246bbd 100644 --- a/integration/top-down/math.ts +++ b/integration/top-down/math.ts @@ -1,9 +1,8 @@ import * as Aabb from "@typeonce/ritmo-math/Aabb" -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 { Vector2 } from "./types.ts" export const clamp = (value: number, min: number, max: number): number => { const nextValue = Scalar.Finite.option(value) @@ -18,22 +17,6 @@ export const clamp = (value: number, min: number, max: number): number => { export const lengthSquared = (vector: Vector2): number => Vector2Module.lengthSquared(vector) -/** - * The walk direction: a tilted left stick wins and keeps its analog length - * (a half tilt walks at half speed); otherwise the merged direction actions - * (keys or d-pad) give a unit direction. - */ -export const normalizeMovement = (input: InputStateValue): Vector2 => { - const stick = Vector2Module.option(input.devices.gamepad.left) - if (stick !== null && Vector2Module.lengthSquared(stick) > 0) return stick - return InputAxis.vectorFromAxes({ - up: input.actions.up.held, - down: input.actions.down.held, - left: input.actions.left.held, - right: input.actions.right.held - }) -} - export const intersects = ( firstPosition: Vector2, firstCollider: { width: number; height: number }, diff --git a/integration/top-down/simulation.ts b/integration/top-down/simulation.ts index e3f19662..a8a358bd 100644 --- a/integration/top-down/simulation.ts +++ b/integration/top-down/simulation.ts @@ -6,7 +6,7 @@ * Tests, benchmarks, and coding agents drive it directly: * * ```ts - * const keyboard = Keyboard.scripted(inputBindings, [{ frame: 0, press: ["right"] }]) + * const keyboard = Keyboard.scripted(inputBindings, [{ frame: 0, press: ["move.right"] }]) * const mouse = Pointer.scripted(mouseBindings, []) * const gamepad = Gamepad.scripted(padBindings, []) * const simulation = createTopDownSimulation({ keyboard, mouse, gamepad, hostClock: HostClock.test() }) diff --git a/integration/top-down/systems/movement.ts b/integration/top-down/systems/movement.ts index f5ae466e..3ed003ba 100644 --- a/integration/top-down/systems/movement.ts +++ b/integration/top-down/systems/movement.ts @@ -1,7 +1,7 @@ import * as Vector2 from "@typeonce/ritmo-math/Vector2" import { PLAYER_SPEED } from "../constants.ts" -import { normalizeMovement, resolveHorizontalMovement, resolveVerticalMovement } from "../math.ts" +import { resolveHorizontalMovement, resolveVerticalMovement } from "../math.ts" import { PlayerMovementQuery, WallCollisionQuery } from "../queries.ts" import { DeltaTime, Game, InputState } from "../schema.ts" @@ -22,7 +22,8 @@ export const PlanPlayerVelocitySystem = Game.System( return } - const direction = normalizeMovement(resources.input.get()) + // Length at most 1: keys and the d-pad give a unit direction, the stick keeps its tilt. + const direction = resources.input.get().vectors.move 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 426b6e49..4776e36c 100644 --- a/integration/top-down/test/simulation.test.ts +++ b/integration/top-down/test/simulation.test.ts @@ -32,7 +32,7 @@ const Collected = Game.Inspector("Test/Collected", { resources: { count: Game.Sy 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"] }]) + const { runtime, update } = start([{ frame: 0, press: ["move.right"] }, { frame: 30, release: ["move.right"] }]) 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) @@ -43,6 +43,14 @@ describe("top-down simulation", () => { }) }) + it("walks diagonally at the same speed as straight", () => { + const { runtime, update } = start([{ frame: 0, press: ["move.right", "move.down"] }, { frame: 30, release: ["move.right", "move.down"] }]) + for (let frame = 0; frame < 60; frame++) expect(runtime.tick(update).ok).toBe(true) + const [position] = runtime.inspect(PlayerPosition) + // Each axis moves 1/√2 of the straight distance (down ends at a wall, so x tells it). + expect((position?.x ?? 0) - 640).toBeCloseTo(280 * 30 / 60 * Math.SQRT1_2) + }) + 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) @@ -70,7 +78,7 @@ describe("top-down simulation", () => { 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: 0, press: ["move.left"] }, { frame: 85, release: ["move.left"] }], [], [{ frame: 40, move: { x: 100, y: 100 } }, { frame: 88, press: ["primary"], release: ["primary"] }] ) @@ -94,8 +102,8 @@ describe("top-down simulation", () => { describe("top-down debug session", () => { it("walks to the first pickup and collects it", () => { const keyboard = Keyboard.scripted(inputBindings, [ - { frame: 0, press: ["left"] }, - { frame: 85, release: ["left"] }, + { frame: 0, press: ["move.left"] }, + { frame: 85, release: ["move.left"] }, { frame: 88, press: ["interact"], release: ["interact"] } ]) const hostClock = HostClock.test() diff --git a/integration/top-down/types.ts b/integration/top-down/types.ts index ef9fef04..85a33f7e 100644 --- a/integration/top-down/types.ts +++ b/integration/top-down/types.ts @@ -1,24 +1,18 @@ -import { Controls } from "@typeonce/ritmo-browser" -import type { Gamepad, Keyboard, Pointer } from "@typeonce/ritmo-browser" +import { Controls, Gamepad, Keyboard } from "@typeonce/ritmo-browser" +import type { Pointer } from "@typeonce/ritmo-browser" import type * as Vector2Value from "@typeonce/ritmo-math/Vector2" export type Vector2 = Vector2Value.Vector2 /** The game's actions, bound on every device below. */ -export const Actions = Controls.actions(["up", "down", "left", "right", "interact"]) +export const Actions = Controls.actions({ buttons: ["interact"], vectors: ["move"] }) export const inputBindings = { - up: ["ArrowUp", "w"], - down: ["ArrowDown", "s"], - left: ["ArrowLeft", "a"], - right: ["ArrowRight", "d"], + move: Keyboard.vector({ up: ["ArrowUp", "w"], down: ["ArrowDown", "s"], left: ["ArrowLeft", "a"], right: ["ArrowRight", "d"] }), interact: ["e", " "] } as const export type KeyboardInput = Keyboard.Actions -/** The same actions on a controller: the d-pad walks (the left stick too, see `normalizeMovement`), south interacts. */ +/** The same actions on a controller: the left stick or the d-pad walks, south interacts. */ export const padBindings = { - up: ["dpadUp"], - down: ["dpadDown"], - left: ["dpadLeft"], - right: ["dpadRight"], + move: Gamepad.vector({ stick: "left", up: ["dpadUp"], down: ["dpadDown"], left: ["dpadLeft"], right: ["dpadRight"] }), interact: ["south"] } as const export type GamepadInput = Gamepad.Actions diff --git a/packages/browser/README.md b/packages/browser/README.md index 0e34490a..c332cc4a 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 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`). +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 as buttons and vectors (movement with length at most 1, from keys, d-pad, stick, or touch stick) 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 db39f70d..46aed32d 100644 --- a/packages/browser/dtslint/Controls.tst.ts +++ b/packages/browser/dtslint/Controls.tst.ts @@ -8,7 +8,7 @@ declare const canvas: Pointer.PointerTarget const keyBindings = { jump: [" "], pause: ["Escape"] } as const const padBindings = { jump: ["south"], pause: ["start"] } as const -const Actions = Controls.actions(["jump", "pause"]) +const Actions = Controls.actions({ buttons: ["jump", "pause"] }) const keyboard = Keyboard.actions(host, keyBindings) const gamepad = Gamepad.actions(host, padBindings) @@ -21,7 +21,7 @@ describe("Controls", () => { const input = Actions.merge({ keyboard, gamepad }).snapshot() expect(input.actions.jump).type.toBe() expect(input.available.pause).type.toBe() - expect(Actions.names).type.toBe>() + expect(Actions.buttons).type.toBe>() // @ts-expect-error Property 'junp' does not exist input.actions.junp }) @@ -118,10 +118,6 @@ describe("Controls", () => { }) }) - it("declares at least one action", () => { - // @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"]) }) @@ -182,4 +178,36 @@ describe("Controls", () => { resources: { Input: Actions.idle({ keyboard: Keyboard.idle(keyBindings), gamepad: Gamepad.idle(padBindings) }) } }) }) + + it("declares vector actions next to buttons, and checks each device binds each with its kind", () => { + const Moving = Controls.actions({ buttons: ["jump"], vectors: ["move"] }) + const keys = Keyboard.actions(host, { move: Keyboard.vector({ up: ["w"], down: ["s"], left: ["a"], right: ["d"] }), jump: [" "] }) + const pad = Gamepad.actions(host, { move: Gamepad.vector({ stick: "left" }), jump: ["south"] }) + const input = Moving.merge({ keyboard: keys, gamepad: pad }).snapshot() + expect(input.vectors.move).type.toBe() + expect(input.available.move).type.toBe() + expect(input.devices.keyboard.vectors.move).type.toBe() + // @ts-expect-error Property 'jump' does not exist + input.vectors.jump + // @ts-expect-error Property 'move' does not exist + input.actions.move + + const moveAsButton = Keyboard.actions(host, { move: ["w"], jump: [" "] }) + // @ts-expect-error keyboard binds move as a button, but it is a vector action: bind it with vector(...) + Moving.merge({ keyboard: moveAsButton, gamepad: pad }) + const jumpAsVector = Gamepad.actions(host, { move: Gamepad.vector({ stick: "left" }), jump: Gamepad.vector({ stick: "right" }) }) + // @ts-expect-error gamepad binds jump as a vector, but it is a button action: bind it to keys or buttons + Moving.merge({ keyboard: keys, gamepad: jumpAsVector }) + const noMove = Keyboard.actions(host, { jump: [" "] }) + // @ts-expect-error keyboard has no action move: bind it on keyboard, or pass lacking(keyboard, [...]) from the action set + Moving.merge({ keyboard: noMove, gamepad: pad }) + expect(Moving.merge({ keyboard: Moving.lacking(noMove, ["move"]), gamepad: pad }).snapshot().available.move).type.toBe() + }) + + it("rejects a name declared as both kinds", () => { + // @ts-expect-error move is declared as both a button and a vector action: give it one kind + Controls.actions({ buttons: ["jump", "move"], vectors: ["move"] }) + // @ts-expect-error Type '[]' is not assignable + Controls.actions({ buttons: [] }) + }) }) diff --git a/packages/browser/dtslint/Gamepad.tst.ts b/packages/browser/dtslint/Gamepad.tst.ts index 5eed96be..8a9e5874 100644 --- a/packages/browser/dtslint/Gamepad.tst.ts +++ b/packages/browser/dtslint/Gamepad.tst.ts @@ -75,4 +75,14 @@ describe("Gamepad.parseRebinding", () => { Gamepad.actions(host, { attack: ["west"], dash: ["south"] }).rebind(parsed.value) } }) + + it("binds a vector to a stick, the four directions, or both", () => { + const input = Gamepad.actions(host, { move: Gamepad.vector({ stick: "left" }), jump: ["south"] }).snapshot() + expect(input.vectors.move).type.toBe() + expect(Gamepad.vector({ up: ["dpadUp"], down: ["dpadDown"], left: ["dpadLeft"], right: ["dpadRight"] })).type.toBe() + // @ts-expect-error Type '"middle"' is not assignable + Gamepad.vector({ stick: "middle" }) + // @ts-expect-error Property 'left' is missing + Gamepad.vector({ up: ["dpadUp"], down: ["dpadDown"], right: ["dpadRight"] }) + }) }) diff --git a/packages/browser/dtslint/Keyboard.tst.ts b/packages/browser/dtslint/Keyboard.tst.ts index d61236c8..13f308d2 100644 --- a/packages/browser/dtslint/Keyboard.tst.ts +++ b/packages/browser/dtslint/Keyboard.tst.ts @@ -75,4 +75,17 @@ describe("Keyboard.parseRebinding", () => { Keyboard.actions(host, { jump: [" "], left: ["a"] }).rebind(parsed.value) } }) + + it("splits button and vector actions, and scripts vectors by direction", () => { + const bindings = { move: Keyboard.vector({ up: ["w"], down: ["s"], left: ["a"], right: ["d"] }), jump: [" "] } as const + const input = Keyboard.scripted(bindings, [{ frame: 0, press: ["move.right", "jump"] }]).snapshot() + expect(input.vectors.move).type.toBe() + expect(input.actions.jump).type.toBe() + // @ts-expect-error Property 'move' does not exist + input.actions.move + // @ts-expect-error Type '"move"' is not assignable + Keyboard.scripted(bindings, [{ frame: 0, press: ["move"] }]) + // @ts-expect-error Property 'right' is missing + Keyboard.vector({ up: ["w"], down: ["s"], left: ["a"] }) + }) }) diff --git a/packages/browser/dtslint/Touch.tst.ts b/packages/browser/dtslint/Touch.tst.ts index 96752a29..e7146652 100644 --- a/packages/browser/dtslint/Touch.tst.ts +++ b/packages/browser/dtslint/Touch.tst.ts @@ -22,9 +22,16 @@ describe("Touch", () => { }) it("types scripted timelines by the button names", () => { - expect(Touch.scripted(["attack", "dash"], [{ frame: 0, press: ["attack"], aim: { attack: { x: 1, y: 0 } } }])).type.toBe>() + expect(Touch.scripted({ buttons: ["attack", "dash"] }, [{ frame: 0, press: ["attack"], aim: { attack: { x: 1, y: 0 } } }])).type.toBe>() // @ts-expect-error Type '"jump"' is not assignable to type '"attack" | "dash"' - Touch.scripted(["attack", "dash"], [{ frame: 0, press: ["jump"] }]) - expect(Touch.idle(["attack"])).type.toBe>() + Touch.scripted({ buttons: ["attack", "dash"] }, [{ frame: 0, press: ["jump"] }]) + expect(Touch.idle({ buttons: ["attack"] })).type.toBe>() + }) + + it("reports the stick as the vector the layout names", () => { + const touch = Touch.scripted({ buttons: ["attack"], vector: "move" }, []) + expect(touch.snapshot().vectors.move).type.toBe() + // @ts-expect-error Property 'move' does not exist + Touch.scripted({ buttons: ["attack"] }, []).snapshot().vectors.move }) }) diff --git a/packages/browser/src/Controls.ts b/packages/browser/src/Controls.ts index ae073d84..bc7f4252 100644 --- a/packages/browser/src/Controls.ts +++ b/packages/browser/src/Controls.ts @@ -2,13 +2,15 @@ * One input source merged from several devices, so gameplay reads each * action once whatever the player uses. * - * Declare the game's actions once with `Controls.actions([...])`, bind them + * Declare the game's actions once with `Controls.actions({ buttons: [...] })`, bind them * on every device (`Keyboard.actions`, `Gamepad.actions`, `Pointer.actions`, * `Touch.actions`), 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 + * when a device released it and none holds it now. A vector action (movement) + * is the longest direction any device reports, so a stick and keys never add + * up past length 1 or cancel each other. Device-specific data * (sticks, triggers, touch aim) stays in `devices`, under the names given to * `merge`; `lastUsed` names the device the player used last, for button * prompts. @@ -34,10 +36,18 @@ * * @example * ```ts - * const Actions = Controls.actions(["left", "right", "jump", "pause"]) + * const Actions = Controls.actions({ buttons: ["jump", "pause"], vectors: ["move"] }) * - * const keyBindings = { left: ["ArrowLeft", "a"], right: ["ArrowRight", "d"], jump: [" "], pause: ["Escape"] } as const - * const padBindings = { left: ["dpadLeft"], right: ["dpadRight"], jump: ["south"], pause: ["start"] } as const + * const keyBindings = { + * move: Keyboard.vector({ up: ["ArrowUp", "w"], down: ["ArrowDown", "s"], left: ["ArrowLeft", "a"], right: ["ArrowRight", "d"] }), + * jump: [" "], + * pause: ["Escape"] + * } as const + * const padBindings = { + * move: Gamepad.vector({ stick: "left", up: ["dpadUp"], down: ["dpadDown"], left: ["dpadLeft"], right: ["dpadRight"] }), + * jump: ["south"], + * pause: ["start"] + * } as const * * // One function builds the controls, for the browser and for tests. * export const makeControls = (devices: { @@ -59,6 +69,7 @@ * * // In a system: * const input = resources.input.get() + * walk(input.vectors.move) // length at most 1: keys, d-pad, or stick * if (input.actions.jump.pressed) jump() * const prompt = input.lastUsed === "gamepad" ? "Ⓐ" : "Space" * ``` @@ -67,19 +78,25 @@ import type * as System from "@typeonce/ritmo/System" import * as Debug from "@typeonce/ritmo/Debug" import type { ActionState } from "./Keyboard.ts" +import { longer, zero } from "./internal/vectors.ts" +import type { Vector } from "./internal/vectors.ts" + +export type { Vector } /** - * The part of a device's snapshot `Controls` merges: every action's state, - * and whether the player used the device in this window. + * The part of a device's snapshot `Controls` merges: every button action's + * state, every vector action's direction, and whether the player used the + * device in this window. */ -export interface DeviceSnapshot { +export interface DeviceSnapshot { readonly actions: { readonly [K in A]: ActionState } + readonly vectors: { readonly [K in V]: Vector } readonly used: boolean } /** A device `Controls` can merge: `Keyboard.actions`, `Gamepad.actions`, `Pointer.actions`, `Touch.actions`, or their scripted forms. */ -export interface Device extends System.Consumes<"snapshot"> { - snapshot(): DeviceSnapshot +export interface Device extends System.Consumes<"snapshot"> { + snapshot(): DeviceSnapshot dispose(): void } @@ -106,11 +123,30 @@ type Unit = Device | Group>> type Entry = Unit | Lacking -/** The actions a device, or the devices of a group, bind. */ +/** The button 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 +/** The vector actions a device, or the devices of a group, bind. */ +type VectorsOf = D extends Group ? { [K in keyof M]: VectorsOf }[keyof M] + : D extends { snapshot(): { readonly vectors: infer Vectors } } ? keyof Vectors & string + : never + +/** Every action a device binds, of either kind. */ +type BoundOf = ActionsOf | VectorsOf + +/** + * Why a device's bindings do not fit the declared buttons `A` and vectors + * `V` (an action of the wrong kind, or one not declared), or `never`. + */ +type KindIssue = [ActionsOf & V] extends [never] + ? [VectorsOf & A] extends [never] + ? [Exclude, A | V>] extends [never] ? never + : `${Name} binds ${Exclude, A | V>}, which is not one of the actions` + : `${Name} binds ${VectorsOf & A} as a vector, but it is a button action: bind it to keys or buttons` + : `${Name} binds ${ActionsOf & V} as a button, but it is a vector action: bind it with vector(...)` + type DeviceOf = E extends Lacking ? D : E type SnapshotOf = D extends { snapshot(): infer S } ? S : never @@ -150,11 +186,11 @@ type UniqueNames = [Repeated] extends [never] : `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 +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` + : [KindIssue] extends [never] ? D + : KindIssue /** Rejects a group of fewer than two devices. */ type TwoOrMore = [keyof M] extends [never] @@ -163,21 +199,21 @@ type TwoOrMore = [keyof M] extends [never] ? [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] - ? [Exclude | Missing>] extends [never] +/** The entry when it covers exactly the declared actions, otherwise an error naming the device and the fix. */ +type CheckEntry = E extends Lacking + ? [KindIssue] extends [never] + ? [Exclude | Missing>] extends [never] ? E - : `${Name} has no action ${Exclude | Missing>}: bind it on ${Name}, or list it in its lacking(...)` - : `${Name} binds ${Exclude, A>}, which is not one of the actions` - : [Exclude, A>] extends [never] - ? [Exclude>] extends [never] + : `${Name} has no action ${Exclude | Missing>}: bind it on ${Name}, or list it in its lacking(...)` + : KindIssue + : [KindIssue] extends [never] + ? [Exclude>] extends [never] ? E - : `${Name} has no action ${Exclude>}: bind it on ${Name}, or pass lacking(${Name}, [...]) from the action set` - : `${Name} binds ${Exclude, A>}, which is not one of the actions` + : `${Name} has no action ${Exclude>}: bind it on ${Name}, or pass lacking(${Name}, [...]) from the action set` + : KindIssue /** The actions some merged device binds. */ -type BoundBy = { [K in keyof R]: ActionsOf> }[keyof R] +type BoundBy = { [K in keyof R]: BoundOf> }[keyof R] /** Rejects a merge where no device binds an action. */ type EveryActionBound = [Exclude>] extends [never] @@ -188,15 +224,21 @@ 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 } + /** + * Each vector action (movement) as a direction with length at most 1, `y` + * growing down: the longest any device reports, so devices never add up + * past 1 or cancel each other. + */ + readonly vectors: { readonly [K in V]: Vector } /** * Whether the last used device binds each action: `false` for the actions * it was merged as lacking, so the game can offer them another way (an * on-screen pause button). Every action is available before any device is * used. */ - readonly available: { readonly [K in A]: boolean } + readonly available: { readonly [K in A | V]: boolean } readonly devices: Devices /** * The device (or group) that reported input most recently, or `null` @@ -208,9 +250,9 @@ export interface Snapshot extends System.Consumes<"snapshot"> { +export interface Controls extends System.Consumes<"snapshot"> { /** Takes every device's snapshot, starting a new press window on each, and merges them. */ - snapshot(): Snapshot, keyof R & string> + 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. */ @@ -221,21 +263,23 @@ export interface Controls extends System.Consumes<"snapshot * The game's actions, declared once. Every merge, gap, and idle snapshot is * built from it, so action names are checked against one list. */ -export interface ActionSet { - /** The declared actions, in order, for listing them on a settings screen. */ - readonly names: ReadonlyArray +export interface ActionSet { + /** The declared button actions, in order, for listing them on a settings screen. */ + readonly buttons: ReadonlyArray + /** The declared vector actions, in order. */ + readonly vectors: ReadonlyArray /** * Marks the actions `device` cannot report, for {@link merge}. List * 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 - : `This device binds ${Exclude, A>}, which is not one of the actions` + [KindIssue] extends [never] + ? [Exclude>] extends [never] ? "This device binds every action: pass it to merge without lacking" : unknown + : KindIssue ), - lacking: Missing & ([Exclude | Missing[number]>] extends [never] ? unknown : `This device also lacks ${Exclude | Missing[number]>}: bind it, or list it here`) + lacking: Missing & ([Exclude | Missing[number]>] extends [never] ? unknown : `This device also lacks ${Exclude | Missing[number]>}: bind it, or list it here`) ): Lacking /** * Groups devices the player uses together (keyboard and mouse), for @@ -246,7 +290,7 @@ export interface ActionSet { * group to {@link lacking} for the actions none of them binds. */ group>>>( - members: { readonly [K in keyof M]: CheckMember } & TwoOrMore + members: { readonly [K in keyof M]: CheckMember } & TwoOrMore ): Group /** * Merges `devices` into one source. Each entry (a device or a @@ -256,14 +300,14 @@ export interface ActionSet { * key `devices` and `rebind`. */ merge>>( - devices: { readonly [K in keyof R]: CheckEntry } & EveryActionBound & UniqueNames - ): Controls + 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 device name. */ - idle>>>(devices: Devices): Snapshot + idle>>>(devices: Devices): Snapshot } const idleState: ActionState = { held: false, pressed: false, released: false } @@ -279,15 +323,32 @@ const membersOf = (name: string, unit: Unit): ReadonlyArray<{ readonly name: str ? Object.entries(unit.members).map(([member, device]) => ({ name: member, device })) : [{ name, device: unit }] +/** Rejects a name declared both as a button and as a vector. */ +type DistinctKinds = [A & V] extends [never] + ? unknown + : `${A & V} is declared as both a button and a vector action: give it one kind` + /** - * Declares the game's actions. Build the merged source with the returned - * set's `merge`. + * Declares the game's actions: `buttons` (pressed and held: jump, pause) + * and `vectors` (a direction with length at most 1: move), each bound on + * every device. Build the merged source with the returned set's `merge`. + * + * @example + * ```ts + * const Actions = Controls.actions({ buttons: ["jump", "pause"], vectors: ["move"] }) + * ``` */ -export const actions = ]>(names: A): ActionSet => { - const list: ReadonlyArray = [...new Set(names)] - const everyAvailable = (): Record => Object.fromEntries(list.map((name) => [name, true])) +export const actions = ], const Vectors extends ReadonlyArray = readonly []>( + names: { readonly buttons: Buttons; readonly vectors?: Vectors } & DistinctKinds +): ActionSet => { + type A = Buttons[number] + type V = Vectors[number] + const list: ReadonlyArray = [...new Set(names.buttons)] + const vectorList: ReadonlyArray = [...new Set(names.vectors ?? [])] + const everyAvailable = (): Record => Object.fromEntries([...list, ...vectorList].map((name) => [name, true])) return { - names: list, + buttons: list, + vectors: vectorList, lacking: (device, lacking) => ({ _tag: "Lacking", device, lacking }), group: (members) => ({ _tag: "Group", members }) as never, merge(devices) { @@ -304,7 +365,7 @@ export const actions = ]>(nam } const controls = { snapshot() { - const snapshots: Record> = {} + const snapshots: Record> = {} 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 @@ -325,7 +386,17 @@ export const actions = ]>(nam merged[action] = held || pressed || released ? { held, pressed, released: released && !held } : idleState available[action] = current === undefined || !current.lacking.has(action) } - return { actions: merged, available, devices: snapshots, lastUsed } + const vectors: Record = {} + for (const action of vectorList) { + let longest = zero + for (const { name } of all) { + const vector = (snapshots[name]!.vectors as Readonly>)[action] + if (vector !== undefined) longest = longer(longest, vector) + } + vectors[action] = longest + available[action] = current === undefined || !current.lacking.has(action) + } + return { actions: merged, vectors, available, devices: snapshots, lastUsed } }, rebind, dispose() { @@ -343,8 +414,9 @@ export const actions = ]>(nam return controls as never }, idle: (devices) => ({ - actions: Object.fromEntries(list.map((name) => [name, idleState])) as Snapshot["actions"], - available: everyAvailable() as Snapshot["available"], + actions: Object.fromEntries(list.map((name) => [name, idleState])) as Snapshot["actions"], + vectors: Object.fromEntries(vectorList.map((name) => [name, zero])) as Snapshot["vectors"], + available: everyAvailable() as Snapshot["available"], devices, lastUsed: null }) diff --git a/packages/browser/src/Gamepad.ts b/packages/browser/src/Gamepad.ts index d26b4502..35a6d97f 100644 --- a/packages/browser/src/Gamepad.ts +++ b/packages/browser/src/Gamepad.ts @@ -31,18 +31,18 @@ * @example * ```ts * const pad = Gamepad.actions(window, { + * move: Gamepad.vector({ stick: "left", up: ["dpadUp"], down: ["dpadDown"], left: ["dpadLeft"], right: ["dpadRight"] }), * attack: ["west"], - * dash: ["south"], * pause: ["start"] * }) * * // Once per update: * const snapshot = pad.snapshot() - * move(snapshot.left) + * move(snapshot.vectors.move) // the stick's tilt, or the d-pad's direction * if (snapshot.actions.attack.pressed) attack() * * // In a settings screen, rebinding keeps the action names: - * pad.rebind({ attack: ["north"], dash: ["south"], pause: ["start"] }) + * pad.rebind({ move: Gamepad.vector({ stick: "right" }), attack: ["north"], pause: ["start"] }) * ``` * * For tests, replays, and headless runs, `scripted(...)` plays a timeline of @@ -56,7 +56,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 { buttonTracker, flattenButtons, idleActions, idleButtons, idleVectors, splitActions } from "./internal/buttons.ts" +import { digital, isVectorBinding, longer } from "./internal/vectors.ts" import { anyUsed } from "./internal/used.ts" /** @@ -87,17 +88,63 @@ export const buttonNames = [ /** One button of the standard mapping, see {@link buttonNames}. */ export type Button = (typeof buttonNames)[number] +/** The buttons of one action: at least one. */ +export type Buttons = readonly [Button, ...Array