├── .gitignore ├── src ├── frame_count.rs ├── lib.rs ├── schedule.rs └── audio.rs ├── Cargo.toml ├── LICENSE-MIT ├── .github └── workflows │ └── ci.yml ├── README.md ├── examples └── states.rs └── LICENSE-APACHE /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | Cargo.lock 3 | .vscode/ 4 | -------------------------------------------------------------------------------- /src/frame_count.rs: -------------------------------------------------------------------------------- 1 | use bevy::prelude::*; 2 | 3 | /// Replacement for Bevy's FrameCount, not tied to rendering 4 | /// 5 | /// Keeps track of the current rollback frame 6 | /// 7 | /// Note: you need to manually add `increase_frame_count` to the rollback schedule 8 | /// for this resource to be updated. 9 | #[derive(Resource, Default, Reflect, Hash, Clone, Copy, Debug)] 10 | #[reflect(Hash)] 11 | pub struct RollFrameCount(pub u32); 12 | 13 | pub fn increase_frame_count(mut frame_count: ResMut) { 14 | frame_count.0 = frame_count.0.wrapping_add(1); 15 | } 16 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | authors = ["Johan Helsing "] 3 | categories = ["game-development"] 4 | description = "Rollback safe utilities and abstractions for Bevy" 5 | edition = "2021" 6 | keywords = ["gamedev", "bevy"] 7 | license = "MIT OR Apache-2.0" 8 | name = "bevy_roll_safe" 9 | repository = "https://github.com/johanhelsing/bevy_roll_safe" 10 | version = "0.6.0" 11 | 12 | [features] 13 | default = ["audio", "bevy_ggrs", "math_determinism"] 14 | bevy_ggrs = ["dep:bevy_ggrs"] 15 | math_determinism = ["bevy_math/libm"] 16 | audio = ["bevy/bevy_audio", "bevy/bevy_asset"] 17 | 18 | [dependencies] 19 | bevy = { version = "0.17", default-features = false, features = ["bevy_state"] } 20 | bevy_math = "0.17" 21 | bevy_ggrs = { version = "0.19", optional = true, default-features = false } 22 | 23 | [dev-dependencies] 24 | bevy = { version = "0.17", default-features = false, features = ["debug"] } 25 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | pull_request: 6 | 7 | name: ci 8 | 9 | jobs: 10 | check: 11 | name: Check 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Install Dependencies 15 | run: sudo apt-get update; sudo apt-get install --no-install-recommends libasound2-dev 16 | - uses: actions/checkout@v4 17 | - uses: dtolnay/rust-toolchain@stable 18 | - uses: Swatinem/rust-cache@v2 19 | - run: cargo check --all-targets 20 | 21 | test: 22 | name: Test Suite 23 | runs-on: ubuntu-latest 24 | steps: 25 | - name: Install Dependencies 26 | run: sudo apt-get update; sudo apt-get install --no-install-recommends libasound2-dev 27 | - uses: actions/checkout@v4 28 | - uses: dtolnay/rust-toolchain@stable 29 | - uses: Swatinem/rust-cache@v2 30 | - run: cargo test 31 | 32 | lints: 33 | name: Lints 34 | runs-on: ubuntu-latest 35 | steps: 36 | - name: Install Dependencies 37 | run: sudo apt-get update; sudo apt-get install --no-install-recommends libasound2-dev 38 | - uses: actions/checkout@v4 39 | - name: Install stable toolchain 40 | uses: dtolnay/rust-toolchain@stable 41 | with: 42 | components: rustfmt, clippy 43 | - uses: Swatinem/rust-cache@v2 44 | - run: cargo fmt --all -- --check 45 | - run: cargo clippy -- -D warnings 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # bevy_roll_safe 2 | 3 | [![crates.io](https://img.shields.io/crates/v/bevy_roll_safe.svg)](https://crates.io/crates/bevy_roll_safe) 4 | ![MIT/Apache 2.0](https://img.shields.io/badge/license-MIT%2FApache-blue.svg) 5 | [![docs.rs](https://img.shields.io/docsrs/bevy_roll_safe)](https://docs.rs/bevy_roll_safe) 6 | 7 | Rollback-safe implementations and utilities for Bevy Engine. 8 | 9 | ## Motivation 10 | 11 | Some of Bevy's features can't be used in a rollback context (with crates such as [`bevy_ggrs`]). This is either because they behave non-deterministically, rely on inaccessible local system state, or are tightly coupled to the `Main` schedule. 12 | 13 | ## Roadmap 14 | 15 | - [x] States 16 | - [x] Basic freely mutable states 17 | - [x] `OnEnter`/`OnLeave`/`OnTransition` 18 | - [x] FrameCount 19 | - [x] Rollback-safe "Main"/default schedules 20 | - [x] Audio playback 21 | - [x] Support all `PlaybackMode`s 22 | - [ ] Support for seeking in "time-critical" audio 23 | - [ ] Support for formats that don't report sound durations (mp3/ogg) 24 | - [ ] Events 25 | 26 | ## States 27 | 28 | Bevy states when added through `app.init_state::()` have two big problems: 29 | 30 | 1. They happen in the `StateTransition` schedule within the `MainSchedule` 31 | 2. If rolled back to the first frame, `OnEnter(InitialState)` is not re-run. 32 | 33 | This crate provides an extension method, `init_roll_state_in_schedule::(schedule)`, which lets you add a state to the schedule you want, and a resource, `InitialStateEntered` which can be rolled back and tracks whether the initial `OnEnter` should be run (or re-run on rollbacks to the initial frame). 34 | 35 | If you are using the rollback schedule plugin as well. Adding a rollback safe state is a simple as `app.init_roll_state::()`. 36 | 37 | See the [`states`](https://github.com/johanhelsing/bevy_roll_safe/blob/main/examples/states.rs) example for usage with [`bevy_ggrs`]. 38 | 39 | ## Default rollback schedule 40 | 41 | `RollbackSchedulePlugin` adds rollback-specific alternatives to the schedules in Bevy's `FixedMain`/`Main` schedules. 42 | 43 | The plugin takes a parent schedule as input, so it can easily be added to the ggrs schedule or any other schedule you want. 44 | 45 | ## Rollback audio 46 | 47 | `RollbackAudioPlugin` lets you easily play sound effects from a rollback world without duplicate sounds playing over each other. It depends on the `RollbackSchedulePlugin`, or you need to add the maintenance system in a similar order to your own schedules. 48 | 49 | ## Cargo features 50 | 51 | - `audio`: Enable rollback-safe wrapper for `bevy_audio` 52 | - `bevy_ggrs`: Enable integration with [`bevy_ggrs`] 53 | - `math_determinism`: Enable cross-platform determinism for operations on Bevy's (`glam`) math types. 54 | 55 | ## Bevy Version Support 56 | 57 | |bevy|bevy_roll_safe| 58 | |----|--------------| 59 | |0.17|0.6, main | 60 | |0.16|0.5 | 61 | |0.15|0.4 | 62 | |0.14|0.3 | 63 | |0.13|0.2 | 64 | |0.12|0.1 | 65 | 66 | ## License 67 | 68 | `bevy_roll_safe` is dual-licensed under either 69 | 70 | - MIT License (./LICENSE-MIT or ) 71 | - Apache License, Version 2.0 (./LICENSE-APACHE or ) 72 | 73 | at your option. 74 | 75 | ## Contributions 76 | 77 | PRs welcome! 78 | 79 | [`bevy_ggrs`]: https://github.com/gschup/bevy_ggrs 80 | -------------------------------------------------------------------------------- /examples/states.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use bevy::{app::ScheduleRunnerPlugin, log::LogPlugin, platform::collections::HashMap, prelude::*}; 4 | use bevy_ggrs::{ 5 | ggrs::{PlayerType, SessionBuilder}, 6 | prelude::*, 7 | LocalInputs, LocalPlayers, 8 | }; 9 | use bevy_roll_safe::prelude::*; 10 | 11 | type GgrsConfig = bevy_ggrs::GgrsConfig; 12 | 13 | fn read_local_input(mut commands: Commands, local_players: Res) { 14 | let mut local_inputs = HashMap::new(); 15 | for handle in &local_players.0 { 16 | local_inputs.insert(*handle, 0); 17 | } 18 | commands.insert_resource(LocalInputs::(local_inputs)); 19 | } 20 | 21 | #[derive(States, Hash, Default, Debug, Eq, PartialEq, Clone)] 22 | enum GameplayState { 23 | #[default] 24 | InRound, 25 | GameOver, 26 | } 27 | 28 | /// Player health 29 | #[derive(Component, Hash, Debug, Clone, Copy)] 30 | struct Health(u32); 31 | 32 | fn main() -> Result<(), Box> { 33 | let session = SessionBuilder::::new() 34 | .with_num_players(1) 35 | // each frame, roll back and resimulate 5 frames back in time, and compare checksums 36 | .with_check_distance(5) 37 | .add_player(PlayerType::Local, 0)? 38 | .start_synctest_session()?; 39 | 40 | App::new() 41 | .add_plugins(( 42 | GgrsPlugin::::default(), 43 | RollbackSchedulePlugin::new_ggrs(), 44 | )) 45 | .insert_resource(RollbackFrameRate(60)) 46 | .add_systems(ReadInputs, read_local_input) 47 | .rollback_component_with_copy::() 48 | .checksum_component_with_hash::() 49 | // Add the state transition to the ggrs schedule and register it for rollback 50 | .init_ggrs_state::() 51 | .add_plugins(( 52 | MinimalPlugins.set(ScheduleRunnerPlugin::run_loop(Duration::from_secs_f64( 53 | 1.0 / 10.0, 54 | ))), 55 | LogPlugin::default(), 56 | )) 57 | .add_systems(OnEnter(GameplayState::InRound), spawn_player) 58 | .add_systems(OnEnter(GameplayState::GameOver), log_game_over) 59 | .add_systems( 60 | RollbackUpdate, 61 | decrease_health 62 | .after(bevy_roll_safe::apply_state_transition::) 63 | .run_if(in_state(GameplayState::InRound)), 64 | ) 65 | .insert_resource(Session::SyncTest(session)) 66 | .run(); 67 | 68 | Ok(()) 69 | } 70 | 71 | fn spawn_player(mut commands: Commands) { 72 | info!("spawning player"); 73 | commands.spawn(Health(10)).add_rollback(); 74 | } 75 | 76 | fn decrease_health( 77 | mut commands: Commands, 78 | mut players: Query<(Entity, &mut Health)>, 79 | mut state: ResMut>, 80 | ) { 81 | // this system should never run in the GameOver state, 82 | // so the player should always exist 83 | let (player_entity, mut health) = players.single_mut().expect("player entity"); 84 | 85 | health.0 = health.0.saturating_sub(1); 86 | info!("{health:?}"); 87 | 88 | if health.0 == 0 { 89 | info!("despawning player, setting GameOver state"); 90 | commands.entity(player_entity).despawn(); 91 | state.set(GameplayState::GameOver); 92 | } 93 | } 94 | 95 | fn log_game_over() { 96 | info!("you dead"); 97 | } 98 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![doc = include_str!("../README.md")] 2 | 3 | use std::marker::PhantomData; 4 | 5 | use bevy::{ecs::schedule::ScheduleLabel, prelude::*, state::state::FreelyMutableState}; 6 | 7 | #[cfg(feature = "audio")] 8 | mod audio; 9 | mod frame_count; 10 | mod schedule; 11 | 12 | // re-exports 13 | #[cfg(feature = "audio")] 14 | pub use audio::{ 15 | remove_finished_sounds, start_rollback_sounds, sync_rollback_sounds, RollbackAudioPlayer, 16 | RollbackAudioPlayerInstance, RollbackAudioPlugin, 17 | }; 18 | pub use frame_count::{increase_frame_count, RollFrameCount}; 19 | pub use schedule::{ 20 | RollbackPostUpdate, RollbackPreUpdate, RollbackSchedulePlugin, RollbackStateTransition, 21 | RollbackUpdate, 22 | }; 23 | 24 | pub mod prelude { 25 | pub use super::{ 26 | RollApp, RollbackPostUpdate, RollbackPreUpdate, RollbackSchedulePlugin, 27 | RollbackStateTransition, RollbackUpdate, 28 | }; 29 | #[cfg(feature = "audio")] 30 | pub use super::{RollbackAudioPlayer, RollbackAudioPlugin}; 31 | } 32 | 33 | pub trait RollApp { 34 | /// Init state transitions in the given schedule 35 | fn init_roll_state_in_schedule( 36 | &mut self, 37 | schedule: impl ScheduleLabel, 38 | ) -> &mut Self; 39 | 40 | /// Init state transitions in the given schedule 41 | fn init_roll_state(&mut self) -> &mut Self; 42 | 43 | #[cfg(feature = "bevy_ggrs")] 44 | /// Register this state to be rolled back by bevy_ggrs 45 | fn init_ggrs_state(&mut self) -> &mut Self; 46 | 47 | #[cfg(feature = "bevy_ggrs")] 48 | /// Register this state to be rolled back by bevy_ggrs in the specified schedule 49 | fn init_ggrs_state_in_schedule( 50 | &mut self, 51 | schedule: impl ScheduleLabel, 52 | ) -> &mut Self; 53 | } 54 | 55 | impl RollApp for App { 56 | fn init_roll_state_in_schedule( 57 | &mut self, 58 | schedule: impl ScheduleLabel, 59 | ) -> &mut Self { 60 | if !self.world().contains_resource::>() { 61 | self.init_resource::>() 62 | .init_resource::>() 63 | .init_resource::>() 64 | // .add_event::>() 65 | .add_systems( 66 | schedule, 67 | ( 68 | run_enter_schedule:: 69 | .run_if(resource_equals(InitialStateEntered::(false, default()))), 70 | mark_state_initialized:: 71 | .run_if(resource_equals(InitialStateEntered::(false, default()))), 72 | apply_state_transition::, 73 | ) 74 | .chain(), 75 | ); 76 | } else { 77 | let name = std::any::type_name::(); 78 | warn!("State {} is already initialized.", name); 79 | } 80 | 81 | self 82 | } 83 | 84 | fn init_roll_state(&mut self) -> &mut Self { 85 | self.init_roll_state_in_schedule::(RollbackStateTransition) 86 | } 87 | 88 | #[cfg(feature = "bevy_ggrs")] 89 | fn init_ggrs_state(&mut self) -> &mut Self { 90 | // verify the schedule exists first? 91 | self.get_schedule(RollbackStateTransition) 92 | .unwrap_or_else(|| { 93 | panic!( 94 | "RollbackStateTransition schedule does not exist. \ 95 | Please add it by adding the `RollbackSchedulePlugin` \ 96 | or call `init_ggrs_state_in_schedule` with the desired schedule." 97 | ) 98 | }); 99 | 100 | self.init_ggrs_state_in_schedule::(RollbackStateTransition) 101 | } 102 | 103 | #[cfg(feature = "bevy_ggrs")] 104 | fn init_ggrs_state_in_schedule( 105 | &mut self, 106 | schedule: impl ScheduleLabel, 107 | ) -> &mut Self { 108 | use crate::ggrs_support::{NextStateStrategy, StateStrategy}; 109 | use bevy_ggrs::{CloneStrategy, ResourceSnapshotPlugin}; 110 | 111 | self.init_roll_state_in_schedule::(schedule) 112 | .add_plugins(( 113 | ResourceSnapshotPlugin::>::default(), 114 | ResourceSnapshotPlugin::>::default(), 115 | ResourceSnapshotPlugin::>>::default(), 116 | )) 117 | } 118 | } 119 | 120 | #[cfg(feature = "bevy_ggrs")] 121 | mod ggrs_support { 122 | use bevy::{prelude::*, state::state::FreelyMutableState}; 123 | use bevy_ggrs::Strategy; 124 | use std::marker::PhantomData; 125 | 126 | pub(crate) struct StateStrategy(PhantomData); 127 | 128 | // todo: make State implement clone instead 129 | impl Strategy for StateStrategy { 130 | type Target = State; 131 | type Stored = S; 132 | 133 | fn store(target: &Self::Target) -> Self::Stored { 134 | target.get().to_owned() 135 | } 136 | 137 | fn load(stored: &Self::Stored) -> Self::Target { 138 | State::new(stored.to_owned()) 139 | } 140 | } 141 | 142 | pub(crate) struct NextStateStrategy(PhantomData); 143 | 144 | // todo: make NextState implement clone instead 145 | impl Strategy for NextStateStrategy { 146 | type Target = NextState; 147 | type Stored = Option; 148 | 149 | fn store(target: &Self::Target) -> Self::Stored { 150 | match target { 151 | NextState::Unchanged => None, 152 | NextState::Pending(s) => Some(s.to_owned()), 153 | } 154 | } 155 | 156 | fn load(stored: &Self::Stored) -> Self::Target { 157 | match stored { 158 | None => NextState::Unchanged, 159 | Some(s) => NextState::Pending(s.to_owned()), 160 | } 161 | } 162 | } 163 | } 164 | 165 | #[derive(Resource, Debug, Reflect, Eq, PartialEq, Clone)] 166 | #[reflect(Resource)] 167 | pub struct InitialStateEntered(bool, PhantomData); 168 | 169 | impl Default for InitialStateEntered { 170 | fn default() -> Self { 171 | Self(false, default()) 172 | } 173 | } 174 | 175 | fn mark_state_initialized( 176 | mut state_initialized: ResMut>, 177 | ) { 178 | state_initialized.0 = true; 179 | } 180 | 181 | /// Run the enter schedule (if it exists) for the current state. 182 | pub fn run_enter_schedule(world: &mut World) { 183 | let Some(state) = world.get_resource::>() else { 184 | return; 185 | }; 186 | world.try_run_schedule(OnEnter(state.get().clone())).ok(); 187 | } 188 | 189 | /// If a new state is queued in [`NextState`], this system: 190 | /// - Takes the new state value from [`NextState`] and updates [`State`]. 191 | /// - Sends a relevant [`StateTransitionEvent`] 192 | /// - Runs the [`OnExit(exited_state)`] schedule, if it exists. 193 | /// - Runs the [`OnTransition { from: exited_state, to: entered_state }`](OnTransition), if it exists. 194 | /// - Runs the [`OnEnter(entered_state)`] schedule, if it exists. 195 | pub fn apply_state_transition(world: &mut World) { 196 | // We want to take the `NextState` resource, 197 | // but only mark it as changed if it wasn't empty. 198 | let Some(mut next_state_resource) = world.get_resource_mut::>() else { 199 | return; 200 | }; 201 | if let NextState::Pending(entered) = next_state_resource.bypass_change_detection() { 202 | let entered = entered.clone(); 203 | *next_state_resource = NextState::Unchanged; 204 | match world.get_resource_mut::>() { 205 | Some(mut state_resource) => { 206 | if *state_resource != entered { 207 | let exited = state_resource.get().clone(); 208 | *state_resource = State::new(entered.clone()); 209 | // world.send_event(StateTransitionEvent { 210 | // exited: Some(exited.clone()), 211 | // entered: Some(entered.clone()), 212 | // }); 213 | // Try to run the schedules if they exist. 214 | world.try_run_schedule(OnExit(exited.clone())).ok(); 215 | world 216 | .try_run_schedule(OnTransition { 217 | exited, 218 | entered: entered.clone(), 219 | }) 220 | .ok(); 221 | world.try_run_schedule(OnEnter(entered)).ok(); 222 | } 223 | } 224 | None => { 225 | world.insert_resource(State::new(entered.clone())); 226 | world.try_run_schedule(OnEnter(entered)).ok(); 227 | } 228 | }; 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /src/schedule.rs: -------------------------------------------------------------------------------- 1 | use bevy::{ 2 | ecs::schedule::{ 3 | ExecutorKind, InternedScheduleLabel, LogLevel, ScheduleBuildSettings, ScheduleLabel, 4 | }, 5 | prelude::*, 6 | }; 7 | 8 | /// Runs rollback-safe state transitions 9 | /// 10 | /// By default, it will be triggered each frame after [`RollbackPreUpdate`], but 11 | /// you can manually trigger it at arbitrary times by creating an exclusive 12 | /// system to run the schedule. 13 | /// 14 | /// ```rust 15 | /// use bevy::state::prelude::*; 16 | /// use bevy::ecs::prelude::*; 17 | /// use bevy_roll_safe::prelude::*; 18 | /// 19 | /// fn run_state_transitions(world: &mut World) { 20 | /// let _ = world.try_run_schedule(RollbackStateTransition); 21 | /// } 22 | /// ``` 23 | #[derive(ScheduleLabel, Clone, Debug, PartialEq, Eq, Hash, Default)] 24 | 25 | pub struct RollbackStateTransition; 26 | 27 | /// The schedule that contains logic that must run before [`RollbackUpdate`]. For example, a system that reads raw keyboard 28 | /// input OS events into an `Events` resource. This enables systems in [`RollbackUpdate`] to consume the events from the `Events` 29 | /// resource without actually knowing about (or taking a direct scheduler dependency on) the "os-level keyboard event system". 30 | /// 31 | /// [`RollbackPreUpdate`] exists to do "engine/plugin preparation work" that ensures the APIs consumed in [`RollbackUpdate`] are "ready". 32 | /// [`RollbackPreUpdate`] abstracts out "pre work implementation details". 33 | #[derive(ScheduleLabel, Clone, Debug, PartialEq, Eq, Hash, Default)] 34 | pub struct RollbackPreUpdate; 35 | 36 | /// The schedule that contains most gameplay logic 37 | /// 38 | /// See the [`RollbackUpdate`] schedule for examples of systems that *should not* use this schedule. 39 | #[derive(ScheduleLabel, Clone, Debug, PartialEq, Eq, Hash, Default)] 40 | pub struct RollbackUpdate; 41 | 42 | /// The schedule that contains logic that must run after [`RollbackUpdate`]. 43 | /// 44 | /// [`RollbackPostUpdate`] exists to do "engine/plugin response work" to things that happened in [`RollbackUpdate`]. 45 | /// [`RollbackPostUpdate`] abstracts out "implementation details" from users defining systems in [`RollbackUpdate`]. 46 | #[derive(ScheduleLabel, Clone, Debug, PartialEq, Eq, Hash, Default)] 47 | pub struct RollbackPostUpdate; 48 | 49 | pub struct RollbackSchedulePlugin { 50 | schedule: InternedScheduleLabel, 51 | } 52 | 53 | impl RollbackSchedulePlugin { 54 | pub fn new(schedule: impl ScheduleLabel + 'static) -> Self { 55 | Self { 56 | schedule: schedule.intern(), 57 | } 58 | } 59 | 60 | #[cfg(feature = "bevy_ggrs")] 61 | pub fn new_ggrs() -> Self { 62 | Self::new(bevy_ggrs::GgrsSchedule) 63 | } 64 | } 65 | 66 | impl Plugin for RollbackSchedulePlugin { 67 | fn build(&self, app: &mut App) { 68 | // simple "facilitator" schedules benefit from simpler single threaded scheduling 69 | let mut rollback_schedule = Schedule::new(self.schedule); 70 | rollback_schedule.set_executor_kind(ExecutorKind::SingleThreaded); 71 | 72 | for label in RollbackScheduleOrder::default().labels { 73 | app.edit_schedule(label, |schedule| { 74 | schedule.set_build_settings(ScheduleBuildSettings { 75 | ambiguity_detection: LogLevel::Error, 76 | ..default() 77 | }); 78 | }); 79 | } 80 | 81 | app.insert_resource(RollbackScheduleOrder::default()) 82 | .add_systems(self.schedule, run_schedules); 83 | } 84 | } 85 | 86 | //TODO: expose in public API? 87 | /// Defines the schedules to be run for the rollback schedule, including 88 | /// their order. 89 | #[derive(Resource, Debug)] 90 | struct RollbackScheduleOrder { 91 | /// The labels to run for the main phase of the rollback schedule (in the order they will be run). 92 | pub labels: Vec, 93 | } 94 | 95 | impl Default for RollbackScheduleOrder { 96 | fn default() -> Self { 97 | Self { 98 | labels: vec![ 99 | RollbackPreUpdate.intern(), 100 | RollbackStateTransition.intern(), 101 | RollbackUpdate.intern(), 102 | RollbackPostUpdate.intern(), 103 | ], 104 | } 105 | } 106 | } 107 | 108 | fn run_schedules(world: &mut World) { 109 | world.resource_scope(|world, order: Mut| { 110 | for label in &order.labels { 111 | trace!("Running rollback schedule: {:?}", label); 112 | let _ = world.try_run_schedule(*label); 113 | } 114 | }); 115 | } 116 | 117 | #[cfg(test)] 118 | mod tests { 119 | use crate::{InitialStateEntered, RollApp}; 120 | 121 | use super::*; 122 | 123 | #[derive(Resource, Debug, Default)] 124 | struct IntResource(i32); 125 | 126 | fn increase_int_resource(mut int_resource: ResMut) { 127 | int_resource.0 += 1; 128 | } 129 | 130 | #[test] 131 | fn rollback_schedule_in_update() { 132 | let mut app = App::new(); 133 | app.add_plugins(RollbackSchedulePlugin::new(Update)); 134 | app.init_resource::(); 135 | app.add_systems(RollbackUpdate, increase_int_resource); 136 | app.update(); 137 | assert_eq!( 138 | app.world().resource::().0, 139 | 1, 140 | "IntResource should be incremented by 1" 141 | ); 142 | app.update(); 143 | assert_eq!( 144 | app.world().resource::().0, 145 | 2, 146 | "IntResource should be incremented by 1 two times" 147 | ); 148 | } 149 | 150 | #[derive(States, Hash, Default, Debug, Eq, PartialEq, Clone)] 151 | enum GameplayState { 152 | #[default] 153 | InRound, 154 | GameOver, 155 | } 156 | 157 | #[test] 158 | fn add_states_to_rollback_schedule() { 159 | let mut app = App::new(); 160 | app.add_plugins(RollbackSchedulePlugin::new(Update)); 161 | app.init_resource::(); 162 | app.init_roll_state::(); 163 | app.add_systems(OnEnter(GameplayState::InRound), increase_int_resource); 164 | assert!(app.world().contains_resource::>()); 165 | assert!(app.world().contains_resource::>()); 166 | assert_eq!(app.world().resource::().0, 0); 167 | assert!( 168 | !app.world() 169 | .resource::>() 170 | .0 171 | ); 172 | 173 | // calling `update` will cause the initial state to be entered 174 | app.update(); 175 | 176 | assert!( 177 | app.world() 178 | .resource::>() 179 | .0 180 | ); 181 | assert_eq!(app.world().resource::().0, 1); 182 | } 183 | 184 | #[test] 185 | #[should_panic(expected = "RollbackStateTransition")] 186 | fn init_ggrs_states_without_rollback_state_transition_schedule_panics() { 187 | App::new().init_ggrs_state::(); 188 | } 189 | 190 | fn set_game_over_state(mut next_state: ResMut>) { 191 | next_state.set(GameplayState::GameOver); 192 | } 193 | 194 | #[test] 195 | #[cfg(feature = "bevy_ggrs")] 196 | fn can_roll_back_states() { 197 | use bevy_ggrs::{AdvanceWorld, GgrsSchedule, LoadWorld, SaveWorld, SnapshotPlugin}; 198 | 199 | let mut app = App::new(); 200 | 201 | app.add_plugins(SnapshotPlugin) 202 | .add_plugins(RollbackSchedulePlugin::new_ggrs()) 203 | // TODO: use `GgrsPlugin` instead of `SnapshotPlugin` and remove this 204 | .add_systems(AdvanceWorld, |world: &mut World| { 205 | dbg!("Advancing world in GgrsSchedule"); 206 | world.try_run_schedule(GgrsSchedule).unwrap(); 207 | }) 208 | .add_systems(RollbackUpdate, || { 209 | dbg!("RollbackUpdate"); 210 | }) 211 | .init_resource::() 212 | .init_ggrs_state::() 213 | .add_systems( 214 | RollbackUpdate, 215 | // go directly to GameOver state 216 | set_game_over_state.run_if(in_state(GameplayState::InRound)), 217 | ); 218 | 219 | assert_eq!( 220 | *app.world().resource::>(), 221 | GameplayState::InRound 222 | ); 223 | 224 | assert!(matches!( 225 | app.world().resource::>(), 226 | NextState::Unchanged, 227 | )); 228 | 229 | app.world_mut().run_schedule(SaveWorld); 230 | app.world_mut().run_schedule(AdvanceWorld); 231 | 232 | assert_eq!( 233 | *app.world().resource::>(), 234 | GameplayState::InRound, 235 | "State should not change until the next frame" 236 | ); 237 | 238 | assert!(matches!( 239 | app.world().resource::>(), 240 | NextState::Pending(GameplayState::GameOver) 241 | )); 242 | 243 | app.world_mut().run_schedule(AdvanceWorld); 244 | 245 | assert_eq!( 246 | *app.world().resource::>(), 247 | GameplayState::GameOver, 248 | ); 249 | 250 | assert!(matches!( 251 | app.world().resource::>(), 252 | NextState::Unchanged 253 | )); 254 | 255 | // Roll back to frame 0 256 | app.world_mut().run_schedule(LoadWorld); 257 | 258 | assert_eq!( 259 | *app.world().resource::>(), 260 | GameplayState::InRound, 261 | ); 262 | 263 | assert!(matches!( 264 | app.world().resource::>(), 265 | NextState::Unchanged, 266 | )); 267 | } 268 | } 269 | -------------------------------------------------------------------------------- /src/audio.rs: -------------------------------------------------------------------------------- 1 | use bevy::{ 2 | audio::PlaybackMode, 3 | platform::collections::{HashMap, HashSet}, 4 | prelude::*, 5 | }; 6 | #[cfg(feature = "bevy_ggrs")] 7 | use bevy_ggrs::RollbackApp; 8 | use std::time::Duration; 9 | 10 | use crate::{RollbackPostUpdate, RollbackPreUpdate}; 11 | 12 | /// Plugin for managing rollback audio effects in a Bevy application. 13 | /// 14 | /// ```rust 15 | /// # use bevy::prelude::*; 16 | /// # use bevy_roll_safe::prelude::*; 17 | /// # fn start() { 18 | /// fn main() { 19 | /// # let mut app = App::new(); 20 | /// app.add_plugins((RollbackSchedulePlugin::new(FixedUpdate), RollbackAudioPlugin)); 21 | /// } 22 | /// 23 | /// # } 24 | /// # #[derive(Resource)] 25 | /// # struct Sounds { 26 | /// # game_over: Handle, 27 | /// # } 28 | /// fn on_game_over(mut commands: Commands, sounds: Res) { 29 | /// // Play a sound effect when the game is over 30 | /// commands.spawn(RollbackAudioPlayer( 31 | /// AudioPlayer::new(sounds.game_over.clone()) 32 | /// )); 33 | /// } 34 | /// 35 | /// ``` 36 | /// 37 | /// See [`RollbackAudioPlayer`] for more details on how to use this plugin. 38 | pub struct RollbackAudioPlugin; 39 | 40 | impl Plugin for RollbackAudioPlugin { 41 | fn build(&self, app: &mut App) { 42 | app.add_systems(Update, sync_rollback_sounds); 43 | app.add_systems(RollbackPreUpdate, remove_finished_sounds); 44 | app.add_systems(RollbackPostUpdate, start_rollback_sounds); 45 | 46 | #[cfg(feature = "bevy_ggrs")] 47 | { 48 | app.rollback_component_with_clone::(); 49 | app.rollback_component_with_clone::(); 50 | app.rollback_component_with_clone::(); 51 | app.add_systems(RollbackPostUpdate, add_rollback_to_rollback_sounds); 52 | } 53 | } 54 | } 55 | 56 | /// Rollback-safe wrapper around [`AudioPlayer`]. 57 | /// 58 | /// Usage is almost identical to [`AudioPlayer`], but sounds will not be played 59 | /// directly, instead another non-rollback entity will be spawned with the 60 | /// actual audio player. 61 | /// 62 | /// State will be synced once per frame, so if the sound effect is despawned 63 | /// and respawned via rollback, the sound will continue playing without 64 | /// interruption. 65 | #[derive(Component, Clone)] 66 | pub struct RollbackAudioPlayer(pub AudioPlayer); 67 | 68 | impl From for RollbackAudioPlayer { 69 | fn from(audio_player: AudioPlayer) -> Self { 70 | Self(audio_player) 71 | } 72 | } 73 | 74 | /// When the sound effect should have started playing 75 | #[derive(Component, Clone, Debug)] 76 | pub struct RollbackAudioPlayerStartTime(pub Duration); 77 | 78 | /// Represents an instance of a rollback sound effect that is currently playing 79 | #[derive(Component)] 80 | pub struct RollbackAudioPlayerInstance { 81 | /// The desired start time in the rollback world's time 82 | desired_start_time: Duration, 83 | } 84 | 85 | #[derive(PartialEq, Eq, Hash)] 86 | struct PlayingRollbackAudioKey { 87 | audio_source: Handle, 88 | start_time: Duration, 89 | // TODO: add more keys as appropriate if sound effects are colliding 90 | } 91 | 92 | /// Updates playing sounds to match the desired state 93 | /// spawns any missing sounds that should be playing. 94 | /// and despawns any sounds that should not be playing. 95 | pub fn sync_rollback_sounds( 96 | mut commands: Commands, 97 | rollback_audio_players: Query<( 98 | &RollbackAudioPlayer, 99 | &RollbackAudioPlayerStartTime, 100 | Option<&PlaybackSettings>, 101 | )>, 102 | instances: Query<(Entity, &RollbackAudioPlayerInstance, &AudioPlayer)>, 103 | ) { 104 | // todo: Ideally we would use a HashSet with settings, but PlaybackSettings 105 | // is not hashable. So we use a HashMap with the key being the audio source 106 | // and start time. This likely leads to some collisions, but leaving as is 107 | // for now. 108 | let desired_state: HashMap> = 109 | rollback_audio_players 110 | .iter() 111 | .map(|(player, start_time, playback_settings)| { 112 | ( 113 | PlayingRollbackAudioKey { 114 | audio_source: player.0 .0.clone(), 115 | start_time: start_time.0, 116 | }, 117 | playback_settings, 118 | ) 119 | }) 120 | .collect(); 121 | 122 | let mut playing_sounds = HashSet::new(); 123 | 124 | for (instance_entity, instance, audio_player) in &instances { 125 | let rollback_sound_key = PlayingRollbackAudioKey { 126 | audio_source: audio_player.0.clone(), 127 | start_time: instance.desired_start_time, 128 | }; 129 | 130 | // if the playing sound is not in the desired state, despawn it 131 | if !desired_state.contains_key(&rollback_sound_key) { 132 | commands.entity(instance_entity).despawn(); 133 | } else { 134 | playing_sounds.insert(rollback_sound_key); 135 | } 136 | } 137 | 138 | // spawn any missing sounds 139 | for (sound, settings) in desired_state { 140 | if playing_sounds.contains(&sound) { 141 | // if the sound is already playing, skip it 142 | continue; 143 | } 144 | 145 | debug!("Spawning sound: {:?}", sound.audio_source); 146 | 147 | let settings = settings.cloned().unwrap_or(PlaybackSettings::ONCE); 148 | 149 | commands.spawn(( 150 | AudioPlayer::new(sound.audio_source.clone()), 151 | settings, 152 | RollbackAudioPlayerInstance { 153 | desired_start_time: sound.start_time, 154 | }, 155 | )); 156 | } 157 | } 158 | 159 | /// Starts the rollback sounds by recording the current time as the start time 160 | pub fn start_rollback_sounds( 161 | mut commands: Commands, 162 | mut rollback_audio_players: Query< 163 | Entity, 164 | ( 165 | With, 166 | Without, 167 | ), 168 | >, 169 | time: Res