Compare commits
9 Commits
a0ca524599
...
f3afb2616c
| Author | SHA1 | Date |
|---|---|---|
|
|
f3afb2616c | 6 days ago |
|
|
2f59af15df | 6 days ago |
|
|
09e6c66788 | 6 days ago |
|
|
494cdce42e | 6 days ago |
|
|
cf3a81f4bd | 6 days ago |
|
|
33c20d47a0 | 6 days ago |
|
|
40b3f2adfc | 6 days ago |
|
|
d8e00525ec | 1 week ago |
|
|
bc13a3b27b | 1 week ago |
@ -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,251 @@
|
||||
//! Proc-macros for the `engine` crate.
|
||||
//!
|
||||
//! Provides [`GameAction`](macro@GameAction), a derive that annotates each enum
|
||||
//! variant with its [`ActionKind`] via `#[action_kind(...)]` and, optionally, a
|
||||
//! system to run for it via `#[action_handler(...)]`.
|
||||
|
||||
use proc_macro::TokenStream;
|
||||
use proc_macro_crate::{crate_name, FoundCrate};
|
||||
use quote::{format_ident, quote};
|
||||
use syn::{spanned::Spanned, Data, DeriveInput, Fields, Ident, Path};
|
||||
|
||||
/// Derives `engine::GameAction` (and `engine::GameActionHandlers`) for a
|
||||
/// fieldless enum.
|
||||
///
|
||||
/// Each variant may carry `#[action_kind(...)]` — one of `instant`, `toggle`,
|
||||
/// `axis`, or `scalar` — and any number of `#[action_handler(path::to::system)]`
|
||||
/// attributes. `#[action_kind]` is optional and **defaults to `instant`**, so
|
||||
/// bare variants are instant actions:
|
||||
///
|
||||
/// ```ignore
|
||||
/// #[derive(Copy, Clone, Eq, PartialEq, Hash, GameAction)]
|
||||
/// enum Action {
|
||||
/// #[action_handler(jump)]
|
||||
/// Jump, // instant (default)
|
||||
/// #[action_kind(axis)] #[action_handler(move_player)] // fn(In<Vec2>, ..)
|
||||
/// Move,
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// This expands to an `impl GameAction` whose `kind` maps each variant to its
|
||||
/// declared [`ActionKind`], plus an `impl GameActionHandlers` whose `add_handlers`
|
||||
/// registers each handler wired for its kind — a gated plain system for
|
||||
/// `instant`/`toggle`, a typed pipe (`axis_value`/`scalar_value`) plus gate for
|
||||
/// `axis`/`scalar`. `ActionsPlugin` calls `add_handlers` for you.
|
||||
#[proc_macro_derive(GameAction, attributes(action_kind, action_handler))]
|
||||
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()
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Kind {
|
||||
Instant,
|
||||
Toggle,
|
||||
Axis,
|
||||
Scalar,
|
||||
}
|
||||
|
||||
impl Kind {
|
||||
/// The `ActionKind` expression this maps to.
|
||||
fn to_tokens(self, engine: &proc_macro2::TokenStream) -> proc_macro2::TokenStream {
|
||||
match self {
|
||||
Kind::Instant => quote!(#engine::ActionKind::Instant),
|
||||
Kind::Toggle => quote!(#engine::ActionKind::Toggle),
|
||||
Kind::Axis => quote!(#engine::ActionKind::Axis),
|
||||
Kind::Scalar => quote!(#engine::ActionKind::Scalar),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn expand(input: DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
|
||||
let Data::Enum(data) = &input.data else {
|
||||
return Err(syn::Error::new(
|
||||
input.ident.span(),
|
||||
format!(
|
||||
"`GameAction` can only be derived for enums, but `{}` is not one.\n\
|
||||
An action set is a fixed list of variants, e.g. `enum {} {{ #[action_kind(instant)] Jump }}`.",
|
||||
input.ident, input.ident
|
||||
),
|
||||
));
|
||||
};
|
||||
|
||||
let engine = engine_path();
|
||||
let ident = &input.ident;
|
||||
let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
|
||||
|
||||
let mut kind_arms = Vec::new();
|
||||
let mut handler_regs = Vec::new();
|
||||
|
||||
for variant in &data.variants {
|
||||
if !matches!(variant.fields, Fields::Unit) {
|
||||
return Err(syn::Error::new(
|
||||
variant.fields.span(),
|
||||
format!(
|
||||
"action variant `{0}` must be a unit variant (no fields).\n\
|
||||
Actions are plain identifiers used as map keys — write `{0}`, not `{0}(..)` or `{0} {{ .. }}`.",
|
||||
variant.ident
|
||||
),
|
||||
));
|
||||
}
|
||||
let variant_ident = &variant.ident;
|
||||
let kind = parse_kind(variant)?;
|
||||
let kind_tokens = kind.to_tokens(&engine);
|
||||
kind_arms.push(quote!(Self::#variant_ident => #kind_tokens));
|
||||
|
||||
for handler in parse_handlers(variant)? {
|
||||
handler_regs.push(gen_handler(&engine, variant_ident, kind, &handler));
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the generated `add_handlers` warning-clean whether or not there are
|
||||
// any handlers: no unused `app` param and no unused trait imports.
|
||||
let handlers_body = if handler_regs.is_empty() {
|
||||
quote! {
|
||||
fn add_handlers(_app: &mut #engine::App) {}
|
||||
}
|
||||
} else {
|
||||
quote! {
|
||||
fn add_handlers(app: &mut #engine::App) {
|
||||
// Bring the builder methods into scope without polluting names.
|
||||
#[allow(unused_imports)]
|
||||
use #engine::IntoScheduleConfigs as _;
|
||||
#[allow(unused_imports)]
|
||||
use #engine::IntoSystem as _;
|
||||
#(#handler_regs)*
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok(quote! {
|
||||
impl #impl_generics #engine::GameAction for #ident #ty_generics #where_clause {
|
||||
fn kind(self) -> #engine::ActionKind {
|
||||
match self {
|
||||
#(#kind_arms,)*
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl #impl_generics #engine::GameActionHandlers for #ident #ty_generics #where_clause {
|
||||
#handlers_body
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Emits one `add_systems` call for a variant's handler, wired for its kind.
|
||||
fn gen_handler(
|
||||
engine: &proc_macro2::TokenStream,
|
||||
variant_ident: &Ident,
|
||||
kind: Kind,
|
||||
handler: &Path,
|
||||
) -> proc_macro2::TokenStream {
|
||||
let action = quote!(Self::#variant_ident);
|
||||
match kind {
|
||||
// No payload: gate a plain system on the action being active.
|
||||
Kind::Instant | Kind::Toggle => quote! {
|
||||
app.add_systems(#engine::Update, #handler.run_if(#engine::on_action(#action)));
|
||||
},
|
||||
// Vec2 payload: pipe it in, gated so it runs while the axis is active.
|
||||
Kind::Axis => quote! {
|
||||
app.add_systems(
|
||||
#engine::Update,
|
||||
#engine::axis_value(#action).pipe(#handler).run_if(#engine::on_action(#action)),
|
||||
);
|
||||
},
|
||||
// f32 payload: pipe it in, gated so it runs when the slider changes.
|
||||
Kind::Scalar => quote! {
|
||||
app.add_systems(
|
||||
#engine::Update,
|
||||
#engine::scalar_value(#action).pipe(#handler).run_if(#engine::on_action(#action)),
|
||||
);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses the `#[action_kind(...)]` attribute on a variant, defaulting to
|
||||
/// [`Kind::Instant`] when it is omitted (the common case).
|
||||
fn parse_kind(variant: &syn::Variant) -> syn::Result<Kind> {
|
||||
let mut kind: Option<Kind> = 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(),
|
||||
format!(
|
||||
"action variant `{}` has more than one `#[action_kind(..)]`; each variant declares exactly one kind",
|
||||
variant.ident
|
||||
),
|
||||
));
|
||||
}
|
||||
let name: Ident = attr.parse_args().map_err(|_| {
|
||||
syn::Error::new(
|
||||
attr.span(),
|
||||
format!(
|
||||
"malformed `#[action_kind(..)]` on `{}`: expected a single kind — one of \
|
||||
`instant`, `toggle`, `axis`, or `scalar`, e.g. `#[action_kind(instant)]`",
|
||||
variant.ident
|
||||
),
|
||||
)
|
||||
})?;
|
||||
kind = Some(match name.to_string().as_str() {
|
||||
"instant" => Kind::Instant,
|
||||
"toggle" => Kind::Toggle,
|
||||
"axis" => Kind::Axis,
|
||||
"scalar" => Kind::Scalar,
|
||||
other => {
|
||||
return Err(syn::Error::new(
|
||||
name.span(),
|
||||
format!(
|
||||
"unknown action kind `{other}` on `{}`; expected `instant`, `toggle`, `axis`, or `scalar`",
|
||||
variant.ident
|
||||
),
|
||||
))
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// A bare variant is an instant action — the most common kind — so the
|
||||
// attribute is optional and only needed to pick a non-default kind.
|
||||
Ok(kind.unwrap_or(Kind::Instant))
|
||||
}
|
||||
|
||||
/// Parses every `#[action_handler(path)]` attribute on a variant (zero or more).
|
||||
fn parse_handlers(variant: &syn::Variant) -> syn::Result<Vec<Path>> {
|
||||
let mut handlers = Vec::new();
|
||||
for attr in &variant.attrs {
|
||||
if !attr.path().is_ident("action_handler") {
|
||||
continue;
|
||||
}
|
||||
let path: Path = attr.parse_args().map_err(|_| {
|
||||
syn::Error::new(
|
||||
attr.span(),
|
||||
format!(
|
||||
"malformed `#[action_handler(..)]` on `{}`: expected a path to a system \
|
||||
function, e.g. `#[action_handler(jump)]` or `#[action_handler(sim::step)]`",
|
||||
variant.ident
|
||||
),
|
||||
)
|
||||
})?;
|
||||
handlers.push(path);
|
||||
}
|
||||
Ok(handlers)
|
||||
}
|
||||
|
||||
/// 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,849 @@
|
||||
//! 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. `#[action_kind]` defaults to `instant`, so a bare
|
||||
//! // variant is an instant action; annotate only the others.
|
||||
//! #[derive(Copy, Clone, Eq, PartialEq, Hash, GameAction)]
|
||||
//! enum Action {
|
||||
//! Jump, // instant (the default — no attribute needed)
|
||||
//! #[action_kind(toggle)] // stays on/off
|
||||
//! Grid,
|
||||
//! #[action_kind(axis)] // carries a Vec2
|
||||
//! Move,
|
||||
//! #[action_kind(scalar)] // carries an f32, from a slider
|
||||
//! Volume,
|
||||
//! }
|
||||
//!
|
||||
//! 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))
|
||||
//! // Typed pipe: deliver the slider's f32, only on the frame it changes.
|
||||
//! .add_systems(Update,
|
||||
//! scalar_value(Action::Volume).pipe(set_volume).run_if(on_action(Action::Volume)));
|
||||
//!
|
||||
//! // 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);
|
||||
//! }
|
||||
//! }
|
||||
//!
|
||||
//! fn set_volume(In(v): In<f32>, mut audio: ResMut<AudioSettings>) { audio.volume = v; }
|
||||
//!
|
||||
//! // 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<bool> -> Toggle
|
||||
//! // commands.spawn((slider(..), ActionSource(Action::Volume))); // ValueChange<f32> -> Scalar
|
||||
//! ```
|
||||
//!
|
||||
//! ## 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`]; scalars (sliders) hold a
|
||||
//! persistent [`f32`]. [`ActionState::value`] reads whichever bucket the action's
|
||||
//! kind selects and returns a unified [`ActionValue`], so every downstream read is
|
||||
//! kind-agnostic.
|
||||
//!
|
||||
//! Kinds split by *trigger style*: [`Instant`](ActionKind::Instant) and
|
||||
//! [`Scalar`](ActionKind::Scalar) are **edge-triggered** (they fire on the frame
|
||||
//! they happen — a press, a slider change), while [`Toggle`](ActionKind::Toggle)
|
||||
//! and [`Axis`](ActionKind::Axis) are **level-triggered** (they report a state
|
||||
//! that is currently true). [`on_action`] reflects that per kind.
|
||||
//!
|
||||
//! ## 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 [`ActionState::active`]: an instant
|
||||
//! fired / a slider changed **this frame** (edge), or a toggle is on / an axis is
|
||||
//! deflected (level). Reach for it when the fact that the action is active is all
|
||||
//! the system needs — "run while the stick is moved", "run when the slider moves".
|
||||
//! * **`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`], the slider [`f32`], a toggle's bool). The
|
||||
//! consumer pulls its type out with [`ActionValue::as_axis`] /
|
||||
//! [`ActionValue::as_scalar`] / [`ActionValue::as_bool`], or use the typed
|
||||
//! [`axis_value`] / [`scalar_value`] / [`toggle_value`] helpers to pipe a bare
|
||||
//! `Vec2` / `f32` / `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.
|
||||
//!
|
||||
//! ## Registering handlers
|
||||
//!
|
||||
//! Two conveniences remove the `add_systems(.., run_if(on_action(..)))` boilerplate;
|
||||
//! use whichever you prefer (or neither — plain `add_systems` always works):
|
||||
//!
|
||||
//! * **`#[action_handler(path)]`** on a variant (see the [`GameAction`](macro@GameAction)
|
||||
//! derive) — colocates the handler with the action and wires it *for its kind*
|
||||
//! automatically: a gated plain system for instant/toggle, a typed pipe for
|
||||
//! axis/scalar. [`ActionsPlugin`] registers them when you add it.
|
||||
//! * **[`AppActionExt::on_action`]** — `app.on_action(Update, Action::Jump, jump)`,
|
||||
//! the explicit, no-macro form that keeps registration visible in `main`.
|
||||
//!
|
||||
//! ## Multiple action sets
|
||||
//!
|
||||
//! The whole API is generic over the action type, so a game is **not** limited to
|
||||
//! one enum. Add an [`ActionsPlugin<T>`] per set to separate concerns — gameplay,
|
||||
//! menu, audio, debug — each with its own bindings and handlers:
|
||||
//!
|
||||
//! ```rust,ignore
|
||||
//! app.add_plugins(ActionsPlugin::<GameplayAction>::new().bind(/* .. */))
|
||||
//! .add_plugins(ActionsPlugin::<MenuAction>::new().bind(/* .. */))
|
||||
//! .add_plugins(ActionsPlugin::<AudioAction>::new().bind(/* .. */));
|
||||
//! ```
|
||||
//!
|
||||
//! Each set gets its **own** independent [`ActionState<T>`], [`InputMap<T>`],
|
||||
//! fan-in systems, and observers; [`on_action`], [`action_value`], and
|
||||
//! [`ActionSource<T>`] are all keyed by the set's type, so sets never collide.
|
||||
//! (Deriving [`GameAction`](macro@GameAction) generates a distinct set of impls
|
||||
//! per enum, and each `ActionsPlugin::<T>` is a distinct plugin type, so adding
|
||||
//! several is fine.)
|
||||
//!
|
||||
//! Bindings are read **independently** per set — there is no cross-set input
|
||||
//! consumption or priority — which is exactly right for concerns that are
|
||||
//! genuinely separate (audio vs debug vs gameplay). To make two sets *mutually
|
||||
//! exclusive* (menu vs gameplay), gate their handlers with `run_if(in_state(..))`:
|
||||
//! Bevy states are already exclusive, so only the active set's handlers run, even
|
||||
//! when the same key is bound in both.
|
||||
|
||||
use core::hash::Hash;
|
||||
use core::marker::PhantomData;
|
||||
|
||||
use bevy::{
|
||||
ecs::{schedule::ScheduleLabel, system::ScheduleSystem},
|
||||
input::mouse::AccumulatedMouseScroll,
|
||||
platform::collections::{HashMap, HashSet},
|
||||
prelude::*,
|
||||
ui::Checked,
|
||||
ui_widgets::{Activate, Checkbox, SetChecked, ValueChange},
|
||||
};
|
||||
|
||||
/// How an action behaves.
|
||||
///
|
||||
/// The kinds split two ways by *trigger style*: [`Instant`](Self::Instant) and
|
||||
/// [`Scalar`](Self::Scalar) are **edge-triggered** (they fire on the frame
|
||||
/// something happens), while [`Toggle`](Self::Toggle) and [`Axis`](Self::Axis) are
|
||||
/// **level-triggered** (they report a state that is currently true). This is what
|
||||
/// [`on_action`] reflects per kind.
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ActionKind {
|
||||
/// Fires for a single frame each time it is triggered (jump, shoot, confirm).
|
||||
/// Edge-triggered.
|
||||
Instant,
|
||||
/// Holds an on/off state until toggled again (show grid, mute, pause).
|
||||
/// Level-triggered.
|
||||
Toggle,
|
||||
/// Carries a [`Vec2`] direction + magnitude, refreshed every frame (movement,
|
||||
/// aiming). Level-triggered. Read the value with [`ActionState::axis`].
|
||||
Axis,
|
||||
/// Carries an `f32`, emitted when a slider changes. Edge-triggered like
|
||||
/// [`Instant`](Self::Instant) but value-carrying like [`Axis`](Self::Axis).
|
||||
/// Read the value with [`ActionState::scalar`].
|
||||
Scalar,
|
||||
}
|
||||
|
||||
/// 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); [`Scalar`](ActionKind::Scalar) produces
|
||||
/// [`Scalar`](Self::Scalar). A consumer that knows the kind it wants pulls the
|
||||
/// payload out with [`as_bool`](Self::as_bool), [`as_axis`](Self::as_axis), or
|
||||
/// [`as_scalar`](Self::as_scalar).
|
||||
#[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),
|
||||
/// An analog 1D value (e.g. a slider position).
|
||||
Scalar(f32),
|
||||
}
|
||||
|
||||
impl ActionValue {
|
||||
/// Whether the value is "non-zero": a `true` bool, a non-zero axis, or a
|
||||
/// non-zero scalar. Note this is a property of the *value* — for the
|
||||
/// edge-triggered kinds ([`Instant`](ActionKind::Instant)/[`Scalar`](ActionKind::Scalar)),
|
||||
/// [`on_action`] instead reports "happened this frame" via
|
||||
/// [`ActionState::active`].
|
||||
pub fn is_active(self) -> bool {
|
||||
match self {
|
||||
ActionValue::Bool(b) => b,
|
||||
ActionValue::Axis(v) => v != Vec2::ZERO,
|
||||
ActionValue::Scalar(v) => v != 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The digital payload. Analog values collapse to whether they are non-zero,
|
||||
/// so a bool consumer can read an axis or scalar action too.
|
||||
pub fn as_bool(self) -> bool {
|
||||
self.is_active()
|
||||
}
|
||||
|
||||
/// The 2D analog payload. Non-axis values yield [`Vec2::ZERO`] (a `true` bool
|
||||
/// or a scalar has no direction).
|
||||
pub fn as_axis(self) -> Vec2 {
|
||||
match self {
|
||||
ActionValue::Axis(v) => v,
|
||||
ActionValue::Bool(_) | ActionValue::Scalar(_) => Vec2::ZERO,
|
||||
}
|
||||
}
|
||||
|
||||
/// The 1D analog payload. A scalar yields its value; an axis yields its `x`;
|
||||
/// a bool yields `0.0`.
|
||||
pub fn as_scalar(self) -> f32 {
|
||||
match self {
|
||||
ActionValue::Scalar(v) => v,
|
||||
ActionValue::Axis(v) => v.x,
|
||||
ActionValue::Bool(_) => 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive macro for [`GameAction`]: annotate each variant with
|
||||
/// `#[action_kind(...)]` — `instant`, `toggle`, `axis`, or `scalar`. The
|
||||
/// attribute is optional and defaults to `instant`, so bare variants are instant
|
||||
/// actions.
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// #[derive(Copy, Clone, Eq, PartialEq, Hash, GameAction)]
|
||||
/// enum Action {
|
||||
/// Jump, // instant (default)
|
||||
/// #[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), [`Axis`](ActionKind::Axis), or
|
||||
/// [`Scalar`](ActionKind::Scalar).
|
||||
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) and [`Scroll`](Self::Scroll) are 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),
|
||||
/// The mouse wheel, producing this frame's scroll delta as a [`Vec2`]
|
||||
/// (`x` = horizontal, `y` = vertical). Like a stick, it drives
|
||||
/// [`Axis`](ActionKind::Axis) actions — but it is a *delta* (non-zero only
|
||||
/// while scrolling), so the axis reads zero on idle frames and
|
||||
/// [`on_action`] fires exactly while the wheel turns.
|
||||
Scroll,
|
||||
}
|
||||
|
||||
/// 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) /
|
||||
/// [`scalar`](Self::scalar) / [`enabled`](Self::enabled). Games may also drive
|
||||
/// actions programmatically with [`press`](Self::press), [`set`](Self::set),
|
||||
/// [`toggle`](Self::toggle), and [`set_scalar`](Self::set_scalar).
|
||||
#[derive(Resource)]
|
||||
pub struct ActionState<T> {
|
||||
/// Instant/Scalar actions that fired or changed this frame; cleared 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>,
|
||||
/// Scalar (slider) actions and their latest value; persists across frames so
|
||||
/// the current value is readable between changes.
|
||||
scalars: HashMap<T, f32>,
|
||||
}
|
||||
|
||||
impl<T> Default for ActionState<T> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
momentary: HashSet::default(),
|
||||
enabled: HashSet::default(),
|
||||
axes: HashMap::default(),
|
||||
scalars: 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, [`Scalar`](ActionValue::Scalar) for
|
||||
/// scalars.
|
||||
///
|
||||
/// 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)),
|
||||
ActionKind::Scalar => ActionValue::Scalar(self.scalar(action)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `action` is currently active — what [`on_action`] gates on.
|
||||
///
|
||||
/// Dispatched by trigger style (see [`ActionKind`]):
|
||||
/// * **edge** — [`Instant`](ActionKind::Instant) fired / [`Scalar`](ActionKind::Scalar)
|
||||
/// changed **this frame**;
|
||||
/// * **level** — [`Toggle`](ActionKind::Toggle) is on / [`Axis`](ActionKind::Axis)
|
||||
/// is deflected (non-zero).
|
||||
///
|
||||
/// For edge kinds this differs from [`value`](Self::value)`(a).is_active()`
|
||||
/// (which is a value property): a slider resting at `3.0` is *active only on the
|
||||
/// frame it moves*, not every frame its value is non-zero.
|
||||
pub fn active(&self, action: T) -> bool {
|
||||
match action.kind() {
|
||||
ActionKind::Instant | ActionKind::Scalar => self.momentary.contains(&action),
|
||||
ActionKind::Toggle => self.enabled.contains(&action),
|
||||
ActionKind::Axis => self.axis(action) != Vec2::ZERO,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// The latest value of a scalar (slider) `action`, or `0.0` if it has never
|
||||
/// been set. Persists between changes, so it is readable on any frame — pair
|
||||
/// with [`on_action`] to react only when it changes.
|
||||
pub fn scalar(&self, action: T) -> f32 {
|
||||
self.scalars.get(&action).copied().unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
|
||||
/// Sets a scalar (slider) `action`'s latest value, and marks it as changed
|
||||
/// this frame (so [`active`](Self::active)/[`on_action`] fire this frame).
|
||||
pub fn set_scalar(&mut self, action: T, value: f32) {
|
||||
self.scalars.insert(action, value);
|
||||
self.momentary.insert(action);
|
||||
}
|
||||
}
|
||||
|
||||
/// Marker component linking a UI widget to the action it drives.
|
||||
///
|
||||
/// Put it on a `Button` ([`Instant`](ActionKind::Instant)), `Checkbox`
|
||||
/// ([`Toggle`](ActionKind::Toggle)), or `Slider` ([`Scalar`](ActionKind::Scalar)) —
|
||||
/// the engine wires the widget's events into [`ActionState`] and keeps checkboxes
|
||||
/// visually in sync.
|
||||
///
|
||||
/// To spawn it inside a `bsn!` scene, the action type must be `Default` (Bevy's
|
||||
/// template system blanket-requires `Clone + Default` for any component in a
|
||||
/// scene), and the entry needs a turbofish so the generic can be inferred in
|
||||
/// template position: `ActionSource::<MyAction>(MyAction::Jump)`. `Default` is
|
||||
/// derived here conditionally, so it is available exactly when the action is.
|
||||
///
|
||||
/// Note the `Default` bound lives only on this derived `Default` impl — the
|
||||
/// `Component` impl stays generic over every [`GameAction`], so the fan-in
|
||||
/// systems work for actions that are not `Default`.
|
||||
#[derive(Component, Copy, Clone, Debug, Default)]
|
||||
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 on `action`, for **any** kind — see [`ActionState::active`] for
|
||||
/// the per-kind meaning: an instant fired / a slider changed this frame (edge), or
|
||||
/// a toggle is on / an axis is deflected (level).
|
||||
///
|
||||
/// So the same call gates "run while paused", "run while the stick is moved", and
|
||||
/// "run when the slider changes".
|
||||
///
|
||||
/// ```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
|
||||
/// app.add_systems(Update, save_volume.run_if(on_action(Action::Volume))); // slider changed
|
||||
/// ```
|
||||
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)
|
||||
}
|
||||
|
||||
/// Typed convenience over [`action_value`] for scalar (slider) actions: pipes a
|
||||
/// bare `f32`, so the consumer takes `In<f32>`.
|
||||
///
|
||||
/// Pair with [`on_action`] to run — and deliver the value — only when the slider
|
||||
/// changes: `scalar_value(a).pipe(sys).run_if(on_action(a))`.
|
||||
pub fn scalar_value<T: GameAction>(action: T) -> impl FnMut(Res<ActionState<T>>) -> f32 + Clone {
|
||||
move |state: Res<ActionState<T>>| state.scalar(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>,
|
||||
scroll: Res<AccumulatedMouseScroll>,
|
||||
map: Res<InputMap<T>>,
|
||||
mut state: ResMut<ActionState<T>>,
|
||||
) {
|
||||
for (&action, bindings) in &map.bindings {
|
||||
match action.kind() {
|
||||
// Analog: pick the strongest reading across analog sources and store it
|
||||
// every frame (zero when nothing is deflected / not scrolling).
|
||||
ActionKind::Axis => {
|
||||
let mut value = Vec2::ZERO;
|
||||
for binding in bindings {
|
||||
let reading = match binding {
|
||||
Binding::Stick(stick) => {
|
||||
// Best deflection across all connected gamepads.
|
||||
let mut best = Vec2::ZERO;
|
||||
for pad in &pads {
|
||||
let r = match stick {
|
||||
Stick::Left => pad.left_stick(),
|
||||
Stick::Right => pad.right_stick(),
|
||||
Stick::DPad => pad.dpad(),
|
||||
};
|
||||
if r.length_squared() > best.length_squared() {
|
||||
best = r;
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
// This frame's wheel delta (zero when not scrolling).
|
||||
Binding::Scroll => scroll.delta,
|
||||
_ => continue,
|
||||
};
|
||||
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)),
|
||||
// Analog sources are not meaningful for digital actions.
|
||||
Binding::Stick(_) | Binding::Scroll => false,
|
||||
};
|
||||
if just_pressed {
|
||||
match kind {
|
||||
ActionKind::Instant => state.press(action),
|
||||
_ => state.toggle(action),
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Scalar actions come only from UI sliders, never from a binding.
|
||||
ActionKind::Scalar => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 | ActionKind::Scalar => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// UI fan-in: a `Slider`'s [`ValueChange<f32>`](ValueChange) feeds a scalar action.
|
||||
///
|
||||
/// The latest value is stored on every change so [`ActionState::scalar`] tracks
|
||||
/// the slider live (including mid-drag), while the edge trigger — what
|
||||
/// [`on_action`] fires on — only marks the interaction's *final* value, so
|
||||
/// gated consumers run once per settled change rather than every drag frame.
|
||||
fn slider_to_action<T: GameAction>(
|
||||
change: On<ValueChange<f32>>,
|
||||
sources: Query<&ActionSource<T>>,
|
||||
mut state: ResMut<ActionState<T>>,
|
||||
) {
|
||||
if let Ok(ActionSource(action)) = sources.get(change.source) {
|
||||
if let ActionKind::Scalar = action.kind() {
|
||||
if change.is_final {
|
||||
// Store value + mark changed this frame (edge trigger).
|
||||
state.set_scalar(*action, change.value);
|
||||
} else {
|
||||
// Mid-drag: keep the live value readable, but don't trigger.
|
||||
state.scalars.insert(*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 a variant's `#[action_handler(..)]` systems onto the [`App`].
|
||||
///
|
||||
/// Implemented automatically by `#[derive(GameAction)]` — an empty impl when no
|
||||
/// variant declares a handler — and called by [`ActionsPlugin`] in
|
||||
/// [`build`](Plugin::build), so annotated handlers register themselves when you
|
||||
/// add the plugin. You rarely name this trait directly; the derive owns it.
|
||||
pub trait GameActionHandlers: GameAction {
|
||||
/// Adds each `#[action_handler(..)]` system, wired for its kind: a gated plain
|
||||
/// system for [`Instant`](ActionKind::Instant)/[`Toggle`](ActionKind::Toggle),
|
||||
/// a typed pipe (`axis_value`/`scalar_value`) plus gate for
|
||||
/// [`Axis`](ActionKind::Axis)/[`Scalar`](ActionKind::Scalar).
|
||||
fn add_handlers(app: &mut App);
|
||||
}
|
||||
|
||||
/// [`App`] extension for wiring a system to an action without the
|
||||
/// `run_if(on_action(..))` boilerplate.
|
||||
///
|
||||
/// The un-macro'd counterpart to `#[action_handler(..)]`: fully explicit, keeps
|
||||
/// registration visible in `main`, and needs no derive.
|
||||
pub trait AppActionExt {
|
||||
/// Adds `systems` to `schedule`, gated on `action` being active — shorthand
|
||||
/// for `add_systems(schedule, systems.run_if(on_action(action)))`.
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// app.on_action(Update, Action::Jump, jump)
|
||||
/// .on_action(Update, Action::Grid, draw_grid);
|
||||
/// ```
|
||||
///
|
||||
/// This is the gated form (the system takes no `In`). For the value kinds,
|
||||
/// pipe explicitly: `add_systems(schedule, axis_value(a).pipe(sys))`.
|
||||
fn on_action<T: GameAction, M>(
|
||||
&mut self,
|
||||
schedule: impl ScheduleLabel,
|
||||
action: T,
|
||||
systems: impl IntoScheduleConfigs<ScheduleSystem, M>,
|
||||
) -> &mut Self;
|
||||
}
|
||||
|
||||
impl AppActionExt for App {
|
||||
fn on_action<T: GameAction, M>(
|
||||
&mut self,
|
||||
schedule: impl ScheduleLabel,
|
||||
action: T,
|
||||
systems: impl IntoScheduleConfigs<ScheduleSystem, M>,
|
||||
) -> &mut Self {
|
||||
self.add_systems(schedule, systems.run_if(on_action(action)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers everything needed for action `T`: the [`ActionState`] and
|
||||
/// [`InputMap`] resources, the hardware/UI fan-in, checkbox syncing, and any
|
||||
/// `#[action_handler(..)]` systems (via [`GameActionHandlers`]).
|
||||
///
|
||||
/// 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: GameActionHandlers> 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>)
|
||||
.add_observer(slider_to_action::<T>);
|
||||
T::add_handlers(app);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Exercises the `#[derive(GameAction)]` codegen: every kind, plus an
|
||||
// `#[action_handler(..)]` per kind wired for its expected signature. If the
|
||||
// generated `impl GameAction` / `impl GameActionHandlers` (with its
|
||||
// kind-specific `add_systems`/pipe wiring) fails to type-check, this won't
|
||||
// compile.
|
||||
#[derive(Copy, Clone, Eq, PartialEq, Hash, GameAction)]
|
||||
enum TestAction {
|
||||
#[action_kind(instant)]
|
||||
#[action_handler(on_jump)]
|
||||
Jump,
|
||||
#[action_kind(toggle)]
|
||||
#[action_handler(on_grid)]
|
||||
Grid,
|
||||
#[action_kind(axis)]
|
||||
#[action_handler(on_move)]
|
||||
Move,
|
||||
#[action_kind(scalar)]
|
||||
#[action_handler(on_volume)]
|
||||
Volume,
|
||||
}
|
||||
|
||||
fn on_jump() {}
|
||||
fn on_grid() {}
|
||||
fn on_move(_dir: In<Vec2>) {}
|
||||
fn on_volume(_level: In<f32>) {}
|
||||
|
||||
#[test]
|
||||
fn derive_and_handlers_compile() {
|
||||
assert_eq!(TestAction::Jump.kind(), ActionKind::Instant);
|
||||
assert_eq!(TestAction::Move.kind(), ActionKind::Axis);
|
||||
assert_eq!(TestAction::Volume.kind(), ActionKind::Scalar);
|
||||
|
||||
// Registers the generated handlers, exercising the piped/gated wiring
|
||||
// and the `AppActionExt::on_action` path shape.
|
||||
let mut app = App::new();
|
||||
app.add_plugins(
|
||||
ActionsPlugin::<TestAction>::new()
|
||||
.bind(TestAction::Jump, [Binding::Key(KeyCode::Space)]),
|
||||
);
|
||||
app.on_action(Update, TestAction::Jump, on_jump);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,157 @@
|
||||
use super::*;
|
||||
|
||||
pub(crate) struct GameActionsPlugin;
|
||||
|
||||
impl Plugin for GameActionsPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_plugins((
|
||||
ActionsPlugin::<SimAction>::new()
|
||||
.bind(SimAction::Toggle, [Binding::Key(KeyCode::Space)])
|
||||
.bind(SimAction::Step, [Binding::Key(KeyCode::KeyN)])
|
||||
.bind(SimAction::Reset, [Binding::Key(KeyCode::KeyR)]),
|
||||
ActionsPlugin::<AudioAction>::new()
|
||||
.bind(AudioAction::Toggle, [Binding::Key(KeyCode::KeyM)]),
|
||||
ActionsPlugin::<ZoomAction>::new()
|
||||
.bind(ZoomAction::Zoom, [Binding::Scroll])
|
||||
.bind(ZoomAction::ZoomIn, [Binding::Key(KeyCode::Equal)])
|
||||
.bind(ZoomAction::ZoomOut, [Binding::Key(KeyCode::Minus)]),
|
||||
ActionsPlugin::<Action>::new()
|
||||
.bind(Action::Quit, [Binding::Key(KeyCode::Escape)]),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Eq, PartialEq, Hash, GameAction, Default)]
|
||||
pub(crate) enum SimAction {
|
||||
#[action_handler(SimAction::toggle)]
|
||||
Toggle,
|
||||
#[action_handler(SimAction::step)]
|
||||
Step,
|
||||
#[action_handler(SimAction::reset)]
|
||||
Reset,
|
||||
#[action_handler(SimAction::wipe)]
|
||||
Wipe,
|
||||
#[default]
|
||||
None,
|
||||
}
|
||||
|
||||
impl SimAction {
|
||||
/// When the user toggles between Play/Pause simulation, just update the state
|
||||
fn toggle(
|
||||
curr: Res<State<SimulationPlayback>>,
|
||||
mut next: ResMut<NextState<SimulationPlayback>>,
|
||||
mut last_board_state: ResMut<LastBoardState>,
|
||||
coordinates: Query<&Coordinates, With<Cell>>,
|
||||
) {
|
||||
next.set(match curr.get() {
|
||||
SimulationPlayback::Run | SimulationPlayback::Step => {
|
||||
// Run | Step -> Pause
|
||||
SimulationPlayback::Pause
|
||||
}
|
||||
SimulationPlayback::Pause => {
|
||||
// Capture current state before running
|
||||
last_board_state.coordinates = coordinates.iter().cloned().collect();
|
||||
// Pause -> Run
|
||||
SimulationPlayback::Run
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// When the user wants to step the simulation, just enter the SimulationStep state
|
||||
// TODO: Should switch back to Pause automatically after step if Step is the current state
|
||||
fn step(mut next: ResMut<NextState<SimulationPlayback>>) {
|
||||
next.set(SimulationPlayback::Step);
|
||||
}
|
||||
|
||||
// Reset board to last saved state (before running, or before wiping)
|
||||
fn reset(
|
||||
last_board_state: Res<LastBoardState>,
|
||||
mut lifecycle: MessageWriter<Lifecycle>,
|
||||
cells: Query<Entity, With<Cell>>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
// TODO: reconcile board instead of despawn/spawning the world
|
||||
cells.iter().for_each(|e| {
|
||||
commands.entity(e).despawn();
|
||||
});
|
||||
last_board_state.coordinates.iter().for_each(|c| {
|
||||
lifecycle.write(Lifecycle::Alive(c.clone()));
|
||||
});
|
||||
}
|
||||
|
||||
// Wipe all cells from the board
|
||||
fn wipe(cells: Query<Entity, With<Cell>>, mut commands: Commands) {
|
||||
cells.iter().for_each(|e| {
|
||||
commands.entity(e).despawn();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Eq, PartialEq, Hash, GameAction, Default)]
|
||||
pub(crate) enum ZoomAction {
|
||||
#[action_kind(axis)]
|
||||
#[action_handler(ZoomAction::zoom)]
|
||||
Zoom,
|
||||
#[action_handler(ZoomAction::zoom_in)]
|
||||
ZoomIn,
|
||||
#[action_handler(ZoomAction::zoom_out)]
|
||||
ZoomOut,
|
||||
#[default]
|
||||
None
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Eq, PartialEq, Hash, GameAction, Default)]
|
||||
pub(crate) enum Action {
|
||||
#[action_handler(Action::quit)]
|
||||
Quit,
|
||||
#[default]
|
||||
None,
|
||||
}
|
||||
|
||||
impl ZoomAction {
|
||||
fn zoom_by(factor: f32, mut projection: Query<&mut Projection>) {
|
||||
projection.iter_mut().for_each(|mut p| {
|
||||
if let Projection::Orthographic(o) = &mut *p {
|
||||
o.scale = (o.scale * factor).clamp(0.05, 20.0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Simple UI zoom out
|
||||
fn zoom(In(delta): In<Vec2>, projection: Query<&mut Projection>) {
|
||||
if delta.y != 0.0 {
|
||||
ZoomAction::zoom_by(0.9_f32.powf(delta.y), projection); // up = zoom in
|
||||
}
|
||||
}
|
||||
|
||||
fn zoom_in(projection: Query<&mut Projection>) {
|
||||
ZoomAction::zoom_by(0.5, projection);
|
||||
}
|
||||
|
||||
fn zoom_out(projection: Query<&mut Projection>) {
|
||||
ZoomAction::zoom_by(2.0, projection);
|
||||
}
|
||||
}
|
||||
|
||||
impl Action {
|
||||
fn quit(mut writer: MessageWriter<AppExit>) {
|
||||
writer.write(AppExit::Success);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Eq, PartialEq, Hash, GameAction, Default)]
|
||||
pub(crate) enum AudioAction {
|
||||
#[action_handler(AudioAction::toggle)]
|
||||
Toggle,
|
||||
#[default]
|
||||
None,
|
||||
}
|
||||
|
||||
impl AudioAction {
|
||||
fn toggle(curr: Res<State<AudioPlayback>>, mut next: ResMut<NextState<AudioPlayback>>) {
|
||||
next.set(match curr.get() {
|
||||
AudioPlayback::Play => AudioPlayback::Mute,
|
||||
AudioPlayback::Mute => AudioPlayback::Play,
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue