UNPKG

ecspresso

Version:

A minimal Entity-Component-System library for typescript and javascript.

214 lines (213 loc) 13.9 kB
/** * 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 {};