Skip to content

Commit 4f31ee0

Browse files
committed
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.
1 parent 3622af5 commit 4f31ee0

9 files changed

Lines changed: 613 additions & 1 deletion

File tree

‎.vitepress/config.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ export default defineConfig({
6666
{ text: '@nativescript/audio-context', link: '/audio-context/' },
6767
{ text: '@nativescript/canvas-polyfill', link: '/plugins/canvas-polyfill' },
6868
{ text: '@nativescript/canvas-media', link: '/plugins/canvas-media' },
69+
{ text: '@nativescript/canvas-gamepad', link: '/plugins/canvas-gamepad' },
6970
{ text: 'Framework Adapters', link: '/plugins/adapters' },
7071
],
7172
},
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
<script setup lang="ts">
2+
import { nextTick, onBeforeUnmount, ref } from 'vue';
3+
import { startGamepadTester } from '../demos/gamepad-tester';
4+
5+
const props = withDefaults(
6+
defineProps<{
7+
title?: string;
8+
}>(),
9+
{
10+
title: 'Gamepad tester',
11+
}
12+
);
13+
14+
const canvasEl = ref<HTMLCanvasElement | null>(null);
15+
const isVisible = ref(false);
16+
const status = ref('');
17+
const error = ref('');
18+
19+
let stopTester: (() => void) | null = null;
20+
let resizeObserver: ResizeObserver | null = null;
21+
22+
function updateStatus() {
23+
const pads = navigator.getGamepads().filter((pad) => pad !== null && pad.connected);
24+
status.value = pads.length
25+
? `${pads.length} controller${pads.length > 1 ? 's' : ''} connected.`
26+
: 'No controller yet. Connect one and press a button: browsers only expose a pad after it is used on the page.';
27+
}
28+
29+
function fitCanvas() {
30+
const canvas = canvasEl.value;
31+
if (!canvas) {
32+
return;
33+
}
34+
35+
const dpr = window.devicePixelRatio || 1;
36+
const width = canvas.clientWidth;
37+
canvas.width = Math.round(width * dpr);
38+
canvas.height = Math.round(((width * 9) / 16) * dpr);
39+
}
40+
41+
function stop() {
42+
stopTester?.();
43+
stopTester = null;
44+
resizeObserver?.disconnect();
45+
resizeObserver = null;
46+
window.removeEventListener('gamepadconnected', updateStatus);
47+
window.removeEventListener('gamepaddisconnected', updateStatus);
48+
}
49+
50+
async function toggleDemo() {
51+
isVisible.value = !isVisible.value;
52+
53+
if (!isVisible.value) {
54+
stop();
55+
return;
56+
}
57+
58+
if (typeof navigator.getGamepads !== 'function') {
59+
error.value = 'This browser does not support the Gamepad API.';
60+
return;
61+
}
62+
63+
error.value = '';
64+
await nextTick();
65+
66+
const canvas = canvasEl.value;
67+
if (!canvas) {
68+
return;
69+
}
70+
71+
fitCanvas();
72+
resizeObserver = new ResizeObserver(fitCanvas);
73+
resizeObserver.observe(canvas);
74+
75+
window.addEventListener('gamepadconnected', updateStatus);
76+
window.addEventListener('gamepaddisconnected', updateStatus);
77+
updateStatus();
78+
79+
stopTester = startGamepadTester(canvas);
80+
}
81+
82+
onBeforeUnmount(stop);
83+
</script>
84+
85+
<template>
86+
<div class="gamepad-demo">
87+
<div class="gamepad-demo__header">
88+
<strong>{{ props.title }}</strong>
89+
<button type="button" class="gamepad-demo__button" @click="toggleDemo">
90+
{{ isVisible ? 'Hide demo' : 'Show demo' }}
91+
</button>
92+
</div>
93+
94+
<template v-if="isVisible">
95+
<p v-if="error" class="gamepad-demo__error">{{ error }}</p>
96+
<template v-else>
97+
<canvas ref="canvasEl" class="gamepad-demo__canvas" aria-label="Live gamepad tester" />
98+
<p class="gamepad-demo__caption">{{ status }}</p>
99+
</template>
100+
</template>
101+
102+
<p v-else class="gamepad-demo__caption gamepad-demo__caption--hidden">
103+
Runs in this page with the browser's own Gamepad API. Connect a controller, then click Show demo.
104+
</p>
105+
</div>
106+
</template>
107+
108+
<style scoped>
109+
.gamepad-demo {
110+
margin: 0.9rem 0 1.1rem;
111+
border: 1px solid var(--vp-c-divider);
112+
border-radius: 14px;
113+
padding: 0.8rem;
114+
background: var(--vp-c-bg-soft);
115+
}
116+
117+
.gamepad-demo__header {
118+
display: flex;
119+
align-items: center;
120+
justify-content: space-between;
121+
gap: 0.75rem;
122+
margin-bottom: 0.65rem;
123+
color: var(--vp-c-text-1);
124+
}
125+
126+
.gamepad-demo__button {
127+
border: 1px solid var(--vp-c-divider);
128+
background: var(--vp-c-bg);
129+
color: var(--vp-c-text-1);
130+
border-radius: 8px;
131+
padding: 0.34rem 0.58rem;
132+
font-size: 0.82rem;
133+
cursor: pointer;
134+
}
135+
136+
.gamepad-demo__button:hover {
137+
border-color: var(--vp-c-brand-1);
138+
color: var(--vp-c-brand-1);
139+
}
140+
141+
.gamepad-demo__canvas {
142+
display: block;
143+
width: 100%;
144+
aspect-ratio: 16 / 9;
145+
border: 1px solid var(--vp-c-divider);
146+
border-radius: 10px;
147+
background: #0f141c;
148+
}
149+
150+
.gamepad-demo__caption {
151+
margin-top: 0.5rem;
152+
margin-bottom: 0;
153+
font-size: 0.82rem;
154+
color: var(--vp-c-text-2);
155+
}
156+
157+
.gamepad-demo__caption--hidden {
158+
margin-top: 0;
159+
}
160+
161+
.gamepad-demo__error {
162+
margin: 0;
163+
font-size: 0.82rem;
164+
color: #dc2626;
165+
}
166+
</style>
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
// Uses only web APIs, so it runs unchanged in a browser and in NativeScript
2+
// with @nativescript/canvas-polyfill and @nativescript/canvas-gamepad installed.
3+
4+
const WIDTH = 480;
5+
const HEIGHT = 270;
6+
7+
const BACKGROUND = '#0f141c';
8+
const IDLE = '#273142';
9+
const OUTLINE = '#4b5563';
10+
const TEXT = '#e5e7eb';
11+
const MUTED = '#94a3b8';
12+
const ACTIVE = '#f75930';
13+
14+
export function startGamepadTester(canvas: HTMLCanvasElement): () => void {
15+
const ctx = canvas.getContext('2d')!;
16+
let frame = 0;
17+
18+
const onConnected = (e: GamepadEvent) => console.log(`gamepad ${e.gamepad.index} connected: ${e.gamepad.id}`);
19+
const onDisconnected = (e: GamepadEvent) => console.log(`gamepad ${e.gamepad.index} disconnected`);
20+
window.addEventListener('gamepadconnected', onConnected);
21+
window.addEventListener('gamepaddisconnected', onDisconnected);
22+
23+
const draw = () => {
24+
// Fit a 480x270 layout into whatever size the canvas has.
25+
const scale = Math.min(canvas.width / WIDTH, canvas.height / HEIGHT);
26+
ctx.setTransform(1, 0, 0, 1, 0, 0);
27+
ctx.fillStyle = BACKGROUND;
28+
ctx.fillRect(0, 0, canvas.width, canvas.height);
29+
ctx.setTransform(scale, 0, 0, scale, (canvas.width - WIDTH * scale) / 2, (canvas.height - HEIGHT * scale) / 2);
30+
31+
// Poll every frame: this is how the Gamepad API is meant to be read.
32+
const pads = navigator.getGamepads().filter((pad): pad is Gamepad => pad !== null && pad.connected);
33+
if (pads.length > 0) {
34+
drawPad(ctx, pads[0], pads.length - 1);
35+
} else {
36+
drawWaiting(ctx);
37+
}
38+
39+
frame = requestAnimationFrame(draw);
40+
};
41+
frame = requestAnimationFrame(draw);
42+
43+
return () => {
44+
cancelAnimationFrame(frame);
45+
window.removeEventListener('gamepadconnected', onConnected);
46+
window.removeEventListener('gamepaddisconnected', onDisconnected);
47+
};
48+
}
49+
50+
function drawWaiting(ctx: CanvasRenderingContext2D) {
51+
ctx.textAlign = 'center';
52+
ctx.textBaseline = 'middle';
53+
ctx.fillStyle = TEXT;
54+
ctx.font = 'bold 16px sans-serif';
55+
ctx.fillText('Connect a controller and press any button', WIDTH / 2, HEIGHT / 2 - 10);
56+
ctx.fillStyle = MUTED;
57+
ctx.font = '12px sans-serif';
58+
ctx.fillText('navigator.getGamepads() has no connected pads yet', WIDTH / 2, HEIGHT / 2 + 16);
59+
}
60+
61+
function drawPad(ctx: CanvasRenderingContext2D, pad: Gamepad, others: number) {
62+
const b = pad.buttons;
63+
64+
ctx.textAlign = 'left';
65+
ctx.textBaseline = 'middle';
66+
ctx.fillStyle = TEXT;
67+
ctx.font = 'bold 12px sans-serif';
68+
ctx.fillText(pad.id.length > 60 ? pad.id.slice(0, 59) + '…' : pad.id, 16, 18);
69+
70+
// Standard mapping: https://w3c.github.io/gamepad/#remapping
71+
trigger(ctx, b[6], 40, 40, 'LT');
72+
trigger(ctx, b[7], 320, 40, 'RT');
73+
shoulder(ctx, b[4], 40, 62, 'LB');
74+
shoulder(ctx, b[5], 320, 62, 'RB');
75+
76+
stick(ctx, pad.axes[0], pad.axes[1], b[10], 110, 140, 'L');
77+
stick(ctx, pad.axes[2], pad.axes[3], b[11], 300, 188, 'R');
78+
79+
dpad(ctx, b[12], b[13], b[14], b[15], 180, 192);
80+
81+
button(ctx, b[3], 370, 110, 'Y');
82+
button(ctx, b[2], 345, 135, 'X');
83+
button(ctx, b[1], 395, 135, 'B');
84+
button(ctx, b[0], 370, 160, 'A');
85+
86+
pill(ctx, b[8], 205, 110, 'Select');
87+
pill(ctx, b[9], 275, 110, 'Start');
88+
if (b.length > 16) {
89+
button(ctx, b[16], 240, 145, 'Home');
90+
}
91+
92+
ctx.textAlign = 'left';
93+
ctx.fillStyle = MUTED;
94+
ctx.font = '11px sans-serif';
95+
const summary = `index ${pad.index} · mapping "${pad.mapping}" · ${pad.axes.length} axes · ${b.length} buttons`;
96+
ctx.fillText(others > 0 ? `${summary} · ${others} more connected` : summary, 16, HEIGHT - 14);
97+
}
98+
99+
function trigger(ctx: CanvasRenderingContext2D, input: GamepadButton | undefined, x: number, y: number, label: string) {
100+
const value = input?.value ?? 0;
101+
ctx.fillStyle = IDLE;
102+
ctx.fillRect(x, y - 7, 100, 14);
103+
ctx.fillStyle = ACTIVE;
104+
ctx.fillRect(x, y - 7, 100 * value, 14);
105+
ctx.textAlign = 'left';
106+
ctx.fillStyle = TEXT;
107+
ctx.font = '11px sans-serif';
108+
ctx.fillText(`${label} ${value.toFixed(2)}`, x + 106, y);
109+
}
110+
111+
function shoulder(ctx: CanvasRenderingContext2D, input: GamepadButton | undefined, x: number, y: number, label: string) {
112+
ctx.fillStyle = input?.pressed ? ACTIVE : IDLE;
113+
ctx.fillRect(x, y - 8, 100, 16);
114+
ctx.textAlign = 'center';
115+
ctx.fillStyle = TEXT;
116+
ctx.font = '11px sans-serif';
117+
ctx.fillText(label, x + 50, y);
118+
}
119+
120+
function stick(ctx: CanvasRenderingContext2D, ax = 0, ay = 0, press: GamepadButton | undefined, x: number, y: number, label: string) {
121+
const radius = 34;
122+
ctx.fillStyle = IDLE;
123+
ctx.beginPath();
124+
ctx.arc(x, y, radius, 0, Math.PI * 2);
125+
ctx.fill();
126+
ctx.lineWidth = 2;
127+
ctx.strokeStyle = press?.pressed ? ACTIVE : OUTLINE;
128+
ctx.stroke();
129+
130+
// Axes run from -1 to 1, with +y pointing down.
131+
ctx.fillStyle = ACTIVE;
132+
ctx.beginPath();
133+
ctx.arc(x + ax * (radius - 8), y + ay * (radius - 8), 8, 0, Math.PI * 2);
134+
ctx.fill();
135+
136+
ctx.textAlign = 'center';
137+
ctx.fillStyle = MUTED;
138+
ctx.font = '10px sans-serif';
139+
ctx.fillText(`${label} ${ax.toFixed(2)}, ${ay.toFixed(2)}`, x, y + radius + 12);
140+
}
141+
142+
function dpad(
143+
ctx: CanvasRenderingContext2D,
144+
up: GamepadButton | undefined,
145+
down: GamepadButton | undefined,
146+
left: GamepadButton | undefined,
147+
right: GamepadButton | undefined,
148+
x: number,
149+
y: number,
150+
) {
151+
const size = 18;
152+
const cells: [GamepadButton | undefined, number, number][] = [
153+
[up, 0, -1],
154+
[down, 0, 1],
155+
[left, -1, 0],
156+
[right, 1, 0],
157+
];
158+
for (const [input, dx, dy] of cells) {
159+
ctx.fillStyle = input?.pressed ? ACTIVE : IDLE;
160+
ctx.fillRect(x + dx * size - size / 2, y + dy * size - size / 2, size, size);
161+
}
162+
ctx.fillStyle = IDLE;
163+
ctx.fillRect(x - size / 2, y - size / 2, size, size);
164+
}
165+
166+
function button(ctx: CanvasRenderingContext2D, input: GamepadButton | undefined, x: number, y: number, label: string) {
167+
ctx.fillStyle = input?.pressed ? ACTIVE : IDLE;
168+
ctx.beginPath();
169+
ctx.arc(x, y, label.length > 1 ? 16 : 12, 0, Math.PI * 2);
170+
ctx.fill();
171+
ctx.textAlign = 'center';
172+
ctx.fillStyle = TEXT;
173+
ctx.font = label.length > 1 ? '10px sans-serif' : 'bold 12px sans-serif';
174+
ctx.fillText(label, x, y);
175+
}
176+
177+
function pill(ctx: CanvasRenderingContext2D, input: GamepadButton | undefined, x: number, y: number, label: string) {
178+
ctx.fillStyle = input?.pressed ? ACTIVE : IDLE;
179+
ctx.fillRect(x - 24, y - 8, 48, 16);
180+
ctx.textAlign = 'center';
181+
ctx.fillStyle = TEXT;
182+
ctx.font = '10px sans-serif';
183+
ctx.fillText(label, x, y);
184+
}

‎.vitepress/theme/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import CanvasDocsHome from './components/CanvasDocsHome.vue';
1515
import CanvasHeroMiniDemo from './components/CanvasHeroMiniDemo.vue';
1616
import AudioDocsHome from './components/AudioDocsHome.vue';
1717
import CanvasPlayground from './components/CanvasPlayground.vue';
18+
import GamepadLiveDemo from './components/GamepadLiveDemo.vue';
1819
import NativeScriptNavTitle from './components/NativeScriptNavTitle.vue';
1920
import NativeScriptFooter from './components/NativeScriptFooter.vue';
2021

@@ -41,5 +42,6 @@ export default {
4142
app.component('CanvasHeroMiniDemo', CanvasHeroMiniDemo);
4243
app.component('AudioDocsHome', AudioDocsHome);
4344
app.component('CanvasPlayground', CanvasPlayground);
45+
app.component('GamepadLiveDemo', GamepadLiveDemo);
4446
},
4547
};

‎content/canvas/ecosystem.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ The NativeScript canvas repository publishes more than the base Canvas package.
1313
| [`@nativescript/canvas-polyfill`](/plugins/canvas-polyfill) | `window`, `document`, `Image`, `navigator.gpu` and other browser globals |
1414
| [`@nativescript/canvas-media`](/plugins/canvas-media) | `Video` and `Audio` views that also act as frame sources |
1515
| [`@nativescript/audio-context`](/audio-context/) | The Web Audio API |
16+
| [`@nativescript/canvas-gamepad`](/plugins/canvas-gamepad) | The Gamepad API: `navigator.getGamepads()` and connection events |
1617
| [Framework adapters](/plugins/adapters) | Three.js, Pixi, Chart.js, Phaser, Phaser CE and Babylon.js |
1718

1819
## Upstream source

‎content/canvas/events.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
title: Events and Input
3-
description: Canvas lifecycle events, pointer and touch input, Android surface events and tvOS remote keys.
3+
description: Canvas lifecycle events, pointer and touch input, Android surface events, tvOS remote keys and game controllers.
44
---
55

66
# Events and Input
@@ -64,3 +64,7 @@ canvas.addEventListener('keydown', (e) => {
6464
```
6565

6666
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.
67+
68+
## Game controllers
69+
70+
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).

0 commit comments

Comments
 (0)