Skip to main content

repame_sim/
state.rs

1//! Typed game states with enter/exit/transition schedules.
2//!
3//! A state set is any small value type (usually an enum) that is `Eq + Hash`;
4//! one `State<S>` resource holds the current value and one `NextState<S>`
5//! holds a pending change. Requesting a transition from any system is a write
6//! to `NextState<S>`; the sim applies it once per fixed step, *before* the
7//! step's main schedule, so a transition never interleaves with gameplay.
8//!
9//! Apply order for `A -> B` matches the reference (bevy_state):
10//!
11//! 1. `OnExit(A)`   - the `State<S>` resource still reads `A`
12//! 2. `State<S> = B`
13//! 3. `OnTransition { exited: A, entered: B }`
14//! 4. `OnEnter(B)`
15//!
16//! If an enter/exit system requests another transition, it is resolved on the
17//! same step, up to [`MAX_CHAIN`] links; beyond that the chain is dropped and
18//! the last state stands. That bound stops a self-triggering transition from
19//! spinning the step forever.
20//!
21//! Determinism: transitions run inside the fixed step and in registration
22//! order, so a replay that requests the same transitions at the same ticks
23//! resolves them identically. Do not request transitions from a thread.
24
25use std::collections::HashMap;
26use std::hash::Hash;
27
28use bevy_ecs::prelude::*;
29use bevy_ecs::schedule::{IntoScheduleConfigs, Schedule};
30use bevy_ecs::system::ScheduleSystem;
31
32/// A state set: a plain value used as a game state.
33///
34/// Usually a fieldless enum. The blanket impl covers any `Clone + Eq + Hash`
35/// value type with no `'static` borrows.
36pub trait States: Clone + Eq + Hash + Send + Sync + 'static {}
37
38impl<T> States for T where T: Clone + Eq + Hash + Send + Sync + 'static {}
39
40/// The current value of a state set.
41#[derive(Resource)]
42pub struct State<S: States>(S);
43
44impl<S: States> State<S> {
45    pub fn new(value: S) -> Self {
46        Self(value)
47    }
48
49    pub fn get(&self) -> &S {
50        &self.0
51    }
52}
53
54/// A pending transition. Writing here requests a change; the sim applies it
55/// before the next step's main schedule.
56#[derive(Resource)]
57pub struct NextState<S: States>(Option<S>);
58
59impl<S: States> NextState<S> {
60    pub fn new() -> Self {
61        Self(None)
62    }
63
64    /// Request a transition. A request equal to the current state is ignored.
65    pub fn set(&mut self, next: S) {
66        self.0 = Some(next);
67    }
68
69    /// The pending request, if any.
70    pub fn pending(&self) -> Option<&S> {
71        self.0.as_ref()
72    }
73
74    fn take(&mut self) -> Option<S> {
75        self.0.take()
76    }
77}
78
79impl<S: States> Default for NextState<S> {
80    fn default() -> Self {
81        Self(None)
82    }
83}
84
85/// Transition schedules, keyed by the state they fire for.
86#[derive(Resource)]
87pub(crate) struct StateSchedules<S: States> {
88    on_enter: HashMap<S, Schedule>,
89    on_exit: HashMap<S, Schedule>,
90    on_transition: HashMap<(S, S), Schedule>,
91}
92
93impl<S: States> Default for StateSchedules<S> {
94    fn default() -> Self {
95        Self {
96            on_enter: HashMap::new(),
97            on_exit: HashMap::new(),
98            on_transition: HashMap::new(),
99        }
100    }
101}
102
103/// Most transition links a single step resolves before giving up.
104pub const MAX_CHAIN: u32 = 8;
105
106/// Applies pending transitions. Registered once per state set; runs at the
107/// head of each fixed step.
108pub(crate) fn apply<S: States>(world: &mut World) {
109    for _ in 0..MAX_CHAIN {
110        let Some(next) = world.resource_mut::<NextState<S>>().take() else {
111            break;
112        };
113        let current = world.resource::<State<S>>().get().clone();
114        if next == current {
115            continue;
116        }
117        world.resource_scope(|world, mut schedules: Mut<StateSchedules<S>>| {
118            if let Some(schedule) = schedules.on_exit.get_mut(&current) {
119                schedule.run(world);
120            }
121            world.resource_mut::<State<S>>().0 = next.clone();
122            if let Some(schedule) = schedules.on_transition.get_mut(&(current, next.clone())) {
123                schedule.run(world);
124            }
125            if let Some(schedule) = schedules.on_enter.get_mut(&next) {
126                schedule.run(world);
127            }
128        });
129    }
130}
131
132/// Run condition: only run the configured systems while the state is `state`.
133///
134/// ```ignore
135/// sim.add_chained_systems(update_walk.run_if(in_state(Mode::Walk)));
136/// ```
137pub fn in_state<S: States>(state: S) -> impl FnMut(Res<State<S>>) -> bool + Clone + Send + Sync + 'static {
138    move |current: Res<State<S>>| current.get() == &state
139}
140
141pub(crate) fn init_schedules<S: States>(world: &mut World) {
142    world.insert_resource(StateSchedules::<S>::default());
143}
144
145pub(crate) fn add_enter<S, M>(
146    world: &mut World,
147    state: S,
148    systems: impl IntoScheduleConfigs<ScheduleSystem, M>,
149) where
150    S: States,
151{
152    world
153        .resource_mut::<StateSchedules<S>>()
154        .on_enter
155        .entry(state)
156        .or_default()
157        .add_systems(systems);
158}
159
160pub(crate) fn add_exit<S, M>(
161    world: &mut World,
162    state: S,
163    systems: impl IntoScheduleConfigs<ScheduleSystem, M>,
164) where
165    S: States,
166{
167    world
168        .resource_mut::<StateSchedules<S>>()
169        .on_exit
170        .entry(state)
171        .or_default()
172        .add_systems(systems);
173}
174
175pub(crate) fn add_transition<S, M>(
176    world: &mut World,
177    exited: S,
178    entered: S,
179    systems: impl IntoScheduleConfigs<ScheduleSystem, M>,
180) where
181    S: States,
182{
183    world
184        .resource_mut::<StateSchedules<S>>()
185        .on_transition
186        .entry((exited, entered))
187        .or_default()
188        .add_systems(systems);
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
196    enum Mode {
197        Menu,
198        Play,
199        Paused,
200    }
201
202    #[test]
203    fn transition_runs_exit_then_enter_in_order() {
204        use std::sync::{Arc, Mutex};
205
206        let log: Arc<Mutex<Vec<&'static str>>> = Arc::new(Mutex::new(Vec::new()));
207        let mut sim = crate::Sim::with_default_step();
208        sim.add_state(Mode::Menu);
209
210        let (a, b, c) = (log.clone(), log.clone(), log.clone());
211        sim.on_exit(Mode::Menu, move || a.lock().unwrap().push("exit:menu"));
212        sim.on_enter(Mode::Play, move || b.lock().unwrap().push("enter:play"));
213        sim.on_transition(Mode::Menu, Mode::Play, move || {
214            c.lock().unwrap().push("transition:menu->play")
215        });
216
217        sim.set_state(Mode::Play);
218        sim.tick();
219        assert_eq!(
220            *log.lock().unwrap(),
221            ["exit:menu", "transition:menu->play", "enter:play"]
222        );
223        assert_eq!(*sim.world.resource::<State<Mode>>().get(), Mode::Play);
224    }
225
226    #[test]
227    fn exit_sees_old_state_and_enter_sees_new_state() {
228        use std::sync::{Arc, Mutex};
229
230        let seen: Arc<Mutex<Vec<Mode>>> = Arc::new(Mutex::new(Vec::new()));
231        let mut sim = crate::Sim::with_default_step();
232        sim.add_state(Mode::Menu);
233
234        let e = seen.clone();
235        sim.on_exit(Mode::Menu, move |s: Res<State<Mode>>| e.lock().unwrap().push(*s.get()));
236        let n = seen.clone();
237        sim.on_enter(Mode::Play, move |s: Res<State<Mode>>| n.lock().unwrap().push(*s.get()));
238
239        sim.set_state(Mode::Play);
240        sim.tick();
241        assert_eq!(*seen.lock().unwrap(), [Mode::Menu, Mode::Play]);
242    }
243
244    #[test]
245    fn a_transition_requested_on_enter_chains_on_the_same_step() {
246        let mut sim = crate::Sim::with_default_step();
247        sim.add_state(Mode::Menu);
248        sim.on_enter(Mode::Play, |mut next: ResMut<NextState<Mode>>| {
249            next.set(Mode::Paused)
250        });
251        sim.set_state(Mode::Play);
252        sim.tick();
253        assert_eq!(*sim.world.resource::<State<Mode>>().get(), Mode::Paused);
254    }
255
256    #[test]
257    fn self_triggering_transition_is_bounded() {
258        let mut sim = crate::Sim::with_default_step();
259        sim.add_state(Mode::Menu);
260        sim.on_enter(Mode::Play, |mut next: ResMut<NextState<Mode>>| {
261            next.set(Mode::Play)
262        });
263        sim.set_state(Mode::Play);
264        sim.tick();
265        assert_eq!(*sim.world.resource::<State<Mode>>().get(), Mode::Play);
266        assert!(sim.world.resource::<NextState<Mode>>().pending().is_none());
267    }
268
269    #[test]
270    fn set_to_current_state_runs_no_schedules() {
271        use std::sync::{Arc, Mutex};
272        let fired = Arc::new(Mutex::new(false));
273        let mut sim = crate::Sim::with_default_step();
274        sim.add_state(Mode::Menu);
275        let f = fired.clone();
276        sim.on_enter(Mode::Menu, move || *f.lock().unwrap() = true);
277        sim.set_state(Mode::Menu);
278        sim.tick();
279        assert!(!*fired.lock().unwrap());
280    }
281
282    #[test]
283    fn in_state_condition_gates_systems() {
284        use std::sync::atomic::{AtomicU32, Ordering};
285        static HITS: AtomicU32 = AtomicU32::new(0);
286
287        let mut sim = crate::Sim::with_default_step();
288        sim.add_state(Mode::Menu);
289        sim.add_chained_systems(
290            (|| {
291                HITS.fetch_add(1, Ordering::SeqCst);
292            })
293            .run_if(in_state(Mode::Menu)),
294        );
295        sim.tick();
296        assert_eq!(HITS.load(Ordering::SeqCst), 1);
297        sim.set_state(Mode::Play);
298        sim.tick();
299        assert_eq!(HITS.load(Ordering::SeqCst), 1, "gated out after switching");
300    }
301}