diff --git a/.gitignore b/.gitignore index 0b7ed76..d0ca6d1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ # IDE +.idea .vscode # Generated by Cargo diff --git a/Cargo.toml b/Cargo.toml index b4502a1..8148053 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,7 @@ categories = ["network-programming", "game-development"] wasm-bindgen = ["instant/wasm-bindgen", "ggrs/wasm-bindgen"] [dependencies] -bevy = { version = "0.9.1", default-features = false, features = ["bevy_render", "bevy_asset","bevy_scene",]} +bevy = { version = "0.10", default-features = false, features = ["bevy_render", "bevy_asset","bevy_scene",]} bytemuck = { version = "1.7", features=["derive"]} instant = "0.1" log = "0.4" @@ -27,9 +27,10 @@ parking_lot = "0.12.1" [dev-dependencies] structopt = "0.3" rand = "0.8.4" -bevy = "0.9.1" +bevy = "0.10" serde = "1.0.130" serde_json = "1.0" +serial_test = "1.0.0" # Examples [[example]] diff --git a/examples/box_game/box_game.rs b/examples/box_game/box_game.rs index 705c448..8a37c9d 100644 --- a/examples/box_game/box_game.rs +++ b/examples/box_game/box_game.rs @@ -91,7 +91,10 @@ pub fn setup_system( // plane commands.spawn(PbrBundle { - mesh: meshes.add(Mesh::from(shape::Plane { size: PLANE_SIZE })), + mesh: meshes.add(Mesh::from(shape::Plane { + size: PLANE_SIZE, + ..default() + })), material: materials.add(Color::rgb(0.3, 0.5, 0.3).into()), ..default() }); diff --git a/examples/box_game/box_game_p2p.rs b/examples/box_game/box_game_p2p.rs index 7538f80..c3903f5 100644 --- a/examples/box_game/box_game_p2p.rs +++ b/examples/box_game/box_game_p2p.rs @@ -1,7 +1,7 @@ use std::net::SocketAddr; -use bevy::prelude::*; -use bevy_ggrs::{GGRSPlugin, Session}; +use bevy::{prelude::*, window::WindowResolution}; +use bevy_ggrs::{GGRSPlugin, GGRSSchedule, Session}; use ggrs::{PlayerType, SessionBuilder, UdpNonBlockingSocket}; use structopt::StructOpt; @@ -9,7 +9,6 @@ mod box_game; use box_game::*; const FPS: usize = 60; -const ROLLBACK_DEFAULT: &str = "rollback_default"; // structopt will read command line parameters for u #[derive(StructOpt, Resource)] @@ -68,30 +67,22 @@ fn main() -> Result<(), Box> { .register_rollback_component::() .register_rollback_component::() .register_rollback_resource::() - // these systems will be executed as part of the advance frame update - .with_rollback_schedule( - Schedule::default().with_stage( - ROLLBACK_DEFAULT, - SystemStage::parallel() - .with_system(move_cube_system) - .with_system(increase_frame_system), - ), - ) // make it happen in the bevy app .build(&mut app); // continue building/running the app like you normally would app.insert_resource(opt) .add_plugins(DefaultPlugins.set(WindowPlugin { - window: WindowDescriptor { - width: 720., - height: 720., + primary_window: Some(Window { + resolution: WindowResolution::new(720., 720.), title: "GGRS Box Game".to_owned(), ..default() - }, + }), ..default() })) .add_startup_system(setup_system) + // these systems will be executed as part of the advance frame update + .add_systems((move_cube_system, increase_frame_system).in_schedule(GGRSSchedule)) // add your GGRS session .insert_resource(Session::P2PSession(sess)) // register a resource that will be rolled back diff --git a/examples/box_game/box_game_spectator.rs b/examples/box_game/box_game_spectator.rs index 72e31b7..788ad3a 100644 --- a/examples/box_game/box_game_spectator.rs +++ b/examples/box_game/box_game_spectator.rs @@ -1,7 +1,7 @@ use std::net::SocketAddr; use bevy::prelude::*; -use bevy_ggrs::{GGRSPlugin, Session}; +use bevy_ggrs::{GGRSPlugin, GGRSSchedule, Session}; use ggrs::{SessionBuilder, UdpNonBlockingSocket}; use structopt::StructOpt; @@ -9,7 +9,6 @@ mod box_game; use box_game::*; const FPS: usize = 60; -const ROLLBACK_DEFAULT: &str = "rollback_default"; // structopt will read command line parameters for u #[derive(StructOpt, Resource)] @@ -47,15 +46,6 @@ fn main() -> Result<(), Box> { .register_rollback_component::() .register_rollback_component::() .register_rollback_resource::() - // these systems will be executed as part of the advance frame update - .with_rollback_schedule( - Schedule::default().with_stage( - ROLLBACK_DEFAULT, - SystemStage::parallel() - .with_system(move_cube_system) - .with_system(increase_frame_system), - ), - ) // make it happen in the bevy app .build(&mut app); @@ -63,6 +53,8 @@ fn main() -> Result<(), Box> { app.insert_resource(opt) .add_plugins(DefaultPlugins) .add_startup_system(setup_system) + // these systems will be executed as part of the advance frame update + .add_systems((move_cube_system, increase_frame_system).in_schedule(GGRSSchedule)) // add your GGRS session .insert_resource(Session::SpectatorSession(sess)) // register a resource that will be rolled back diff --git a/examples/box_game/box_game_synctest.rs b/examples/box_game/box_game_synctest.rs index 788c631..f3a4e76 100644 --- a/examples/box_game/box_game_synctest.rs +++ b/examples/box_game/box_game_synctest.rs @@ -1,5 +1,5 @@ use bevy::prelude::*; -use bevy_ggrs::{GGRSPlugin, Session}; +use bevy_ggrs::{GGRSPlugin, GGRSSchedule, Session}; use ggrs::{PlayerType, SessionBuilder}; use structopt::StructOpt; @@ -7,7 +7,6 @@ mod box_game; use box_game::*; const FPS: usize = 60; -const ROLLBACK_DEFAULT: &str = "rollback_default"; // structopt will read command line parameters for u #[derive(StructOpt, Resource)] @@ -47,15 +46,6 @@ fn main() -> Result<(), Box> { .register_rollback_component::() .register_rollback_component::() .register_rollback_resource::() - // these systems will be executed as part of the advance frame update - .with_rollback_schedule( - Schedule::default().with_stage( - ROLLBACK_DEFAULT, - SystemStage::parallel() - .with_system(move_cube_system) - .with_system(increase_frame_system), - ), - ) // make it happen in the bevy app .build(&mut app); @@ -63,6 +53,8 @@ fn main() -> Result<(), Box> { app.insert_resource(opt) .add_plugins(DefaultPlugins) .add_startup_system(setup_system) + // these systems will be executed as part of the advance frame update + .add_systems((move_cube_system, increase_frame_system).in_schedule(GGRSSchedule)) // add your GGRS session .insert_resource(Session::SyncTestSession(sess)) // register a resource that will be rolled back diff --git a/src/ggrs_stage.rs b/src/ggrs_stage.rs index 98da9a3..0b4ed58 100644 --- a/src/ggrs_stage.rs +++ b/src/ggrs_stage.rs @@ -1,17 +1,16 @@ -use crate::{world_snapshot::WorldSnapshot, PlayerInputs, Session}; +use crate::{world_snapshot::WorldSnapshot, GGRSSchedule, PlayerInputs, Session}; use bevy::{prelude::*, reflect::TypeRegistry}; use ggrs::{ Config, GGRSError, GGRSRequest, GameStateCell, InputStatus, PlayerHandle, SessionState, }; use instant::{Duration, Instant}; +#[derive(Resource)] /// The GGRSStage handles updating, saving and loading the game state. pub(crate) struct GGRSStage where T: Config, { - /// Inside this schedule, all rollback systems are registered. - schedule: Schedule, /// Used to register all types considered when loading and saving pub(crate) type_registry: TypeRegistry, /// This system is used to get an encoded representation of the input that GGRS can handle @@ -30,16 +29,20 @@ where run_slow: bool, } -impl Stage for GGRSStage { - fn run(&mut self, world: &mut World) { +impl GGRSStage { + pub(crate) fn run(world: &mut World) { + let mut stage = world + .remove_resource::>() + .expect("failed to extract ggrs schedule"); + // get delta time from last run() call and accumulate it - let delta = Instant::now().duration_since(self.last_update); - let mut fps_delta = 1. / self.update_frequency as f64; - if self.run_slow { + let delta = Instant::now().duration_since(stage.last_update); + let mut fps_delta = 1. / stage.update_frequency as f64; + if stage.run_slow { fps_delta *= 1.1; } - self.accumulator = self.accumulator.saturating_add(delta); - self.last_update = Instant::now(); + stage.accumulator = stage.accumulator.saturating_add(delta); + stage.last_update = Instant::now(); // no matter what, poll remotes and send responses if let Some(mut session) = world.get_resource_mut::>() { @@ -55,28 +58,29 @@ impl Stage for GGRSStage { } // if we accumulated enough time, do steps - while self.accumulator.as_secs_f64() > fps_delta { + while stage.accumulator.as_secs_f64() > fps_delta { // decrease accumulator - self.accumulator = self + stage.accumulator = stage .accumulator .saturating_sub(Duration::from_secs_f64(fps_delta)); // depending on the session type, doing a single update looks a bit different let session = world.get_resource::>(); match session { - Some(&Session::SyncTestSession(_)) => self.run_synctest(world), - Some(&Session::P2PSession(_)) => self.run_p2p(world), - Some(&Session::SpectatorSession(_)) => self.run_spectator(world), - _ => self.reset(), // No session has been started yet + Some(&Session::SyncTestSession(_)) => stage.run_synctest(world), + Some(&Session::P2PSession(_)) => stage.run_p2p(world), + Some(&Session::SpectatorSession(_)) => stage.run_spectator(world), + _ => stage.reset(), // No session has been started yet } } + + world.insert_resource(stage); } } impl GGRSStage { pub(crate) fn new(input_system: Box>) -> Self { Self { - schedule: Schedule::default(), type_registry: TypeRegistry::default(), input_system, snapshots: Vec::new(), @@ -248,7 +252,7 @@ impl GGRSStage { ) { debug!("advancing to frame: {}", self.frame + 1); world.insert_resource(PlayerInputs::(inputs)); - self.schedule.run_once(world); + world.run_schedule(GGRSSchedule); world.remove_resource::>(); self.frame += 1; debug!("frame {} completed", self.frame); @@ -258,10 +262,6 @@ impl GGRSStage { self.update_frequency = update_frequency } - pub(crate) fn set_schedule(&mut self, schedule: Schedule) { - self.schedule = schedule; - } - pub(crate) fn set_type_registry(&mut self, type_registry: TypeRegistry) { self.type_registry = type_registry; } diff --git a/src/lib.rs b/src/lib.rs index c93b047..5b7c0c2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,7 @@ #![forbid(unsafe_code)] // let us try use bevy::{ + ecs::schedule::{LogLevel, ScheduleBuildSettings, ScheduleLabel}, prelude::*, reflect::{FromType, GetTypeRegistration, TypeRegistry, TypeRegistryInternal}, }; @@ -15,10 +16,11 @@ pub use ggrs; pub(crate) mod ggrs_stage; pub(crate) mod world_snapshot; -/// Stage label for the Custom GGRS Stage. -pub const GGRS_UPDATE: &str = "ggrs_update"; const DEFAULT_FPS: usize = 60; +#[derive(ScheduleLabel, Debug, Hash, PartialEq, Eq, Clone)] +pub struct GGRSSchedule; + /// Defines the Session that the GGRS Plugin should expect as a resource. #[derive(Resource)] pub enum Session { @@ -94,7 +96,6 @@ pub struct GGRSPlugin { input_system: Option>>, fps: usize, type_registry: TypeRegistry, - schedule: Schedule, } impl Default for GGRSPlugin { @@ -118,7 +119,6 @@ impl Default for GGRSPlugin { r })), }, - schedule: Default::default(), } } } @@ -172,13 +172,6 @@ impl GGRSPlugin { self } - /// Adds a schedule into the GGRSStage that holds the game logic systems. This schedule should contain all - /// systems you want to be executed during frame advances. - pub fn with_rollback_schedule(mut self, schedule: Schedule) -> Self { - self.schedule = schedule; - self - } - /// Consumes the builder and makes changes on the bevy app according to the settings. pub fn build(self, app: &mut App) { let mut input_system = self @@ -188,9 +181,17 @@ impl GGRSPlugin { input_system.initialize(&mut app.world); let mut stage = GGRSStage::::new(input_system); stage.set_update_frequency(self.fps); - stage.set_schedule(self.schedule); + + let mut schedule = Schedule::default(); + schedule.set_build_settings(ScheduleBuildSettings { + ambiguity_detection: LogLevel::Error, + ..default() + }); + app.add_schedule(GGRSSchedule, schedule); + stage.set_type_registry(self.type_registry); - app.add_stage_before(CoreStage::Update, GGRS_UPDATE, stage); + app.add_system(GGRSStage::::run.in_base_set(CoreSet::PreUpdate)); + app.insert_resource(stage); // other resources app.insert_resource(RollbackIdProvider::default()); } diff --git a/src/world_snapshot.rs b/src/world_snapshot.rs index 436f201..8065be5 100644 --- a/src/world_snapshot.rs +++ b/src/world_snapshot.rs @@ -89,7 +89,8 @@ impl WorldSnapshot { .enumerate() { let entity = entity.entity(); - if let Some(component) = reflect_component.reflect(world, entity) { + let entity_ref = world.entity(entity); + if let Some(component) = reflect_component.reflect(entity_ref) { assert_eq!(entity, snapshot.entities[entities_offset + i].entity); // add the hash value of that component to the shapshot checksum, if that component supports hashing if let Some(hash) = component.reflect_hash() { @@ -174,11 +175,15 @@ impl WorldSnapshot { // For example, an apply() will do an in-place update such that apply an // array to an array will add items to the array instead of completely // replacing the current array with the new one. - reflect_component.remove(world, entity); - reflect_component.insert(world, entity, &**component); + let mut entity_mut = world.entity_mut(entity); + reflect_component.remove(&mut entity_mut); + reflect_component.insert(&mut entity_mut, &**component); } // if we don't have any data saved, we need to remove that component from the entity - None => reflect_component.remove(world, entity), + None => { + let mut entity_mut = world.entity_mut(entity); + reflect_component.remove(&mut entity_mut); + } } } else { // the entity in the world has no such component @@ -188,7 +193,8 @@ impl WorldSnapshot { .find(|comp| comp.type_name() == registration.type_name()) { // if we have data saved in the snapshot, add the component to the entity - reflect_component.insert(world, entity, &**component); + let mut entity_mut = world.entity_mut(entity); + reflect_component.insert(&mut entity_mut, &**component); } // if both the snapshot and the world does not have the registered component, we don't need to to anything } diff --git a/tests/entity_maping.rs b/tests/entity_maping.rs index 6850aaf..affb976 100644 --- a/tests/entity_maping.rs +++ b/tests/entity_maping.rs @@ -88,16 +88,10 @@ fn entity_mapping() { .register_rollback_component::() .register_rollback_component::() .register_rollback_resource::() - .with_rollback_schedule( - Schedule::default().with_stage( - "default", - SystemStage::single_threaded() - .with_system(delete_child_system) - .with_system(frame_counter.before(delete_child_system)), - ), - ) .build(&mut app); + app.add_systems((frame_counter, delete_child_system).chain().in_schedule(GGRSSchedule)); + // Sleep helper that will make sure at least one frame should be executed by the GGRS fixed // update loop. let sleep = || std::thread::sleep(Duration::from_secs_f32(1.0 / 60.0)); diff --git a/tests/example_integration.rs b/tests/example_integration.rs new file mode 100644 index 0000000..d0a183b --- /dev/null +++ b/tests/example_integration.rs @@ -0,0 +1,194 @@ +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use serial_test::serial; +use bevy::MinimalPlugins; +use bevy::core::{Pod, Zeroable}; +use bevy::input::{ButtonState, Input, InputPlugin}; +use bevy::input::keyboard::KeyboardInput; +use bevy::prelude::*; +use bevy_ggrs::{GGRSPlugin, GGRSSchedule, PlayerInputs, Rollback, RollbackIdProvider, Session}; +use ggrs::{Config, SessionBuilder, PlayerType, UdpNonBlockingSocket, PlayerHandle, P2PSession}; + +#[test] +#[serial] +fn it_syncs_rollback_resources() -> Result<(), Box> { + let (player1, player2) = create_players(); + let session1 = start_session(&player1, &player2)?; + let mut app1 = create_app::(session1); + let session2 = start_session(&player2, &player1)?; + let mut app2 = create_app::(session2); + + for _ in 0..50 { + app1.update(); + app2.update(); + } + + let frame_count1 = app1.world.get_resource::().unwrap(); + let frame_count2 = app2.world.get_resource::().unwrap(); + assert!(frame_count1.frame > 0); + assert!(frame_count2.frame > 0); + assert_eq!(frame_count1.frame, frame_count2.frame); + Ok(()) +} + +#[test] +#[serial] +fn it_syncs_rollback_components() -> Result<(), Box> { + let (player1, player2) = create_players(); + let session1 = start_session(&player1, &player2)?; + let mut app1 = create_app::(session1); + let session2 = start_session(&player2, &player1)?; + let mut app2 = create_app::(session2); + + for _ in 0..250 { + press_key(&mut app1, KeyCode::W); + app1.update(); + app2.update(); + } + + let mut app2_query = app2.world.query::<(&Transform, &PlayerComponent)>(); + for (transform, player) in app2_query.iter(&app2.world) { + if player.handle == player1.handle { + assert!(transform.translation.z < 0., "Remote player moves forward"); + } + } + Ok(()) +} + +pub struct TestPlayer { + handle: PlayerHandle, + address: SocketAddr, +} + +fn create_players() -> (TestPlayer, TestPlayer) { + const PLAYER1_PORT: u16 = 8081; + const REMOTE_PORT: u16 = 8082; + let remote_addr1 = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), PLAYER1_PORT); + let remote_addr2 = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), REMOTE_PORT); + return (TestPlayer { handle: 0, address: remote_addr1 }, TestPlayer { handle: 1, address: remote_addr2 }); +} + +fn start_session(local_player: &TestPlayer, remote_player: &TestPlayer) + -> Result, Box> { + let mut session_builder = SessionBuilder::::new() + .with_num_players(2) + .with_max_prediction_window(12) // (optional) set max prediction window + .with_input_delay(2); // (optional) set input delay for the local player + session_builder = session_builder.add_player(PlayerType::Local, local_player.handle)?; + session_builder = session_builder.add_player(PlayerType::Remote(remote_player.address), remote_player.handle)?; + let socket = UdpNonBlockingSocket::bind_to_port(local_player.address.port())?; + let session = session_builder.start_p2p_session(socket)?; + Ok(session) +} + +fn create_app(session: P2PSession) -> App { + const FPS: usize = 60; + let mut app = App::new(); + app.add_plugins(MinimalPlugins); + app.add_plugin(InputPlugin::default()); + + GGRSPlugin::::new() + .with_update_frequency(FPS) + .with_input_system(rollback_input_system) + .register_rollback_resource::() + .register_rollback_component::() + .register_rollback_component::() + .build(&mut app); + + app.add_startup_system(spawn_players) + .add_systems((move_player_system, increase_frame_system).in_schedule(GGRSSchedule)) + .insert_resource(Session::P2PSession(session)) + .insert_resource(FrameCount { frame: 0 }); + app +} + +pub struct GGRSConfig; +impl Config for GGRSConfig { + type Input = BoxInput; + type State = u8; + type Address = SocketAddr; +} + +#[repr(C)] +#[derive(Copy, Clone, PartialEq, Eq, Pod, Zeroable)] +pub struct BoxInput { + pub inp: u8, +} + +#[derive(Resource, Default, Reflect, Hash)] +#[reflect(Hash)] +pub struct FrameCount { + pub frame: u32, +} + +// Components that should be saved/loaded need to implement the `Reflect` trait +#[derive(Default, Reflect, Component)] +pub struct Velocity { + pub x: f32, + pub y: f32, + pub z: f32, +} + +pub fn increase_frame_system(mut frame_count: ResMut) { + frame_count.frame += 1; +} + +const INPUT_UP: u8 = 1 << 0; + +pub fn rollback_input_system(_handle: In, keyboard_input: Res>) -> BoxInput { + let mut input: u8 = 0; + if keyboard_input.pressed(KeyCode::W) { + input |= INPUT_UP; + } + BoxInput { inp: input } +} + + +fn press_key(app: &mut App, key: KeyCode) { + app.world.send_event(KeyboardInput { + scan_code: 0, + key_code: Option::from(key), + state: ButtonState::Pressed, + }); +} + +#[derive(Component, Clone, Copy, Default)] +pub struct PlayerComponent { + pub handle: usize, +} + +pub fn move_player_system( + mut query: Query<(&mut Transform, &mut Velocity, &PlayerComponent), With>, + inputs: Res>, +) { + const MOVEMENT_SPEED: f32 = 0.1; + for (mut t, mut v, p) in query.iter_mut() { + let input = inputs[p.handle].0.inp; + if input & INPUT_UP != 0 { + v.z -= MOVEMENT_SPEED; + } + t.translation.z += v.z; + } +} + +pub fn spawn_players( + mut commands: Commands, + mut rip: ResMut, + session: Res>, +) { + let num_players = match &*session { + Session::SyncTestSession(s) => s.num_players(), + Session::P2PSession(s) => s.num_players(), + Session::SpectatorSession(s) => s.num_players(), + }; + + for handle in 0..num_players { + commands.spawn(( + PlayerComponent { handle }, + Velocity::default(), + Transform::default(), + rip.next(), + )); + } +} + +