From 0bc5af331661c5d1c83342245b6db99ea145cb24 Mon Sep 17 00:00:00 2001 From: Shatur Date: Thu, 3 Oct 2024 20:59:47 +0300 Subject: [PATCH] Initial commit --- .github/dependabot.yml | 14 + .github/workflows/dependencies.yml | 30 + .github/workflows/main.yml | 132 +++++ .gitignore | 8 + Cargo.toml | 39 ++ LICENSE-APACHE | 176 ++++++ LICENSE-MIT | 21 + README.md | 7 + deny.toml | 15 + examples/minimal.rs | 133 +++++ src/action_value.rs | 125 +++++ src/input_context.rs | 114 ++++ src/input_context/context_map.rs | 206 +++++++ src/input_context/input_action.rs | 301 ++++++++++ src/input_context/input_condition.rs | 520 ++++++++++++++++++ .../input_condition/primitives.rs | 76 +++ src/input_context/input_modifier.rs | 413 ++++++++++++++ src/input_context/trigger_tracker.rs | 118 ++++ src/input_reader.rs | 266 +++++++++ src/lib.rs | 49 ++ 20 files changed, 2763 insertions(+) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/dependencies.yml create mode 100644 .github/workflows/main.yml create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 LICENSE-APACHE create mode 100644 LICENSE-MIT create mode 100644 README.md create mode 100644 deny.toml create mode 100644 examples/minimal.rs create mode 100644 src/action_value.rs create mode 100644 src/input_context.rs create mode 100644 src/input_context/context_map.rs create mode 100644 src/input_context/input_action.rs create mode 100644 src/input_context/input_condition.rs create mode 100644 src/input_context/input_condition/primitives.rs create mode 100644 src/input_context/input_modifier.rs create mode 100644 src/input_context/trigger_tracker.rs create mode 100644 src/input_reader.rs create mode 100644 src/lib.rs diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..9fd7fdb --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +version: 2 +updates: + - package-ecosystem: cargo + directory: / + schedule: + interval: weekly + labels: + - "dependencies" + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + labels: + - "github actions" diff --git a/.github/workflows/dependencies.yml b/.github/workflows/dependencies.yml new file mode 100644 index 0000000..3bf64dd --- /dev/null +++ b/.github/workflows/dependencies.yml @@ -0,0 +1,30 @@ +name: Dependencies +on: + push: + branches: + - master + paths: + - "Cargo.toml" + - "Cargo.lock" + - "deny.toml" + pull_request: + paths: + - "Cargo.toml" + - "Cargo.lock" + - "deny.toml" + schedule: + - cron: "0 0 * * 0" +env: + CARGO_TERM_COLOR: always +jobs: + dependencies: + name: Check dependencies + runs-on: ubuntu-latest + steps: + - name: Clone repo + uses: actions/checkout@v4 + + - name: Check dependencies + uses: EmbarkStudios/cargo-deny-action@v2 + with: + command-arguments: -D warnings diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..a4bf0b7 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,132 @@ +name: Main +on: + push: + branches: + - master + paths-ignore: + - ".gitignore" + - ".github/dependabot.yml" + - "deny.toml" + pull_request: + paths-ignore: + - ".gitignore" + - ".github/dependabot.yml" + - "deny.toml" +env: + CARGO_TERM_COLOR: always +jobs: + format: + name: Format + runs-on: ubuntu-latest + steps: + - name: Clone repo + uses: actions/checkout@v4 + + - name: Cache crates + uses: Swatinem/rust-cache@v2 + + - name: Install Taplo + run: cargo install --locked taplo-cli + + - name: Format + run: | + cargo fmt --all --check + taplo fmt --check + + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - name: Clone repo + uses: actions/checkout@v4 + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install --no-install-recommends libudev-dev libwayland-dev libxkbcommon-dev + + - name: Instal stable toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache crates + uses: Swatinem/rust-cache@v2 + + - name: Clippy + run: cargo clippy --tests -- -D warnings + + - name: Rustdoc + run: cargo rustdoc --all-features -- -D warnings + + doctest: + name: Doctest + runs-on: ubuntu-latest + steps: + - name: Clone repo + uses: actions/checkout@v4 + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install --no-install-recommends libudev-dev libwayland-dev libxkbcommon-dev + + - name: Instal stable toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache crates + uses: Swatinem/rust-cache@v2 + + - name: Test doc + run: cargo test --all-features --doc + + test: + name: Test + runs-on: ubuntu-latest + steps: + - name: Clone repo + uses: actions/checkout@v4 + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install --no-install-recommends libudev-dev libwayland-dev libxkbcommon-dev + + - name: Instal stable toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache crates + uses: Swatinem/rust-cache@v2 + + - name: Install LLVM tools + run: rustup component add llvm-tools-preview + + - name: Install Tarpaulin + run: cargo install cargo-tarpaulin + + - name: Test + run: cargo tarpaulin --all-features --engine llvm --out lcov + + - name: Upload code coverage results + if: github.actor != 'dependabot[bot]' + uses: actions/upload-artifact@v4 + with: + name: code-coverage-report + path: lcov.info + + codecov: + name: Upload to Codecov + if: github.actor != 'dependabot[bot]' + needs: [format, lint, doctest, test] + runs-on: ubuntu-latest + steps: + - name: Clone repo + uses: actions/checkout@v4 + + - name: Download code coverage results + uses: actions/download-artifact@v4 + with: + name: code-coverage-report + + - name: Upload to Codecov + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4f87075 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +# Binaries +target + +# Code coverage +html + +# For library +Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..ffd6fb9 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "bevy_enhanced_input" +version = "0.1.0" +authors = [ + "Hennadii Chernyshchyk ", +] +edition = "2021" +description = "Dynamic and contextual input mappings for Bevy " +readme = "README.md" +repository = "https://github.com/projectharmonia/bevy_enhanced_input" +keywords = [ + "bevy", + "input", +] +categories = ["game-development"] +license = "MIT OR Apache-2.0" +include = ["/src", "/tests", "/LICENSE*"] + +[dependencies] +bevy = { version = "0.14", default-features = false, features = ["serialize"] } +bevy_egui = { version = "0.30", default-features = false, features = ["immutable_ctx"], optional = true } +serde = "1.0" +bitflags = { version = "2.6", features = ["serde"] } +interpolation = "0.3" + +[dev-dependencies] +bevy = { version = "0.14", default-features = false, features = [ + "bevy_gilrs", + "x11", +] } + +[features] +default = ["ui_priority", "egui_priority"] + +# Prioritizes 'bevy_ui' actions when processing inputs. +ui_priority = ['bevy/bevy_ui'] + +# Prioritizes 'egui' over actions when processing inputs. +egui_priority = ['dep:bevy_egui'] diff --git a/LICENSE-APACHE b/LICENSE-APACHE new file mode 100644 index 0000000..d9a10c0 --- /dev/null +++ b/LICENSE-APACHE @@ -0,0 +1,176 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 0000000..eae692d --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Hennadii Chernyshchyk + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..bf32530 --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +# Bevy Enhanced Input + +[![crates.io](https://img.shields.io/crates/v/bevy_enhanced_input)](https://crates.io/crates/bevy_enhanced_input) +[![docs.rs](https://docs.rs/bevy_enhanced_input/badge.svg)](https://docs.rs/bevy_enhanced_input) +[![codecov](https://codecov.io/gh/projectharmonia/bevy_enhanced_input/graph/badge.svg?token=wirFEuKmMz)](https://codecov.io/gh/projectharmonia/bevy_enhanced_input) + +Reimplementation of [Enhanced Input](https://dev.epicgames.com/documentation/en-us/unreal-engine/enhanced-input-in-unreal-engine) plugin from Unreal Engine for Bevy. diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..fbe64dd --- /dev/null +++ b/deny.toml @@ -0,0 +1,15 @@ +[licenses] +allow = [ + "Apache-2.0", + "BSD-2-Clause", + "BSD-3-Clause", + "CC0-1.0", + "ISC", + "MIT", + "MIT-0", + "Unicode-DFS-2016", + "Zlib", +] + +[bans] +multiple-versions = "allow" diff --git a/examples/minimal.rs b/examples/minimal.rs new file mode 100644 index 0000000..e21d863 --- /dev/null +++ b/examples/minimal.rs @@ -0,0 +1,133 @@ +use bevy::prelude::*; +use bevy_enhanced_input::prelude::*; + +fn main() { + App::new() + .add_plugins(DefaultPlugins) + .add_plugins(EnhancedInputPlugin) + .add_input_context::() + .add_input_context::() + .add_systems(Startup, spawn) + .observe(walk) + .observe(jump) + .observe(enter_car) + .observe(drive) + .observe(exit_car) + .run(); +} + +#[derive(Component)] +struct OnFoot; + +impl InputContext for OnFoot { + fn context_map() -> ContextMap { + let mut map = ContextMap::default(); + + map.bind::().with_wasd(); + map.bind::().with(KeyCode::Space); + map.bind::().with(KeyCode::Enter); + + map + } +} + +#[derive(Debug)] +struct Walk; + +impl InputAction for Walk { + const DIM: ActionValueDim = ActionValueDim::Axis2D; +} + +#[derive(Debug)] +struct Jump; + +impl InputAction for Jump { + const DIM: ActionValueDim = ActionValueDim::Bool; +} + +#[derive(Debug)] +struct EnterCar; + +impl InputAction for EnterCar { + const DIM: ActionValueDim = ActionValueDim::Bool; +} + +#[derive(Component)] +struct InCar; + +impl InputContext for InCar { + fn context_map() -> ContextMap { + let mut map = ContextMap::default(); + + map.bind::().with_wasd(); + map.bind::().with(KeyCode::Enter); + + map + } +} + +#[derive(Debug)] +struct Drive; + +impl InputAction for Drive { + const DIM: ActionValueDim = ActionValueDim::Axis2D; +} + +#[derive(Debug)] +struct ExitCar; + +impl InputAction for ExitCar { + const DIM: ActionValueDim = ActionValueDim::Bool; +} + +fn spawn(mut commands: Commands) { + commands.spawn(OnFoot); +} + +fn walk(trigger: Trigger>) { + if let ActionEventKind::Fired { + value, + fired_secs, + elapsed_secs: _, + } = trigger.event().kind + { + info!("walking with direction `{value:?}` for `{fired_secs}` secs"); + } +} + +fn jump(trigger: Trigger>) { + if trigger.event().is_started() { + info!("jumping in the air"); + } +} + +fn enter_car(trigger: Trigger>, mut commands: Commands) { + if trigger.event().is_started() { + info!("entering car"); + commands + .entity(trigger.entity()) + .remove::() + .insert(InCar); + } +} + +fn drive(trigger: Trigger>) { + if let ActionEventKind::Fired { + value, + fired_secs, + elapsed_secs: _, + } = trigger.event().kind + { + info!("driving with direction `{value:?}` for `{fired_secs}` secs"); + } +} + +fn exit_car(trigger: Trigger>, mut commands: Commands) { + if trigger.event().is_started() { + info!("exiting car"); + commands + .entity(trigger.entity()) + .remove::() + .insert(OnFoot); + } +} diff --git a/src/action_value.rs b/src/action_value.rs new file mode 100644 index 0000000..1130d44 --- /dev/null +++ b/src/action_value.rs @@ -0,0 +1,125 @@ +use bevy::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Debug)] +pub enum ActionValue { + Bool(bool), + Axis1D(f32), + Axis2D(Vec2), + Axis3D(Vec3), +} + +impl ActionValue { + pub fn zero(dim: ActionValueDim) -> Self { + match dim { + ActionValueDim::Bool => ActionValue::Bool(false), + ActionValueDim::Axis1D => ActionValue::Axis1D(0.0), + ActionValueDim::Axis2D => ActionValue::Axis2D(Vec2::ZERO), + ActionValueDim::Axis3D => ActionValue::Axis3D(Vec3::ZERO), + } + } + + pub fn dim(self) -> ActionValueDim { + match self { + Self::Bool(_) => ActionValueDim::Bool, + Self::Axis1D(_) => ActionValueDim::Axis1D, + Self::Axis2D(_) => ActionValueDim::Axis2D, + Self::Axis3D(_) => ActionValueDim::Axis3D, + } + } + + pub fn convert(self, dim: ActionValueDim) -> Self { + match dim { + ActionValueDim::Bool => self.as_bool().into(), + ActionValueDim::Axis1D => self.as_axis1d().into(), + ActionValueDim::Axis2D => self.as_axis2d().into(), + ActionValueDim::Axis3D => self.as_axis3d().into(), + } + } + + pub fn as_bool(self) -> bool { + match self { + Self::Bool(value) => value, + Self::Axis1D(value) => value != 0.0, + Self::Axis2D(value) => value != Vec2::ZERO, + Self::Axis3D(value) => value != Vec3::ZERO, + } + } + + pub fn as_axis1d(self) -> f32 { + match self { + Self::Bool(value) => { + if value { + 1.0 + } else { + 0.0 + } + } + Self::Axis1D(value) => value, + Self::Axis2D(value) => value.x, + Self::Axis3D(value) => value.x, + } + } + + pub fn as_axis2d(self) -> Vec2 { + match self { + Self::Bool(value) => { + if value { + Vec2::X * 1.0 + } else { + Vec2::ZERO + } + } + Self::Axis1D(value) => Vec2::X * value, + Self::Axis2D(value) => value, + Self::Axis3D(value) => value.xy(), + } + } + + pub fn as_axis3d(self) -> Vec3 { + match self { + Self::Bool(value) => { + if value { + Vec3::X * 1.0 + } else { + Vec3::ZERO + } + } + Self::Axis1D(value) => Vec3::X * value, + Self::Axis2D(value) => value.extend(0.0), + Self::Axis3D(value) => value, + } + } +} + +#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)] +pub enum ActionValueDim { + Bool, + Axis1D, + Axis2D, + Axis3D, +} + +impl From for ActionValue { + fn from(value: bool) -> Self { + ActionValue::Bool(value) + } +} + +impl From for ActionValue { + fn from(value: f32) -> Self { + ActionValue::Axis1D(value) + } +} + +impl From for ActionValue { + fn from(value: Vec2) -> Self { + ActionValue::Axis2D(value) + } +} + +impl From for ActionValue { + fn from(value: Vec3) -> Self { + ActionValue::Axis3D(value) + } +} diff --git a/src/input_context.rs b/src/input_context.rs new file mode 100644 index 0000000..243633e --- /dev/null +++ b/src/input_context.rs @@ -0,0 +1,114 @@ +pub mod context_map; +pub mod input_action; +pub mod input_condition; +pub mod input_modifier; +pub mod trigger_tracker; + +use std::any::{self, TypeId}; + +use bevy::prelude::*; + +use crate::input_reader::InputReader; +use context_map::ContextMap; + +pub trait ContextAppExt { + fn add_input_context(&mut self) -> &mut Self; +} + +impl ContextAppExt for App { + fn add_input_context(&mut self) -> &mut Self { + debug!("registering context `{}`", any::type_name::()); + + self.observe(on_context_add::) + .observe(on_context_remove::); + + self + } +} + +#[derive(Resource, Default, Deref)] +pub(crate) struct InputContexts(Vec); + +impl InputContexts { + fn insert(&mut self, instance: ContextInstance) { + let index = self + .binary_search_by_key(&instance.map.priority(), |reg| reg.map.priority()) + .unwrap_or_else(|e| e); + self.0.insert(index, instance); + } + + fn remove(&mut self, entity: Entity) -> ContextInstance { + // TODO: Consider storing per entity. + let index = self + .iter() + .position(|instance| instance.entity == entity && instance.type_id == TypeId::of::()) + .unwrap(); + self.0.remove(index) + } + + pub(crate) fn iter_mut(&mut self) -> impl Iterator { + self.0.iter_mut() + } +} + +pub(crate) struct ContextInstance { + entity: Entity, + type_id: TypeId, + map: ContextMap, +} + +impl ContextInstance { + fn new(entity: Entity) -> Self { + Self { + entity, + type_id: TypeId::of::(), + map: C::context_map(), + } + } + + pub(crate) fn update( + &mut self, + world: &World, + commands: &mut Commands, + reader: &mut InputReader, + delta: f32, + ) { + self.map.update(world, commands, reader, self.entity, delta); + } +} + +pub trait InputContext: Component { + const PRIORITY: usize = 0; + + fn context_map() -> ContextMap; +} + +fn on_context_add( + trigger: Trigger, + mut contexts: ResMut, +) { + debug!( + "adding input context `{}` to `{}`", + any::type_name::(), + trigger.entity(), + ); + + contexts.insert(ContextInstance::new::(trigger.entity())); +} + +fn on_context_remove( + trigger: Trigger, + mut commands: Commands, + mut contexts: ResMut, +) { + debug!( + "removing input context `{}` from `{}`", + any::type_name::(), + trigger.entity() + ); + + let instance = contexts.remove::(trigger.entity()); + instance + .map + .trigger_removed(&mut commands, trigger.entity()); +} diff --git a/src/input_context/context_map.rs b/src/input_context/context_map.rs new file mode 100644 index 0000000..9deb14a --- /dev/null +++ b/src/input_context/context_map.rs @@ -0,0 +1,206 @@ +use std::any::TypeId; + +use bevy::{prelude::*, utils::Entry}; + +use super::{ + input_action::{Accumulation, ActionData, ActionsData, InputAction}, + input_condition::InputCondition, + input_modifier::InputModifier, + trigger_tracker::TriggerTracker, +}; +use crate::{ + action_value::{ActionValue, ActionValueDim}, + input_reader::{Input, InputReader}, + prelude::{Negate, SwizzleAxis}, +}; + +#[derive(Default)] +pub struct ContextMap { + priority: usize, + actions: Vec, + actions_data: ActionsData, +} + +impl ContextMap { + pub fn with_priority(priority: usize) -> Self { + Self { + priority, + ..Default::default() + } + } + + pub fn bind(&mut self) -> &mut ActionMap { + let type_id = TypeId::of::(); + match self.actions_data.entry(type_id) { + Entry::Occupied(_entry) => self + .actions + .iter_mut() + .find(|action_map| action_map.type_id == type_id) + .expect("data and actions should have matching type IDs"), + Entry::Vacant(entry) => { + entry.insert(ActionData::new::()); + self.actions.push(ActionMap::new::()); + self.actions.last_mut().unwrap() + } + } + } + + pub(super) fn update( + &mut self, + world: &World, + commands: &mut Commands, + reader: &mut InputReader, + entity: Entity, + delta: f32, + ) { + for action_map in &mut self.actions { + action_map.update( + world, + commands, + reader, + &mut self.actions_data, + entity, + delta, + ); + } + } + + pub(super) fn trigger_removed(mut self, commands: &mut Commands, entity: Entity) { + // TODO: Consider redundantly store dimention in the data. + for action_map in self.actions.drain(..) { + let data = self + .actions_data + .remove(&action_map.type_id) + .expect("data and actions should have matching type IDs"); + data.trigger_removed(commands, entity, action_map.dim); + } + } + + pub fn iter_mut(&mut self) -> impl Iterator { + self.actions.iter_mut() + } + + pub(super) fn priority(&self) -> usize { + self.priority + } +} + +pub struct ActionMap { + type_id: TypeId, + consumes_input: bool, + accumulation: Accumulation, + dim: ActionValueDim, + last_value: ActionValue, + + modifiers: Vec>, + conditions: Vec>, + inputs: Vec, +} + +impl ActionMap { + fn new() -> Self { + Self { + type_id: TypeId::of::(), + dim: A::DIM, + consumes_input: A::CONSUMES_INPUT, + accumulation: A::ACCUMULATION, + last_value: ActionValue::zero(A::DIM), + modifiers: Default::default(), + conditions: Default::default(), + inputs: Default::default(), + } + } + + pub fn with_wasd(&mut self) -> &mut Self { + self.with(InputMap::new(KeyCode::KeyW).with_modifier(SwizzleAxis::YXZ)) + .with(InputMap::new(KeyCode::KeyA).with_modifier(Negate)) + .with( + InputMap::new(KeyCode::KeyS) + .with_modifier(Negate) + .with_modifier(SwizzleAxis::YXZ), + ) + .with(InputMap::new(KeyCode::KeyD)) + } + + pub fn with_modifier(&mut self, modifier: impl InputModifier) -> &mut Self { + self.modifiers.push(Box::new(modifier)); + self + } + + pub fn with_condition(&mut self, condition: impl InputCondition) -> &mut Self { + self.conditions.push(Box::new(condition)); + self + } + + pub fn with(&mut self, map: impl Into) -> &mut Self { + self.inputs.push(map.into()); + self + } + + pub fn clear_mappings(&mut self) { + self.inputs.clear(); + } + + fn update( + &mut self, + world: &World, + commands: &mut Commands, + reader: &mut InputReader, + actions_data: &mut ActionsData, + entity: Entity, + delta: f32, + ) { + let mut tracker = TriggerTracker::new(ActionValue::zero(self.dim)); + for input_map in &mut self.inputs { + if let Some(value) = reader.read(input_map.input, self.consumes_input) { + self.last_value = value.convert(self.dim); + } + let mut current_tracker = TriggerTracker::new(self.last_value); + current_tracker.apply_modifiers(world, delta, &mut input_map.modifiers); + current_tracker.apply_conditions(world, actions_data, delta, &mut input_map.conditions); + tracker.merge(current_tracker, self.accumulation); + } + + tracker.apply_modifiers(world, delta, &mut self.modifiers); + tracker.apply_conditions(world, actions_data, delta, &mut self.conditions); + + let (state, value) = tracker.finish(); + let data = actions_data + .get_mut(&self.type_id) + .expect("data and actions should have matching type IDs"); + + data.update(commands, entity, state, value, delta); + } +} + +pub struct InputMap { + pub input: Input, + pub modifiers: Vec>, + pub conditions: Vec>, +} + +impl InputMap { + pub fn new(input: impl Into) -> Self { + Self { + input: input.into(), + modifiers: Default::default(), + conditions: Default::default(), + } + } + + pub fn with_modifier(mut self, modifier: impl InputModifier) -> Self { + self.modifiers.push(Box::new(modifier)); + self + } + + pub fn with_condition(mut self, condition: impl InputCondition) -> Self { + self.conditions.push(Box::new(condition)); + self + } +} + +impl From for InputMap { + fn from(value: KeyCode) -> Self { + Self::new(value) + } +} diff --git a/src/input_context/input_action.rs b/src/input_context/input_action.rs new file mode 100644 index 0000000..2c37b69 --- /dev/null +++ b/src/input_context/input_action.rs @@ -0,0 +1,301 @@ +use std::{any::TypeId, fmt::Debug, marker::PhantomData}; + +use bevy::{prelude::*, utils::HashMap}; + +use crate::action_value::{ActionValue, ActionValueDim}; + +#[derive(Deref, Default, DerefMut)] +pub struct ActionsData(HashMap); + +impl ActionsData { + pub fn get_action(&self) -> Option<&ActionData> { + self.get(&TypeId::of::()) + } +} + +impl From> for ActionsData { + fn from(value: HashMap) -> Self { + Self(value) + } +} + +#[derive(Clone, Copy)] +pub struct ActionData { + state: ActionState, + elapsed_secs: f32, + fired_secs: f32, + trigger_events: fn(&Self, &mut Commands, Entity, ActionState, ActionValue), +} + +impl ActionData { + pub fn new() -> Self { + Self { + state: Default::default(), + elapsed_secs: 0.0, + fired_secs: 0.0, + trigger_events: Self::trigger::, + } + } + + pub fn update( + &mut self, + commands: &mut Commands, + entity: Entity, + state: ActionState, + value: ActionValue, + delta: f32, + ) { + // Add time from the previous frame if needed + // before triggering events. + match self.state { + ActionState::None => (), + ActionState::Ongoing => { + self.elapsed_secs += delta; + } + ActionState::Fired => { + self.elapsed_secs += delta; + self.fired_secs += delta; + } + } + + (self.trigger_events)(self, commands, entity, state, value); + + // Reset time for updated state. + self.state = state; + match self.state { + ActionState::None => { + self.elapsed_secs = 0.0; + self.fired_secs = 0.0; + } + ActionState::Ongoing => { + self.fired_secs = 0.0; + } + ActionState::Fired => (), + } + } + + pub fn trigger_removed(&self, commands: &mut Commands, entity: Entity, dim: ActionValueDim) { + (self.trigger_events)( + self, + commands, + entity, + ActionState::None, + ActionValue::zero(dim), + ); + } + + fn trigger( + &self, + commands: &mut Commands, + entity: Entity, + state: ActionState, + value: ActionValue, + ) { + match (self.state(), state) { + (ActionState::None, ActionState::None) => (), + (ActionState::None, ActionState::Ongoing) => commands.trigger_targets( + ActionEvent::::from(ActionEventKind::Started { value }), + entity, + ), + (ActionState::None, ActionState::Fired) => { + commands.trigger_targets( + ActionEvent::::from(ActionEventKind::Started { value }), + entity, + ); + commands.trigger_targets( + ActionEvent::::from(ActionEventKind::Fired { + value, + fired_secs: 0.0, + elapsed_secs: 0.0, + }), + entity, + ); + } + (ActionState::Ongoing, ActionState::None) => commands.trigger_targets( + ActionEvent::::from(ActionEventKind::Canceled { + value, + elapsed_secs: self.elapsed_secs, + }), + entity, + ), + (ActionState::Ongoing, ActionState::Ongoing) => commands.trigger_targets( + ActionEvent::::from(ActionEventKind::Ongoing { + value, + elapsed_secs: self.elapsed_secs, + }), + entity, + ), + (ActionState::Ongoing, ActionState::Fired) => commands.trigger_targets( + ActionEvent::::from(ActionEventKind::Fired { + value, + fired_secs: self.fired_secs, + elapsed_secs: self.elapsed_secs, + }), + entity, + ), + (ActionState::Fired, ActionState::None) => commands.trigger_targets( + ActionEvent::::from(ActionEventKind::Completed { + value, + fired_secs: self.fired_secs, + elapsed_secs: self.elapsed_secs, + }), + entity, + ), + (ActionState::Fired, ActionState::Ongoing) => commands.trigger_targets( + ActionEvent::::from(ActionEventKind::Ongoing { + value, + elapsed_secs: self.elapsed_secs, + }), + entity, + ), + (ActionState::Fired, ActionState::Fired) => commands.trigger_targets( + ActionEvent::::from(ActionEventKind::Fired { + value, + fired_secs: self.fired_secs, + elapsed_secs: self.elapsed_secs, + }), + entity, + ), + } + } + + pub fn state(&self) -> ActionState { + self.state + } + + pub fn elapsed_secs(&self) -> f32 { + self.elapsed_secs + } + + pub fn fired_secs(&self) -> f32 { + self.fired_secs + } +} + +#[derive(Clone, Copy, Default, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum ActionState { + /// Condition is not triggered. + #[default] + None, + /// Condition has started triggering, but has not yet finished. + /// + /// For example, a time-based condition requires its state to be maintained over several frames. + Ongoing, + /// The condition has been met. + Fired, +} + +#[derive(Debug, Event, Deref)] +pub struct ActionEvent { + pub marker: PhantomData, + #[deref] + pub kind: ActionEventKind, +} + +impl From for ActionEvent { + fn from(kind: ActionEventKind) -> Self { + Self { + marker: PhantomData, + kind, + } + } +} + +#[derive(Debug, Event)] +pub enum ActionEventKind { + /// Triggers every frame when an action state is [`ActionState::Fired`]. + Fired { + value: ActionValue, + + /// Time that this action was in [`ActionState::Fired`] state. + fired_secs: f32, + + /// Total time this action has been in both [`ActionState::Ongoing`] and [`ActionState::Fired`]. + elapsed_secs: f32, + }, + + /// Triggers when an action switches its state from [`ActionState::None`] + /// to [`ActionState::Fired`] or [`ActionState::Ongoing`]. + Started { value: ActionValue }, + + /// Triggers every frame when an action state is [`ActionState::Ongoing`]. + Ongoing { + value: ActionValue, + + /// Time that this action was in [`ActionState::Ongoing`] state. + elapsed_secs: f32, + }, + + /// Triggers when action switches its state from [`ActionState::Fired`] to [`ActionState::None`], + Completed { + value: ActionValue, + + /// Time that this action was in [`ActionState::Fired`] state. + fired_secs: f32, + + /// Total time this action has been in both [`ActionState::Ongoing`] and [`ActionState::Fired`]. + elapsed_secs: f32, + }, + + /// Triggers when action switches its state from [`ActionState::Ongoing`] to [`ActionState::None`], + Canceled { + value: ActionValue, + + /// Time that this action was in [`ActionState::Ongoing`] state. + elapsed_secs: f32, + }, +} + +impl ActionEventKind { + pub fn is_fired(&self) -> bool { + matches!(self, ActionEventKind::Fired { .. }) + } + + pub fn is_started(&self) -> bool { + matches!(self, ActionEventKind::Started { .. }) + } + + pub fn is_ongoing(&self) -> bool { + matches!(self, ActionEventKind::Ongoing { .. }) + } + + pub fn is_completed(&self) -> bool { + matches!(self, ActionEventKind::Completed { .. }) + } + + pub fn is_canceled(&self) -> bool { + matches!(self, ActionEventKind::Canceled { .. }) + } +} + +pub trait InputAction: Debug + Send + Sync + 'static { + const DIM: ActionValueDim; + + /// Specifies whether this action should swallow any inputs bound to it or + /// allow them to pass through to affect lower-priority bound actions. + /// + /// By default is set to `true`. + const CONSUMES_INPUT: bool = true; + + /// Associated accumulation behavior. + /// + /// By default set to [`Accumulation::MaxAbs`]. + const ACCUMULATION: Accumulation = Accumulation::MaxAbs; +} + +/// Defines how the value of an [`InputAction`] is calculated when there are multiple mappings. +#[derive(Default, Clone, Copy, Debug)] +pub enum Accumulation { + /// Take the value from the mapping with the highest absolute value. + /// + /// For example, given values of 0.5 and -1.5, the input action's value would be -1.0. + #[default] + MaxAbs, + + /// Cumulatively add the key values for each mapping. + /// + /// For example, given values of 0.5 and -0.3, the input action's value would be 0.2. + /// + /// Usually used for things like WASD movement, when you want pressing W and S to cancel each other out. + Cumulative, +} diff --git a/src/input_context/input_condition.rs b/src/input_context/input_condition.rs new file mode 100644 index 0000000..42f8eff --- /dev/null +++ b/src/input_context/input_condition.rs @@ -0,0 +1,520 @@ +pub mod primitives; + +use std::{any, fmt::Debug, marker::PhantomData}; + +use bevy::prelude::*; + +use super::input_action::{ActionState, ActionsData, InputAction}; +use crate::action_value::ActionValue; +use primitives::{Actuation, HeldTimer}; + +pub trait InputCondition: Sync + Send + Debug + 'static { + fn update_state( + &mut self, + world: &World, + actions_data: &ActionsData, + delta: f32, + value: ActionValue, + ) -> ActionState; + + fn kind(&self) -> ConditionKind { + ConditionKind::Explicit + } +} + +/// Determines how a condition it contributes to [`ActionState`]. +pub enum ConditionKind { + Explicit, + Implicit, +} + +/// Returns [`ActionState::Fired`] when the input exceeds the actuation threshold. +#[derive(Default, Debug)] +pub struct Down { + pub actuation: Actuation, +} + +impl Down { + pub fn new(actuation: Actuation) -> Self { + Self { actuation } + } +} + +impl InputCondition for Down { + fn update_state( + &mut self, + _world: &World, + _actions_data: &ActionsData, + _delta: f32, + value: ActionValue, + ) -> ActionState { + if self.actuation.is_actuated(value) { + ActionState::Fired + } else { + ActionState::None + } + } +} + +/// Like [`Down`] but returns [`ActionState::Fired`] only once until the next actuation. +/// +/// Holding the input will not cause further triggers. +#[derive(Default, Debug)] +pub struct Pressed { + pub actuation: Actuation, + actuated: bool, +} + +impl Pressed { + pub fn new(actuation: Actuation) -> Self { + Self { + actuation, + actuated: false, + } + } +} + +impl InputCondition for Pressed { + fn update_state( + &mut self, + _world: &World, + _actions_data: &ActionsData, + _delta: f32, + value: ActionValue, + ) -> ActionState { + let previosly_actuated = self.actuated; + self.actuated = self.actuation.is_actuated(value); + + if self.actuated && !previosly_actuated { + ActionState::Fired + } else { + ActionState::None + } + } +} + +/// Returns [`ActionState::Ongoing`]` when the input exceeds the actuation threshold and +/// [`ActionState::Fired`] once when the input drops back below the actuation threshold. +#[derive(Default, Debug)] +pub struct Released { + pub actuation: Actuation, + actuated: bool, +} + +impl Released { + pub fn new(actuation: Actuation) -> Self { + Self { + actuation, + actuated: false, + } + } +} + +impl InputCondition for Released { + fn update_state( + &mut self, + _world: &World, + _actions_data: &ActionsData, + _delta: f32, + value: ActionValue, + ) -> ActionState { + let previosly_actuated = self.actuated; + self.actuated = self.actuation.is_actuated(value); + + if self.actuated { + // Ongoing on hold. + ActionState::Ongoing + } else if previosly_actuated { + // Fired on release. + ActionState::Fired + } else { + ActionState::None + } + } +} + +/// Returns [`ActionState::Ongoing`] when the input becomes actuated and +/// [`ActionState::Fired`] when input remained actuated for [`Self::hold_time`] seconds. +/// +/// Returns [`ActionState::None`] when the input stops being actuated earlier than [`Self::hold_time`] seconds. +/// May optionally fire once, or repeatedly fire. +#[derive(Debug)] +pub struct Hold { + // How long does the input have to be held to cause trigger. + pub hold_time: f32, + + // Should this trigger fire only once, or fire every frame once the hold time threshold is met? + pub one_shot: bool, + + pub actuation: Actuation, + + held_timer: HeldTimer, + + fired: bool, +} + +impl Hold { + pub fn new(hold_time: f32) -> Self { + Self { + hold_time, + one_shot: false, + actuation: Default::default(), + held_timer: Default::default(), + fired: false, + } + } + + pub fn one_shot(mut self, one_shot: bool) -> Self { + self.one_shot = one_shot; + self + } + + pub fn with_actuation(mut self, actuation: impl Into) -> Self { + self.actuation = actuation.into(); + self + } + + pub fn with_held_timer(mut self, held_timer: HeldTimer) -> Self { + self.held_timer = held_timer; + self + } +} + +impl InputCondition for Hold { + fn update_state( + &mut self, + world: &World, + _actions_data: &ActionsData, + delta: f32, + value: ActionValue, + ) -> ActionState { + let actuated = self.actuation.is_actuated(value); + if actuated { + self.held_timer.update(world, delta); + } else { + self.held_timer.reset(); + } + + let is_first_trigger = !self.fired; + self.fired = self.held_timer.duration() >= self.hold_time; + + if self.fired { + if is_first_trigger || !self.one_shot { + ActionState::Fired + } else { + ActionState::None + } + } else if actuated { + ActionState::Ongoing + } else { + ActionState::None + } + } +} + +/// Returns [`ActionState::Ongoing`] when input becomes actuated and [`ActionState::Fired`] +/// when the input is released after having been actuated for [`Self::hold_time`] seconds. +/// +/// Returns [`ActionState::None`] when the input stops being actuated earlier than [`Self::hold_time`] seconds. +#[derive(Debug)] +pub struct HoldAndRelease { + // How long does the input have to be held to cause trigger. + pub hold_time: f32, + + pub actuation: Actuation, + + held_timer: HeldTimer, +} + +impl HoldAndRelease { + pub fn new(hold_time: f32) -> Self { + Self { + hold_time, + actuation: Default::default(), + held_timer: Default::default(), + } + } + + pub fn with_actuation(mut self, actuation: impl Into) -> Self { + self.actuation = actuation.into(); + self + } + + pub fn with_held_timer(mut self, held_timer: HeldTimer) -> Self { + self.held_timer = held_timer; + self + } +} + +impl InputCondition for HoldAndRelease { + fn update_state( + &mut self, + world: &World, + _actions_data: &ActionsData, + delta: f32, + value: ActionValue, + ) -> ActionState { + // Evaluate the updated held duration prior to checking for actuation. + // This stops us failing to trigger if the input is released on the + // threshold frame due to held duration being 0. + self.held_timer.update(world, delta); + let held_duration = self.held_timer.duration(); + + if self.actuation.is_actuated(value) { + ActionState::Ongoing + } else { + self.held_timer.reset(); + // Trigger if we've passed the threshold and released. + if held_duration > self.hold_time { + ActionState::Fired + } else { + ActionState::None + } + } + } +} + +/// Returns [`ActionState::Ongoing`] when input becomes actuated and [`ActionState::Fired`] +/// when the input is released within the [`Self::release_time`] seconds. +/// +/// Returns [`ActionState::None`] when the input is actuated more than [`Self::release_time`] seconds. +#[derive(Debug)] +pub struct Tap { + pub release_time: f32, + + pub actuation: Actuation, + + held_timer: HeldTimer, + actuated: bool, +} + +impl Tap { + pub fn new(release_time: f32) -> Self { + Self { + release_time, + actuation: Default::default(), + held_timer: Default::default(), + actuated: false, + } + } + + pub fn with_actuation(mut self, actuation: impl Into) -> Self { + self.actuation = actuation.into(); + self + } + + pub fn with_held_timer(mut self, held_timer: HeldTimer) -> Self { + self.held_timer = held_timer; + self + } +} + +impl InputCondition for Tap { + fn update_state( + &mut self, + world: &World, + _actions_data: &ActionsData, + delta: f32, + value: ActionValue, + ) -> ActionState { + let last_actuated = self.actuated; + let last_held_duration = self.held_timer.duration(); + self.actuated = self.actuation.is_actuated(value); + if self.actuated { + self.held_timer.update(world, delta); + } else { + self.held_timer.reset(); + } + + if last_actuated && !self.actuated && last_held_duration <= self.release_time { + // Only trigger if pressed then released quickly enough. + ActionState::Fired + } else if self.held_timer.duration() >= self.release_time { + // Once we pass the threshold halt all triggering until released. + ActionState::None + } else if self.actuated { + ActionState::Ongoing + } else { + ActionState::None + } + } +} + +/// Returns [`ActionState::Ongoing`] when input becomes actuated and [`ActionState::Fired`] +/// each [`Self::interval`] seconds. +/// +/// Note: [`ActionEventKind::Completed`](super::input_action::ActionEventKind::Completed) only fires +/// when the repeat limit is reached or when input is released immediately after being triggered. +/// Otherwise, [`ActionEventKind::Canceled`](super::input_action::ActionEventKind::Canceled) is fired when input is released. +#[derive(Debug)] +pub struct Pulse { + /// Time in seconds between each triggering while input is held. + pub interval: f32, + + // Number of times the condition can be triggered (0 means no limit). + pub trigger_limit: u32, + + /// Whether to trigger when the input first exceeds the actuation threshold or wait for the first interval. + pub trigger_on_start: bool, + + pub actuation: Actuation, + + held_timer: HeldTimer, + + trigger_count: u32, +} + +impl Pulse { + pub fn new(interval: f32) -> Self { + Self { + interval, + trigger_limit: 0, + trigger_on_start: true, + trigger_count: 0, + actuation: Default::default(), + held_timer: Default::default(), + } + } + + pub fn with_trigger_limit(mut self, trigger_limit: u32) -> Self { + self.trigger_limit = trigger_limit; + self + } + + pub fn trigger_on_start(mut self, trigger_on_start: bool) -> Self { + self.trigger_on_start = trigger_on_start; + self + } + + pub fn with_actuation(mut self, actuation: impl Into) -> Self { + self.actuation = actuation.into(); + self + } + + pub fn with_held_timer(mut self, held_timer: HeldTimer) -> Self { + self.held_timer = held_timer; + self + } +} + +impl InputCondition for Pulse { + fn update_state( + &mut self, + world: &World, + _actions_data: &ActionsData, + delta: f32, + value: ActionValue, + ) -> ActionState { + if self.actuation.is_actuated(value) { + self.held_timer.update(world, delta); + + if self.trigger_limit == 0 || self.trigger_count < self.trigger_limit { + let trigger_count = if self.trigger_on_start { + self.trigger_count + } else { + self.trigger_count + 1 + }; + + // If the repeat count limit has not been reached. + if self.held_timer.duration() > self.interval * trigger_count as f32 { + // Trigger when held duration exceeds the interval threshold. + self.trigger_count += 1; + ActionState::Fired + } else { + ActionState::Ongoing + } + } else { + ActionState::None + } + } else { + self.held_timer.reset(); + + self.trigger_count = 0; + ActionState::None + } + } +} + +/// Requires action `A` to be triggered within the same context. +/// +/// Inherits [`ActionState`] from the specified action. +#[derive(Debug)] +pub struct Chord { + pub marker: PhantomData, +} + +impl Default for Chord { + fn default() -> Self { + Self { + marker: PhantomData, + } + } +} + +impl InputCondition for Chord { + fn update_state( + &mut self, + _world: &World, + actions_data: &ActionsData, + _delta: f32, + _value: ActionValue, + ) -> ActionState { + if let Some(data) = actions_data.get_action::() { + // Inherit state from the chorded action. + data.state() + } else { + warn_once!( + "action `{}` is not present in context", + any::type_name::() + ); + ActionState::None + } + } + + fn kind(&self) -> ConditionKind { + ConditionKind::Implicit + } +} + +/// Requires another action to not be triggered within the same context. +/// +/// Could be used for chords to avoid triggering required actions. +#[derive(Debug)] +pub struct BlockedBy { + pub marker: PhantomData, +} + +impl Default for BlockedBy { + fn default() -> Self { + Self { + marker: PhantomData, + } + } +} + +impl InputCondition for BlockedBy { + fn update_state( + &mut self, + _world: &World, + actions_data: &ActionsData, + _delta: f32, + _value: ActionValue, + ) -> ActionState { + if let Some(data) = actions_data.get_action::() { + if data.state() == ActionState::Fired { + return ActionState::None; + } + } else { + warn_once!( + "action `{}` is not present in context", + any::type_name::() + ); + } + + ActionState::Fired + } + + fn kind(&self) -> ConditionKind { + ConditionKind::Implicit + } +} diff --git a/src/input_context/input_condition/primitives.rs b/src/input_context/input_condition/primitives.rs new file mode 100644 index 0000000..e98bf77 --- /dev/null +++ b/src/input_context/input_condition/primitives.rs @@ -0,0 +1,76 @@ +use bevy::prelude::*; + +use crate::action_value::ActionValue; + +/// Helper for building triggers that have firing conditions governed by elapsed time. +#[derive(Default, Debug)] +pub struct HeldTimer { + /// If set to `true`, [`Time::relative_speed`] will be applied to the held duration. + /// + /// By default is set to `false`. + pub relative_to_speed: bool, + + duration: f32, +} + +impl HeldTimer { + pub fn relative_to_speed(relative_to_speed: bool) -> Self { + Self { + relative_to_speed, + duration: 0.0, + } + } + + pub fn update(&mut self, world: &World, mut delta: f32) { + if self.relative_to_speed { + let time = world.resource::>(); + delta *= time.relative_speed() + } + + self.duration += delta; + } + + pub fn reset(&mut self) { + self.duration = 0.0; + } + + pub fn duration(&self) -> f32 { + self.duration + } +} + +/// Value at which a button considered actuated. +#[derive(Clone, Copy, Debug)] +pub struct Actuation(pub f32); + +impl Actuation { + /// Returns `true` if the value in sufficiently large. + pub fn is_actuated(self, value: ActionValue) -> bool { + let value = match value { + ActionValue::Bool(value) => { + if value { + 1.0 + } else { + 0.0 + } + } + ActionValue::Axis1D(value) => value * value, + ActionValue::Axis2D(value) => value.length_squared(), + ActionValue::Axis3D(value) => value.length_squared(), + }; + + value >= self.0 * self.0 + } +} + +impl Default for Actuation { + fn default() -> Self { + Self(0.5) + } +} + +impl From for Actuation { + fn from(value: f32) -> Self { + Self(value) + } +} diff --git a/src/input_context/input_modifier.rs b/src/input_context/input_modifier.rs new file mode 100644 index 0000000..d440bc5 --- /dev/null +++ b/src/input_context/input_modifier.rs @@ -0,0 +1,413 @@ +pub use interpolation::EaseFunction; + +use std::{any, fmt::Debug}; + +use bevy::prelude::*; +use interpolation::Ease; + +use crate::action_value::{ActionValue, ActionValueDim}; + +pub trait InputModifier: Sync + Send + Debug + 'static { + fn apply(&mut self, world: &World, delta: f32, value: ActionValue) -> ActionValue; +} + +macro_rules! ignore_incompatible { + ($value:expr) => { + warn_once!( + "trying to apply `{}` to a `{:?}` value, which is not possible", + any::type_name::(), + $value.dim(), + ); + return $value + }; +} + +/// Input values within the range [Self::lower_threshold] -> [Self::upper_threshold] will be remapped from 0 -> 1. +/// Values outside this range will be clamped. +/// +/// Can't be applied to [`ActionValue::Bool`]. +#[derive(Clone, Copy, Debug)] +pub struct DeadZone { + pub kind: DeadZoneKind, + + /// Threshold below which input is ignored. + pub lower_threshold: f32, + + /// Threshold below which input is ignored. + pub upper_threshold: f32, +} + +impl DeadZone { + fn dead_zone(self, axis_value: f32) -> f32 { + // Translate and scale the input to the +/- 1 range after removing the dead zone. + let lower_bound = (axis_value.abs() - self.lower_threshold).max(0.0); + let scaled_value = lower_bound / (self.upper_threshold - self.lower_threshold); + scaled_value.min(1.0) * axis_value.signum() + } +} + +impl Default for DeadZone { + fn default() -> Self { + Self { + kind: Default::default(), + lower_threshold: 0.2, + upper_threshold: 1.0, + } + } +} + +impl InputModifier for DeadZone { + fn apply(&mut self, _world: &World, _delta: f32, value: ActionValue) -> ActionValue { + match value { + ActionValue::Bool(_) => { + ignore_incompatible!(value); + } + ActionValue::Axis1D(value) => self.dead_zone(value).into(), + ActionValue::Axis2D(mut value) => match self.kind { + DeadZoneKind::Radial => { + (value.normalize_or_zero() * self.dead_zone(value.length())).into() + } + DeadZoneKind::Axial => { + value.x = self.dead_zone(value.x); + value.y = self.dead_zone(value.y); + value.into() + } + }, + ActionValue::Axis3D(mut value) => match self.kind { + DeadZoneKind::Radial => { + (value.normalize_or_zero() * self.dead_zone(value.length())).into() + } + DeadZoneKind::Axial => { + value.x = self.dead_zone(value.x); + value.y = self.dead_zone(value.y); + value.z = self.dead_zone(value.z); + value.into() + } + }, + } + } +} + +#[derive(Default, Clone, Copy, Debug)] +pub enum DeadZoneKind { + // Apply dead zone logic to all axes simultaneously. + // + // This gives smooth input (circular/spherical coverage). On a 1d axis input this works identically to [`Self::Axial`]. + #[default] + Radial, + + // Apply dead zone to axes individually. + // + // This will result in input being chamfered at the corners for 2d/3d axis inputs. + Axial, +} + +/// Response curve exponential. +/// +/// Apply a simple exponential response curve to input values, per axis. +/// +/// Can't be applied to [`ActionValue::Bool`]. +#[derive(Clone, Copy, Debug)] +pub struct ExponentialCurve { + pub exponent: Vec3, +} + +impl ExponentialCurve { + fn curve(value: f32, exponent: f32) -> f32 { + if value != 1.0 { + value.signum() * value.abs().powf(exponent) + } else { + value + } + } +} + +impl Default for ExponentialCurve { + fn default() -> Self { + Self { + exponent: Vec3::ONE, + } + } +} + +impl InputModifier for ExponentialCurve { + fn apply(&mut self, _world: &World, _delta: f32, value: ActionValue) -> ActionValue { + match value { + ActionValue::Bool(_) => { + ignore_incompatible!(value); + } + ActionValue::Axis1D(value) => Self::curve(value, self.exponent.x).into(), + ActionValue::Axis2D(mut value) => { + value.x = Self::curve(value.x, self.exponent.x); + value.y = Self::curve(value.y, self.exponent.y); + value.into() + } + ActionValue::Axis3D(mut value) => { + value.x = Self::curve(value.x, self.exponent.x); + value.y = Self::curve(value.y, self.exponent.y); + value.y = Self::curve(value.z, self.exponent.z); + value.into() + } + } + } +} + +/// Scales input by a set factor per axis. +/// +/// Can't be applied to [`ActionValue::Bool`]. +#[derive(Clone, Copy, Debug)] +pub struct Scalar { + /// The scalar that will be applied to the input value. + /// + /// For example, with the scalar set to `Vec3::new(2.0, 2.0, 2.0)`, each input axis will be multiplied by 2.0. + /// + /// Does nothing for boolean values. + pub scalar: Vec3, +} + +impl InputModifier for Scalar { + fn apply(&mut self, _world: &World, _delta: f32, value: ActionValue) -> ActionValue { + match value { + ActionValue::Bool(_) => { + ignore_incompatible!(value); + } + ActionValue::Axis1D(value) => (value * self.scalar.x).into(), + ActionValue::Axis2D(value) => (value * self.scalar.xy()).into(), + ActionValue::Axis3D(value) => (value * self.scalar).into(), + } + } +} + +/// Multiplies the input value by delta time for this frame. +/// +/// Can't be applied to [`ActionValue::Bool`]. +#[derive(Clone, Copy, Debug)] +pub struct ScaleByDelta; + +impl InputModifier for ScaleByDelta { + fn apply(&mut self, _world: &World, delta: f32, value: ActionValue) -> ActionValue { + match value { + ActionValue::Bool(_) => { + ignore_incompatible!(value); + } + ActionValue::Axis1D(value) => (value * delta).into(), + ActionValue::Axis2D(value) => (value * delta).into(), + ActionValue::Axis3D(value) => (value * delta).into(), + } + } +} + +/// Smooth inputs out over multiple frames. +/// +/// Can't be applied to [`ActionValue::Bool`]. +#[derive(Debug)] +pub struct Smooth { + /// How long input has been zero. + zero_time: f32, + + /// Current average input/sample. + average_value: Vec3, + + /// Number of samples since input has been zero. + samples: u32, + + /// Input sampling total time. + total_sample_time: f32, +} + +impl InputModifier for Smooth { + fn apply(&mut self, _world: &World, delta: f32, value: ActionValue) -> ActionValue { + let dim = value.dim(); + if dim == ActionValueDim::Bool { + ignore_incompatible!(value); + } + + let mut sample_count: u8 = 1; + if self.average_value.length_squared() != 0.0 { + self.total_sample_time += delta; + self.samples += sample_count as u32; + } + + let mut value = value.as_axis3d(); + if delta < 0.25 { + if self.samples > 0 && self.total_sample_time > 0.0 { + // Seconds/sample. + let axis_sampling_time = self.total_sample_time / self.samples as f32; + debug_assert!(axis_sampling_time > 0.0); + + if value.length_squared() != 0.0 && sample_count > 0 { + self.zero_time = 0.0; + if self.average_value.length_squared() != 0.0 { + // This isn't the first tick with non-zero value. + if delta < axis_sampling_time * (sample_count as f32 + 1.0) { + // Smooth value so samples/tick is constant. + value *= delta / (axis_sampling_time * sample_count as f32); + sample_count = 1; + } + } + + self.average_value = value * (1.0 / sample_count as f32); + } else { + // No value received. + if self.zero_time < axis_sampling_time { + // Zero value is possibly because less than the value sampling interval has passed. + value = self.average_value * (delta / axis_sampling_time); + } else { + self.reset(); + } + + self.zero_time += delta; // increment length of time we've been at zero + } + } + } else { + // If we had an abnormally long frame, clear everything so it doesn't distort the results. + self.reset(); + } + + ActionValue::Axis3D(value).convert(dim) + } +} + +impl Default for Smooth { + fn default() -> Self { + Self { + zero_time: Default::default(), + average_value: Default::default(), + samples: Default::default(), + total_sample_time: Self::DEFAULT_SAMPLE_TIME, + } + } +} + +impl Smooth { + const DEFAULT_SAMPLE_TIME: f32 = 0.0083; + + fn reset(&mut self) { + self.zero_time = 0.0; + self.average_value = Vec3::ZERO; + self.samples = 0; + self.total_sample_time = Self::DEFAULT_SAMPLE_TIME; + } +} + +/// Normalized smooth delta +/// +/// Produces a smoothed normalized delta of the current(new) and last(old) input value. +/// +/// Can't be applied to [`ActionValue::Bool`]. +#[derive(Debug)] +pub struct SmoothDelta { + pub smoothing_method: SmoothingMethod, + + /// Speed, or alpha. + /// + /// If the speed given is 0, then jump to the target. + pub speed: f32, + + old_value: Vec3, + + value_delta: Vec3, +} + +impl SmoothDelta { + pub fn new(smoothing_method: SmoothingMethod, speed: f32) -> Self { + Self { + smoothing_method, + speed, + old_value: Default::default(), + value_delta: Default::default(), + } + } +} + +impl InputModifier for SmoothDelta { + fn apply(&mut self, _world: &World, delta: f32, value: ActionValue) -> ActionValue { + let dim = value.dim(); + if dim == ActionValueDim::Bool { + ignore_incompatible!(value); + } + + let value = value.as_axis3d(); + let target_value_delta = (self.old_value - value).normalize_or_zero(); + self.old_value = value; + + let normalized_delta = delta / self.speed; + self.value_delta = match self.smoothing_method { + SmoothingMethod::EaseFunction(ease_function) => { + let ease_delta = normalized_delta.calc(ease_function); + self.value_delta.lerp(target_value_delta, ease_delta) + } + SmoothingMethod::Linear => self.value_delta.lerp(target_value_delta, normalized_delta), + }; + + ActionValue::Axis3D(self.value_delta).convert(dim) + } +} + +/// Behavior options for [`SmoothDelta`]. +/// +/// Describe how eased value should be computed. +#[derive(Clone, Copy, Debug)] +pub enum SmoothingMethod { + /// Follow [`EaseFunction`]. + EaseFunction(EaseFunction), + /// Linear interpolation, with no function. + Linear, +} + +#[derive(Debug)] +pub struct Negate; + +impl InputModifier for Negate { + fn apply(&mut self, _world: &World, _delta: f32, value: ActionValue) -> ActionValue { + match value { + ActionValue::Bool(value) => (!value).into(), + ActionValue::Axis1D(value) => (-value).into(), + ActionValue::Axis2D(value) => (-value).into(), + ActionValue::Axis3D(value) => (-value).into(), + } + } +} + +/// Swizzle axis components of an input value. +/// +/// Useful to map a 1D input onto the Y axis of a 2D action. +/// +/// Can't be applied to [`ActionValue::Bool`] and [`ActionValue::Axis1D`]. +#[derive(Debug)] +pub enum SwizzleAxis { + /// Swap X and Y axis. Useful for binding 1D inputs to the Y axis for 2D actions. + YXZ, + /// Swap X and Z axis. + ZYX, + /// Swap Y and Z axis. + XZY, + /// Reorder all axes, Y first. + YZX, + /// Reorder all axes, Z first. + ZXY, +} + +impl InputModifier for SwizzleAxis { + fn apply(&mut self, _world: &World, _delta: f32, value: ActionValue) -> ActionValue { + match value { + ActionValue::Bool(_) | ActionValue::Axis1D(_) => { + ignore_incompatible!(value); + } + ActionValue::Axis2D(value) => match self { + SwizzleAxis::YXZ => value.yx().into(), + SwizzleAxis::ZYX => Vec2::new(0.0, value.y).into(), + SwizzleAxis::XZY => Vec2::new(value.x, 0.0).into(), + SwizzleAxis::YZX => Vec2::new(value.y, 0.0).into(), + SwizzleAxis::ZXY => Vec2::new(0.0, value.x).into(), + }, + ActionValue::Axis3D(value) => match self { + SwizzleAxis::YXZ => value.yxz().into(), + SwizzleAxis::ZYX => value.zyx().into(), + SwizzleAxis::XZY => value.xzy().into(), + SwizzleAxis::YZX => value.yzx().into(), + SwizzleAxis::ZXY => value.zxy().into(), + }, + } + } +} diff --git a/src/input_context/trigger_tracker.rs b/src/input_context/trigger_tracker.rs new file mode 100644 index 0000000..288bc7b --- /dev/null +++ b/src/input_context/trigger_tracker.rs @@ -0,0 +1,118 @@ +use std::cmp::Ordering; + +use bevy::prelude::*; + +use super::{ + input_action::{Accumulation, ActionState, ActionsData}, + input_condition::{ConditionKind, InputCondition}, + input_modifier::InputModifier, +}; +use crate::action_value::ActionValue; + +pub(super) struct TriggerTracker { + value: ActionValue, + state: ActionState, + blocked: bool, + found_explicit: bool, +} + +impl TriggerTracker { + pub(super) fn new(value: ActionValue) -> Self { + Self { + value, + state: Default::default(), + blocked: false, + found_explicit: false, + } + } + + pub(super) fn apply_modifiers( + &mut self, + world: &World, + delta: f32, + modifiers: &mut [Box], + ) { + for modifier in modifiers { + let new_value = modifier.apply(world, delta, self.value); + debug_assert_eq!( + new_value.dim(), + self.value.dim(), + "modifiers should preserve action dimentions" + ); + + self.value = new_value; + } + } + + pub(super) fn apply_conditions( + &mut self, + world: &World, + actions_data: &ActionsData, + delta: f32, + conditions: &mut [Box], + ) { + // Note: No early outs permitted! + // All conditions must be evaluated to update their internal state/delta time. + for condition in conditions { + let state = condition.update_state(world, actions_data, delta, self.value); + match condition.kind() { + ConditionKind::Explicit => { + self.found_explicit = true; + if state > self.state { + // Retain the most interesting. + self.state = state; + } + } + ConditionKind::Implicit => { + if state != ActionState::Fired { + self.blocked = true; + } + } + } + } + } + + pub(super) fn merge(&mut self, other: Self, accumulation: Accumulation) { + if other.blocked { + return; + } + + if other.found_explicit { + self.found_explicit = true; + } + + match self.state.cmp(&other.state) { + Ordering::Less => { + self.state = other.state; + self.value = other.value; + } + Ordering::Equal => { + let accumulated = match accumulation { + Accumulation::MaxAbs => { + let mut value = self.value.as_axis3d().to_array(); + let other_value = other.value.as_axis3d().to_array(); + for (axis, other_axis) in value.iter_mut().zip(other_value) { + if axis.abs() < other_axis.abs() { + *axis = other_axis; + } + } + value.into() + } + Accumulation::Cumulative => self.value.as_axis3d() + other.value.as_axis3d(), + }; + self.value = ActionValue::Axis3D(accumulated).convert(self.value.dim()); + } + Ordering::Greater => (), + } + } + + pub(super) fn finish(mut self) -> (ActionState, ActionValue) { + if self.blocked { + self.state = ActionState::None + } else if !self.found_explicit && self.value.as_bool() { + self.state = ActionState::Fired; + } + + (self.state, self.value) + } +} diff --git a/src/input_reader.rs b/src/input_reader.rs new file mode 100644 index 0000000..840fede --- /dev/null +++ b/src/input_reader.rs @@ -0,0 +1,266 @@ +use bevy::{ + ecs::system::SystemParam, + input::{ + gamepad::{GamepadAxisChangedEvent, GamepadButtonInput}, + keyboard::KeyboardInput, + mouse::{MouseButtonInput, MouseMotion, MouseWheel}, + ButtonState, + }, + prelude::*, + utils::HashMap, +}; +#[cfg(feature = "egui_priority")] +use bevy_egui::EguiContext; +use bitflags::bitflags; +use serde::{Deserialize, Serialize}; + +use crate::action_value::ActionValue; + +#[derive(SystemParam)] +pub(super) struct InputReader<'w, 's> { + mouse_motion_events: EventReader<'w, 's, MouseMotion>, + mouse_wheel_events: EventReader<'w, 's, MouseWheel>, + keyboard_events: EventReader<'w, 's, KeyboardInput>, + mouse_button_events: EventReader<'w, 's, MouseButtonInput>, + gamepad_button_events: EventReader<'w, 's, GamepadButtonInput>, + gamepad_axis_events: EventReader<'w, 's, GamepadAxisChangedEvent>, + tracker: Local<'s, InputTracker>, + #[cfg(feature = "ui_priority")] + interactions: Query<'w, 's, &'static Interaction>, + #[cfg(feature = "egui_priority")] + egui: Query<'w, 's, &'static EguiContext>, +} + +impl InputReader<'_, '_> { + pub(super) fn update_state(&mut self) { + for input in self.keyboard_events.read() { + // Record modifiers redundantly for quick access. + match input.key_code { + KeyCode::AltLeft | KeyCode::AltRight => { + self.tracker.modifiers &= KeyboardModifiers::ALT; + } + KeyCode::ControlLeft | KeyCode::ControlRight => { + self.tracker.modifiers &= KeyboardModifiers::CONTROL; + } + KeyCode::ShiftLeft | KeyCode::ShiftRight => { + self.tracker.modifiers &= KeyboardModifiers::SHIFT; + } + KeyCode::SuperLeft | KeyCode::SuperRight => { + self.tracker.modifiers &= KeyboardModifiers::SUPER; + } + _ => (), + } + + let pressed = match input.state { + ButtonState::Pressed => true.into(), + ButtonState::Released => false.into(), + }; + + self.tracker.key_codes.insert(input.key_code, pressed); + } + + if !self.mouse_motion_events.is_empty() { + let mouse_motion: Vec2 = self + .mouse_motion_events + .read() + .map(|event| event.delta) + .sum(); + self.tracker.mouse_motion = Some(mouse_motion.into()); + } + + if !self.mouse_wheel_events.is_empty() { + let mouse_wheel: Vec2 = self + .mouse_wheel_events + .read() + .map(|event| Vec2::new(event.x, event.y)) + .sum(); + self.tracker.mouse_wheel = Some(mouse_wheel.into()); + } + + for input in self.mouse_button_events.read() { + let pressed = match input.state { + ButtonState::Pressed => true.into(), + ButtonState::Released => false.into(), + }; + + self.tracker.mouse_buttons.insert(input.button, pressed); + } + + for input in self.gamepad_button_events.read() { + let pressed = match input.state { + ButtonState::Pressed => true.into(), + ButtonState::Released => false.into(), + }; + + self.tracker.gamepad_buttons.insert(input.button, pressed); + } + + for event in self.gamepad_axis_events.read() { + let axis = GamepadAxis { + gamepad: event.gamepad, + axis_type: event.axis_type, + }; + + self.tracker.gamepad_axes.insert(axis, event.value.into()); + } + + #[cfg(feature = "ui_priority")] + { + if self + .interactions + .iter() + .any(|&interaction| interaction != Interaction::None) + { + self.tracker.mouse_buttons.clear(); + self.tracker.mouse_wheel = None; + } + } + + #[cfg(feature = "egui_priority")] + { + if self + .egui + .iter() + .any(|ctx| ctx.get().is_pointer_over_area() || ctx.get().wants_pointer_input()) + { + self.tracker.mouse_buttons.clear(); + self.tracker.mouse_wheel = None; + } + + if self.egui.iter().any(|ctx| ctx.get().wants_keyboard_input()) { + self.tracker.key_codes.clear(); + self.tracker.modifiers = KeyboardModifiers::empty(); + } + } + } + + pub(super) fn read(&mut self, input: Input, consume: bool) -> Option { + match input { + Input::Keyboard { + key_code, + modifiers, + } => { + if !self.tracker.modifiers.contains(modifiers) { + return None; + } + + if consume { + self.tracker.key_codes.remove(&key_code) + } else { + self.tracker.key_codes.get(&key_code).copied() + } + } + Input::MouseButton { button, modifiers } => { + if !self.tracker.modifiers.contains(modifiers) { + return None; + } + + if consume { + self.tracker.mouse_buttons.remove(&button) + } else { + self.tracker.mouse_buttons.get(&button).copied() + } + } + Input::MouseMotion { modifiers } => { + if !self.tracker.modifiers.contains(modifiers) { + return None; + } + + if consume { + self.tracker.mouse_motion.take() + } else { + self.tracker.mouse_motion + } + } + Input::MouseWheel { modifiers } => { + if !self.tracker.modifiers.contains(modifiers) { + return None; + } + + if consume { + self.tracker.mouse_wheel.take() + } else { + self.tracker.mouse_wheel + } + } + Input::GamepadButton(gamepad_button) => { + if consume { + self.tracker.gamepad_buttons.remove(&gamepad_button) + } else { + self.tracker.gamepad_buttons.get(&gamepad_button).copied() + } + } + Input::GamepadAxis(gamepad_axis) => { + if consume { + self.tracker.gamepad_axes.remove(&gamepad_axis) + } else { + self.tracker.gamepad_axes.get(&gamepad_axis).copied() + } + } + } + } +} + +#[derive(Resource, Default)] +struct InputTracker { + key_codes: HashMap, + modifiers: KeyboardModifiers, + mouse_motion: Option, + mouse_wheel: Option, + mouse_buttons: HashMap, + gamepad_buttons: HashMap, + gamepad_axes: HashMap, +} + +bitflags! { + /// Modifiers for both left and right keys. + #[derive(Default, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] + pub struct KeyboardModifiers: u8 { + /// Corresponds to [`KeyCode::AltLeft`] and [`KeyCode::AltRight`]. + const ALT = 0b00000001; + /// Corresponds to [`KeyCode::ControlLeft`] and [`KeyCode::ControlRight`]. + const CONTROL = 0b00000010; + /// Corresponds to [`KeyCode::ShiftLeft`] and [`KeyCode::ShiftRight`] + const SHIFT = 0b00000100; + /// Corresponds to [`KeyCode::SuperLeft`] and [`KeyCode::SuperRight`]. + const SUPER = 0b00001000; + } +} + +#[derive(Clone, Copy, Serialize, Deserialize)] +pub enum Input { + Keyboard { + key_code: KeyCode, + modifiers: KeyboardModifiers, + }, + MouseButton { + button: MouseButton, + modifiers: KeyboardModifiers, + }, + MouseMotion { + modifiers: KeyboardModifiers, + }, + MouseWheel { + modifiers: KeyboardModifiers, + }, + GamepadButton(GamepadButton), + GamepadAxis(GamepadAxis), +} + +impl From for Input { + fn from(key_code: KeyCode) -> Self { + Self::Keyboard { + key_code, + modifiers: KeyboardModifiers::empty(), + } + } +} + +impl From for Input { + fn from(button: MouseButton) -> Self { + Self::MouseButton { + button, + modifiers: KeyboardModifiers::empty(), + } + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..b84e39b --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,49 @@ +pub mod action_value; +pub mod input_context; +pub mod input_reader; + +pub mod prelude { + pub use super::{ + action_value::{ActionValue, ActionValueDim}, + input_context::{ + context_map::{ActionMap, ContextMap, InputMap}, + input_action::{ActionEvent, ActionEventKind, InputAction}, + input_condition::*, + input_modifier::*, + ContextAppExt, InputContext, + }, + input_reader::Input, + input_reader::KeyboardModifiers, + EnhancedInputPlugin, + }; +} + +use bevy::{ecs::system::SystemState, input::InputSystem, prelude::*}; + +use input_context::InputContexts; +use input_reader::InputReader; + +pub struct EnhancedInputPlugin; + +impl Plugin for EnhancedInputPlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + .add_systems(PreUpdate, Self::update.after(InputSystem)); + } +} + +impl EnhancedInputPlugin { + fn update(world: &mut World, state: &mut SystemState<(Commands, InputReader, Res