393 lines
12 KiB
Rust
393 lines
12 KiB
Rust
//! 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::<Fixed>::from_hz(SIM_TICK_RATE_HZ as f64))
|
|
.init_resource::<SimEnabled>()
|
|
.register_type::<Player>()
|
|
.register_type::<PlayerVelocity>()
|
|
.register_type::<Grounded>()
|
|
.register_type::<Crouching>()
|
|
.register_type::<JumpState>()
|
|
.register_type::<PlayerCamera>()
|
|
.register_type::<CameraSensitivity>()
|
|
.register_type::<PlayerYawSensitivity>()
|
|
.register_type::<SimEnabled>()
|
|
.register_type::<GameInputEnabled>()
|
|
.register_type::<GameInputFocused>();
|
|
}
|
|
}
|
|
|
|
/// 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<PlayerInputIntent> = (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);
|
|
}
|
|
}
|