Refactor: extract functional core into sim/ and net/

Reshape the engine into a functional-core / imperative-shell split.
The 2500-line state.rs blob is now 1700 lines of mostly GPU plumbing
plus a thin tick() shell that composes pure transformations from the
new sim/ and net/ modules.

src/sim/ (no GPU, no winit, no thread-locals — all pure):
  body.rs       PlayerBody value type + take_damage / respawned_at
  collision.rs  AABB primitives, sweep_axis returning (Vec3, bool)
                instead of mutating &mut Vec3
  edit.rs       block_from_u8, apply_edit, chunks_for_edit
  event.rs      SimEvent enum (Landed, VoidDeath, BlockEdited)
  input.rs      TouchBridge data type + merge_held
  physics.rs    step_movement(world, body, MoveInput) -> MoveOutcome
                — the central morphism: one tick of player movement
                + collision + landing detection + void check, returns
                a new body value and a list of events
  spawn.rs      find_safe_spawn, fall_damage

src/net/:
  parse_inbox(Vec<String>) -> Vec<NetEvent>
  Malformed lines drop silently; the shell folds typed events into
  the world / remote-player map.

src/state.rs:
  - App now holds a single PlayerBody value instead of scattered
    velocity / on_ground / hp / alive / max_y_since_ground fields.
  - tick() is a pipeline: collect input → merge_held → step_movement
    → fold sim events → block interaction → render_frame.
  - drain_net_inbox() parses to events first, then applies.
  - render_frame() extracted so the paused and active branches share
    the upload + cull + draw path.

Tests: 45 passing (up from 33). New coverage for the physics step
itself: airborne gravity, jump-only-when-grounded, long-fall-emits-
Landed, void floor, dead-body-does-not-move. Net parsing tests for
malformed lines and round-trips. Body tests for damage / respawn.

Build: cargo test (45/45), cargo build --target wasm32-unknown-unknown
--lib --release, and the axum server all green.
This commit is contained in:
Maximus Gorog 2026-05-23 18:55:05 -06:00
parent f239a939ce
commit e4cf5a9bed
11 changed files with 1163 additions and 772 deletions

View file

@ -1,6 +1,8 @@
pub mod camera;
pub mod mesh;
pub mod net;
pub mod proto;
pub mod sim;
pub mod state;
pub mod world;

87
src/net/mod.rs Normal file
View file

@ -0,0 +1,87 @@
//! Pure network message parsing. The shell pulls raw JSON lines out of
//! the JS-fed inbox; `parse_inbox` turns them into typed `NetEvent`
//! values without touching the world. The shell then folds the events
//! into its world / remote-player map.
use crate::proto::{EditRec, PlayerInfo, ServerMsg};
#[derive(Debug, Clone)]
pub enum NetEvent {
Welcome { id: u32, edits: Vec<EditRec> },
PlayerList(Vec<PlayerInfo>),
Edit(EditRec),
Leave { id: u32 },
}
/// Parse a batch of inbox lines into events. Lines that don't deserialize
/// as `ServerMsg` are silently dropped — matches the previous tick
/// behavior and avoids letting one malformed message take down the
/// session.
pub fn parse_inbox(lines: Vec<String>) -> Vec<NetEvent> {
lines
.into_iter()
.filter_map(|s| serde_json::from_str::<ServerMsg>(&s).ok())
.map(|m| match m {
ServerMsg::Welcome { id, edits } => NetEvent::Welcome { id, edits },
ServerMsg::Players { list } => NetEvent::PlayerList(list),
ServerMsg::Edit { x, y, z, block } => NetEvent::Edit(EditRec { x, y, z, block }),
ServerMsg::Leave { id } => NetEvent::Leave { id },
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn malformed_lines_are_dropped() {
let evs = parse_inbox(vec![
"not json".into(),
"{\"t\":\"BogusVariant\"}".into(),
"".into(),
]);
assert!(evs.is_empty());
}
#[test]
fn welcome_round_trips() {
let line = r#"{"t":"Welcome","id":42,"edits":[]}"#.to_string();
let evs = parse_inbox(vec![line]);
assert_eq!(evs.len(), 1);
match &evs[0] {
NetEvent::Welcome { id, edits } => {
assert_eq!(*id, 42);
assert!(edits.is_empty());
}
_ => panic!("expected Welcome"),
}
}
#[test]
fn edit_message_becomes_edit_event() {
let line = r#"{"t":"Edit","x":1,"y":2,"z":3,"block":7}"#.to_string();
let evs = parse_inbox(vec![line]);
assert_eq!(evs.len(), 1);
match &evs[0] {
NetEvent::Edit(rec) => {
assert_eq!(rec.x, 1);
assert_eq!(rec.y, 2);
assert_eq!(rec.z, 3);
assert_eq!(rec.block, 7);
}
_ => panic!("expected Edit"),
}
}
#[test]
fn multiple_lines_are_parsed_in_order() {
let evs = parse_inbox(vec![
r#"{"t":"Welcome","id":1,"edits":[]}"#.into(),
"garbage".into(),
r#"{"t":"Leave","id":5}"#.into(),
]);
assert_eq!(evs.len(), 2);
assert!(matches!(evs[0], NetEvent::Welcome { id: 1, .. }));
assert!(matches!(evs[1], NetEvent::Leave { id: 5 }));
}
}

90
src/sim/body.rs Normal file
View file

@ -0,0 +1,90 @@
//! The player's physical body — position, velocity, health. Passed by
//! value through pure transitions so a tick's update is
//! `body.step(...)` returning a new body rather than mutating fields
//! across a 200-line function.
use glam::Vec3;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PlayerBody {
pub feet: Vec3,
pub velocity: Vec3,
pub on_ground: bool,
pub max_y_since_ground: f32,
pub hp: u8,
pub alive: bool,
}
impl PlayerBody {
pub const MAX_HP: u8 = 20;
pub fn spawned_at(feet: Vec3) -> Self {
Self {
feet,
velocity: Vec3::ZERO,
on_ground: false,
max_y_since_ground: feet.y,
hp: Self::MAX_HP,
alive: true,
}
}
pub fn take_damage(self, d: u8) -> Self {
if !self.alive {
return self;
}
let hp = self.hp.saturating_sub(d);
Self {
hp,
alive: hp > 0,
..self
}
}
pub fn respawned_at(feet: Vec3) -> Self {
Self::spawned_at(feet)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn damage_reduces_hp_and_kills_at_zero() {
let b = PlayerBody::spawned_at(Vec3::ZERO);
assert_eq!(b.hp, 20);
let b = b.take_damage(5);
assert_eq!(b.hp, 15);
assert!(b.alive);
let b = b.take_damage(20); // overkill
assert_eq!(b.hp, 0);
assert!(!b.alive);
}
#[test]
fn dead_body_is_immune_to_further_damage() {
let dead = PlayerBody::spawned_at(Vec3::ZERO).take_damage(20);
assert!(!dead.alive);
let still_dead = dead.take_damage(50);
assert_eq!(still_dead.hp, 0);
assert!(!still_dead.alive);
}
#[test]
fn respawn_restores_full_hp_and_zero_velocity() {
let battered = PlayerBody {
feet: Vec3::new(1.0, 2.0, 3.0),
velocity: Vec3::new(5.0, -10.0, 0.0),
on_ground: false,
max_y_since_ground: 50.0,
hp: 3,
alive: false,
};
let _ = battered; // we don't reuse it; respawn doesn't depend on prior state by design
let fresh = PlayerBody::respawned_at(Vec3::new(0.5, 64.0, 0.5));
assert_eq!(fresh.hp, 20);
assert!(fresh.alive);
assert_eq!(fresh.velocity, Vec3::ZERO);
assert!(!fresh.on_ground);
}
}

160
src/sim/collision.rs Normal file
View file

@ -0,0 +1,160 @@
//! AABB collision primitives. `sweep_axis` is the load-bearing function:
//! one axis at a time, capped per-substep, snap-on-hit. Returning
//! `(Vec3, bool)` instead of mutating `&mut Vec3` is the small but
//! visible step toward a pure-pipeline shape.
use crate::world::World;
use glam::{IVec3, Vec3};
pub const PLAYER_HALF_W: f32 = 0.3;
pub const PLAYER_HEIGHT: f32 = 1.8;
pub const EYE_HEIGHT: f32 = 1.62;
#[derive(Copy, Clone, Debug)]
pub enum Axis {
X,
Y,
Z,
}
pub struct AabbI {
pub min: Vec3,
pub max: Vec3,
}
impl AabbI {
pub fn block(p: IVec3) -> Self {
Self {
min: Vec3::new(p.x as f32, p.y as f32, p.z as f32),
max: Vec3::new(p.x as f32 + 1.0, p.y as f32 + 1.0, p.z as f32 + 1.0),
}
}
}
pub fn aabb_overlap_player(b: AabbI, feet: Vec3) -> bool {
let p_min = Vec3::new(feet.x - PLAYER_HALF_W, feet.y, feet.z - PLAYER_HALF_W);
let p_max = Vec3::new(
feet.x + PLAYER_HALF_W,
feet.y + PLAYER_HEIGHT,
feet.z + PLAYER_HALF_W,
);
p_min.x < b.max.x
&& p_max.x > b.min.x
&& p_min.y < b.max.y
&& p_max.y > b.min.y
&& p_min.z < b.max.z
&& p_max.z > b.min.z
}
pub fn player_overlaps_solid(world: &World, feet: Vec3) -> bool {
let eps = 0.0;
let min_x = (feet.x - PLAYER_HALF_W + eps).floor() as i32;
let max_x = (feet.x + PLAYER_HALF_W - eps).floor() as i32;
let min_y = feet.y.floor() as i32;
let max_y = (feet.y + PLAYER_HEIGHT - 0.001).floor() as i32;
let min_z = (feet.z - PLAYER_HALF_W + eps).floor() as i32;
let max_z = (feet.z + PLAYER_HALF_W - eps).floor() as i32;
for x in min_x..=max_x {
for y in min_y..=max_y {
for z in min_z..=max_z {
if world.get_block(IVec3::new(x, y, z)).solid() {
return true;
}
}
}
}
false
}
/// Sweep the player AABB along `axis` by `delta`, snapping against the
/// first solid face encountered. Sub-steps are capped below one block so
/// the single-face snap is always correct — a one-shot snap at high
/// terminal-velocity falls could otherwise place the player *inside*
/// terrain.
///
/// Pure shape: `(World, Vec3, f32, Axis) -> (Vec3, bool)`.
pub fn sweep_axis(world: &World, feet: Vec3, delta: f32, axis: Axis) -> (Vec3, bool) {
if delta == 0.0 {
return (feet, false);
}
const MAX_STEP: f32 = 0.45;
let n = (delta.abs() / MAX_STEP).ceil().max(1.0) as i32;
let step = delta / n as f32;
let eps = 0.001;
let mut feet = feet;
for _ in 0..n {
let candidate = match axis {
Axis::X => Vec3::new(feet.x + step, feet.y, feet.z),
Axis::Y => Vec3::new(feet.x, feet.y + step, feet.z),
Axis::Z => Vec3::new(feet.x, feet.y, feet.z + step),
};
if !player_overlaps_solid(world, candidate) {
feet = candidate;
continue;
}
let snapped = match axis {
Axis::X => Vec3::new(
if step > 0.0 {
(candidate.x + PLAYER_HALF_W).floor() - PLAYER_HALF_W - eps
} else {
(candidate.x - PLAYER_HALF_W).floor() + 1.0 + PLAYER_HALF_W + eps
},
feet.y,
feet.z,
),
Axis::Z => Vec3::new(
feet.x,
feet.y,
if step > 0.0 {
(candidate.z + PLAYER_HALF_W).floor() - PLAYER_HALF_W - eps
} else {
(candidate.z - PLAYER_HALF_W).floor() + 1.0 + PLAYER_HALF_W + eps
},
),
Axis::Y => Vec3::new(
feet.x,
if step > 0.0 {
(candidate.y + PLAYER_HEIGHT).floor() - PLAYER_HEIGHT - eps
} else {
candidate.y.floor() + 1.0 + eps
},
feet.z,
),
};
return (snapped, true);
}
(feet, false)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::world::natural_surface_y;
#[test]
fn passes_freely_through_air() {
let world = World::new();
let (feet, hit) = sweep_axis(&world, Vec3::new(0.5, 60.0, 0.5), -1.0, Axis::Y);
assert!(!hit);
assert!((feet.y - 59.0).abs() < 1e-3);
}
#[test]
fn blocks_against_ground() {
let world = World::new();
let surface = natural_surface_y(0, 0);
let start = Vec3::new(0.5, (surface + 1) as f32 + 0.01, 0.5);
let (feet, hit) = sweep_axis(&world, start, -5.0, Axis::Y);
assert!(hit);
assert!(feet.y >= (surface + 1) as f32);
assert!(feet.y < (surface + 1) as f32 + 0.05);
}
#[test]
fn never_enters_a_solid_block() {
let world = World::new();
let surface = natural_surface_y(0, 0);
let start = Vec3::new(0.5, (surface + 1) as f32, 0.5);
let (feet, _) = sweep_axis(&world, start, -100.0, Axis::Y);
assert!(!player_overlaps_solid(&world, feet));
}
}

81
src/sim/edit.rs Normal file
View file

@ -0,0 +1,81 @@
//! Block-edit primitives: u8 → Block, applying an edit, and the set of
//! chunks that touching a single block invalidates.
use crate::proto::EditRec;
use crate::world::{Block, Face, World};
use glam::IVec3;
pub fn block_from_u8(b: u8) -> Block {
match b {
x if x == Block::Grass as u8 => Block::Grass,
x if x == Block::Dirt as u8 => Block::Dirt,
x if x == Block::Stone as u8 => Block::Stone,
x if x == Block::Sand as u8 => Block::Sand,
x if x == Block::Wood as u8 => Block::Wood,
x if x == Block::Leaves as u8 => Block::Leaves,
x if x == Block::Cobble as u8 => Block::Cobble,
x if x == Block::Brick as u8 => Block::Brick,
x if x == Block::Snow as u8 => Block::Snow,
x if x == Block::Ice as u8 => Block::Ice,
_ => Block::Stone,
}
}
/// Apply a single `EditRec` to the world. Returns whether anything
/// changed. Mutates world — this is the lowest-level imperative call;
/// callers compose it with `chunks_for_edit` to know which meshes to
/// rebuild.
pub fn apply_edit(world: &mut World, e: &EditRec) -> bool {
let block = if e.block == 0 {
Block::Air
} else {
block_from_u8(e.block)
};
world.set_block(IVec3::new(e.x, e.y, e.z), block)
}
/// The chunks whose meshes need rebuilding after the block at `p` is
/// edited: the chunk containing `p`, plus any neighbor chunk that
/// touches `p`'s 6 faces.
pub fn chunks_for_edit(p: IVec3) -> Vec<IVec3> {
let (c, _) = World::block_to_chunk(p);
let mut out = vec![c];
for face in Face::ALL {
let n = p + face.normal();
let (nc, _) = World::block_to_chunk(n);
if nc != c && !out.contains(&nc) {
out.push(nc);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn u8_roundtrips_for_every_hotbar_slot() {
let expected: &[(u8, Block)] = &[
(1, Block::Grass),
(2, Block::Dirt),
(3, Block::Stone),
(4, Block::Sand),
(5, Block::Wood),
(6, Block::Leaves),
(7, Block::Cobble),
(8, Block::Brick),
(9, Block::Snow),
(10, Block::Ice),
];
for &(u, b) in expected {
assert_eq!(block_from_u8(u), b, "slot {} must map to {:?}", u, b);
assert!(b.solid());
}
}
#[test]
fn u8_falls_back_to_stone_on_garbage() {
assert_eq!(block_from_u8(99), Block::Stone);
assert_eq!(block_from_u8(255), Block::Stone);
}
}

19
src/sim/event.rs Normal file
View file

@ -0,0 +1,19 @@
//! Side-effects emitted by the sim layer. The shell consumes the list
//! returned from `step_movement` and applies these to the renderer / HP
//! bookkeeping / network outbox.
use crate::proto::EditRec;
use glam::IVec3;
#[derive(Debug, Clone)]
pub enum SimEvent {
/// Player just landed after a fall of `fall_dist` blocks. The shell
/// converts this to damage via `sim::spawn::fall_damage`.
Landed { fall_dist: f32 },
/// Player crossed the void floor; force max damage.
VoidDeath,
/// A block edit succeeded and needs to be rebuilt + broadcast.
BlockEdited {
edit: EditRec,
dirty_chunks: Vec<IVec3>,
},
}

123
src/sim/input.rs Normal file
View file

@ -0,0 +1,123 @@
//! Input snapshots and the touch/controller bridge.
//!
//! `TouchBridge` is a plain data struct that the wasm bindings store in
//! a `RefCell` in `crate::state::wasm_api` — this module only knows the
//! shape so the merge functions can be tested without any browser.
use crate::camera::KbHeld;
#[derive(Default, Clone, Debug, PartialEq)]
pub struct TouchBridge {
pub touch_mode: bool,
pub forward: bool,
pub back: bool,
pub left: bool,
pub right: bool,
pub jump: bool,
pub sprint: bool,
pub look_dx: f32,
pub look_dy: f32,
pub break_pressed: bool,
pub place_pressed: bool,
pub selected: Option<u8>,
}
/// Snapshot of all input for one tick. The shell builds this once per
/// frame, then passes it through `step_movement`. Held flags are the
/// merged result of keyboard + bridge; one-shots are consumed.
#[derive(Default, Clone, Debug)]
pub struct Input {
pub held: KbHeld,
pub look_dx: f32,
pub look_dy: f32,
pub primary: bool,
pub secondary: bool,
pub selected_block: u8,
}
/// Pure: combine sticky keyboard hold state with the live touch / gamepad
/// bridge. The "release the joystick and the player stops" property
/// hinges on this being recomputed fresh every tick — never folded back
/// into a persistent field, which was the source of the original
/// sticky-input bug.
pub fn merge_held(kb: &KbHeld, br: &TouchBridge) -> KbHeld {
KbHeld {
forward: kb.forward || br.forward,
back: kb.back || br.back,
left: kb.left || br.left,
right: kb.right || br.right,
up: kb.up || br.jump,
down: kb.down,
sprint: kb.sprint || br.sprint,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn passes_through_keyboard_alone() {
let kb = KbHeld {
forward: true,
..Default::default()
};
let br = TouchBridge::default();
let m = merge_held(&kb, &br);
assert!(m.forward);
assert!(!m.back);
assert!(!m.up);
}
#[test]
fn passes_through_bridge_alone() {
let kb = KbHeld::default();
let br = TouchBridge {
forward: true,
jump: true,
..Default::default()
};
let m = merge_held(&kb, &br);
assert!(m.forward);
assert!(m.up);
}
#[test]
fn releases_when_bridge_releases() {
let kb = KbHeld::default();
let br_pressed = TouchBridge {
forward: true,
..Default::default()
};
let br_released = TouchBridge::default();
assert!(merge_held(&kb, &br_pressed).forward);
// Crucial: stepping from "bridge held" to "bridge released" must
// immediately read as not-pressed. The pre-fix code failed this
// because it folded the prior `true` into a persistent field via OR.
assert!(!merge_held(&kb, &br_released).forward);
}
#[test]
fn releases_jump_too() {
let kb = KbHeld::default();
let br_jumping = TouchBridge {
jump: true,
..Default::default()
};
let br_idle = TouchBridge::default();
assert!(merge_held(&kb, &br_jumping).up);
assert!(
!merge_held(&kb, &br_idle).up,
"releasing the jump button must clear `up` so the player stops bouncing"
);
}
#[test]
fn kb_wins_even_if_bridge_drops() {
let kb = KbHeld {
forward: true,
..Default::default()
};
let br = TouchBridge::default();
assert!(merge_held(&kb, &br).forward);
}
}

27
src/sim/mod.rs Normal file
View file

@ -0,0 +1,27 @@
//! Pure simulation core. No GPU, no winit, no thread-locals — every
//! function here is a value-in / value-out transformation that can be
//! tested without a render context.
//!
//! Categorical shape:
//!
//! ```text
//! (World, PlayerBody, MoveInput, dt) ──step_movement──▶ (PlayerBody', [SimEvent])
//! (World, EditRec) ──apply_edit────▶ bool (mut world)
//! (KbHeld, TouchBridge) ──merge_held────▶ KbHeld
//! ([inbox line]) ──parse_inbox───▶ [NetEvent] (in `crate::net`)
//! ```
//!
//! The imperative shell in `crate::state` is the only place these
//! morphisms are composed against the real World/Renderer/network.
pub mod body;
pub mod collision;
pub mod edit;
pub mod event;
pub mod input;
pub mod physics;
pub mod spawn;
pub use body::PlayerBody;
pub use event::SimEvent;
pub use input::{merge_held, Input, TouchBridge};
pub use physics::{step_movement, MoveInput, MoveOutcome};

251
src/sim/physics.rs Normal file
View file

@ -0,0 +1,251 @@
//! The physics step — the single highest-leverage extract from the
//! imperative tick. Given a body, world, and held inputs, return the
//! next body plus any events the shell needs to act on.
//!
//! Total function: `(World, PlayerBody, MoveInput) -> MoveOutcome`.
use crate::camera::KbHeld;
use crate::sim::body::PlayerBody;
use crate::sim::collision::{sweep_axis, Axis};
use crate::sim::event::SimEvent;
use glam::Vec3;
pub const GRAVITY: f32 = -30.0;
pub const JUMP_VEL: f32 = 9.0;
pub const TERMINAL_VEL: f32 = -55.0;
pub const WALK_SPEED: f32 = 4.6;
pub const SPRINT_SPEED: f32 = 7.5;
/// Below this Y, the player is in the void and dies outright.
pub const VOID_Y: f32 = -25.0;
#[derive(Debug, Clone)]
pub struct MoveInput {
pub held: KbHeld,
pub forward_flat: Vec3,
pub right_flat: Vec3,
pub dt: f32,
}
#[derive(Debug, Clone)]
pub struct MoveOutcome {
pub body: PlayerBody,
pub events: Vec<SimEvent>,
}
/// Integrate one tick of movement + collision. Pure aside from reading
/// `world` for collision queries; produces a new body value and a list
/// of events.
pub fn step_movement(world: &crate::world::World, body: PlayerBody, input: MoveInput) -> MoveOutcome {
let mut events = Vec::new();
if !body.alive {
return MoveOutcome { body, events };
}
let dt = input.dt;
let held = &input.held;
// Horizontal wish vector built from facing × held flags.
let mut wish = Vec3::ZERO;
if held.forward {
wish += input.forward_flat;
}
if held.back {
wish -= input.forward_flat;
}
if held.right {
wish += input.right_flat;
}
if held.left {
wish -= input.right_flat;
}
let wish = wish.normalize_or_zero();
let speed = if held.sprint { SPRINT_SPEED } else { WALK_SPEED };
let mut velocity = Vec3::new(wish.x * speed, body.velocity.y, wish.z * speed);
// Jump: only when grounded.
let mut on_ground = body.on_ground;
if held.up && on_ground {
velocity.y = JUMP_VEL;
on_ground = false;
}
// Gravity + terminal velocity.
velocity.y = (velocity.y + GRAVITY * dt).max(TERMINAL_VEL);
// Collision sweeps. The Y-result's hit flag tells us whether we
// collided this tick — if delta.y < 0 that's landing.
let was_on_ground = on_ground;
let mut feet = body.feet;
let delta = velocity * dt;
let (f1, _) = sweep_axis(world, feet, delta.x, Axis::X);
feet = f1;
let (f2, _) = sweep_axis(world, feet, delta.z, Axis::Z);
feet = f2;
let (f3, y_hit) = sweep_axis(world, feet, delta.y, Axis::Y);
feet = f3;
if y_hit {
if delta.y < 0.0 {
on_ground = true;
}
velocity.y = 0.0;
} else if delta.y != 0.0 {
on_ground = false;
}
// Track the highest Y reached since leaving the ground for fall damage.
let max_y_since_ground = if was_on_ground && !on_ground {
feet.y
} else if !on_ground {
body.max_y_since_ground.max(feet.y)
} else {
body.max_y_since_ground
};
if !was_on_ground && on_ground {
let dist = (max_y_since_ground - feet.y).max(0.0);
if dist > 0.0 {
events.push(SimEvent::Landed { fall_dist: dist });
}
}
if feet.y < VOID_Y {
events.push(SimEvent::VoidDeath);
}
MoveOutcome {
body: PlayerBody {
feet,
velocity,
on_ground,
max_y_since_ground,
hp: body.hp,
alive: body.alive,
},
events,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::world::{natural_surface_y, World};
fn idle_input(dt: f32) -> MoveInput {
MoveInput {
held: KbHeld::default(),
forward_flat: Vec3::new(1.0, 0.0, 0.0),
right_flat: Vec3::new(0.0, 0.0, 1.0),
dt,
}
}
#[test]
fn airborne_body_accumulates_gravity() {
let world = World::new();
let start = PlayerBody {
feet: Vec3::new(0.5, 60.0, 0.5),
velocity: Vec3::ZERO,
on_ground: false,
max_y_since_ground: 60.0,
hp: 20,
alive: true,
};
let out = step_movement(&world, start, idle_input(0.1));
assert!(out.body.velocity.y < 0.0, "gravity must pull down");
assert!(out.body.feet.y < 60.0);
}
#[test]
fn jump_only_works_when_grounded() {
let world = World::new();
let surface = natural_surface_y(0, 0);
let mut input = idle_input(0.016);
input.held.up = true;
// Airborne: jump ignored.
let airborne = PlayerBody {
feet: Vec3::new(0.5, 200.0, 0.5),
velocity: Vec3::ZERO,
on_ground: false,
..PlayerBody::spawned_at(Vec3::new(0.5, 200.0, 0.5))
};
let out = step_movement(&world, airborne, input.clone());
assert!(out.body.velocity.y <= 0.0, "no jump while airborne");
// Grounded: jump engages.
let grounded = PlayerBody {
feet: Vec3::new(0.5, (surface + 1) as f32, 0.5),
velocity: Vec3::ZERO,
on_ground: true,
..PlayerBody::spawned_at(Vec3::new(0.5, (surface + 1) as f32, 0.5))
};
let out = step_movement(&world, grounded, input);
assert!(out.body.velocity.y > 0.0, "jump must yield upward velocity");
assert!(!out.body.on_ground, "leaving the ground breaks contact");
}
#[test]
fn long_fall_emits_landed_event() {
let world = World::new();
let surface = natural_surface_y(0, 0);
// Spawn 30 blocks above ground, then step until we land. We loop
// because a single tick at low dt won't cover the fall.
let mut body = PlayerBody {
feet: Vec3::new(0.5, (surface + 30) as f32, 0.5),
velocity: Vec3::ZERO,
on_ground: false,
max_y_since_ground: (surface + 30) as f32,
hp: 20,
alive: true,
};
let mut saw_landing = false;
for _ in 0..200 {
let out = step_movement(&world, body, idle_input(0.05));
body = out.body;
if out.events.iter().any(|e| matches!(e, SimEvent::Landed { .. })) {
saw_landing = true;
break;
}
}
assert!(saw_landing, "must observe a Landed event after a long fall");
assert!(body.on_ground);
}
#[test]
fn void_floor_emits_void_death() {
// Place the player below the void Y with downward velocity; even
// though the world's chunks don't extend that far, the void
// check should still fire.
let world = World::new();
let body = PlayerBody {
feet: Vec3::new(0.5, -30.0, 0.5),
velocity: Vec3::ZERO,
on_ground: false,
max_y_since_ground: -30.0,
hp: 20,
alive: true,
};
let out = step_movement(&world, body, idle_input(0.05));
assert!(out.events.iter().any(|e| matches!(e, SimEvent::VoidDeath)));
}
#[test]
fn dead_body_does_not_move() {
let world = World::new();
let body = PlayerBody {
feet: Vec3::new(5.0, 100.0, 5.0),
velocity: Vec3::new(10.0, -5.0, 0.0),
on_ground: false,
max_y_since_ground: 100.0,
hp: 0,
alive: false,
};
let out = step_movement(&world, body, idle_input(0.1));
assert_eq!(out.body.feet, body.feet);
assert_eq!(out.body.velocity, body.velocity);
assert!(out.events.is_empty());
}
}

114
src/sim/spawn.rs Normal file
View file

@ -0,0 +1,114 @@
//! Spawn-point selection and fall-damage. Both are pure functions of
//! world state and a scalar input, so they're easy to pin with
//! regression tests.
use crate::world::{natural_surface_y, World, CHUNK_HEIGHT};
use glam::{IVec3, Vec3};
/// Returns the player feet position to spawn at. Anchored to the
/// *natural* terrain height computed from the same noise the generator
/// uses, so player edits at spawn (towers, holes) don't permanently
/// move the spawn point. Only scans upward from the natural surface if
/// a tower currently blocks it.
pub fn find_safe_spawn(world: &World) -> Vec3 {
let (x, z) = (0_i32, 0_i32);
let surface_y = natural_surface_y(x, z);
let mut feet_y = surface_y + 1;
let max_y = CHUNK_HEIGHT - 2;
while feet_y < max_y {
let body_blocked = world.get_block(IVec3::new(x, feet_y, z)).solid()
|| world.get_block(IVec3::new(x, feet_y + 1, z)).solid();
if !body_blocked {
return Vec3::new(x as f32 + 0.5, feet_y as f32, z as f32 + 0.5);
}
feet_y += 1;
}
Vec3::new(x as f32 + 0.5, (surface_y + 1) as f32, z as f32 + 0.5)
}
/// Damage from a free-fall of `distance` blocks. The first 3.5 blocks
/// are safe (jump height + slack); each block beyond costs one HP,
/// capped at 20. NaN / infinity / negative distances all collapse to 0.
pub fn fall_damage(distance: f32) -> u8 {
if !distance.is_finite() || distance <= 3.5 {
0
} else {
(distance - 3.5).floor().clamp(0.0, 20.0) as u8
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::world::Block;
#[test]
fn fall_damage_zero_for_short_falls() {
assert_eq!(fall_damage(0.0), 0);
assert_eq!(fall_damage(2.5), 0);
assert_eq!(fall_damage(3.5), 0);
}
#[test]
fn fall_damage_starts_at_one_just_past_threshold() {
assert_eq!(fall_damage(4.5), 1);
assert_eq!(fall_damage(5.0), 1);
}
#[test]
fn fall_damage_caps_at_20() {
assert_eq!(fall_damage(100.0), 20);
assert_eq!(fall_damage(f32::MAX), 20);
}
#[test]
fn fall_damage_handles_nonsense() {
assert_eq!(fall_damage(-5.0), 0);
assert_eq!(fall_damage(f32::NAN), 0);
assert_eq!(fall_damage(f32::INFINITY), 0);
assert_eq!(fall_damage(f32::NEG_INFINITY), 0);
}
#[test]
fn spawn_lands_on_natural_surface_in_pristine_world() {
let world = World::new();
let spawn = find_safe_spawn(&world);
let expected = natural_surface_y(0, 0) + 1;
assert_eq!(spawn.y as i32, expected);
}
#[test]
fn spawn_rises_above_player_built_tower() {
let mut world = World::new();
let surface = natural_surface_y(0, 0);
for y in (surface + 1)..=(surface + 10) {
assert!(world.set_block(IVec3::new(0, y, 0), Block::Stone));
}
let spawn = find_safe_spawn(&world);
assert!(spawn.y as i32 > surface + 10);
}
#[test]
fn spawn_returns_to_natural_after_tower_is_broken() {
let mut world = World::new();
let surface = natural_surface_y(0, 0);
for y in (surface + 1)..=(surface + 10) {
world.set_block(IVec3::new(0, y, 0), Block::Stone);
}
for y in (surface + 1)..=(surface + 10) {
world.set_block(IVec3::new(0, y, 0), Block::Air);
}
let spawn = find_safe_spawn(&world);
assert_eq!(spawn.y as i32, surface + 1);
}
#[test]
fn spawn_unaffected_by_remote_holes_below_surface() {
let mut world = World::new();
let surface = natural_surface_y(0, 0);
for y in 0..=surface {
world.set_block(IVec3::new(0, y, 0), Block::Air);
}
let spawn = find_safe_spawn(&world);
assert_eq!(spawn.y as i32, surface + 1);
}
}

File diff suppressed because it is too large Load diff