diff --git a/docs/src/content/docs/changelog/index.mdx b/docs/src/content/docs/changelog/index.mdx index 848353e..15f0a79 100644 --- a/docs/src/content/docs/changelog/index.mdx +++ b/docs/src/content/docs/changelog/index.mdx @@ -39,6 +39,7 @@ The following components have been extended to support onAction and anchor props | [XYSlider](/components/basic/xy-slider/#implemented-navigation-actions), [Scroll](/components/layout/scroll/#implemented-navigation-actions) | , | | [Tooltip](/components/feedback/tooltip/#implemented-navigation-actions) | , | | [TextBlock](/components/basic/text-block#props), [InlineTextBlock](/components/basic/inline-text-block#props) | (`onAction`, `anchor`) | +| [Keybind](/components/basic/keybinds#implemented-navigation-actions) | , | ### 📖 New Guides @@ -46,7 +47,9 @@ The following components have been extended to support onAction and anchor props - [GridTile](/components/layout/grid-tile) - Provide [a guide](/components/layout/grid-tile/#making-grid-tiles-navigable) on how to initialize grid tiles as navigable elements within a [Navigation](/components/utility/Navigation/) area. -## 1.2.0 :badge[Latest]{variant="note"} +- [Keybinds](/components/basic/keybinds) - Provide [an extended overview](/components/basic/keybinds#gamepad-support) on gamepad support. + +## 1.2.0 Added [Icon](/components/media/icon/) component. diff --git a/docs/src/content/docs/components/Basic/keybinds.mdx b/docs/src/content/docs/components/Basic/keybinds.mdx index b82efc2..7b1c7d3 100644 --- a/docs/src/content/docs/components/Basic/keybinds.mdx +++ b/docs/src/content/docs/components/Basic/keybinds.mdx @@ -4,6 +4,8 @@ tableOfContents: maxHeadingLevel: 4 --- +import { Badge } from '@astrojs/starlight/components' + The `Keybinds` component is an easy way to set up keyboard + mouse binding logic in your UI. It works together with the `Keybind` component, which represents a single action slot. `Keybinds` provides context, conflict handling, and programmatic control. @@ -93,6 +95,8 @@ The `value` prop can be omitted since it will be seeded from `defaults`. | `ref` | `(ref: KeybindsRef) => void` | `undefined` | Exposes programmatic methods and the current bindings. | | `onConflict` | `(action: string, key: string \| null, conflictAction: string) => void` | `undefined` | Called when a conflict happens under any policy. | | `onChange` | `(prev: string \| null, next: string \| null, action: string) => void` | `undefined` | Called when a bind or unbind operation succeeds. | +| `mode` | `gamepad` \| `keyboard` | `keyboard` | Sets the mode for the input. Use `gamepad` in order to accept and display gamepad buttons. | +| `glyphOverrides` | `Partial \| JSX.Element>>` | `{}` | Object of custom display glyph overrides. Include only the gamepad values you want to change (e.g., `0`, `1`, `left.joystick`). [See Glyphs Object](#glyphs-object) | ### Ref API @@ -135,7 +139,142 @@ it starts to listen for the user to press a key or mouse button to bind the asso | Prop name | Type | Default | Description | | --------- | ---- | ------- | ----------- | | `action` | `string` | `undefined` | The action that will have a key bound to it. | -| `value` | `string \| undefined` | `undefined` | The value associated with the keybind by default. If omitted, it will be null. The string provided must match the label from the [mappings object](#mappings-object), unless overridden. | +| `value` | `string \| undefined` | `undefined` | The value associated with the keybind by default. If omitted, it will be `null`. The string provided must match the label from the [mappings object](#mappings-object), unless overridden. If the `mode` prop is set to `gamepad` the value must match any of the values or aliases from the [Glyph Object](#glyphs-object) | +| `onAction` | `Record void>` | `undefined` | Allows you to add custom navigation action handlers to the keybind. See [Implemented Navigation Actions](#implemented-navigation-actions) for details. | +| `anchor` | `string \| HTMLElement` | `undefined` | Links navigation to another element. When the anchor element is focused, the button's actions will execute. Can be a CSS selector or HTMLElement. | + +### Implemented Navigation Actions + +The `Keybind` component implements the following navigation actions by default: + +| Action Name | Behavior | +|-------------|-----------------------------------| +| `select` | Begin listening for gamepad input | + +You can extend the `Keybind` with additional navigation actions or override the default behavior using the `onAction` prop: + +```tsx +import Keybind from '@components/Basic/Keybinds/Keybind'; + + console.log('custom action')}}/> +``` + +For more information about navigation actions, see the [Navigation component documentation](/components/utility/navigation#extending-component-navigation-actions). + +## Gamepad Support :badge[2.0.0]{variant=note} + +With the introduction of the [Navigation component](/components/utility/navigation/), the `Keybinds` component now supports gamepad input as well. + +To enable gamepad support, set the `mode` prop to `gamepad`. +When in gamepad mode, the component will listen for gamepad button presses instead of keyboard/mouse input. + +Internally, the gamepad buttons are identified and stored by the [Gamepad API standard button indices](https://developer.mozilla.org/en-US/docs/Web/API/Gamepad/buttons), +while the axes are identified by custom aliases (e.g., `left.joystick.up`, `right.joystick.left`, etc.). +See the [Glyphs object](#glyphs-object) table for the full list of supported gamepad button codes and axis aliases. + +For easier readability, you can use the same button codes and axis aliases used by the [Interaction Manager Gamepad Class](https://frontend-tools.coherent-labs.com/interaction-manager/features/gamepad/#options). +These will resolve to the correct button indices internally. + +### Usage + +In order for the `Keybind` component to listen for a gamepad button press, the `Keybind` component must be focused via the `Navigation` component system. The easiest way is to wrap the `Keybind` components with +a `Navigation.Area` component. + +```tsx +import Keybinds from '@components/Basic/Keybinds/Keybinds'; +import Keybind from '@components/Basic/Keybinds/Keybind'; +import Navigation from '@components/Utility/Navigation/Navigation'; + +const App = () => { + return ( + + + + + + + + + + ); +}; + +export default App; +``` + +:::caution[Duplicate bindings] +Binding a value of `xbox.a` and `face-button-down` or even `0` will result in the same button trying to be bound. In which case it will be treated as a conflict. +::: + +### Displayed glyphs + +When displaying the bound keys, the component will render the corresponding glyphs for the gamepad buttons. The default glyphs are based on the standard Xbox controller layout and are displayed by the +[Icon](/components/media/icon/) component. + +You can override the default glyphs by providing a custom `glyphOverrides` prop to the `Keybinds` component and providing your own components or elements for the desired buttons. + +```tsx {"You can pass overrides in the form of:": 5} {"- Component reference": 7-8} {"- Calling the component directly to be able to provide custom props such as styles,": 10-11} {" - HTML element such as an image": 12-13} +import Keybinds from '@components/Basic/Keybinds/Keybinds'; +import Keybind from '@components/Basic/Keybinds/Keybind'; + +const App = () => { + + const GLYPHS: GlyphOverrides = { + + '0': Icon.gamepad.ps5.cross, + '1': Icon.gamepad.ps5.circle, + + '2': , + + '3': , + } + + return ( + + + + + + + + ); +}; + +export default App; +``` + +Now the `Keybind` component with `xbox.a` value will display the ps5 cross glyph instead of the default xbox a glyph. + +:::note +When overriding glyphs, make sure to use the exact same gamepad button codes and axis aliases as specified in the [Glyphs object](#glyphs-object) table. +::: + +### Styling the gamepad glyphs + +If you wish to provide custom styling to the displayed glyphs such as changing the `width/height` of the displayed image, +you can do so by overriding the glyph via the `glyphOverrides` prop and providing your own component or element with the desired styles. + +For example, to keep the default xbox glyphs but change their size, you can do the following: + +```css title="index.css" +.gamepad-glyph { + width: 5vmax; + height: 5vmax; +} +``` + +```tsx +const GLYPHS: GlyphOverrides = { + '0': , + '1': , + '2': , + '3': , +} +``` + +:::tip +We recommend using the [Icon](/components/media/icon/) component for rendering the glyphs. +::: ## Guide @@ -545,4 +684,50 @@ The object is located at `src/components/Basic/Keybinds/mappings.ts`. | Code | Label | |-------------------|----------------| | WheelUp | Wheel Up | -| WheelDown | Wheel Down | \ No newline at end of file +| WheelDown | Wheel Down | + +### Glyphs object + +The `Keybinds` component comes with a default glyphs object that maps gamepad button codes to their corresponding glyph components for display purposes. + +The glyphs object is located at `src/components/Basic/Keybinds/glyphs.ts`. + +You can override the glyphs you want to display custom `Components` or `HTMLElements` by providing a custom `glyphOverrides` prop to the `Keybinds` component. +Keep in mind that you should use the exact same gamepad button codes and axis aliases as specified below. + +#### Buttons (Indices) + +| Code | Standard Xbox | Standard PlayStation | +|------|---------------|----------------------| +| 0 | A | Cross | +| 1 | B | Circle | +| 2 | X | Square | +| 3 | Y | Triangle | +| 4 | LB (Bumper) | L1 | +| 5 | RB (Bumper) | R1 | +| 6 | LT (Trigger) | L2 | +| 7 | RT (Trigger) | R2 | +| 8 | View (Back) | Create/Share | +| 9 | Menu (Start) | Options | +| 10 | Left Stick Press (L3) | L3 | +| 11 | Right Stick Press (R3) | R3 | +| 12 | D-Pad Up | D-Pad Up | +| 13 | D-Pad Down | D-Pad Down | +| 14 | D-Pad Left | D-Pad Left | +| 15 | D-Pad Right | D-Pad Right | +| 16 | Guide / Share | PS Button | + +#### Joysticks aliases + +| Code | Description | +| :--- | :--- | +| `left.joystick` | The entire Left Analog Stick | +| `left.joystick.up` | Left Stick Up direction | +| `left.joystick.down` | Left Stick Down direction | +| `left.joystick.left` | Left Stick Left direction | +| `left.joystick.right` | Left Stick Right direction | +| `right.joystick` | The entire Right Analog Stick | +| `right.joystick.up` | Right Stick Up direction | +| `right.joystick.down` | Right Stick Down direction | +| `right.joystick.left` | Right Stick Left direction | +| `right.joystick.right` | Right Stick Right direction | \ No newline at end of file diff --git a/docs/src/content/docs/components/Utility/Navigation.mdx b/docs/src/content/docs/components/Utility/Navigation.mdx index 3ca123a..ca0ae44 100644 --- a/docs/src/content/docs/components/Utility/Navigation.mdx +++ b/docs/src/content/docs/components/Utility/Navigation.mdx @@ -383,6 +383,8 @@ The `Navigation` component exposes a comprehensive API through the `useNavigatio | `getScope` | None | `string` | Gets the current navigation scope (typically the name of the active navigation area). | | `getAction` | `name: ActionName` | `ActionCfg \| undefined` | Gets a specific action configuration by name. Returns undefined if the action doesn't exist. | | `getActions` | None | `ActionMap` | Gets all currently registered actions. | +| `pauseInput` | None | `void` | Snapshots current pause states and forcefully pauses all actions (ideal for stopping all action input). `resumeInput` must be used to unpause all actions. | +| `resumeInput` | None | `void` | Releases the global pause and restores actions to their state prior to the `pauseInput` call. | ### Area Methods diff --git a/package.json b/package.json index 3fc8885..713620e 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "dependencies": { "@solid-primitives/jsx-tokenizer": "^1.1.1", "@types/node": "^22.9.0", - "coherent-gameface-interaction-manager": "^2.6.0", + "coherent-gameface-interaction-manager": "^2.8.0", "cors-env": "^1.0.2", "dotenv": "^17.2.0", "glob": "^11.0.0", @@ -40,4 +40,4 @@ "vite-gameface": "^1.0.3", "vite-solid-style-to-css": "^1.0.1" } -} \ No newline at end of file +} diff --git a/src/components/Basic/Keybinds/Keybind.tsx b/src/components/Basic/Keybinds/Keybind.tsx index 1a2283d..6fff00d 100644 --- a/src/components/Basic/Keybinds/Keybind.tsx +++ b/src/components/Basic/Keybinds/Keybind.tsx @@ -1,13 +1,17 @@ -import { createMemo, createSignal, onMount, useContext } from "solid-js"; -import { KeybindsContext } from "./Keybinds"; +import { createMemo, createSignal, JSX, onMount, useContext } from "solid-js"; +import { KeybindsContext, KeyCode } from "./Keybinds"; import { ComponentProps } from "@components/types/ComponentProps"; import useBaseComponent from "@components/BaseComponent/BaseComponent"; import style from './Keybinds.module.scss' import { BindingCode, BindingLabel } from "./util/mappings"; +import { gamepad, GamepadInput, GamepadMappings } from "coherent-gameface-interaction-manager"; +import { GamepadBindingCode } from "./util/glyphs"; +import mergeNavigationActions from "@components/utils/mergeNavigationActions"; +import { useNavigation } from "@components/Utility/Navigation/Navigation"; interface KeyBindProps extends ComponentProps { action: string, - value?: BindingLabel | (string & {}), + value?: BindingLabel | GamepadInput | (string & {}), } const Keybind = (props: KeyBindProps) => { @@ -19,15 +23,29 @@ const Keybind = (props: KeyBindProps) => { const [listening, setListening] = createSignal(false); let el!: HTMLDivElement; + const nav = useNavigation(); const label = createMemo(() => { if (listening()) return context.listeningText?.() ?? 'Press any key...'; + + const currentValue = context.bindings[props.action]; + if (!currentValue) return context.placeholder?.() ?? ''; + + if (context.mode === 'gamepad') { + const DisplayValue = context.GLYPHS[currentValue as GamepadBindingCode]; + if (typeof DisplayValue === 'function') { + return DisplayValue({}); + } + return DisplayValue as JSX.Element; + } - return context.bindings[props.action] || (context.placeholder?.() ?? ''); + return currentValue; }) const startListening = () => { - setListening(true); + if (context.mode === 'gamepad') return; + + setListening(true) el.focus(); window.addEventListener("keydown", onKeyDown, true); window.addEventListener("mousedown", onMousedown, true); @@ -43,6 +61,75 @@ const Keybind = (props: KeyBindProps) => { setListening(false); }; + const startListeningGamepad = () => { + setListening(true); + // pause all actions + nav?.pauseInput(); + + let isFinished = false; + // axis callbacks references + const registeredCallbacks: Record void> = {}; + + const stopAllListeners = () => { + clearInterval(pollInterval); + // remove axes callbacks + for (const alias in registeredCallbacks) { + gamepad.off([alias as GamepadInput], registeredCallbacks[alias]); + } + }; + + const bindAndCleanup = (inputCode: KeyCode, isButton: boolean = false, pressedBtnObj?: GamepadButton) => { + if (isFinished) return; + isFinished = true; + + stopAllListeners(); + + const prevKey = context.bindings[props.action] || null; + const success = context.bind(props.action, inputCode); + + if (success) context.onChange?.(prevKey, String(inputCode), props.action); + setListening(false); + + if (isButton && pressedBtnObj) { + const releaseInterval = setInterval(() => { + if (!pressedBtnObj.pressed) { + clearInterval(releaseInterval); + nav?.resumeInput(); + } + }, 500); + } else { + nav?.resumeInput(); + } + }; + + // Stick logic + GamepadMappings.axisAliases.forEach((alias, index) => { + if (index <= 1) return; + // Create the specific reference + const callback = () => bindAndCleanup(alias as KeyCode); + // Save it for later + registeredCallbacks[alias] = callback; + // Register it + gamepad.on({ actions: [alias], callback }); + }); + + // Button logic + const pollInterval = setInterval(() => { + const gamepad = navigator.getGamepads()[0]; + if (!gamepad || isFinished) return; + + let buttonIdx = -1; + const pressedButton = gamepad?.buttons.find((btn, idx) => { + if (btn.pressed) { + buttonIdx = idx; + return btn + } + }) + + if (pressedButton) bindAndCleanup(buttonIdx as any as KeyCode, true, pressedButton); + }, 150) + } + const eatEvent = (e :Event) => { e.preventDefault(); e.stopPropagation(); @@ -80,7 +167,7 @@ const Keybind = (props: KeyBindProps) => { }) props.componentClasses = style.keybind; - const { className, inlineStyles, forwardEvents, forwardAttrs } = useBaseComponent(props); + const { className, inlineStyles, forwardEvents, forwardAttrs, navigationActions } = useBaseComponent(props); return (
{ style={inlineStyles()} use:forwardEvents={props} use:forwardAttrs={props} + use:navigationActions={mergeNavigationActions(props, {'select': startListeningGamepad})} onmouseup={startListening}> {label()}
diff --git a/src/components/Basic/Keybinds/Keybinds.module.scss b/src/components/Basic/Keybinds/Keybinds.module.scss index 57b753d..7ab3a0e 100644 --- a/src/components/Basic/Keybinds/Keybinds.module.scss +++ b/src/components/Basic/Keybinds/Keybinds.module.scss @@ -8,7 +8,8 @@ align-items: center; justify-content: center; - &:hover { + &:hover, + &:focus { border-color: rgba($primaryColor, 0.75); } } \ No newline at end of file diff --git a/src/components/Basic/Keybinds/Keybinds.tsx b/src/components/Basic/Keybinds/Keybinds.tsx index 13c68a5..b156498 100644 --- a/src/components/Basic/Keybinds/Keybinds.tsx +++ b/src/components/Basic/Keybinds/Keybinds.tsx @@ -2,11 +2,14 @@ import { BaseComponentRef } from "@components/types/ComponentProps"; import { Accessor, batch, createContext, createEffect, createMemo, on, onMount, ParentComponent } from "solid-js"; import { createStore, unwrap } from "solid-js/store"; import {BindingCode, BindingLabel, MAPPINGS} from "./util/mappings"; +import { GlyphOverrides } from "./util/glyphs"; +import { GLYPHS as DEFAULT_GLYPHS } from "./util/glyphs"; +import { AxisInput, GamepadInput, GamepadMappings } from "coherent-gameface-interaction-manager"; export type Action = string; -export type KeyCode = BindingLabel | (string & {}) | null; +export type KeyCode = BindingLabel | GamepadInput | (string & {}) | null; export type ConflictPolicy = 'block' | 'replace-existing' | 'swap' | 'allow-duplicates' -type Bindings = Record; +export type Bindings = Record; export interface KeybindsRef { bindings: Bindings, @@ -17,32 +20,38 @@ export interface KeybindsRef { reset: () => void, } -interface KeybindsContext { +interface SharedProps { + onChange?: (prev: KeyCode, next: KeyCode, action: Action) => void, + mode?: 'gamepad' | 'keyboard' +} + +interface KeybindsContext extends SharedProps { KEYS: Record, + GLYPHS: GlyphOverrides, bindings: Bindings, bind: (action: Action, newKey: KeyCode) => boolean, placeholder?: Accessor, listeningText?: Accessor, - useChars?: Accessor - onChange?: (prev: KeyCode, next: KeyCode, action: Action) => void, + useChars?: Accessor, } export const KeybindsContext = createContext(); -interface KeybindsProps { +interface KeybindsProps extends SharedProps { defaults?: Bindings, overrides?: Partial>, placeholder?: string, listeningText?: string, conflictPolicy?: ConflictPolicy, + glyphOverrides?: GlyphOverrides; ref?: unknown | ((ref: BaseComponentRef) => void); onConflict?: (action: Action, key: KeyCode, conflictAction: Action) => void, - onChange?: (prev: KeyCode, next: KeyCode, action: Action) => void, } const Keybinds: ParentComponent = (props) => { const KEYS = {...MAPPINGS, ...props.overrides}; // { event.code: label } const DISPLAY_LABELS = new Set(Object.values(KEYS)); // Set of valid labels only + const GLYPHS = {... DEFAULT_GLYPHS, ...props.glyphOverrides }; const [bindings, setBindings] = createStore({}); let defaults = props.defaults ?? undefined; @@ -55,6 +64,26 @@ const Keybinds: ParentComponent = (props) => { mapBindings({...bindings}) }, {defer: true})); + const sanitizeButtonInput = (input: string | number) => { + const num = Number(input); + if (!isNaN(num) || typeof input === 'number') { + if (0 <= num && num <= 16) return String(input); + else { + console.warn(`${input} is not a valid gamepad button. Please ensure the number is between 0 and 16 inclusive`); + return null + } + } + + const button = input.toLowerCase(); + if ((GamepadMappings.axisAliases as readonly string[]).includes(button)) return button as AxisInput; + + const key = GamepadMappings.aliases[button as keyof typeof GamepadMappings.aliases]; + if (key) return String(GamepadMappings[key]); + + console.warn(`${input} is not a valid gamepad button.`); + return null; + } + const verifyKey = (key: string | null) => { if (key === null) return null; if (DISPLAY_LABELS.has(key)) return key; @@ -78,14 +107,17 @@ const Keybinds: ParentComponent = (props) => { const prevKey = bindings[action]; if (prevKey === newKey) return false; + newKey = props.mode === 'gamepad' + ? sanitizeButtonInput(newKey as GamepadInput) + : verifyKey(newKey as BindingLabel); + const conflictAction = byKey.get(newKey); // no conflict -> just bind if (!conflictAction || conflictAction === action) { if (prevKey) byKey.delete(prevKey); - const verifiedKey = verifyKey(newKey); - setBindings(action, verifiedKey); - if (verifiedKey !== null) byKey.set(newKey, action); + setBindings(action, newKey); + if (newKey !== null) byKey.set(newKey, action); return true } @@ -175,7 +207,9 @@ const Keybinds: ParentComponent = (props) => { listeningText, placeholder, KEYS, - onChange: props.onChange + GLYPHS, + onChange: props.onChange, + mode: props.mode } return ( diff --git a/src/components/Basic/Keybinds/util/glyphs.ts b/src/components/Basic/Keybinds/util/glyphs.ts new file mode 100644 index 0000000..6071b51 --- /dev/null +++ b/src/components/Basic/Keybinds/util/glyphs.ts @@ -0,0 +1,53 @@ +import { Icon } from "@components/Media/Icon/Icon"; +import { Component, JSX } from "solid-js"; + +/** + * Maps standard Gamepad API button indices to Gameface-ui Icon components. + * [Reference](https://developer.mozilla.org/en-US/docs/Web/API/Gamepad_API/Using_the_Gamepad_API#button_layout) + */ +export const GLYPHS = { + // Face Buttons + '0': Icon.gamepad.xbox.a, + '1': Icon.gamepad.xbox.b, + '2': Icon.gamepad.xbox.x, + '3': Icon.gamepad.xbox.y, + + // Shoulder Buttons (Bumpers) + '4': Icon.gamepad.xbox.lb, + '5': Icon.gamepad.xbox.rb, + + // Triggers + '6': Icon.gamepad.xbox.lt, + '7': Icon.gamepad.xbox.rt, + + // Navigation / Center Buttons + '8': Icon.gamepad.xbox.view, // Often used as 'Back' or 'Select' + '9': Icon.gamepad.xbox.menu, // Often used as 'Start' + + // Stick Presses (L3 / R3) + '10': Icon.gamepad.xbox.leftStickPress, + '11': Icon.gamepad.xbox.rightStickPress, + + // D-Pad + '12': Icon.gamepad.xbox.dpadUp, + '13': Icon.gamepad.xbox.dpadDown, + '14': Icon.gamepad.xbox.dpadLeft, + '15': Icon.gamepad.xbox.dpadRight, + + // Additional Button (Xbox Guide/Share) + '16': Icon.gamepad.xbox.share, + + 'right.joystick': Icon.gamepad.xbox.rightStick, + 'left.joystick': Icon.gamepad.xbox.leftStick, + 'left.joystick.down': Icon.gamepad.xbox.leftStick, + 'left.joystick.up': Icon.gamepad.xbox.leftStick, + 'left.joystick.left': Icon.gamepad.xbox.leftStick, + 'left.joystick.right': Icon.gamepad.xbox.leftStick, + 'right.joystick.down': Icon.gamepad.xbox.rightStick, + 'right.joystick.up': Icon.gamepad.xbox.rightStick, + 'right.joystick.left': Icon.gamepad.xbox.rightStick, + 'right.joystick.right': Icon.gamepad.xbox.rightStick +}; + +export type GamepadBindingCode = keyof typeof GLYPHS; +export type GlyphOverrides = Partial | JSX.Element>>; \ No newline at end of file diff --git a/src/components/Media/Icon/Icon.tsx b/src/components/Media/Icon/Icon.tsx index 3e14b78..f707f60 100644 --- a/src/components/Media/Icon/Icon.tsx +++ b/src/components/Media/Icon/Icon.tsx @@ -30,7 +30,7 @@ const IconComponent = (src: string): Component => { {...props} use:forwardAttrs={props} src={src} - class={`${styles.icon} ${props.class || ''}`} + class={`${styles.icon} ${props?.class || ''}`} onError={() => setHasError(true)} /> )} diff --git a/src/components/Utility/Navigation/actionMethods/actionMethods.types.ts b/src/components/Utility/Navigation/actionMethods/actionMethods.types.ts index ab91f2e..88c5bff 100644 --- a/src/components/Utility/Navigation/actionMethods/actionMethods.types.ts +++ b/src/components/Utility/Navigation/actionMethods/actionMethods.types.ts @@ -83,6 +83,16 @@ export interface ActionMethods { */ resumeAction: (action: ActionName, force?: boolean) => void; + /** + * Snapshots current pause states and forcefully pauses all actions (ideal for stopping all action input). `resumeInput` must be used to unpause all actions. + */ + pauseInput: () => void; + + /** + * Releases the global pause and restores actions to their state prior to the pauseInput call. + */ + resumeInput: () => void; + /** * Checks if an action is currently paused * @param action - The name of the action to check diff --git a/src/components/Utility/Navigation/actionMethods/useActionMethods.ts b/src/components/Utility/Navigation/actionMethods/useActionMethods.ts index 3a2ce2a..0de38d2 100644 --- a/src/components/Utility/Navigation/actionMethods/useActionMethods.ts +++ b/src/components/Utility/Navigation/actionMethods/useActionMethods.ts @@ -13,6 +13,8 @@ export default function createActionMethods( const actionSubscribers = new Map(); // For force paused actions const forcePausedActions = new Set(); + // For remembering which already paused actions were affected in the pause input function + const inputCaptureSnapshot = new Set(); const registerAction = (actionName: ActionName) => { const {key, button, callback, global} = getAction(actionName)!; @@ -156,6 +158,35 @@ export default function createActionMethods( return getAction(name)?.paused ?? false; }; + const pauseInput = () => { + const allActions = getActions(); + inputCaptureSnapshot.clear(); + + for (const actionName in allActions) { + if (isPaused(actionName)) { + inputCaptureSnapshot.add(actionName); + } + + pauseAction(actionName, true); + } + }; + + const resumeInput = () => { + const allActions = getActions(); + + for (const actionName in allActions) { + if (inputCaptureSnapshot.has(actionName)) { + // remove the force flag only + forcePausedActions.delete(actionName); + continue; + } + + resumeAction(actionName, true); + } + + inputCaptureSnapshot.clear(); + }; + return { addAction, removeAction, @@ -168,6 +199,8 @@ export default function createActionMethods( getActions, pauseAction, resumeAction, + pauseInput, + resumeInput, isPaused, }; } diff --git a/src/components/Utility/Navigation/areaMethods/areaMethods.types.ts b/src/components/Utility/Navigation/areaMethods/areaMethods.types.ts index 35d5a59..a69275b 100644 --- a/src/components/Utility/Navigation/areaMethods/areaMethods.types.ts +++ b/src/components/Utility/Navigation/areaMethods/areaMethods.types.ts @@ -1,4 +1,4 @@ -import { KeyName } from "coherent-gameface-interaction-manager/dist/types/utils/keyboard-mappings"; +import { KeyName } from 'coherent-gameface-interaction-manager/dist/types/interaction-manager'; /** * Interface defining all area-related navigation methods diff --git a/src/components/Utility/Navigation/areaMethods/useAreaMethods.ts b/src/components/Utility/Navigation/areaMethods/useAreaMethods.ts index 2b51adc..bdd5536 100644 --- a/src/components/Utility/Navigation/areaMethods/useAreaMethods.ts +++ b/src/components/Utility/Navigation/areaMethods/useAreaMethods.ts @@ -3,7 +3,7 @@ import { SetStoreFunction } from 'solid-js/store'; import { NavigationConfigType } from '../types'; import { AreaMethods } from './areaMethods.types'; import { spatialNavigation } from 'coherent-gameface-interaction-manager'; -import { KeyName } from 'coherent-gameface-interaction-manager/dist/types/utils/keyboard-mappings'; +import { KeyName } from 'coherent-gameface-interaction-manager/dist/types/interaction-manager'; export default function createAreaMethods( areas: Set, diff --git a/src/components/Utility/Navigation/types.ts b/src/components/Utility/Navigation/types.ts index de54d06..4dd98b0 100644 --- a/src/components/Utility/Navigation/types.ts +++ b/src/components/Utility/Navigation/types.ts @@ -1,5 +1,4 @@ -import { KeyName } from 'coherent-gameface-interaction-manager/dist/types/utils/keyboard-mappings'; -import { GamepadInput } from 'coherent-gameface-interaction-manager/dist/types/utils/gamepad-mappings'; +import { KeyName, GamepadInput } from 'coherent-gameface-interaction-manager/dist/types/interaction-manager'; type ActionType = 'press' | 'hold' | 'lift'; diff --git a/tests/shared/keybinds/glyph-mappings.json b/tests/shared/keybinds/glyph-mappings.json new file mode 100644 index 0000000..db47644 --- /dev/null +++ b/tests/shared/keybinds/glyph-mappings.json @@ -0,0 +1,12 @@ +[ + { "action": "moveForward", "key": "12", "glyph": "dpadUp" }, + { "action": "moveBackward", "key": "13", "glyph": "dpadDown" }, + { "action": "moveLeft", "key": "14", "glyph": "dpadLeft" }, + { "action": "moveRight", "key": "15", "glyph": "dpadRight" }, + { "action": "jump", "key": "0", "glyph": "a" }, + { "action": "crouch", "key": "2", "glyph": "x" }, + { "action": "sprint", "key": "10", "glyph": "leftStickPress" }, + { "action": "fire", "key": "7", "glyph": "rt" }, + { "action": "aimDownSights","key": "6", "glyph": "lt" }, + { "action": "reload", "key": "1", "glyph": "b" } +] \ No newline at end of file diff --git a/tests/shared/keybinds/keybinds-selectors.json b/tests/shared/keybinds/keybinds-selectors.json index 02c7689..2e44f79 100644 --- a/tests/shared/keybinds/keybinds-selectors.json +++ b/tests/shared/keybinds/keybinds-selectors.json @@ -5,5 +5,6 @@ "keybind": "keybind", "listening-text": "Please type", "placeholder-text": "Unassigned", - "reactive": "reactive" + "reactive": "reactive", + "actionTest": "action-test-element" } \ No newline at end of file diff --git a/tests/specs/keybinds.spec.js b/tests/specs/keybinds.spec.js index 0743a4e..b9fedb1 100644 --- a/tests/specs/keybinds.spec.js +++ b/tests/specs/keybinds.spec.js @@ -2,6 +2,7 @@ const assert = require('assert'); const selectors = require('../shared/keybinds/keybinds-selectors.json'); const DEFAULT_MAPPINGS = require('../shared/keybinds/default-mappings.json'); const ALTERNATE_MAPPINGS = require('../shared/keybinds/alternate-mappings.json'); +const GAMEPAD_GLYPHS = require ("../shared/keybinds/glyph-mappings.json"); const OVERRIDES = require('../shared/keybinds/overrides.json'); const { navigateToPage } = require('../shared/utils'); @@ -256,4 +257,75 @@ describe('Keybinds', function () { assert.strictEqual(await keybinds[1].text(), keybind2InitialText, `Keybind should have its initial value`); }); }) + + describe('Keybinds - gamepad mode', function () { + this.beforeAll(async () => { + await gf.connectGamepad('gamepad') + await gf.click(`.${selectors.scenarioBtn}.scenario-12`) + }) + + async function getIconSource (keybind) { + const image = await keybind.find('img'); + return await image.getAttribute('src'); + } + + it('Should render default gamepad glyphs', async () => { + const container = await gf.getAll(`.${selectors.keybindsContainer}`); + const keybinds = await container[2].findAll(`.${selectors.keybind}`); + + let idx = 0; + for (const keybind of keybinds) { + const src = await getIconSource(keybind); + + assert.equal(src.includes(`/${GAMEPAD_GLYPHS[idx].glyph}`), true, 'Correct icon is displayed'); + idx++; + } + }); + + it('Should rebind a gamepad input', async () => { + const container = await gf.getAll(`.${selectors.keybindsContainer}`); + const keybind = await container[2].find(`.${selectors.keybind}`); + const gamepad = gf.getGamepad('gamepad') + + await gamepad.sequence([ + gf.GAMEPAD_BUTTONS.FACE_BUTTON_DOWN, + gf.GAMEPAD_BUTTONS.FACE_BUTTON_RIGHT, + ]); + + const src = await getIconSource(keybind); + assert.equal(src.includes(`/b`), true, 'Correct icon is displayed'); + }) + + it('Should prevent execution of any actions while listening for input', async () => { + const assertionEl = await gf.get(`.${selectors.actionTest}`) + const gamepad = gf.getGamepad('gamepad') + + await gamepad.sequence([ + gf.GAMEPAD_BUTTONS.FACE_BUTTON_DOWN, + gf.GAMEPAD_BUTTONS.RIGHT_SHOULDER, + ]); + + assert.equal(await assertionEl.text(), 'test', 'Element\'s text should remain unchanged after action is bound') + }) + + it('Should render custom glyphs', async () => { + const container = await gf.getAll(`.${selectors.keybindsContainer}`); + const keybinds = await container[3].findAll(`.${selectors.keybind}`); + + const NEW_GLYPHS_TEXT = ['up', 'down', 'left', 'right']; + let idx = 0; + + for (const keybind of keybinds) { + if (idx === NEW_GLYPHS_TEXT.length) break; + + const child = await keybind.find('div'); + assert.equal(await child.text(), NEW_GLYPHS_TEXT[idx], 'Glyph text content should match structure.'); + if (idx === NEW_GLYPHS_TEXT.length - 1) { + const styles = await child.styles(); + assert.equal(styles['background-color'], 'rgba(0, 0, 255, 1)', 'Styles of glyphs changed'); + } + idx++; + } + }) + }) }); \ No newline at end of file diff --git a/tests/src/components/Keybinds/KeybindsTest.tsx b/tests/src/components/Keybinds/KeybindsTest.tsx index 173d5d5..03b5f4f 100644 --- a/tests/src/components/Keybinds/KeybindsTest.tsx +++ b/tests/src/components/Keybinds/KeybindsTest.tsx @@ -1,22 +1,26 @@ import Tab from "@components/Layout/Tab/Tab"; -import { createMemo, createSignal, For, Match, onCleanup, onMount, Switch } from "solid-js"; +import { createMemo, createSignal, For, onCleanup, onMount } from "solid-js"; import selectors from "../../../shared/keybinds/keybinds-selectors.json"; import Keybinds, { KeybindsRef } from "@components/Basic/Keybinds/Keybinds"; import Keybind from "@components/Basic/Keybinds/Keybind"; import TEST_KEYS from '../../../shared/keybinds/default-mappings.json'; import ALTERNATE_KEYS from "../../../shared/keybinds/alternate-mappings.json"; +import GAMEPAD_GLYPHS from "../../../shared/keybinds/glyph-mappings.json"; import OVERRIDES from "../../../shared/keybinds/overrides.json"; import Flex from "@components/Layout/Flex/Flex"; +import Navigation, { NavigationRef } from "@components/Utility/Navigation/Navigation"; const KeybindsTest = () => { let keybindsRef!: KeybindsRef; let keybindsRefWithDefault!: KeybindsRef; + let nav!: NavigationRef; const [test, setTest] = createSignal('red'); const [lastChanged, setLastChanged] = createSignal({ prev: "", next: "", action: ""}); const [hasConflict, setHasConflict] = createSignal(false); const [policy, setPolicy] = createSignal<'block' | 'replace-existing' | 'allow-duplicates' | 'swap'>('block') const [listeningText, setListeningText] = createSignal(undefined) const [placeholder, setPlaceholder] = createSignal('Unbound') + const [actionTest, setActionTest] = createSignal('test'); const scenarios = [ { label: "Change policy - replace-existing", action: () => { setPolicy('replace-existing') } }, @@ -31,6 +35,7 @@ const KeybindsTest = () => { { label: "Change placeholder text", action: () => { setPlaceholder(selectors["placeholder-text"]) } }, { label: "Change styles", action: () => { setTest('blue') } }, { label: "Reset defaults - alternate", action: () => { keybindsRefWithDefault.reset() } }, + { label: "Focus keybind nav area", action: () => { nav.focusFirst('keybinds') } }, ]; const reset = () => { @@ -43,6 +48,8 @@ const KeybindsTest = () => { keybindsRef?.reset(); }; + const testComponent = () =>
down
; + const isReactive = createMemo(() => test() === 'blue'); const reactiveClass = createMemo(() => isReactive() ? 'reactive' : ''); const reactiveStyle = createMemo(() => isReactive() ? { 'background-color': 'blue' } : {}); @@ -94,6 +101,37 @@ const KeybindsTest = () => { + + {/* Gamepad support */} + setActionTest('changed')} }}> + + + + + {(entry) => } + + + +
{actionTest()}
+
+ + up, + '13': testComponent, + '14': () =>
left
, + '15': () =>
right
+ }}> + + {(entry) => } + +
+
+
) }