You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

683 lines
27 KiB
Rust

//! Abstract, remappable game actions that unify keyboard, gamepad, and UI input.
//!
//! Games define their own action enum, then either gate a system on an action
//! ([`on_action`]) or pipe an action's payload into one ([`axis_value`]) —
//! regardless of whether the trigger was a keyboard, a gamepad, or a UI widget:
//!
//! ```rust,ignore
//! use engine::actions::*;
//! use engine::*; // bevy prelude
//!
//! // 1. Your game's actions, with each variant's kind declared inline.
//! #[derive(Copy, Clone, Eq, PartialEq, Hash, GameAction)]
//! enum Action {
//! #[action_kind(instant)] // fires for one frame
//! Jump,
//! #[action_kind(toggle)] // stays on/off
//! Grid,
//! #[action_kind(axis)] // carries a Vec2
//! Move,
//! #[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.
use core::hash::Hash;
use core::marker::PhantomData;
use bevy::{
platform::collections::{HashMap, HashSet},
prelude::*,
ui::Checked,
ui_widgets::{Activate, Checkbox, SetChecked, ValueChange},
};
/// How an action behaves.
///
/// 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`.
///
/// ```rust,ignore
/// #[derive(Copy, Clone, Eq, PartialEq, Hash, GameAction)]
/// enum Action {
/// #[action_kind(instant)]
/// Jump,
/// #[action_kind(toggle)]
/// Grid,
/// }
/// ```
pub use engine_macros::GameAction;
/// Implemented by a game's action enum to describe each action's behavior.
///
/// Usually derived — see the [`GameAction`](macro@GameAction) derive macro.
///
/// The bounds mirror [`ButtonInput`]'s key type: actions must be cheap, hashable
/// identifiers. `kind` is the only thing the engine needs from the game.
pub trait GameAction: Copy + Eq + Hash + Send + Sync + 'static {
/// Returns whether this action is [`Instant`](ActionKind::Instant),
/// [`Toggle`](ActionKind::Toggle), [`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) is analog (for [`Axis`](ActionKind::Axis)
/// actions).
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Reflect)]
pub enum Binding {
/// A keyboard key.
Key(KeyCode),
/// A mouse button.
Mouse(MouseButton),
/// A gamepad button (matched across all connected gamepads).
Gamepad(GamepadButton),
/// A gamepad analog source producing a [`Vec2`] (matched across all connected
/// gamepads; deadzone already applied by Bevy).
Stick(Stick),
}
/// An analog 2D source on a gamepad.
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Reflect)]
pub enum Stick {
/// The left analog stick.
Left,
/// The right analog stick.
Right,
/// The directional pad, reported as a [`Vec2`].
DPad,
}
/// Remappable map from actions to the physical inputs that trigger them.
///
/// This is a [`Resource`]; mutate it at runtime to rebind controls (e.g. from a
/// settings menu). One action may have many bindings and they are additive.
#[derive(Resource)]
pub struct InputMap<T> {
bindings: HashMap<T, Vec<Binding>>,
}
impl<T> Default for InputMap<T> {
fn default() -> Self {
Self {
bindings: HashMap::default(),
}
}
}
impl<T: Clone> Clone for InputMap<T> {
fn clone(&self) -> Self {
Self {
bindings: self.bindings.clone(),
}
}
}
impl<T: GameAction> InputMap<T> {
/// Adds one or more bindings for `action`. Additive: existing bindings are
/// kept, so repeated calls accumulate.
pub fn bind(&mut self, action: T, bindings: impl IntoIterator<Item = Binding>) -> &mut Self {
self.bindings.entry(action).or_default().extend(bindings);
self
}
/// Replaces all bindings for `action` with `bindings` (for rebinding UIs).
pub fn set_bindings(&mut self, action: T, bindings: impl IntoIterator<Item = Binding>) {
self.bindings.insert(action, bindings.into_iter().collect());
}
/// The bindings currently mapped to `action`, if any.
pub fn bindings(&self, action: T) -> Option<&[Binding]> {
self.bindings.get(&action).map(Vec::as_slice)
}
}
/// The unified, source-agnostic state of every action for the current frame.
///
/// Populated by the engine each frame from the [`InputMap`] and UI widgets.
/// Read it kind-agnostically via [`value`](Self::value) (behind [`on_action`] and
/// [`action_value`]), or reach for a specific bucket with [`axis`](Self::axis) /
/// [`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.
#[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:
///
/// * 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>,
map: Res<InputMap<T>>,
mut state: ResMut<ActionState<T>>,
) {
for (&action, bindings) in &map.bindings {
match action.kind() {
// Analog: pick the most-deflected reading across sticks/gamepads and
// store it every frame (zero when nothing is connected or centered).
ActionKind::Axis => {
let mut value = Vec2::ZERO;
for binding in bindings {
let Binding::Stick(stick) = binding else {
continue;
};
for pad in &pads {
let reading = match stick {
Stick::Left => pad.left_stick(),
Stick::Right => pad.right_stick(),
Stick::DPad => pad.dpad(),
};
if reading.length_squared() > value.length_squared() {
value = reading;
}
}
}
state.set_axis(action, value);
}
// Digital: `break` on the first firing binding avoids double-toggling
// when two bound inputs are pressed on the same frame.
kind @ (ActionKind::Instant | ActionKind::Toggle) => {
for binding in bindings {
let just_pressed = match binding {
Binding::Key(k) => keys.just_pressed(*k),
Binding::Mouse(m) => mouse.just_pressed(*m),
Binding::Gamepad(g) => pads.iter().any(|pad| pad.just_pressed(*g)),
Binding::Stick(_) => false, // not meaningful for digital actions
};
if just_pressed {
match kind {
ActionKind::Instant => state.press(action),
_ => state.toggle(action),
}
break;
}
}
}
// 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 everything needed for action `T`: the [`ActionState`] and
/// [`InputMap`] resources, the hardware/UI fan-in, and checkbox syncing.
///
/// Build it with [`new`](Self::new) and [`bind`](Self::bind); one instance per
/// action type.
pub struct ActionsPlugin<T> {
map: InputMap<T>,
_marker: PhantomData<T>,
}
impl<T: GameAction> Default for ActionsPlugin<T> {
fn default() -> Self {
Self {
map: InputMap::default(),
_marker: PhantomData,
}
}
}
impl<T: GameAction> ActionsPlugin<T> {
/// Creates a plugin with no bindings.
pub fn new() -> Self {
Self::default()
}
/// Adds one or more default bindings for `action` (chainable). Games can
/// still rebind at runtime by mutating the [`InputMap`] resource.
pub fn bind(mut self, action: T, bindings: impl IntoIterator<Item = Binding>) -> Self {
self.map.bind(action, bindings);
self
}
}
impl<T: GameAction> Plugin for ActionsPlugin<T> {
fn build(&self, app: &mut App) {
app.init_resource::<ActionState<T>>()
.insert_resource(self.map.clone())
.add_systems(First, clear_momentary::<T>)
.add_systems(PreUpdate, hardware_to_actions::<T>)
.add_systems(Update, sync_checkboxes::<T>)
.add_observer(button_to_action::<T>)
.add_observer(checkbox_to_action::<T>)
.add_observer(slider_to_action::<T>);
}
}