diff --git a/src/lib.rs b/src/lib.rs index d301ef8..4d87450 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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; diff --git a/src/net/mod.rs b/src/net/mod.rs new file mode 100644 index 0000000..dff99e6 --- /dev/null +++ b/src/net/mod.rs @@ -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 }, + PlayerList(Vec), + 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) -> Vec { + lines + .into_iter() + .filter_map(|s| serde_json::from_str::(&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 })); + } +} diff --git a/src/sim/body.rs b/src/sim/body.rs new file mode 100644 index 0000000..e6aaf4f --- /dev/null +++ b/src/sim/body.rs @@ -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); + } +} diff --git a/src/sim/collision.rs b/src/sim/collision.rs new file mode 100644 index 0000000..05f02c9 --- /dev/null +++ b/src/sim/collision.rs @@ -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)); + } +} diff --git a/src/sim/edit.rs b/src/sim/edit.rs new file mode 100644 index 0000000..707eb9b --- /dev/null +++ b/src/sim/edit.rs @@ -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 { + 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); + } +} diff --git a/src/sim/event.rs b/src/sim/event.rs new file mode 100644 index 0000000..46e139e --- /dev/null +++ b/src/sim/event.rs @@ -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, + }, +} diff --git a/src/sim/input.rs b/src/sim/input.rs new file mode 100644 index 0000000..7a0774d --- /dev/null +++ b/src/sim/input.rs @@ -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, +} + +/// 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); + } +} diff --git a/src/sim/mod.rs b/src/sim/mod.rs new file mode 100644 index 0000000..c51dc82 --- /dev/null +++ b/src/sim/mod.rs @@ -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}; diff --git a/src/sim/physics.rs b/src/sim/physics.rs new file mode 100644 index 0000000..74a77fb --- /dev/null +++ b/src/sim/physics.rs @@ -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, +} + +/// 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()); + } +} diff --git a/src/sim/spawn.rs b/src/sim/spawn.rs new file mode 100644 index 0000000..8d97b0b --- /dev/null +++ b/src/sim/spawn.rs @@ -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); + } +} diff --git a/src/state.rs b/src/state.rs index 993ed77..b329007 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,6 +1,17 @@ +//! Imperative shell: owns the GPU resources, the winit window, and the +//! JS bridges. Every tick threads the pure simulation core +//! (`crate::sim`) and pure net parser (`crate::net`) through the +//! world/renderer/network — this file should remain *thin*. use crate::camera::{Camera, InputState, KbHeld}; use crate::mesh::{build_chunk_mesh, Vertex}; -use crate::proto::{ClientMsg, EditRec, ServerMsg}; +use crate::net::{parse_inbox, NetEvent}; +use crate::proto::{ClientMsg, EditRec}; +use crate::sim::collision::{aabb_overlap_player, AabbI, EYE_HEIGHT}; +use crate::sim::edit::{apply_edit, block_from_u8, chunks_for_edit}; +use crate::sim::input::TouchBridge; +use crate::sim::physics::MoveInput; +use crate::sim::spawn::{fall_damage, find_safe_spawn}; +use crate::sim::{merge_held, step_movement, PlayerBody, SimEvent}; use crate::world::{Block, World, CHUNK_HEIGHT, CHUNK_SIZE, WORLD_RADIUS}; use bytemuck::{Pod, Zeroable}; use glam::{IVec3, Mat4, Vec3}; @@ -43,55 +54,13 @@ struct ChunkBuffers { index_count: u32, } -const PLAYER_HALF_W: f32 = 0.3; -const PLAYER_HEIGHT: f32 = 1.8; -const EYE_HEIGHT: f32 = 1.62; -const GRAVITY: f32 = -30.0; -const JUMP_VEL: f32 = 9.0; -const TERMINAL_VEL: f32 = -55.0; -const WALK_SPEED: f32 = 4.6; -const SPRINT_SPEED: f32 = 7.5; const REACH: f32 = 6.0; const OUTLINE_VERT_COUNT: u64 = 24; -#[derive(Default, Clone)] -struct TouchBridge { - touch_mode: bool, - forward: bool, - back: bool, - left: bool, - right: bool, - jump: bool, - sprint: bool, - look_dx: f32, - look_dy: f32, - break_pressed: bool, - place_pressed: bool, - selected: Option, -} - -/// Take a Clone snapshot of the live bridge. Used so we can read the held -/// fields without holding the RefCell borrow across other code paths. -fn snapshot_bridge() -> TouchBridge { - TOUCH_BRIDGE.with(|b| b.borrow().clone()) -} - -/// Pure function: 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 — see `merge_*` tests. -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, - } -} - thread_local! { + /// Storage for the touch/controller bridge. The struct itself lives + /// in `crate::sim::input`; this RefCell is the only place it's + /// mutated, by the wasm bindings below. static TOUCH_BRIDGE: RefCell = RefCell::new(TouchBridge::default()); static GAME_STATUS: RefCell = RefCell::new(GameStatus { hp: 20, @@ -108,9 +77,9 @@ struct Settings { fov_deg: f32, render_dist: f32, paused: bool, - /// Multiplier on real time used to drive the day/night cycle. 0 = frozen, - /// 1 = realtime (one in-game day every 5 minutes per shader constant), - /// up to 8x for fast-forward playtesting. + /// Multiplier on real time used to drive the day/night cycle. + /// 0 = frozen, 1 = realtime (one in-game day every 5 minutes per + /// shader constant), up to 8x for fast-forward playtesting. time_scale: f32, } @@ -299,8 +268,6 @@ pub struct Renderer { remote_index_count: u32, // ---- Post processing (Step 1: pass-through scene → surface) ---- - /// Offscreen color target the world is rendered into. Same format as - /// the surface so the post pipeline can write to either interchangeably. scene_color: wgpu::TextureView, scene_color_format: wgpu::TextureFormat, post_sampler: wgpu::Sampler, @@ -310,8 +277,6 @@ pub struct Renderer { } const MAX_REMOTE_PLAYERS: u64 = 32; -// 2 boxes per remote player (body + head). Each box = 6 faces × 4 verts and -// 6 faces × 6 indices. const REMOTE_VERTS_PER_PLAYER: u64 = 2 * 24; const REMOTE_INDICES_PER_PLAYER: u64 = 2 * 36; @@ -321,8 +286,6 @@ impl Renderer { #[allow(unused_mut)] let (mut width, mut height) = (size.width.max(1), size.height.max(1)); - // On wasm winit's inner_size can be 1×1 if the canvas wasn't laid out - // before window creation. Pull the real drawingBuffer size in that case. #[cfg(target_arch = "wasm32")] { if width <= 2 || height <= 2 { @@ -343,9 +306,6 @@ impl Renderer { } log::info!("initial surface size: {}x{}", width, height); - // Pick exactly one backend on wasm. Probe WebGPU by actually asking - // for an adapter — `navigator.gpu` can exist while requestAdapter() - // still returns null (e.g. unsafe-webgpu flag off). #[cfg(target_arch = "wasm32")] let backends = { let webgpu_ok = detect_webgpu().await; @@ -393,8 +353,6 @@ impl Renderer { } }; - // Use higher limits only when the adapter actually negotiated WebGPU; - // for the WebGL2 fallback we must stay within the conservative caps. let info = adapter.get_info(); log::info!( "wgpu adapter: backend={:?} type={:?} name={:?}", @@ -766,13 +724,11 @@ impl Renderer { let max = players.len().min(MAX_REMOTE_PLAYERS as usize); for p in &players[..max] { let h = name_hash(&p.name); - let r = 0.35 + ((h >> 0) & 0x3F) as f32 / 255.0; + let r = 0.35 + (h & 0x3F) as f32 / 255.0; let g = 0.35 + ((h >> 8) & 0x3F) as f32 / 255.0; let b = 0.35 + ((h >> 16) & 0x3F) as f32 / 255.0; let body = [r, g, b]; let head = [r * 0.85 + 0.15, g * 0.85 + 0.15, b * 0.85 + 0.15]; - // Body: 0.6 × 1.3 × 0.4 (a little narrower front-to-back than side- - // to-side so the rotation is visually obvious). emit_oriented_box( Vec3::new(p.pos.x, p.pos.y + 0.65, p.pos.z), Vec3::new(0.3, 0.65, 0.2), @@ -781,7 +737,6 @@ impl Renderer { &mut verts, &mut indices, ); - // Head: 0.5 cube, sitting on top of body. emit_oriented_box( Vec3::new(p.pos.x, p.pos.y + 1.55, p.pos.z), Vec3::new(0.25, 0.25, 0.25), @@ -806,7 +761,11 @@ impl Renderer { if let Some(b) = target { let eps = 0.002; let min = [b.x as f32 - eps, b.y as f32 - eps, b.z as f32 - eps]; - let max = [b.x as f32 + 1.0 + eps, b.y as f32 + 1.0 + eps, b.z as f32 + 1.0 + eps]; + let max = [ + b.x as f32 + 1.0 + eps, + b.y as f32 + 1.0 + eps, + b.z as f32 + 1.0 + eps, + ]; let c000 = [min[0], min[1], min[2]]; let c100 = [max[0], min[1], min[2]]; let c001 = [min[0], min[1], max[2]]; @@ -816,9 +775,8 @@ impl Renderer { let c011 = [min[0], max[1], max[2]]; let c111 = [max[0], max[1], max[2]]; let verts = [ - c000, c100, c100, c101, c101, c001, c001, c000, - c010, c110, c110, c111, c111, c011, c011, c010, - c000, c010, c100, c110, c101, c111, c001, c011, + c000, c100, c100, c101, c101, c001, c001, c000, c010, c110, c110, c111, c111, + c011, c011, c010, c000, c010, c100, c110, c101, c111, c001, c011, ]; let data: Vec = verts.iter().map(|p| OutlineVertex { pos: *p }).collect(); self.queue @@ -899,7 +857,7 @@ impl Renderer { .device .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("enc") }); - // ---- Scene pass: render the world into the offscreen scene_color. ---- + // ---- Scene pass: render world into the offscreen scene_color. ---- { let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("scene pass"), @@ -956,7 +914,7 @@ impl Renderer { } } - // ---- Post pass: copy scene_color to the surface (effects later). ---- + // ---- Post pass: copy scene_color to surface (effects later). ---- { let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("post pass"), @@ -1001,6 +959,51 @@ fn create_depth_view(device: &wgpu::Device, w: u32, h: u32) -> wgpu::TextureView tex.create_view(&wgpu::TextureViewDescriptor::default()) } +fn create_scene_color_view( + device: &wgpu::Device, + w: u32, + h: u32, + format: wgpu::TextureFormat, +) -> wgpu::TextureView { + let tex = device.create_texture(&wgpu::TextureDescriptor { + label: Some("scene color"), + size: wgpu::Extent3d { + width: w.max(1), + height: h.max(1), + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }); + tex.create_view(&wgpu::TextureViewDescriptor::default()) +} + +fn create_post_bg( + device: &wgpu::Device, + layout: &wgpu::BindGroupLayout, + scene: &wgpu::TextureView, + sampler: &wgpu::Sampler, +) -> wgpu::BindGroup { + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("post bg"), + layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(scene), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(sampler), + }, + ], + }) +} + #[derive(Default)] pub struct App { window: Option>, @@ -1009,23 +1012,24 @@ pub struct App { camera: Rc>>, input: InputState, keyboard: KbHeld, + body: PlayerBody, last_frame: Option, start_clock: Option, pointer_locked: bool, - velocity: Vec3, - on_ground: bool, - hp: u8, - alive: bool, - max_y_since_ground: f32, last_net_send: f32, was_connected: bool, - /// Accumulated *scaled* time. Real-time `dt` is multiplied by - /// `settings.time_scale` each tick before being added, so the shader's - /// day/night cycle slows / freezes / fast-forwards according to the - /// player's setting. + /// Accumulated *scaled* time. Real dt × `settings.time_scale` per + /// tick — the shader's day/night cycle slows / freezes / fast- + /// forwards according to the player's setting. shader_time: f32, } +impl Default for PlayerBody { + fn default() -> Self { + PlayerBody::spawned_at(Vec3::ZERO) + } +} + struct FrameClock { #[cfg(not(target_arch = "wasm32"))] instant: Instant, @@ -1044,9 +1048,7 @@ impl FrameClock { #[cfg(target_arch = "wasm32")] { let perf = web_sys::window().unwrap().performance().unwrap(); - Self { - millis: perf.now(), - } + Self { millis: perf.now() } } } @@ -1081,10 +1083,6 @@ impl ApplicationHandler for App { .and_then(|d| d.get_element_by_id("game-canvas")) .and_then(|e| e.dyn_into::().ok()); if let Some(c) = canvas { - // Force the drawingBuffer to match the laid-out CSS size × DPR - // before handing the canvas to winit. Without this, winit can - // hand wgpu a 1×1 canvas which renders as a single stretched - // colored pixel on WebGPU. let dpr = web_sys::window() .map(|w| w.device_pixel_ratio()) .unwrap_or(1.0) @@ -1132,7 +1130,6 @@ impl ApplicationHandler for App { self.window = Some(window.clone()); - // World + camera + initial meshes let world = World::new(); let aspect = { let s = window.inner_size(); @@ -1141,7 +1138,7 @@ impl ApplicationHandler for App { let spawn = find_safe_spawn(&world); let mut camera = Camera::new(aspect); camera.position = Vec3::new(spawn.x, spawn.y + EYE_HEIGHT, spawn.z); - self.max_y_since_ground = spawn.y; + self.body = PlayerBody::spawned_at(spawn); *self.camera.borrow_mut() = Some(camera); *self.world.borrow_mut() = Some(world); @@ -1172,8 +1169,6 @@ impl ApplicationHandler for App { self.last_frame = Some(FrameClock::now()); self.start_clock = Some(FrameClock::now()); self.input.selected_block = Block::Stone as u8; - self.hp = 20; - self.alive = true; self.push_status(); window.request_redraw(); } @@ -1293,58 +1288,43 @@ impl App { fn push_status(&self) { GAME_STATUS.with(|s| { let mut s = s.borrow_mut(); - s.hp = self.hp; - s.alive = self.alive; + s.hp = self.body.hp; + s.alive = self.body.alive; }); } - fn take_damage(&mut self, d: u8) { - if !self.alive { - return; - } - self.hp = self.hp.saturating_sub(d); - if self.hp == 0 { - self.alive = false; - } - self.push_status(); - } - fn do_respawn(&mut self) { - let feet = { - let world_borrow = self.world.borrow(); - match world_borrow.as_ref() { - Some(w) => find_safe_spawn(w), - None => Vec3::new(0.5, 60.0, 0.5), - } - }; + let feet = self + .world + .borrow() + .as_ref() + .map(find_safe_spawn) + .unwrap_or(Vec3::new(0.5, 60.0, 0.5)); + self.body = PlayerBody::respawned_at(feet); if let Some(cam) = self.camera.borrow_mut().as_mut() { cam.position = Vec3::new(feet.x, feet.y + EYE_HEIGHT, feet.z); } - self.velocity = Vec3::ZERO; - self.on_ground = false; - self.max_y_since_ground = feet.y; - self.hp = 20; - self.alive = true; self.push_status(); } + /// Drain the JS-fed inbox, parse via `net::parse_inbox` into typed + /// events, then fold each one into the world / remote-player map. fn drain_net_inbox(&mut self) { let inbox: Vec = NET_BRIDGE.with(|n| std::mem::take(&mut n.borrow_mut().inbox)); if inbox.is_empty() { return; } + let events = parse_inbox(inbox); + let mut world_borrow = self.world.borrow_mut(); let Some(world) = world_borrow.as_mut() else { return; }; let mut dirty: std::collections::HashSet = std::collections::HashSet::new(); - for line in inbox { - let Ok(msg) = serde_json::from_str::(&line) else { - continue; - }; - match msg { - ServerMsg::Welcome { id, edits } => { + for ev in events { + match ev { + NetEvent::Welcome { id, edits } => { NET_BRIDGE.with(|n| n.borrow_mut().my_id = Some(id)); for e in edits { if apply_edit(world, &e) { @@ -1354,7 +1334,7 @@ impl App { } } } - ServerMsg::Players { list } => { + NetEvent::PlayerList(list) => { NET_BRIDGE.with(|n| { let mut n = n.borrow_mut(); let my = n.my_id; @@ -1375,15 +1355,14 @@ impl App { } }); } - ServerMsg::Edit { x, y, z, block } => { - let rec = EditRec { x, y, z, block }; + NetEvent::Edit(rec) => { if apply_edit(world, &rec) { - for c in chunks_for_edit(IVec3::new(x, y, z)) { + for c in chunks_for_edit(IVec3::new(rec.x, rec.y, rec.z)) { dirty.insert(c); } } } - ServerMsg::Leave { id } => { + NetEvent::Leave { id } => { NET_BRIDGE.with(|n| { n.borrow_mut().remote_players.remove(&id); }); @@ -1402,6 +1381,9 @@ impl App { } } + /// One frame: collect inputs → step physics → fold sim events → + /// world edits → render. The pure pieces live in `crate::sim` and + /// `crate::net`; this function only composes them. fn tick(&mut self) { let dt = match self.last_frame.as_ref() { Some(c) => c.elapsed().as_secs_f32().min(0.1), @@ -1413,14 +1395,11 @@ impl App { .as_ref() .map(|c| c.elapsed().as_secs_f32()) .unwrap_or(0.0); - let scale = SETTINGS.with(|s| s.borrow().time_scale); - self.shader_time += dt * scale; - let time = real_time; - let shader_time = self.shader_time; - let settings = SETTINGS.with(|s| *s.borrow()); + self.shader_time += dt * settings.time_scale; - // While paused (menu open), clear inputs so player doesn't drift, and skip physics. + // While paused, freeze inputs and skip physics — render the + // last frame so the menu draws over the world. if settings.paused { self.keyboard = KbHeld::default(); self.input.mouse_dx = 0.0; @@ -1441,32 +1420,12 @@ impl App { br.place_pressed = false; }); self.drain_net_inbox(); - let camera_borrow = self.camera.borrow(); - if let Some(camera) = camera_borrow.as_ref() { - let world_borrow = self.world.borrow(); - let visible = if let Some(w) = world_borrow.as_ref() { - compute_visible_chunks(w, camera, settings.render_dist) - } else { - Vec::new() - }; - let remotes: Vec = NET_BRIDGE.with(|n| { - n.borrow().remote_players.values().cloned().collect() - }); - if let Some(r) = self.renderer.borrow_mut().as_mut() { - r.set_outline(None); - r.set_visible(visible); - r.set_remote_players(&remotes); - r.upload_camera(camera, shader_time); - let _ = r.render(); - } - } + self.render_frame(settings, None); return; } - // Drain the bridge into the per-tick one-shots; the *held* directional - // state is merged into locals further down — never written back into - // a persistent field, which would re-introduce the sticky-input bug. - TOUCH_BRIDGE.with(|b| { + // Snapshot + drain the touch bridge into the per-tick one-shots. + let bridge = TOUCH_BRIDGE.with(|b| { let mut br = b.borrow_mut(); self.input.mouse_dx += br.look_dx; self.input.mouse_dy += br.look_dy; @@ -1483,28 +1442,30 @@ impl App { if let Some(sel) = br.selected.take() { self.input.selected_block = sel; } + br.clone() }); - // Honor respawn request. - let respawn = GAME_STATUS.with(|s| { + // Respawn from JS? + let respawn_now = GAME_STATUS.with(|s| { let mut s = s.borrow_mut(); let r = s.respawn_requested; s.respawn_requested = false; r }); - if respawn { + if respawn_now { self.do_respawn(); } - // Pull network messages (edits, player list). self.drain_net_inbox(); - // On (re)connect, send Hello + first state. + // Hello on (re)connection. let connected = NET_BRIDGE.with(|n| n.borrow().connected); if connected && !self.was_connected { let name = NET_BRIDGE .with(|n| n.borrow_mut().pending_name.take()) - .unwrap_or_else(|| format!("guest-{}", (time * 1000.0) as u32 % 10000)); + .unwrap_or_else(|| { + format!("guest-{}", (real_time * 1000.0) as u32 % 10000) + }); let msg = ClientMsg::Hello { name }; if let Ok(s) = serde_json::to_string(&msg) { NET_BRIDGE.with(|n| n.borrow_mut().outbox.push(s)); @@ -1512,13 +1473,12 @@ impl App { } self.was_connected = connected; + // Mouse look → camera yaw/pitch. let (mx, my) = self.input.consume_mouse(); - let mut camera_borrow = self.camera.borrow_mut(); let Some(camera) = camera_borrow.as_mut() else { return; }; - camera.yaw += mx * settings.mouse_sens; camera.pitch -= my * settings.mouse_sens; let limit = std::f32::consts::FRAC_PI_2 - 0.01; @@ -1530,85 +1490,49 @@ impl App { return; }; - let mut pending_damage: u8 = 0; - if self.alive { - // Merge sticky keyboard + bridge into a tick-local snapshot. Pure - // function so the bug class "release leaves the player walking" - // can be regression-tested without spinning up a real World. - let held = merge_held(&self.keyboard, &snapshot_bridge()); + // ---- Physics: pure step ---- + let held = merge_held(&self.keyboard, &bridge); + let outcome = step_movement( + world, + self.body, + MoveInput { + held, + forward_flat: camera.forward_flat(), + right_flat: camera.right_flat(), + dt, + }, + ); + self.body = outcome.body; - let mut wish = Vec3::ZERO; - if held.forward { - wish += camera.forward_flat(); - } - if held.back { - wish -= camera.forward_flat(); - } - if held.right { - wish += camera.right_flat(); - } - if held.left { - wish -= camera.right_flat(); - } - let wish = wish.normalize_or_zero(); - let h_speed = if held.sprint { SPRINT_SPEED } else { WALK_SPEED }; - self.velocity.x = wish.x * h_speed; - self.velocity.z = wish.z * h_speed; - - if held.up && self.on_ground { - self.velocity.y = JUMP_VEL; - self.on_ground = false; - } - - self.velocity.y += GRAVITY * dt; - if self.velocity.y < TERMINAL_VEL { - self.velocity.y = TERMINAL_VEL; - } - - let was_on_ground = self.on_ground; - let mut feet = Vec3::new( - camera.position.x, - camera.position.y - EYE_HEIGHT, - camera.position.z, - ); - let delta = self.velocity * dt; - move_axis(world, &mut feet, delta.x, Axis::X); - move_axis(world, &mut feet, delta.z, Axis::Z); - let y_hit = move_axis(world, &mut feet, delta.y, Axis::Y); - if y_hit { - if delta.y < 0.0 { - self.on_ground = true; + // Fold sim events into damage. (Edits come from block + // interaction below, not from movement.) + let mut total_damage: u8 = 0; + for e in outcome.events { + match e { + SimEvent::Landed { fall_dist } => { + total_damage = total_damage.saturating_add(fall_damage(fall_dist)); } - self.velocity.y = 0.0; - } else if delta.y != 0.0 { - self.on_ground = false; + SimEvent::VoidDeath => { + total_damage = 20; + } + SimEvent::BlockEdited { .. } => {} } + } - // Fall damage bookkeeping. - if was_on_ground && !self.on_ground { - self.max_y_since_ground = feet.y; - } - if !self.on_ground { - self.max_y_since_ground = self.max_y_since_ground.max(feet.y); - } - if !was_on_ground && self.on_ground { - let dist = (self.max_y_since_ground - feet.y).max(0.0); - pending_damage = pending_damage.saturating_add(fall_damage(dist)); - } + // Sync the camera position with the body's feet + eye height. + camera.position = Vec3::new( + self.body.feet.x, + self.body.feet.y + EYE_HEIGHT, + self.body.feet.z, + ); - // Void death. - if feet.y < -25.0 { - pending_damage = 20; - } + // ---- Block interaction (pick → break/place) ---- + let hit = world.raycast(camera.position, camera.forward(), REACH); + let primary = std::mem::replace(&mut self.input.primary_clicked, false); + let secondary = std::mem::replace(&mut self.input.secondary_clicked, false); + let mut broadcast_edit: Option = None; - camera.position = Vec3::new(feet.x, feet.y + EYE_HEIGHT, feet.z); - - // Target + block interactions. - let hit = world.raycast(camera.position, camera.forward(), REACH); - let primary = std::mem::replace(&mut self.input.primary_clicked, false); - let secondary = std::mem::replace(&mut self.input.secondary_clicked, false); - - let mut edit_to_broadcast: Option = None; + if self.body.alive { if let Some((hit_pos, prev_pos)) = hit { if primary || secondary { let (target, set_to) = if primary { @@ -1617,10 +1541,8 @@ impl App { let block = block_from_u8(self.input.selected_block); (prev_pos, block) }; - let blocks_player = set_to.solid() && { - let bb = AabbI::block(target); - aabb_overlap_player(bb, feet) - }; + let blocks_player = set_to.solid() + && aabb_overlap_player(AabbI::block(target), self.body.feet); if !blocks_player && world.set_block(target, set_to) { let dirty: Vec = world .chunks @@ -1636,7 +1558,7 @@ impl App { } } } - edit_to_broadcast = Some(EditRec { + broadcast_edit = Some(EditRec { x: target.x, y: target.y, z: target.z, @@ -1645,30 +1567,31 @@ impl App { } } } - if let Some(e) = edit_to_broadcast { - let msg = ClientMsg::Edit { - x: e.x, - y: e.y, - z: e.z, - block: e.block, - }; - if let Ok(s) = serde_json::to_string(&msg) { - NET_BRIDGE.with(|n| n.borrow_mut().outbox.push(s)); - } + } + + if let Some(e) = broadcast_edit { + let msg = ClientMsg::Edit { + x: e.x, + y: e.y, + z: e.z, + block: e.block, + }; + if let Ok(s) = serde_json::to_string(&msg) { + NET_BRIDGE.with(|n| n.borrow_mut().outbox.push(s)); } - let _ = hit; - } else { - // Dead — drain inputs without acting on them. - self.input.primary_clicked = false; - self.input.secondary_clicked = false; + } + + if total_damage > 0 { + self.body = self.body.take_damage(total_damage); + self.push_status(); } // Periodic state broadcast. - if connected && self.alive && time - self.last_net_send > 0.1 { - self.last_net_send = time; + if connected && self.body.alive && real_time - self.last_net_send > 0.1 { + self.last_net_send = real_time; let msg = ClientMsg::State { x: camera.position.x, - y: camera.position.y - EYE_HEIGHT, + y: self.body.feet.y, z: camera.position.z, yaw: camera.yaw, pitch: camera.pitch, @@ -1678,24 +1601,26 @@ impl App { } } - // Compute outline target now (with mutable borrow already done above we re-raycast cheaply). - let outline_target = if self.alive { - world - .raycast(camera.position, camera.forward(), REACH) - .map(|(h, _)| h) + let outline = if self.body.alive { + hit.map(|(h, _)| h) } else { None }; drop(world_borrow); drop(camera_borrow); + self.render_frame(settings, outline); + let _ = WORLD_RADIUS; + } - if pending_damage > 0 { - self.take_damage(pending_damage); - } - + /// Render-only path used by both the active tick and the paused + /// branch. Keeps the camera-upload + visibility cull + remote- + /// player upload in one place. + fn render_frame(&self, settings: Settings, outline: Option) { let camera_borrow = self.camera.borrow(); - let camera = camera_borrow.as_ref().unwrap(); + let Some(camera) = camera_borrow.as_ref() else { + return; + }; let world_borrow = self.world.borrow(); let world_ref = world_borrow.as_ref(); let visible = if let Some(w) = world_ref { @@ -1704,17 +1629,13 @@ impl App { Vec::new() }; let remotes: Vec = NET_BRIDGE.with(|n| { - n.borrow() - .remote_players - .values() - .cloned() - .collect() + n.borrow().remote_players.values().cloned().collect() }); if let Some(r) = self.renderer.borrow_mut().as_mut() { - r.set_outline(outline_target); + r.set_outline(outline); r.set_visible(visible); r.set_remote_players(&remotes); - r.upload_camera(camera, time); + r.upload_camera(camera, self.shader_time); match r.render() { Ok(()) => {} Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => { @@ -1724,49 +1645,9 @@ impl App { Err(e) => log::error!("render error: {e:?}"), } } - let _ = WORLD_RADIUS; } } -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, - } -} - -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) -} - -fn chunks_for_edit(p: IVec3) -> Vec { - use crate::world::Face; - 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(target_arch = "wasm32")] async fn detect_webgpu() -> bool { use wasm_bindgen::{JsCast, JsValue}; @@ -1861,92 +1742,6 @@ fn html_escape(s: &str) -> String { .replace('>', ">") } -/// 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, -/// in which case we land the player on top of that tower — and as soon as -/// it's broken, the spawn drops back to the natural floor. -/// Pure: damage taken 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. Extracted so the boundary cases can be unit-tested. -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 - } -} - -/// Create the offscreen color texture + view for the world render. Same -/// format as the surface so its pixels can be sampled and written back to -/// the surface without any conversion cost. -fn create_scene_color_view( - device: &wgpu::Device, - w: u32, - h: u32, - format: wgpu::TextureFormat, -) -> wgpu::TextureView { - let tex = device.create_texture(&wgpu::TextureDescriptor { - label: Some("scene color"), - size: wgpu::Extent3d { - width: w.max(1), - height: h.max(1), - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, - view_formats: &[], - }); - tex.create_view(&wgpu::TextureViewDescriptor::default()) -} - -fn create_post_bg( - device: &wgpu::Device, - layout: &wgpu::BindGroupLayout, - scene: &wgpu::TextureView, - sampler: &wgpu::Sampler, -) -> wgpu::BindGroup { - device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("post bg"), - layout, - entries: &[ - wgpu::BindGroupEntry { - binding: 0, - resource: wgpu::BindingResource::TextureView(scene), - }, - wgpu::BindGroupEntry { - binding: 1, - resource: wgpu::BindingResource::Sampler(sampler), - }, - ], - }) -} - -fn find_safe_spawn(world: &World) -> Vec3 { - use crate::world::natural_surface_y; - let (x, z) = (0_i32, 0_i32); - let surface_y = natural_surface_y(x, z); // topmost solid in original terrain - let mut feet_y = surface_y + 1; // first air block above - 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) -} - fn name_hash(s: &str) -> u32 { let mut h: u32 = 2166136261; for b in s.bytes() { @@ -1956,9 +1751,9 @@ fn name_hash(s: &str) -> u32 { h } -/// Emit 6 faces of a box centered at `center`, rotated around Y by `yaw`, -/// extending `half_extents` in each local-space axis. Each face still winds -/// CCW outward (verified by `oriented_box_winds_correctly` test). +/// Emit 6 faces of a box centered at `center`, rotated around Y by +/// `yaw`, extending `half_extents` in each local-space axis. Each face +/// still winds CCW outward (verified by `oriented_box_winds_correctly`). pub fn emit_oriented_box( center: Vec3, half_extents: Vec3, @@ -1980,45 +1775,37 @@ pub fn emit_oriented_box( }; let (hx, hy, hz) = (half_extents.x, half_extents.y, half_extents.z); - // Faces in local coords; each face's four corners traverse CCW when - // viewed from outside, matching the [0,2,1,0,3,2] winding below. let faces: [([f32; 3], [[f32; 3]; 4]); 6] = [ - // +X ([1.0, 0.0, 0.0], [ [ hx, -hy, -hz], [ hx, -hy, hz], [ hx, hy, hz], [ hx, hy, -hz], ]), - // -X ([-1.0, 0.0, 0.0], [ [-hx, -hy, hz], [-hx, -hy, -hz], [-hx, hy, -hz], [-hx, hy, hz], ]), - // +Y ([0.0, 1.0, 0.0], [ [-hx, hy, -hz], [ hx, hy, -hz], [ hx, hy, hz], [-hx, hy, hz], ]), - // -Y ([0.0, -1.0, 0.0], [ [-hx, -hy, hz], [ hx, -hy, hz], [ hx, -hy, -hz], [-hx, -hy, -hz], ]), - // +Z ([0.0, 0.0, 1.0], [ [ hx, -hy, hz], [-hx, -hy, hz], [-hx, hy, hz], [ hx, hy, hz], ]), - // -Z ([0.0, 0.0, -1.0], [ [-hx, -hy, -hz], [ hx, -hy, -hz], @@ -2064,7 +1851,6 @@ fn compute_visible_chunks(world: &World, camera: &Camera, render_dist: f32) -> V let cam_xz = (camera.position.x, camera.position.z); let mut out = Vec::with_capacity(world.chunks.len()); for coord in world.chunks.keys() { - // Cheap radial cull first (square distance from camera to chunk centre on XZ). let cx = (coord.x * CHUNK_SIZE + CHUNK_SIZE / 2) as f32; let cz = (coord.z * CHUNK_SIZE + CHUNK_SIZE / 2) as f32; let dx = cx - cam_xz.0; @@ -2095,344 +1881,9 @@ fn compute_visible_chunks(world: &World, camera: &Camera, render_dist: f32) -> V out } -#[derive(Copy, Clone)] -enum Axis { - X, - Y, - Z, -} - -struct AabbI { - min: Vec3, - max: Vec3, -} - -impl AabbI { - 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), - } - } -} - -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 -} - -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 — the previous one-shot snap could -/// place the player *inside* terrain when per-tick delta exceeded one block -/// (e.g. high terminal-velocity falls). -fn move_axis(world: &World, feet: &mut Vec3, delta: f32, axis: Axis) -> bool { - if delta == 0.0 { - return 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; - 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; - } - match axis { - Axis::X => { - if step > 0.0 { - let edge = candidate.x + PLAYER_HALF_W; - feet.x = edge.floor() - PLAYER_HALF_W - eps; - } else { - let edge = candidate.x - PLAYER_HALF_W; - feet.x = edge.floor() + 1.0 + PLAYER_HALF_W + eps; - } - } - Axis::Z => { - if step > 0.0 { - let edge = candidate.z + PLAYER_HALF_W; - feet.z = edge.floor() - PLAYER_HALF_W - eps; - } else { - let edge = candidate.z - PLAYER_HALF_W; - feet.z = edge.floor() + 1.0 + PLAYER_HALF_W + eps; - } - } - Axis::Y => { - if step > 0.0 { - let top = candidate.y + PLAYER_HEIGHT; - feet.y = top.floor() - PLAYER_HEIGHT - eps; - } else { - let bottom = candidate.y; - feet.y = bottom.floor() + 1.0 + eps; - } - } - } - return true; - } - false -} - #[cfg(test)] mod tests { use super::*; - use crate::world::{natural_surface_y, Block, World}; - use glam::IVec3; - - // ---------- fall_damage: pure boundary tests ---------- - - // ---------- block_from_u8: every hotbar slot must round-trip ---------- - - #[test] - fn block_from_u8_roundtrips_for_every_hotbar_slot() { - // The HTML hotbar exposes data-b values 1..=10. Each must produce a - // solid Block when fed through select_block → tick → block_from_u8. - use crate::world::Block; - 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(), "{:?} should be a solid placeable block", b); - } - } - - #[test] - fn block_from_u8_falls_back_to_stone_on_garbage() { - use crate::world::Block; - assert_eq!(block_from_u8(99), Block::Stone); - assert_eq!(block_from_u8(255), Block::Stone); - } - - #[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); // exactly the threshold - } - - #[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() { - // Negative distances, NaN, and infinity all collapse to "no damage" - // — safer than guessing what an impossible fall should cost. - 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); - } - - // ---------- find_safe_spawn: prevents the death-loop bug ---------- - - #[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), - "tower set must succeed" - ); - } - let spawn = find_safe_spawn(&world); - assert!( - spawn.y as i32 > surface + 10, - "spawn must be above the tower top; got {} surface {}", - spawn.y, - surface - ); - } - - #[test] - fn spawn_returns_to_natural_after_tower_is_broken() { - // Reproduces the user-reported bug: build at spawn, break it, - // spawn must drop back to the natural floor (not stay perched). - 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); - let expected = surface + 1; - assert_eq!( - spawn.y as i32, expected, - "after breaking the tower spawn should return to natural surface" - ); - } - - #[test] - fn spawn_unaffected_by_remote_holes_below_surface() { - // Even if someone digs into the original ground, the *spawn anchor* - // is the natural surface, so we still try there first. - 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); - // Spawn y should still be the natural surface + 1 — the player will - // fall into the hole on the next physics tick, which is correct - // behavior (the spawn point itself didn't move). - assert_eq!(spawn.y as i32, surface + 1); - } - - // ---------- move_axis: collision invariants ---------- - - #[test] - fn move_axis_passes_freely_through_air() { - let world = World::new(); - let mut feet = Vec3::new(0.5, 60.0, 0.5); - let hit = move_axis(&world, &mut feet, -1.0, Axis::Y); - assert!(!hit); - assert!((feet.y - 59.0).abs() < 1e-3); - } - - #[test] - fn move_axis_blocks_against_ground() { - let world = World::new(); - let surface = natural_surface_y(0, 0); - let mut feet = Vec3::new(0.5, (surface + 1) as f32 + 0.01, 0.5); - // Sink 5 blocks downward — should snap to top of surface block. - let hit = move_axis(&world, &mut feet, -5.0, Axis::Y); - assert!(hit, "moving into the ground must register a hit"); - assert!( - feet.y >= (surface + 1) as f32, - "feet must rest on or above surface; got y={}", - feet.y - ); - assert!( - feet.y < (surface + 1) as f32 + 0.05, - "feet should snap to surface, not float; got y={}", - feet.y - ); - } - - // ---------- merge_held: the bug that ate playtests ---------- - // - // Original bug: tick() did `self.input.forward = self.input.forward || br.forward`. - // Releasing the joystick set `br.forward = false`, but the OR kept - // `self.input.forward` true forever, so the player walked indefinitely - // (and the jump button latched on the same way). These tests pin the - // behavior that lifting both sources of "forward" must actually stop. - - #[test] - fn merge_held_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 merge_held_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 merge_held_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); - // The crucial assertion: 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 merge_held_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 merge_held_kb_wins_even_if_bridge_drops() { - // While W is held, the joystick releasing shouldn't make the player - // stop — keyboard is an independent source. - let kb = KbHeld { forward: true, ..Default::default() }; - let br = TouchBridge::default(); - assert!(merge_held(&kb, &br).forward); - } - - // ---------- emit_oriented_box: rotation correctness ---------- fn cross_normal(a: [f32; 3], b: [f32; 3], c: [f32; 3]) -> [f32; 3] { let u = [b[0] - a[0], b[1] - a[1], b[2] - a[2]]; @@ -2455,12 +1906,17 @@ mod tests { #[test] fn oriented_box_winds_correctly_at_any_yaw() { - // At every yaw the cross-product per triangle must agree with the - // (rotated) stored normal. for &yaw in &[0.0_f32, 0.7, 1.5708, 3.14, -1.0, 5.0] { let mut v = vec![]; let mut i = vec![]; - emit_oriented_box(Vec3::new(5.0, 7.0, -3.0), Vec3::new(0.3, 0.6, 0.2), yaw, [1.0; 3], &mut v, &mut i); + emit_oriented_box( + Vec3::new(5.0, 7.0, -3.0), + Vec3::new(0.3, 0.6, 0.2), + yaw, + [1.0; 3], + &mut v, + &mut i, + ); for tri in i.chunks_exact(3) { let a = v[tri[0] as usize].pos; let b = v[tri[1] as usize].pos; @@ -2468,24 +1924,17 @@ mod tests { let n = v[tri[0] as usize].normal; let geo = cross_normal(a, b, c); let dot = geo[0] * n[0] + geo[1] * n[1] + geo[2] * n[2]; - assert!( - dot > 0.0, - "yaw {} triangle [{},{},{}] winds opposite normal {:?}", - yaw, tri[0], tri[1], tri[2], n - ); + assert!(dot > 0.0, "yaw {} tri winds opposite normal {:?}", yaw, n); } } } #[test] fn oriented_box_normal_rotates_with_yaw() { - // At yaw = 90° the local +X face's normal should point at world +Z. let mut v = vec![]; let mut i = vec![]; let yaw = std::f32::consts::FRAC_PI_2; emit_oriented_box(Vec3::ZERO, Vec3::splat(0.5), yaw, [1.0; 3], &mut v, &mut i); - // The first face emitted is +X local (see emit_oriented_box). Its - // vertices are 0..3. Their stored normal should be ~ (0, 0, 1). let n = v[0].normal; assert!( (n[0]).abs() < 1e-5 && (n[2] - 1.0).abs() < 1e-5, @@ -2493,16 +1942,4 @@ mod tests { n ); } - - #[test] - fn move_axis_does_not_let_player_enter_a_solid_block() { - let world = World::new(); - let surface = natural_surface_y(0, 0); - let mut feet = Vec3::new(0.5, (surface + 1) as f32, 0.5); - let _ = move_axis(&world, &mut feet, -100.0, Axis::Y); - assert!( - !player_overlaps_solid(&world, feet), - "after a downward sweep the player must never end up inside a block" - ); - } }