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
13 changes: 13 additions & 0 deletions .changeset/gamepad-actions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@typeonce/ritmo-browser": minor
---

**`Gamepad` reads controllers as named actions, and keyboard and gamepad bindings can change at runtime.**

- **`Gamepad.actions(window, bindings, options)`** binds actions to buttons named by the browser's standard mapping (`"south"`, `"west"`, `"rightTrigger"`, `"dpadUp"`, `"start"`...). Each snapshot has `actions` (as `Keyboard`'s), every `buttons` state by name whatever the bindings (for menu navigation and for a settings screen waiting for the button to bind), the `left` and `right` sticks with a radial dead zone (`y` grows down, as `Touch`'s stick), how far each trigger is pulled, and the followed pad's `id`. It follows the pad used last and releases everything held when that pad disconnects. It reads the pads on every animation frame (and on every snapshot) until `dispose()`, so taps shorter than an update count with nothing to call; the lifecycle is `snapshot` / `rebind` / `dispose`, as for `Keyboard`. `Gamepad.idle`, `scripted`, `recording`, `parseTimeline`, and `isButton` match the other devices; timelines name buttons, so a replay follows the bindings, and an entry's `connected` records a disconnect or reconnect, so a replay drops and regains the pad on the same frames.
- **`rebind(bindings)`** on `Keyboard.actions(...)` and `Gamepad.actions(...)` binds the same actions to new keys or buttons, for players who remap their controls. An action held only through a key it lost reports `released`; a key held across the rebind holds a newly bound action only after a fresh press, so an action never turns held without a press. It takes exactly the actions the source was made with: an extra action fails to compile with `<name> is not an action of these bindings`, even from an object not written inline. Scripted keyboard actions ignore it (their timeline names actions).
- **`Keyboard.presses(window)`** reports every key that went down since the previous snapshot, bound or not, as `{ key, code }`, so a settings screen can wait for the key to bind (the gamepad equivalent is the snapshot's `buttons`).
- **`Keyboard.parseRebinding(bindings, data)`** and **`Gamepad.parseRebinding(bindings, data)`** validate saved bindings (for example from `localStorage`) as a `Result` ready for `rebind`, with the offending `path` on failure.
- **Timelines replay a release and press inside one update.** An action (keyboard) or button (gamepad) in both `press` and `release` of one entry is now a release then a new press when it was held, and still a tap when it was up, so `recording(...)` captures a quick double tap and the replay matches.
- **Breaking: `InputCapture.system` requires the resource's value type to be exactly the source's snapshot type.** A resource that only matched both ways (for example one with an extra optional field) now fails with a message naming the fix: declare it as `Descriptor.TransientResource<InputCapture.SnapshotOf<typeof Source>>()`.
- **Breaking for hand-written sources:** `Keyboard.Actions` now requires `rebind`. An object implementing it by hand adds `rebind() {}`.
8 changes: 5 additions & 3 deletions integration/top-down/debug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,14 @@
* the timeline, the invariants, and the questions.
*/
import { HostClock } from "@typeonce/ritmo"
import { Keyboard } from "@typeonce/ritmo-browser"
import { Gamepad, Keyboard } from "@typeonce/ritmo-browser"
import { Invariant, Session } from "@typeonce/ritmo-devtools"

import { WORLD_HEIGHT, WORLD_WIDTH } from "./constants.ts"
import { PlayerPositionQuery } from "./queries.ts"
import { Game, Player, Position } from "./schema.ts"
import { createTopDownSimulation } from "./simulation.ts"
import { inputBindings } from "./types.ts"
import { inputBindings, padBindings } from "./types.ts"

// Frame 0 is the first update. Press and release are applied before that
// frame's input capture; a press and release in one frame is a tap.
Expand All @@ -30,7 +30,9 @@ const keyboard = Keyboard.scripted(inputBindings, [

// A test clock keeps every timing at 0, so the output is the same on every run.
const hostClock = HostClock.test()
const simulation = createTopDownSimulation({ keyboard, hostClock })
// No controller in this run; script one the same way, by button names.
const gamepad = Gamepad.scripted(padBindings, [])
const simulation = createTopDownSimulation({ keyboard, gamepad, hostClock })
if (!simulation.ok) throw new Error(simulation.error.message)
const { runtime, setup, update } = simulation.value

Expand Down
23 changes: 16 additions & 7 deletions integration/top-down/math.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import * as InputAxis from "@typeonce/ritmo-math/InputAxis"
import * as Scalar from "@typeonce/ritmo-math/Scalar"
import * as Vector2Module from "@typeonce/ritmo-math/Vector2"
import { WORLD_HEIGHT, WORLD_WIDTH } from "./constants.ts"
import type { InputStateValue, Vector2 } from "./types.ts"
import type { Gamepad } from "@typeonce/ritmo-browser"
import type { InputStateValue, padBindings, Vector2 } from "./types.ts"

export const clamp = (value: number, min: number, max: number): number => {
const nextValue = Scalar.Finite.option(value)
Expand All @@ -18,13 +19,21 @@ export const clamp = (value: number, min: number, max: number): number => {

export const lengthSquared = (vector: Vector2): number => Vector2Module.lengthSquared(vector)

export const normalizeMovement = (input: InputStateValue): Vector2 =>
InputAxis.vectorFromAxes({
up: input.up.held,
down: input.down.held,
left: input.left.held,
right: input.right.held
/**
* The walk direction from both devices: a tilted left stick wins and keeps
* its analog length (a half tilt walks at half speed); otherwise the keys
* and the d-pad give a unit direction.
*/
export const normalizeMovement = (keys: InputStateValue, pad: Gamepad.Snapshot<typeof padBindings>): Vector2 => {
const stick = Vector2Module.option(pad.left)
if (stick !== null && Vector2Module.lengthSquared(stick) > 0) return stick
return InputAxis.vectorFromAxes({
up: keys.up.held || pad.actions.up.held,
down: keys.down.held || pad.actions.down.held,
left: keys.left.held || pad.actions.left.held,
right: keys.right.held || pad.actions.right.held
})
}

export const intersects = (
firstPosition: Vector2,
Expand Down
5 changes: 3 additions & 2 deletions integration/top-down/runtime.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { Keyboard } from "@typeonce/ritmo-browser"
import { Gamepad, Keyboard } from "@typeonce/ritmo-browser"

import { pickupLayout } from "./content.ts"
import { Facing, Game, Locomotion } from "./schema.ts"
import { inputBindings } from "./types.ts"
import { inputBindings, padBindings } from "./types.ts"
import type { FrameContextValue } from "./types.ts"

export const makeEmptyFocusedCollectable = () => ({
Expand All @@ -14,6 +14,7 @@ export const makeEmptyFocusedCollectable = () => ({
export const initialResources = (frame: FrameContextValue) => ({
DeltaTime: frame.deltaSeconds,
InputState: Keyboard.idle(inputBindings),
PadState: Gamepad.idle(padBindings),
FocusedCollectable: makeEmptyFocusedCollectable(),
CollectedCount: 0,
TotalCollectables: pickupLayout.length
Expand Down
4 changes: 3 additions & 1 deletion integration/top-down/schedules.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
CaptureFrameContextSystem,
CaptureInputSystem,
CapturePadSystem,
CollectFocusedCollectableSystem,
MovePlayerSystem,
PlanPlayerVelocitySystem,
Expand All @@ -17,10 +18,11 @@ export const setupWorldSchedule = Game.Schedule(
Game.Schedule.applyDeferred()
)

/** One fixed update. It needs only the keyboard and the frame context, so it runs headless (see `simulation.ts`). */
/** One fixed update. It needs only the keyboard, the gamepad, and the frame context, so it runs headless (see `simulation.ts`). */
export const simulationSchedule = Game.Schedule(
CaptureFrameContextSystem,
CaptureInputSystem,
CapturePadSystem,
PlanPlayerVelocitySystem,
MovePlayerSystem,
UpdateFocusedCollectableSystem,
Expand Down
6 changes: 6 additions & 0 deletions integration/top-down/schema.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { Descriptor, Entity, Schema } from "@typeonce/ritmo"
import type { InputCapture } from "@typeonce/ritmo-browser"
import * as Size2 from "@typeonce/ritmo-math/Size2"
import * as Vector2 from "@typeonce/ritmo-math/Vector2"

import type {
FrameContextValue,
GamepadInput as GamepadInputValue,
InputStateValue,
KeyboardInput as KeyboardInputValue
} from "./types.ts"
Expand Down Expand Up @@ -34,6 +36,9 @@ export const CollectedCount = Descriptor.Resource<number>()("TopDown/CollectedCo
export const TotalCollectables = Descriptor.Resource<number>()("TopDown/TotalCollectables")

export const KeyboardInput = Descriptor.Service<KeyboardInputValue>()("TopDown/KeyboardInput")
export const GamepadInput = Descriptor.Service<GamepadInputValue>()("TopDown/GamepadInput")
// Captured from the gamepad at the start of each update; its type is exactly what the source produces.
export const PadState = Descriptor.TransientResource<InputCapture.SnapshotOf<typeof GamepadInput>>()("TopDown/PadState")
export const FrameContext = Descriptor.Service<FrameContextValue>()("TopDown/FrameContext")

export const Game = Schema.bind(
Expand All @@ -56,6 +61,7 @@ export const Game = Schema.bind(
resources: {
DeltaTime,
InputState,
PadState,
FocusedCollectable,
CollectedCount,
TotalCollectables
Expand Down
14 changes: 9 additions & 5 deletions integration/top-down/simulation.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
/**
* The top-down game without a browser: the simulation schedules on a
* runtime that needs only a keyboard and a frame context, with the debug
* handle attached.
* runtime that needs only a keyboard, a gamepad, and a frame context, with
* the debug handle attached.
*
* Tests, benchmarks, and coding agents drive it directly:
*
* ```ts
* const keyboard = Keyboard.scripted(inputBindings, [{ frame: 0, press: ["right"] }])
* const simulation = createTopDownSimulation({ keyboard, hostClock: HostClock.test() })
* const gamepad = Gamepad.scripted(padBindings, [])
* const simulation = createTopDownSimulation({ keyboard, gamepad, hostClock: HostClock.test() })
* if (simulation.ok) {
* simulation.value.runtime.tick(simulation.value.setup)
* simulation.value.runtime.tick(simulation.value.update)
Expand All @@ -19,12 +20,14 @@ import * as Result from "@typeonce/ritmo/Result"

import { FIXED_STEP_SECONDS } from "./constants.ts"
import { initialMachines, initialResources } from "./runtime.ts"
import { FrameContext, Game, KeyboardInput } from "./schema.ts"
import { FrameContext, Game, GamepadInput, KeyboardInput } from "./schema.ts"
import { setupWorldSchedule, simulationSchedule } from "./schedules.ts"
import type { FrameContextValue, KeyboardInput as KeyboardInputValue } from "./types.ts"
import type { FrameContextValue, GamepadInput as GamepadInputValue, KeyboardInput as KeyboardInputValue } from "./types.ts"

export interface TopDownSimulationOptions {
readonly keyboard: KeyboardInputValue
/** `Gamepad.actions(window, padBindings)` in a browser, `Gamepad.scripted(padBindings, timeline)` headless. */
readonly gamepad: GamepadInputValue
/** Times the debug traces; `HostClock.test()` keeps every timing at 0. Pass the same clock to `Session.make`. */
readonly hostClock: HostClock.HostClock
/** Defaults to one fixed step at 60 Hz. */
Expand All @@ -40,6 +43,7 @@ export const createTopDownSimulation = (options: TopDownSimulationOptions) => {
const made = Game.Runtime.make({
services: Game.Runtime.services(
Game.Runtime.service(KeyboardInput, options.keyboard),
Game.Runtime.service(GamepadInput, options.gamepad),
Game.Runtime.service(FrameContext, frame)
),
resources: initialResources(frame),
Expand Down
2 changes: 1 addition & 1 deletion integration/top-down/systems/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export { SetupWorldSystem } from "./setup.ts"
export { CaptureFrameContextSystem, CaptureInputSystem } from "./input.ts"
export { CaptureFrameContextSystem, CaptureInputSystem, CapturePadSystem } from "./input.ts"
export { PlanPlayerVelocitySystem, MovePlayerSystem } from "./movement.ts"
export { UpdateFocusedCollectableSystem, CollectFocusedCollectableSystem } from "./interaction.ts"
export { ResolveFacingSystem, ResolveLocomotionSystem } from "./facing.ts"
10 changes: 9 additions & 1 deletion integration/top-down/systems/input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ import {
DeltaTime,
FrameContext,
Game,
GamepadInput,
InputState,
KeyboardInput
KeyboardInput,
PadState
} from "../schema.ts"

export const CaptureInputSystem = InputCapture.system(Game, {
Expand All @@ -13,6 +15,12 @@ export const CaptureInputSystem = InputCapture.system(Game, {
resource: InputState
})

export const CapturePadSystem = InputCapture.system(Game, {
name: "TopDown/CapturePad",
source: GamepadInput,
resource: PadState
})

export const CaptureFrameContextSystem = Game.System(
"TopDown/CaptureFrameContext",
{
Expand Down
6 changes: 4 additions & 2 deletions integration/top-down/systems/interaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import {
Collectable,
FocusedCollectable,
Game,
InputState
InputState,
PadState
} from "../schema.ts"
import { makeEmptyFocusedCollectable } from "../runtime.ts"

Expand Down Expand Up @@ -64,14 +65,15 @@ export const CollectFocusedCollectableSystem = Game.System(
},
resources: {
input: Game.System.readResource(InputState),
pad: Game.System.readResource(PadState),
focused: Game.System.writeResource(FocusedCollectable),
collectedCount: Game.System.writeResource(CollectedCount)
},
commands: Game.System.commands(Game.System.despawns(Collectable))
},
({ queries, resources, commands }) =>
{
if (!resources.input.get().interact.pressed) {
if (!resources.input.get().interact.pressed && !resources.pad.get().actions.interact.pressed) {
return
}

Expand Down
7 changes: 4 additions & 3 deletions integration/top-down/systems/movement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import * as Vector2 from "@typeonce/ritmo-math/Vector2"
import { PLAYER_SPEED } from "../constants.ts"
import { normalizeMovement, resolveHorizontalMovement, resolveVerticalMovement } from "../math.ts"
import { PlayerMovementQuery, WallCollisionQuery } from "../queries.ts"
import { DeltaTime, Game, InputState } from "../schema.ts"
import { DeltaTime, Game, InputState, PadState } from "../schema.ts"

export const PlanPlayerVelocitySystem = Game.System(
"TopDown/PlanPlayerVelocity",
Expand All @@ -12,7 +12,8 @@ export const PlanPlayerVelocitySystem = Game.System(
player: PlayerMovementQuery
},
resources: {
input: Game.System.readResource(InputState)
input: Game.System.readResource(InputState),
pad: Game.System.readResource(PadState)
}
},
({ queries, resources }) =>
Expand All @@ -22,7 +23,7 @@ export const PlanPlayerVelocitySystem = Game.System(
return
}

const direction = normalizeMovement(resources.input.get())
const direction = normalizeMovement(resources.input.get(), resources.pad.get())
player.value.data.velocity.updateRaw(() =>
({
x: direction.x * PLAYER_SPEED,
Expand Down
42 changes: 37 additions & 5 deletions integration/top-down/test/simulation.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import { HostClock } from "@typeonce/ritmo"
import { Keyboard } from "@typeonce/ritmo-browser"
import { Gamepad, Keyboard } from "@typeonce/ritmo-browser"
import { Session } from "@typeonce/ritmo-devtools"
import { describe, expect, it } from "vitest"

import { createTopDownSimulation } from "../simulation.ts"
import { PlayerPositionQuery } from "../queries.ts"
import { CollectedCount, Game } from "../schema.ts"
import { inputBindings } from "../types.ts"
import { inputBindings, padBindings } from "../types.ts"

const start = (timeline: Keyboard.Timeline<typeof inputBindings>) => {
const simulation = createTopDownSimulation({ keyboard: Keyboard.scripted(inputBindings, timeline), hostClock: HostClock.test() })
const start = (timeline: Keyboard.Timeline<typeof inputBindings>, pad: Gamepad.Timeline = []) => {
const simulation = createTopDownSimulation({
keyboard: Keyboard.scripted(inputBindings, timeline),
gamepad: Gamepad.scripted(padBindings, pad),
hostClock: HostClock.test()
})
if (!simulation.ok) throw new Error(simulation.error.message)
const { runtime, setup, update } = simulation.value
expect(runtime.tick(setup).ok).toBe(true)
Expand All @@ -19,6 +23,9 @@ const start = (timeline: Keyboard.Timeline<typeof inputBindings>) => {
const PlayerPosition = Game.Inspector("Test/PlayerPosition", { queries: { player: PlayerPositionQuery } }, ({ queries }) =>
queries.player.each().map((match) => match.data.position.get()))

const Collected = Game.Inspector("Test/Collected", { resources: { count: Game.System.readResource(CollectedCount) } }, ({ resources }) =>
resources.count.get())

describe("top-down simulation", () => {
it("runs headless and moves the player with scripted input", () => {
const { runtime, update } = start([{ frame: 0, press: ["right"] }, { frame: 30, release: ["right"] }])
Expand All @@ -32,6 +39,31 @@ describe("top-down simulation", () => {
})
})

it("moves the player with the left stick, at its analog length", () => {
const { runtime, update } = start([], [{ frame: 0, left: { x: 0.5, y: 0 } }, { frame: 30, left: { x: 0, y: 0 } }])
for (let frame = 0; frame < 60; frame++) expect(runtime.tick(update).ok).toBe(true)
const [position] = runtime.inspect(PlayerPosition)
expect(position?.x).toBeCloseTo(640 + 0.5 * 280 * 30 / 60)
expect(position?.y).toBe(260)
})

it("moves the player with the d-pad like the arrow keys", () => {
const { runtime, update } = start([], [{ frame: 0, press: ["dpadRight"] }, { frame: 30, release: ["dpadRight"] }])
for (let frame = 0; frame < 60; frame++) expect(runtime.tick(update).ok).toBe(true)
const [position] = runtime.inspect(PlayerPosition)
expect(position?.x).toBeCloseTo(640 + 280 * 30 / 60)
})

it("collects a pickup with the south button", () => {
const { runtime, update } = start([], [
{ frame: 0, press: ["dpadLeft"] },
{ frame: 85, release: ["dpadLeft"] },
{ frame: 88, press: ["south"], release: ["south"] }
])
for (let frame = 0; frame < 120; frame++) expect(runtime.tick(update).ok).toBe(true)
expect(runtime.inspect(Collected)).toBe(1)
})

it("describes the simulation schedules without lint warnings", () => {
const { runtime } = start([])
const description = runtime.debug.describe()
Expand All @@ -48,7 +80,7 @@ describe("top-down debug session", () => {
{ frame: 88, press: ["interact"], release: ["interact"] }
])
const hostClock = HostClock.test()
const simulation = createTopDownSimulation({ keyboard, hostClock })
const simulation = createTopDownSimulation({ keyboard, gamepad: Gamepad.scripted(padBindings, []), hostClock })
if (!simulation.ok) throw new Error(simulation.error.message)
const { runtime, setup, update } = simulation.value
const session = Session.make(runtime, { hostClock, schedules: { setup, update } })
Expand Down
11 changes: 10 additions & 1 deletion integration/top-down/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Keyboard } from "@typeonce/ritmo-browser"
import type { Gamepad, Keyboard } from "@typeonce/ritmo-browser"
import type * as Vector2Value from "@typeonce/ritmo-math/Vector2"

export type Vector2 = Vector2Value.Vector2
Expand All @@ -11,6 +11,15 @@ export const inputBindings = {
} as const
export type InputStateValue = Keyboard.Snapshot<typeof inputBindings>
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. */
export const padBindings = {
up: ["dpadUp"],
down: ["dpadDown"],
left: ["dpadLeft"],
right: ["dpadRight"],
interact: ["south"]
} as const
export type GamepadInput = Gamepad.Actions<typeof padBindings>
/** What one fixed update needs from the host: the step length. */
export type FrameContextValue = {
readonly deltaSeconds: number
Expand Down
Loading
Loading