Skip to content

Commit fa244b9

Browse files
authored
Merge pull request #1 from NativeScript/docs/canvas-gamepad
docs: add @nativescript/canvas-gamepad
2 parents 3622af5 + 4f31ee0 commit fa244b9

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)