Add generic, remappable game action system
Introduce engine::actions: an input-source-agnostic action layer that fans keyboard, gamepad, and Feathers UI (Button/Checkbox) input into a single ActionState<T> resource, so games gate systems on abstract actions rather than raw inputs. - GameAction trait + #[derive(GameAction)] proc macro (new engine_macros crate) to declare each enum variant's ActionKind via #[action_kind(...)]. - Three kinds: Instant (momentary), Toggle (persistent on/off), Axis (Vec2). - Remappable InputMap<T> with additive bind(action, [Binding, ...]). - Symmetrical entry points over a unified ActionValue: run_if(on_action(a)) to gate any kind, action_value(a).pipe(sys) to deliver its payload; typed axis_value/toggle_value helpers for bare Vec2/bool. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>main
parent
bc13a3b27b
commit
d8e00525ec
@ -0,0 +1,13 @@
|
|||||||
|
[package]
|
||||||
|
name = "engine_macros"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
proc-macro = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
syn = "2"
|
||||||
|
quote = "1"
|
||||||
|
proc-macro2 = "1"
|
||||||
|
proc-macro-crate = "3"
|
||||||
@ -0,0 +1,137 @@
|
|||||||
|
//! Proc-macros for the `engine` crate.
|
||||||
|
//!
|
||||||
|
//! Currently provides [`GameAction`](macro@GameAction), a derive that annotates
|
||||||
|
//! each enum variant with its [`ActionKind`] via `#[action_kind(...)]`.
|
||||||
|
|
||||||
|
use proc_macro::TokenStream;
|
||||||
|
use proc_macro_crate::{crate_name, FoundCrate};
|
||||||
|
use quote::{format_ident, quote};
|
||||||
|
use syn::{spanned::Spanned, Data, DeriveInput, Fields, Ident};
|
||||||
|
|
||||||
|
/// Derives `engine::GameAction` for a fieldless enum.
|
||||||
|
///
|
||||||
|
/// Each variant must be annotated with `#[action_kind(instant)]`,
|
||||||
|
/// `#[action_kind(toggle)]`, or `#[action_kind(axis)]`:
|
||||||
|
///
|
||||||
|
/// ```ignore
|
||||||
|
/// #[derive(Copy, Clone, Eq, PartialEq, Hash, GameAction)]
|
||||||
|
/// enum Action {
|
||||||
|
/// #[action_kind(instant)]
|
||||||
|
/// Jump,
|
||||||
|
/// #[action_kind(toggle)]
|
||||||
|
/// Grid,
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// This expands to an `impl GameAction for Action` whose `kind` matches each
|
||||||
|
/// variant to its declared [`ActionKind`].
|
||||||
|
#[proc_macro_derive(GameAction, attributes(action_kind))]
|
||||||
|
pub fn derive_game_action(input: TokenStream) -> TokenStream {
|
||||||
|
let input = syn::parse_macro_input!(input as DeriveInput);
|
||||||
|
expand(input)
|
||||||
|
.unwrap_or_else(syn::Error::into_compile_error)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expand(input: DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
|
||||||
|
let Data::Enum(data) = &input.data else {
|
||||||
|
return Err(syn::Error::new(
|
||||||
|
input.ident.span(),
|
||||||
|
"`GameAction` can only be derived for enums",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
let engine = engine_path();
|
||||||
|
let ident = &input.ident;
|
||||||
|
let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
|
||||||
|
|
||||||
|
let arms = data
|
||||||
|
.variants
|
||||||
|
.iter()
|
||||||
|
.map(|variant| {
|
||||||
|
if !matches!(variant.fields, Fields::Unit) {
|
||||||
|
return Err(syn::Error::new(
|
||||||
|
variant.fields.span(),
|
||||||
|
"`GameAction` variants must be fieldless (unit variants)",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let variant_ident = &variant.ident;
|
||||||
|
let kind = parse_kind(variant, &engine)?;
|
||||||
|
Ok(quote!(Self::#variant_ident => #kind))
|
||||||
|
})
|
||||||
|
.collect::<syn::Result<Vec<_>>>()?;
|
||||||
|
|
||||||
|
Ok(quote! {
|
||||||
|
impl #impl_generics #engine::GameAction for #ident #ty_generics #where_clause {
|
||||||
|
fn kind(self) -> #engine::ActionKind {
|
||||||
|
match self {
|
||||||
|
#(#arms,)*
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses the single required `#[action_kind(instant|toggle)]` attribute on a
|
||||||
|
/// variant into the corresponding `ActionKind` expression.
|
||||||
|
fn parse_kind(
|
||||||
|
variant: &syn::Variant,
|
||||||
|
engine: &proc_macro2::TokenStream,
|
||||||
|
) -> syn::Result<proc_macro2::TokenStream> {
|
||||||
|
let mut kind: Option<proc_macro2::TokenStream> = None;
|
||||||
|
|
||||||
|
for attr in &variant.attrs {
|
||||||
|
if !attr.path().is_ident("action_kind") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if kind.is_some() {
|
||||||
|
return Err(syn::Error::new(
|
||||||
|
attr.span(),
|
||||||
|
"duplicate `#[action_kind(...)]` attribute",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let variant_name: Ident = attr.parse_args().map_err(|_| {
|
||||||
|
syn::Error::new(
|
||||||
|
attr.span(),
|
||||||
|
"expected `#[action_kind(instant)]` or `#[action_kind(toggle)]`",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
kind = Some(match variant_name.to_string().as_str() {
|
||||||
|
"instant" => quote!(#engine::ActionKind::Instant),
|
||||||
|
"toggle" => quote!(#engine::ActionKind::Toggle),
|
||||||
|
"axis" => quote!(#engine::ActionKind::Axis),
|
||||||
|
other => {
|
||||||
|
return Err(syn::Error::new(
|
||||||
|
variant_name.span(),
|
||||||
|
format!(
|
||||||
|
"unknown action kind `{other}`, expected `instant`, `toggle`, or `axis`"
|
||||||
|
),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
kind.ok_or_else(|| {
|
||||||
|
syn::Error::new(
|
||||||
|
variant.ident.span(),
|
||||||
|
format!(
|
||||||
|
"variant `{}` needs `#[action_kind(instant)]` or `#[action_kind(toggle)]`",
|
||||||
|
variant.ident
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves the path to the `engine` crate so the derive works whether it is
|
||||||
|
/// invoked from a game, from `engine`'s own tests, or under a renamed dependency.
|
||||||
|
fn engine_path() -> proc_macro2::TokenStream {
|
||||||
|
match crate_name("engine") {
|
||||||
|
Ok(FoundCrate::Itself) => quote!(crate),
|
||||||
|
Ok(FoundCrate::Name(name)) => {
|
||||||
|
let ident = format_ident!("{name}");
|
||||||
|
quote!(::#ident)
|
||||||
|
}
|
||||||
|
// Fallback: assume the conventional crate name.
|
||||||
|
Err(_) => quote!(::engine),
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,563 @@
|
|||||||
|
//! Abstract, remappable game actions that unify keyboard, gamepad, and UI input.
|
||||||
|
//!
|
||||||
|
//! Games define their own action enum, then either gate a system on an action
|
||||||
|
//! ([`on_action`]) or pipe an action's payload into one ([`axis_value`]) —
|
||||||
|
//! regardless of whether the trigger was a keyboard, a gamepad, or a UI widget:
|
||||||
|
//!
|
||||||
|
//! ```rust,ignore
|
||||||
|
//! use engine::actions::*;
|
||||||
|
//! use engine::*; // bevy prelude
|
||||||
|
//!
|
||||||
|
//! // 1. Your game's actions, with each variant's kind declared inline.
|
||||||
|
//! #[derive(Copy, Clone, Eq, PartialEq, Hash, GameAction)]
|
||||||
|
//! enum Action {
|
||||||
|
//! #[action_kind(instant)] // fires for one frame
|
||||||
|
//! Jump,
|
||||||
|
//! #[action_kind(toggle)] // stays on/off
|
||||||
|
//! Grid,
|
||||||
|
//! #[action_kind(axis)] // carries a Vec2
|
||||||
|
//! Move,
|
||||||
|
//! }
|
||||||
|
//!
|
||||||
|
//! App::new()
|
||||||
|
//! .add_plugins(DefaultPlugins)
|
||||||
|
//! .add_plugins(
|
||||||
|
//! ActionsPlugin::<Action>::new()
|
||||||
|
//! .bind(Action::Jump, [
|
||||||
|
//! Binding::Key(KeyCode::Space),
|
||||||
|
//! Binding::Gamepad(GamepadButton::South),
|
||||||
|
//! ])
|
||||||
|
//! .bind(Action::Grid, [Binding::Key(KeyCode::KeyG)])
|
||||||
|
//! .bind(Action::Move, [Binding::Stick(Stick::Left)]),
|
||||||
|
//! )
|
||||||
|
//! // The two entry points are symmetrical and take an action of ANY kind:
|
||||||
|
//! // `run_if(on_action(a))` gates a system; `action_value(a).pipe(sys)` feeds it.
|
||||||
|
//! .add_systems(Update, jump.run_if(on_action(Action::Jump)))
|
||||||
|
//! .add_systems(Update, draw_grid.run_if(on_action(Action::Grid)))
|
||||||
|
//! .add_systems(Update, action_value(Action::Move).pipe(move_player));
|
||||||
|
//!
|
||||||
|
//! // Piped consumer: receives an `ActionValue` and never touches `ActionState`.
|
||||||
|
//! fn move_player(In(value): In<ActionValue>, mut q: Query<&mut Transform, With<Player>>) {
|
||||||
|
//! let direction = value.as_axis(); // magnitude + direction
|
||||||
|
//! for mut t in &mut q {
|
||||||
|
//! t.translation += direction.extend(0.0);
|
||||||
|
//! }
|
||||||
|
//! }
|
||||||
|
//!
|
||||||
|
//! // 2. UI feeds the same actions: tag a widget with the action it drives.
|
||||||
|
//! // commands.spawn((button(..), ActionSource(Action::Jump))); // Activate -> Instant
|
||||||
|
//! // commands.spawn((checkbox(..), ActionSource(Action::Grid))); // ValueChange -> Toggle
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ## How it works
|
||||||
|
//!
|
||||||
|
//! All input sources fan in to a single [`ActionState<T>`] resource. Instant
|
||||||
|
//! actions live in a momentary set cleared every frame; toggles live in a
|
||||||
|
//! persistent set; axes hold a per-frame [`Vec2`]. [`ActionState::value`] reads
|
||||||
|
//! whichever bucket the action's kind selects and returns a unified
|
||||||
|
//! [`ActionValue`], so every downstream read is kind-agnostic.
|
||||||
|
//!
|
||||||
|
//! ## Two symmetrical entry points
|
||||||
|
//!
|
||||||
|
//! Whatever action you can gate on, you can also receive the value of. The pair
|
||||||
|
//! differs only in *how the system gets involved*, not in which actions it accepts:
|
||||||
|
//!
|
||||||
|
//! * **`sys.run_if(on_action(a))`** — gate a system on the action. A run condition
|
||||||
|
//! can only return `bool`, so this reports [`ActionValue::is_active`]: `true` for
|
||||||
|
//! a fired instant, an on toggle, **or a deflected axis**. Reach for it when the
|
||||||
|
//! fact that the action is active is all the system needs — including "run every
|
||||||
|
//! frame the stick is being moved".
|
||||||
|
//! * **`action_value(a).pipe(sys)`** — deliver the action's [`ActionValue`] into a
|
||||||
|
//! system that takes `In<ActionValue>`. Reach for it when the system needs the
|
||||||
|
//! **payload** (the axis [`Vec2`], or a toggle's bool to store). The consumer
|
||||||
|
//! pulls its type out with [`ActionValue::as_axis`] / [`ActionValue::as_bool`],
|
||||||
|
//! or use the typed [`axis_value`] / [`toggle_value`] helpers to pipe a bare
|
||||||
|
//! `Vec2` / `bool`.
|
||||||
|
//!
|
||||||
|
//! They compose — `action_value(a).pipe(sys).run_if(on_action(a))` delivers the
|
||||||
|
//! payload *and* skips the system when the action is inactive — and neither is
|
||||||
|
//! mandatory: any system may read [`ActionState`] directly.
|
||||||
|
|
||||||
|
use core::hash::Hash;
|
||||||
|
use core::marker::PhantomData;
|
||||||
|
|
||||||
|
use bevy::{
|
||||||
|
platform::collections::{HashMap, HashSet},
|
||||||
|
prelude::*,
|
||||||
|
ui::Checked,
|
||||||
|
ui_widgets::{Activate, Checkbox, SetChecked, ValueChange},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// How an action behaves: momentary, a persistent on/off toggle, or an analog
|
||||||
|
/// 2D axis (e.g. a gamepad stick).
|
||||||
|
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum ActionKind {
|
||||||
|
/// Fires for a single frame each time it is triggered (jump, shoot, confirm).
|
||||||
|
Instant,
|
||||||
|
/// Holds an on/off state until toggled again (show grid, mute, pause).
|
||||||
|
Toggle,
|
||||||
|
/// Carries a [`Vec2`] direction + magnitude, refreshed every frame (movement,
|
||||||
|
/// aiming). Read the value with [`ActionState::axis`].
|
||||||
|
Axis,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The current value of an action, unified across kinds so one provider
|
||||||
|
/// ([`action_value`]) and one run condition ([`on_action`]) work for every kind.
|
||||||
|
///
|
||||||
|
/// Digital kinds ([`Instant`](ActionKind::Instant)/[`Toggle`](ActionKind::Toggle))
|
||||||
|
/// produce [`Bool`](Self::Bool); [`Axis`](ActionKind::Axis) produces
|
||||||
|
/// [`Axis`](Self::Axis). A consumer that knows the kind it wants pulls the payload
|
||||||
|
/// out with [`as_bool`](Self::as_bool) or [`as_axis`](Self::as_axis).
|
||||||
|
#[derive(Copy, Clone, Debug, PartialEq)]
|
||||||
|
pub enum ActionValue {
|
||||||
|
/// A digital value: an instant action fired this frame, or a toggle is on.
|
||||||
|
Bool(bool),
|
||||||
|
/// An analog 2D value.
|
||||||
|
Axis(Vec2),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActionValue {
|
||||||
|
/// Whether the action is "active": a `true` bool, or a non-zero axis. This is
|
||||||
|
/// what [`on_action`] gates on, so a system can run "while the stick is moved"
|
||||||
|
/// exactly as it runs "while the toggle is on".
|
||||||
|
pub fn is_active(self) -> bool {
|
||||||
|
match self {
|
||||||
|
ActionValue::Bool(b) => b,
|
||||||
|
ActionValue::Axis(v) => v != Vec2::ZERO,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The digital payload. An axis collapses to whether it is deflected, so a
|
||||||
|
/// bool consumer can read an axis action too.
|
||||||
|
pub fn as_bool(self) -> bool {
|
||||||
|
self.is_active()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The analog payload. A digital value maps to [`Vec2::ZERO`] when off; a
|
||||||
|
/// `true` bool has no direction and also yields [`Vec2::ZERO`].
|
||||||
|
pub fn as_axis(self) -> Vec2 {
|
||||||
|
match self {
|
||||||
|
ActionValue::Axis(v) => v,
|
||||||
|
ActionValue::Bool(_) => Vec2::ZERO,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derive macro for [`GameAction`]: annotate each variant with
|
||||||
|
/// `#[action_kind(instant)]` or `#[action_kind(toggle)]`.
|
||||||
|
///
|
||||||
|
/// ```rust,ignore
|
||||||
|
/// #[derive(Copy, Clone, Eq, PartialEq, Hash, GameAction)]
|
||||||
|
/// enum Action {
|
||||||
|
/// #[action_kind(instant)]
|
||||||
|
/// Jump,
|
||||||
|
/// #[action_kind(toggle)]
|
||||||
|
/// Grid,
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
pub use engine_macros::GameAction;
|
||||||
|
|
||||||
|
/// Implemented by a game's action enum to describe each action's behavior.
|
||||||
|
///
|
||||||
|
/// Usually derived — see the [`GameAction`](macro@GameAction) derive macro.
|
||||||
|
///
|
||||||
|
/// The bounds mirror [`ButtonInput`]'s key type: actions must be cheap, hashable
|
||||||
|
/// identifiers. `kind` is the only thing the engine needs from the game.
|
||||||
|
pub trait GameAction: Copy + Eq + Hash + Send + Sync + 'static {
|
||||||
|
/// Returns whether this action is [`Instant`](ActionKind::Instant),
|
||||||
|
/// [`Toggle`](ActionKind::Toggle), or [`Axis`](ActionKind::Axis).
|
||||||
|
fn kind(self) -> ActionKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single physical input that can trigger an action. Extend as needed.
|
||||||
|
///
|
||||||
|
/// [`Key`](Self::Key), [`Mouse`](Self::Mouse) and [`Gamepad`](Self::Gamepad) are
|
||||||
|
/// digital (for [`Instant`](ActionKind::Instant)/[`Toggle`](ActionKind::Toggle)
|
||||||
|
/// actions); [`Stick`](Self::Stick) is analog (for [`Axis`](ActionKind::Axis)
|
||||||
|
/// actions).
|
||||||
|
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Reflect)]
|
||||||
|
pub enum Binding {
|
||||||
|
/// A keyboard key.
|
||||||
|
Key(KeyCode),
|
||||||
|
/// A mouse button.
|
||||||
|
Mouse(MouseButton),
|
||||||
|
/// A gamepad button (matched across all connected gamepads).
|
||||||
|
Gamepad(GamepadButton),
|
||||||
|
/// A gamepad analog source producing a [`Vec2`] (matched across all connected
|
||||||
|
/// gamepads; deadzone already applied by Bevy).
|
||||||
|
Stick(Stick),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An analog 2D source on a gamepad.
|
||||||
|
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Reflect)]
|
||||||
|
pub enum Stick {
|
||||||
|
/// The left analog stick.
|
||||||
|
Left,
|
||||||
|
/// The right analog stick.
|
||||||
|
Right,
|
||||||
|
/// The directional pad, reported as a [`Vec2`].
|
||||||
|
DPad,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remappable map from actions to the physical inputs that trigger them.
|
||||||
|
///
|
||||||
|
/// This is a [`Resource`]; mutate it at runtime to rebind controls (e.g. from a
|
||||||
|
/// settings menu). One action may have many bindings and they are additive.
|
||||||
|
#[derive(Resource)]
|
||||||
|
pub struct InputMap<T> {
|
||||||
|
bindings: HashMap<T, Vec<Binding>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> Default for InputMap<T> {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
bindings: HashMap::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Clone> Clone for InputMap<T> {
|
||||||
|
fn clone(&self) -> Self {
|
||||||
|
Self {
|
||||||
|
bindings: self.bindings.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: GameAction> InputMap<T> {
|
||||||
|
/// Adds one or more bindings for `action`. Additive: existing bindings are
|
||||||
|
/// kept, so repeated calls accumulate.
|
||||||
|
pub fn bind(&mut self, action: T, bindings: impl IntoIterator<Item = Binding>) -> &mut Self {
|
||||||
|
self.bindings.entry(action).or_default().extend(bindings);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces all bindings for `action` with `bindings` (for rebinding UIs).
|
||||||
|
pub fn set_bindings(&mut self, action: T, bindings: impl IntoIterator<Item = Binding>) {
|
||||||
|
self.bindings.insert(action, bindings.into_iter().collect());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bindings currently mapped to `action`, if any.
|
||||||
|
pub fn bindings(&self, action: T) -> Option<&[Binding]> {
|
||||||
|
self.bindings.get(&action).map(Vec::as_slice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The unified, source-agnostic state of every action for the current frame.
|
||||||
|
///
|
||||||
|
/// Populated by the engine each frame from the [`InputMap`] and UI widgets.
|
||||||
|
/// Read it kind-agnostically via [`value`](Self::value) (behind [`on_action`] and
|
||||||
|
/// [`action_value`]), or reach for a specific bucket with [`axis`](Self::axis) /
|
||||||
|
/// [`enabled`](Self::enabled). Games may also drive actions programmatically with
|
||||||
|
/// [`press`](Self::press), [`set`](Self::set), and [`toggle`](Self::toggle).
|
||||||
|
#[derive(Resource)]
|
||||||
|
pub struct ActionState<T> {
|
||||||
|
/// Instant actions active this frame; cleared at the start of every frame.
|
||||||
|
momentary: HashSet<T>,
|
||||||
|
/// Toggle actions currently switched on; persists across frames.
|
||||||
|
enabled: HashSet<T>,
|
||||||
|
/// Axis actions and their current value; refreshed every frame.
|
||||||
|
axes: HashMap<T, Vec2>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> Default for ActionState<T> {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
momentary: HashSet::default(),
|
||||||
|
enabled: HashSet::default(),
|
||||||
|
axes: HashMap::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: GameAction> ActionState<T> {
|
||||||
|
/// The current [`ActionValue`] of `action`, dispatched by its
|
||||||
|
/// [`ActionKind`]: [`Bool`](ActionValue::Bool) for instant/toggle,
|
||||||
|
/// [`Axis`](ActionValue::Axis) for axes.
|
||||||
|
///
|
||||||
|
/// This is the single accessor every other read is built on, so one provider
|
||||||
|
/// ([`action_value`]) and one run condition ([`on_action`]) serve all kinds.
|
||||||
|
pub fn value(&self, action: T) -> ActionValue {
|
||||||
|
match action.kind() {
|
||||||
|
ActionKind::Instant => ActionValue::Bool(self.momentary.contains(&action)),
|
||||||
|
ActionKind::Toggle => ActionValue::Bool(self.enabled.contains(&action)),
|
||||||
|
ActionKind::Axis => ActionValue::Axis(self.axis(action)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `action` is currently active: an [`Instant`](ActionKind::Instant)
|
||||||
|
/// action fired this frame, a [`Toggle`](ActionKind::Toggle) that is on, or an
|
||||||
|
/// [`Axis`](ActionKind::Axis) that is deflected (non-zero).
|
||||||
|
///
|
||||||
|
/// Shorthand for `self.value(action).is_active()`; what [`on_action`] gates on.
|
||||||
|
pub fn active(&self, action: T) -> bool {
|
||||||
|
self.value(action).is_active()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a toggle `action` is currently switched on.
|
||||||
|
pub fn enabled(&self, action: T) -> bool {
|
||||||
|
self.enabled.contains(&action)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The current value of an axis `action` (its direction and magnitude), or
|
||||||
|
/// [`Vec2::ZERO`] if it has no value this frame.
|
||||||
|
pub fn axis(&self, action: T) -> Vec2 {
|
||||||
|
self.axes.get(&action).copied().unwrap_or(Vec2::ZERO)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fires an instant `action` for this frame.
|
||||||
|
pub fn press(&mut self, action: T) {
|
||||||
|
self.momentary.insert(action);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets a toggle `action` to `on`.
|
||||||
|
pub fn set(&mut self, action: T, on: bool) {
|
||||||
|
if on {
|
||||||
|
self.enabled.insert(action);
|
||||||
|
} else {
|
||||||
|
self.enabled.remove(&action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flips a toggle `action`.
|
||||||
|
pub fn toggle(&mut self, action: T) {
|
||||||
|
if !self.enabled.remove(&action) {
|
||||||
|
self.enabled.insert(action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets an axis `action`'s value for this frame.
|
||||||
|
pub fn set_axis(&mut self, action: T, value: Vec2) {
|
||||||
|
self.axes.insert(action, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marker component linking a UI widget to the action it drives.
|
||||||
|
///
|
||||||
|
/// Put it on a `Button` ([`Instant`](ActionKind::Instant)) or `Checkbox`
|
||||||
|
/// ([`Toggle`](ActionKind::Toggle)) — the engine wires the widget's events into
|
||||||
|
/// [`ActionState`] and keeps checkboxes visually in sync.
|
||||||
|
#[derive(Component, Copy, Clone, Debug)]
|
||||||
|
pub struct ActionSource<T: GameAction>(pub T);
|
||||||
|
|
||||||
|
/// The two symmetrical entry points below let any system consume any action:
|
||||||
|
///
|
||||||
|
/// * gate a system on it — [`on_action`] (a run condition, so bool-only), or
|
||||||
|
/// * receive its value — [`action_value`] (a pipe provider carrying the payload).
|
||||||
|
///
|
||||||
|
/// Both accept an action of any [`ActionKind`]; the value type is unified by
|
||||||
|
/// [`ActionValue`].
|
||||||
|
|
||||||
|
/// Run condition that is active while `action` is active, for **any** kind: a
|
||||||
|
/// `true` bool (instant fired / toggle on) or a non-zero axis (stick deflected).
|
||||||
|
///
|
||||||
|
/// So the same call gates "run while paused" and "run while the stick is moved".
|
||||||
|
///
|
||||||
|
/// ```rust,ignore
|
||||||
|
/// app.add_systems(Update, jump.run_if(on_action(Action::Jump)));
|
||||||
|
/// app.add_systems(Update, camera_follow.run_if(on_action(Action::Move))); // stick moved
|
||||||
|
/// ```
|
||||||
|
pub fn on_action<T: GameAction>(action: T) -> impl FnMut(Res<ActionState<T>>) -> bool + Clone {
|
||||||
|
move |state: Res<ActionState<T>>| state.active(action)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Provider for [`pipe`](bevy::prelude::IntoSystem::pipe)ing an action's
|
||||||
|
/// [`ActionValue`] into a focused consumer, for **any** kind. The consumer takes
|
||||||
|
/// `In<ActionValue>` and never touches [`ActionState`].
|
||||||
|
///
|
||||||
|
/// This is the pipe-side twin of [`on_action`]: whatever action you can gate on,
|
||||||
|
/// you can also deliver.
|
||||||
|
///
|
||||||
|
/// ```rust,ignore
|
||||||
|
/// app.add_systems(Update, action_value(Action::Move).pipe(move_player));
|
||||||
|
/// app.add_systems(Update, action_value(Action::Grid).pipe(set_grid));
|
||||||
|
///
|
||||||
|
/// fn move_player(In(v): In<ActionValue>, mut q: Query<&mut Transform, With<Player>>) {
|
||||||
|
/// let dir = v.as_axis();
|
||||||
|
/// for mut t in &mut q { t.translation += dir.extend(0.0); }
|
||||||
|
/// }
|
||||||
|
///
|
||||||
|
/// fn set_grid(In(v): In<ActionValue>, mut grid: ResMut<Grid>) {
|
||||||
|
/// grid.visible = v.as_bool();
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// Compose with [`on_action`] to also skip the consumer when inactive:
|
||||||
|
/// `action_value(a).pipe(sys).run_if(on_action(a))`.
|
||||||
|
pub fn action_value<T: GameAction>(
|
||||||
|
action: T,
|
||||||
|
) -> impl FnMut(Res<ActionState<T>>) -> ActionValue + Clone {
|
||||||
|
move |state: Res<ActionState<T>>| state.value(action)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Typed convenience over [`action_value`] for axis actions: pipes a bare
|
||||||
|
/// [`Vec2`] instead of an [`ActionValue`], so the consumer takes `In<Vec2>`.
|
||||||
|
pub fn axis_value<T: GameAction>(action: T) -> impl FnMut(Res<ActionState<T>>) -> Vec2 + Clone {
|
||||||
|
move |state: Res<ActionState<T>>| state.axis(action)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Typed convenience over [`action_value`] for toggle actions: pipes a bare
|
||||||
|
/// `bool`, so the consumer takes `In<bool>`.
|
||||||
|
pub fn toggle_value<T: GameAction>(action: T) -> impl FnMut(Res<ActionState<T>>) -> bool + Clone {
|
||||||
|
move |state: Res<ActionState<T>>| state.enabled(action)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- fan-in systems -------------------------------------------------------
|
||||||
|
|
||||||
|
/// Clears momentary state at the start of each frame so instant actions have
|
||||||
|
/// just-pressed semantics. Toggle state is left untouched.
|
||||||
|
fn clear_momentary<T: GameAction>(mut state: ResMut<ActionState<T>>) {
|
||||||
|
if !state.momentary.is_empty() {
|
||||||
|
state.momentary.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hardware fan-in: reads keyboard, mouse, and gamepads, consults the
|
||||||
|
/// [`InputMap`], and applies each triggered action according to its kind.
|
||||||
|
fn hardware_to_actions<T: GameAction>(
|
||||||
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
|
mouse: Res<ButtonInput<MouseButton>>,
|
||||||
|
pads: Query<&Gamepad>,
|
||||||
|
map: Res<InputMap<T>>,
|
||||||
|
mut state: ResMut<ActionState<T>>,
|
||||||
|
) {
|
||||||
|
for (&action, bindings) in &map.bindings {
|
||||||
|
match action.kind() {
|
||||||
|
// Analog: pick the most-deflected reading across sticks/gamepads and
|
||||||
|
// store it every frame (zero when nothing is connected or centered).
|
||||||
|
ActionKind::Axis => {
|
||||||
|
let mut value = Vec2::ZERO;
|
||||||
|
for binding in bindings {
|
||||||
|
let Binding::Stick(stick) = binding else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
for pad in &pads {
|
||||||
|
let reading = match stick {
|
||||||
|
Stick::Left => pad.left_stick(),
|
||||||
|
Stick::Right => pad.right_stick(),
|
||||||
|
Stick::DPad => pad.dpad(),
|
||||||
|
};
|
||||||
|
if reading.length_squared() > value.length_squared() {
|
||||||
|
value = reading;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.set_axis(action, value);
|
||||||
|
}
|
||||||
|
// Digital: `break` on the first firing binding avoids double-toggling
|
||||||
|
// when two bound inputs are pressed on the same frame.
|
||||||
|
kind @ (ActionKind::Instant | ActionKind::Toggle) => {
|
||||||
|
for binding in bindings {
|
||||||
|
let just_pressed = match binding {
|
||||||
|
Binding::Key(k) => keys.just_pressed(*k),
|
||||||
|
Binding::Mouse(m) => mouse.just_pressed(*m),
|
||||||
|
Binding::Gamepad(g) => pads.iter().any(|pad| pad.just_pressed(*g)),
|
||||||
|
Binding::Stick(_) => false, // not meaningful for digital actions
|
||||||
|
};
|
||||||
|
if just_pressed {
|
||||||
|
match kind {
|
||||||
|
ActionKind::Instant => state.press(action),
|
||||||
|
_ => state.toggle(action),
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// UI fan-in: a `Button`'s [`Activate`] drives an instant action.
|
||||||
|
fn button_to_action<T: GameAction>(
|
||||||
|
activate: On<Activate>,
|
||||||
|
sources: Query<&ActionSource<T>>,
|
||||||
|
mut state: ResMut<ActionState<T>>,
|
||||||
|
) {
|
||||||
|
if let Ok(ActionSource(action)) = sources.get(activate.entity) {
|
||||||
|
match action.kind() {
|
||||||
|
ActionKind::Instant => state.press(*action),
|
||||||
|
// A button bound to a toggle acts as a "flip" control (menu item).
|
||||||
|
ActionKind::Toggle => state.toggle(*action),
|
||||||
|
// A button can't produce an analog value; nothing to do.
|
||||||
|
ActionKind::Axis => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// UI fan-in: a `Checkbox`'s [`ValueChange`] sets a toggle action's on/off state.
|
||||||
|
fn checkbox_to_action<T: GameAction>(
|
||||||
|
change: On<ValueChange<bool>>,
|
||||||
|
sources: Query<&ActionSource<T>>,
|
||||||
|
mut state: ResMut<ActionState<T>>,
|
||||||
|
) {
|
||||||
|
if let Ok(ActionSource(action)) = sources.get(change.source) {
|
||||||
|
if let ActionKind::Toggle = action.kind() {
|
||||||
|
state.set(*action, change.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// State -> UI sync: pushes toggle state back onto checkboxes so a keyboard or
|
||||||
|
/// gamepad toggle also moves the checkmark. Runs only when state changed.
|
||||||
|
fn sync_checkboxes<T: GameAction>(
|
||||||
|
state: Res<ActionState<T>>,
|
||||||
|
checkboxes: Query<(Entity, &ActionSource<T>, Has<Checked>), With<Checkbox>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
if !state.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (entity, ActionSource(action), is_checked) in &checkboxes {
|
||||||
|
if let ActionKind::Toggle = action.kind() {
|
||||||
|
let want = state.enabled(*action);
|
||||||
|
if want != is_checked {
|
||||||
|
commands.trigger(SetChecked { entity, checked: want });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Registers everything needed for action `T`: the [`ActionState`] and
|
||||||
|
/// [`InputMap`] resources, the hardware/UI fan-in, and checkbox syncing.
|
||||||
|
///
|
||||||
|
/// Build it with [`new`](Self::new) and [`bind`](Self::bind); one instance per
|
||||||
|
/// action type.
|
||||||
|
pub struct ActionsPlugin<T> {
|
||||||
|
map: InputMap<T>,
|
||||||
|
_marker: PhantomData<T>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: GameAction> Default for ActionsPlugin<T> {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
map: InputMap::default(),
|
||||||
|
_marker: PhantomData,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: GameAction> ActionsPlugin<T> {
|
||||||
|
/// Creates a plugin with no bindings.
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds one or more default bindings for `action` (chainable). Games can
|
||||||
|
/// still rebind at runtime by mutating the [`InputMap`] resource.
|
||||||
|
pub fn bind(mut self, action: T, bindings: impl IntoIterator<Item = Binding>) -> Self {
|
||||||
|
self.map.bind(action, bindings);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: GameAction> Plugin for ActionsPlugin<T> {
|
||||||
|
fn build(&self, app: &mut App) {
|
||||||
|
app.init_resource::<ActionState<T>>()
|
||||||
|
.insert_resource(self.map.clone())
|
||||||
|
.add_systems(First, clear_momentary::<T>)
|
||||||
|
.add_systems(PreUpdate, hardware_to_actions::<T>)
|
||||||
|
.add_systems(Update, sync_checkboxes::<T>)
|
||||||
|
.add_observer(button_to_action::<T>)
|
||||||
|
.add_observer(checkbox_to_action::<T>);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue