From 40b3f2adfc60ce37e35c37c12cf8634260670390 Mon Sep 17 00:00:00 2001 From: Elijah Voigt Date: Thu, 16 Jul 2026 10:07:59 -0700 Subject: [PATCH] Add Scalar action kind for UI sliders Adds a fourth ActionKind, Scalar, carrying an f32 from a slider's ValueChange. 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. - #[action_kind(scalar)] added to the derive macro. Co-Authored-By: Claude Opus 4.8 --- engine/macros/src/lib.rs | 7 +- engine/src/actions.rs | 219 ++++++++++++++++++++++++++++++--------- 2 files changed, 173 insertions(+), 53 deletions(-) diff --git a/engine/macros/src/lib.rs b/engine/macros/src/lib.rs index 8ac039e..d59a13b 100644 --- a/engine/macros/src/lib.rs +++ b/engine/macros/src/lib.rs @@ -10,8 +10,8 @@ use syn::{spanned::Spanned, Data, DeriveInput, Fields, Ident}; /// Derives `engine::GameAction` for a fieldless enum. /// -/// Each variant must be annotated with `#[action_kind(instant)]`, -/// `#[action_kind(toggle)]`, or `#[action_kind(axis)]`: +/// Each variant must be annotated with `#[action_kind(...)]` — one of `instant`, +/// `toggle`, `axis`, or `scalar`: /// /// ```ignore /// #[derive(Copy, Clone, Eq, PartialEq, Hash, GameAction)] @@ -100,11 +100,12 @@ fn parse_kind( "instant" => quote!(#engine::ActionKind::Instant), "toggle" => quote!(#engine::ActionKind::Toggle), "axis" => quote!(#engine::ActionKind::Axis), + "scalar" => quote!(#engine::ActionKind::Scalar), other => { return Err(syn::Error::new( variant_name.span(), format!( - "unknown action kind `{other}`, expected `instant`, `toggle`, or `axis`" + "unknown action kind `{other}`, expected `instant`, `toggle`, `axis`, or `scalar`" ), )) } diff --git a/engine/src/actions.rs b/engine/src/actions.rs index 5b3dbbe..2787c4e 100644 --- a/engine/src/actions.rs +++ b/engine/src/actions.rs @@ -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, mut q: Query<&mut Transform, With>) { @@ -44,18 +49,28 @@ //! } //! } //! +//! fn set_volume(In(v): In, mut audio: ResMut) { 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 -> Toggle +//! // commands.spawn((slider(..), ActionSource(Action::Volume))); // ValueChange -> Scalar //! ``` //! //! ## How it works //! //! All input sources fan in to a single [`ActionState`] 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 [`ActionValue::is_active`]: `true` for -//! a fired instant, an on toggle, **or a deflected axis**. Reach for it when the -//! fact that the action is active is all the system needs — including "run every -//! frame the stick is being moved". +//! 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`. 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. 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 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 InputMap { /// 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 { - /// 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, /// Toggle actions currently switched on; persists across frames. enabled: HashSet, /// Axis actions and their current value; refreshed every frame. axes: HashMap, + /// Scalar (slider) actions and their latest value; persists across frames so + /// the current value is readable between changes. + scalars: HashMap, } impl Default for ActionState { @@ -266,6 +315,7 @@ impl Default for ActionState { momentary: HashSet::default(), enabled: HashSet::default(), axes: HashMap::default(), + scalars: HashMap::default(), } } } @@ -273,7 +323,8 @@ impl Default for ActionState { impl ActionState { /// 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 ActionState { 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 ActionState { 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 ActionState { 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(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(pub T); /// The two symmetrical entry points below let any system consume any action: /// @@ -348,14 +425,17 @@ pub struct ActionSource(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(action: T) -> impl FnMut(Res>) -> bool + Clone { move |state: Res>| state.active(action) @@ -402,6 +482,15 @@ pub fn toggle_value(action: T) -> impl FnMut(Res>) move |state: Res>| state.enabled(action) } +/// Typed convenience over [`action_value`] for scalar (slider) actions: pipes a +/// bare `f32`, so the consumer takes `In`. +/// +/// 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(action: T) -> impl FnMut(Res>) -> f32 + Clone { + move |state: Res>| 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( } } } + // Scalar actions come only from UI sliders, never from a binding. + ActionKind::Scalar => {} } } } @@ -479,7 +570,7 @@ fn button_to_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::Axis | ActionKind::Scalar => {} } } } @@ -497,6 +588,30 @@ fn checkbox_to_action( } } +/// UI fan-in: a `Slider`'s [`ValueChange`](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( + change: On>, + sources: Query<&ActionSource>, + mut state: ResMut>, +) { + 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( @@ -511,7 +626,10 @@ fn sync_checkboxes( 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 Plugin for ActionsPlugin { .add_systems(PreUpdate, hardware_to_actions::) .add_systems(Update, sync_checkboxes::) .add_observer(button_to_action::) - .add_observer(checkbox_to_action::); + .add_observer(checkbox_to_action::) + .add_observer(slider_to_action::); } }