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