diff --git a/.vitepress/config.ts b/.vitepress/config.ts index d4e2921..f381039 100644 --- a/.vitepress/config.ts +++ b/.vitepress/config.ts @@ -66,6 +66,7 @@ export default defineConfig({ { text: '@nativescript/audio-context', link: '/audio-context/' }, { text: '@nativescript/canvas-polyfill', link: '/plugins/canvas-polyfill' }, { text: '@nativescript/canvas-media', link: '/plugins/canvas-media' }, + { text: '@nativescript/canvas-gamepad', link: '/plugins/canvas-gamepad' }, { text: 'Framework Adapters', link: '/plugins/adapters' }, ], }, diff --git a/.vitepress/theme/components/GamepadLiveDemo.vue b/.vitepress/theme/components/GamepadLiveDemo.vue new file mode 100644 index 0000000..600517f --- /dev/null +++ b/.vitepress/theme/components/GamepadLiveDemo.vue @@ -0,0 +1,166 @@ + + + + + + {{ props.title }} + + {{ isVisible ? 'Hide demo' : 'Show demo' }} + + + + + {{ error }} + + + {{ status }} + + + + + Runs in this page with the browser's own Gamepad API. Connect a controller, then click Show demo. + + + + + diff --git a/.vitepress/theme/demos/gamepad-tester.ts b/.vitepress/theme/demos/gamepad-tester.ts new file mode 100644 index 0000000..3685807 --- /dev/null +++ b/.vitepress/theme/demos/gamepad-tester.ts @@ -0,0 +1,184 @@ +// Uses only web APIs, so it runs unchanged in a browser and in NativeScript +// with @nativescript/canvas-polyfill and @nativescript/canvas-gamepad installed. + +const WIDTH = 480; +const HEIGHT = 270; + +const BACKGROUND = '#0f141c'; +const IDLE = '#273142'; +const OUTLINE = '#4b5563'; +const TEXT = '#e5e7eb'; +const MUTED = '#94a3b8'; +const ACTIVE = '#f75930'; + +export function startGamepadTester(canvas: HTMLCanvasElement): () => void { + const ctx = canvas.getContext('2d')!; + let frame = 0; + + const onConnected = (e: GamepadEvent) => console.log(`gamepad ${e.gamepad.index} connected: ${e.gamepad.id}`); + const onDisconnected = (e: GamepadEvent) => console.log(`gamepad ${e.gamepad.index} disconnected`); + window.addEventListener('gamepadconnected', onConnected); + window.addEventListener('gamepaddisconnected', onDisconnected); + + const draw = () => { + // Fit a 480x270 layout into whatever size the canvas has. + const scale = Math.min(canvas.width / WIDTH, canvas.height / HEIGHT); + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.fillStyle = BACKGROUND; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.setTransform(scale, 0, 0, scale, (canvas.width - WIDTH * scale) / 2, (canvas.height - HEIGHT * scale) / 2); + + // Poll every frame: this is how the Gamepad API is meant to be read. + const pads = navigator.getGamepads().filter((pad): pad is Gamepad => pad !== null && pad.connected); + if (pads.length > 0) { + drawPad(ctx, pads[0], pads.length - 1); + } else { + drawWaiting(ctx); + } + + frame = requestAnimationFrame(draw); + }; + frame = requestAnimationFrame(draw); + + return () => { + cancelAnimationFrame(frame); + window.removeEventListener('gamepadconnected', onConnected); + window.removeEventListener('gamepaddisconnected', onDisconnected); + }; +} + +function drawWaiting(ctx: CanvasRenderingContext2D) { + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = TEXT; + ctx.font = 'bold 16px sans-serif'; + ctx.fillText('Connect a controller and press any button', WIDTH / 2, HEIGHT / 2 - 10); + ctx.fillStyle = MUTED; + ctx.font = '12px sans-serif'; + ctx.fillText('navigator.getGamepads() has no connected pads yet', WIDTH / 2, HEIGHT / 2 + 16); +} + +function drawPad(ctx: CanvasRenderingContext2D, pad: Gamepad, others: number) { + const b = pad.buttons; + + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = TEXT; + ctx.font = 'bold 12px sans-serif'; + ctx.fillText(pad.id.length > 60 ? pad.id.slice(0, 59) + '…' : pad.id, 16, 18); + + // Standard mapping: https://w3c.github.io/gamepad/#remapping + trigger(ctx, b[6], 40, 40, 'LT'); + trigger(ctx, b[7], 320, 40, 'RT'); + shoulder(ctx, b[4], 40, 62, 'LB'); + shoulder(ctx, b[5], 320, 62, 'RB'); + + stick(ctx, pad.axes[0], pad.axes[1], b[10], 110, 140, 'L'); + stick(ctx, pad.axes[2], pad.axes[3], b[11], 300, 188, 'R'); + + dpad(ctx, b[12], b[13], b[14], b[15], 180, 192); + + button(ctx, b[3], 370, 110, 'Y'); + button(ctx, b[2], 345, 135, 'X'); + button(ctx, b[1], 395, 135, 'B'); + button(ctx, b[0], 370, 160, 'A'); + + pill(ctx, b[8], 205, 110, 'Select'); + pill(ctx, b[9], 275, 110, 'Start'); + if (b.length > 16) { + button(ctx, b[16], 240, 145, 'Home'); + } + + ctx.textAlign = 'left'; + ctx.fillStyle = MUTED; + ctx.font = '11px sans-serif'; + const summary = `index ${pad.index} · mapping "${pad.mapping}" · ${pad.axes.length} axes · ${b.length} buttons`; + ctx.fillText(others > 0 ? `${summary} · ${others} more connected` : summary, 16, HEIGHT - 14); +} + +function trigger(ctx: CanvasRenderingContext2D, input: GamepadButton | undefined, x: number, y: number, label: string) { + const value = input?.value ?? 0; + ctx.fillStyle = IDLE; + ctx.fillRect(x, y - 7, 100, 14); + ctx.fillStyle = ACTIVE; + ctx.fillRect(x, y - 7, 100 * value, 14); + ctx.textAlign = 'left'; + ctx.fillStyle = TEXT; + ctx.font = '11px sans-serif'; + ctx.fillText(`${label} ${value.toFixed(2)}`, x + 106, y); +} + +function shoulder(ctx: CanvasRenderingContext2D, input: GamepadButton | undefined, x: number, y: number, label: string) { + ctx.fillStyle = input?.pressed ? ACTIVE : IDLE; + ctx.fillRect(x, y - 8, 100, 16); + ctx.textAlign = 'center'; + ctx.fillStyle = TEXT; + ctx.font = '11px sans-serif'; + ctx.fillText(label, x + 50, y); +} + +function stick(ctx: CanvasRenderingContext2D, ax = 0, ay = 0, press: GamepadButton | undefined, x: number, y: number, label: string) { + const radius = 34; + ctx.fillStyle = IDLE; + ctx.beginPath(); + ctx.arc(x, y, radius, 0, Math.PI * 2); + ctx.fill(); + ctx.lineWidth = 2; + ctx.strokeStyle = press?.pressed ? ACTIVE : OUTLINE; + ctx.stroke(); + + // Axes run from -1 to 1, with +y pointing down. + ctx.fillStyle = ACTIVE; + ctx.beginPath(); + ctx.arc(x + ax * (radius - 8), y + ay * (radius - 8), 8, 0, Math.PI * 2); + ctx.fill(); + + ctx.textAlign = 'center'; + ctx.fillStyle = MUTED; + ctx.font = '10px sans-serif'; + ctx.fillText(`${label} ${ax.toFixed(2)}, ${ay.toFixed(2)}`, x, y + radius + 12); +} + +function dpad( + ctx: CanvasRenderingContext2D, + up: GamepadButton | undefined, + down: GamepadButton | undefined, + left: GamepadButton | undefined, + right: GamepadButton | undefined, + x: number, + y: number, +) { + const size = 18; + const cells: [GamepadButton | undefined, number, number][] = [ + [up, 0, -1], + [down, 0, 1], + [left, -1, 0], + [right, 1, 0], + ]; + for (const [input, dx, dy] of cells) { + ctx.fillStyle = input?.pressed ? ACTIVE : IDLE; + ctx.fillRect(x + dx * size - size / 2, y + dy * size - size / 2, size, size); + } + ctx.fillStyle = IDLE; + ctx.fillRect(x - size / 2, y - size / 2, size, size); +} + +function button(ctx: CanvasRenderingContext2D, input: GamepadButton | undefined, x: number, y: number, label: string) { + ctx.fillStyle = input?.pressed ? ACTIVE : IDLE; + ctx.beginPath(); + ctx.arc(x, y, label.length > 1 ? 16 : 12, 0, Math.PI * 2); + ctx.fill(); + ctx.textAlign = 'center'; + ctx.fillStyle = TEXT; + ctx.font = label.length > 1 ? '10px sans-serif' : 'bold 12px sans-serif'; + ctx.fillText(label, x, y); +} + +function pill(ctx: CanvasRenderingContext2D, input: GamepadButton | undefined, x: number, y: number, label: string) { + ctx.fillStyle = input?.pressed ? ACTIVE : IDLE; + ctx.fillRect(x - 24, y - 8, 48, 16); + ctx.textAlign = 'center'; + ctx.fillStyle = TEXT; + ctx.font = '10px sans-serif'; + ctx.fillText(label, x, y); +} diff --git a/.vitepress/theme/index.ts b/.vitepress/theme/index.ts index 44cf6ef..bf3cd46 100644 --- a/.vitepress/theme/index.ts +++ b/.vitepress/theme/index.ts @@ -15,6 +15,7 @@ import CanvasDocsHome from './components/CanvasDocsHome.vue'; import CanvasHeroMiniDemo from './components/CanvasHeroMiniDemo.vue'; import AudioDocsHome from './components/AudioDocsHome.vue'; import CanvasPlayground from './components/CanvasPlayground.vue'; +import GamepadLiveDemo from './components/GamepadLiveDemo.vue'; import NativeScriptNavTitle from './components/NativeScriptNavTitle.vue'; import NativeScriptFooter from './components/NativeScriptFooter.vue'; @@ -41,5 +42,6 @@ export default { app.component('CanvasHeroMiniDemo', CanvasHeroMiniDemo); app.component('AudioDocsHome', AudioDocsHome); app.component('CanvasPlayground', CanvasPlayground); + app.component('GamepadLiveDemo', GamepadLiveDemo); }, }; diff --git a/content/canvas/ecosystem.md b/content/canvas/ecosystem.md index c4c083f..839177b 100644 --- a/content/canvas/ecosystem.md +++ b/content/canvas/ecosystem.md @@ -13,6 +13,7 @@ The NativeScript canvas repository publishes more than the base Canvas package. | [`@nativescript/canvas-polyfill`](/plugins/canvas-polyfill) | `window`, `document`, `Image`, `navigator.gpu` and other browser globals | | [`@nativescript/canvas-media`](/plugins/canvas-media) | `Video` and `Audio` views that also act as frame sources | | [`@nativescript/audio-context`](/audio-context/) | The Web Audio API | +| [`@nativescript/canvas-gamepad`](/plugins/canvas-gamepad) | The Gamepad API: `navigator.getGamepads()` and connection events | | [Framework adapters](/plugins/adapters) | Three.js, Pixi, Chart.js, Phaser, Phaser CE and Babylon.js | ## Upstream source diff --git a/content/canvas/events.md b/content/canvas/events.md index 509f0be..34d8593 100644 --- a/content/canvas/events.md +++ b/content/canvas/events.md @@ -1,6 +1,6 @@ --- title: Events and Input -description: Canvas lifecycle events, pointer and touch input, Android surface events and tvOS remote keys. +description: Canvas lifecycle events, pointer and touch input, Android surface events, tvOS remote keys and game controllers. --- # Events and Input @@ -64,3 +64,7 @@ canvas.addEventListener('keydown', (e) => { ``` When the canvas is attached, it takes focus. Presses still continue up the responder chain afterwards, so the system behaviour stays intact: for example, Menu still returns to the home screen. + +## Game controllers + +For full controller state (both sticks, analog triggers and every button) on iOS, tvOS, Android and Windows, use the web Gamepad API through [`@nativescript/canvas-gamepad`](/plugins/canvas-gamepad). diff --git a/content/plugins/canvas-gamepad.md b/content/plugins/canvas-gamepad.md new file mode 100644 index 0000000..a2addfc --- /dev/null +++ b/content/plugins/canvas-gamepad.md @@ -0,0 +1,252 @@ +--- +title: '@nativescript/canvas-gamepad' +description: The web Gamepad API (navigator.getGamepads, gamepadconnected and gamepaddisconnected) for game controllers on iOS, Android and Windows. +--- + +# @nativescript/canvas-gamepad + +Game controller input through the web [Gamepad API](https://developer.mozilla.org/en-US/docs/Web/API/Gamepad_API): `navigator.getGamepads()`, plus the `gamepadconnected` and `gamepaddisconnected` events. Code written for the browser runs without changes. + +```bash +npm install @nativescript/canvas-gamepad @nativescript/canvas-polyfill +``` + +Import the polyfill once, before anything that reads input: + +```ts +// app.ts +import '@nativescript/canvas-polyfill'; +``` + +When `@nativescript/canvas-gamepad` is installed, the [polyfill](/plugins/canvas-polyfill) backs `navigator.getGamepads()` and the window gamepad events with it. Without the package, `navigator.getGamepads()` returns an empty array. + +## Live demo + +The demo below runs in this page using your browser's Gamepad API. The same file runs unchanged in a NativeScript app. + + + +To run it on a device, put the file next to your page and start it when the canvas is ready: + +```xml + +``` + +```ts +import '@nativescript/canvas-polyfill'; +import { startGamepadTester } from './gamepad-tester'; + +let stop: () => void; + +export function canvasReady(args) { + stop = startGamepadTester(args.object); +} + +export function canvasUnloaded() { + stop?.(); +} +``` + +::: details gamepad-tester.ts +<<< ../../.vitepress/theme/demos/gamepad-tester.ts +::: + +## Reading input + +The Gamepad API is polled, not event driven. Read `navigator.getGamepads()` once per frame, usually from `requestAnimationFrame`: + +```ts +function frame() { + const pad = navigator.getGamepads()[0]; + if (pad) { + player.x += pad.axes[0] * speed; + player.y += pad.axes[1] * speed; + if (pad.buttons[0].pressed) player.jump(); + } + requestAnimationFrame(frame); +} +requestAnimationFrame(frame); +``` + +The array always has four slots. An empty slot is `null`, and a controller keeps its `index` for as long as it stays connected. + +### Standard mapping + +Every controller is reported with `mapping === 'standard'`, so button and axis indices mean the same thing on every platform and in every browser: + +| Index | Button | | Index | Button | +| --- | --- | --- | --- | --- | +| 0 | A (bottom face) | | 9 | Start / Menu | +| 1 | B (right face) | | 10 | Left stick press | +| 2 | X (left face) | | 11 | Right stick press | +| 3 | Y (top face) | | 12 | D-pad up | +| 4 | Left bumper | | 13 | D-pad down | +| 5 | Right bumper | | 14 | D-pad left | +| 6 | Left trigger | | 15 | D-pad right | +| 7 | Right trigger | | 16 | Home / Guide (not on Windows) | +| 8 | Select / Back / Options | | | | + +| Axis | Value | +| --- | --- | +| 0, 1 | Left stick x and y | +| 2, 3 | Right stick x and y | + +Axes range from `-1` to `1`, with positive y pointing **down**. Triggers are buttons with an analog `value` from `0` to `1`. Each button has `pressed`, `touched` and `value`. + +### Dead zones + +Axis values are passed through raw, as in browsers. A stick at rest rarely reports exactly `0`, so apply your own dead zone: + +```ts +function deadZone(value: number, threshold = 0.15) { + return Math.abs(value) < threshold ? 0 : value; +} + +const x = deadZone(pad.axes[0]); +const y = deadZone(pad.axes[1]); +``` + +### A press, not a hold + +`buttons[i].pressed` is true for as long as the button is held. To act once per press, compare against the previous frame. `Gamepad` objects are updated in place (like Firefox, and unlike Chrome's snapshots), so keep plain booleans instead of the old objects: + +```ts +const previous: boolean[][] = []; + +function justPressed(pad: Gamepad, button: number) { + const last = (previous[pad.index] ??= []); + const now = pad.buttons[button].pressed; + const result = now && !last[button]; + last[button] = now; + return result; +} + +if (justPressed(pad, 9)) togglePause(); +``` + +This pattern works in every browser and on every platform. + +## Connection events + +```ts +window.addEventListener('gamepadconnected', (e) => { + console.log(`controller ${e.gamepad.index} connected: ${e.gamepad.id}`); +}); + +window.addEventListener('gamepaddisconnected', (e) => { + console.log(`controller ${e.gamepad.index} disconnected`); +}); +``` + +Use the events as notifications, for example to show a "controller connected" message or to pause when a controller drops. Use `navigator.getGamepads()` as the source of truth. Monitoring starts with the first gamepad listener or `getGamepads()` call, and at that point every controller that is already connected is reported. As in browsers, each connection is announced once: a listener added later is not told about controllers that are already connected. + +## Without the polyfill + +If you don't want `window`, `document` and the other browser globals, import the package directly. It has the same `Gamepad` objects, just not hung off `navigator`: + +| With the polyfill | Without | +| --- | --- | +| `navigator.getGamepads()` | `getGamepads()` | +| `window.addEventListener('gamepadconnected', fn)` | `gamepads.addListener(fn)`, then check `e.type` | +| `window.removeEventListener(...)` | `gamepads.removeListener(fn)` | + +Native monitoring starts on the first `getGamepads()` call or `addListener()`, not on import. + +### Example: move a dot with the left stick + +This uses only `@nativescript/core` and `@nativescript/canvas`. The left stick moves the dot, A changes its colour, and the connection listener updates a label: + +```xml + + + + + + +``` + +```ts +import type { EventData, Label } from '@nativescript/core'; +import type { Canvas } from '@nativescript/canvas'; +import { gamepads, getGamepads, type GamepadEvent } from '@nativescript/canvas-gamepad'; + +const COLORS = ['#f75930', '#22c55e', '#3b82f6', '#eab308']; + +let frame = 0; +let status: Label; + +function deadZone(value: number, threshold = 0.15) { + return Math.abs(value) < threshold ? 0 : value; +} + +function onConnection(e: GamepadEvent) { + const connected = getGamepads().filter((pad) => pad !== null).length; + status.text = e.type === 'gamepadconnected' + ? `${e.gamepad.id} connected` + : connected > 0 ? `${connected} controller(s) connected` : 'Connect a controller'; +} + +export function canvasReady(args: EventData) { + const canvas = args.object as Canvas; + status = canvas.page.getViewById('status'); + const ctx = canvas.getContext('2d') as CanvasRenderingContext2D; + + const dot = { x: canvas.width / 2, y: canvas.height / 2, color: 0 }; + let wasPressed = false; + + gamepads.addListener(onConnection); + + const draw = () => { + const pad = getGamepads().find((p) => p !== null); + + if (pad) { + const speed = canvas.width / 100; + dot.x = Math.min(canvas.width, Math.max(0, dot.x + deadZone(pad.axes[0]) * speed)); + dot.y = Math.min(canvas.height, Math.max(0, dot.y + deadZone(pad.axes[1]) * speed)); + + // Act on the press, not on every frame the button is held. + const pressed = pad.buttons[0].pressed; + if (pressed && !wasPressed) { + dot.color = (dot.color + 1) % COLORS.length; + } + wasPressed = pressed; + } + + ctx.fillStyle = '#0f141c'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.fillStyle = COLORS[dot.color]; + ctx.beginPath(); + ctx.arc(dot.x, dot.y, canvas.width / 20, 0, Math.PI * 2); + ctx.fill(); + + frame = requestAnimationFrame(draw); + }; + frame = requestAnimationFrame(draw); +} + +export function onUnloaded() { + cancelAnimationFrame(frame); + gamepads.removeListener(onConnection); +} +``` + +`requestAnimationFrame` here is the global from `@nativescript/core`, so the loop needs nothing from the polyfill. + +## Platform notes + +| Platform | Source | Notes | +| --- | --- | --- | +| iOS, tvOS, visionOS | GameController (`GCExtendedGamepad`) | The package sets `valueChangedHandler` on each controller, replacing any handler your own code set. | +| Android | Gamepad and joystick `KeyEvent` / `MotionEvent` | Input from connected controllers is consumed at the activity window, so B no longer triggers Back while the API is in use. | +| Windows | `Windows.Gaming.Input.Gamepad` | 16 buttons: there is no Guide button. | + +On tvOS the Siri Remote is not a gamepad. It keeps sending `keydown` and `keyup` events, as described in [Events and Input](/canvas/events#keyboard-and-tvos-remote). + +## Differences from browsers + +- **No button press required.** Browsers hide controllers until one is used on the page. Here a connected controller is reported immediately. +- **Live objects.** Each slot holds the same `Gamepad` object between calls, updated in place. Copy `axes` or `buttons` if you need to compare frames. +- **`timestamp`** is the `performance.now()` value at the first `getGamepads()` call after the controller's state changed. +- **No rumble.** `vibrationActuator` is `null` and `hapticActuators` is empty. +- **Up to four controllers**, all using the standard mapping. +- **`id` format** differs by platform, for example `Xbox Wireless Controller (STANDARD GAMEPAD)` on iOS. Show it to users, but don't parse it. diff --git a/content/plugins/canvas-polyfill.md b/content/plugins/canvas-polyfill.md index 9eb6be9..70ead64 100644 --- a/content/plugins/canvas-polyfill.md +++ b/content/plugins/canvas-polyfill.md @@ -32,6 +32,7 @@ The [framework adapters](/plugins/adapters) (Three.js, Pixi, Phaser, Babylon) im | Data | `XMLHttpRequest` (supports `file://`, `~/`, `blob:` and `data:` URLs), `Blob`, `FileReader`, `URL.createObjectURL`, `TextEncoder`, `TextDecoder`, `AbortController` | | Media | `VideoFrame`, `VideoColorSpace` | | Audio | If [`@nativescript/audio-context`](/audio-context/) is installed: `AudioContext`, `OfflineAudioContext` and every node type | +| Gamepad | If [`@nativescript/canvas-gamepad`](/plugins/canvas-gamepad) is installed: `navigator.getGamepads()` and the `gamepadconnected` / `gamepaddisconnected` window events | `fetch` is not polyfilled here. It comes from `@nativescript/core`. diff --git a/content/plugins/index.md b/content/plugins/index.md index 7dc7f86..0b4b1af 100644 --- a/content/plugins/index.md +++ b/content/plugins/index.md @@ -12,6 +12,7 @@ These packages are published from the NativeScript/canvas monorepo. The 3.x pack - [@nativescript/canvas-polyfill](/plugins/canvas-polyfill): browser globals for web graphics code - [@nativescript/canvas-media](/plugins/canvas-media): video and audio views that also act as frame sources +- [@nativescript/canvas-gamepad](/plugins/canvas-gamepad): the Gamepad API for game controllers ## Framework adapters
{{ error }}
{{ status }}
+ Runs in this page with the browser's own Gamepad API. Connect a controller, then click Show demo. +