Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .changeset/controls-merge.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<device>` 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.
Expand Down
13 changes: 13 additions & 0 deletions .changeset/vector-actions.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions integration/top-down/debug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
])

Expand Down
19 changes: 1 addition & 18 deletions integration/top-down/math.ts
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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 },
Expand Down
2 changes: 1 addition & 1 deletion integration/top-down/simulation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() })
Expand Down
5 changes: 3 additions & 2 deletions integration/top-down/systems/movement.ts
Original file line number Diff line number Diff line change
@@ -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"

Expand All @@ -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,
Expand Down
16 changes: 12 additions & 4 deletions integration/top-down/test/simulation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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"] }]
)
Expand All @@ -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()
Expand Down
18 changes: 6 additions & 12 deletions integration/top-down/types.ts
Original file line number Diff line number Diff line change
@@ -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<typeof inputBindings>
/** 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<typeof padBindings>
Expand Down
2 changes: 1 addition & 1 deletion packages/browser/README.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
40 changes: 34 additions & 6 deletions packages/browser/dtslint/Controls.tst.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -21,7 +21,7 @@ describe("Controls", () => {
const input = Actions.merge({ keyboard, gamepad }).snapshot()
expect(input.actions.jump).type.toBe<Keyboard.ActionState>()
expect(input.available.pause).type.toBe<boolean>()
expect(Actions.names).type.toBe<ReadonlyArray<"jump" | "pause">>()
expect(Actions.buttons).type.toBe<ReadonlyArray<"jump" | "pause">>()
// @ts-expect-error Property 'junp' does not exist
input.actions.junp
})
Expand Down Expand Up @@ -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"]) })
Expand Down Expand Up @@ -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<Controls.Vector>()
expect(input.available.move).type.toBe<boolean>()
expect(input.devices.keyboard.vectors.move).type.toBe<Keyboard.Vector>()
// @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<boolean>()
})

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: [] })
})
})
10 changes: 10 additions & 0 deletions packages/browser/dtslint/Gamepad.tst.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Gamepad.Vector>()
expect(Gamepad.vector({ up: ["dpadUp"], down: ["dpadDown"], left: ["dpadLeft"], right: ["dpadRight"] })).type.toBe<Gamepad.VectorBinding>()
// @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"] })
})
})
13 changes: 13 additions & 0 deletions packages/browser/dtslint/Keyboard.tst.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Keyboard.Vector>()
expect(input.actions.jump).type.toBe<Keyboard.ActionState>()
// @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"] })
})
})
13 changes: 10 additions & 3 deletions packages/browser/dtslint/Touch.tst.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Touch.Scripted<"attack" | "dash">>()
expect(Touch.scripted({ buttons: ["attack", "dash"] }, [{ frame: 0, press: ["attack"], aim: { attack: { x: 1, y: 0 } } }])).type.toBe<Touch.Scripted<"attack" | "dash">>()
// @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.Snapshot<"attack">>()
Touch.scripted({ buttons: ["attack", "dash"] }, [{ frame: 0, press: ["jump"] }])
expect(Touch.idle({ buttons: ["attack"] })).type.toBe<Touch.Snapshot<"attack">>()
})

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<Touch.Vector>()
// @ts-expect-error Property 'move' does not exist
Touch.scripted({ buttons: ["attack"] }, []).snapshot().vectors.move
})
})
Loading
Loading