ecspresso
Version:
A minimal Entity-Component-System library for typescript and javascript.
214 lines (213 loc) • 13.9 kB
TypeScript
/**
* Input Plugin for ECSpresso
*
* Resource-only plugin — input is polled via the `inputState` resource. Provides
* frame-accurate keyboard, pointer (mouse + touch via PointerEvent), up to 4
* gamepads, and unified + per-player action maps.
*
* Mutation model: DOM events accumulate into `raw` between frames and are
* flattened once per frame into a stable `frame` object whose Sets are cleared
* and refilled in place (no per-frame allocations). Gamepads are polled once
* per frame via `navigator.getGamepads()` (or an injected poll function).
* Unified and per-player action states ping-pong two Sets (`active` / `prev`)
* so edge detection costs nothing beyond one `.add()` per active action.
*/
import { type BasePluginOptions, type Vector2D } from 'ecspresso';
type LowercaseLetter = 'a' | 'b' | 'c' | 'd' | 'e' | 'f' | 'g' | 'h' | 'i' | 'j' | 'k' | 'l' | 'm' | 'n' | 'o' | 'p' | 'q' | 'r' | 's' | 't' | 'u' | 'v' | 'w' | 'x' | 'y' | 'z';
type Digit = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9';
type Punctuation = '`' | '~' | '!' | '@' | '#' | '$' | '%' | '^' | '&' | '*' | '(' | ')' | '-' | '_' | '=' | '+' | '[' | '{' | ']' | '}' | '\\' | '|' | ';' | ':' | "'" | '"' | ',' | '<' | '.' | '>' | '/' | '?';
type ModifierKey = 'Alt' | 'AltGraph' | 'CapsLock' | 'Control' | 'Fn' | 'FnLock' | 'Hyper' | 'Meta' | 'NumLock' | 'ScrollLock' | 'Shift' | 'Super' | 'Symbol' | 'SymbolLock';
type WhitespaceKey = 'Enter' | 'Tab' | ' ';
type NavigationKey = `Arrow${'Down' | 'Left' | 'Right' | 'Up'}` | 'End' | 'Home' | 'PageDown' | 'PageUp';
type EditingKey = 'Backspace' | 'Clear' | 'Copy' | 'CrSel' | 'Cut' | 'Delete' | 'EraseEof' | 'ExSel' | 'Insert' | 'Paste' | 'Redo' | 'Undo';
type UIKey = 'Accept' | 'Again' | 'Attn' | 'Cancel' | 'ContextMenu' | 'Escape' | 'Execute' | 'Find' | 'Finish' | 'Help' | 'Pause' | 'Play' | 'Props' | 'Select' | 'ZoomIn' | 'ZoomOut';
type DeviceKey = 'BrightnessDown' | 'BrightnessUp' | 'Eject' | 'Hibernate' | 'LogOff' | 'Power' | 'PowerOff' | 'PrintScreen' | 'Standby' | 'WakeUp';
type IMEKey = 'AllCandidates' | 'Alphanumeric' | 'CodeInput' | 'Compose' | 'Convert' | 'FinalMode' | 'GroupFirst' | 'GroupLast' | 'GroupNext' | 'GroupPrevious' | 'ModeChange' | 'NextCandidate' | 'NonConvert' | 'PreviousCandidate' | 'Process' | 'SingleCandidate' | 'HangulMode' | 'HanjaMode' | 'JunjaMode' | 'Eisu' | 'Hankaku' | 'Hiragana' | 'HiraganaKatakana' | 'KanaMode' | 'KanjiMode' | 'Katakana' | 'Romaji' | 'Zenkaku' | 'ZenkakuHankaku';
type FunctionKey = `F${1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24}` | 'Soft1' | 'Soft2' | 'Soft3' | 'Soft4';
type PhoneKey = 'AppSwitch' | 'Call' | 'Camera' | 'CameraFocus' | 'EndCall' | 'GoBack' | 'GoHome' | 'HeadsetHook' | 'LastNumberRedial' | 'Notification' | 'MannerMode' | 'VoiceDial';
type MultimediaKey = 'ChannelDown' | 'ChannelUp' | `Media${'FastForward' | 'Pause' | 'Play' | 'PlayPause' | 'Record' | 'Rewind' | 'Stop' | 'TrackNext' | 'TrackPrevious'}`;
type AudioKey = `Audio${'BalanceLeft' | 'BalanceRight' | 'BassDown' | 'BassBoostDown' | 'BassBoostToggle' | 'BassBoostUp' | 'BassUp' | 'FaderFront' | 'FaderRear' | 'SurroundModeNext' | 'TrebleDown' | 'TrebleUp' | 'VolumeDown' | 'VolumeMute' | 'VolumeUp'}` | `Microphone${'Toggle' | 'VolumeDown' | 'VolumeMute' | 'VolumeUp'}`;
type TVKey = 'TV' | `TV${'3DMode' | 'AntennaCable' | 'AudioDescription' | 'AudioDescriptionMixDown' | 'AudioDescriptionMixUp' | 'ContentsMenu' | 'DataService' | 'Input' | 'InputComponent1' | 'InputComponent2' | 'InputComposite1' | 'InputComposite2' | 'InputHDMI1' | 'InputHDMI2' | 'InputHDMI3' | 'InputHDMI4' | 'InputVGA1' | 'MediaContext' | 'Network' | 'NumberEntry' | 'Power' | 'RadioService' | 'Satellite' | 'SatelliteBS' | 'SatelliteCS' | 'SatelliteToggle' | 'TerrestrialAnalog' | 'TerrestrialDigital' | 'Timer'}`;
type MediaControllerKey = 'AVRInput' | 'AVRPower' | `Color${'F0Red' | 'F1Green' | 'F2Yellow' | 'F3Blue' | 'F4Grey' | 'F5Brown'}` | 'ClosedCaptionToggle' | 'Dimmer' | 'DisplaySwap' | 'DVR' | 'Exit' | `Favorite${'Clear' | 'Recall' | 'Store'}${0 | 1 | 2 | 3}` | 'Guide' | 'GuideNextDay' | 'GuidePreviousDay' | 'Info' | 'InstantReplay' | 'Link' | 'ListProgram' | 'LiveContent' | 'Lock' | `Media${'Apps' | 'AudioTrack' | 'Last' | 'SkipBackward' | 'SkipForward' | 'StepBackward' | 'StepForward' | 'TopMenu'}` | `Navigate${'In' | 'Next' | 'Out' | 'Previous'}` | 'NextFavoriteChannel' | 'NextUserProfile' | 'OnDemand' | 'Pairing' | `PinP${'Down' | 'Move' | 'Toggle' | 'Up'}` | `PlaySpeed${'Down' | 'Reset' | 'Up'}` | 'RandomToggle' | 'RcLowBattery' | 'RecordSpeedNext' | 'RfBypass' | 'ScanChannelsToggle' | 'ScreenModeNext' | 'Settings' | 'SplitScreenToggle' | 'STBInput' | 'STBPower' | 'Subtitle' | 'Teletext' | 'VideoModeNext' | 'Wink' | 'ZoomToggle';
type SpeechKey = 'SpeechCorrectionList' | 'SpeechInputToggle';
type DocumentKey = 'Close' | 'New' | 'Open' | 'Print' | 'Save' | 'SpellCheck' | 'MailForward' | 'MailReply' | 'MailSend';
type LaunchKey = `Launch${'Calculator' | 'Calendar' | 'Contacts' | 'Mail' | 'MediaPlayer' | 'MusicPlayer' | 'MyComputer' | 'Phone' | 'ScreenSaver' | 'Spreadsheet' | 'WebBrowser' | 'WebCam' | 'WordProcessor' | `Application${1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16}`}`;
type BrowserKey = `Browser${'Back' | 'Favorites' | 'Forward' | 'Home' | 'Refresh' | 'Search' | 'Stop'}`;
type NumpadKey = 'Decimal' | 'Key11' | 'Key12' | 'Multiply' | 'Add' | 'Divide' | 'Subtract' | 'Separator';
export type KeyCode = LowercaseLetter | Uppercase<LowercaseLetter> | Digit | Punctuation | ModifierKey | WhitespaceKey | NavigationKey | EditingKey | UIKey | DeviceKey | IMEKey | FunctionKey | PhoneKey | MultimediaKey | AudioKey | TVKey | MediaControllerKey | SpeechKey | DocumentKey | LaunchKey | BrowserKey | NumpadKey | 'Unidentified' | 'Dead';
export interface KeyboardState {
isDown(key: KeyCode): boolean;
justPressed(key: KeyCode): boolean;
justReleased(key: KeyCode): boolean;
}
export interface PointerState {
readonly position: Readonly<Vector2D>;
readonly delta: Readonly<Vector2D>;
isDown(button: number): boolean;
justPressed(button: number): boolean;
justReleased(button: number): boolean;
}
export interface GamepadState {
readonly connected: boolean;
readonly id: string | null;
isDown(button: number): boolean;
justPressed(button: number): boolean;
justReleased(button: number): boolean;
/** Analog button value in [0, 1]. Useful for triggers. Returns 0 when disconnected or out of range. */
buttonValue(button: number): number;
/** Deadzone-applied axis value in [-1, 1]. Sticks use radial deadzone on axis pairs (0,1) and (2,3). */
axis(index: number): number;
/** Raw axis value in [-1, 1] with no deadzone applied. */
rawAxis(index: number): number;
}
export interface ActionState<A extends string = string> {
isActive(action: A): boolean;
justActivated(action: A): boolean;
justDeactivated(action: A): boolean;
}
export interface PlayerInput<A extends string = string> {
readonly actions: ActionState<A>;
setActionMap(map: ActionMap<A>): void;
getActionMap(): Readonly<ActionMap<A>>;
}
export interface InputState<A extends string = string> {
readonly keyboard: KeyboardState;
readonly pointer: PointerState;
/** Always length 4 (standard web gamepad slot count). Disconnected slots return `connected: false`. */
readonly gamepads: ReadonlyArray<GamepadState>;
/** Unified action state — fires when any bound input (keyboard, pointer, any pad) is active. Intended for menu/shared input. */
readonly actions: ActionState<A>;
setActionMap(actions: ActionMap<A>): void;
getActionMap(): Readonly<ActionMap<A>>;
/** Register or replace a player's action map. Per-player states are isolated from the unified `actions`. */
definePlayer(id: string, map: ActionMap<A>): void;
/** Returns true if the player existed and was removed. */
removePlayer(id: string): boolean;
/** Returns a handle to a registered player's input, or undefined if no such player. */
player(id: string): PlayerInput<A> | undefined;
playerIds(): readonly string[];
}
export interface GamepadButtonRef {
pad: number;
button: number;
}
export interface GamepadAxisRef {
pad: number;
axis: number;
/** Which half of the axis counts as "active". */
direction: 1 | -1;
/** Magnitude at which the axis triggers the action. Applied to the deadzone-adjusted axis value. Default: 0.5. */
threshold?: number;
}
export interface ActionBinding {
keys?: KeyCode[];
/** Pointer (mouse/touch) button indices — 0 = primary, 1 = auxiliary, 2 = secondary, etc. */
pointerButtons?: number[];
gamepadButtons?: GamepadButtonRef[];
gamepadAxes?: GamepadAxisRef[];
}
export type ActionMap<A extends string = string> = Record<A, ActionBinding>;
export interface InputResourceTypes<A extends string = string> {
inputState: InputState<A>;
}
/**
* Minimal gamepad shape required by the injectable poll function. A structural
* subset of the browser `Gamepad` interface — `navigator.getGamepads()` satisfies
* it directly, and test doubles can supply just these fields.
*/
export interface GamepadLike {
id: string;
connected: boolean;
buttons: ReadonlyArray<{
pressed: boolean;
value: number;
}>;
axes: ReadonlyArray<number>;
}
export interface GamepadOptions {
/** Radial deadzone applied to stick pairs (axes 0,1 and 2,3). Value in [0, 1]. Default: 0.15. */
deadzone?: number;
/**
* Custom poll function returning up to 4 gamepad slots. Defaults to `navigator.getGamepads()`.
* Primarily an injection point for tests; in the browser the default is correct.
*/
poll?: () => ReadonlyArray<GamepadLike | null>;
}
export interface InputPluginOptions<A extends string = string, G extends string = 'input'> extends BasePluginOptions<G> {
/** Initial unified action map. */
actions?: ActionMap<A>;
/** Initial per-player action maps, keyed by player id. */
players?: Record<string, ActionMap<A>>;
/** EventTarget to attach listeners to (default: globalThis). Pass a custom target for testability. */
target?: EventTarget;
/** Gamepad polling and deadzone configuration. */
gamepad?: GamepadOptions;
/**
* Optional conversion from raw DOM client coordinates to the space `inputState.pointer.position` should report.
* Renderer-agnostic: wire to `clientToLogical(...)` from renderer2D when using `screenScale`, or to a renderer-specific helper.
* When omitted, pointer coords remain raw `clientX`/`clientY` (not canvas-relative).
*/
coordinateTransform?: (clientX: number, clientY: number) => {
x: number;
y: number;
};
/**
* Keys whose browser default behaviour should be suppressed (e.g. `['Tab', ' ', 'ArrowDown']`).
* Ignored when `shouldPreventDefault` is provided.
*/
preventDefaultKeys?: KeyCode[];
/**
* Pointer button indices whose browser default behaviour should be suppressed (e.g. `[0, 2]`).
* Ignored when `shouldPreventDefault` is provided.
*/
preventDefaultPointerButtons?: number[];
/**
* Custom predicate for full control over `preventDefault`. Receives the raw DOM event and returns
* `true` to suppress its default behaviour. When provided, `preventDefaultKeys` and
* `preventDefaultPointerButtons` are ignored.
*/
shouldPreventDefault?: (event: KeyboardEvent | PointerEvent) => boolean;
}
/** Create a single action binding. Identity function that provides type inference for inline literals. */
export declare function createActionBinding(binding: ActionBinding): ActionBinding;
/** Build an array of gamepad button refs scoped to one pad — `gamepadButtonsOn(0, 0, 1, 9)` = pad 0's buttons 0, 1, 9. */
export declare function gamepadButtonsOn(pad: number, ...buttons: number[]): GamepadButtonRef[];
/** Build a gamepad axis ref. `threshold` defaults to 0.5 at activation time. */
export declare function gamepadAxisOn(pad: number, axis: number, direction: 1 | -1, threshold?: number): GamepadAxisRef;
/**
* Create an input plugin for ECSpresso.
*
* Provides:
* - Frame-accurate keyboard state (isDown, justPressed, justReleased)
* - Pointer position/delta and button state (mouse + touch via PointerEvent)
* - Up to 4 gamepads polled per frame, with radial deadzone on sticks and analog button values
* - Unified action mapping (keyboard + pointer + any pad)
* - Per-player action maps for local co-op (`definePlayer`, `player(id)`)
* - Automatic listener cleanup on detach
*
* @example
* ```typescript
* const ecs = ECSpresso.create()
* .withPlugin(createInputPlugin({
* actions: {
* jump: { keys: [' ', 'ArrowUp'], gamepadButtons: [{ pad: 0, button: 0 }] },
* shoot: { keys: ['z'], pointerButtons: [0] },
* },
* players: {
* p1: { jump: { keys: [' '] }, shoot: { keys: ['z'] } },
* p2: {
* jump: { gamepadButtons: gamepadButtonsOn(0, 0) },
* shoot: { gamepadButtons: gamepadButtonsOn(0, 2) },
* },
* },
* }))
* .build();
*
* const input = ecs.getResource('inputState');
* if (input.actions.justActivated('jump')) { ... } // any source
* if (input.player('p1')?.actions.isActive('jump')) { ... } // just player 1
* if (input.gamepads[0].isDown(0)) { ... } // raw pad 0 A-button
* ```
*/
export declare function createInputPlugin<A extends string = string, G extends string = 'input'>(options?: InputPluginOptions<A, G>): import("ecspresso").Plugin<import("ecspresso").WithResources<import("ecspresso").EmptyConfig, InputResourceTypes<A>>, import("ecspresso").EmptyConfig, "input-state", G, never, never>;
export {};