diff --git a/Cargo.toml b/Cargo.toml
index 2675f76fa..2c3a24ff0 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -16,6 +16,7 @@ members = [
"crates/canvas-svg-napi",
"crates/audio-context-napi",
"crates/canvas-media-napi",
+ "crates/canvas-gamepad-napi",
"crates/canvas-svg-ios",
"crates/canvas-svg-android",
]
diff --git a/Makefile b/Makefile
index 8517b8711..db0afa888 100644
--- a/Makefile
+++ b/Makefile
@@ -26,6 +26,8 @@ windows-audio: $(addsuffix _audio_napi,$(ARCHS_WINDOWS))
windows-media: $(addsuffix _media_napi,$(ARCHS_WINDOWS))
+windows-gamepad: $(addsuffix _gamepad_napi,$(ARCHS_WINDOWS))
+
ios-svg: GENERATE_IOS_SVG
visionos-svg: GENERATE_VISIONOS_SVG
@@ -104,6 +106,10 @@ $(addsuffix _audio_napi,$(ARCHS_WINDOWS)): %_audio_napi:
$(addsuffix _media_napi,$(ARCHS_WINDOWS)): %_media_napi:
./tools/scripts/build-napi.sh $* release-napi canvas-media-napi
+.PHONY: windows-gamepad $(addsuffix _gamepad_napi,$(ARCHS_WINDOWS))
+$(addsuffix _gamepad_napi,$(ARCHS_WINDOWS)): %_gamepad_napi:
+ ./tools/scripts/build-napi.sh $* release-napi canvas-gamepad-napi
+
.PHONY: GENERATE_ANDROID
GENERATE_ANDROID: $(ARCHS_ANDROID)
diff --git a/apps/demo/package.json b/apps/demo/package.json
index dd2a2e6b8..5dce04007 100644
--- a/apps/demo/package.json
+++ b/apps/demo/package.json
@@ -8,6 +8,7 @@
"@nativescript/canvas": "file:../../packages/canvas",
"@nativescript/canvas-babylon": "file:../../packages/canvas-babylon",
"@nativescript/canvas-chartjs": "file:../../packages/canvas-chartjs",
+ "@nativescript/canvas-gamepad": "file:../../packages/canvas-gamepad",
"@nativescript/canvas-media": "file:../../packages/canvas-media",
"@nativescript/canvas-phaser": "file:../../packages/canvas-phaser",
"@nativescript/canvas-phaser-ce": "file:../../packages/canvas-phaser-ce",
@@ -15,7 +16,7 @@
"@nativescript/canvas-polyfill": "file:../../packages/canvas-polyfill",
"@nativescript/canvas-svg": "file:../../packages/canvas-svg",
"@nativescript/canvas-three": "file:../../packages/canvas-three",
- "@nativescript/core": "https://pkg.pr.new/@nativescript/core@11272",
+ "@nativescript/core": "https://pkg.pr.new/@nativescript/core@54125e1",
"@nativescript/font-manager": "file:../../node_modules/@nativescript/font-manager",
"@pixi-essentials/svg": "file:../../node_modules/@pixi-essentials/svg",
"babylonjs": "file:../../node_modules/babylonjs",
@@ -37,7 +38,7 @@
"@nativescript/devtools": "~0.0.1",
"@nativescript/ios": "9.0.4-next.2026-08-13-31742124335",
"@nativescript/visionos": "~9.0.1",
- "@nativescript/webpack": "https://pkg.pr.new/@nativescript/webpack@11272",
+ "@nativescript/webpack": "https://pkg.pr.new/@nativescript/webpack@54125e1",
"@nativescript/windows": "beta",
"typescript": "~5.6.0"
}
diff --git a/apps/demo/src/main-page.xml b/apps/demo/src/main-page.xml
index 651727419..e86cd2456 100644
--- a/apps/demo/src/main-page.xml
+++ b/apps/demo/src/main-page.xml
@@ -10,6 +10,7 @@
+
diff --git a/apps/demo/src/plugin-demos/canvas-gamepad.ts b/apps/demo/src/plugin-demos/canvas-gamepad.ts
new file mode 100644
index 000000000..4ee6fbad0
--- /dev/null
+++ b/apps/demo/src/plugin-demos/canvas-gamepad.ts
@@ -0,0 +1,9 @@
+import { EventData, Page } from '@nativescript/core';
+import { DemoSharedCanvasGamepad } from '@demo/shared';
+
+export function navigatingTo(args: EventData) {
+ const page = args.object;
+ page.bindingContext = new DemoModel();
+}
+
+export class DemoModel extends DemoSharedCanvasGamepad {}
diff --git a/apps/demo/src/plugin-demos/canvas-gamepad.xml b/apps/demo/src/plugin-demos/canvas-gamepad.xml
new file mode 100644
index 000000000..5abf5f9e8
--- /dev/null
+++ b/apps/demo/src/plugin-demos/canvas-gamepad.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/apps/demo/tsconfig.json b/apps/demo/tsconfig.json
index 2ff1032f6..93932e0bf 100644
--- a/apps/demo/tsconfig.json
+++ b/apps/demo/tsconfig.json
@@ -15,6 +15,7 @@
"@nativescript/canvas-pixi": ["../../packages/canvas-pixi/index.ts"],
"@nativescript/canvas-three": ["../../packages/canvas-three/index.ts"],
"@nativescript/canvas-media": ["../../packages/canvas-media/index.ts"],
+ "@nativescript/canvas-gamepad": ["../../packages/canvas-gamepad/index.ts"],
"@nativescript/*": ["../../packages/*"],
"@nativescript/audio-context": ["../../packages/audio-context/index.d.ts"]
}
diff --git a/crates/canvas-gamepad-napi/Cargo.toml b/crates/canvas-gamepad-napi/Cargo.toml
new file mode 100644
index 000000000..a30d44611
--- /dev/null
+++ b/crates/canvas-gamepad-napi/Cargo.toml
@@ -0,0 +1,29 @@
+[package]
+name = "canvas-gamepad-napi"
+version = "0.1.0"
+edition = "2021"
+publish = false
+description = "Node-API binding for @nativescript/canvas-gamepad on Windows: Windows.Gaming.Input readings for navigator.getGamepads()."
+
+[lib]
+crate-type = ["cdylib"]
+
+[features]
+default = ["napi8"]
+napi8 = ["napi/napi8"]
+# Hosts whose Node-API lacks type tags (NativeScript Windows on JavaScriptCore).
+napi7 = ["napi/napi7"]
+
+[dependencies]
+napi = { version = "3.13.0", default-features = false, features = ["dyn-symbols"] }
+napi-derive = "3.6.9"
+
+[target.'cfg(target_os = "windows")'.dependencies]
+windows = { workspace = true, features = [
+ "Foundation",
+ "Foundation_Collections",
+ "Gaming_Input",
+] }
+
+[build-dependencies]
+napi-build = "2.5.0"
diff --git a/crates/canvas-gamepad-napi/build.rs b/crates/canvas-gamepad-napi/build.rs
new file mode 100644
index 000000000..0f1b01002
--- /dev/null
+++ b/crates/canvas-gamepad-napi/build.rs
@@ -0,0 +1,3 @@
+fn main() {
+ napi_build::setup();
+}
diff --git a/crates/canvas-gamepad-napi/package.json b/crates/canvas-gamepad-napi/package.json
new file mode 100644
index 000000000..ddc9972a3
--- /dev/null
+++ b/crates/canvas-gamepad-napi/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "@nativescript/canvas-gamepad-napi",
+ "private": true,
+ "version": "0.1.0",
+ "description": "Node-API build of the canvas-gamepad native module (Windows.Gaming.Input poller for Windows).",
+ "napi": {
+ "binaryName": "canvasgamepad",
+ "targets": [
+ "x86_64-pc-windows-msvc",
+ "aarch64-pc-windows-msvc"
+ ]
+ },
+ "engines": {
+ "node": ">=20"
+ }
+}
diff --git a/crates/canvas-gamepad-napi/src/lib.rs b/crates/canvas-gamepad-napi/src/lib.rs
new file mode 100644
index 000000000..c53df25f5
--- /dev/null
+++ b/crates/canvas-gamepad-napi/src/lib.rs
@@ -0,0 +1,211 @@
+#![cfg(target_os = "windows")]
+#![deny(clippy::all)]
+
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::sync::Arc;
+
+use napi::bindgen_prelude::{Float32Array, Function, ObjectFinalize};
+use napi::threadsafe_function::{ThreadsafeFunctionCallMode, UnknownReturnValue};
+use napi::{Env, Result};
+use napi_derive::napi;
+use windows::Foundation::EventHandler;
+use windows::Gaming::Input::{Gamepad, GamepadButtons, RawGameController};
+
+// Keep in sync with packages/canvas-gamepad/common.ts.
+const MAX_GAMEPADS: usize = 4;
+const MAX_AXES: usize = 8;
+const SLOT_CONNECTED: usize = 0;
+const SLOT_SEQUENCE: usize = 1;
+const SLOT_MAPPING: usize = 2;
+const SLOT_AXIS_COUNT: usize = 3;
+const SLOT_BUTTON_COUNT: usize = 4;
+const SLOT_AXES: usize = 5;
+const SLOT_BUTTONS: usize = SLOT_AXES + MAX_AXES;
+const SLOT_STRIDE: usize = 80;
+const BUTTON_PRESSED: u32 = 1;
+const BUTTON_TOUCHED: u32 = 2;
+const MAPPING_STANDARD: f32 = 1.0;
+const STANDARD_AXES: f32 = 4.0;
+// Windows.Gaming.Input.Gamepad has no guide button.
+const STANDARD_BUTTONS: f32 = 16.0;
+// Float32 holds integers exactly up to 2^24.
+const SEQUENCE_WRAP: f32 = 16_777_216.0;
+const TRIGGER_PRESSED: f32 = 0.1;
+const MICROSOFT_VENDOR: u16 = 0x045e;
+
+const DIGITAL: [(usize, GamepadButtons); 14] = [
+ (0, GamepadButtons::A),
+ (1, GamepadButtons::B),
+ (2, GamepadButtons::X),
+ (3, GamepadButtons::Y),
+ (4, GamepadButtons::LeftShoulder),
+ (5, GamepadButtons::RightShoulder),
+ (8, GamepadButtons::View),
+ (9, GamepadButtons::Menu),
+ (10, GamepadButtons::LeftThumbstick),
+ (11, GamepadButtons::RightThumbstick),
+ (12, GamepadButtons::DPadUp),
+ (13, GamepadButtons::DPadDown),
+ (14, GamepadButtons::DPadLeft),
+ (15, GamepadButtons::DPadRight),
+];
+
+fn write_button(slot: &mut [f32], index: usize, value: f32, pressed: bool) {
+ let offset = SLOT_BUTTONS + index * 2;
+ let touched = pressed || value > 0.0;
+ slot[offset] = value;
+ slot[offset + 1] = ((if pressed { BUTTON_PRESSED } else { 0 }) | (if touched { BUTTON_TOUCHED } else { 0 })) as f32;
+}
+
+type ChangeCallback<'a> = Function<'a, (), UnknownReturnValue>;
+
+#[napi(js_name = "NSCGamepadPoller", custom_finalize)]
+pub struct NSCGamepadPoller {
+ slots: [Option; MAX_GAMEPADS],
+ ids: [String; MAX_GAMEPADS],
+ dirty: Arc,
+ tokens: Option<(i64, i64)>,
+}
+
+impl ObjectFinalize for NSCGamepadPoller {
+ fn finalize(mut self, _: Env) -> Result<()> {
+ self.close();
+ Ok(())
+ }
+}
+
+#[napi]
+impl NSCGamepadPoller {
+ #[napi(constructor, ts_args_type = "onConnectionChanged: () => void")]
+ pub fn new(on_change: ChangeCallback) -> Result {
+ let dirty = Arc::new(AtomicBool::new(true));
+ let tsfn = on_change.build_threadsafe_function::<()>().weak::().build()?;
+ let tsfn = Arc::new(tsfn);
+
+ let handler = {
+ let dirty = Arc::clone(&dirty);
+ let tsfn = Arc::clone(&tsfn);
+ EventHandler::::new(move |_, _| {
+ dirty.store(true, Ordering::Release);
+ tsfn.call((), ThreadsafeFunctionCallMode::NonBlocking);
+ Ok(())
+ })
+ };
+ let tokens = match (Gamepad::GamepadAdded(&handler), Gamepad::GamepadRemoved(&handler)) {
+ (Ok(added), Ok(removed)) => Some((added, removed)),
+ _ => None,
+ };
+
+ Ok(Self {
+ slots: Default::default(),
+ ids: Default::default(),
+ dirty,
+ tokens,
+ })
+ }
+
+ #[napi]
+ pub fn poll(&mut self, mut buffer: Float32Array) -> u32 {
+ // SAFETY: the JS caller owns the array and is blocked for the duration of the call.
+ let buffer: &mut [f32] = unsafe { buffer.as_mut() };
+ if buffer.len() < MAX_GAMEPADS * SLOT_STRIDE {
+ return 0;
+ }
+ let changed = if self.dirty.swap(false, Ordering::AcqRel) {
+ self.sync(buffer)
+ } else {
+ 0
+ };
+
+ for (index, pad) in self.slots.iter().enumerate() {
+ let Some(pad) = pad else { continue };
+ let Ok(reading) = pad.GetCurrentReading() else { continue };
+ let slot = &mut buffer[index * SLOT_STRIDE..(index + 1) * SLOT_STRIDE];
+
+ let axes = &mut slot[SLOT_AXES..SLOT_AXES + 4];
+ axes[0] = reading.LeftThumbstickX as f32;
+ axes[1] = -reading.LeftThumbstickY as f32;
+ axes[2] = reading.RightThumbstickX as f32;
+ axes[3] = -reading.RightThumbstickY as f32;
+
+ for (button, flag) in DIGITAL {
+ let pressed = reading.Buttons.contains(flag);
+ write_button(slot, button, if pressed { 1.0 } else { 0.0 }, pressed);
+ }
+ let left = reading.LeftTrigger as f32;
+ let right = reading.RightTrigger as f32;
+ write_button(slot, 6, left, left > TRIGGER_PRESSED);
+ write_button(slot, 7, right, right > TRIGGER_PRESSED);
+
+ slot[SLOT_SEQUENCE] = (slot[SLOT_SEQUENCE] + 1.0) % SEQUENCE_WRAP;
+ }
+ changed
+ }
+
+ #[napi]
+ pub fn id(&self, index: u32) -> String {
+ self.ids.get(index as usize).cloned().unwrap_or_default()
+ }
+
+ #[napi]
+ pub fn close(&mut self) {
+ if let Some((added, removed)) = self.tokens.take() {
+ let _ = Gamepad::RemoveGamepadAdded(added);
+ let _ = Gamepad::RemoveGamepadRemoved(removed);
+ }
+ self.slots = Default::default();
+ }
+}
+
+impl NSCGamepadPoller {
+ fn sync(&mut self, buffer: &mut [f32]) -> u32 {
+ let current: Vec = Gamepad::Gamepads().map(|list| list.into_iter().collect()).unwrap_or_default();
+ let mut changed = 0u32;
+
+ for index in 0..MAX_GAMEPADS {
+ let gone = matches!(&self.slots[index], Some(pad) if !current.contains(pad));
+ if gone {
+ self.slots[index] = None;
+ self.ids[index].clear();
+ buffer[index * SLOT_STRIDE..(index + 1) * SLOT_STRIDE].fill(0.0);
+ changed |= 1 << index;
+ }
+ }
+
+ for pad in current {
+ if self.slots.iter().any(|slot| slot.as_ref() == Some(&pad)) {
+ continue;
+ }
+ let Some(index) = self.slots.iter().position(Option::is_none) else { break };
+ let slot = &mut buffer[index * SLOT_STRIDE..(index + 1) * SLOT_STRIDE];
+ slot.fill(0.0);
+ slot[SLOT_CONNECTED] = 1.0;
+ slot[SLOT_MAPPING] = MAPPING_STANDARD;
+ slot[SLOT_AXIS_COUNT] = STANDARD_AXES;
+ slot[SLOT_BUTTON_COUNT] = STANDARD_BUTTONS;
+ self.ids[index] = describe(&pad);
+ self.slots[index] = Some(pad);
+ changed |= 1 << index;
+ }
+ changed
+ }
+}
+
+fn describe(pad: &Gamepad) -> String {
+ match RawGameController::FromGameController(pad) {
+ Ok(raw) => {
+ let name = raw.DisplayName().map(|n| n.to_string()).unwrap_or_default();
+ let vendor = raw.HardwareVendorId().unwrap_or(0);
+ let product = raw.HardwareProductId().unwrap_or(0);
+ // Over Bluetooth Windows names Xbox pads by their HID class; Chrome reports the product name.
+ let generic = name.is_empty() || name.starts_with("HID-compliant");
+ let name = match (generic, vendor) {
+ (true, MICROSOFT_VENDOR) => "Xbox Wireless Controller".to_string(),
+ (true, _) if name.is_empty() => "Xbox Controller".to_string(),
+ _ => name,
+ };
+ format!("{name} (STANDARD GAMEPAD Vendor: {vendor:04x} Product: {product:04x})")
+ }
+ Err(_) => "Xbox Controller (STANDARD GAMEPAD)".to_string(),
+ }
+}
diff --git a/packages/canvas-babylon/package.json b/packages/canvas-babylon/package.json
index f84de7708..7274fcfdd 100644
--- a/packages/canvas-babylon/package.json
+++ b/packages/canvas-babylon/package.json
@@ -1,6 +1,6 @@
{
"name": "@nativescript/canvas-babylon",
- "version": "3.0.0-alpha.15",
+ "version": "3.0.0-alpha.16",
"description": "",
"main": "index",
"typings": "index.d.ts",
diff --git a/packages/canvas-chartjs/package.json b/packages/canvas-chartjs/package.json
index 671d65cc1..9bc5cb1f2 100644
--- a/packages/canvas-chartjs/package.json
+++ b/packages/canvas-chartjs/package.json
@@ -1,6 +1,6 @@
{
"name": "@nativescript/canvas-chartjs",
- "version": "3.0.0-alpha.15",
+ "version": "3.0.0-alpha.16",
"description": "ChartJS",
"main": "index",
"typings": "index.d.ts",
diff --git a/packages/canvas-gamepad/.eslintrc.json b/packages/canvas-gamepad/.eslintrc.json
new file mode 100644
index 000000000..be41074b7
--- /dev/null
+++ b/packages/canvas-gamepad/.eslintrc.json
@@ -0,0 +1,18 @@
+{
+ "extends": ["../../.eslintrc.json"],
+ "ignorePatterns": ["!**/*", "node_modules/**/*"],
+ "overrides": [
+ {
+ "files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
+ "rules": {}
+ },
+ {
+ "files": ["*.ts", "*.tsx"],
+ "rules": {}
+ },
+ {
+ "files": ["*.js", "*.jsx"],
+ "rules": {}
+ }
+ ]
+}
diff --git a/packages/canvas-gamepad/README.md b/packages/canvas-gamepad/README.md
new file mode 100644
index 000000000..27d862f35
--- /dev/null
+++ b/packages/canvas-gamepad/README.md
@@ -0,0 +1,53 @@
+# Nativescript canvas-gamepad
+
+The [Gamepad API](https://developer.mozilla.org/en-US/docs/Web/API/Gamepad_API) for NativeScript: `navigator.getGamepads()` plus `gamepadconnected` / `gamepaddisconnected`.
+
+```javascript
+ns plugin add @nativescript/canvas-gamepad
+```
+
+## Usage
+
+With `@nativescript/canvas-polyfill`, web code works unchanged:
+
+```ts
+window.addEventListener('gamepadconnected', (e) => console.log(e.gamepad.id));
+
+function frame() {
+ const pad = navigator.getGamepads()[0];
+ if (pad) {
+ const [x, y] = pad.axes;
+ const jump = pad.buttons[0].pressed;
+ }
+ requestAnimationFrame(frame);
+}
+requestAnimationFrame(frame);
+```
+
+Without the polyfill:
+
+```ts
+import { gamepads, getGamepads } from '@nativescript/canvas-gamepad';
+
+gamepads.addListener((e) => console.log(e.type, e.gamepad.index));
+const pads = getGamepads();
+```
+
+Monitoring starts on the first `getGamepads()` call or gamepad listener.
+
+## Behaviour
+
+- Controllers use the [standard mapping](https://w3c.github.io/gamepad/#remapping): 4 axes, 17 buttons (16 on Windows, which has no guide button). Up to 4 controllers.
+- `Gamepad` objects are updated in place between calls, as in Firefox. Copy `axes` / `buttons` if you compare frames.
+- `timestamp` is `performance.now()` at the first `getGamepads()` after the controller changed.
+- `vibrationActuator` is `null`.
+
+| Platform | Source | Notes |
+| --- | --- | --- |
+| iOS, tvOS, visionOS | GameController `GCExtendedGamepad` | The Siri Remote stays mapped to keyboard events. `valueChangedHandler` on each controller is taken over. |
+| Android | `KeyEvent` / `MotionEvent` from gamepad and joystick sources | Events from connected controllers are consumed at the activity window, so B no longer triggers Back while the API is in use. |
+| Windows | `Windows.Gaming.Input.Gamepad` | Build the native module with `make windows-gamepad`. |
+
+## License
+
+Apache License Version 2.0
diff --git a/packages/canvas-gamepad/__test__/common.test.mjs b/packages/canvas-gamepad/__test__/common.test.mjs
new file mode 100644
index 000000000..c6608a81f
--- /dev/null
+++ b/packages/canvas-gamepad/__test__/common.test.mjs
@@ -0,0 +1,132 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { fileURLToPath } from 'node:url';
+import { build } from 'esbuild';
+
+const entry = fileURLToPath(new URL('../common.ts', import.meta.url));
+const { outputFiles } = await build({ entryPoints: [entry], bundle: true, format: 'esm', write: false, platform: 'neutral' });
+const common = await import('data:text/javascript;base64,' + Buffer.from(outputFiles[0].text).toString('base64'));
+const { Gamepads, SLOT_STRIDE, SLOT_CONNECTED, SLOT_SEQUENCE, SLOT_MAPPING, SLOT_AXIS_COUNT, SLOT_BUTTON_COUNT, SLOT_AXES, SLOT_BUTTONS, BUTTON_PRESSED, BUTTON_TOUCHED, BUFFER_LENGTH } = common;
+
+function fakeBackend() {
+ const backend = {
+ buffer: null,
+ notify: null,
+ polls: 0,
+ start(buffer, onConnection) {
+ backend.buffer = buffer;
+ backend.notify = onConnection;
+ },
+ connect(index, id) {
+ const base = index * SLOT_STRIDE;
+ backend.buffer[base + SLOT_CONNECTED] = 1;
+ backend.buffer[base + SLOT_MAPPING] = 1;
+ backend.buffer[base + SLOT_AXIS_COUNT] = 4;
+ backend.buffer[base + SLOT_BUTTON_COUNT] = 17;
+ backend.notify(index, true, id);
+ },
+ disconnect(index) {
+ backend.buffer.fill(0, index * SLOT_STRIDE, (index + 1) * SLOT_STRIDE);
+ backend.notify(index, false, '');
+ },
+ write(index, axes, buttons) {
+ const base = index * SLOT_STRIDE;
+ axes.forEach((v, i) => (backend.buffer[base + SLOT_AXES + i] = v));
+ for (const [i, value, flags] of buttons) {
+ backend.buffer[base + SLOT_BUTTONS + i * 2] = value;
+ backend.buffer[base + SLOT_BUTTONS + i * 2 + 1] = flags;
+ }
+ backend.buffer[base + SLOT_SEQUENCE] += 1;
+ },
+ };
+ return backend;
+}
+
+test('layout fits the standard mapping', () => {
+ assert.ok(SLOT_BUTTONS + 32 * 2 <= SLOT_STRIDE);
+ assert.equal(BUFFER_LENGTH, 4 * SLOT_STRIDE);
+});
+
+test('does not start native until used', () => {
+ const backend = fakeBackend();
+ new Gamepads(backend);
+ assert.equal(backend.buffer, null);
+});
+
+test('reports four slots, null until connected', () => {
+ const backend = fakeBackend();
+ const pads = new Gamepads(backend);
+ assert.deepEqual(pads.getGamepads(), [null, null, null, null]);
+ assert.equal(backend.buffer.length, BUFFER_LENGTH);
+});
+
+test('connect, read state, disconnect', () => {
+ const backend = fakeBackend();
+ const pads = new Gamepads(backend);
+ const events = [];
+ pads.addListener((e) => events.push([e.type, e.gamepad.index, e.gamepad.id]));
+
+ backend.connect(1, 'Pad (STANDARD GAMEPAD)');
+ assert.deepEqual(events, [['gamepadconnected', 1, 'Pad (STANDARD GAMEPAD)']]);
+
+ let pad = pads.getGamepads()[1];
+ assert.equal(pad.mapping, 'standard');
+ assert.equal(pad.axes.length, 4);
+ assert.equal(pad.buttons.length, 17);
+ assert.equal(pad.buttons[0].pressed, false);
+
+ backend.write(1, [0.5, -1, 0, 0.25], [
+ [0, 1, BUTTON_PRESSED | BUTTON_TOUCHED],
+ [7, 0.05, BUTTON_TOUCHED],
+ ]);
+ pad = pads.getGamepads()[1];
+ assert.deepEqual(pad.axes, [0.5, -1, 0, 0.25]);
+ assert.deepEqual({ ...pad.buttons[0] }, { pressed: true, touched: true, value: 1 });
+ assert.equal(pad.buttons[7].pressed, false);
+ assert.equal(pad.buttons[7].touched, true);
+ assert.ok(Math.abs(pad.buttons[7].value - 0.05) < 1e-6);
+
+ backend.disconnect(1);
+ assert.equal(pad.connected, false);
+ assert.deepEqual(events.at(-1), ['gamepaddisconnected', 1, 'Pad (STANDARD GAMEPAD)']);
+ assert.equal(pads.getGamepads()[1], null);
+});
+
+test('timestamp only moves when the sequence changes', async () => {
+ const backend = fakeBackend();
+ const pads = new Gamepads(backend);
+ pads.start();
+ backend.connect(0, 'Pad');
+ const first = pads.getGamepads()[0].timestamp;
+ await new Promise((r) => setTimeout(r, 5));
+ assert.equal(pads.getGamepads()[0].timestamp, first);
+ backend.write(0, [1], []);
+ assert.ok(pads.getGamepads()[0].timestamp > first);
+});
+
+test('poll-only backends are polled on every getGamepads', () => {
+ const backend = fakeBackend();
+ backend.poll = () => backend.polls++;
+ const pads = new Gamepads(backend);
+ pads.getGamepads();
+ pads.getGamepads();
+ assert.equal(backend.polls, 2);
+});
+
+test('a listener that throws does not stop the others', () => {
+ const backend = fakeBackend();
+ const pads = new Gamepads(backend);
+ let called = false;
+ const error = console.error;
+ console.error = () => {};
+ try {
+ pads.addListener(() => {
+ throw new Error('boom');
+ });
+ pads.addListener(() => (called = true));
+ backend.connect(0, 'Pad');
+ } finally {
+ console.error = error;
+ }
+ assert.ok(called);
+});
diff --git a/packages/canvas-gamepad/backend.android.ts b/packages/canvas-gamepad/backend.android.ts
new file mode 100644
index 000000000..e9670d497
--- /dev/null
+++ b/packages/canvas-gamepad/backend.android.ts
@@ -0,0 +1,19 @@
+import { Application, Utils } from '@nativescript/core';
+import type { GamepadBackend } from './common';
+
+export function createBackend(): GamepadBackend {
+ return {
+ start(buffer, onConnection) {
+ org.nativescript.canvas.gamepad.NSCGamepadManager.start(
+ Utils.android.getApplicationContext(),
+ Application.android.foregroundActivity ?? Application.android.startActivity ?? null,
+ buffer as never,
+ new org.nativescript.canvas.gamepad.NSCGamepadManager.Listener({
+ onConnection(index: number, connected: boolean, id: string) {
+ onConnection(index, connected, id);
+ },
+ }),
+ );
+ },
+ };
+}
diff --git a/packages/canvas-gamepad/backend.d.ts b/packages/canvas-gamepad/backend.d.ts
new file mode 100644
index 000000000..3fe60ca53
--- /dev/null
+++ b/packages/canvas-gamepad/backend.d.ts
@@ -0,0 +1,3 @@
+import type { GamepadBackend } from './common';
+
+export declare function createBackend(): GamepadBackend;
diff --git a/packages/canvas-gamepad/backend.ios.ts b/packages/canvas-gamepad/backend.ios.ts
new file mode 100644
index 000000000..e81ef6dbe
--- /dev/null
+++ b/packages/canvas-gamepad/backend.ios.ts
@@ -0,0 +1,9 @@
+import type { GamepadBackend } from './common';
+
+export function createBackend(): GamepadBackend {
+ return {
+ start(buffer, onConnection) {
+ NSCGamepadManager.shared.startWithBufferLengthListener(buffer as never, buffer.length, (index, connected, id) => onConnection(index, connected, id));
+ },
+ };
+}
diff --git a/packages/canvas-gamepad/backend.windows.ts b/packages/canvas-gamepad/backend.windows.ts
new file mode 100644
index 000000000..6f397e294
--- /dev/null
+++ b/packages/canvas-gamepad/backend.windows.ts
@@ -0,0 +1,36 @@
+import { MAX_GAMEPADS, SLOT_CONNECTED, SLOT_STRIDE, type GamepadBackend } from './common';
+
+declare const __non_webpack_require__: (specifier: string) => any;
+
+interface GamepadPoller {
+ poll(buffer: Float32Array): number;
+ id(index: number): string;
+}
+
+const Native: { NSCGamepadPoller: new (onConnectionChanged: () => void) => GamepadPoller } = __non_webpack_require__('system_lib://canvasgamepad.node');
+
+export function createBackend(): GamepadBackend {
+ let poller: GamepadPoller;
+ let notify: (index: number, connected: boolean, id: string) => void;
+ let target: Float32Array;
+
+ const poll = (buffer: Float32Array) => {
+ const changed = poller.poll(buffer);
+ for (let i = 0; changed !== 0 && i < MAX_GAMEPADS; i++) {
+ if (changed & (1 << i)) {
+ const connected = buffer[i * SLOT_STRIDE + SLOT_CONNECTED] === 1;
+ notify(i, connected, connected ? poller.id(i) : '');
+ }
+ }
+ };
+
+ return {
+ start(buffer, onConnection) {
+ target = buffer;
+ notify = onConnection;
+ poller = new Native.NSCGamepadPoller(() => poll(target));
+ poll(buffer);
+ },
+ poll,
+ };
+}
diff --git a/packages/canvas-gamepad/common.ts b/packages/canvas-gamepad/common.ts
new file mode 100644
index 000000000..ba92a822d
--- /dev/null
+++ b/packages/canvas-gamepad/common.ts
@@ -0,0 +1,163 @@
+export const MAX_GAMEPADS = 4;
+export const MAX_AXES = 8;
+export const MAX_BUTTONS = 32;
+
+export const SLOT_CONNECTED = 0;
+export const SLOT_SEQUENCE = 1;
+export const SLOT_MAPPING = 2;
+export const SLOT_AXIS_COUNT = 3;
+export const SLOT_BUTTON_COUNT = 4;
+export const SLOT_AXES = 5;
+export const SLOT_BUTTONS = SLOT_AXES + MAX_AXES;
+export const SLOT_STRIDE = 80;
+export const BUFFER_LENGTH = MAX_GAMEPADS * SLOT_STRIDE;
+
+export const BUTTON_PRESSED = 1;
+export const BUTTON_TOUCHED = 2;
+
+export const MAPPING_STANDARD = 1;
+
+export type GamepadMappingType = '' | 'standard' | 'xr-standard';
+
+export interface GamepadBackend {
+ start(buffer: Float32Array, onConnection: (index: number, connected: boolean, id: string) => void): void;
+ poll?(buffer: Float32Array): void;
+}
+
+export class GamepadButton {
+ pressed = false;
+ touched = false;
+ value = 0;
+}
+
+export class Gamepad {
+ readonly axes: number[] = [];
+ readonly buttons: GamepadButton[] = [];
+ readonly mapping: GamepadMappingType;
+ readonly vibrationActuator = null;
+ readonly hapticActuators = [];
+ connected = true;
+ timestamp = 0;
+
+ constructor(
+ readonly index: number,
+ readonly id: string,
+ buffer: Float32Array,
+ ) {
+ const base = index * SLOT_STRIDE;
+ this.mapping = buffer[base + SLOT_MAPPING] === MAPPING_STANDARD ? 'standard' : '';
+ const axisCount = Math.min(buffer[base + SLOT_AXIS_COUNT], MAX_AXES);
+ const buttonCount = Math.min(buffer[base + SLOT_BUTTON_COUNT], MAX_BUTTONS);
+ for (let i = 0; i < axisCount; i++) {
+ this.axes.push(0);
+ }
+ for (let i = 0; i < buttonCount; i++) {
+ this.buttons.push(new GamepadButton());
+ }
+ this._update(buffer, performance.now());
+ }
+
+ _update(buffer: Float32Array, timestamp: number) {
+ const base = this.index * SLOT_STRIDE;
+ const axes = this.axes;
+ for (let i = 0, n = axes.length, o = base + SLOT_AXES; i < n; i++) {
+ axes[i] = buffer[o + i];
+ }
+ const buttons = this.buttons;
+ for (let i = 0, n = buttons.length, o = base + SLOT_BUTTONS; i < n; i++, o += 2) {
+ const button = buttons[i];
+ const flags = buffer[o + 1];
+ button.value = buffer[o];
+ button.pressed = (flags & BUTTON_PRESSED) !== 0;
+ button.touched = (flags & BUTTON_TOUCHED) !== 0;
+ }
+ this.timestamp = timestamp;
+ }
+}
+
+export class GamepadEvent {
+ constructor(
+ readonly type: 'gamepadconnected' | 'gamepaddisconnected',
+ readonly gamepad: Gamepad,
+ ) {}
+}
+
+export type GamepadListener = (event: GamepadEvent) => void;
+
+export class Gamepads {
+ private readonly _buffer = new Float32Array(BUFFER_LENGTH);
+ private readonly _pads: (Gamepad | null)[] = new Array(MAX_GAMEPADS).fill(null);
+ private readonly _sequences = new Float32Array(MAX_GAMEPADS).fill(-1);
+ private readonly _listeners = new Set();
+ private _started = false;
+
+ constructor(private readonly _backend: GamepadBackend) {}
+
+ start() {
+ if (this._started) {
+ return;
+ }
+ this._started = true;
+ this._backend.start(this._buffer, (index, connected, id) => this._onConnection(index, connected, id));
+ }
+
+ getGamepads(): (Gamepad | null)[] {
+ this.start();
+ const buffer = this._buffer;
+ this._backend.poll?.(buffer);
+ const pads = this._pads;
+ let now = -1;
+ for (let i = 0; i < MAX_GAMEPADS; i++) {
+ const pad = pads[i];
+ if (pad === null) {
+ continue;
+ }
+ const sequence = buffer[i * SLOT_STRIDE + SLOT_SEQUENCE];
+ if (sequence !== this._sequences[i]) {
+ this._sequences[i] = sequence;
+ if (now < 0) {
+ now = performance.now();
+ }
+ pad._update(buffer, now);
+ }
+ }
+ return pads.slice();
+ }
+
+ addListener(listener: GamepadListener) {
+ this._listeners.add(listener);
+ this.start();
+ }
+
+ removeListener(listener: GamepadListener) {
+ this._listeners.delete(listener);
+ }
+
+ private _onConnection(index: number, connected: boolean, id: string) {
+ if (index < 0 || index >= MAX_GAMEPADS) {
+ return;
+ }
+ const previous = this._pads[index];
+ if (previous) {
+ previous.connected = false;
+ this._pads[index] = null;
+ this._emit(new GamepadEvent('gamepaddisconnected', previous));
+ }
+ if (connected) {
+ const pad = new Gamepad(index, id, this._buffer);
+ this._sequences[index] = this._buffer[index * SLOT_STRIDE + SLOT_SEQUENCE];
+ this._pads[index] = pad;
+ this._emit(new GamepadEvent('gamepadconnected', pad));
+ }
+ }
+
+ private _emit(event: GamepadEvent) {
+ for (const listener of this._listeners) {
+ try {
+ listener(event);
+ } catch (e) {
+ console.error(e);
+ }
+ }
+ }
+}
diff --git a/packages/canvas-gamepad/index.ts b/packages/canvas-gamepad/index.ts
new file mode 100644
index 000000000..8cd35c596
--- /dev/null
+++ b/packages/canvas-gamepad/index.ts
@@ -0,0 +1,11 @@
+import { Gamepads } from './common';
+import { createBackend } from './backend';
+
+export { Gamepad, GamepadButton, GamepadEvent, Gamepads, MAX_GAMEPADS } from './common';
+export type { GamepadBackend, GamepadListener, GamepadMappingType } from './common';
+
+export const gamepads = new Gamepads(createBackend());
+
+export function getGamepads() {
+ return gamepads.getGamepads();
+}
diff --git a/packages/canvas-gamepad/package.json b/packages/canvas-gamepad/package.json
new file mode 100644
index 000000000..b00b7d1a9
--- /dev/null
+++ b/packages/canvas-gamepad/package.json
@@ -0,0 +1,38 @@
+{
+ "name": "@nativescript/canvas-gamepad",
+ "version": "3.0.0-alpha.16",
+ "description": "Gamepad API (navigator.getGamepads) for NativeScript",
+ "main": "index",
+ "typings": "index.d.ts",
+ "nativescript": {
+ "platforms": {
+ "ios": "6.0.0",
+ "android": "6.0.0",
+ "windows": "9.1.0"
+ }
+ },
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/NativeScript/canvas.git"
+ },
+ "keywords": [
+ "NativeScript",
+ "JavaScript",
+ "TypeScript",
+ "iOS",
+ "Android",
+ "Windows",
+ "Gamepad"
+ ],
+ "author": {
+ "name": "NativeScript",
+ "email": "oss@nativescript.org"
+ },
+ "bugs": {
+ "url": "https://github.com/NativeScript/canvas/issues"
+ },
+ "license": "Apache-2.0",
+ "homepage": "https://github.com/NativeScript/canvas",
+ "readmeFilename": "README.md",
+ "bootstrapper": "@nativescript/plugin-seed"
+}
diff --git a/packages/canvas-gamepad/platforms/android/canvas_gamepad.aar b/packages/canvas-gamepad/platforms/android/canvas_gamepad.aar
new file mode 100644
index 000000000..e207c7e63
Binary files /dev/null and b/packages/canvas-gamepad/platforms/android/canvas_gamepad.aar differ
diff --git a/packages/canvas-gamepad/platforms/android/include.gradle b/packages/canvas-gamepad/platforms/android/include.gradle
new file mode 100644
index 000000000..3152a7d54
--- /dev/null
+++ b/packages/canvas-gamepad/platforms/android/include.gradle
@@ -0,0 +1,10 @@
+android {
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_17
+ targetCompatibility JavaVersion.VERSION_17
+ }
+}
+
+dependencies {
+ implementation "androidx.annotation:annotation:1.8.1"
+}
diff --git a/packages/canvas-gamepad/platforms/android/java/org/nativescript/canvas/gamepad/GamepadWindowCallback.java b/packages/canvas-gamepad/platforms/android/java/org/nativescript/canvas/gamepad/GamepadWindowCallback.java
new file mode 100644
index 000000000..f9b89abe6
--- /dev/null
+++ b/packages/canvas-gamepad/platforms/android/java/org/nativescript/canvas/gamepad/GamepadWindowCallback.java
@@ -0,0 +1,161 @@
+package org.nativescript.canvas.gamepad;
+
+import android.os.Build;
+import android.view.ActionMode;
+import android.view.KeyEvent;
+import android.view.KeyboardShortcutGroup;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.MotionEvent;
+import android.view.SearchEvent;
+import android.view.View;
+import android.view.Window;
+import android.view.WindowManager;
+import android.view.accessibility.AccessibilityEvent;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
+
+import java.util.List;
+
+// Joystick events only reach the focused view.
+final class GamepadWindowCallback implements Window.Callback {
+ private final Window.Callback wrapped;
+
+ GamepadWindowCallback(Window.Callback wrapped) {
+ this.wrapped = wrapped;
+ }
+
+ @Override
+ public boolean dispatchKeyEvent(KeyEvent event) {
+ return NSCGamepadManager.onKeyEvent(event) || wrapped.dispatchKeyEvent(event);
+ }
+
+ @Override
+ public boolean dispatchGenericMotionEvent(MotionEvent event) {
+ return NSCGamepadManager.onMotionEvent(event) || wrapped.dispatchGenericMotionEvent(event);
+ }
+
+ @Override
+ public boolean dispatchKeyShortcutEvent(KeyEvent event) {
+ return wrapped.dispatchKeyShortcutEvent(event);
+ }
+
+ @Override
+ public boolean dispatchTouchEvent(MotionEvent event) {
+ return wrapped.dispatchTouchEvent(event);
+ }
+
+ @Override
+ public boolean dispatchTrackballEvent(MotionEvent event) {
+ return wrapped.dispatchTrackballEvent(event);
+ }
+
+ @Override
+ public boolean dispatchPopulateAccessibilityEvent(AccessibilityEvent event) {
+ return wrapped.dispatchPopulateAccessibilityEvent(event);
+ }
+
+ @Nullable
+ @Override
+ public View onCreatePanelView(int featureId) {
+ return wrapped.onCreatePanelView(featureId);
+ }
+
+ @Override
+ public boolean onCreatePanelMenu(int featureId, @NonNull Menu menu) {
+ return wrapped.onCreatePanelMenu(featureId, menu);
+ }
+
+ @Override
+ public boolean onPreparePanel(int featureId, @Nullable View view, @NonNull Menu menu) {
+ return wrapped.onPreparePanel(featureId, view, menu);
+ }
+
+ @Override
+ public boolean onMenuOpened(int featureId, @NonNull Menu menu) {
+ return wrapped.onMenuOpened(featureId, menu);
+ }
+
+ @Override
+ public boolean onMenuItemSelected(int featureId, @NonNull MenuItem item) {
+ return wrapped.onMenuItemSelected(featureId, item);
+ }
+
+ @Override
+ public void onWindowAttributesChanged(WindowManager.LayoutParams attrs) {
+ wrapped.onWindowAttributesChanged(attrs);
+ }
+
+ @Override
+ public void onContentChanged() {
+ wrapped.onContentChanged();
+ }
+
+ @Override
+ public void onWindowFocusChanged(boolean hasFocus) {
+ wrapped.onWindowFocusChanged(hasFocus);
+ }
+
+ @Override
+ public void onAttachedToWindow() {
+ wrapped.onAttachedToWindow();
+ }
+
+ @Override
+ public void onDetachedFromWindow() {
+ wrapped.onDetachedFromWindow();
+ }
+
+ @Override
+ public void onPanelClosed(int featureId, @NonNull Menu menu) {
+ wrapped.onPanelClosed(featureId, menu);
+ }
+
+ @Override
+ public boolean onSearchRequested() {
+ return wrapped.onSearchRequested();
+ }
+
+ @RequiresApi(Build.VERSION_CODES.M)
+ @Override
+ public boolean onSearchRequested(SearchEvent searchEvent) {
+ return wrapped.onSearchRequested(searchEvent);
+ }
+
+ @Nullable
+ @Override
+ public ActionMode onWindowStartingActionMode(ActionMode.Callback callback) {
+ return wrapped.onWindowStartingActionMode(callback);
+ }
+
+ @RequiresApi(Build.VERSION_CODES.M)
+ @Nullable
+ @Override
+ public ActionMode onWindowStartingActionMode(ActionMode.Callback callback, int type) {
+ return wrapped.onWindowStartingActionMode(callback, type);
+ }
+
+ @Override
+ public void onActionModeStarted(ActionMode mode) {
+ wrapped.onActionModeStarted(mode);
+ }
+
+ @Override
+ public void onActionModeFinished(ActionMode mode) {
+ wrapped.onActionModeFinished(mode);
+ }
+
+ @RequiresApi(Build.VERSION_CODES.N)
+ @Override
+ public void onProvideKeyboardShortcuts(List data, @Nullable Menu menu, int deviceId) {
+ wrapped.onProvideKeyboardShortcuts(data, menu, deviceId);
+ }
+
+ @RequiresApi(Build.VERSION_CODES.O)
+ @Override
+ public void onPointerCaptureChanged(boolean hasCapture) {
+ wrapped.onPointerCaptureChanged(hasCapture);
+ }
+}
diff --git a/packages/canvas-gamepad/platforms/android/java/org/nativescript/canvas/gamepad/NSCGamepadManager.java b/packages/canvas-gamepad/platforms/android/java/org/nativescript/canvas/gamepad/NSCGamepadManager.java
new file mode 100644
index 000000000..baa26d56d
--- /dev/null
+++ b/packages/canvas-gamepad/platforms/android/java/org/nativescript/canvas/gamepad/NSCGamepadManager.java
@@ -0,0 +1,367 @@
+package org.nativescript.canvas.gamepad;
+
+import android.app.Activity;
+import android.app.Application;
+import android.content.Context;
+import android.hardware.input.InputManager;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.view.InputDevice;
+import android.view.KeyEvent;
+import android.view.MotionEvent;
+import android.view.Window;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.nio.FloatBuffer;
+import java.util.Locale;
+
+public final class NSCGamepadManager {
+ public interface Listener {
+ void onConnection(int index, boolean connected, String id);
+ }
+
+ // Keep in sync with packages/canvas-gamepad/common.ts.
+ static final int MAX_GAMEPADS = 4;
+ static final int MAX_AXES = 8;
+ static final int SLOT_CONNECTED = 0;
+ static final int SLOT_SEQUENCE = 1;
+ static final int SLOT_MAPPING = 2;
+ static final int SLOT_AXIS_COUNT = 3;
+ static final int SLOT_BUTTON_COUNT = 4;
+ static final int SLOT_AXES = 5;
+ static final int SLOT_BUTTONS = SLOT_AXES + MAX_AXES;
+ static final int SLOT_STRIDE = 80;
+ static final int BUTTON_PRESSED = 1;
+ static final int BUTTON_TOUCHED = 2;
+ static final float MAPPING_STANDARD = 1;
+ static final int STANDARD_AXES = 4;
+ static final int STANDARD_BUTTONS = 17;
+ // Float32 holds integers exactly up to 2^24.
+ static final float SEQUENCE_WRAP = 16777216f;
+ static final float TRIGGER_PRESSED = 0.1f;
+
+ static final int BUTTON_LEFT_TRIGGER = 6;
+ static final int BUTTON_RIGHT_TRIGGER = 7;
+ static final int BUTTON_DPAD_UP = 12;
+ static final int BUTTON_DPAD_DOWN = 13;
+ static final int BUTTON_DPAD_LEFT = 14;
+ static final int BUTTON_DPAD_RIGHT = 15;
+
+ private static final class Slot {
+ final int deviceId;
+ final boolean hasHat;
+ final boolean hasAnalogTriggers;
+ final int rightX;
+ final int rightY;
+
+ Slot(InputDevice device) {
+ deviceId = device.getId();
+ hasHat = device.getMotionRange(MotionEvent.AXIS_HAT_X) != null;
+ hasAnalogTriggers = device.getMotionRange(MotionEvent.AXIS_LTRIGGER) != null || device.getMotionRange(MotionEvent.AXIS_BRAKE) != null;
+ boolean z = device.getMotionRange(MotionEvent.AXIS_Z) != null;
+ boolean rx = device.getMotionRange(MotionEvent.AXIS_RX) != null;
+ rightX = !z && rx ? MotionEvent.AXIS_RX : MotionEvent.AXIS_Z;
+ rightY = !z && rx ? MotionEvent.AXIS_RY : MotionEvent.AXIS_RZ;
+ }
+ }
+
+ private static final Slot[] slots = new Slot[MAX_GAMEPADS];
+ @Nullable
+ private static FloatBuffer buffer;
+ @Nullable
+ private static Listener listener;
+ @Nullable
+ private static InputManager inputManager;
+ @Nullable
+ private static Application application;
+
+ private static final InputManager.InputDeviceListener deviceListener = new InputManager.InputDeviceListener() {
+ @Override
+ public void onInputDeviceAdded(int deviceId) {
+ attach(deviceId);
+ }
+
+ @Override
+ public void onInputDeviceRemoved(int deviceId) {
+ detach(deviceId);
+ }
+
+ @Override
+ public void onInputDeviceChanged(int deviceId) {
+ }
+ };
+
+ private static final Application.ActivityLifecycleCallbacks lifecycle = new Application.ActivityLifecycleCallbacks() {
+ @Override
+ public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle savedInstanceState) {
+ hook(activity);
+ }
+
+ @Override
+ public void onActivityStarted(@NonNull Activity activity) {
+ }
+
+ @Override
+ public void onActivityResumed(@NonNull Activity activity) {
+ hook(activity);
+ }
+
+ @Override
+ public void onActivityPaused(@NonNull Activity activity) {
+ }
+
+ @Override
+ public void onActivityStopped(@NonNull Activity activity) {
+ }
+
+ @Override
+ public void onActivitySaveInstanceState(@NonNull Activity activity, @NonNull Bundle outState) {
+ }
+
+ @Override
+ public void onActivityDestroyed(@NonNull Activity activity) {
+ }
+ };
+
+ private NSCGamepadManager() {
+ }
+
+ public static void start(@NonNull Context context, @Nullable Activity activity, @NonNull FloatBuffer buffer, @NonNull Listener listener) {
+ stop();
+ if (buffer.capacity() < MAX_GAMEPADS * SLOT_STRIDE) {
+ return;
+ }
+ NSCGamepadManager.buffer = buffer;
+ NSCGamepadManager.listener = listener;
+ for (int i = 0; i < MAX_GAMEPADS * SLOT_STRIDE; i++) {
+ buffer.put(i, 0);
+ }
+
+ Context app = context.getApplicationContext();
+ if (app instanceof Application) {
+ application = (Application) app;
+ application.registerActivityLifecycleCallbacks(lifecycle);
+ }
+ if (activity != null) {
+ hook(activity);
+ }
+
+ inputManager = (InputManager) app.getSystemService(Context.INPUT_SERVICE);
+ if (inputManager != null) {
+ inputManager.registerInputDeviceListener(deviceListener, new Handler(Looper.getMainLooper()));
+ for (int id : inputManager.getInputDeviceIds()) {
+ attach(id);
+ }
+ }
+ }
+
+ public static void stop() {
+ if (inputManager != null) {
+ inputManager.unregisterInputDeviceListener(deviceListener);
+ inputManager = null;
+ }
+ if (application != null) {
+ application.unregisterActivityLifecycleCallbacks(lifecycle);
+ application = null;
+ }
+ for (int i = 0; i < MAX_GAMEPADS; i++) {
+ slots[i] = null;
+ }
+ buffer = null;
+ listener = null;
+ }
+
+ static boolean isGamepad(int sources) {
+ return (sources & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD || (sources & InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK;
+ }
+
+ private static void hook(Activity activity) {
+ Window window = activity.getWindow();
+ if (window == null) {
+ return;
+ }
+ Window.Callback callback = window.getCallback();
+ if (callback != null && !(callback instanceof GamepadWindowCallback)) {
+ window.setCallback(new GamepadWindowCallback(callback));
+ }
+ }
+
+ private static int slotOf(int deviceId) {
+ for (int i = 0; i < MAX_GAMEPADS; i++) {
+ Slot slot = slots[i];
+ if (slot != null && slot.deviceId == deviceId) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ private static void attach(int deviceId) {
+ FloatBuffer buffer = NSCGamepadManager.buffer;
+ InputDevice device = InputDevice.getDevice(deviceId);
+ if (buffer == null || device == null || device.isVirtual() || !isGamepad(device.getSources()) || slotOf(deviceId) != -1) {
+ return;
+ }
+ int index = -1;
+ for (int i = 0; i < MAX_GAMEPADS; i++) {
+ if (slots[i] == null) {
+ index = i;
+ break;
+ }
+ }
+ if (index == -1) {
+ return;
+ }
+ slots[index] = new Slot(device);
+
+ int base = index * SLOT_STRIDE;
+ for (int i = 0; i < SLOT_STRIDE; i++) {
+ buffer.put(base + i, 0);
+ }
+ buffer.put(base + SLOT_CONNECTED, 1);
+ buffer.put(base + SLOT_MAPPING, MAPPING_STANDARD);
+ buffer.put(base + SLOT_AXIS_COUNT, STANDARD_AXES);
+ buffer.put(base + SLOT_BUTTON_COUNT, STANDARD_BUTTONS);
+
+ if (listener != null) {
+ String id = String.format(Locale.US, "%s (STANDARD GAMEPAD Vendor: %04x Product: %04x)", device.getName(), device.getVendorId(), device.getProductId());
+ listener.onConnection(index, true, id);
+ }
+ }
+
+ private static void detach(int deviceId) {
+ FloatBuffer buffer = NSCGamepadManager.buffer;
+ int index = slotOf(deviceId);
+ if (buffer == null || index == -1) {
+ return;
+ }
+ slots[index] = null;
+ int base = index * SLOT_STRIDE;
+ for (int i = 0; i < SLOT_STRIDE; i++) {
+ buffer.put(base + i, 0);
+ }
+ if (listener != null) {
+ listener.onConnection(index, false, "");
+ }
+ }
+
+ static int buttonForKey(int keyCode) {
+ switch (keyCode) {
+ case KeyEvent.KEYCODE_BUTTON_A:
+ return 0;
+ case KeyEvent.KEYCODE_BUTTON_B:
+ return 1;
+ case KeyEvent.KEYCODE_BUTTON_X:
+ return 2;
+ case KeyEvent.KEYCODE_BUTTON_Y:
+ return 3;
+ case KeyEvent.KEYCODE_BUTTON_L1:
+ return 4;
+ case KeyEvent.KEYCODE_BUTTON_R1:
+ return 5;
+ case KeyEvent.KEYCODE_BUTTON_L2:
+ return BUTTON_LEFT_TRIGGER;
+ case KeyEvent.KEYCODE_BUTTON_R2:
+ return BUTTON_RIGHT_TRIGGER;
+ case KeyEvent.KEYCODE_BUTTON_SELECT:
+ return 8;
+ case KeyEvent.KEYCODE_BUTTON_START:
+ return 9;
+ case KeyEvent.KEYCODE_BUTTON_THUMBL:
+ return 10;
+ case KeyEvent.KEYCODE_BUTTON_THUMBR:
+ return 11;
+ case KeyEvent.KEYCODE_DPAD_UP:
+ return BUTTON_DPAD_UP;
+ case KeyEvent.KEYCODE_DPAD_DOWN:
+ return BUTTON_DPAD_DOWN;
+ case KeyEvent.KEYCODE_DPAD_LEFT:
+ return BUTTON_DPAD_LEFT;
+ case KeyEvent.KEYCODE_DPAD_RIGHT:
+ return BUTTON_DPAD_RIGHT;
+ case KeyEvent.KEYCODE_BUTTON_MODE:
+ return 16;
+ default:
+ return -1;
+ }
+ }
+
+ private static void writeButton(FloatBuffer buffer, int base, int button, float value, boolean pressed) {
+ int offset = base + SLOT_BUTTONS + button * 2;
+ buffer.put(offset, value);
+ buffer.put(offset + 1, (pressed ? BUTTON_PRESSED : 0) | (pressed || value > 0 ? BUTTON_TOUCHED : 0));
+ }
+
+ private static void bump(FloatBuffer buffer, int base) {
+ buffer.put(base + SLOT_SEQUENCE, (buffer.get(base + SLOT_SEQUENCE) + 1) % SEQUENCE_WRAP);
+ }
+
+ static boolean onKeyEvent(KeyEvent event) {
+ FloatBuffer buffer = NSCGamepadManager.buffer;
+ if (buffer == null || !isGamepad(event.getSource())) {
+ return false;
+ }
+ int index = slotOf(event.getDeviceId());
+ int button = buttonForKey(event.getKeyCode());
+ if (index == -1 || button == -1) {
+ return false;
+ }
+ int action = event.getAction();
+ if (action != KeyEvent.ACTION_DOWN && action != KeyEvent.ACTION_UP) {
+ return true;
+ }
+ if (event.getRepeatCount() > 0) {
+ return true;
+ }
+ Slot slot = slots[index];
+ boolean analogTrigger = slot.hasAnalogTriggers && (button == BUTTON_LEFT_TRIGGER || button == BUTTON_RIGHT_TRIGGER);
+ boolean hatDpad = slot.hasHat && button >= BUTTON_DPAD_UP && button <= BUTTON_DPAD_RIGHT;
+ if (!analogTrigger && !hatDpad) {
+ int base = index * SLOT_STRIDE;
+ boolean down = action == KeyEvent.ACTION_DOWN;
+ writeButton(buffer, base, button, down ? 1 : 0, down);
+ bump(buffer, base);
+ }
+ return true;
+ }
+
+ static boolean onMotionEvent(MotionEvent event) {
+ FloatBuffer buffer = NSCGamepadManager.buffer;
+ if (buffer == null || (event.getSource() & InputDevice.SOURCE_JOYSTICK) != InputDevice.SOURCE_JOYSTICK || event.getActionMasked() != MotionEvent.ACTION_MOVE) {
+ return false;
+ }
+ int index = slotOf(event.getDeviceId());
+ if (index == -1) {
+ return false;
+ }
+ Slot slot = slots[index];
+ int base = index * SLOT_STRIDE;
+
+ buffer.put(base + SLOT_AXES, event.getAxisValue(MotionEvent.AXIS_X));
+ buffer.put(base + SLOT_AXES + 1, event.getAxisValue(MotionEvent.AXIS_Y));
+ buffer.put(base + SLOT_AXES + 2, event.getAxisValue(slot.rightX));
+ buffer.put(base + SLOT_AXES + 3, event.getAxisValue(slot.rightY));
+
+ if (slot.hasAnalogTriggers) {
+ float left = Math.max(event.getAxisValue(MotionEvent.AXIS_LTRIGGER), event.getAxisValue(MotionEvent.AXIS_BRAKE));
+ float right = Math.max(event.getAxisValue(MotionEvent.AXIS_RTRIGGER), event.getAxisValue(MotionEvent.AXIS_GAS));
+ writeButton(buffer, base, BUTTON_LEFT_TRIGGER, left, left > TRIGGER_PRESSED);
+ writeButton(buffer, base, BUTTON_RIGHT_TRIGGER, right, right > TRIGGER_PRESSED);
+ }
+
+ if (slot.hasHat) {
+ float x = event.getAxisValue(MotionEvent.AXIS_HAT_X);
+ float y = event.getAxisValue(MotionEvent.AXIS_HAT_Y);
+ writeButton(buffer, base, BUTTON_DPAD_UP, y < -0.5f ? 1 : 0, y < -0.5f);
+ writeButton(buffer, base, BUTTON_DPAD_DOWN, y > 0.5f ? 1 : 0, y > 0.5f);
+ writeButton(buffer, base, BUTTON_DPAD_LEFT, x < -0.5f ? 1 : 0, x < -0.5f);
+ writeButton(buffer, base, BUTTON_DPAD_RIGHT, x > 0.5f ? 1 : 0, x > 0.5f);
+ }
+
+ bump(buffer, base);
+ return true;
+ }
+}
diff --git a/packages/canvas-gamepad/platforms/ios/build.xcconfig b/packages/canvas-gamepad/platforms/ios/build.xcconfig
new file mode 100644
index 000000000..6146ffdc3
--- /dev/null
+++ b/packages/canvas-gamepad/platforms/ios/build.xcconfig
@@ -0,0 +1 @@
+OTHER_LDFLAGS = $(inherited) -framework "GameController"
diff --git a/packages/canvas-gamepad/platforms/ios/src/NSCCanvasGamepadUmbrella.h b/packages/canvas-gamepad/platforms/ios/src/NSCCanvasGamepadUmbrella.h
new file mode 100644
index 000000000..a1ee85320
--- /dev/null
+++ b/packages/canvas-gamepad/platforms/ios/src/NSCCanvasGamepadUmbrella.h
@@ -0,0 +1 @@
+#import "NSCGamepadManager.h"
diff --git a/packages/canvas-gamepad/platforms/ios/src/NSCGamepadManager.h b/packages/canvas-gamepad/platforms/ios/src/NSCGamepadManager.h
new file mode 100644
index 000000000..b5f790723
--- /dev/null
+++ b/packages/canvas-gamepad/platforms/ios/src/NSCGamepadManager.h
@@ -0,0 +1,17 @@
+#import
+
+NS_ASSUME_NONNULL_BEGIN
+
+typedef void (^NSCGamepadConnectionListener)(NSInteger index, BOOL connected, NSString *gamepadId);
+
+@interface NSCGamepadManager : NSObject
+
+@property (class, nonatomic, readonly, strong) NSCGamepadManager *shared;
+
+- (void)startWithBuffer:(void *)buffer length:(NSInteger)length listener:(NSCGamepadConnectionListener)listener;
+
+- (void)stop;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/packages/canvas-gamepad/platforms/ios/src/NSCGamepadManager.m b/packages/canvas-gamepad/platforms/ios/src/NSCGamepadManager.m
new file mode 100644
index 000000000..680e7cbbe
--- /dev/null
+++ b/packages/canvas-gamepad/platforms/ios/src/NSCGamepadManager.m
@@ -0,0 +1,191 @@
+#import "NSCGamepadManager.h"
+#import
+
+// Keep in sync with packages/canvas-gamepad/common.ts.
+static const NSInteger kMaxGamepads = 4;
+static const NSInteger kMaxAxes = 8;
+static const NSInteger kSlotConnected = 0;
+static const NSInteger kSlotSequence = 1;
+static const NSInteger kSlotMapping = 2;
+static const NSInteger kSlotAxisCount = 3;
+static const NSInteger kSlotButtonCount = 4;
+static const NSInteger kSlotAxes = 5;
+static const NSInteger kSlotButtons = kSlotAxes + kMaxAxes;
+static const NSInteger kSlotStride = 80;
+static const int kButtonPressed = 1;
+static const int kButtonTouched = 2;
+static const float kMappingStandard = 1;
+static const NSInteger kStandardAxes = 4;
+static const NSInteger kStandardButtons = 17;
+// Float32 holds integers exactly up to 2^24.
+static const float kSequenceWrap = 16777216.0f;
+
+static inline void NSCWriteButton(float *slot, NSInteger index, GCControllerButtonInput *_Nullable button) {
+ float *out = slot + kSlotButtons + index * 2;
+ if (button == nil) {
+ out[0] = 0;
+ out[1] = 0;
+ return;
+ }
+ float value = button.value;
+ BOOL pressed = button.isPressed;
+ out[0] = value;
+ out[1] = (float) ((pressed ? kButtonPressed : 0) | ((pressed || value > 0) ? kButtonTouched : 0));
+}
+
+@implementation NSCGamepadManager {
+ float *_buffer;
+ NSCGamepadConnectionListener _listener;
+ NSMutableArray *_slots;
+}
+
++ (NSCGamepadManager *)shared {
+ static NSCGamepadManager *shared;
+ static dispatch_once_t once;
+ dispatch_once(&once, ^{
+ shared = [[NSCGamepadManager alloc] init];
+ });
+ return shared;
+}
+
+- (instancetype)init {
+ if (self = [super init]) {
+ _slots = [NSMutableArray arrayWithCapacity:kMaxGamepads];
+ for (NSInteger i = 0; i < kMaxGamepads; i++) {
+ [_slots addObject:NSNull.null];
+ }
+ }
+ return self;
+}
+
+- (void)startWithBuffer:(void *)buffer length:(NSInteger)length listener:(NSCGamepadConnectionListener)listener {
+ [self stop];
+ if (buffer == NULL || length < kMaxGamepads * kSlotStride) {
+ return;
+ }
+ _buffer = (float *) buffer;
+ _listener = [listener copy];
+ memset(_buffer, 0, sizeof(float) * kMaxGamepads * kSlotStride);
+
+ NSNotificationCenter *center = NSNotificationCenter.defaultCenter;
+ [center addObserver:self selector:@selector(controllerDidConnect:) name:GCControllerDidConnectNotification object:nil];
+ [center addObserver:self selector:@selector(controllerDidDisconnect:) name:GCControllerDidDisconnectNotification object:nil];
+
+ for (GCController *controller in GCController.controllers) {
+ [self attach:controller];
+ }
+}
+
+- (void)stop {
+ [NSNotificationCenter.defaultCenter removeObserver:self];
+ for (NSInteger i = 0; i < kMaxGamepads; i++) {
+ id controller = _slots[i];
+ if (controller != NSNull.null) {
+ ((GCController *) controller).extendedGamepad.valueChangedHandler = nil;
+ _slots[i] = NSNull.null;
+ }
+ }
+ _buffer = NULL;
+ _listener = nil;
+}
+
+- (void)controllerDidConnect:(NSNotification *)notification {
+ [self attach:notification.object];
+}
+
+- (void)controllerDidDisconnect:(NSNotification *)notification {
+ GCController *controller = notification.object;
+ NSUInteger index = [_slots indexOfObjectIdenticalTo:controller];
+ if (index == NSNotFound || _buffer == NULL) {
+ return;
+ }
+ controller.extendedGamepad.valueChangedHandler = nil;
+ _slots[index] = NSNull.null;
+ memset(_buffer + index * kSlotStride, 0, sizeof(float) * kSlotStride);
+ if (_listener) {
+ _listener(index, NO, @"");
+ }
+}
+
+- (void)attach:(GCController *)controller {
+ GCExtendedGamepad *gamepad = controller.extendedGamepad;
+ // The Siri Remote stays on NSCCanvas's keyboard mapping.
+ if (gamepad == nil || _buffer == NULL || [_slots indexOfObjectIdenticalTo:controller] != NSNotFound) {
+ return;
+ }
+ NSUInteger index = [_slots indexOfObjectIdenticalTo:NSNull.null];
+ if (index == NSNotFound) {
+ return;
+ }
+ _slots[index] = controller;
+
+ float *slot = _buffer + index * kSlotStride;
+ slot[kSlotConnected] = 1;
+ slot[kSlotMapping] = kMappingStandard;
+ slot[kSlotAxisCount] = kStandardAxes;
+ slot[kSlotButtonCount] = kStandardButtons;
+ [self write:gamepad slot:slot];
+
+ __weak NSCGamepadManager *weakSelf = self;
+ gamepad.valueChangedHandler = ^(GCExtendedGamepad *changed, __unused GCControllerElement *element) {
+ [weakSelf update:changed];
+ };
+
+ if (_listener) {
+ NSString *name = controller.vendorName ?: @"Gamepad";
+ _listener(index, YES, [NSString stringWithFormat:@"%@ (STANDARD GAMEPAD)", name]);
+ }
+}
+
+- (void)update:(GCExtendedGamepad *)gamepad {
+ if (_buffer == NULL) {
+ return;
+ }
+ NSUInteger index = [_slots indexOfObjectIdenticalTo:gamepad.controller];
+ if (index == NSNotFound) {
+ return;
+ }
+ [self write:gamepad slot:_buffer + index * kSlotStride];
+}
+
+- (void)write:(GCExtendedGamepad *)gamepad slot:(float *)slot {
+ float *axes = slot + kSlotAxes;
+ axes[0] = gamepad.leftThumbstick.xAxis.value;
+ axes[1] = -gamepad.leftThumbstick.yAxis.value;
+ axes[2] = gamepad.rightThumbstick.xAxis.value;
+ axes[3] = -gamepad.rightThumbstick.yAxis.value;
+
+ NSCWriteButton(slot, 0, gamepad.buttonA);
+ NSCWriteButton(slot, 1, gamepad.buttonB);
+ NSCWriteButton(slot, 2, gamepad.buttonX);
+ NSCWriteButton(slot, 3, gamepad.buttonY);
+ NSCWriteButton(slot, 4, gamepad.leftShoulder);
+ NSCWriteButton(slot, 5, gamepad.rightShoulder);
+ NSCWriteButton(slot, 6, gamepad.leftTrigger);
+ NSCWriteButton(slot, 7, gamepad.rightTrigger);
+ GCControllerButtonInput *options = nil, *menu = nil, *home = nil, *leftStick = nil, *rightStick = nil;
+ if (@available(iOS 12.1, tvOS 12.1, *)) {
+ leftStick = gamepad.leftThumbstickButton;
+ rightStick = gamepad.rightThumbstickButton;
+ }
+ if (@available(iOS 13.0, tvOS 13.0, *)) {
+ options = gamepad.buttonOptions;
+ menu = gamepad.buttonMenu;
+ }
+ if (@available(iOS 14.0, tvOS 14.0, *)) {
+ home = gamepad.buttonHome;
+ }
+ NSCWriteButton(slot, 8, options);
+ NSCWriteButton(slot, 9, menu);
+ NSCWriteButton(slot, 10, leftStick);
+ NSCWriteButton(slot, 11, rightStick);
+ NSCWriteButton(slot, 12, gamepad.dpad.up);
+ NSCWriteButton(slot, 13, gamepad.dpad.down);
+ NSCWriteButton(slot, 14, gamepad.dpad.left);
+ NSCWriteButton(slot, 15, gamepad.dpad.right);
+ NSCWriteButton(slot, 16, home);
+
+ slot[kSlotSequence] = fmodf(slot[kSlotSequence] + 1, kSequenceWrap);
+}
+
+@end
diff --git a/packages/canvas-gamepad/platforms/ios/src/module.modulemap b/packages/canvas-gamepad/platforms/ios/src/module.modulemap
new file mode 100644
index 000000000..01240421c
--- /dev/null
+++ b/packages/canvas-gamepad/platforms/ios/src/module.modulemap
@@ -0,0 +1,5 @@
+module NSCCanvasGamepad {
+ umbrella header "NSCCanvasGamepadUmbrella.h"
+ export *
+ module * { export * }
+}
diff --git a/packages/canvas-gamepad/platforms/windows/arm64/canvasgamepad.node b/packages/canvas-gamepad/platforms/windows/arm64/canvasgamepad.node
new file mode 100644
index 000000000..f667355a7
Binary files /dev/null and b/packages/canvas-gamepad/platforms/windows/arm64/canvasgamepad.node differ
diff --git a/packages/canvas-gamepad/platforms/windows/plugin.props b/packages/canvas-gamepad/platforms/windows/plugin.props
new file mode 100644
index 000000000..2dd101ce0
--- /dev/null
+++ b/packages/canvas-gamepad/platforms/windows/plugin.props
@@ -0,0 +1,2 @@
+
+
diff --git a/packages/canvas-gamepad/platforms/windows/plugin.targets b/packages/canvas-gamepad/platforms/windows/plugin.targets
new file mode 100644
index 000000000..c06394e0b
--- /dev/null
+++ b/packages/canvas-gamepad/platforms/windows/plugin.targets
@@ -0,0 +1,19 @@
+
+
+
+ <_NSCanvasGamepadArch Condition="'$(NSLibPlatform)' != ''">$(NSLibPlatform)
+ <_NSCanvasGamepadArch Condition="'$(_NSCanvasGamepadArch)' == '' and ('$(Platform)' == 'ARM64' or '$(Platform)' == 'arm64')">arm64
+
+ <_NSCanvasGamepadArch Condition="'$(_NSCanvasGamepadArch)' == '' or '$(_NSCanvasGamepadArch)' == 'x86_64'">x64
+ <_NSCanvasGamepadDir>$(MSBuildThisFileDirectory)$(_NSCanvasGamepadArch)\
+
+
+
+
+
+ <_NSCanvasGamepadFiles Include="$(_NSCanvasGamepadDir)*.node" />
+
+
+
+
+
diff --git a/packages/canvas-gamepad/platforms/windows/x64/canvasgamepad.node b/packages/canvas-gamepad/platforms/windows/x64/canvasgamepad.node
new file mode 100644
index 000000000..357955e8e
Binary files /dev/null and b/packages/canvas-gamepad/platforms/windows/x64/canvasgamepad.node differ
diff --git a/packages/canvas-gamepad/project.json b/packages/canvas-gamepad/project.json
new file mode 100644
index 000000000..1682b0ba1
--- /dev/null
+++ b/packages/canvas-gamepad/project.json
@@ -0,0 +1,70 @@
+{
+ "name": "canvas-gamepad",
+ "$schema": "../../node_modules/nx/schemas/project-schema.json",
+ "sourceRoot": "packages/canvas-gamepad",
+ "projectType": "library",
+ "generators": {},
+ "targets": {
+ "build": {
+ "options": {
+ "outputPath": "dist/packages/canvas-gamepad",
+ "tsConfig": "packages/canvas-gamepad/tsconfig.json",
+ "packageJson": "packages/canvas-gamepad/package.json",
+ "main": "packages/canvas-gamepad/index.ts",
+ "assets": [
+ "packages/canvas-gamepad/*.md",
+ {
+ "glob": "backend.d.ts",
+ "input": "packages/canvas-gamepad/",
+ "output": "./"
+ },
+ "LICENSE",
+ {
+ "glob": "**/*",
+ "input": "packages/canvas-gamepad/platforms/",
+ "output": "./platforms/"
+ },
+ {
+ "glob": "**/*.d.ts",
+ "input": "packages/canvas-gamepad/typings/",
+ "output": "./typings"
+ }
+ ]
+ },
+ "executor": "@nx/js:tsc",
+ "dependsOn": [
+ {
+ "target": "build.all",
+ "dependencies": true
+ }
+ ]
+ },
+ "build.all": {
+ "outputs": ["{workspaceRoot}/dist/packages/canvas-gamepad"],
+ "options": {
+ "commands": ["node tools/scripts/build-finish.ts canvas-gamepad"],
+ "parallel": false
+ },
+ "executor": "nx:run-commands",
+ "dependsOn": [
+ {
+ "target": "build.all",
+ "dependencies": true
+ },
+ {
+ "target": "build"
+ }
+ ]
+ },
+ "focus": {
+ "options": {
+ "commands": ["nx workspace-schematic focus-packages canvas-gamepad"],
+ "parallel": false
+ },
+ "executor": "nx:run-commands"
+ },
+ "lint": {
+ "executor": "@nx/eslint:lint"
+ }
+ }
+}
diff --git a/packages/canvas-gamepad/references.d.ts b/packages/canvas-gamepad/references.d.ts
new file mode 100644
index 000000000..22bac92c6
--- /dev/null
+++ b/packages/canvas-gamepad/references.d.ts
@@ -0,0 +1 @@
+///
diff --git a/packages/canvas-gamepad/tsconfig.json b/packages/canvas-gamepad/tsconfig.json
new file mode 100644
index 000000000..ff0d2fc68
--- /dev/null
+++ b/packages/canvas-gamepad/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "outDir": "../../dist/out-tsc",
+ "rootDir": "."
+ },
+ "exclude": ["**/*.spec.ts", "angular"],
+ "include": ["**/*.ts", "references.d.ts"]
+}
diff --git a/packages/canvas-gamepad/typings/android.d.ts b/packages/canvas-gamepad/typings/android.d.ts
new file mode 100644
index 000000000..8fef8ead7
--- /dev/null
+++ b/packages/canvas-gamepad/typings/android.d.ts
@@ -0,0 +1,18 @@
+declare namespace org {
+ export namespace nativescript {
+ export namespace canvas {
+ export namespace gamepad {
+ export class NSCGamepadManager {
+ static start(context: android.content.Context, activity: android.app.Activity | null, buffer: java.nio.FloatBuffer, listener: NSCGamepadManager.Listener): void;
+ static stop(): void;
+ }
+ export namespace NSCGamepadManager {
+ export class Listener {
+ constructor(implementation: { onConnection(index: number, connected: boolean, id: string): void });
+ onConnection(index: number, connected: boolean, id: string): void;
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/packages/canvas-gamepad/typings/objc!NSCCanvasGamepad.d.ts b/packages/canvas-gamepad/typings/objc!NSCCanvasGamepad.d.ts
new file mode 100644
index 000000000..0d3bde3b3
--- /dev/null
+++ b/packages/canvas-gamepad/typings/objc!NSCCanvasGamepad.d.ts
@@ -0,0 +1,11 @@
+declare class NSCGamepadManager extends NSObject {
+ static alloc(): NSCGamepadManager; // inherited from NSObject
+
+ static new(): NSCGamepadManager; // inherited from NSObject
+
+ static readonly shared: NSCGamepadManager;
+
+ startWithBufferLengthListener(buffer: interop.Pointer | interop.Reference, length: number, listener: (p1: number, p2: boolean, p3: string) => void): void;
+
+ stop(): void;
+}
diff --git a/packages/canvas-media/package.json b/packages/canvas-media/package.json
index cf99bf828..52c968a54 100644
--- a/packages/canvas-media/package.json
+++ b/packages/canvas-media/package.json
@@ -1,6 +1,6 @@
{
"name": "@nativescript/canvas-media",
- "version": "3.0.0-alpha.15",
+ "version": "3.0.0-alpha.16",
"description": "Canvas media",
"main": "index",
"typings": "index.d.ts",
diff --git a/packages/canvas-phaser-ce/package.json b/packages/canvas-phaser-ce/package.json
index 0bad8632f..e17969598 100644
--- a/packages/canvas-phaser-ce/package.json
+++ b/packages/canvas-phaser-ce/package.json
@@ -1,6 +1,6 @@
{
"name": "@nativescript/canvas-phaser-ce",
- "version": "3.0.0-alpha.15",
+ "version": "3.0.0-alpha.16",
"description": "Tools for using Phaser-ce to build native 2D games in NativeScript 👾",
"main": "index",
"typings": "index.d.ts",
diff --git a/packages/canvas-phaser/package.json b/packages/canvas-phaser/package.json
index 36b631e42..32f7c4aae 100644
--- a/packages/canvas-phaser/package.json
+++ b/packages/canvas-phaser/package.json
@@ -1,6 +1,6 @@
{
"name": "@nativescript/canvas-phaser",
- "version": "3.0.0-alpha.15",
+ "version": "3.0.0-alpha.16",
"description": "Build awesome 2D games with Phaser.js and NativeScript",
"main": "index",
"typings": "index.d.ts",
diff --git a/packages/canvas-pixi/package.json b/packages/canvas-pixi/package.json
index 52e4e11c2..97137b97d 100644
--- a/packages/canvas-pixi/package.json
+++ b/packages/canvas-pixi/package.json
@@ -1,6 +1,6 @@
{
"name": "@nativescript/canvas-pixi",
- "version": "3.0.0-alpha.15",
+ "version": "3.0.0-alpha.16",
"description": "Plugin for using pixi.js in NativeScript",
"main": "index",
"typings": "index.d.ts",
diff --git a/packages/canvas-polyfill/gamepad.ts b/packages/canvas-polyfill/gamepad.ts
new file mode 100644
index 000000000..f23a38a98
--- /dev/null
+++ b/packages/canvas-polyfill/gamepad.ts
@@ -0,0 +1,43 @@
+let gamepadModule: { getGamepads(): unknown[] } | null | undefined;
+
+function dispatch(type: string, gamepad: unknown) {
+ const g = global as any;
+ // The iOS runtime has its own global EventTarget.
+ if (typeof g.dispatchEvent === 'function' && typeof g.Event === 'function') {
+ const event = new g.Event(type);
+ event.gamepad = gamepad;
+ g.dispatchEvent(event);
+ }
+ const emitter = g.emitter;
+ emitter?.notify?.({ eventName: type, object: emitter, type, gamepad });
+}
+
+export function gamepads() {
+ if (gamepadModule === undefined) {
+ gamepadModule = null;
+ try {
+ // @ts-ignore
+ const module = require('@nativescript/canvas-gamepad');
+ module.gamepads.addListener((event) => dispatch(event.type, event.gamepad));
+ gamepadModule = module;
+ } catch (_e) {}
+ }
+ return gamepadModule;
+}
+
+export function hookGamepadListeners() {
+ for (const host of new Set([global as any, (global as any).window])) {
+ const add = host?.addEventListener;
+ if (typeof add !== 'function' || add.__gamepadHook) {
+ continue;
+ }
+ const hooked = function (this: unknown, type: string, ...rest: unknown[]) {
+ if (type === 'gamepadconnected' || type === 'gamepaddisconnected') {
+ gamepads();
+ }
+ return add.call(this, type, ...rest);
+ };
+ hooked.__gamepadHook = true;
+ host.addEventListener = hooked;
+ }
+}
diff --git a/packages/canvas-polyfill/navigator.ts b/packages/canvas-polyfill/navigator.ts
index 4b40b06ba..b69a93e91 100644
--- a/packages/canvas-polyfill/navigator.ts
+++ b/packages/canvas-polyfill/navigator.ts
@@ -1,5 +1,6 @@
import { Device } from '@nativescript/core';
import { GPU } from '@nativescript/canvas';
+import { gamepads } from './gamepad';
const gpu = new GPU();
@@ -29,7 +30,6 @@ export class Navigator {
return Device.os;
}
getGamepads() {
- // todo
- return [];
+ return gamepads()?.getGamepads() ?? [];
}
}
diff --git a/packages/canvas-polyfill/package.json b/packages/canvas-polyfill/package.json
index b0e10cec8..f5a19d13d 100644
--- a/packages/canvas-polyfill/package.json
+++ b/packages/canvas-polyfill/package.json
@@ -1,6 +1,6 @@
{
"name": "@nativescript/canvas-polyfill",
- "version": "3.0.0-alpha.15",
+ "version": "3.0.0-alpha.16",
"description": "Polyfill for making NativeScript compatible with web libs like pixi.js, three.js, phaser.js, babylon.js, etc....",
"main": "index",
"typings": "global.d.ts",
diff --git a/packages/canvas-polyfill/window.ts b/packages/canvas-polyfill/window.ts
index 0cdf64d7a..5be3ad449 100644
--- a/packages/canvas-polyfill/window.ts
+++ b/packages/canvas-polyfill/window.ts
@@ -12,6 +12,7 @@ import { CanvasRenderingContext2D, WebGLRenderingContext, WebGL2RenderingContext
import { HTMLCollection } from './DOM/HTMLCollection';
import { HTMLUnknownElement } from './DOM/HTMLUnknownElement';
import { Navigator } from './navigator';
+import { hookGamepadListeners } from './gamepad';
(global as any).CANVAS_RENDERER = 'true';
(global as any).WEBGL_RENDERER = 'true';
(global as any).window = (global as any).window || {
@@ -145,6 +146,7 @@ for (const method of ['addEventListener', 'removeEventListener']) {
if (typeof host.window[method] !== 'function') host.window[method] = host[method].bind(host);
if (typeof host[method] !== 'function') host[method] = host.window[method].bind(host.window);
}
+hookGamepadListeners();
import { DOMParser as Parser } from '@xmldom/xmldom';
diff --git a/packages/canvas-svg/package.json b/packages/canvas-svg/package.json
index 39b14772e..ebcf4c682 100644
--- a/packages/canvas-svg/package.json
+++ b/packages/canvas-svg/package.json
@@ -1,6 +1,6 @@
{
"name": "@nativescript/canvas-svg",
- "version": "3.0.0-alpha.15",
+ "version": "3.0.0-alpha.16",
"description": "Add a plugin description",
"main": "index",
"typings": "index.d.ts",
diff --git a/packages/canvas-three/package.json b/packages/canvas-three/package.json
index da86b2b79..c59209993 100644
--- a/packages/canvas-three/package.json
+++ b/packages/canvas-three/package.json
@@ -1,6 +1,6 @@
{
"name": "@nativescript/canvas-three",
- "version": "3.0.0-alpha.15",
+ "version": "3.0.0-alpha.16",
"description": "Utilities for using THREE.js on NativeScript",
"main": "index",
"typings": "index.d.ts",
diff --git a/packages/canvas/package.json b/packages/canvas/package.json
index af89a57a7..963f37098 100644
--- a/packages/canvas/package.json
+++ b/packages/canvas/package.json
@@ -1,6 +1,6 @@
{
"name": "@nativescript/canvas",
- "version": "3.0.0-alpha.15",
+ "version": "3.0.0-alpha.16",
"description": "DOM Canvas API for NativeScript",
"main": "index",
"typings": "index.d.ts",
diff --git a/packages/canvas/platforms/windows/arm64/canvasnative.node b/packages/canvas/platforms/windows/arm64/canvasnative.node
index 133875fb9..c71006cff 100644
Binary files a/packages/canvas/platforms/windows/arm64/canvasnative.node and b/packages/canvas/platforms/windows/arm64/canvasnative.node differ
diff --git a/packages/canvas/platforms/windows/x64/canvasnative.node b/packages/canvas/platforms/windows/x64/canvasnative.node
index 072901e5b..7030e9f61 100644
Binary files a/packages/canvas/platforms/windows/x64/canvasnative.node and b/packages/canvas/platforms/windows/x64/canvasnative.node differ
diff --git a/tools/demo/canvas-gamepad/index.ts b/tools/demo/canvas-gamepad/index.ts
new file mode 100644
index 000000000..46f37e7e1
--- /dev/null
+++ b/tools/demo/canvas-gamepad/index.ts
@@ -0,0 +1,92 @@
+import { DemoSharedBase } from '../utils';
+import '@nativescript/canvas-polyfill';
+
+const LABELS = ['A', 'B', 'X', 'Y', 'LB', 'RB', 'LT', 'RT', 'Sel', 'Start', 'L3', 'R3', 'Up', 'Down', 'Left', 'Right', 'Home'];
+
+export class DemoSharedCanvasGamepad extends DemoSharedBase {
+ private _raf = 0;
+
+ canvasLoaded(args) {
+ const canvas = args.object;
+ const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
+
+ window.addEventListener('gamepadconnected', (e: any) => console.log(`GAMEPAD|connected ${e.gamepad.index} ${e.gamepad.id}`));
+ window.addEventListener('gamepaddisconnected', (e: any) => console.log(`GAMEPAD|disconnected ${e.gamepad.index}`));
+
+ this.bench();
+
+ const draw = () => {
+ const width = canvas.width;
+ const height = canvas.height;
+ const scale = width / 400;
+ ctx.fillStyle = '#111';
+ ctx.fillRect(0, 0, width, height);
+ ctx.font = `${14 * scale}px sans-serif`;
+
+ const pads = navigator.getGamepads().filter(Boolean);
+ if (pads.length === 0) {
+ ctx.fillStyle = '#aaa';
+ ctx.fillText('Connect a controller and press a button', 16 * scale, 32 * scale);
+ }
+ let y = 16 * scale;
+ for (const pad of pads) {
+ ctx.fillStyle = '#fff';
+ ctx.fillText(`${pad.index}: ${pad.id}`, 16 * scale, (y += 18 * scale));
+ ctx.fillText(`mapping=${pad.mapping} t=${pad.timestamp.toFixed(1)}`, 16 * scale, (y += 18 * scale));
+ y += 12 * scale;
+ for (let i = 0; i < pad.axes.length; i += 2) {
+ const cx = (60 + i * 60) * scale;
+ const cy = y + 40 * scale;
+ ctx.strokeStyle = '#666';
+ ctx.beginPath();
+ ctx.arc(cx, cy, 36 * scale, 0, Math.PI * 2);
+ ctx.stroke();
+ ctx.fillStyle = '#4af';
+ ctx.beginPath();
+ ctx.arc(cx + pad.axes[i] * 30 * scale, cy + (pad.axes[i + 1] ?? 0) * 30 * scale, 8 * scale, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ y += 92 * scale;
+ pad.buttons.forEach((button, i) => {
+ const col = i % 6;
+ const row = Math.floor(i / 6);
+ const bx = (16 + col * 62) * scale;
+ const by = y + row * 36 * scale;
+ ctx.fillStyle = button.pressed ? '#4c4' : '#333';
+ ctx.fillRect(bx, by, 56 * scale, 30 * scale);
+ ctx.fillStyle = '#6a6';
+ ctx.fillRect(bx, by + 26 * scale, 56 * scale * button.value, 4 * scale);
+ ctx.fillStyle = '#fff';
+ ctx.fillText(LABELS[i] ?? String(i), bx + 6 * scale, by + 20 * scale);
+ });
+ y += 120 * scale;
+ }
+ this._raf = requestAnimationFrame(draw);
+ };
+ this._raf = requestAnimationFrame(draw);
+ }
+
+ unloaded() {
+ cancelAnimationFrame(this._raf);
+ }
+
+ private bench() {
+ let sink = 0;
+ const frame = () => {
+ const pads = navigator.getGamepads();
+ for (let p = 0; p < pads.length; p++) {
+ const pad = pads[p];
+ if (!pad) continue;
+ for (let a = 0; a < pad.axes.length; a++) sink += pad.axes[a];
+ for (let b = 0; b < pad.buttons.length; b++) sink += pad.buttons[b].value;
+ }
+ };
+ for (let i = 0; i < 1000; i++) frame();
+ const n = 100000;
+ const start = performance.now();
+ for (let i = 0; i < n; i++) frame();
+ const ns = ((performance.now() - start) * 1e6) / n;
+ const connected = navigator.getGamepads().filter(Boolean).length;
+ console.log(`GAMEPAD|bench ${ns.toFixed(0)} ns/frame pads=${connected} (${sink === -1 ? '' : 'ok'})`);
+ }
+}
diff --git a/tools/demo/index.ts b/tools/demo/index.ts
index 969d94897..ff5647f80 100644
--- a/tools/demo/index.ts
+++ b/tools/demo/index.ts
@@ -3,6 +3,7 @@ export * from './audio-context';
export * from './canvas';
export * from './canvas-babylon';
export * from './canvas-chartjs';
+export * from './canvas-gamepad';
export * from './canvas-media';
export * from './canvas-phaser';
export * from './canvas-phaser-ce';
diff --git a/tools/scripts/build-napi.sh b/tools/scripts/build-napi.sh
index 9ad00504a..b8b6a1b77 100644
--- a/tools/scripts/build-napi.sh
+++ b/tools/scripts/build-napi.sh
@@ -7,8 +7,9 @@
# PROFILE release-napi (default; panic=unwind so the host app survives a panic) or dev
# CRATE canvas-napi (default: @nativescript/canvas, canvasnative.node), canvas-svg-napi
# (@nativescript/canvas-svg, canvassvg.node), audio-context-napi
-# (@nativescript/audio-context, audiocontext.node) or canvas-media-napi
-# (@nativescript/canvas-media, canvasmedia.node)
+# (@nativescript/audio-context, audiocontext.node), canvas-media-napi
+# (@nativescript/canvas-media, canvasmedia.node) or canvas-gamepad-napi
+# (@nativescript/canvas-gamepad, canvasgamepad.node)
set -e
@@ -28,6 +29,7 @@ case "$CRATE" in
canvas-svg-napi) PACKAGE="$ROOT/packages/canvas-svg"; NAME=canvassvg ;;
audio-context-napi) PACKAGE="$ROOT/packages/audio-context"; NAME=audiocontext ;;
canvas-media-napi) PACKAGE="$ROOT/packages/canvas-media"; NAME=canvasmedia ;;
+ canvas-gamepad-napi) PACKAGE="$ROOT/packages/canvas-gamepad"; NAME=canvasgamepad ;;
*)
echo "unsupported crate: $CRATE"
exit 1
diff --git a/tools/workspace-scripts.js b/tools/workspace-scripts.js
index 14f3ab795..b4b9be0b0 100644
--- a/tools/workspace-scripts.js
+++ b/tools/workspace-scripts.js
@@ -117,6 +117,13 @@ module.exports = {
description: '@nativescript/canvas-media: Build',
},
},
+ // @nativescript/canvas-gamepad
+ 'canvas-gamepad': {
+ build: {
+ script: 'nx run canvas-gamepad:build.all',
+ description: '@nativescript/canvas-gamepad: Build',
+ },
+ },
// @nativescript/canvas-chartjs
'canvas-chartjs': {
build: {
@@ -190,6 +197,10 @@ module.exports = {
script: 'nx run canvas-media:focus',
description: 'Focus on @nativescript/canvas-media',
},
+ 'canvas-gamepad': {
+ script: 'nx run canvas-gamepad:focus',
+ description: 'Focus on @nativescript/canvas-gamepad',
+ },
'canvas-chartjs': {
script: 'nx run canvas-chartjs:focus',
description: 'Focus on @nativescript/canvas-chartjs',
diff --git a/tsconfig.base.json b/tsconfig.base.json
index a253372b4..fa2cb0d27 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -26,6 +26,7 @@
"@nativescript/canvas-babylon": ["packages/canvas-babylon/index.ts"],
"@nativescript/canvas-chartjs": ["packages/canvas-chartjs/index.d.ts"],
"@nativescript/canvas-media": ["packages/canvas-media/index.ts"],
+ "@nativescript/canvas-gamepad": ["packages/canvas-gamepad/index.ts"],
"@nativescript/canvas-phaser": ["packages/canvas-phaser/index.ts"],
"@nativescript/canvas-phaser-ce": ["packages/canvas-phaser-ce/index.ts"],
"@nativescript/canvas-pixi": ["packages/canvas-pixi/index.ts"],