//! Deterministic fixed-step gameplay simulation. //! //! The sim crate owns gameplay state and movement. Client/editor crates feed it //! protocol input intent and keep rendering, audio, UI, and tools outside this layer. #![allow(clippy::type_complexity)] use avian3d::prelude::*; use bevy::prelude::*; use bevy::time::Fixed; use protocol::{PlayerInputIntent, ProtocolPlugin, SIM_DELTA_SECS, SIM_TICK_RATE_HZ}; use settings::SimTuning; use shared::SharedTypesPlugin; /// Tunable gameplay/movement constants, grouped for easy tweaking. pub mod tuning { /// Capsule radius (meters). pub const PLAYER_RADIUS: f32 = 0.4; /// Length of the capsule's cylindrical section (meters). /// Total standing height is `PLAYER_LENGTH + 2 * PLAYER_RADIUS`. pub const PLAYER_LENGTH: f32 = 1.0; /// Camera (eye) offset above the capsule center while standing. pub const EYE_OFFSET_STAND: f32 = 0.6; /// Camera (eye) offset above the capsule center while crouching. pub const EYE_OFFSET_CROUCH: f32 = 0.1; /// Downward acceleration (m/s^2). Slightly stronger than Earth for snappy feel. pub const GRAVITY: f32 = 22.0; /// Ground walking speed (m/s). pub const WALK_SPEED: f32 = 6.0; /// Sprint speed (m/s). pub const RUN_SPEED: f32 = 9.5; /// Crouch speed (m/s). pub const CROUCH_SPEED: f32 = 3.0; /// Upward launch velocity on jump (m/s). pub const JUMP_SPEED: f32 = 7.5; /// Horizontal acceleration while grounded (m/s^2). pub const GROUND_ACCEL: f32 = 70.0; /// Horizontal acceleration while airborne (m/s^2). pub const AIR_ACCEL: f32 = 16.0; /// Distance below the feet used to detect ground (meters). pub const GROUND_CHECK_DIST: f32 = 0.2; /// Collision skin width kept between the capsule and surfaces (meters). pub const SKIN: f32 = 0.02; /// Maximum collide-and-slide iterations per fixed tick. pub const MAX_SLIDE_ITER: u32 = 4; /// A surface counts as ground when its normal.y exceeds this (roughly <60 degree slope). pub const GROUND_NORMAL_Y: f32 = 0.5; /// How long after leaving a ledge a jump is still allowed (seconds). pub const COYOTE_TIME: f32 = 0.12; /// How long a jump press is remembered before landing (seconds). pub const JUMP_BUFFER: f32 = 0.12; } /// Marks the player's root (body) entity. #[derive(Component, Reflect, Default)] #[reflect(Component, Default)] pub struct Player; /// The player's current world-space velocity (m/s). #[derive(Component, Reflect, Default, Deref, DerefMut)] #[reflect(Component, Default)] pub struct PlayerVelocity(pub Vec3); /// Whether the player is currently standing on walkable ground. #[derive(Component, Reflect, Default)] #[reflect(Component, Default)] pub struct Grounded(pub bool); /// Whether the player is currently crouching. #[derive(Component, Reflect, Default)] #[reflect(Component, Default)] pub struct Crouching(pub bool); /// Timers backing forgiving jump input (coyote-time and jump-buffering). #[derive(Component, Reflect, Default)] #[reflect(Component, Default)] pub struct JumpState { /// Time remaining during which a late jump is still permitted. pub coyote: f32, /// Time remaining during which a buffered jump press is still valid. pub buffer: f32, } /// Horizontal look sensitivity consumed by the fixed-step sim. #[derive(Component, Reflect, Deref, DerefMut)] #[reflect(Component, Default)] pub struct PlayerYawSensitivity(pub f32); impl Default for PlayerYawSensitivity { fn default() -> Self { Self(0.003) } } /// Marks the player's first-person camera (a child of the [`Player`] body). #[derive(Component, Reflect, Default)] #[reflect(Component, Default)] pub struct PlayerCamera; /// Per-axis mouse sensitivity (radians of rotation per pixel of motion). #[derive(Component, Reflect, Deref, DerefMut)] #[reflect(Component, Default)] pub struct CameraSensitivity(pub Vec2); impl Default for CameraSensitivity { fn default() -> Self { Self(Vec2::new(0.003, 0.0022)) } } /// Controls whether runtime player input/movement systems should run. #[derive(Resource, Debug, Clone, Copy, Reflect)] #[reflect(Resource)] pub struct GameInputEnabled(pub bool); impl Default for GameInputEnabled { fn default() -> Self { Self(true) } } /// When false, FPS look/movement intent is suppressed (e.g. pointer outside Game View). #[derive(Resource, Debug, Clone, Copy, Reflect)] #[reflect(Resource)] pub struct GameInputFocused(pub bool); impl Default for GameInputFocused { fn default() -> Self { Self(true) } } /// Controls whether fixed-step gameplay simulation is advancing. /// /// The standalone game leaves this enabled. The in-process editor disables it /// while editing so pressing Play is a clean gameplay boundary instead of /// inheriting a player body that has been simulating off-camera. #[derive(Resource, Debug, Clone, Copy, Reflect)] #[reflect(Resource)] pub struct SimEnabled(pub bool); impl Default for SimEnabled { fn default() -> Self { Self(true) } } /// Fixed-step gameplay simulation plugin. pub struct SimPlugin; impl Plugin for SimPlugin { fn build(&self, app: &mut App) { app.add_plugins((ProtocolPlugin, PhysicsPlugins::default(), SharedTypesPlugin)) .insert_resource(Time::::from_hz(SIM_TICK_RATE_HZ as f64)) .init_resource::() .register_type::() .register_type::() .register_type::() .register_type::() .register_type::() .register_type::() .register_type::() .register_type::() .register_type::() .register_type::() .register_type::(); } } /// Deterministic no-collision state used by unit tests and future replay checks. #[derive(Debug, Clone, Copy, PartialEq)] pub struct DeterminismState { pub position: Vec3, pub velocity: Vec3, pub yaw: f32, pub grounded: bool, pub crouching: bool, pub coyote: f32, pub jump_buffer: f32, } impl Default for DeterminismState { fn default() -> Self { Self { position: Vec3::ZERO, velocity: Vec3::ZERO, yaw: 0.0, grounded: true, crouching: false, coyote: tuning::COYOTE_TIME, jump_buffer: 0.0, } } } /// A compact, stable summary suitable for equality and hashing. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct SimStateSummary { pub position: [u32; 3], pub velocity: [u32; 3], pub yaw: u32, pub grounded: bool, pub crouching: bool, } impl From<&DeterminismState> for SimStateSummary { fn from(state: &DeterminismState) -> Self { Self { position: vec3_bits(state.position), velocity: vec3_bits(state.velocity), yaw: state.yaw.to_bits(), grounded: state.grounded, crouching: state.crouching, } } } /// Steps a simplified no-collision controller with the same intent semantics as the ECS sim. pub fn step_determinism_state( state: &mut DeterminismState, intent: &PlayerInputIntent, tuning: &SimTuning, ) { let dt = SIM_DELTA_SECS; state.yaw -= intent.look_delta.x * PlayerYawSensitivity::default().0; let (sin, cos) = state.yaw.sin_cos(); let forward = Vec3::new(-sin, 0.0, -cos); let right = Vec3::new(cos, 0.0, -sin); let movement = intent.movement.clamp_length_max(1.0); let wish = (right * movement.x + forward * movement.y).normalize_or_zero(); state.crouching = intent.crouch_held; let speed = if state.crouching { tuning.crouch_speed } else if intent.sprint_held { tuning.run_speed } else { tuning.walk_speed }; accelerate_horizontal(&mut state.velocity, wish, speed, state.grounded, dt, tuning); if intent.jump_pressed { state.jump_buffer = tuning.jump_buffer; } else { state.jump_buffer = (state.jump_buffer - dt).max(0.0); } if state.grounded { state.coyote = tuning.coyote_time; } else { state.coyote = (state.coyote - dt).max(0.0); } if state.grounded && state.velocity.y <= 0.0 { state.velocity.y = -2.0; } else { state.velocity.y -= tuning.gravity * dt; } if state.jump_buffer > 0.0 && state.coyote > 0.0 { state.velocity.y = tuning.jump_speed; state.jump_buffer = 0.0; state.coyote = 0.0; state.grounded = false; } state.position += state.velocity * dt; } /// Stable FNV-1a hash of a state summary. pub fn stable_state_hash(summary: &SimStateSummary) -> u64 { let mut hash = 0xcbf29ce484222325_u64; for value in summary .position .into_iter() .chain(summary.velocity) .chain([summary.yaw]) { for byte in value.to_le_bytes() { hash ^= u64::from(byte); hash = hash.wrapping_mul(0x100000001b3); } } for flag in [summary.grounded, summary.crouching] { hash ^= u64::from(flag as u8); hash = hash.wrapping_mul(0x100000001b3); } hash } pub fn accelerate_horizontal( velocity: &mut Vec3, wish: Vec3, speed: f32, grounded: bool, dt: f32, tuning: &SimTuning, ) { let target = wish * speed; let accel = if grounded { tuning.ground_accel } else { tuning.air_accel }; let horizontal = Vec3::new(velocity.x, 0.0, velocity.z); let next = horizontal.move_towards(target, accel * dt); velocity.x = next.x; velocity.z = next.z; } /// Drops the Y component and renormalizes a horizontal direction vector. pub fn flatten(v: Vec3) -> Vec3 { Vec3::new(v.x, 0.0, v.z).normalize_or_zero() } fn vec3_bits(v: Vec3) -> [u32; 3] { [v.x.to_bits(), v.y.to_bits(), v.z.to_bits()] } #[cfg(test)] mod tests { use super::*; use settings::{PhysicsSettings, SimTuning}; fn test_tuning() -> SimTuning { SimTuning::from_physics(&PhysicsSettings::default()) } #[test] fn same_inputs_over_same_ticks_produce_same_summary_and_hash() { let tuning = test_tuning(); let inputs: Vec = (0..160) .map(|tick| PlayerInputIntent { movement: if tick < 96 { Vec2::Y } else { Vec2::X }, look_delta: Vec2::new(if tick % 8 == 0 { 2.0 } else { 0.0 }, 0.0), jump_pressed: tick == 12 || tick == 80, sprint_held: (24..72).contains(&tick), crouch_held: tick >= 120, }) .collect(); let run = |inputs: &[PlayerInputIntent]| { let mut state = DeterminismState::default(); for intent in inputs { step_determinism_state(&mut state, intent, &tuning); } let summary = SimStateSummary::from(&state); (summary, stable_state_hash(&summary)) }; let first = run(&inputs); let second = run(&inputs); assert_eq!(first, second); } #[test] fn different_inputs_change_state_hash() { let tuning = test_tuning(); let mut base = DeterminismState::default(); let mut changed = DeterminismState::default(); for tick in 0..64 { step_determinism_state( &mut base, &PlayerInputIntent { movement: Vec2::Y, ..default() }, &tuning, ); step_determinism_state( &mut changed, &PlayerInputIntent { movement: Vec2::Y, jump_pressed: tick == 4, ..default() }, &tuning, ); } let base_hash = stable_state_hash(&SimStateSummary::from(&base)); let changed_hash = stable_state_hash(&SimStateSummary::from(&changed)); assert_ne!(base_hash, changed_hash); } }