Compare commits

...

9 Commits

Author SHA1 Message Date
Elijah Voigt f3afb2616c use actions for zooming w/ buttons, keys, and scroll wheel 6 days ago
Elijah Voigt 2f59af15df Add Binding::Scroll as an axis input source
The mouse wheel's per-frame delta is a Vec2, so it maps directly onto
axis actions alongside gamepad sticks. hardware_to_actions now reads
AccumulatedMouseScroll in the Axis branch (max-magnitude combine); the
digital branch ignores it, and Scalar is unchanged.

Since an axis is refreshed every frame and the scroll delta is zero when
idle, on_action fires exactly while the wheel turns and the piped value
is the delta — no persistence or edge-guarding needed. Enables e.g.
`.bind(Action::Zoom, [Binding::Scroll])`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
6 days ago
Elijah Voigt 09e6c66788 Adopt actions framework 6 days ago
Elijah Voigt 494cdce42e Default action_kind to instant; document multiple action sets
- The #[action_kind(..)] attribute is now optional and defaults to instant
  (the most common kind), so bare enum variants are instant actions.
- Document the multiple-plugin pattern: add one ActionsPlugin::<T> per action
  enum (gameplay/menu/audio/debug) for separation of concerns, each with its
  own independent ActionState/InputMap/systems, plus the run_if(in_state(..))
  approach for mutually-exclusive sets.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
6 days ago
Elijah Voigt cf3a81f4bd Make ActionSource usable in bsn!; clearer macro errors
- Derive Default on ActionSource so it satisfies bevy's template system
  (Template/FromTemplate are blanket-impl'd only for Clone + Default + Unpin),
  letting it be spawned as a component in a bsn! scene. The bound sits on the
  derived Default impl only, so the Component impl stays generic over every
  GameAction — the fan-in systems keep working for non-Default actions.
- Improve #[derive(GameAction)] diagnostics: each now names the offending
  variant and shows the exact expected syntax (not-an-enum, non-unit variant,
  missing/duplicate/unknown action_kind, malformed action_handler).
- Add a #[cfg(test)] module exercising all four kinds + an #[action_handler]
  each + ActionsPlugin + App::on_action, proving the macro codegen compiles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
6 days ago
Elijah Voigt 33c20d47a0 Add two ways to register action handlers
Both remove the add_systems(.., run_if(on_action(..))) boilerplate:

- `#[action_handler(path)]` derive attribute colocates a handler with its
  action variant and wires it for its kind — a gated plain system for
  instant/toggle, a typed pipe (axis_value/scalar_value) plus gate for
  axis/scalar. The derive generates an `impl GameActionHandlers` (empty when
  no variant declares one), which ActionsPlugin calls on build.
- `App::on_action(schedule, action, system)` (AppActionExt) — the explicit,
  no-macro form that keeps registration visible in main.

ActionsPlugin's Plugin impl now requires T: GameActionHandlers (satisfied
automatically by the derive).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
6 days ago
Elijah Voigt 40b3f2adfc Add Scalar action kind for UI sliders
Adds a fourth ActionKind, Scalar, carrying an f32 from a slider's
ValueChange<f32>. It is edge-triggered like Instant but value-carrying
like Axis, completing the edge/level split (Instant/Scalar vs Toggle/Axis).

- ActionValue::Scalar(f32) + as_scalar(); ActionState scalar()/set_scalar().
- active() is now kind-dispatched: edge kinds report "this frame", level
  kinds report current state.
- slider_to_action observer stores live value on every change but only
  triggers on is_final, so gated consumers run once per settled change.
- scalar_value(a) typed provider pipes a bare f32 into In<f32>.
- #[action_kind(scalar)] added to the derive macro.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
6 days ago
Elijah Voigt d8e00525ec 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>
1 week ago
Elijah Voigt bc13a3b27b space to play/pause 1 week ago

11
engine/Cargo.lock generated

@ -2413,6 +2413,17 @@ name = "engine"
version = "0.1.0"
dependencies = [
"bevy",
"engine_macros",
]
[[package]]
name = "engine_macros"
version = "0.1.0"
dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn",
]
[[package]]

@ -5,6 +5,7 @@ edition = "2024"
[dependencies]
bevy = "0.19.0"
engine_macros = { path = "macros" }
[features]
dev = [

@ -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);
}
}

@ -1,3 +1,6 @@
pub mod actions;
pub use actions::*;
pub mod color;
pub use color::*;
@ -9,9 +12,13 @@ pub use bevy::{
color::palettes::basic::*,
feathers::{
FeathersPlugins,
constants::{fonts, size},
containers::*,
controls::*,
dark_theme::create_dark_theme,
theme::{ThemeProps, ThemedText, UiTheme},
font_styles::InheritableFont,
theme::{InheritableThemeTextColor, ThemeProps, ThemedText, UiTheme},
tokens,
},
input::{
common_conditions::*,
@ -22,5 +29,10 @@ pub use bevy::{
platform::collections::{HashMap, HashSet},
prelude::*,
sprite_render::MeshMaterial2dTemplate,
ui_widgets::Activate,
text::FontWeight,
ui::{Checked, InteractionDisabled},
ui_widgets::{
Activate, ActivateOnPress, Checkbox, SliderPrecision, SliderStep, ValueChange,
checkbox_self_update, slider_self_update,
},
};

11
life/Cargo.lock generated

@ -2383,6 +2383,17 @@ name = "engine"
version = "0.1.0"
dependencies = [
"bevy",
"engine_macros",
]
[[package]]
name = "engine_macros"
version = "0.1.0"
dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn",
]
[[package]]

@ -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,
});
}
}

@ -11,23 +11,11 @@
// Only because of FromTemplat macros unfortunately...
#![allow(dead_code)]
use bevy::{
feathers::{
constants::{fonts, size},
containers::*,
font_styles::InheritableFont,
theme::{InheritableThemeTextColor, ThemeProps},
tokens,
},
text::FontWeight,
ui::{Checked, InteractionDisabled},
ui_widgets::{
ActivateOnPress, Checkbox, SliderPrecision, SliderStep, ValueChange, checkbox_self_update,
slider_self_update,
},
};
use engine::*;
mod actions;
use actions::*;
const TILE: &str = "tileGrey_01.png";
const SIM_FRAME_DURATION: f32 = 0.5;
@ -38,6 +26,7 @@ fn main() {
..default()
}))
.add_plugins(FeathersPlugins)
.add_plugins(GameActionsPlugin)
.init_resource::<NextCoordinates>()
.init_resource::<CellAssets>()
.init_resource::<Panning>()
@ -58,41 +47,60 @@ fn main() {
load_meshes,
),
)
// Cell management/Simulation
.add_systems(
Update,
(
cell_lifecycle.run_if(on_message::<Lifecycle>),
update_grid_position.run_if(on_message::<CursorMoved>),
// Run if any component added/removed: AudioSink
// Run if state changed: AudioPlayback
control_volume,
toggle_audio_state.run_if(input_just_pressed(KeyCode::KeyM)),
de_spawn_cells
.run_if(not(is_ui_hovered))
.run_if(not(is_panning))
.run_if(input_just_released(MouseButton::Left)),
assert_cell_uniqueness.run_if(any_with_component::<Cell>),
simulation_step.run_if(input_just_pressed(KeyCode::ArrowRight)),
simulation_step
.run_if(in_state(SimulationPlayback::Run))
.run_if(cooldown_secs(SIM_FRAME_DURATION)),
simulation_step.run_if(state_changed::<SimulationPlayback>),
grid_gizmo,
// TODO: Visualize audio play/mute buttons enable/disable
toggle_interactive::<SimulationPlayback>,
// TODO Visualize simulation run/pause buttons enable/disable
toggle_interactive::<AudioPlayback>,
pause_simulation.run_if(in_state(SimulationPlayback::Step)),
update_material,
),
)
// Audio Systems
.add_systems(
Update,
(
control_volume,
update_pitch_map_resource_ui.run_if(resource_changed::<PitchMap>),
reassign_cell_pitch.run_if(resource_changed::<PitchMap>),
update_audio,
update_material,
),
)
// Debugging
.add_systems(
Update,
(
assert_cell_uniqueness.run_if(any_with_component::<Cell>),
grid_gizmo,
update_debug_info_cell_count,
update_debug_info_coordinates,
),
)
// Ui Controllers
.add_systems(
Update,
(
// TODO: Visualize audio play/mute buttons enable/disable
manage_interactive::<SimulationPlayback>,
// TODO Visualize simulation run/pause buttons enable/disable
manage_interactive::<AudioPlayback>,
update_note_interactivity,
),
)
.add_systems(Update, cell_lifecycle.run_if(on_message::<Lifecycle>))
.add_systems(Update, mouse_pan.run_if(on_message::<MouseMotion>))
.add_systems(Update, set_pan_state)
// Input handling
.add_systems(
Update,
(mouse_pan.run_if(on_message::<MouseMotion>), set_pan_state),
)
.run();
}
@ -353,81 +361,29 @@ fn primary_ui() -> impl Scene {
}
Children [
ui_button(UiButton::Power)
on(|_: On<Activate>, mut writer: MessageWriter<AppExit>| {
writer.write(AppExit::Success);
}),
ActionSource<Action>(Action::Quit),
ui_button(UiButton::Pause)
SimulationPlayback::Pause
on(|_: On<Activate>, mut next: ResMut<NextState<SimulationPlayback>>| {
// Set simulation state
next.set(SimulationPlayback::Pause);
}),
ActionSource<SimAction>(SimAction::Toggle),
ui_button(UiButton::Play)
SimulationPlayback::Run
on(|
_: On<Activate>,
mut next: ResMut<NextState<SimulationPlayback>>,
mut last_board_state: ResMut<LastBoardState>,
coordinates: Query<&Coordinates, With<Cell>>
| {
last_board_state.coordinates = coordinates.iter().cloned().collect();
// Set simulation state
next.set(SimulationPlayback::Run);
}),
ActionSource<SimAction>(SimAction::Toggle),
ui_button(UiButton::Step)
on(|_: On<Activate>, mut next: ResMut<NextState<SimulationPlayback>>| {
next.set(SimulationPlayback::Step);
}),
ActionSource<SimAction>(SimAction::Step),
ui_button(UiButton::Reset)
on(|
_: On<Activate>,
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()));
});
}),
ActionSource<SimAction>(SimAction::Reset),
ui_button(UiButton::Wipe)
on(|_: On<Activate>, cells: Query<Entity, With<Cell>>, mut commands: Commands| {
cells.iter().for_each(|e| {
commands.entity(e).despawn();
});
}),
ActionSource<SimAction>(SimAction::Wipe),
ui_button(UiButton::ZoomOut)
on(|_: On<Activate>, mut projection: Query<&mut Projection>| {
projection.iter_mut().for_each(|mut p| match *p {
Projection::Orthographic(ref mut o) => {
o.scale /= 0.5;
}
_ => todo!(),
});
}),
ActionSource<ZoomAction>(ZoomAction::ZoomOut),
ui_button(UiButton::ZoomIn)
on(|_: On<Activate>, mut projection: Query<&mut Projection>| {
projection.iter_mut().for_each(|mut p| match *p {
Projection::Orthographic(ref mut o) => {
o.scale *= 0.5;
}
_ => todo!(),
});
}),
ActionSource<ZoomAction>(ZoomAction::ZoomIn),
ui_button(UiButton::MuteAudio)
AudioPlayback::Mute
on(|_: On<Activate>, mut next: ResMut<NextState<AudioPlayback>>| {
next.set(AudioPlayback::Mute);
}),
ActionSource<AudioAction>(AudioAction::Toggle),
ui_button(UiButton::PlayAudio)
AudioPlayback::Play
on(|_: On<Activate>, mut next: ResMut<NextState<AudioPlayback>>| {
next.set(AudioPlayback::Play);
}),
ActionSource<AudioAction>(AudioAction::Toggle),
]
],
subpane()
@ -982,21 +938,6 @@ fn load_audio_tones(mut pitches: ResMut<Assets<Pitch>>, mut cell_assets: ResMut<
debug_assert_eq!(cell_assets.pitches.iter().len(), 132);
}
// When volume is toggled on/off make the following updates:
// * AudioPlayback state is flipped
fn toggle_audio_state(
mut next_state: ResMut<NextState<AudioPlayback>>,
curr_state: Res<State<AudioPlayback>>,
) {
// Set next audio playback state
next_state.set(
match curr_state.get() {
AudioPlayback::Play => AudioPlayback::Mute,
AudioPlayback::Mute => AudioPlayback::Play,
}
);
}
fn control_volume(
audio_state: Res<State<AudioPlayback>>,
simulation_state: Res<State<SimulationPlayback>>,
@ -1116,7 +1057,7 @@ fn create_light_theme() -> ThemeProps {
)
}
fn toggle_interactive<T: Component + States + PartialEq>(
fn manage_interactive<T: Component + States + PartialEq>(
q: Query<(Entity, &T)>,
s: Res<State<T>>,
mut commands: Commands,
@ -1244,3 +1185,8 @@ fn update_note_interactivity(
});
}
}
// Sets the sim state to paused
fn pause_simulation(mut next: ResMut<NextState<SimulationPlayback>>) {
next.set(SimulationPlayback::Pause)
}

Loading…
Cancel
Save