feat: Added input system

- Added input plugin to manage controls config
- Added tests for casting InputMap to InputAsset and backwards
This commit is contained in:
Alexey 2026-03-03 09:26:52 +03:00
commit ae7bfd7c27
5 changed files with 471 additions and 3 deletions

164
Cargo.lock generated
View file

@ -23,6 +23,10 @@ name = "accesskit"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf203f9d3bd8f29f98833d1fbef628df18f759248a547e7e01cfbf63cda36a99"
dependencies = [
"enumn",
"serde",
]
[[package]]
name = "accesskit_consumer"
@ -167,6 +171,12 @@ dependencies = [
"libc",
]
[[package]]
name = "anyhow"
version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "approx"
version = "0.5.1"
@ -334,6 +344,7 @@ dependencies = [
"bevy_derive",
"bevy_ecs",
"bevy_reflect",
"serde",
]
[[package]]
@ -548,6 +559,21 @@ dependencies = [
"wgpu-types",
]
[[package]]
name = "bevy_common_assets"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad51a6e9def88caadc4ce2a5670382d6ff19fa1e7e7af934326c5c0b376bdd9f"
dependencies = [
"anyhow",
"bevy_app",
"bevy_asset",
"bevy_reflect",
"serde",
"thiserror 2.0.18",
"toml 0.9.12+spec-1.1.0",
]
[[package]]
name = "bevy_core_pipeline"
version = "0.18.0"
@ -836,6 +862,7 @@ dependencies = [
"bevy_reflect",
"derive_more",
"log",
"serde",
"smol_str",
"thiserror 2.0.18",
]
@ -1002,6 +1029,7 @@ dependencies = [
"bytemuck",
"derive_more",
"hexasphere",
"serde",
"thiserror 2.0.18",
"tracing",
"wgpu-types",
@ -1463,6 +1491,7 @@ dependencies = [
"bevy_utils",
"bevy_window",
"derive_more",
"serde",
"smallvec",
"taffy",
"thiserror 2.0.18",
@ -2174,6 +2203,24 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
[[package]]
name = "dyn-clone"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "dyn-eq"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c2d035d21af5cde1a6f5c7b444a5bf963520a9f142e5d06931178433d7d5388"
[[package]]
name = "dyn-hash"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fdab65db9274e0168143841eb8f864a0a21f8b1b8d2ba6812bbe6024346e99e"
[[package]]
name = "either"
version = "1.15.0"
@ -2211,6 +2258,17 @@ dependencies = [
"syn",
]
[[package]]
name = "enumn"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f9ed6b3789237c8a0c1c505af1c7eb2c560df6186f01b098c3a1064ea532f38"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@ -2273,6 +2331,11 @@ name = "expedition_demo"
version = "0.1.0"
dependencies = [
"bevy",
"bevy_common_assets",
"bevy_input",
"leafwing-input-manager",
"serde",
"toml 1.0.3+spec-1.1.0",
]
[[package]]
@ -2911,6 +2974,34 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "leafwing-input-manager"
version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fed83d1d7334e742aab3409782cdbb2bc96cc017df9ff342dd5aeb80eed1b784"
dependencies = [
"bevy",
"dyn-clone",
"dyn-eq",
"dyn-hash",
"itertools 0.14.0",
"leafwing_input_manager_macros",
"serde",
"serde_flexitos",
]
[[package]]
name = "leafwing_input_manager_macros"
version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2226cb83129176a6c634f2ce0828c2c29896ea0898fc198636f98696b8056890"
dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "lewton"
version = "0.10.2"
@ -4154,6 +4245,16 @@ dependencies = [
"syn",
]
[[package]]
name = "serde_flexitos"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3323d093d7597660758b742dd7a1525539613f6182b306a4e1dd6e01a89bada9"
dependencies = [
"erased-serde",
"serde",
]
[[package]]
name = "serde_json"
version = "1.0.149"
@ -4167,6 +4268,15 @@ dependencies = [
"zmij",
]
[[package]]
name = "serde_spanned"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776"
dependencies = [
"serde_core",
]
[[package]]
name = "sharded-slab"
version = "0.1.7"
@ -4228,6 +4338,9 @@ name = "smallvec"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
dependencies = [
"serde",
]
[[package]]
name = "smithay-client-toolkit"
@ -4469,6 +4582,36 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "toml"
version = "0.9.12+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned",
"toml_datetime 0.7.5+spec-1.1.0",
"toml_parser",
"toml_writer",
"winnow",
]
[[package]]
name = "toml"
version = "1.0.3+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7614eaf19ad818347db24addfa201729cf2a9b6fdfd9eb0ab870fcacc606c0c"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned",
"toml_datetime 1.0.0+spec-1.1.0",
"toml_parser",
"toml_writer",
"winnow",
]
[[package]]
name = "toml_datetime"
version = "0.7.5+spec-1.1.0"
@ -4478,6 +4621,15 @@ dependencies = [
"serde_core",
]
[[package]]
name = "toml_datetime"
version = "1.0.0+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e"
dependencies = [
"serde_core",
]
[[package]]
name = "toml_edit"
version = "0.23.10+spec-1.0.0"
@ -4485,20 +4637,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269"
dependencies = [
"indexmap",
"toml_datetime",
"toml_datetime 0.7.5+spec-1.1.0",
"toml_parser",
"winnow",
]
[[package]]
name = "toml_parser"
version = "1.0.7+spec-1.1.0"
version = "1.0.9+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "247eaa3197818b831697600aadf81514e577e0cba5eab10f7e064e78ae154df1"
checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4"
dependencies = [
"winnow",
]
[[package]]
name = "toml_writer"
version = "1.0.6+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607"
[[package]]
name = "tracing"
version = "0.1.44"

View file

@ -7,6 +7,11 @@ edition = "2024"
[dependencies]
bevy = { version = "0.18.0" }
bevy_common_assets = { version = "0.15.0", features = ["toml"] }
bevy_input = { version = "0.18.0", features = ["serde", "serialize"] }
leafwing-input-manager = "0.20.0"
serde = { version = "1.0.228", features = ["derive"] }
toml = "1.0.3"
[profile.dev]
opt-level = 1

217
src/input.rs Normal file
View file

@ -0,0 +1,217 @@
use std::{any::{Any, TypeId}, collections::HashMap, hash::Hash, marker::PhantomData};
use bevy::prelude::*;
use bevy_common_assets::toml::TomlAssetPlugin;
use leafwing_input_manager::{Actionlike, prelude::{Axislike, Buttonlike, DualAxislike, InputMap}};
use serde::{Deserialize, Serialize, de::DeserializeOwned};
const INPUT_ASSET_EXTENSIONS: [&'static str; 1] = ["input.toml"];
#[derive(Clone, Debug, Serialize, Deserialize, Default, PartialEq, Eq)]
pub struct MultiInput {
pub keyboard: Option<Vec<KeyCode>>,
pub mouse: Option<Vec<MouseButton>>,
pub gamepad: Option<Vec<GamepadButton>>,
}
impl From<MultiInput> for InputKind {
fn from(value: MultiInput) -> Self {
Self::Button(value)
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(untagged)]
pub enum InputKind {
Button(MultiInput),
Axis(Vec<Box<dyn Axislike>>),
DualAxis(Vec<Box<dyn DualAxislike>>),
}
#[derive(Default, Deref, DerefMut, Debug, Asset, Reflect, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct InputAsset<Name>
where Name: Sized + Hash + Eq + Reflect + TypePath + Actionlike {
#[serde(flatten)]
events: HashMap<Name, InputKind>,
}
fn copy_keys<Name, Button>(input_map: &mut InputMap<Name>, name: &Name, buttons: &Vec<Button>)
where Name: Sized + Hash + Eq + Reflect + TypePath + Actionlike,
Button: Buttonlike + Clone {
let bindings: Vec<(Name, Button)> = buttons.iter()
.map(|k| (name.clone(), k.to_owned())).collect();
input_map.insert_multiple(bindings);
}
impl<Name> From<InputAsset<Name>> for InputMap<Name>
where Name: Sized + Hash + Eq + Reflect + TypePath + Actionlike {
fn from(asset: InputAsset<Name>) -> Self {
let new_asset = (*asset).clone();
let mut input_map = InputMap::default();
for (name, input_kind) in new_asset.into_iter() {
match input_kind {
InputKind::Button(input) => {
if let Some(buttons) = input.keyboard {
copy_keys(&mut input_map, &name, &buttons);
}
if let Some(buttons) = input.mouse {
copy_keys(&mut input_map, &name, &buttons);
}
if let Some(buttons) = input.gamepad {
copy_keys(&mut input_map, &name, &buttons);
}
},
InputKind::Axis(axises) => {
for axis in axises {
input_map.insert_axis_boxed(name.clone(), axis);
}
},
InputKind::DualAxis(axises) => {
for axis in axises {
input_map.insert_dual_axis_boxed(name.clone(), axis);
}
},
}
}
input_map
}
}
impl<Name> InputAsset<Name>
where Name: Sized + Hash + Eq + Reflect + TypePath + Actionlike {
/// This method does several things:
/// - Replace all actions which both are contained in the [`InputAsset`] and in the [`InputMap`]
/// and have the same "type" (e.g. [`Buttonlike`]).
/// - Insert all actions which are not contained in the map
/// Common usecase for this is to update default values with this asset
pub fn replace_input_map_actions(&self, map: &mut InputMap<Name>) {
for (name, input_kind) in self.iter() {
match input_kind {
InputKind::Button(input) => {
if map.iter_buttonlike().find(|(n, _)| n == &name).is_some() {
map.clear_action(name);
}
if let Some(buttons) = &input.keyboard {
copy_keys(map, name, &buttons);
}
if let Some(buttons) = &input.mouse {
copy_keys(map, name, &buttons);
}
if let Some(buttons) = &input.gamepad {
copy_keys(map, name, &buttons);
}
},
InputKind::Axis(axises) => {
if map.iter_axislike().find(|(n, _)| n == &name).is_some() {
map.clear_action(name);
}
for axis in axises.iter() {
map.insert_axis_boxed(name.to_owned(), axis.to_owned());
}
},
InputKind::DualAxis(axises) => {
if map.iter_dual_axislike().find(|(n, _)| n == &name).is_some() {
map.clear_action(name);
}
for axis in axises.iter() {
map.insert_dual_axis_boxed(name.to_owned(), axis.to_owned());
}
}
}
}
}
}
fn try_multi_input_insert<T: Clone>(input_type: &mut Option<Vec<T>>, new_key: T) {
match input_type {
Some(key) => key.push(new_key),
None => {
let wrapped_new_key = Some(vec![new_key]);
*input_type = wrapped_new_key;
},
}
}
impl<Name> From<InputMap<Name>> for InputAsset<Name>
where Name: Sized + Hash + Eq + Reflect + TypePath + Actionlike + Default {
fn from(map: InputMap<Name>) -> Self {
let mut asset: InputAsset<Name> = InputAsset::default();
const KC_TYPE: TypeId = TypeId::of::<KeyCode>();
const MB_TYPE: TypeId = TypeId::of::<MouseButton>();
const GP_TYPE: TypeId = TypeId::of::<GamepadButton>();
for (name, buttonlikes) in map.iter_buttonlike() {
let multi_input = {
let input_kind = match asset.get_mut(name) {
Some(input_kind) => input_kind,
None => {
asset.insert(name.to_owned(), MultiInput::default().into());
asset.get_mut(name).unwrap()
}
};
let InputKind::Button(btn) = input_kind else {
continue;
};
btn
};
for buttonlike in buttonlikes.iter() {
let buttonlike = &(**buttonlike) as &dyn Any;
let checked_type = buttonlike.type_id();
if checked_type == KC_TYPE {
let new_key = (*buttonlike).downcast_ref::<KeyCode>().unwrap().to_owned();
try_multi_input_insert(&mut multi_input.keyboard, new_key);
} else if checked_type == MB_TYPE {
let new_key = (*buttonlike).downcast_ref::<MouseButton>().unwrap().to_owned();
try_multi_input_insert(&mut multi_input.mouse, new_key);
} else if checked_type == GP_TYPE {
let new_key = (*buttonlike).downcast_ref::<GamepadButton>().unwrap().to_owned();
try_multi_input_insert(&mut multi_input.gamepad, new_key);
}
}
}
for (name, axislikes) in map.iter_axislike() {
match asset.get_mut(name) {
Some(input_kind) => {
let InputKind::Axis(axises) = input_kind else {
continue;
};
for axislike in axislikes {
axises.push(axislike.clone());
}
},
None => {
asset.insert(name.clone(), InputKind::Axis(axislikes.clone()));
}
}
}
for (name, axislikes) in map.iter_dual_axislike() {
match asset.get_mut(name) {
Some(input_kind) => {
let InputKind::DualAxis(axises) = input_kind else {
continue;
};
for axislike in axislikes {
axises.push(axislike.clone());
}
},
None => {
asset.insert(name.clone(), InputKind::DualAxis(axislikes.clone()));
}
}
}
asset
}
}
#[derive(Debug, Default)]
pub struct InputAssetPlugin<T> (PhantomData<T>);
impl<T> Plugin for InputAssetPlugin<T>
where T: Sized + Hash + Eq + Reflect + TypePath + Actionlike + DeserializeOwned
{
fn build(&self, app: &mut App) {
app.add_plugins(TomlAssetPlugin::<InputAsset<T>>::new(&INPUT_ASSET_EXTENSIONS));
}
}

View file

@ -1,10 +1,20 @@
pub mod player;
pub mod layout;
pub mod input;
#[cfg(test)]
mod tests;
use bevy::prelude::*;
pub struct ExpeditionPlugin;
pub enum InputActions {
MoveLeft,
MoveRight,
ToggleInventory,
Interact,
}
fn camera_bundle() -> impl Bundle {
(
Camera2d,

78
src/tests.rs Normal file
View file

@ -0,0 +1,78 @@
use std::any::{Any, TypeId};
use super::*;
use leafwing_input_manager::prelude::*;
#[derive(Actionlike, Reflect, Clone, Debug, PartialEq, Eq, Hash, Default)]
enum Action {
#[default]
#[actionlike(DualAxis)]
DualAxis,
#[actionlike(Axis)]
SingleAxis,
Button,
}
#[test]
fn input_asset_from_map() {
let mut input_map = InputMap::default();
input_map.insert(Action::Button, KeyCode::KeyE);
input_map.insert(Action::Button, GamepadButton::East);
input_map.insert_axis(Action::SingleAxis, GamepadAxis::LeftStickX);
input_map.insert_axis(Action::SingleAxis, VirtualAxis::ad());
input_map.insert_dual_axis(Action::DualAxis, GamepadStick::RIGHT);
input_map.insert_dual_axis(Action::DualAxis, VirtualDPad::wasd());
let mut expected_input_asset = input::InputAsset::default();
expected_input_asset.insert(Action::Button, input::MultiInput {
keyboard: Some(vec![KeyCode::KeyE]),
gamepad: Some(vec![GamepadButton::East]),
mouse: None,
}.into());
expected_input_asset.insert(Action::SingleAxis, input::InputKind::Axis(vec![
Box::new(GamepadAxis::LeftStickX),
Box::new(VirtualAxis::ad()),
]));
expected_input_asset.insert(Action::DualAxis, input::InputKind::DualAxis(vec![
Box::new(GamepadStick::RIGHT),
Box::new(VirtualDPad::wasd()),
]));
let input_asset = input::InputAsset::from(input_map);
assert_eq!(input_asset, expected_input_asset);
}
#[test]
fn input_map_from_asset() {
let mut input_asset = input::InputAsset::default();
input_asset.insert(Action::Button, input::MultiInput {
keyboard: Some(vec![KeyCode::KeyE]),
gamepad: Some(vec![GamepadButton::East]),
mouse: None,
}.into());
input_asset.insert(Action::SingleAxis, input::InputKind::Axis(vec![
Box::new(GamepadAxis::LeftStickX),
Box::new(VirtualAxis::ad()),
]));
input_asset.insert(Action::DualAxis, input::InputKind::DualAxis(vec![
Box::new(GamepadStick::RIGHT),
Box::new(VirtualDPad::wasd()),
]));
let mut expected_input_map = InputMap::default();
expected_input_map.insert(Action::Button, KeyCode::KeyE);
expected_input_map.insert(Action::Button, GamepadButton::East);
expected_input_map.insert_axis(Action::SingleAxis, GamepadAxis::LeftStickX);
expected_input_map.insert_axis(Action::SingleAxis, VirtualAxis::ad());
expected_input_map.insert_dual_axis(Action::DualAxis, GamepadStick::RIGHT);
expected_input_map.insert_dual_axis(Action::DualAxis, VirtualDPad::wasd());
let input_map = InputMap::from(input_asset);
assert_eq!(input_map, expected_input_map);
}