use std::collections::HashSet; use bevy::math::DVec2; use bevy::transform::TransformSystems; use bevy_replicon::client::confirm_history::ConfirmHistory; use bevy_replicon::prelude::Replicated; use bevy_replicon::shared::replicon_tick::RepliconTick; use crate::prelude::*; use crate::shared::config::planet::{Planet, PlanetSpring}; use crate::shared::world_config::WorldConfigResource; const SKIP_THRESHOLD: f64 = 10.0; #[derive(Component, Default)] struct LocalState { position: DVec2, linvel: DVec2, angvel: f64, rotation_cos: f64, rotation_sin: f64, } #[derive(Component, Default)] struct CorrectionTarget { needs_correction: bool, position: DVec2, linvel: DVec2, angvel: f64, rotation_cos: f64, rotation_sin: f64, last_tick: Option, } #[derive(Resource, Default)] struct LocalTick(u64); #[derive(Resource)] struct LagEstimate { horizon: f64, last_local_tick: u64, } impl Default for LagEstimate { fn default() -> Self { Self { horizon: 4.0, last_local_tick: 0 } } } #[derive(Resource, Default)] struct PlanetPositions(Vec<(DVec2, f64)>); /// Public so future thrust-prediction systems can replay buffered inputs at the correct tick. #[derive(Resource, Default)] pub struct Resimulating { pub active: bool, /// The absolute `LocalTick` value corresponding to the current resimulation step. pub resim_tick: u64, } struct PhysicsSnapshot { position: DVec2, rotation_cos: f64, rotation_sin: f64, linvel: DVec2, angvel: f64, } impl PhysicsSnapshot { fn from_target(t: &CorrectionTarget) -> Self { Self { position: t.position, rotation_cos: t.rotation_cos, rotation_sin: t.rotation_sin, linvel: t.linvel, angvel: t.angvel, } } fn from_components( p: &Position, r: &Rotation, lv: &LinearVelocity, av: &AngularVelocity, ) -> Self { Self { position: p.0, rotation_cos: r.cos, rotation_sin: r.sin, linvel: lv.0, angvel: av.0, } } fn apply(&self, world: &mut World, entity: Entity) { let mut e = world.entity_mut(entity); if let Some(mut p) = e.get_mut::() { p.0 = self.position; } if let Some(mut r) = e.get_mut::() { r.cos = self.rotation_cos; r.sin = self.rotation_sin; } if let Some(mut lv) = e.get_mut::() { lv.0 = self.linvel; } if let Some(mut av) = e.get_mut::() { av.0 = self.angvel; } } } pub fn prediction_plugin(app: &mut App) { app .init_resource::() .init_resource::() .init_resource::() .init_resource::() .add_observer(init_prediction) .add_systems(FixedFirst, tick_local_clock) .add_systems(PreUpdate, ( ( save_local_state, collect_planet_positions, ).before(bevy_replicon::client::ClientSystems::Receive), record_server_correction.after(bevy_replicon::client::ClientSystems::Receive), )) .add_systems(PostUpdate, perform_resimulation.before(TransformSystems::Propagate)); } fn tick_local_clock(mut tick: ResMut) { tick.0 += 1; } fn init_prediction( trigger: On, query: Query< (&Position, &Rotation, &LinearVelocity, Option<&AngularVelocity>), (Without, Without), >, mut commands: Commands, ) { let entity = trigger.event_target(); let Ok((pos, rot, linvel, angvel)) = query.get(entity) else { return }; commands.entity(entity).insert(( LocalState { position: pos.0, linvel: linvel.0, angvel: angvel.map(|a| a.0).unwrap_or(0.0), rotation_cos: rot.cos, rotation_sin: rot.sin, }, CorrectionTarget::default(), )); } fn collect_planet_positions( mut resource: ResMut, planets: Query<(&Position, &Mass), (With, Without)>, ) { resource.0 = planets.iter().map(|(p, m)| (p.0, m.0 as f64)).collect(); } fn save_local_state( mut query: Query<(&Position, &Rotation, &LinearVelocity, &AngularVelocity, &mut LocalState)>, ) { for (pos, rot, linvel, angvel, mut local) in &mut query { if pos.0.is_nan() { continue; } local.position = pos.0; local.linvel = linvel.0; local.angvel = angvel.0; local.rotation_cos = rot.cos; local.rotation_sin = rot.sin; } } fn record_server_correction( mut query: Query<( &mut Position, &mut Rotation, &mut LinearVelocity, &mut AngularVelocity, &ConfirmHistory, &mut CorrectionTarget, &LocalState, )>, planet_positions: Res, world_config: Res, local_tick: Res, mut lag: ResMut, ) { let Some(cfg) = &world_config.config else { for (mut pos, mut rot, mut linvel, mut angvel, _, _, local) in &mut query { pos.0 = local.position; rot.cos = local.rotation_cos; rot.sin = local.rotation_sin; linvel.0 = local.linvel; angvel.0 = local.angvel; } return; }; let planet_snapshot = &planet_positions.0; for (mut pos, mut rot, mut linvel, mut angvel, history, mut target, local) in &mut query { let tick = history.last_tick(); if target.last_tick == Some(tick) { pos.0 = local.position; rot.cos = local.rotation_cos; rot.sin = local.rotation_sin; linvel.0 = local.linvel; angvel.0 = local.angvel; continue; } let elapsed = (local_tick.0.saturating_sub(lag.last_local_tick)).clamp(1, 20) as f64; lag.horizon = lag.horizon * 0.9 + elapsed * 0.1; lag.last_local_tick = local_tick.0; target.last_tick = Some(tick); let server_pos = pos.0; let server_vel = linvel.0; let n = lag.horizon.round() as u64; let dt = 1.0 / crate::shared::plugins::TICK_RATE; let (ext_pos, _) = extrapolate( server_pos, server_vel, &planet_snapshot, n, dt, cfg.world.gravity, ); let error = (ext_pos - local.position).length(); if error < SKIP_THRESHOLD { target.needs_correction = false; } else { target.needs_correction = true; target.position = server_pos; target.linvel = server_vel; target.angvel = angvel.0; target.rotation_cos = rot.cos; target.rotation_sin = rot.sin; } pos.0 = local.position; rot.cos = local.rotation_cos; rot.sin = local.rotation_sin; linvel.0 = local.linvel; angvel.0 = local.angvel; } } fn extrapolate( mut pos: DVec2, mut vel: DVec2, planets: &[(DVec2, f64)], n: u64, dt: f64, gravity: f64, ) -> (DVec2, DVec2) { for _ in 0..n { let mut accel = DVec2::ZERO; for &(planet_pos, planet_mass) in planets { let diff = planet_pos - pos; let dist_sq = diff.length_squared().max(1.0); accel += diff.normalize() * gravity * planet_mass / dist_sq; } vel += accel * dt; pos += vel * dt; } (pos, vel) } fn perform_resimulation(world: &mut World) { let corrections: Vec<(Entity, PhysicsSnapshot)> = { let mut q = world.query::<(Entity, &CorrectionTarget)>(); q.iter(world) .filter(|(_, t)| t.needs_correction) .map(|(e, t)| (e, PhysicsSnapshot::from_target(t))) .collect() }; if corrections.is_empty() { return; } let saved: Vec<(Entity, PhysicsSnapshot)> = { let mut q = world.query::<( Entity, &Position, &Rotation, &LinearVelocity, &AngularVelocity, )>(); q.iter(world) .map(|(e, p, r, lv, av)| (e, PhysicsSnapshot::from_components(p, r, lv, av))) .collect() }; // Save Transforms separately; avian2d's Writeback overwrites Transform in each FixedUpdate // step, so non-corrected entities would visually jump if we don't restore it. let saved_transforms: Vec<(Entity, Vec3, Quat)> = { let mut q = world.query::<(Entity, &Transform)>(); q.iter(world) .map(|(e, t)| (e, t.translation, t.rotation)) .collect() }; let corrected_set: HashSet = corrections.iter().map(|(e, _)| *e).collect(); for (entity, snapshot) in &corrections { snapshot.apply(world, *entity); } let n = world.resource::().horizon.round() as u64; let base_tick = world.resource::().0; world.resource_mut::().active = true; for step in 0..n { world.resource_mut::().resim_tick = base_tick + step; world.run_schedule(FixedUpdate); } world.resource_mut::().active = false; for (entity, snapshot) in &saved { if !corrected_set.contains(entity) { snapshot.apply(world, *entity); } } // Restore transforms for non-corrected entities so the visual doesn't jump. for (entity, translation, rotation) in &saved_transforms { if !corrected_set.contains(entity) { let mut e = world.entity_mut(*entity); if let Some(mut t) = e.get_mut::() { t.translation = *translation; t.rotation = *rotation; } } } for (entity, _) in &corrections { if let Some(mut t) = world.entity_mut(*entity).get_mut::() { t.needs_correction = false; } } }