@ -17,6 +17,8 @@
//! Grid,
//! #[action_kind(axis)] // carries a Vec2
//! Move,
//! #[action_kind(scalar)] // carries an f32, from a slider
//! Volume,
//! }
//!
//! App::new()
@ -34,7 +36,10 @@
//! // `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));
//! .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>>) {
@ -44,18 +49,28 @@
//! }
//! }
//!
//! 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 -> Toggle
//! // 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`]. [`ActionState::value`] reads
//! whichever bucket the action's kind selects and returns a unified
//! [`ActionValue`], so every downstream read is kind-agnostic.
//! 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
//!
@ -63,16 +78,17 @@
//! 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 [`Action Value::is_active`]: `true` for
//! a fired instant, an on toggle, **or a deflected axis**. Reach for it when the
//! fact that the action is active is all the system needs — including "run every
//! frame the stick is being moved ".
//! can only return `bool`, so this reports [`Action State::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`], or a toggle's bool to store). The consumer
//! pulls its type out with [`ActionValue::as_axis`] / [`ActionValue::as_bool`],
//! or use the typed [`axis_value`] / [`toggle_value`] helpers to pipe a bare
//! `Vec2` / `bool`.
//! **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
@ -88,17 +104,28 @@ use bevy::{
ui_widgets ::{ Activate , Checkbox , SetChecked , ValueChange } ,
} ;
/// How an action behaves: momentary, a persistent on/off toggle, or an analog
/// 2D axis (e.g. a gamepad stick).
/// 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). Read the value with [`ActionState::axis`].
/// 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
@ -106,45 +133,62 @@ pub enum ActionKind {
///
/// Digital kinds ([`Instant`](ActionKind::Instant)/[`Toggle`](ActionKind::Toggle))
/// produce [`Bool`](Self::Bool); [`Axis`](ActionKind::Axis) produces
/// [`Axis`](Self::Axis). A consumer that knows the kind it wants pulls the payload
/// out with [`as_bool`](Self::as_bool) or [`as_axis`](Self::as_axis).
/// [`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 action is "active": a `true` bool, or a non-zero axis. This is
/// what [`on_action`] gates on, so a system can run "while the stick is moved"
/// exactly as it runs "while the toggle is on".
/// 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. An axis collapses to whether it is deflected, so a
/// bool consumer can read an axis action too.
/// The digital payload. An alog 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 analog payload. A digital value maps to [`Vec2::ZERO`] when off; a
/// `true` bool has no direction and also yields [`Vec2::ZERO`] .
/// 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 ( _ ) = > Vec2 ::ZERO ,
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)]` or `#[action_kind(toggle)]`.
/// `#[action_kind( ...)]` — `instant`, `toggle`, `axis`, or `scalar `.
///
/// ```rust,ignore
/// #[derive(Copy, Clone, Eq, PartialEq, Hash, GameAction)]
@ -165,7 +209,8 @@ pub use engine_macros::GameAction;
/// identifiers. `kind` is the only thing the engine needs from the game.
pub trait GameAction : Copy + Eq + Hash + Send + Sync + ' static {
/// Returns whether this action is [`Instant`](ActionKind::Instant),
/// [`Toggle`](ActionKind::Toggle), or [`Axis`](ActionKind::Axis).
/// [`Toggle`](ActionKind::Toggle), [`Axis`](ActionKind::Axis), or
/// [`Scalar`](ActionKind::Scalar).
fn kind ( self ) -> ActionKind ;
}
@ -248,16 +293,20 @@ impl<T: GameAction> InputMap<T> {
/// Populated by the engine each frame from the [`InputMap`] and UI widgets.
/// Read it kind-agnostically via [`value`](Self::value) (behind [`on_action`] and
/// [`action_value`]), or reach for a specific bucket with [`axis`](Self::axis) /
/// [`enabled`](Self::enabled). Games may also drive actions programmatically with
/// [`press`](Self::press), [`set`](Self::set), and [`toggle`](Self::toggle).
/// [`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 actions active this frame; cleared at the start of every frame.
/// 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 > {
@ -266,6 +315,7 @@ impl<T> Default for ActionState<T> {
momentary : HashSet ::default ( ) ,
enabled : HashSet ::default ( ) ,
axes : HashMap ::default ( ) ,
scalars : HashMap ::default ( ) ,
}
}
}
@ -273,7 +323,8 @@ impl<T> Default for ActionState<T> {
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.
/// [`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.
@ -282,16 +333,27 @@ impl<T: GameAction> ActionState<T> {
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: an [`Instant`](ActionKind::Instant)
/// action fired this frame, a [`Toggle`](ActionKind::Toggle) that is on, or an
/// [`Axis`](ActionKind::Axis) that is deflected (non-zero).
/// Whether `action` is currently active — what [`on_action`] gates on.
///
/// Shorthand for `self.value(action).is_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 {
self . value ( action ) . is_active ( )
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.
@ -305,6 +367,13 @@ impl<T: GameAction> ActionState<T> {
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 ) ;
@ -330,15 +399,23 @@ impl<T: GameAction> ActionState<T> {
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)) or `Checkbox`
/// ([`Toggle`](ActionKind::Toggle)) — the engine wires the widget's events into
/// [`ActionState`] and keeps checkboxes visually in sync.
#[ derive(Component, Copy, Clone, Debug) ]
pub struct ActionSource < T : GameAction > ( pub T ) ;
/// 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.
#[ derive(Component, Copy, Clone, Debug, Default) ]
pub struct ActionSource < T : GameAction + Default > ( pub T ) ;
/// The two symmetrical entry points below let any system consume any action:
///
@ -348,14 +425,17 @@ pub struct ActionSource<T: GameAction>(pub T);
/// Both accept an action of any [`ActionKind`]; the value type is unified by
/// [`ActionValue`].
/// Run condition that is active while `action` is active, for **any** kind: a
/// `true` bool (instant fired / toggle on) or a non-zero axis (stick deflected).
/// 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" and "run while the stick is moved".
/// 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, 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 )
@ -402,6 +482,15 @@ pub fn toggle_value<T: GameAction>(action: T) -> impl FnMut(Res<ActionState<T>>)
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
@ -463,6 +552,8 @@ fn hardware_to_actions<T: GameAction>(
}
}
}
// Scalar actions come only from UI sliders, never from a binding.
ActionKind ::Scalar = > { }
}
}
}
@ -479,7 +570,7 @@ fn button_to_action<T: GameAction>(
// 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 ::Axis | ActionKind ::Scalar => { }
}
}
}
@ -497,6 +588,30 @@ fn checkbox_to_action<T: GameAction>(
}
}
/// 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 > (
@ -511,7 +626,10 @@ fn sync_checkboxes<T: GameAction>(
if let ActionKind ::Toggle = action . kind ( ) {
let want = state . enabled ( * action ) ;
if want ! = is_checked {
commands . trigger ( SetChecked { entity , checked : want } ) ;
commands . trigger ( SetChecked {
entity ,
checked : want ,
} ) ;
}
}
}
@ -558,6 +676,7 @@ impl<T: GameAction> Plugin for ActionsPlugin<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 ( checkbox_to_action ::< T > )
. add_observer ( slider_to_action ::< T > ) ;
}
}