From 4f31ee04f849b2686bfac86e6f4cd039876b7637 Mon Sep 17 00:00:00 2001 From: Osei Fortune Date: Mon, 28 Sep 2026 15:08:54 -0400 Subject: [PATCH] docs: add @nativescript/canvas-gamepad Add a Gamepad API page with a live controller tester that runs on the browser's own Gamepad API. The tester source is embedded in the page, so the code shown is the code running. Covers usage with and without the polyfill, the standard mapping, dead zones, press detection, platform notes and differences from browsers. Link the package from the sidebar, packages index, ecosystem table, polyfill globals table and the Events and Input page. --- .vitepress/config.ts | 1 + .../theme/components/GamepadLiveDemo.vue | 166 ++++++++++++ .vitepress/theme/demos/gamepad-tester.ts | 184 +++++++++++++ .vitepress/theme/index.ts | 2 + content/canvas/ecosystem.md | 1 + content/canvas/events.md | 6 +- content/plugins/canvas-gamepad.md | 252 ++++++++++++++++++ content/plugins/canvas-polyfill.md | 1 + content/plugins/index.md | 1 + 9 files changed, 613 insertions(+), 1 deletion(-) create mode 100644 .vitepress/theme/components/GamepadLiveDemo.vue create mode 100644 .vitepress/theme/demos/gamepad-tester.ts create mode 100644 content/plugins/canvas-gamepad.md 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 @@ + + + + + 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