Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
],
},
Expand Down
166 changes: 166 additions & 0 deletions .vitepress/theme/components/GamepadLiveDemo.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
<script setup lang="ts">
import { nextTick, onBeforeUnmount, ref } from 'vue';
import { startGamepadTester } from '../demos/gamepad-tester';

const props = withDefaults(
defineProps<{
title?: string;
}>(),
{
title: 'Gamepad tester',
}
);

const canvasEl = ref<HTMLCanvasElement | null>(null);
const isVisible = ref(false);
const status = ref('');
const error = ref('');

let stopTester: (() => void) | null = null;
let resizeObserver: ResizeObserver | null = null;

function updateStatus() {
const pads = navigator.getGamepads().filter((pad) => pad !== null && pad.connected);
status.value = pads.length
? `${pads.length} controller${pads.length > 1 ? 's' : ''} connected.`
: 'No controller yet. Connect one and press a button: browsers only expose a pad after it is used on the page.';
}

function fitCanvas() {
const canvas = canvasEl.value;
if (!canvas) {
return;
}

const dpr = window.devicePixelRatio || 1;
const width = canvas.clientWidth;
canvas.width = Math.round(width * dpr);
canvas.height = Math.round(((width * 9) / 16) * dpr);
}

function stop() {
stopTester?.();
stopTester = null;
resizeObserver?.disconnect();
resizeObserver = null;
window.removeEventListener('gamepadconnected', updateStatus);
window.removeEventListener('gamepaddisconnected', updateStatus);
}

async function toggleDemo() {
isVisible.value = !isVisible.value;

if (!isVisible.value) {
stop();
return;
}

if (typeof navigator.getGamepads !== 'function') {
error.value = 'This browser does not support the Gamepad API.';
return;
}

error.value = '';
await nextTick();

const canvas = canvasEl.value;
if (!canvas) {
return;
}

fitCanvas();
resizeObserver = new ResizeObserver(fitCanvas);
resizeObserver.observe(canvas);

window.addEventListener('gamepadconnected', updateStatus);
window.addEventListener('gamepaddisconnected', updateStatus);
updateStatus();

stopTester = startGamepadTester(canvas);
}

onBeforeUnmount(stop);
</script>

<template>
<div class="gamepad-demo">
<div class="gamepad-demo__header">
<strong>{{ props.title }}</strong>
<button type="button" class="gamepad-demo__button" @click="toggleDemo">
{{ isVisible ? 'Hide demo' : 'Show demo' }}
</button>
</div>

<template v-if="isVisible">
<p v-if="error" class="gamepad-demo__error">{{ error }}</p>
<template v-else>
<canvas ref="canvasEl" class="gamepad-demo__canvas" aria-label="Live gamepad tester" />
<p class="gamepad-demo__caption">{{ status }}</p>
</template>
</template>

<p v-else class="gamepad-demo__caption gamepad-demo__caption--hidden">
Runs in this page with the browser's own Gamepad API. Connect a controller, then click Show demo.
</p>
</div>
</template>

<style scoped>
.gamepad-demo {
margin: 0.9rem 0 1.1rem;
border: 1px solid var(--vp-c-divider);
border-radius: 14px;
padding: 0.8rem;
background: var(--vp-c-bg-soft);
}

.gamepad-demo__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.65rem;
color: var(--vp-c-text-1);
}

.gamepad-demo__button {
border: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg);
color: var(--vp-c-text-1);
border-radius: 8px;
padding: 0.34rem 0.58rem;
font-size: 0.82rem;
cursor: pointer;
}

.gamepad-demo__button:hover {
border-color: var(--vp-c-brand-1);
color: var(--vp-c-brand-1);
}

.gamepad-demo__canvas {
display: block;
width: 100%;
aspect-ratio: 16 / 9;
border: 1px solid var(--vp-c-divider);
border-radius: 10px;
background: #0f141c;
}

.gamepad-demo__caption {
margin-top: 0.5rem;
margin-bottom: 0;
font-size: 0.82rem;
color: var(--vp-c-text-2);
}

.gamepad-demo__caption--hidden {
margin-top: 0;
}

.gamepad-demo__error {
margin: 0;
font-size: 0.82rem;
color: #dc2626;
}
</style>
184 changes: 184 additions & 0 deletions .vitepress/theme/demos/gamepad-tester.ts
Original file line number Diff line number Diff line change
@@ -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);
}
2 changes: 2 additions & 0 deletions .vitepress/theme/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -41,5 +42,6 @@ export default {
app.component('CanvasHeroMiniDemo', CanvasHeroMiniDemo);
app.component('AudioDocsHome', AudioDocsHome);
app.component('CanvasPlayground', CanvasPlayground);
app.component('GamepadLiveDemo', GamepadLiveDemo);
},
};
1 change: 1 addition & 0 deletions content/canvas/ecosystem.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion content/canvas/events.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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).
Loading
Loading