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}
29
30impl<A: ActionLike> ActionState<A> {
31    pub fn new(map: ActionMap<A>) -> Self {
32        Self {
33            map,
34            pressed: HashSet::new(),
35            just_pressed: HashSet::new(),
36            just_released: HashSet::new(),
37            consumed: HashSet::new(),
38            axes: HashMap::new(),
39            active_contexts: HashSet::new(),
40        }
41    }
42
43    pub fn map(&self) -> &ActionMap<A> {
44        &self.map
45    }
46
47    /// Edge rollover for the tick end: edges clear, levels persist.
48    /// Frame-scope readers use [`clear_edges`](Self::clear_edges) instead.
49    pub fn end_tick(&mut self) {
50        self.just_pressed.clear();
51        self.just_released.clear();
52    }
53
54    /// Clear edges outside the schedule (after frame-scope consumption).
55    pub fn clear_edges(&mut self) {
56        self.end_tick();
57    }
58
59    /// Switch the active context set (phase gating, e.g. menu vs play).
60    /// Empty set with contexts defined means only context-free actions fire.
61    /// Pending edges drop only when the set actually changes: re-setting
62    /// the same contexts every frame (the normal pump pattern) keeps them.
63    /// Levels (`pressed`) always track hardware truthfully.
64    pub fn set_contexts(&mut self, contexts: &[&str]) {
65        let next: HashSet<String> = contexts.iter().map(|s| s.to_string()).collect();
66        if next != self.active_contexts {
67            self.active_contexts = next;
68            self.just_pressed.clear();
69            self.just_released.clear();
70        }
71    }
72
73    fn live(&self, action: &A) -> bool {
74        self.map.live_in(action, &self.active_contexts) && !self.consumed.contains(action)
75    }
76
77    fn press(&mut self, action: &A) {
78        if self.pressed.insert(action.clone()) {
79            self.just_pressed.insert(action.clone());
80        }
81    }
82
83    fn release(&mut self, action: &A) {
84        if self.pressed.remove(action) {
85            self.just_released.insert(action.clone());
86        }
87    }
88
89    fn fire_binding(&mut self, binding: &Binding, down: bool) {
90        let actions: Vec<A> = self
91            .map
92            .actions()
93            .filter(|a| self.map.bindings_for(a).contains(binding))
94            .cloned()
95            .collect();
96        for action in actions {
97            if down {
98                self.press(&action);
99            } else {
100                self.release(&action);
101            }
102        }
103    }
104
105    /// Feed a keyboard chord press/release from the runner.
106    pub fn key(&mut self, chord: &KeyChord, down: bool) {
107        self.fire_binding(&Binding::Key(chord.clone()), down);
108    }
109
110    /// Feed a mouse button press/release.
111    pub fn mouse(&mut self, button: PointerButton, down: bool) {
112        self.fire_binding(&Binding::Mouse(button), down);
113    }
114
115    /// Feed a gamepad button press/release.
116    pub fn pad(&mut self, button: GamepadButton, down: bool) {
117        self.fire_binding(&Binding::Pad(button), down);
118    }
119
120    /// Feed an axis value; threshold bindings flip on crossing.
121    pub fn axis(&mut self, axis: GamepadAxis, value: f32) {
122        self.axes.insert(axis, value);
123        let flips: Vec<(A, bool)> = self
124            .map
125            .actions()
126            .flat_map(|a| {
127                self.map
128                    .bindings_for(a)
129                    .iter()
130                    .filter_map(move |b| match b {
131                        Binding::Axis {
132                            axis: ba,
133                            threshold,
134                        } if *ba == axis => {
135                            Some((a.clone(), Binding::axis_active(*threshold, value)))
136                        }
137                        _ => None,
138                    })
139            })
140            .collect();
141        for (action, active) in flips {
142            if active {
143                self.press(&action);
144            } else {
145                self.release(&action);
146            }
147        }
148    }
149
150    /// Latest value for an axis (0.0 if never fed).
151    pub fn axis_value(&self, axis: GamepadAxis) -> f32 {
152        self.axes.get(&axis).copied().unwrap_or(0.0)
153    }
154
155    /// Held (and live in the active context, not consumed).
156    pub fn pressed(&self, action: &A) -> bool {
157        self.pressed.contains(action) && self.live(action)
158    }
159
160    /// Started this tick (and live in the active context, not consumed).
161    pub fn just_pressed(&self, action: &A) -> bool {
162        self.just_pressed.contains(action) && self.live(action)
163    }
164
165    /// Released this tick.
166    pub fn just_released(&self, action: &A) -> bool {
167        self.just_released.contains(action)
168    }
169
170    /// Swallow an action so later readers skip it (UI consumed the click).
171    /// Returns true while the action is held.
172    pub fn consume(&mut self, action: &A) -> bool {
173        let held = self.pressed.contains(action);
174        self.consumed.insert(action.clone());
175        held
176    }
177
178    pub fn consume_all(&mut self) {
179        self.consumed.extend(self.pressed.iter().cloned());
180    }
181
182    /// Drive an action without hardware (cutscenes, AI, tests).
183    pub fn mock(&mut self, action: &A, down: bool) {
184        if down {
185            self.press(action);
186        } else {
187            self.release(action);
188        }
189    }
190
191    /// Clear consumption (fresh frame handoff from UI).
192    pub fn clear_consumed(&mut self) {
193        self.consumed.clear();
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200    use repose_core::input::{Key, Modifiers};
201
202    fn jump_map() -> ActionMap<&'static str> {
203        let mut map = ActionMap::new();
204        map.bind(
205            "jump",
206            Binding::Key(KeyChord::new(Key::Space, Modifiers::default())),
207        );
208        map.bind("jump", Binding::Pad(GamepadButton::South));
209        map
210    }
211
212    fn space() -> KeyChord {
213        KeyChord::new(Key::Space, Modifiers::default())
214    }
215
216    #[test]
217    fn edges_clear_at_tick_end() {
218        let mut st = ActionState::new(jump_map());
219        st.key(&space(), true);
220        assert!(st.just_pressed(&"jump"));
221        assert!(st.pressed(&"jump"));
222        // Edge survives (no schedule ran yet); systems read, then clear.
223        assert!(st.just_pressed(&"jump"));
224        st.end_tick();
225        assert!(!st.just_pressed(&"jump"));
226        assert!(st.pressed(&"jump"));
227        st.key(&space(), false);
228        assert!(st.just_released(&"jump"));
229        assert!(!st.pressed(&"jump"));
230        st.end_tick();
231        assert!(!st.just_released(&"jump"));
232    }
233
234    #[test]
235    fn pad_and_key_drive_same_action() {
236        let mut st = ActionState::new(jump_map());
237        st.pad(GamepadButton::South, true);
238        assert!(st.just_pressed(&"jump"));
239        st.end_tick();
240        st.pad(GamepadButton::South, false);
241        st.end_tick();
242        st.key(&space(), true);
243        assert!(st.just_pressed(&"jump"));
244    }
245
246    #[test]
247    fn axis_threshold_flips() {
248        let mut map = ActionMap::new();
249        map.bind(
250            "left",
251            Binding::Axis {
252                axis: GamepadAxis::LeftStickX,
253                threshold: -0.5,
254            },
255        );
256        let mut st = ActionState::new(map);
257        st.axis(GamepadAxis::LeftStickX, -0.2);
258        assert!(!st.pressed(&"left"));
259        st.axis(GamepadAxis::LeftStickX, -0.8);
260        assert!(st.just_pressed(&"left"));
261        assert_eq!(st.axis_value(GamepadAxis::LeftStickX), -0.8);
262        st.axis(GamepadAxis::LeftStickX, 0.0);
263        assert!(st.just_released(&"left"));
264    }
265
266    #[test]
267    fn consume_hides_from_late_readers() {
268        let mut st = ActionState::new(jump_map());
269        st.key(&space(), true);
270        assert!(st.consume(&"jump"));
271        assert!(!st.pressed(&"jump"));
272        assert!(!st.just_pressed(&"jump"));
273        st.clear_consumed();
274        assert!(st.pressed(&"jump"));
275    }
276
277    #[test]
278    fn context_gates_sim_action() {
279        let mut map = jump_map();
280        map.in_context("gameplay", "jump");
281        let mut st = ActionState::new(map);
282        st.set_contexts(&["menu"]);
283        st.key(&space(), true);
284        assert!(!st.pressed(&"jump"));
285        st.set_contexts(&["gameplay"]);
286        st.key(&space(), true);
287        assert!(st.pressed(&"jump"));
288    }
289
290    #[test]
291    fn context_switch_drops_pending_edges() {
292        let mut map = jump_map();
293        map.in_context("gameplay", "jump");
294        let mut st = ActionState::new(map);
295        st.set_contexts(&["gameplay"]);
296        st.key(&space(), true);
297        assert!(st.just_pressed(&"jump"));
298        st.set_contexts(&["menu"]);
299        assert!(!st.just_pressed(&"jump"));
300        assert!(!st.pressed(&"jump"));
301        st.key(&space(), false);
302        st.set_contexts(&["gameplay"]);
303        assert!(!st.just_released(&"jump"));
304    }
305
306    #[test]
307    fn mock_drives_without_hardware() {
308        let mut st = ActionState::new(jump_map());
309        st.mock(&"jump", true);
310        assert!(st.just_pressed(&"jump"));
311        st.mock(&"jump", false);
312        assert!(st.just_released(&"jump"));
313    }
314}