Skip to main content

repame_ui/
flow.rs

1//! Menu and run flow on `repame_sim::state`, not on a per-game `Phase` enum.
2//!
3//! Every template-derived game hand-rolls the same three things: a `Phase` enum
4//! (`Menu` / `Playing` / `Break(...)`), a `Panel` enum for which menu is
5//! frontmost, and a `RunState` that re-derives the same four questions —
6//! *playing*, *modal*, *transitional*, *suspended*. This module is the flow
7//! half of that once, as three state sets with enter/exit/transition schedules:
8//!
9//! * [`Flow`] — the screen: front end, live run, player pause, rule-driven
10//!   modal.
11//! * [`Transition`] — a wipe/curtain is running over the run.
12//! * [`Suspension`] — the platform took the game away (app backgrounded, window
13//!   gone); the run is frozen but not paused.
14//!
15//! Because these are real states, `OnEnter(Flow::Paused)` and
16//! `OnExit(Transition::Running)` are where a game opens its pause panel and
17//! stops its wipe sound, instead of an `if` inside the frame loop. Which panel
18//! is frontmost stays a game resource: the kit cannot know the game's panels.
19//!
20//! [`FlowState`] is the readout the per-game `RunState` was re-deriving. It is
21//! plain data, so the view side can hold one and the sim side can build one from
22//! the world. `run_live` and `panel_owns_input` are also run conditions.
23
24use repame_sim::Sim;
25use repame_sim::bevy_ecs::prelude::{Res, World};
26use repame_sim::state::State;
27
28/// The screen the game is on.
29#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
30pub enum Flow {
31    /// Front end: title, options, level select. No run in flight.
32    Menu,
33    /// A run is live and answers input.
34    Playing,
35    /// The player froze the run; a pause panel owns input.
36    Paused,
37    /// A game rule froze the run; a modal panel owns input (death screen,
38    /// revive offer, upgrade pick, results).
39    Modal,
40}
41
42impl Flow {
43    /// Short name for logs and diagnostics.
44    pub fn name(self) -> &'static str {
45        match self {
46            Flow::Menu => "menu",
47            Flow::Playing => "playing",
48            Flow::Paused => "paused",
49            Flow::Modal => "modal",
50        }
51    }
52
53    /// Whether a run is still in flight behind whatever is on screen.
54    ///
55    /// `Menu` is the only flow with nothing to resume.
56    pub fn keeps_run(self) -> bool {
57        !matches!(self, Flow::Menu)
58    }
59}
60
61/// Whether a wipe, curtain or other full-screen transition is running.
62#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
63pub enum Transition {
64    Idle,
65    Running,
66}
67
68/// Whether the platform has suspended gameplay.
69///
70/// Distinct from [`Flow::Paused`]: a suspend is not a player decision, so it
71/// must not open a pause panel or resume into one. Set it from the app's
72/// foreground/background listener.
73#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
74pub enum Suspension {
75    Live,
76    Suspended,
77}
78
79/// Register the flow state sets on a sim, before any `on_enter`/`on_exit`/
80/// `on_transition` call for them.
81///
82/// ```ignore
83/// repame_ui::flow::install(&mut sim, Flow::Menu);
84/// sim.on_enter(Flow::Playing, start_run);
85/// ```
86pub fn install(sim: &mut Sim, start: Flow) -> &mut Sim {
87    sim.add_state(start);
88    sim.add_state(Transition::Idle);
89    sim.add_state(Suspension::Live);
90    sim
91}
92
93/// Who is answering input this frame.
94#[derive(Clone, Copy, Debug, PartialEq, Eq)]
95pub enum InputOwner {
96    /// Gameplay: sticks, buttons, the touch overlay.
97    Game,
98    /// A panel: menus, the pause screen, a modal.
99    Panel,
100}
101
102/// The flow distinctions a per-game `RunState` re-derives, as one value.
103#[derive(Clone, Copy, Debug, PartialEq, Eq)]
104pub struct FlowState {
105    flow: Flow,
106    transition: Transition,
107    suspension: Suspension,
108}
109
110impl FlowState {
111    /// The front end with nothing running: what a game that has not installed
112    /// the flow reads as, so a mis-wired frame freezes the game instead of
113    /// running it behind a menu.
114    pub const CLOSED: FlowState =
115        FlowState::new(Flow::Menu, Transition::Idle, Suspension::Live);
116
117    pub const fn new(flow: Flow, transition: Transition, suspension: Suspension) -> Self {
118        Self {
119            flow,
120            transition,
121            suspension,
122        }
123    }
124
125    /// Read the flow state sets out of a sim world.
126    ///
127    /// `None` when [`install`] was never called, so a caller cannot mistake
128    /// "not wired" for `Flow::Menu`.
129    pub fn from_world(world: &World) -> Option<Self> {
130        Some(Self {
131            flow: *world.get_resource::<State<Flow>>()?.get(),
132            transition: *world.get_resource::<State<Transition>>()?.get(),
133            suspension: *world.get_resource::<State<Suspension>>()?.get(),
134        })
135    }
136
137    pub const fn flow(&self) -> Flow {
138        self.flow
139    }
140
141    pub const fn transition(&self) -> Transition {
142        self.transition
143    }
144
145    pub const fn suspension(&self) -> Suspension {
146        self.suspension
147    }
148
149    /// The screen is the front end.
150    pub const fn menu(&self) -> bool {
151        matches!(self.flow, Flow::Menu)
152    }
153
154    /// A run is live, whatever overlays are up.
155    pub const fn in_run(&self) -> bool {
156        matches!(self.flow, Flow::Playing)
157    }
158
159    /// The player paused the run.
160    pub const fn paused(&self) -> bool {
161        matches!(self.flow, Flow::Paused)
162    }
163
164    /// A blocking panel owns the screen: the pause panel or a game modal.
165    ///
166    /// The condition the game's own overlay and the ECS gameplay systems both
167    /// gate on, so a modal owned by the sim cannot be half-drawn.
168    pub const fn modal(&self) -> bool {
169        matches!(self.flow, Flow::Paused | Flow::Modal)
170    }
171
172    /// A wipe or curtain is running.
173    pub const fn transitional(&self) -> bool {
174        matches!(self.transition, Transition::Running)
175    }
176
177    /// The platform suspended gameplay.
178    pub const fn suspended(&self) -> bool {
179        matches!(self.suspension, Suspension::Suspended)
180    }
181
182    /// The world should advance this step: a live run, no transition, no
183    /// suspend. The gameplay schedule's gate.
184    pub const fn run_live(&self) -> bool {
185        self.in_run() && !self.transitional() && !self.suspended()
186    }
187
188    /// A panel (menu, pause, modal) owns the screen.
189    pub const fn panel_owns_input(&self) -> bool {
190        self.menu() || self.modal()
191    }
192
193    /// The game answers sticks and buttons: a live run that is neither
194    /// transitioning nor suspended.
195    ///
196    /// The touch overlay's gate.
197    pub const fn game_has_input(&self) -> bool {
198        self.run_live()
199    }
200
201    pub const fn input_owner(&self) -> InputOwner {
202        if self.panel_owns_input() {
203            InputOwner::Panel
204        } else {
205            InputOwner::Game
206        }
207    }
208}
209
210/// Run condition: the world should advance this step.
211///
212/// ```ignore
213/// sim.add_chained_systems(movement.run_if(flow::run_live));
214/// ```
215pub fn run_live(
216    flow: Res<State<Flow>>,
217    transition: Res<State<Transition>>,
218    suspension: Res<State<Suspension>>,
219) -> bool {
220    FlowState::new(*flow.get(), *transition.get(), *suspension.get()).run_live()
221}
222
223/// Run condition: a menu, pause or modal panel owns the screen.
224pub fn panel_owns_input(
225    flow: Res<State<Flow>>,
226    transition: Res<State<Transition>>,
227    suspension: Res<State<Suspension>>,
228) -> bool {
229    FlowState::new(*flow.get(), *transition.get(), *suspension.get()).panel_owns_input()
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235    use repame_sim::bevy_ecs::schedule::IntoScheduleConfigs;
236    use repame_sim::in_state;
237
238    const FLOWS: [Flow; 4] = [Flow::Menu, Flow::Playing, Flow::Paused, Flow::Modal];
239    const TRANSITIONS: [Transition; 2] = [Transition::Idle, Transition::Running];
240    const SUSPENSIONS: [Suspension; 2] = [Suspension::Live, Suspension::Suspended];
241
242    /// One row per `(flow, transition, suspension)`: menu, in_run, paused,
243    /// modal, transitional, suspended, run_live, panel_owns_input, owner.
244    fn expected(
245        flow: Flow,
246        transition: Transition,
247        suspension: Suspension,
248    ) -> (bool, bool, bool, bool, bool, bool, bool, bool, InputOwner) {
249        let transitional = transition == Transition::Running;
250        let suspended = suspension == Suspension::Suspended;
251        (
252            flow == Flow::Menu,
253            flow == Flow::Playing,
254            flow == Flow::Paused,
255            matches!(flow, Flow::Paused | Flow::Modal),
256            transitional,
257            suspended,
258            flow == Flow::Playing && !transitional && !suspended,
259            matches!(flow, Flow::Menu | Flow::Paused | Flow::Modal),
260            if matches!(flow, Flow::Menu | Flow::Paused | Flow::Modal) {
261                InputOwner::Panel
262            } else {
263                InputOwner::Game
264            },
265        )
266    }
267
268    #[test]
269    fn the_predicates_agree_with_the_truth_table() {
270        for flow in FLOWS {
271            for transition in TRANSITIONS {
272                for suspension in SUSPENSIONS {
273                    let state = FlowState::new(flow, transition, suspension);
274                    let row = expected(flow, transition, suspension);
275                    let case = format!("({flow:?}, {transition:?}, {suspension:?})");
276                    assert_eq!(state.menu(), row.0, "menu {case}");
277                    assert_eq!(state.in_run(), row.1, "in_run {case}");
278                    assert_eq!(state.paused(), row.2, "paused {case}");
279                    assert_eq!(state.modal(), row.3, "modal {case}");
280                    assert_eq!(state.transitional(), row.4, "transitional {case}");
281                    assert_eq!(state.suspended(), row.5, "suspended {case}");
282                    assert_eq!(state.run_live(), row.6, "run_live {case}");
283                    assert_eq!(state.panel_owns_input(), row.7, "panel_owns_input {case}");
284                    assert_eq!(state.input_owner(), row.8, "owner {case}");
285                    assert_eq!(state.game_has_input(), row.6, "game_has_input {case}");
286                }
287            }
288        }
289    }
290
291    #[test]
292    fn a_suspend_does_not_become_a_pause() {
293        let suspended = FlowState::new(Flow::Playing, Transition::Idle, Suspension::Suspended);
294        assert!(suspended.suspended());
295        assert!(!suspended.paused());
296        assert!(!suspended.modal());
297        assert!(!suspended.run_live());
298        assert_eq!(suspended.input_owner(), InputOwner::Game);
299    }
300
301    #[test]
302    fn menu_is_the_only_flow_without_a_run_behind_it() {
303        assert!(!Flow::Menu.keeps_run());
304        assert!(Flow::Playing.keeps_run());
305        assert!(Flow::Paused.keeps_run());
306        assert!(Flow::Modal.keeps_run());
307    }
308
309    #[test]
310    fn state_schedules_fire_in_enter_exit_order() {
311        use std::sync::{Arc, Mutex};
312
313        let log: Arc<Mutex<Vec<&'static str>>> = Arc::new(Mutex::new(Vec::new()));
314        let mut sim = Sim::with_default_step();
315        install(&mut sim, Flow::Menu);
316
317        let (a, b, c) = (log.clone(), log.clone(), log.clone());
318        sim.on_exit(Flow::Menu, move || a.lock().unwrap().push("exit:menu"));
319        sim.on_enter(Flow::Playing, move || b.lock().unwrap().push("enter:playing"));
320        sim.on_transition(Flow::Menu, Flow::Playing, move || {
321            c.lock().unwrap().push("menu->playing")
322        });
323
324        sim.set_state(Flow::Playing);
325        let state = FlowState::from_world(&sim.world).expect("install registered the flow");
326        assert!(state.menu(), "the transition applies at the step head");
327        sim.tick();
328        assert_eq!(
329            *log.lock().unwrap(),
330            ["exit:menu", "menu->playing", "enter:playing"]
331        );
332        let state = FlowState::from_world(&sim.world).expect("install registered the flow");
333        assert!(state.run_live());
334    }
335
336    #[test]
337    fn run_live_gates_a_system_through_the_sim() {
338        use std::sync::atomic::{AtomicU32, Ordering};
339        static TICKS: AtomicU32 = AtomicU32::new(0);
340
341        let mut sim = Sim::with_default_step();
342        install(&mut sim, Flow::Menu);
343        sim.add_chained_systems(
344            (|| {
345                TICKS.fetch_add(1, Ordering::SeqCst);
346            })
347            .run_if(run_live),
348        );
349
350        sim.tick();
351        assert_eq!(TICKS.load(Ordering::SeqCst), 0, "menu does not advance the world");
352
353        sim.set_state(Flow::Playing);
354        sim.tick();
355        assert_eq!(TICKS.load(Ordering::SeqCst), 1);
356
357        sim.set_state(Transition::Running);
358        sim.tick();
359        assert_eq!(TICKS.load(Ordering::SeqCst), 1, "a wipe freezes the world");
360
361        sim.set_state(Transition::Idle);
362        sim.set_state(Suspension::Suspended);
363        sim.tick();
364        assert_eq!(TICKS.load(Ordering::SeqCst), 1, "a suspend freezes the world");
365
366        sim.set_state(Suspension::Live);
367        sim.tick();
368        assert_eq!(TICKS.load(Ordering::SeqCst), 2);
369    }
370
371    #[test]
372    fn states_work_through_in_state_too() {
373        use std::sync::atomic::{AtomicU32, Ordering};
374        static HITS: AtomicU32 = AtomicU32::new(0);
375
376        let mut sim = Sim::with_default_step();
377        install(&mut sim, Flow::Menu);
378        sim.add_chained_systems(
379            (|| {
380                HITS.fetch_add(1, Ordering::SeqCst);
381            })
382            .run_if(in_state(Flow::Playing)),
383        );
384        sim.set_state(Flow::Playing);
385        sim.tick();
386        assert_eq!(HITS.load(Ordering::SeqCst), 1);
387        sim.set_state(Flow::Paused);
388        sim.tick();
389        assert_eq!(HITS.load(Ordering::SeqCst), 1);
390    }
391
392    #[test]
393    fn a_missing_install_reads_as_none() {
394        let sim = Sim::with_default_step();
395        assert!(FlowState::from_world(&sim.world).is_none());
396    }
397}