diff --git a/example/pong-network/apps/client/src/main.ts b/example/pong-network/apps/client/src/main.ts index 2b4ef183..7a804c44 100644 --- a/example/pong-network/apps/client/src/main.ts +++ b/example/pong-network/apps/client/src/main.ts @@ -3,6 +3,7 @@ import { NanoforgeFactory } from "@nanoforge-dev/core"; import { EcsLibrary } from "@nanoforge-dev/ecs/client"; import { Circle, Graphics2DLibrary, Layer, Rect } from "@nanoforge-dev/graphics-2d"; import { InputEnum, InputLibrary } from "@nanoforge-dev/input"; +import { ControllerLibrary } from "@nanoforge-dev/input/controller"; import { Channel, NetworkClientLibrary } from "@nanoforge-dev/network/client"; import { AssignId, Play } from "../../../shared/protocol"; @@ -27,14 +28,23 @@ export const main = async (options: ClientRunOptions): Promise => { const ecs = new EcsLibrary(); const network = new NetworkClientLibrary(); const input = new InputLibrary(); + const controller = new ControllerLibrary(); app.use(graphics); app.use(ecs); app.use(network); app.use(input); + app.use(controller); await app.init(options); + // TEMP controller test: log connections and rumble on connect. + controller.onConnect((c) => { + console.log("Manette connectée :", c.id, "mapping:", c.mapping); + void controller.vibrate({ duration: 300 }); + }); + controller.onDisconnect((c) => console.log("Manette déconnectée :", c.id)); + const registry = ecs.registry; graphics.stage.add(layer); diff --git a/example/pong-network/apps/client/src/systems/systems.ts b/example/pong-network/apps/client/src/systems/systems.ts index 9ae87e9b..575ff1a4 100644 --- a/example/pong-network/apps/client/src/systems/systems.ts +++ b/example/pong-network/apps/client/src/systems/systems.ts @@ -1,5 +1,6 @@ import { type Context } from "@nanoforge-dev/common"; import { type Registry } from "@nanoforge-dev/ecs/client"; +import { ControllerAxis, ControllerButton } from "@nanoforge-dev/input/controller"; import { Input, Move } from "../../../../shared/protocol"; import { @@ -24,10 +25,19 @@ export const controlPlayer = (registry: Registry, ctx: Context) => { const entities = registry.getZipper([Controller]); const input = ctx.input; const network = ctx.network; + const pad = ctx.controller; entities.forEach(({ Controller }) => { - const upPressed = input.isKeyPressed(Controller.up); - const downPressed = input.isKeyPressed(Controller.down); + // TEMP controller test: D-pad or left stick drive the paddle too. + const stickY = pad.getAxis(ControllerAxis.LeftStickY); + const upPressed = + !!input.isKeyPressed(Controller.up) || + pad.isButtonPressed(ControllerButton.DPadUp) || + stickY < -0.5; + const downPressed = + !!input.isKeyPressed(Controller.down) || + pad.isButtonPressed(ControllerButton.DPadDown) || + stickY > 0.5; if (upPressed == downPressed) { if (Controller.lastPressedUp || Controller.lastPressedDown) { network.packets.send(Input, { key: "stop" }); diff --git a/modules/ecs/build/libecs.d.ts b/modules/ecs/build/libecs.d.ts index 058db154..9fb10755 100644 --- a/modules/ecs/build/libecs.d.ts +++ b/modules/ecs/build/libecs.d.ts @@ -16,7 +16,7 @@ export interface ClassHandle { [Symbol.dispose](): void; clone(): this; } -export interface container extends ClassHandle { +export interface container extends ClassHandle, Iterable { size(): number; get(_0: number): any | undefined | undefined; push_back(_0?: any): void; diff --git a/modules/input/package.json b/modules/input/package.json index b93c811e..dd356517 100644 --- a/modules/input/package.json +++ b/modules/input/package.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/package.json", "name": "@nanoforge-dev/input", "version": "2.0.0", - "description": "NanoForge Engine - Input", + "description": "NanoForge Engine - Input (keyboard, mouse and controller)", "keywords": [ "nanoforge", "game", @@ -35,6 +35,26 @@ "default": "./dist/index.js" } }, + "./keyboard-mouse": { + "require": { + "types": "./dist/keyboard-mouse/index.d.cts", + "default": "./dist/keyboard-mouse/index.cjs" + }, + "import": { + "types": "./dist/keyboard-mouse/index.d.ts", + "default": "./dist/keyboard-mouse/index.js" + } + }, + "./controller": { + "require": { + "types": "./dist/controller/index.d.cts", + "default": "./dist/controller/index.cjs" + }, + "import": { + "types": "./dist/controller/index.d.ts", + "default": "./dist/controller/index.js" + } + }, "./package.json": "./package.json" }, "type": "module", diff --git a/modules/input/src/controller/context-augmentation.ts b/modules/input/src/controller/context-augmentation.ts new file mode 100644 index 00000000..85e7003f --- /dev/null +++ b/modules/input/src/controller/context-augmentation.ts @@ -0,0 +1,7 @@ +import type { ControllerContextApi } from "./controller-context.type"; + +declare module "@nanoforge-dev/common" { + interface Context { + controller: ControllerContextApi; + } +} diff --git a/modules/input/src/controller/controller-context.type.ts b/modules/input/src/controller/controller-context.type.ts new file mode 100644 index 00000000..51db8308 --- /dev/null +++ b/modules/input/src/controller/controller-context.type.ts @@ -0,0 +1,23 @@ +import type { ControllerAxis, ControllerButton, ControllerStick } from "./controller.enum"; +import type { + ControllerListener, + ControllerState, + ControllerVibration, + StickPosition, +} from "./controller.types"; + +/** Public surface of `ControllerLibrary`, exposed on `Context.controller`. */ +export interface ControllerContextApi { + getControllers(): ControllerState[]; + getController(index?: number): ControllerState | undefined; + isConnected(index?: number): boolean; + isButtonPressed(button: ControllerButton, index?: number): boolean; + isButtonJustPressed(button: ControllerButton, index?: number): boolean; + isButtonJustReleased(button: ControllerButton, index?: number): boolean; + getButtonValue(button: ControllerButton, index?: number): number; + getAxis(axis: ControllerAxis, index?: number): number; + getStick(stick: ControllerStick, index?: number): StickPosition; + vibrate(effect: ControllerVibration, index?: number): Promise; + onConnect(listener: ControllerListener): () => void; + onDisconnect(listener: ControllerListener): () => void; +} diff --git a/modules/input/src/controller/controller-handler.ts b/modules/input/src/controller/controller-handler.ts new file mode 100644 index 00000000..c69f47af --- /dev/null +++ b/modules/input/src/controller/controller-handler.ts @@ -0,0 +1,148 @@ +import { nextButtonState } from "../shared"; +import { ControllerAxis, type ControllerStick } from "./controller.enum"; +import type { + ControllerButtonState, + ControllerListener, + ControllerState, + StickPosition, +} from "./controller.types"; + +const STICK_AXES: Record = { + left: [ControllerAxis.LeftStickX, ControllerAxis.LeftStickY], + right: [ControllerAxis.RightStickX, ControllerAxis.RightStickY], +}; + +/** + * Tracks connected controllers through the Gamepad API. + * + * @remarks + * The Gamepad API does not emit events for buttons or axes: state is read by + * `poll`, once per frame. Connection events are only used to report + * connections/disconnections as early as possible; `poll` also detects them, + * since some browsers only expose a controller after its first input. + */ +export class ControllerHandler { + private readonly deadzone: number; + private readonly controllers = new Map(); + private readonly connectListeners = new Set(); + private readonly disconnectListeners = new Set(); + + constructor(deadzone: number) { + this.deadzone = deadzone; + + window.addEventListener("gamepadconnected", this.onConnected); + window.addEventListener("gamepaddisconnected", this.onDisconnected); + } + + /** Removes every listener registered by the constructor. */ + public destroy(): void { + window.removeEventListener("gamepadconnected", this.onConnected); + window.removeEventListener("gamepaddisconnected", this.onDisconnected); + this.controllers.clear(); + this.connectListeners.clear(); + this.disconnectListeners.clear(); + } + + /** Reads every gamepad and updates button transitions for this frame. */ + public poll(): void { + const seen = new Set(); + for (const gamepad of navigator.getGamepads()) { + if (!gamepad?.connected) continue; + seen.add(gamepad.index); + this.update(gamepad); + } + for (const index of [...this.controllers.keys()]) { + if (!seen.has(index)) this.disconnect(index); + } + } + + public getControllers(): ControllerState[] { + return [...this.controllers.values()]; + } + + public getController(index: number): ControllerState | undefined { + return this.controllers.get(index); + } + + public getButton(button: number, index: number): ControllerButtonState | undefined { + return this.controllers.get(index)?.buttons[button]; + } + + /** Axis value with the deadzone applied and rescaled to keep the full range. */ + public getAxis(axis: number, index: number): number { + const value = this.controllers.get(index)?.axes[axis] ?? 0; + const magnitude = Math.abs(value); + if (magnitude < this.deadzone) return 0; + return (Math.sign(value) * (magnitude - this.deadzone)) / (1 - this.deadzone); + } + + /** Stick position with a radial deadzone, so diagonals are not clipped. */ + public getStick(stick: ControllerStick, index: number): StickPosition { + const controller = this.controllers.get(index); + const [axisX, axisY] = STICK_AXES[stick]; + const x = controller?.axes[axisX] ?? 0; + const y = controller?.axes[axisY] ?? 0; + const magnitude = Math.hypot(x, y); + if (magnitude < this.deadzone) return { x: 0, y: 0 }; + const scale = Math.min(1, (magnitude - this.deadzone) / (1 - this.deadzone)) / magnitude; + return { x: x * scale, y: y * scale }; + } + + public async vibrate( + index: number, + duration: number, + strongMagnitude: number, + weakMagnitude: number, + ): Promise { + const gamepad = navigator.getGamepads()[index]; + const actuator = gamepad?.vibrationActuator as GamepadHapticActuator | null | undefined; + if (!actuator) return false; + const result = await actuator.playEffect("dual-rumble", { + duration, + strongMagnitude, + weakMagnitude, + }); + return result === "complete"; + } + + public onConnect(listener: ControllerListener): () => void { + this.connectListeners.add(listener); + return () => this.connectListeners.delete(listener); + } + + public onDisconnect(listener: ControllerListener): () => void { + this.disconnectListeners.add(listener); + return () => this.disconnectListeners.delete(listener); + } + + private readonly onConnected = (e: GamepadEvent): void => { + this.update(e.gamepad); + }; + + private readonly onDisconnected = (e: GamepadEvent): void => { + this.disconnect(e.gamepad.index); + }; + + private update(gamepad: Gamepad): void { + const previous = this.controllers.get(gamepad.index); + const state: ControllerState = { + index: gamepad.index, + id: gamepad.id, + mapping: gamepad.mapping, + buttons: gamepad.buttons.map((button, i) => ({ + ...nextButtonState(button.pressed, previous?.buttons[i]?.pressed), + value: button.value, + })), + axes: [...gamepad.axes], + }; + this.controllers.set(gamepad.index, state); + if (!previous) this.connectListeners.forEach((listener) => listener(state)); + } + + private disconnect(index: number): void { + const state = this.controllers.get(index); + if (!state) return; + this.controllers.delete(index); + this.disconnectListeners.forEach((listener) => listener(state)); + } +} diff --git a/modules/input/src/controller/controller.enum.ts b/modules/input/src/controller/controller.enum.ts new file mode 100644 index 00000000..2ae331f7 --- /dev/null +++ b/modules/input/src/controller/controller.enum.ts @@ -0,0 +1,52 @@ +/** + * Controller buttons, following the W3C "standard" gamepad layout. + * + * @remarks + * Values are the indices of `Gamepad.buttons` when `Gamepad.mapping` is + * `"standard"`. Face buttons are named after their Xbox labels: `A` is the + * bottom face button (Cross on PlayStation), `B` the right one, `X` the left + * one and `Y` the top one. Use with `ControllerLibrary.isButtonPressed`. + * + * @example + * ```ts + * if (ctx.controller.isButtonJustPressed(ControllerButton.A)) { + * player.jump(); + * } + * ``` + */ +export enum ControllerButton { + A = 0, + B = 1, + X = 2, + Y = 3, + LeftBumper = 4, + RightBumper = 5, + LeftTrigger = 6, + RightTrigger = 7, + Select = 8, + Start = 9, + LeftStick = 10, + RightStick = 11, + DPadUp = 12, + DPadDown = 13, + DPadLeft = 14, + DPadRight = 15, + Home = 16, +} + +/** + * Controller axes, following the W3C "standard" gamepad layout. + * + * @remarks + * Values are the indices of `Gamepad.axes` when `Gamepad.mapping` is + * `"standard"`. Axis values range from `-1` to `1`; negative is left/up. + */ +export enum ControllerAxis { + LeftStickX = 0, + LeftStickY = 1, + RightStickX = 2, + RightStickY = 3, +} + +/** Analog sticks available on a "standard" controller. */ +export type ControllerStick = "left" | "right"; diff --git a/modules/input/src/controller/controller.library.ts b/modules/input/src/controller/controller.library.ts new file mode 100644 index 00000000..13564ac6 --- /dev/null +++ b/modules/input/src/controller/controller.library.ts @@ -0,0 +1,161 @@ +import { Library, defineLibraryKey } from "@nanoforge-dev/common"; + +import { clientOnlyError } from "../shared"; +import type { ControllerContextApi } from "./controller-context.type"; +import { ControllerHandler } from "./controller-handler"; +import type { ControllerAxis, ControllerButton, ControllerStick } from "./controller.enum"; +import type { + ControllerLibraryOptions, + ControllerListener, + ControllerState, + ControllerVibration, + StickPosition, +} from "./controller.types"; + +const DEFAULT_DEADZONE = 0.15; + +/** + * Built-in controller library, based on the Gamepad API. + * + * @remarks + * Polls `navigator.getGamepads()` once per frame and exposes a snapshot of + * every connected controller on `Context.controller`. Buttons and axes follow + * the W3C "standard" layout (`ControllerButton`, `ControllerAxis`); every + * method takes an optional controller index, `0` by default. Client-only — + * `__init` throws if the Gamepad API is unavailable. Browsers only expose a + * controller after the user pressed one of its buttons. + * + * @example + * ```ts + * app.use(new ControllerLibrary({ deadzone: 0.2 })); + * + * // in a system + * const { x, y } = ctx.controller.getStick("left"); + * if (ctx.controller.isButtonJustPressed(ControllerButton.A)) player.jump(); + * ``` + */ +export class ControllerLibrary extends Library { + readonly key = defineLibraryKey("controller"); + + private readonly _deadzone: number; + private _handler?: ControllerHandler; + + constructor(options: ControllerLibraryOptions = {}) { + super(); + const deadzone = options.deadzone ?? DEFAULT_DEADZONE; + if (deadzone < 0 || deadzone >= 1) { + throw new RangeError("ControllerLibrary deadzone must be in [0, 1)."); + } + this._deadzone = deadzone; + } + + public override async __init(): Promise { + if (typeof navigator === "undefined" || typeof navigator.getGamepads !== "function") { + throw clientOnlyError("ControllerLibrary", "the Gamepad API is unavailable"); + } + this._handler = new ControllerHandler(this._deadzone); + } + + public override async __run(): Promise { + if (!this._handler) this.throwNotInitializedError(); + this._handler.poll(); + } + + public override async __clear(): Promise { + this._handler?.destroy(); + } + + public getControllers(): ControllerState[] { + if (!this._handler) this.throwNotInitializedError(); + return this._handler.getControllers(); + } + + public getController(index = 0): ControllerState | undefined { + if (!this._handler) this.throwNotInitializedError(); + return this._handler.getController(index); + } + + public isConnected(index = 0): boolean { + return this.getController(index) !== undefined; + } + + public isButtonPressed(button: ControllerButton, index = 0): boolean { + if (!this._handler) this.throwNotInitializedError(); + return this._handler.getButton(button, index)?.pressed ?? false; + } + + public isButtonJustPressed(button: ControllerButton, index = 0): boolean { + if (!this._handler) this.throwNotInitializedError(); + return this._handler.getButton(button, index)?.justPressed ?? false; + } + + public isButtonJustReleased(button: ControllerButton, index = 0): boolean { + if (!this._handler) this.throwNotInitializedError(); + return this._handler.getButton(button, index)?.justReleased ?? false; + } + + /** @returns The analog value of the button, between `0` and `1`. */ + public getButtonValue(button: ControllerButton, index = 0): number { + if (!this._handler) this.throwNotInitializedError(); + return this._handler.getButton(button, index)?.value ?? 0; + } + + /** @returns The axis value between `-1` and `1`, with the deadzone applied. */ + public getAxis(axis: ControllerAxis, index = 0): number { + if (!this._handler) this.throwNotInitializedError(); + return this._handler.getAxis(axis, index); + } + + /** @returns The stick position, with a radial deadzone applied. */ + public getStick(stick: ControllerStick, index = 0): StickPosition { + if (!this._handler) this.throwNotInitializedError(); + return this._handler.getStick(stick, index); + } + + /** + * Plays a rumble effect. + * + * @returns `true` if the effect played to completion, `false` if the + * controller has no vibration support or the effect was interrupted. + */ + public vibrate(effect: ControllerVibration, index = 0): Promise { + if (!this._handler) this.throwNotInitializedError(); + return this._handler.vibrate( + index, + effect.duration, + effect.strongMagnitude ?? 1, + effect.weakMagnitude ?? 1, + ); + } + + /** @returns A function that removes the listener. */ + public onConnect(listener: ControllerListener): () => void { + if (!this._handler) this.throwNotInitializedError(); + return this._handler.onConnect(listener); + } + + /** @returns A function that removes the listener. */ + public onDisconnect(listener: ControllerListener): () => void { + if (!this._handler) this.throwNotInitializedError(); + return this._handler.onDisconnect(listener); + } + + public override expose(): ControllerContextApi { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const library = this; + return { + getControllers: () => library.getControllers(), + getController: (index) => library.getController(index), + isConnected: (index) => library.isConnected(index), + isButtonPressed: (button, index) => library.isButtonPressed(button, index), + isButtonJustPressed: (button, index) => library.isButtonJustPressed(button, index), + isButtonJustReleased: (button, index) => library.isButtonJustReleased(button, index), + getButtonValue: (button, index) => library.getButtonValue(button, index), + getAxis: (axis, index) => library.getAxis(axis, index), + getStick: (stick, index) => library.getStick(stick, index), + vibrate: (effect, index) => library.vibrate(effect, index), + onConnect: (listener) => library.onConnect(listener), + onDisconnect: (listener) => library.onDisconnect(listener), + }; + } +} diff --git a/modules/input/src/controller/controller.types.ts b/modules/input/src/controller/controller.types.ts new file mode 100644 index 00000000..a142b239 --- /dev/null +++ b/modules/input/src/controller/controller.types.ts @@ -0,0 +1,55 @@ +import type { ButtonState } from "../shared"; + +/** State of a single controller button for the current frame. */ +export type ControllerButtonState = ButtonState & { + /** Analog value between `0` and `1` (e.g. how far a trigger is pulled). */ + value: number; +}; + +/** + * Snapshot of a connected controller for the current frame. + * + * @remarks + * Returned by `ControllerLibrary.getControllers` and `ControllerLibrary.getController`. + * `axes` are the raw values reported by the browser, without deadzone. + */ +export type ControllerState = { + /** Slot of the controller, as given by `Gamepad.index`. */ + index: number; + /** Browser-provided identifier of the device. */ + id: string; + /** `"standard"` when `ControllerButton`/`ControllerAxis` apply, `""` otherwise. */ + mapping: GamepadMappingType; + /** Button states, indexed like `Gamepad.buttons`. */ + buttons: ControllerButtonState[]; + /** Raw axis values between `-1` and `1`, indexed like `Gamepad.axes`. */ + axes: number[]; +}; + +/** Position of an analog stick, each component between `-1` and `1`. */ +export type StickPosition = { + x: number; + y: number; +}; + +/** Rumble effect played by `ControllerLibrary.vibrate`. */ +export type ControllerVibration = { + /** Duration of the effect, in milliseconds. */ + duration: number; + /** Intensity of the low-frequency motor, between `0` and `1`. Defaults to `1`. */ + strongMagnitude?: number; + /** Intensity of the high-frequency motor, between `0` and `1`. Defaults to `1`. */ + weakMagnitude?: number; +}; + +/** Called when a controller is connected or disconnected. */ +export type ControllerListener = (controller: ControllerState) => void; + +/** Options accepted by the `ControllerLibrary` constructor. */ +export type ControllerLibraryOptions = { + /** + * Axis values whose magnitude is below this threshold are reported as `0` + * by `getAxis` and `getStick`. Must be in `[0, 1)`. Defaults to `0.15`. + */ + deadzone?: number; +}; diff --git a/modules/input/src/controller/index.ts b/modules/input/src/controller/index.ts new file mode 100644 index 00000000..c9d4b0db --- /dev/null +++ b/modules/input/src/controller/index.ts @@ -0,0 +1,14 @@ +import "./context-augmentation"; + +export { ControllerAxis, ControllerButton, type ControllerStick } from "./controller.enum"; +export type { ButtonState } from "../shared"; +export type { ControllerContextApi } from "./controller-context.type"; +export { ControllerLibrary } from "./controller.library"; +export type { + ControllerButtonState, + ControllerLibraryOptions, + ControllerListener, + ControllerState, + ControllerVibration, + StickPosition, +} from "./controller.types"; diff --git a/modules/input/src/index.ts b/modules/input/src/index.ts index c3ea2db1..d7667b9a 100644 --- a/modules/input/src/index.ts +++ b/modules/input/src/index.ts @@ -1,6 +1,3 @@ -import "./context-augmentation"; - -export { InputEnum } from "./input.enum"; -export type { InputContextApi } from "./input-context.type"; -export { InputLibrary } from "./input.library"; -export type { DragState, MouseState, WheelState } from "./mouse.types"; +// Root entry kept for backward compatibility: prefer `@nanoforge-dev/input/keyboard-mouse`. +export * from "./keyboard-mouse"; +export type { ButtonState } from "./shared"; diff --git a/modules/input/src/context-augmentation.ts b/modules/input/src/keyboard-mouse/context-augmentation.ts similarity index 100% rename from modules/input/src/context-augmentation.ts rename to modules/input/src/keyboard-mouse/context-augmentation.ts diff --git a/modules/input/src/keyboard-mouse/index.ts b/modules/input/src/keyboard-mouse/index.ts new file mode 100644 index 00000000..c3ea2db1 --- /dev/null +++ b/modules/input/src/keyboard-mouse/index.ts @@ -0,0 +1,6 @@ +import "./context-augmentation"; + +export { InputEnum } from "./input.enum"; +export type { InputContextApi } from "./input-context.type"; +export { InputLibrary } from "./input.library"; +export type { DragState, MouseState, WheelState } from "./mouse.types"; diff --git a/modules/input/src/input-context.type.ts b/modules/input/src/keyboard-mouse/input-context.type.ts similarity index 100% rename from modules/input/src/input-context.type.ts rename to modules/input/src/keyboard-mouse/input-context.type.ts diff --git a/modules/input/src/input-handler.ts b/modules/input/src/keyboard-mouse/input-handler.ts similarity index 100% rename from modules/input/src/input-handler.ts rename to modules/input/src/keyboard-mouse/input-handler.ts diff --git a/modules/input/src/input.enum.ts b/modules/input/src/keyboard-mouse/input.enum.ts similarity index 100% rename from modules/input/src/input.enum.ts rename to modules/input/src/keyboard-mouse/input.enum.ts diff --git a/modules/input/src/input.library.ts b/modules/input/src/keyboard-mouse/input.library.ts similarity index 96% rename from modules/input/src/input.library.ts rename to modules/input/src/keyboard-mouse/input.library.ts index 174fc502..8ea317fb 100644 --- a/modules/input/src/input.library.ts +++ b/modules/input/src/keyboard-mouse/input.library.ts @@ -1,5 +1,6 @@ import { type InitContext, Library, defineLibraryKey } from "@nanoforge-dev/common"; +import { clientOnlyError } from "../shared"; import type { InputContextApi } from "./input-context.type"; import { InputHandler } from "./input-handler"; import type { InputEnum } from "./input.enum"; @@ -28,9 +29,7 @@ export class InputLibrary extends Library { public override async __init(ctx: InitContext): Promise { if (!ctx.container) { - throw new Error( - "InputLibrary must be registered on the client (InitContext must contain a container element).", - ); + throw clientOnlyError("InputLibrary", "InitContext must contain a container element"); } this._inputHandler = new InputHandler(ctx.container, ctx.viewport); } diff --git a/modules/input/src/mouse.types.ts b/modules/input/src/keyboard-mouse/mouse.types.ts similarity index 100% rename from modules/input/src/mouse.types.ts rename to modules/input/src/keyboard-mouse/mouse.types.ts diff --git a/modules/input/src/shared/button-state.ts b/modules/input/src/shared/button-state.ts new file mode 100644 index 00000000..47120856 --- /dev/null +++ b/modules/input/src/shared/button-state.ts @@ -0,0 +1,22 @@ +/** + * State of a digital button for the current frame. + * + * @remarks + * `justPressed` / `justReleased` are `true` only on the frame where the + * transition happened. + */ +export type ButtonState = { + /** `true` while the button is held. */ + pressed: boolean; + /** `true` on the frame the button went down. */ + justPressed: boolean; + /** `true` on the frame the button went up. */ + justReleased: boolean; +}; + +/** Computes this frame's `ButtonState` from the current reading and the previous frame. */ +export const nextButtonState = (pressed: boolean, wasPressed = false): ButtonState => ({ + pressed, + justPressed: pressed && !wasPressed, + justReleased: !pressed && wasPressed, +}); diff --git a/modules/input/src/shared/client-only-error.ts b/modules/input/src/shared/client-only-error.ts new file mode 100644 index 00000000..aeccff7f --- /dev/null +++ b/modules/input/src/shared/client-only-error.ts @@ -0,0 +1,3 @@ +/** Error thrown by an input library initialized outside of a browser client. */ +export const clientOnlyError = (library: string, reason: string): Error => + new Error(`${library} must be registered on the client (${reason}).`); diff --git a/modules/input/src/shared/index.ts b/modules/input/src/shared/index.ts new file mode 100644 index 00000000..82e3f713 --- /dev/null +++ b/modules/input/src/shared/index.ts @@ -0,0 +1,2 @@ +export { type ButtonState, nextButtonState } from "./button-state"; +export { clientOnlyError } from "./client-only-error"; diff --git a/modules/input/test/controller/controller.library.spec.ts b/modules/input/test/controller/controller.library.spec.ts new file mode 100644 index 00000000..79b1d063 --- /dev/null +++ b/modules/input/test/controller/controller.library.spec.ts @@ -0,0 +1,270 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { ControllerAxis, ControllerButton, ControllerLibrary } from "../../src/controller"; + +type MockGamepad = { + index: number; + id: string; + mapping: GamepadMappingType; + connected: boolean; + buttons: { pressed: boolean; value: number }[]; + axes: number[]; + vibrationActuator?: { playEffect: ReturnType } | null; +}; + +const makeGamepad = (index = 0): MockGamepad => ({ + index, + id: `Mock controller ${index}`, + mapping: "standard", + connected: true, + buttons: Array.from({ length: 17 }, () => ({ pressed: false, value: 0 })), + axes: [0, 0, 0, 0], +}); + +const press = (gamepad: MockGamepad, button: ControllerButton, value = 1): void => { + gamepad.buttons[button] = { pressed: value > 0, value }; +}; + +const makeWindowMock = () => { + const listeners: Record void)[]> = {}; + return { + addEventListener: vi.fn((event: string, handler: (e: { gamepad: MockGamepad }) => void) => { + if (!listeners[event]) listeners[event] = []; + listeners[event].push(handler); + }), + removeEventListener: vi.fn((event: string, handler: (e: { gamepad: MockGamepad }) => void) => { + listeners[event] = (listeners[event] ?? []).filter((h) => h !== handler); + }), + dispatch: (event: string, gamepad: MockGamepad) => { + listeners[event]?.forEach((h) => h({ gamepad })); + }, + }; +}; + +describe("ControllerLibrary", () => { + let windowMock: ReturnType; + let gamepads: (MockGamepad | null)[]; + + beforeEach(() => { + windowMock = makeWindowMock(); + gamepads = [null, null, null, null]; + vi.stubGlobal("window", windowMock); + vi.stubGlobal("navigator", { getGamepads: () => gamepads }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + const init = async (options?: ConstructorParameters[0]) => { + const library = new ControllerLibrary(options); + await library.__init(); + return library; + }; + + // __run ignores its context argument. + const tick = (library: ControllerLibrary) => library.__run({} as never); + + describe("metadata", () => { + it("should use the controller key", () => { + expect(new ControllerLibrary().key).toBe("controller"); + }); + + it("should reject an out-of-range deadzone", () => { + expect(() => new ControllerLibrary({ deadzone: 1 })).toThrow(RangeError); + expect(() => new ControllerLibrary({ deadzone: -0.1 })).toThrow(RangeError); + }); + }); + + describe("lifecycle", () => { + it("should throw before __init", () => { + expect(() => new ControllerLibrary().isButtonPressed(ControllerButton.A)).toThrow(); + }); + + it("should throw on __init when the Gamepad API is unavailable", async () => { + vi.stubGlobal("navigator", {}); + await expect(new ControllerLibrary().__init()).rejects.toThrow(/Gamepad API/); + }); + + it("should remove its window listeners on __clear", async () => { + const library = await init(); + await library.__clear({} as never); + expect(windowMock.removeEventListener).toHaveBeenCalledWith( + "gamepadconnected", + expect.any(Function), + ); + expect(windowMock.removeEventListener).toHaveBeenCalledWith( + "gamepaddisconnected", + expect.any(Function), + ); + }); + }); + + describe("connection", () => { + it("should detect a controller through polling", async () => { + const library = await init(); + expect(library.isConnected()).toBe(false); + + gamepads[0] = makeGamepad(0); + await tick(library); + + expect(library.isConnected()).toBe(true); + expect(library.getControllers()).toHaveLength(1); + expect(library.getController()?.id).toBe("Mock controller 0"); + }); + + it("should notify connect and disconnect listeners once", async () => { + const library = await init(); + const onConnect = vi.fn(); + const onDisconnect = vi.fn(); + library.onConnect(onConnect); + library.onDisconnect(onDisconnect); + + const gamepad = makeGamepad(1); + gamepads[1] = gamepad; + windowMock.dispatch("gamepadconnected", gamepad); + await tick(library); + expect(onConnect).toHaveBeenCalledTimes(1); + expect(onConnect).toHaveBeenCalledWith(expect.objectContaining({ index: 1 })); + + gamepads[1] = null; + windowMock.dispatch("gamepaddisconnected", gamepad); + await tick(library); + expect(onDisconnect).toHaveBeenCalledTimes(1); + expect(library.isConnected(1)).toBe(false); + }); + + it("should stop notifying an unsubscribed listener", async () => { + const library = await init(); + const onConnect = vi.fn(); + library.onConnect(onConnect)(); + + gamepads[0] = makeGamepad(0); + await tick(library); + expect(onConnect).not.toHaveBeenCalled(); + }); + }); + + describe("buttons", () => { + it("should report just pressed / just released on a single frame", async () => { + const library = await init(); + const gamepad = makeGamepad(0); + gamepads[0] = gamepad; + await tick(library); + + press(gamepad, ControllerButton.A); + await tick(library); + expect(library.isButtonPressed(ControllerButton.A)).toBe(true); + expect(library.isButtonJustPressed(ControllerButton.A)).toBe(true); + + await tick(library); + expect(library.isButtonPressed(ControllerButton.A)).toBe(true); + expect(library.isButtonJustPressed(ControllerButton.A)).toBe(false); + + press(gamepad, ControllerButton.A, 0); + await tick(library); + expect(library.isButtonPressed(ControllerButton.A)).toBe(false); + expect(library.isButtonJustReleased(ControllerButton.A)).toBe(true); + + await tick(library); + expect(library.isButtonJustReleased(ControllerButton.A)).toBe(false); + }); + + it("should expose analog button values", async () => { + const library = await init(); + const gamepad = makeGamepad(0); + press(gamepad, ControllerButton.RightTrigger, 0.4); + gamepads[0] = gamepad; + await tick(library); + + expect(library.getButtonValue(ControllerButton.RightTrigger)).toBe(0.4); + }); + + it("should keep controllers independent", async () => { + const library = await init(); + const second = makeGamepad(1); + press(second, ControllerButton.Start); + gamepads[0] = makeGamepad(0); + gamepads[1] = second; + await tick(library); + + expect(library.isButtonPressed(ControllerButton.Start, 0)).toBe(false); + expect(library.isButtonPressed(ControllerButton.Start, 1)).toBe(true); + }); + + it("should return neutral values for a missing controller", async () => { + const library = await init(); + expect(library.isButtonPressed(ControllerButton.A, 3)).toBe(false); + expect(library.getButtonValue(ControllerButton.A, 3)).toBe(0); + expect(library.getAxis(ControllerAxis.LeftStickX, 3)).toBe(0); + expect(library.getStick("left", 3)).toEqual({ x: 0, y: 0 }); + }); + }); + + describe("axes", () => { + it("should apply the deadzone and rescale axis values", async () => { + const library = await init({ deadzone: 0.2 }); + const gamepad = makeGamepad(0); + gamepads[0] = gamepad; + + gamepad.axes = [0.1, -0.6, 1, 0]; + await tick(library); + + expect(library.getAxis(ControllerAxis.LeftStickX)).toBe(0); + expect(library.getAxis(ControllerAxis.LeftStickY)).toBeCloseTo(-0.5); + expect(library.getAxis(ControllerAxis.RightStickX)).toBeCloseTo(1); + expect(library.getController()?.axes[0]).toBe(0.1); + }); + + it("should apply a radial deadzone to sticks", async () => { + const library = await init({ deadzone: 0.2 }); + const gamepad = makeGamepad(0); + gamepads[0] = gamepad; + + gamepad.axes = [0.1, 0.1, 0.6, 0.8]; + await tick(library); + + expect(library.getStick("left")).toEqual({ x: 0, y: 0 }); + const right = library.getStick("right"); + expect(Math.hypot(right.x, right.y)).toBeCloseTo(1); + expect(right.x / right.y).toBeCloseTo(0.75); + }); + }); + + describe("vibrate", () => { + it("should play a dual-rumble effect when supported", async () => { + const library = await init(); + const playEffect = vi.fn().mockResolvedValue("complete"); + gamepads[0] = { ...makeGamepad(0), vibrationActuator: { playEffect } }; + + await expect(library.vibrate({ duration: 200, weakMagnitude: 0.5 })).resolves.toBe(true); + expect(playEffect).toHaveBeenCalledWith("dual-rumble", { + duration: 200, + strongMagnitude: 1, + weakMagnitude: 0.5, + }); + }); + + it("should resolve false without vibration support", async () => { + const library = await init(); + gamepads[0] = { ...makeGamepad(0), vibrationActuator: null }; + await expect(library.vibrate({ duration: 200 })).resolves.toBe(false); + await expect(library.vibrate({ duration: 200 }, 2)).resolves.toBe(false); + }); + }); + + describe("expose", () => { + it("should forward the context API to the library", async () => { + const library = await init(); + const gamepad = makeGamepad(0); + press(gamepad, ControllerButton.B); + gamepads[0] = gamepad; + await tick(library); + + const api = library.expose(); + expect(api.isConnected()).toBe(true); + expect(api.isButtonPressed(ControllerButton.B)).toBe(true); + expect(api.isButtonJustPressed(ControllerButton.B)).toBe(true); + }); + }); +}); diff --git a/modules/input/test/input.library.spec.ts b/modules/input/test/keyboard-mouse/input.library.spec.ts similarity index 99% rename from modules/input/test/input.library.spec.ts rename to modules/input/test/keyboard-mouse/input.library.spec.ts index 4d31465b..8bf5afe0 100644 --- a/modules/input/test/input.library.spec.ts +++ b/modules/input/test/keyboard-mouse/input.library.spec.ts @@ -1,7 +1,7 @@ import type { InitContext } from "@nanoforge-dev/common"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { InputEnum, InputLibrary } from "../src"; +import { InputEnum, InputLibrary } from "../../src/keyboard-mouse"; type MockInputEvent = { code?: string; diff --git a/modules/input/test/shared/button-state.spec.ts b/modules/input/test/shared/button-state.spec.ts new file mode 100644 index 00000000..f17a4d87 --- /dev/null +++ b/modules/input/test/shared/button-state.spec.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; + +import { clientOnlyError, nextButtonState } from "../../src/shared"; + +describe("nextButtonState", () => { + it("should flag the frame a button goes down", () => { + expect(nextButtonState(true, false)).toEqual({ + pressed: true, + justPressed: true, + justReleased: false, + }); + }); + + it("should flag the frame a button goes up", () => { + expect(nextButtonState(false, true)).toEqual({ + pressed: false, + justPressed: false, + justReleased: true, + }); + }); + + it("should not flag a held or idle button", () => { + expect(nextButtonState(true, true)).toEqual({ + pressed: true, + justPressed: false, + justReleased: false, + }); + expect(nextButtonState(false)).toEqual({ + pressed: false, + justPressed: false, + justReleased: false, + }); + }); +}); + +describe("clientOnlyError", () => { + it("should name the library and the reason", () => { + expect(clientOnlyError("MyLibrary", "no window").message).toBe( + "MyLibrary must be registered on the client (no window).", + ); + }); +}); diff --git a/modules/input/tsdown.config.ts b/modules/input/tsdown.config.ts index 12f5dea6..099e72f4 100644 --- a/modules/input/tsdown.config.ts +++ b/modules/input/tsdown.config.ts @@ -1,3 +1,15 @@ +import { type UserConfig } from "tsdown"; + import { createTsdownConfig } from "../../tsdown.config"; -export default [createTsdownConfig()]; +const createInputPartConfig = (part: "keyboard-mouse" | "controller"): UserConfig => + createTsdownConfig({ + entry: `src/${part}/index.ts`, + outDir: `dist/${part}`, + }); + +export default [ + createTsdownConfig(), + createInputPartConfig("keyboard-mouse"), + createInputPartConfig("controller"), +];