Skip to main content

repame_input/
state.rs

1//! Per-tick action state: levels, edges, consumption, mocking.
2
3use std::collections::{HashMap, HashSet};
4
5use bevy_ecs::prelude::*;
6use repose_core::input::{GamepadAxis, GamepadButton, PointerButton};
7use repose_core::shortcuts::KeyChord;
8
9use super::ActionLike;
10use super::binding::Binding;
11use super::map::ActionMap;
12
13/// Sim-side gameplay input: fed from repose events each frame, read by
14/// fixed-step systems each tick. Edges (`just_pressed`/`just_released`)
15/// clear at the END of each tick ([`end_tick`](Self::end_tick),
16/// registered last), so compose-fed events survive until the tick's
17/// systems run. Frame-scope readers call
18/// [`clear_edges`](Self::clear_edges) after consuming.
19#[derive(Resource, Debug)]
20pub struct ActionState<A: ActionLike> {
21    map: ActionMap<A>,
22    pressed: HashSet<A>,
23    just_pressed: HashSet<A>,
24    just_released: HashSet<A>,
25    consumed: HashSet<A>,
26    axes: HashMap<GamepadAxis, f32>,
27    active_contexts: HashSet<String>,
28    /// Currently-held button bindings (key/mouse/pad). Releases only drop
29    /// the action when *no* binding for it remains down (OR semantics).
30    down_buttons: HashSet<Binding>,
31}
32
33impl<A: ActionLike> ActionState<A> {
34    pub fn new(map: ActionMap<A>) -> Self {
35        Self {
36            map,
37            pressed: HashSet::new(),
38            just_pressed: HashSet::new(),
39            just_released: HashSet::new(),
40            consumed: HashSet::new(),
41            axes: HashMap::new(),
42            active_contexts: HashSet::new(),
43            down_buttons: HashSet::new(),
44        }
45    }
46
47    pub fn map(&self) -> &ActionMap<A> {
48        &self.map
49    }
50
51    /// Edge rollover for the tick end: edges clear, levels persist.
52    /// Frame-scope readers use [`clear_edges`](Self::clear_edges) instead.
53    pub fn end_tick(&mut self) {
54        self.just_pressed.clear();
55        self.just_released.clear();
56    }
57
58    /// Clear edges outside the schedule (after frame-scope consumption).
59    pub fn clear_edges(&mut self) {
60        self.end_tick();
61    }
62
63    /// Switch the active context set (phase gating, e.g. menu vs play).
64    /// Empty set with contexts defined means only context-free actions fire.
65    /// Pending edges drop only when the set actually changes: re-setting
66    /// the same contexts every frame (the normal pump pattern) keeps them.
67    /// Levels (`pressed`) always track hardware truthfully.
68    pub fn set_contexts(&mut self, contexts: &[&str]) {
69        let next: HashSet<String> = contexts.iter().map(|s| s.to_string()).collect();
70        if next != self.active_contexts {
71            self.active_contexts = next;
72            self.just_pressed.clear();
73            self.just_released.clear();
74        }
75    }
76
77    fn live(&self, action: &A) -> bool {
78        self.map.live_in(action, &self.active_contexts) && !self.consumed.contains(action)
79    }
80
81    fn press(&mut self, action: &A) {
82        if self.pressed.insert(action.clone()) {
83            self.just_pressed.insert(action.clone());
84        }
85    }
86
87    fn release(&mut self, action: &A) {
88        if self.pressed.remove(action) {
89            self.just_released.insert(action.clone());
90        }
91    }
92
93    fn fire_binding(&mut self, binding: &Binding, down: bool) {
94        match binding {
95            Binding::Key(_) | Binding::Mouse(_) | Binding::Pad(_) => {
96                if down {
97                    self.down_buttons.insert(binding.clone());
98                } else {
99                    self.down_buttons.remove(binding);
100                }
101            }
102            Binding::Axis { .. } => {}
103        }
104        let actions: Vec<A> = self
105            .map
106            .actions()
107            .filter(|a| self.map.bindings_for(a).contains(binding))
108            .cloned()
109            .collect();
110        for action in actions {
111            if self.action_down(&action) {
112                self.press(&action);
113            } else {
114                self.release(&action);
115            }
116        }
117    }
118
119    fn binding_down(&self, binding: &Binding) -> bool {
120        match binding {
121            Binding::Key(_) | Binding::Mouse(_) | Binding::Pad(_) => {
122                self.down_buttons.contains(binding)
123            }
124            Binding::Axis { axis, threshold } => {
125                Binding::axis_active(*threshold, self.axis_value(*axis))
126            }
127        }
128    }
129
130    fn action_down(&self, action: &A) -> bool {
131        self.map
132            .bindings_for(action)
133            .iter()
134            .any(|b| self.binding_down(b))
135    }
136
137    /// Feed a keyboard chord press/release from the runner.
138    pub fn key(&mut self, chord: &KeyChord, down: bool) {
139        self.fire_binding(&Binding::Key(chord.clone()), down);
140    }
141
142    /// Feed a mouse button press/release.
143    pub fn mouse(&mut self, button: PointerButton, down: bool) {
144        self.fire_binding(&Binding::Mouse(button), down);
145    }
146
147    /// Feed a gamepad button press/release.
148    pub fn pad(&mut self, button: GamepadButton, down: bool) {
149        self.fire_binding(&Binding::Pad(button), down);
150    }
151
152    /// Feed an axis value; threshold bindings flip on crossing.
153    /// Re-evaluates every action bound to this axis with OR semantics, so
154    /// a second binding holding the action keeps it pressed.
155    pub fn axis(&mut self, axis: GamepadAxis, value: f32) {
156        self.axes.insert(axis, value);
157        let actions: Vec<A> = self
158            .map
159            .actions()
160            .filter(|a| {
161                self.map.bindings_for(a).iter().any(|b| match b {
162                    Binding::Axis { axis: ba, .. } => *ba == axis,
163                    _ => false,
164                })
165            })
166            .cloned()
167            .collect();
168        for action in actions {
169            if self.action_down(&action) {
170                self.press(&action);
171            } else {
172                self.release(&action);
173            }
174        }
175    }
176
177    /// Latest raw value fed for an axis (`0.0` if never fed).
178    ///
179    /// This is the raw deflection in `-1..1` (sticks) or `0..1` (triggers),
180    /// with no threshold or deadzone applied. Use [`ActionState::strength`]
181    /// for the per-action intensity with thresholds applied.
182    pub fn axis_value(&self, axis: GamepadAxis) -> f32 {
183        self.axes.get(&axis).copied().unwrap_or(0.0)
184    }
185
186    /// Intensity of an action, from `0.0` (inactive) to `1.0` (fully held).
187    ///
188    /// For button bindings (key, mouse, pad) the value is `0` or `1`. For
189    /// axis bindings it is the stick deflection remapped from
190    /// `threshold..1` to `0..1` (radial deadzone, leafwing feel), so the
191    /// further the stick is pushed past its threshold, the closer the
192    /// value is to `1`. When several bindings drive one action,
193    /// the strongest active binding wins.
194    ///
195    /// Returns `0.0` while the action is gated out by its context or has
196    /// been [`consumed`](ActionState::consume), matching
197    /// [`pressed`](ActionState::pressed).
198    ///
199    /// In most cases where you need a direction (twin-stick movement,
200    /// menus), use [`vector`](ActionState::vector) instead of combining
201    /// strengths by hand.
202    pub fn strength(&self, action: &A) -> f32 {
203        if !self.live(action) {
204            return 0.0;
205        }
206        let mut best = 0.0f32;
207        for b in self.map.bindings_for(action) {
208            let s = match b {
209                Binding::Key(_) | Binding::Mouse(_) | Binding::Pad(_) => {
210                    if self.binding_down(b) {
211                        1.0
212                    } else {
213                        0.0
214                    }
215                }
216                Binding::Axis { axis, threshold } => {
217                    let v = self.axis_value(*axis);
218                    if !Binding::axis_active(*threshold, v) {
219                        0.0
220                    } else {
221                        // Radial deadzone remap t..1 → 0..1 (leafwing feel).
222                        let t = threshold.abs().clamp(0.0, 0.95);
223                        let a = v.abs();
224                        if a <= t {
225                            0.0
226                        } else {
227                            ((a - t) / (1.0 - t)).clamp(0.0, 1.0).max(1e-6)
228                        }
229                    }
230                }
231            };
232            best = best.max(s);
233        }
234        best.clamp(0.0, 1.0)
235    }
236
237    /// Movement vector from four actions: `(x, y)` with
238    /// `x = strength(pos_x) - strength(neg_x)` and
239    /// `y = strength(pos_y) - strength(neg_y)`.
240    ///
241    /// This is useful for vector input such as a joystick, d-pad, arrows,
242    /// or WASD driving movement. The result has a circular deadzone and its
243    /// length limited to `1`, so diagonal input never moves faster than
244    /// axis-aligned input:
245    ///
246    /// - Lengths below `deadzone` (range `0..1`) snap to `(0, 0)`, which
247    ///   keeps a resting stick from drifting the player. Use `0.2` for a
248    ///   standard stick feel, `0.0` to disable.
249    /// - Lengths above `1` (opposite pairs can't exceed this, but two
250    ///   full-strength diagonals can in theory) are normalized back to `1`.
251    ///
252    /// **Note:** `y` follows screen convention (`pos_y` is down-positive).
253    /// For world-up movement, negate `y` or swap the pair at the call site.
254    ///
255    /// ```ignore
256    /// let (x, y) = state.vector(&MoveLeft, &MoveRight, &MoveUp, &MoveDown, 0.2);
257    /// player.vel = Vec2::new(x, y) * SPEED;
258    /// ```
259    pub fn vector(&self, neg_x: &A, pos_x: &A, neg_y: &A, pos_y: &A, deadzone: f32) -> (f32, f32) {
260        let mut x = self.strength(pos_x) - self.strength(neg_x);
261        let mut y = self.strength(pos_y) - self.strength(neg_y);
262        let len = (x * x + y * y).sqrt();
263        if len < deadzone.max(0.0) {
264            return (0.0, 0.0);
265        }
266        if len > 1.0 {
267            x /= len;
268            y /= len;
269        }
270        (x, y)
271    }
272
273    /// Held (and live in the active context, not consumed).
274    ///
275    /// Returns `true` for every tick while any of the action's bindings is
276    /// held down. Actions gated out by [`set_contexts`](ActionState::set_contexts)
277    /// or hidden by [`consume`](ActionState::consume) read as released here
278    /// even while the hardware is held. The level below keeps tracking the
279    /// hardware truthfully, so re-entering the context (or
280    /// [`clear_consumed`](ActionState::clear_consumed)) reports held again
281    /// without a new press.
282    pub fn pressed(&self, action: &A) -> bool {
283        self.pressed.contains(action) && self.live(action)
284    }
285
286    /// Started this tick (and live in the active context, not consumed).
287    ///
288    /// Returns `true` only on the tick the action was pressed: the edge is
289    /// set by the press event and cleared at the end of the tick by
290    /// [`end_tick`](ActionState::end_tick), so holding the button does not
291    /// re-trigger it. If several ticks run in one frame, only systems in
292    /// the first tick observe the edge.
293    pub fn just_pressed(&self, action: &A) -> bool {
294        self.just_pressed.contains(action) && self.live(action)
295    }
296
297    /// Released this tick (and live in the active context, not consumed).
298    /// Same gating as `pressed`/`just_pressed`, so context-gated and
299    /// UI-consumed releases never leak to late readers.
300    ///
301    /// Returns `true` only on the tick the last held binding was released
302    /// (see the OR release rule in the module docs). Like `just_pressed`,
303    /// the edge clears at [`end_tick`](ActionState::end_tick).
304    pub fn just_released(&self, action: &A) -> bool {
305        self.just_released.contains(action) && self.live(action)
306    }
307
308    /// Swallow an action so later readers skip it (UI consumed the click).
309    /// Returns true while the action is held.
310    pub fn consume(&mut self, action: &A) -> bool {
311        let held = self.pressed.contains(action);
312        self.consumed.insert(action.clone());
313        held
314    }
315
316    pub fn consume_all(&mut self) {
317        self.consumed.extend(self.pressed.iter().cloned());
318    }
319
320    /// Drive an action without hardware (cutscenes, AI, tests).
321    pub fn mock(&mut self, action: &A, down: bool) {
322        if down {
323            self.press(action);
324        } else {
325            self.release(action);
326        }
327    }
328
329    /// Clear consumption (fresh frame handoff from UI).
330    pub fn clear_consumed(&mut self) {
331        self.consumed.clear();
332    }
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338    use repose_core::input::{Key, Modifiers};
339
340    fn jump_map() -> ActionMap<&'static str> {
341        let mut map = ActionMap::new();
342        map.bind(
343            "jump",
344            Binding::Key(KeyChord::new(Key::Space, Modifiers::default())),
345        );
346        map.bind("jump", Binding::Pad(GamepadButton::South));
347        map
348    }
349
350    fn space() -> KeyChord {
351        KeyChord::new(Key::Space, Modifiers::default())
352    }
353
354    #[test]
355    fn edges_clear_at_tick_end() {
356        let mut st = ActionState::new(jump_map());
357        st.key(&space(), true);
358        assert!(st.just_pressed(&"jump"));
359        assert!(st.pressed(&"jump"));
360        // Edge survives (no schedule ran yet); systems read, then clear.
361        assert!(st.just_pressed(&"jump"));
362        st.end_tick();
363        assert!(!st.just_pressed(&"jump"));
364        assert!(st.pressed(&"jump"));
365        st.key(&space(), false);
366        assert!(st.just_released(&"jump"));
367        assert!(!st.pressed(&"jump"));
368        st.end_tick();
369        assert!(!st.just_released(&"jump"));
370    }
371
372    #[test]
373    fn pad_and_key_drive_same_action() {
374        let mut st = ActionState::new(jump_map());
375        st.pad(GamepadButton::South, true);
376        assert!(st.just_pressed(&"jump"));
377        st.end_tick();
378        st.pad(GamepadButton::South, false);
379        st.end_tick();
380        st.key(&space(), true);
381        assert!(st.just_pressed(&"jump"));
382    }
383
384    #[test]
385    fn axis_threshold_flips() {
386        let mut map = ActionMap::new();
387        map.bind(
388            "left",
389            Binding::Axis {
390                axis: GamepadAxis::LeftStickX,
391                threshold: -0.5,
392            },
393        );
394        let mut st = ActionState::new(map);
395        st.axis(GamepadAxis::LeftStickX, -0.2);
396        assert!(!st.pressed(&"left"));
397        st.axis(GamepadAxis::LeftStickX, -0.8);
398        assert!(st.just_pressed(&"left"));
399        assert_eq!(st.axis_value(GamepadAxis::LeftStickX), -0.8);
400        st.axis(GamepadAxis::LeftStickX, 0.0);
401        assert!(st.just_released(&"left"));
402    }
403
404    #[test]
405    fn consume_hides_from_late_readers() {
406        let mut st = ActionState::new(jump_map());
407        st.key(&space(), true);
408        assert!(st.consume(&"jump"));
409        assert!(!st.pressed(&"jump"));
410        assert!(!st.just_pressed(&"jump"));
411        st.clear_consumed();
412        assert!(st.pressed(&"jump"));
413    }
414
415    #[test]
416    fn context_gates_sim_action() {
417        let mut map = jump_map();
418        map.in_context("gameplay", "jump");
419        let mut st = ActionState::new(map);
420        st.set_contexts(&["menu"]);
421        st.key(&space(), true);
422        assert!(!st.pressed(&"jump"));
423        st.set_contexts(&["gameplay"]);
424        st.key(&space(), true);
425        assert!(st.pressed(&"jump"));
426    }
427
428    #[test]
429    fn context_switch_drops_pending_edges() {
430        let mut map = jump_map();
431        map.in_context("gameplay", "jump");
432        let mut st = ActionState::new(map);
433        st.set_contexts(&["gameplay"]);
434        st.key(&space(), true);
435        assert!(st.just_pressed(&"jump"));
436        st.set_contexts(&["menu"]);
437        assert!(!st.just_pressed(&"jump"));
438        assert!(!st.pressed(&"jump"));
439        st.key(&space(), false);
440        st.set_contexts(&["gameplay"]);
441        assert!(!st.just_released(&"jump"));
442    }
443
444    #[test]
445    fn mock_drives_without_hardware() {
446        let mut st = ActionState::new(jump_map());
447        st.mock(&"jump", true);
448        assert!(st.just_pressed(&"jump"));
449        st.mock(&"jump", false);
450        assert!(st.just_released(&"jump"));
451    }
452
453    #[test]
454    fn multi_binding_release_is_or() {
455        let mut st = ActionState::new(jump_map());
456        st.key(&space(), true);
457        st.pad(GamepadButton::South, true);
458        assert!(st.pressed(&"jump"));
459        st.end_tick();
460        st.key(&space(), false);
461        assert!(st.pressed(&"jump"), "South still held");
462        assert!(!st.just_released(&"jump"), "no release while OR-held");
463        st.pad(GamepadButton::South, false);
464        assert!(!st.pressed(&"jump"));
465        assert!(st.just_released(&"jump"));
466    }
467
468    #[test]
469    fn consume_hides_release_too() {
470        let mut st = ActionState::new(jump_map());
471        st.key(&space(), true);
472        st.end_tick();
473        st.key(&space(), false);
474        assert!(st.just_released(&"jump"));
475        st.consume(&"jump");
476        assert!(!st.just_released(&"jump"));
477    }
478
479    #[test]
480    fn strength_and_vector_behaviour() {
481        use repose_core::input::Key;
482        let mut map = ActionMap::new();
483        map.bind(
484            "right",
485            Binding::Axis {
486                axis: GamepadAxis::LeftStickX,
487                threshold: 0.2,
488            },
489        );
490        map.bind(
491            "left",
492            Binding::Axis {
493                axis: GamepadAxis::LeftStickX,
494                threshold: -0.2,
495            },
496        );
497        map.bind(
498            "jump",
499            Binding::Key(KeyChord::new(Key::Space, Modifiers::default())),
500        );
501        let mut st = ActionState::new(map);
502        // Rest: all zero.
503        assert_eq!(st.strength(&"right"), 0.0);
504        assert_eq!(st.vector(&"left", &"right", &"jump", &"jump", 0.2), (0.0, 0.0));
505        // Half deflection right: deadzone-remapped strength.
506        st.axis(GamepadAxis::LeftStickX, 0.5);
507        assert!((st.strength(&"right") - 0.375).abs() < 1e-6);
508        assert_eq!(st.strength(&"left"), 0.0);
509        let (x, y) = st.vector(&"left", &"right", &"jump", &"jump", 0.2);
510        assert!((x - 0.375).abs() < 1e-6 && y.abs() < 1e-6);
511        // Vector deadzone snaps small-but-active sticks to zero.
512        assert_eq!(st.vector(&"left", &"right", &"jump", &"jump", 0.6), (0.0, 0.0));
513        // Below deadzone the vector snaps to zero even if bound.
514        st.axis(GamepadAxis::LeftStickX, 0.1);
515        assert_eq!(st.strength(&"right"), 0.0);
516        // Button: full strength.
517        st.key(&space(), true);
518        assert_eq!(st.strength(&"jump"), 1.0);
519    }
520}