Blacksite/crates/sim/src/lib.rs
Rbanh f29712d158
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run
Initial project import
2026-06-05 21:44:45 -04:00

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);
}
}