From e44aab6f5cbd42d1fc4614dfaad07a1889dd3812 Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Wed, 11 Nov 2026 15:43:32 +0100 Subject: [PATCH] Add gamepad support to the desktop build Co-Authored-By: deepseek-v4.1-flash (opencode-go) via CodeCraft --- Cargo.lock | 116 ++++++++++++++++++++++++++++++++ Cargo.toml | 3 +- INSTALL.md | 5 +- VERSION | 2 +- src/pad.rs | 146 +++++++++++++++++++++++++++++++++++++---- src/platform.rs | 16 +---- tools/build_desktop.py | 3 +- 7 files changed, 260 insertions(+), 31 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 034649a..a509e70 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -93,6 +93,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + [[package]] name = "color_quant" version = "1.1.0" @@ -194,6 +200,7 @@ name = "dungeoncraft" version = "0.1.0" dependencies = [ "cpal", + "gilrs", "macroquad", "rfd", "serde", @@ -226,6 +233,12 @@ dependencies = [ "zlib-rs", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -266,6 +279,40 @@ dependencies = [ "slab", ] +[[package]] +name = "gilrs" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "902fb00d3f6398e635be22e5c837b303c501835cca7ac11a47bba138f7aafdd8" +dependencies = [ + "fnv", + "gilrs-core", + "log", + "uuid", + "vec_map", +] + +[[package]] +name = "gilrs-core" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc7f0ce6237abcc0523f2a5502b1e3fe5802daaae47ac14e166fe49551301ea9" +dependencies = [ + "inotify", + "js-sys", + "libc", + "libudev-sys", + "log", + "nix", + "objc2-core-foundation", + "objc2-io-kit", + "uuid", + "vec_map", + "wasm-bindgen", + "web-sys", + "windows", +] + [[package]] name = "glam" version = "0.27.0" @@ -312,6 +359,26 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "inotify" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" +dependencies = [ + "bitflags 2.13.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + [[package]] name = "itoa" version = "1.0.18" @@ -399,6 +466,16 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "libudev-sys" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324" +dependencies = [ + "libc", + "pkg-config", +] + [[package]] name = "log" version = "0.4.35" @@ -513,6 +590,18 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "num-derive" version = "0.4.2" @@ -666,6 +755,17 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "bitflags 2.13.2", + "libc", + "objc2-core-foundation", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -983,6 +1083,22 @@ version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" +[[package]] +name = "uuid" +version = "1.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bed99139f7dac9b5cfe5f17be4d66dc7079a34dee1d74bcc77217119240e267c" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "vec_map" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" + [[package]] name = "walkdir" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index 43b62c2..1843ae9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,7 +26,8 @@ inherits = "release" lto = "thin" codegen-units = 16 -# desktop builds: system file dialog (xdg portal on Linux) and sound output +# desktop builds: system file dialog (xdg portal on Linux), sound output and gamepads [target.'cfg(not(target_arch = "wasm32"))'.dependencies] rfd = { version = "0.17", default-features = false, features = ["xdg-portal"] } cpal = "0.18" +gilrs = "0.11" diff --git a/INSTALL.md b/INSTALL.md index 19fb988..0dd19a5 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -46,8 +46,9 @@ cargo build --release ./target/release/dungeoncraft ``` -On Linux the build needs the ALSA headers (`alsa-lib` / `libasound2-dev`); the sound goes through ALSA (PipeWire and -PulseAudio take it). The settings, the autosave, the campaign progress and the profile are kept as files in the +On Linux the build needs the ALSA headers (`alsa-lib` / `libasound2-dev`) and libudev for gamepads +(`libudev-dev`); the sound goes through ALSA (PipeWire and PulseAudio take it). Gamepads are read directly on the +desktop (gilrs, hot-plug included); the browser build uses the browser's Gamepad API. The settings, the autosave, the campaign progress and the profile are kept as files in the data folder: `~/.local/share/dungeoncraft/` (Linux, or `$XDG_DATA_HOME/dungeoncraft`), `%APPDATA%\DungeonCraft` (Windows), `~/Library/Application Support/DungeonCraft` (macOS); `DUNGEONCRAFT_DATA` chooses another one. *Save Game* and saved campaigns land in its `saves/` folder; *Load Game* opens the system file dialog there (on diff --git a/VERSION b/VERSION index bfd5cea..e8d0cac 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v202611-00079 +v202611-00080 diff --git a/src/pad.rs b/src/pad.rs index 49e04f6..319251b 100644 --- a/src/pad.rs +++ b/src/pad.rs @@ -65,6 +65,32 @@ pub fn kind_for(setting: u8, detected: i32) -> Kind { } } +// Desktop: the pad's vendor id names the type (Microsoft, Sony, Nintendo) +#[cfg(not(target_arch = "wasm32"))] +fn kind_from_vendor(vendor: u16) -> i32 { + match vendor { + 0x045e => 1, + 0x054c => 2, + 0x057e => 3, + _ => 0, + } +} + +// Desktop: a pad without a known vendor, its name usually says what it is +#[cfg(not(target_arch = "wasm32"))] +fn kind_from_name(name: &str) -> i32 { + let n = name.to_lowercase(); + if n.contains("xbox") || n.contains("xinput") || n.contains("microsoft") { + 1 + } else if n.contains("nintendo") || n.contains("switch") || n.contains("joy-con") || n.contains("joycon") || n.contains("pro controller") { + 3 + } else if n.contains("playstation") || n.contains("dualshock") || n.contains("dualsense") || n.contains("sony") || n.contains("wireless controller") { + 2 + } else { + 0 + } +} + thread_local! { static KIND: Cell = const { Cell::new(Kind::Xbox) }; } @@ -84,28 +110,113 @@ pub struct Pad { axes: [f32; 4], pointer: (f32, f32), // device pixels of the interface pointer_ready: bool, + // desktop builds read the pads themselves (miniquad does not poll them); gilrs opens the + // system's input devices, None when that is impossible or there are none + #[cfg(not(target_arch = "wasm32"))] + gilrs: Option, + #[cfg(not(target_arch = "wasm32"))] + tried: bool, } impl Pad { pub fn new() -> Pad { - Pad { connected: false, buttons: 0, prev: 0, axes: [0.0; 4], pointer: (0.0, 0.0), pointer_ready: false } + Pad { + connected: false, + buttons: 0, + prev: 0, + axes: [0.0; 4], + pointer: (0.0, 0.0), + pointer_ready: false, + #[cfg(not(target_arch = "wasm32"))] + gilrs: None, + #[cfg(not(target_arch = "wasm32"))] + tried: false, + } } // Read the pad for this frame; kind_setting = Settings::gamepad. pub fn poll(&mut self, kind_setting: u8) { self.prev = if self.connected { self.buttons } else { 0 }; - let slot = crate::platform::gamepad_slot(); - self.connected = slot >= 0; - let detected = if self.connected { crate::platform::gamepad_kind(slot) } else { 0 }; - KIND.with(|c| c.set(kind_for(kind_setting, detected))); - if !self.connected { - self.buttons = 0; - self.axes = [0.0; 4]; - return; + #[cfg(target_arch = "wasm32")] + { + let slot = crate::platform::gamepad_slot(); + self.connected = slot >= 0; + let detected = if self.connected { crate::platform::gamepad_kind(slot) } else { 0 }; + KIND.with(|c| c.set(kind_for(kind_setting, detected))); + if !self.connected { + self.buttons = 0; + self.axes = [0.0; 4]; + return; + } + self.buttons = crate::platform::gamepad_buttons(slot); + for (i, a) in self.axes.iter_mut().enumerate() { + *a = crate::platform::gamepad_axis(slot, i as i32); + } } - self.buttons = crate::platform::gamepad_buttons(slot); - for (i, a) in self.axes.iter_mut().enumerate() { - *a = crate::platform::gamepad_axis(slot, i as i32); + #[cfg(not(target_arch = "wasm32"))] + self.poll_desktop(kind_setting); + } + + // Desktop: gilrs reports the pad's buttons and sticks. Its Y axes point up when positive, + // the browser's Gamepad API points down, so they are flipped to the same meaning here. + #[cfg(not(target_arch = "wasm32"))] + fn poll_desktop(&mut self, kind_setting: u8) { + use gilrs::{Axis, Button as GBtn}; + if self.gilrs.is_none() && !self.tried { + self.tried = true; + match gilrs::Gilrs::new() { + Ok(g) => self.gilrs = Some(g), + Err(e) => eprintln!("DungeonCraft: no gamepad support: {e}"), + } + } + let state = match self.gilrs.as_mut() { + None => None, + Some(g) => { + // drain the queue, so hot-plugged pads show up and the state stays current + while g.next_event().is_some() {} + g.gamepads().next().map(|(_, p)| { + let mut m = 0u32; + for (gb, b) in [ + (GBtn::South, Btn::South), + (GBtn::East, Btn::East), + (GBtn::West, Btn::West), + (GBtn::North, Btn::North), + (GBtn::LeftTrigger, Btn::Lb), + (GBtn::RightTrigger, Btn::Rb), + (GBtn::LeftTrigger2, Btn::Lt), + (GBtn::RightTrigger2, Btn::Rt), + (GBtn::Select, Btn::Select), + (GBtn::Start, Btn::Start), + (GBtn::LeftThumb, Btn::LStick), + (GBtn::RightThumb, Btn::RStick), + (GBtn::DPadUp, Btn::Up), + (GBtn::DPadDown, Btn::Down), + (GBtn::DPadLeft, Btn::Left), + (GBtn::DPadRight, Btn::Right), + ] { + if p.is_pressed(gb) { + m |= b.bit(); + } + } + let axes = [p.value(Axis::LeftStickX), -p.value(Axis::LeftStickY), p.value(Axis::RightStickX), -p.value(Axis::RightStickY)]; + let detected = p.vendor_id().map(kind_from_vendor).unwrap_or_else(|| kind_from_name(&p.name())); + (detected, m, axes) + }) + } + }; + match state { + Some((detected, buttons, axes)) => { + self.connected = true; + self.buttons = buttons; + self.axes = axes; + KIND.with(|c| c.set(kind_for(kind_setting, detected))); + } + None => { + self.connected = false; + self.buttons = 0; + self.axes = [0.0; 4]; + KIND.with(|c| c.set(kind_for(kind_setting, 0))); + } } } @@ -212,6 +323,17 @@ mod tests { assert!(!p.pressed(Btn::South)); } + // the desktop path opens the system's input devices; without a pad (or without access to + // them) it simply reports none, and it must never panic + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn polling_the_desktop_pad_is_safe() { + let mut p = Pad::new(); + p.poll(0); + p.poll(2); + assert_eq!(p.connected(), p.gilrs.as_ref().map_or(false, |g| g.gamepads().next().is_some())); + } + #[test] fn the_pointer_follows_the_mouse_and_the_stick() { let mut p = Pad::new(); diff --git a/src/platform.rs b/src/platform.rs index c209758..39131a2 100644 --- a/src/platform.rs +++ b/src/platform.rs @@ -388,20 +388,8 @@ mod imp { } } - // The Gamepad API only exists in browsers. The desktop build has no gamepad input yet - // (miniquad does not poll gamepads; that would need a crate like gilrs). - pub fn gamepad_slot() -> i32 { - -1 - } - pub fn gamepad_buttons(_slot: i32) -> u32 { - 0 - } - pub fn gamepad_axis(_slot: i32, _axis: i32) -> f32 { - 0.0 - } - pub fn gamepad_kind(_slot: i32) -> i32 { - 0 - } + // Gamepads are polled by pad.rs with gilrs; the browser's Gamepad API lives in the + // dc_gamepad_* imports of the JS plugin (see the wasm imp above). } pub use imp::*; diff --git a/tools/build_desktop.py b/tools/build_desktop.py index 1e9b529..bb3089f 100644 --- a/tools/build_desktop.py +++ b/tools/build_desktop.py @@ -6,7 +6,8 @@ - linux: dist/DungeonCraft--x86_64.AppImage built with cargo-zigbuild against glibc 2.28, so it runs on distributions from about 2019 on; - needs on the build machine: zig, cargo-zigbuild, the ALSA headers (alsa-lib / libasound2-dev); + needs on the build machine: zig, cargo-zigbuild, the ALSA headers (alsa-lib / libasound2-dev) + and libudev (libudev-dev, for gamepads); appimagetool is downloaded once into ~/.cache/dungeoncraft/ - windows: dist/DungeonCraft--windows-x64.exe (one file, Windows 10 and newer) cross-compiled with cargo-zigbuild for x86_64-pc-windows-gnu