Add gamepad support to the desktop build

Co-Authored-By: deepseek-v4.1-flash (opencode-go) via CodeCraft
This commit is contained in:
Richard Zinkanddeepseek-v4.1-flash (opencode-go) via CodeCraft committed 2026-11-11 15:43:32 +01:00
1 parent 8d51d6ec6a
commit e44aab6f5c
7 files changed
+249 -20

No files matched your search

Generated
+116
View File
@@ -93,6 +93,12 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "cfg_aliases"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]] [[package]]
name = "color_quant" name = "color_quant"
version = "1.1.0" version = "1.1.0"
@@ -194,6 +200,7 @@ name = "dungeoncraft"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"cpal", "cpal",
"gilrs",
"macroquad", "macroquad",
"rfd", "rfd",
"serde", "serde",
@@ -226,6 +233,12 @@ dependencies = [
"zlib-rs", "zlib-rs",
] ]
[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]] [[package]]
name = "foldhash" name = "foldhash"
version = "0.1.5" version = "0.1.5"
@@ -266,6 +279,40 @@ dependencies = [
"slab", "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]] [[package]]
name = "glam" name = "glam"
version = "0.27.0" version = "0.27.0"
@@ -312,6 +359,26 @@ dependencies = [
"hashbrown 0.17.1", "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]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.18" version = "1.0.18"
@@ -399,6 +466,16 @@ version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" 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]] [[package]]
name = "log" name = "log"
version = "0.4.35" version = "0.4.35"
@@ -513,6 +590,18 @@ dependencies = [
"jni-sys 0.3.1", "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]] [[package]]
name = "num-derive" name = "num-derive"
version = "0.4.2" version = "0.4.2"
@@ -666,6 +755,17 @@ dependencies = [
"objc2-core-foundation", "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]] [[package]]
name = "percent-encoding" name = "percent-encoding"
version = "2.3.2" version = "2.3.2"
@@ -983,6 +1083,22 @@ version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" 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]] [[package]]
name = "walkdir" name = "walkdir"
version = "2.5.0" version = "2.5.0"
+2 -1
View File
@@ -26,7 +26,8 @@ inherits = "release"
lto = "thin" lto = "thin"
codegen-units = 16 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] [target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rfd = { version = "0.17", default-features = false, features = ["xdg-portal"] } rfd = { version = "0.17", default-features = false, features = ["xdg-portal"] }
cpal = "0.18" cpal = "0.18"
gilrs = "0.11"
+3 -2
View File
@@ -46,8 +46,9 @@ cargo build --release
./target/release/dungeoncraft ./target/release/dungeoncraft
``` ```
On Linux the build needs the ALSA headers (`alsa-lib` / `libasound2-dev`); the sound goes through ALSA (PipeWire and On Linux the build needs the ALSA headers (`alsa-lib` / `libasound2-dev`) and libudev for gamepads
PulseAudio take it). The settings, the autosave, the campaign progress and the profile are kept as files in the (`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` data folder: `~/.local/share/dungeoncraft/` (Linux, or `$XDG_DATA_HOME/dungeoncraft`), `%APPDATA%\DungeonCraft`
(Windows), `~/Library/Application Support/DungeonCraft` (macOS); `DUNGEONCRAFT_DATA` chooses another one. (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 *Save Game* and saved campaigns land in its `saves/` folder; *Load Game* opens the system file dialog there (on
+1 -1
View File
@@ -1 +1 @@
v202611-00079 v202611-00080
+123 -1
View File
@@ -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! { thread_local! {
static KIND: Cell<Kind> = const { Cell::new(Kind::Xbox) }; static KIND: Cell<Kind> = const { Cell::new(Kind::Xbox) };
} }
@@ -84,16 +110,35 @@ pub struct Pad {
axes: [f32; 4], axes: [f32; 4],
pointer: (f32, f32), // device pixels of the interface pointer: (f32, f32), // device pixels of the interface
pointer_ready: bool, 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<gilrs::Gilrs>,
#[cfg(not(target_arch = "wasm32"))]
tried: bool,
} }
impl Pad { impl Pad {
pub fn new() -> 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. // Read the pad for this frame; kind_setting = Settings::gamepad.
pub fn poll(&mut self, kind_setting: u8) { pub fn poll(&mut self, kind_setting: u8) {
self.prev = if self.connected { self.buttons } else { 0 }; self.prev = if self.connected { self.buttons } else { 0 };
#[cfg(target_arch = "wasm32")]
{
let slot = crate::platform::gamepad_slot(); let slot = crate::platform::gamepad_slot();
self.connected = slot >= 0; self.connected = slot >= 0;
let detected = if self.connected { crate::platform::gamepad_kind(slot) } else { 0 }; let detected = if self.connected { crate::platform::gamepad_kind(slot) } else { 0 };
@@ -108,6 +153,72 @@ impl Pad {
*a = crate::platform::gamepad_axis(slot, i as i32); *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)));
}
}
}
pub fn connected(&self) -> bool { pub fn connected(&self) -> bool {
self.connected self.connected
@@ -212,6 +323,17 @@ mod tests {
assert!(!p.pressed(Btn::South)); 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] #[test]
fn the_pointer_follows_the_mouse_and_the_stick() { fn the_pointer_follows_the_mouse_and_the_stick() {
let mut p = Pad::new(); let mut p = Pad::new();
+2 -14
View File
@@ -388,20 +388,8 @@ mod imp {
} }
} }
// The Gamepad API only exists in browsers. The desktop build has no gamepad input yet // Gamepads are polled by pad.rs with gilrs; the browser's Gamepad API lives in the
// (miniquad does not poll gamepads; that would need a crate like gilrs). // dc_gamepad_* imports of the JS plugin (see the wasm imp above).
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
}
} }
pub use imp::*; pub use imp::*;
+2 -1
View File
@@ -6,7 +6,8 @@
- linux: dist/DungeonCraft-<version>-x86_64.AppImage - linux: dist/DungeonCraft-<version>-x86_64.AppImage
built with cargo-zigbuild against glibc 2.28, so it runs on distributions from about 2019 on; 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/ appimagetool is downloaded once into ~/.cache/dungeoncraft/
- windows: dist/DungeonCraft-<version>-windows-x64.exe (one file, Windows 10 and newer) - windows: dist/DungeonCraft-<version>-windows-x64.exe (one file, Windows 10 and newer)
cross-compiled with cargo-zigbuild for x86_64-pc-windows-gnu cross-compiled with cargo-zigbuild for x86_64-pc-windows-gnu