Skip to main content

steel_core/physics/
entity_move.rs

1//! Entity movement physics with vanilla parity.
2//!
3//! Implements vanilla's `Entity.move()` method with:
4//! - Step-up mechanics for climbing small obstacles
5//! - Sneak-edge prevention for staying on blocks while crouching
6//! - Proper collision detection and resolution
7
8use glam::DVec3;
9use steel_utils::WorldAabb;
10
11use crate::behavior::BlockCollisionContext;
12use crate::physics::{
13    collision::CollisionWorld, physics_state::EntityPhysicsState, shapes::collide,
14};
15use steel_utils::axis::Axis;
16
17const ZERO_MOVEMENT_EPSILON: f64 = 1.0e-7;
18const EDGE_STEP: f64 = 0.05;
19const EDGE_COLLISION_EPSILON: f64 = 1.0e-7;
20const STEP_HEIGHT_COLLISION_EPSILON: f64 = 1.0e-5;
21const MTH_EQUAL_EPSILON: f64 = 1.0e-5;
22
23/// Type of movement being performed.
24///
25/// Affects how the entity interacts with the world during movement.
26/// Matches vanilla's `MoverType` enum.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum MoverType {
29    /// Normal entity movement (walking, jumping, gravity).
30    SelfMovement,
31    /// Movement requested by a serverbound player or controlled-vehicle packet.
32    Player,
33    /// Movement caused by external forces (pistons, etc).
34    Piston,
35    /// Movement from shulker box opening/closing.
36    ShulkerBox,
37    /// Movement from shulker entity teleportation.
38    Shulker,
39}
40
41/// Result of a movement operation.
42#[derive(Debug, Clone)]
43pub struct MoveResult {
44    /// The entity's final position after movement and collision resolution.
45    pub final_position: DVec3,
46
47    /// The actual movement delta applied (may differ from requested due to collisions).
48    pub actual_movement: DVec3,
49
50    /// Whether the entity is on the ground after movement.
51    pub on_ground: bool,
52
53    /// Whether horizontal collision occurred (X or Z).
54    pub horizontal_collision: bool,
55
56    /// Whether vertical collision occurred.
57    pub vertical_collision: bool,
58
59    /// Whether X-axis collision occurred (requested.x != actual.x).
60    pub x_collision: bool,
61
62    /// Whether Z-axis collision occurred (requested.z != actual.z).
63    pub z_collision: bool,
64
65    /// The entity's AABB at the final position.
66    pub final_aabb: WorldAabb,
67}
68
69/// Moves an entity through the world with collision detection and resolution.
70///
71/// This is the main physics function that implements vanilla's `Entity.move()` behavior,
72/// including step-up mechanics and sneak-edge prevention.
73///
74/// # Arguments
75/// * `state` - The entity's current physics state
76/// * `delta` - The desired movement vector (velocity * dt)
77/// * `mover_type` - Type of movement being performed
78/// * `world` - World collision provider
79///
80/// # Returns
81/// A `MoveResult` containing the final position and collision information.
82///
83/// # Vanilla Reference
84/// `net.minecraft.world.entity.Entity.move(MoverType, Vec3)`
85pub(crate) fn move_entity(
86    state: &EntityPhysicsState,
87    delta: DVec3,
88    mover_type: MoverType,
89    world: &dyn CollisionWorld,
90) -> MoveResult {
91    // Early exit for zero movement
92    if delta.x.abs() < ZERO_MOVEMENT_EPSILON
93        && delta.y.abs() < ZERO_MOVEMENT_EPSILON
94        && delta.z.abs() < ZERO_MOVEMENT_EPSILON
95    {
96        return MoveResult {
97            final_position: state.position(),
98            actual_movement: DVec3::new(0.0, 0.0, 0.0),
99            on_ground: state.on_ground(),
100            horizontal_collision: false,
101            vertical_collision: false,
102            x_collision: false,
103            z_collision: false,
104            final_aabb: state.bounding_box(),
105        };
106    }
107
108    // Vanilla `Entity.move()` uses the real bounding box for collision.
109    // Deflating here causes entities (especially items) to end up slightly inside blocks,
110    // which can create client-side desync where the client thinks the entity is falling.
111    let aabb = state.bounding_box();
112
113    // Apply sneak-edge prevention if crouching.
114    let movement = if state.backs_off_from_edge()
115        && matches!(mover_type, MoverType::SelfMovement | MoverType::Player)
116    {
117        apply_sneak_edge_prevention(state, delta, &aabb, world)
118    } else {
119        delta
120    };
121
122    let swept_aabb = sweep_aabb(&aabb, movement);
123    let entity_collisions = world.get_entity_collisions(&swept_aabb);
124
125    // Perform basic collision resolution
126    let collision_result = collide_with_world(state, movement, &aabb, world, &entity_collisions);
127
128    // Try step-up if horizontal collision occurred
129    if should_try_step_up(state, &collision_result, mover_type) {
130        try_step_up(
131            state,
132            movement,
133            &aabb,
134            &collision_result,
135            &entity_collisions,
136            world,
137        )
138    } else {
139        collision_result
140    }
141}
142
143/// Applies sneak-edge prevention to keep player from walking off blocks.
144///
145/// When crouching, checks if the movement would cause the player to fall off
146/// a block edge. If so, clips the movement to keep them on the block.
147///
148/// Matches: `Player.maybeBackOffFromEdge(Vec3, MoverType)`
149fn apply_sneak_edge_prevention(
150    state: &EntityPhysicsState,
151    delta: DVec3,
152    aabb: &WorldAabb,
153    world: &dyn CollisionWorld,
154) -> DVec3 {
155    if delta.y > 0.0 || !is_above_ground(state, aabb, world) {
156        return delta;
157    }
158
159    let max_down_step = f64::from(state.max_up_step());
160    let mut delta_x = delta.x;
161    let mut delta_z = delta.z;
162    let step_x = delta_x.signum() * EDGE_STEP;
163    let step_z = delta_z.signum() * EDGE_STEP;
164
165    while delta_x != 0.0 && can_fall_at_least(state, aabb, delta_x, 0.0, max_down_step, world) {
166        if delta_x.abs() <= EDGE_STEP {
167            delta_x = 0.0;
168            break;
169        }
170
171        delta_x -= step_x;
172    }
173
174    while delta_z != 0.0 && can_fall_at_least(state, aabb, 0.0, delta_z, max_down_step, world) {
175        if delta_z.abs() <= EDGE_STEP {
176            delta_z = 0.0;
177            break;
178        }
179
180        delta_z -= step_z;
181    }
182
183    while delta_x != 0.0
184        && delta_z != 0.0
185        && can_fall_at_least(state, aabb, delta_x, delta_z, max_down_step, world)
186    {
187        if delta_x.abs() <= EDGE_STEP {
188            delta_x = 0.0;
189        } else {
190            delta_x -= step_x;
191        }
192
193        if delta_z.abs() <= EDGE_STEP {
194            delta_z = 0.0;
195        } else {
196            delta_z -= step_z;
197        }
198    }
199
200    DVec3::new(delta_x, delta.y, delta_z)
201}
202
203fn is_above_ground(
204    state: &EntityPhysicsState,
205    aabb: &WorldAabb,
206    world: &dyn CollisionWorld,
207) -> bool {
208    if state.on_ground() {
209        return true;
210    }
211
212    let max_down_step = f64::from(state.max_up_step());
213    let fall_distance = state.fall_distance();
214    fall_distance < max_down_step
215        && !can_fall_at_least(state, aabb, 0.0, 0.0, max_down_step - fall_distance, world)
216}
217
218fn can_fall_at_least(
219    state: &EntityPhysicsState,
220    aabb: &WorldAabb,
221    delta_x: f64,
222    delta_z: f64,
223    min_height: f64,
224    world: &dyn CollisionWorld,
225) -> bool {
226    if min_height <= 0.0 {
227        return false;
228    }
229
230    let fall_aabb = WorldAabb::new(
231        aabb.min_x() + EDGE_COLLISION_EPSILON + delta_x,
232        aabb.min_y() - min_height - EDGE_COLLISION_EPSILON,
233        aabb.min_z() + EDGE_COLLISION_EPSILON + delta_z,
234        aabb.max_x() - EDGE_COLLISION_EPSILON + delta_x,
235        aabb.min_y(),
236        aabb.max_z() - EDGE_COLLISION_EPSILON + delta_z,
237    );
238
239    !world.has_collision_with_context(&fall_aabb, state.block_collision_context())
240}
241
242/// Returns the axis step order for collision resolution.
243///
244/// Vanilla's `Direction.axisStepOrder(Vec3)` returns:
245/// - YZX if `|x| < |z|` (move along Z before X)
246/// - YXZ otherwise (move along X before Z)
247///
248/// Y is always first because gravity/vertical movement should be resolved first.
249fn axis_step_order(movement: DVec3) -> [Axis; 3] {
250    if movement.x.abs() < movement.z.abs() {
251        [Axis::Y, Axis::Z, Axis::X]
252    } else {
253        [Axis::Y, Axis::X, Axis::Z]
254    }
255}
256
257/// Performs collision detection and resolution along all three axes.
258///
259/// Matches vanilla's `Entity.collideWithShapes()` behavior exactly:
260/// - Uses dynamic axis order based on movement direction (Y first, then X/Z based on magnitude)
261/// - Accumulates resolved movement and moves AABB after each axis
262#[expect(
263    clippy::float_cmp,
264    reason = "intentional: checking if collision clipped the movement value"
265)]
266fn collide_with_world(
267    state: &EntityPhysicsState,
268    movement: DVec3,
269    aabb: &WorldAabb,
270    world: &dyn CollisionWorld,
271    entity_collisions: &[WorldAabb],
272) -> MoveResult {
273    // Get all collision shapes that could intersect with our movement
274    let swept_aabb = sweep_aabb(aabb, movement);
275    let collisions = collect_collisions_with_context(
276        world,
277        &swept_aabb,
278        state.block_collision_context(),
279        entity_collisions,
280    );
281
282    let (resolved, current_aabb) = collide_with_shapes(movement, aabb, &collisions);
283    let final_position = state.position() + resolved;
284
285    // Check if on ground (touching block below with epsilon tolerance)
286    let on_ground = resolved.y != movement.y && movement.y < 0.0;
287
288    // Detect collisions (vanilla: Entity.move lines 751-757)
289    let x_collision = horizontal_axis_collided(movement.x, resolved.x);
290    let z_collision = horizontal_axis_collided(movement.z, resolved.z);
291    let horizontal_collision = x_collision || z_collision;
292    let vertical_collision = resolved.y != movement.y;
293
294    MoveResult {
295        final_position,
296        actual_movement: resolved,
297        on_ground,
298        horizontal_collision,
299        vertical_collision,
300        x_collision,
301        z_collision,
302        final_aabb: current_aabb,
303    }
304}
305
306/// Resolves movement against a pre-collected shape set.
307fn collide_with_shapes(
308    movement: DVec3,
309    aabb: &WorldAabb,
310    collisions: &[WorldAabb],
311) -> (DVec3, WorldAabb) {
312    // Vanilla: collideWithShapes iterates in dynamic axis order
313    let axes = axis_step_order(movement);
314
315    // Track resolved movement per axis and current AABB position
316    let mut resolved = DVec3::new(0.0, 0.0, 0.0);
317    let mut current_aabb = *aabb;
318
319    for axis in axes {
320        let axis_movement = match axis {
321            Axis::X => movement.x,
322            Axis::Y => movement.y,
323            Axis::Z => movement.z,
324        };
325
326        if axis_movement != 0.0 {
327            let collision = collide(axis, &current_aabb, collisions, axis_movement);
328
329            // Update resolved movement for this axis
330            match axis {
331                Axis::X => resolved.x = collision,
332                Axis::Y => resolved.y = collision,
333                Axis::Z => resolved.z = collision,
334            }
335
336            // Move AABB by the resolved amount (vanilla: boundingBox.move(resolvedMovement))
337            current_aabb = move_aabb(&current_aabb, axis, collision);
338        }
339    }
340
341    (resolved, current_aabb)
342}
343
344fn collect_collisions_with_context(
345    world: &dyn CollisionWorld,
346    aabb: &WorldAabb,
347    context: BlockCollisionContext,
348    entity_collisions: &[WorldAabb],
349) -> Vec<WorldAabb> {
350    let mut collisions = Vec::with_capacity(entity_collisions.len());
351    collisions.extend_from_slice(entity_collisions);
352    collisions.extend(world.get_world_border_collisions(aabb));
353    collisions.extend(world.get_block_collisions_with_context(aabb, context));
354    collisions
355}
356
357/// Moves an AABB along a single axis by the given amount.
358fn move_aabb(aabb: &WorldAabb, axis: Axis, amount: f64) -> WorldAabb {
359    match axis {
360        Axis::X => aabb.translate(DVec3::ZERO.with_x(amount)),
361        Axis::Y => aabb.translate(DVec3::ZERO.with_y(amount)),
362        Axis::Z => aabb.translate(DVec3::ZERO.with_z(amount)),
363    }
364}
365
366fn horizontal_axis_collided(requested: f64, actual: f64) -> bool {
367    // Vanilla reports horizontal collision with `!Mth.equal(requested, actual)`.
368    (actual - requested).abs() >= MTH_EQUAL_EPSILON
369}
370
371/// Checks if step-up should be attempted.
372fn should_try_step_up(
373    state: &EntityPhysicsState,
374    collision_result: &MoveResult,
375    mover_type: MoverType,
376) -> bool {
377    // Only try step-up for normal entity/player movement.
378    if !matches!(mover_type, MoverType::SelfMovement | MoverType::Player) {
379        return false;
380    }
381
382    // Must have step height > 0
383    if state.max_up_step() <= 0.0 {
384        return false;
385    }
386
387    // Must have horizontal collision
388    if !collision_result.horizontal_collision {
389        return false;
390    }
391
392    // Must be on ground or just landed
393    if !state.on_ground() && !collision_result.on_ground {
394        return false;
395    }
396
397    true
398}
399
400/// Attempts to step up over an obstacle.
401///
402/// This implements vanilla's step-up algorithm from `Entity.collide()`.
403///
404/// Vanilla calls `collideWithShapes(Vec3(movement.x, stepHeight, movement.z), groundedAABB, colliders)`
405/// which uses the dynamic axis order. If step-up gives more horizontal progress, use it.
406///
407/// Matches: `Entity.collide()` lines 1077-1095
408#[expect(
409    clippy::float_cmp,
410    reason = "intentional: checking if collision clipped the movement value"
411)]
412fn try_step_up(
413    state: &EntityPhysicsState,
414    movement: DVec3,
415    aabb: &WorldAabb,
416    ground_result: &MoveResult,
417    entity_collisions: &[WorldAabb],
418    world: &dyn CollisionWorld,
419) -> MoveResult {
420    let max_step = f64::from(state.max_up_step());
421    let on_ground_after_collision = ground_result.vertical_collision && movement.y < 0.0;
422    let grounded_aabb = if on_ground_after_collision {
423        aabb.translate(DVec3::ZERO.with_y(ground_result.actual_movement.y))
424    } else {
425        *aabb
426    };
427
428    let mut step_sweep_aabb =
429        grounded_aabb.expand_towards(DVec3::new(movement.x, max_step, movement.z));
430    if !on_ground_after_collision {
431        step_sweep_aabb =
432            step_sweep_aabb.expand_towards(DVec3::new(0.0, -STEP_HEIGHT_COLLISION_EPSILON, 0.0));
433    }
434    let collisions = collect_collisions_with_context(
435        world,
436        &step_sweep_aabb,
437        state.block_collision_context(),
438        entity_collisions,
439    );
440    let candidates = collect_candidate_step_up_heights(
441        &grounded_aabb,
442        &collisions,
443        max_step,
444        ground_result.actual_movement.y,
445    );
446
447    let ground_dist_sq =
448        ground_result.actual_movement.x.powi(2) + ground_result.actual_movement.z.powi(2);
449
450    for candidate in candidates {
451        let step_movement = DVec3::new(movement.x, candidate, movement.z);
452        let (step_from_ground, stepped_aabb) =
453            collide_with_shapes(step_movement, &grounded_aabb, &collisions);
454        let step_dist_sq = step_from_ground.x.powi(2) + step_from_ground.z.powi(2);
455
456        if step_dist_sq <= ground_dist_sq {
457            continue;
458        }
459
460        let distance_to_ground = aabb.min_y() - grounded_aabb.min_y();
461        let actual_movement = step_from_ground - DVec3::new(0.0, distance_to_ground, 0.0);
462        let final_aabb = stepped_aabb.translate(DVec3::ZERO.with_y(-distance_to_ground));
463        let x_collision = horizontal_axis_collided(movement.x, actual_movement.x);
464        let z_collision = horizontal_axis_collided(movement.z, actual_movement.z);
465        let vertical_collision = actual_movement.y != movement.y;
466
467        return MoveResult {
468            final_position: state.position() + actual_movement,
469            actual_movement,
470            on_ground: vertical_collision && movement.y < 0.0,
471            horizontal_collision: x_collision || z_collision,
472            vertical_collision,
473            x_collision,
474            z_collision,
475            final_aabb,
476        };
477    }
478
479    MoveResult {
480        final_position: ground_result.final_position,
481        actual_movement: ground_result.actual_movement,
482        on_ground: ground_result.on_ground,
483        horizontal_collision: ground_result.horizontal_collision,
484        vertical_collision: ground_result.vertical_collision,
485        x_collision: ground_result.x_collision,
486        z_collision: ground_result.z_collision,
487        final_aabb: ground_result.final_aabb,
488    }
489}
490
491#[expect(
492    clippy::float_cmp,
493    reason = "intentional: vanilla candidate filtering uses exact float equality"
494)]
495fn collect_candidate_step_up_heights(
496    grounded_aabb: &WorldAabb,
497    collisions: &[WorldAabb],
498    max_step_height: f64,
499    step_height_to_skip: f64,
500) -> Vec<f64> {
501    let mut candidates = Vec::new();
502
503    for collider in collisions {
504        push_step_height_candidate(
505            &mut candidates,
506            collider.min_y() - grounded_aabb.min_y(),
507            max_step_height,
508            step_height_to_skip,
509        );
510        push_step_height_candidate(
511            &mut candidates,
512            collider.max_y() - grounded_aabb.min_y(),
513            max_step_height,
514            step_height_to_skip,
515        );
516    }
517
518    candidates.sort_by(f64::total_cmp);
519    candidates.dedup_by(|a, b| *a == *b);
520    candidates
521}
522
523#[expect(
524    clippy::float_cmp,
525    reason = "intentional: vanilla candidate filtering uses exact float equality"
526)]
527fn push_step_height_candidate(
528    candidates: &mut Vec<f64>,
529    relative_height: f64,
530    max_step_height: f64,
531    step_height_to_skip: f64,
532) {
533    if relative_height < 0.0
534        || relative_height > max_step_height
535        || relative_height == step_height_to_skip
536    {
537        return;
538    }
539
540    candidates.push(relative_height);
541}
542
543/// Creates an AABB that encompasses the start and end positions of a movement.
544fn sweep_aabb(aabb: &WorldAabb, movement: DVec3) -> WorldAabb {
545    aabb.expand_towards(movement)
546}
547
548#[cfg(test)]
549mod tests {
550    use super::*;
551    use crate::physics::collision::CollisionWorld;
552    use steel_registry::REGISTRY;
553    use steel_registry::vanilla_blocks;
554    use steel_registry::vanilla_entities;
555    use steel_utils::BlockPos;
556
557    /// Mock collision world for testing
558    struct MockWorld {
559        // Block at Y=0 (floor)
560        has_floor: bool,
561    }
562
563    impl CollisionWorld for MockWorld {
564        fn get_block_state(&self, pos: BlockPos) -> steel_utils::BlockStateId {
565            if self.has_floor && pos.y() == 0 {
566                REGISTRY.blocks.get_base_state_id(&vanilla_blocks::STONE)
567            } else {
568                REGISTRY.blocks.get_base_state_id(&vanilla_blocks::AIR)
569            }
570        }
571
572        fn get_block_collisions(&self, aabb: &WorldAabb) -> Vec<WorldAabb> {
573            let mut collisions = Vec::new();
574
575            if self.has_floor && aabb.min_y() <= 1.0 {
576                // Full block at Y=0
577                collisions.push(WorldAabb::new(-10.0, 0.0, -10.0, 10.0, 1.0, 10.0));
578            }
579
580            collisions
581        }
582
583        fn get_pre_move_collisions(
584            &self,
585            _aabb: &WorldAabb,
586            _old_pos: DVec3,
587            _descending: bool,
588        ) -> Vec<WorldAabb> {
589            Vec::new()
590        }
591    }
592
593    struct BoxWorld {
594        boxes: Vec<WorldAabb>,
595    }
596
597    impl CollisionWorld for BoxWorld {
598        fn get_block_state(&self, _pos: BlockPos) -> steel_utils::BlockStateId {
599            REGISTRY.blocks.get_base_state_id(&vanilla_blocks::AIR)
600        }
601
602        fn get_block_collisions(&self, aabb: &WorldAabb) -> Vec<WorldAabb> {
603            self.boxes
604                .iter()
605                .copied()
606                .filter(|collision| collision.intersects(*aabb))
607                .collect()
608        }
609
610        fn get_pre_move_collisions(
611            &self,
612            _aabb: &WorldAabb,
613            _old_pos: DVec3,
614            _descending: bool,
615        ) -> Vec<WorldAabb> {
616            Vec::new()
617        }
618    }
619
620    struct EntityBoxWorld {
621        boxes: Vec<WorldAabb>,
622    }
623
624    impl CollisionWorld for EntityBoxWorld {
625        fn get_block_state(&self, _pos: BlockPos) -> steel_utils::BlockStateId {
626            REGISTRY.blocks.get_base_state_id(&vanilla_blocks::AIR)
627        }
628
629        fn get_block_collisions(&self, _aabb: &WorldAabb) -> Vec<WorldAabb> {
630            Vec::new()
631        }
632
633        fn get_entity_collisions(&self, aabb: &WorldAabb) -> Vec<WorldAabb> {
634            self.boxes
635                .iter()
636                .copied()
637                .filter(|collision| collision.intersects(*aabb))
638                .collect()
639        }
640    }
641
642    struct BorderWorld {
643        boxes: Vec<WorldAabb>,
644    }
645
646    impl CollisionWorld for BorderWorld {
647        fn get_block_state(&self, _pos: BlockPos) -> steel_utils::BlockStateId {
648            REGISTRY.blocks.get_base_state_id(&vanilla_blocks::AIR)
649        }
650
651        fn get_block_collisions(&self, _aabb: &WorldAabb) -> Vec<WorldAabb> {
652            Vec::new()
653        }
654
655        fn get_world_border_collisions(&self, aabb: &WorldAabb) -> Vec<WorldAabb> {
656            self.boxes
657                .iter()
658                .copied()
659                .filter(|collision| collision.intersects(*aabb))
660                .collect()
661        }
662    }
663
664    fn player_state(position: DVec3) -> EntityPhysicsState {
665        EntityPhysicsState::with_dimensions(position, vanilla_entities::PLAYER.dimensions, 0.6)
666    }
667
668    fn item_state(position: DVec3) -> EntityPhysicsState {
669        EntityPhysicsState::with_dimensions(position, vanilla_entities::ITEM.dimensions, 0.6)
670    }
671
672    #[test]
673    fn test_move_entity_free_fall() {
674        let state = player_state(DVec3::new(0.0, 10.0, 0.0));
675
676        let world = MockWorld { has_floor: true };
677        let gravity = DVec3::new(0.0, -0.08, 0.0); // Vanilla gravity per tick
678
679        let result = move_entity(&state, gravity, MoverType::SelfMovement, &world);
680
681        assert!(result.final_position.y < 10.0, "Should fall down");
682        assert!(
683            !result.on_ground,
684            "Should not be on ground yet (only fell 0.08)"
685        );
686    }
687
688    #[test]
689    fn test_move_entity_land_on_ground() {
690        let state = player_state(DVec3::new(0.0, 5.0, 0.0));
691
692        let world = MockWorld { has_floor: true };
693        let large_fall = DVec3::new(0.0, -10.0, 0.0);
694
695        let result = move_entity(&state, large_fall, MoverType::SelfMovement, &world);
696
697        assert!(result.on_ground, "Should be on ground after landing");
698
699        assert!(
700            result.vertical_collision,
701            "Should detect vertical collision"
702        );
703    }
704
705    #[test]
706    fn test_move_entity_no_collision_in_air() {
707        let state = player_state(DVec3::new(0.0, 10.0, 0.0));
708
709        let world = MockWorld { has_floor: false };
710        let movement = DVec3::new(1.0, 0.0, 1.0);
711
712        let result = move_entity(&state, movement, MoverType::SelfMovement, &world);
713
714        assert_eq!(
715            result.actual_movement, movement,
716            "Should move freely in air"
717        );
718        assert!(!result.horizontal_collision, "Should have no collision");
719    }
720
721    #[test]
722    fn test_item_on_ground_with_accumulated_velocity() {
723        // Simulates an item that's on the ground (Y=1.0 on top of floor)
724        // and has accumulated negative velocity from gravity
725        let state = item_state(DVec3::new(0.0, 1.0, 0.0)).with_on_ground(true);
726
727        let world = MockWorld { has_floor: true };
728
729        // Simulate accumulated velocity from 25 ticks of gravity (0.04 per tick)
730        let accumulated_velocity = DVec3::new(0.0, -1.0, 0.0);
731
732        let result = move_entity(
733            &state,
734            accumulated_velocity,
735            MoverType::SelfMovement,
736            &world,
737        );
738
739        // Item should NOT fall through the floor
740        assert!(
741            result.final_position.y >= 0.99,
742            "Item should stay on floor, but Y = {}",
743            result.final_position.y
744        );
745        assert!(result.on_ground, "Item should still be on ground");
746    }
747
748    #[test]
749    fn test_item_slightly_above_ground() {
750        // Simulates an item that's slightly above the ground due to floating point
751        // Floor at Y=1.0, item at Y=1.00001 (just above)
752        let state = item_state(DVec3::new(0.0, 1.00001, 0.0));
753
754        let world = MockWorld { has_floor: true };
755
756        // Small downward velocity
757        let velocity = DVec3::new(0.0, -0.04, 0.0);
758
759        let result = move_entity(&state, velocity, MoverType::SelfMovement, &world);
760
761        // Item should land on the floor, not fall through
762        assert!(
763            result.final_position.y >= 0.99,
764            "Item should land on floor, but Y = {}",
765            result.final_position.y
766        );
767    }
768
769    #[test]
770    fn test_crouching_backs_off_from_edge_incrementally() {
771        let state = player_state(DVec3::new(0.0, 1.0, 0.0))
772            .with_on_ground(true)
773            .with_backs_off_from_edge(true);
774
775        let world = BoxWorld {
776            boxes: vec![WorldAabb::new(-2.0, 0.0, -2.0, 0.5, 1.0, 2.0)],
777        };
778
779        let result = move_entity(&state, DVec3::new(1.0, 0.0, 0.0), MoverType::Player, &world);
780
781        assert!(
782            result.actual_movement.x > 0.0 && result.actual_movement.x < 1.0,
783            "sneak edge should trim movement instead of fully allowing or fully blocking it: {:?}",
784            result.actual_movement
785        );
786        assert!(result.actual_movement.y.abs() < ZERO_MOVEMENT_EPSILON);
787    }
788
789    #[test]
790    fn test_sneak_edge_treats_entity_collision_as_support() {
791        let state = player_state(DVec3::new(0.0, 1.0, 0.0))
792            .with_on_ground(true)
793            .with_backs_off_from_edge(true);
794        let world = EntityBoxWorld {
795            boxes: vec![WorldAabb::new(0.7, 0.4, -0.3, 1.3, 1.0, 0.3)],
796        };
797        let movement = DVec3::new(1.0, 0.0, 0.0);
798
799        let result = move_entity(&state, movement, MoverType::Player, &world);
800
801        assert_eq!(result.actual_movement, movement);
802    }
803
804    #[test]
805    fn test_not_crouching_can_move_off_edge() {
806        let state = player_state(DVec3::new(0.0, 1.0, 0.0)).with_on_ground(true);
807
808        let world = BoxWorld {
809            boxes: vec![WorldAabb::new(-2.0, 0.0, -2.0, 0.5, 1.0, 2.0)],
810        };
811        let movement = DVec3::new(1.0, 0.0, 0.0);
812
813        let result = move_entity(&state, movement, MoverType::Player, &world);
814
815        assert_eq!(result.actual_movement, movement);
816    }
817
818    #[test]
819    fn test_entity_collision_clips_horizontal_movement() {
820        let state = player_state(DVec3::new(0.0, 1.0, 0.0));
821        let world = EntityBoxWorld {
822            boxes: vec![WorldAabb::new(0.7, 1.0, -0.3, 1.7, 2.8, 0.3)],
823        };
824
825        let result = move_entity(
826            &state,
827            DVec3::new(1.0, 0.0, 0.0),
828            MoverType::SelfMovement,
829            &world,
830        );
831
832        assert!(
833            result.actual_movement.x > 0.39 && result.actual_movement.x < 0.41,
834            "entity collision should clip movement at the other entity's box: {:?}",
835            result.actual_movement
836        );
837        assert!(result.horizontal_collision);
838        assert!(result.x_collision);
839    }
840
841    #[test]
842    fn test_world_border_collision_clips_horizontal_movement() {
843        let state = player_state(DVec3::new(0.0, 1.0, 0.0));
844        let world = BorderWorld {
845            boxes: vec![WorldAabb::new(
846                1.0,
847                f64::NEG_INFINITY,
848                f64::NEG_INFINITY,
849                f64::INFINITY,
850                f64::INFINITY,
851                f64::INFINITY,
852            )],
853        };
854
855        let result = move_entity(
856            &state,
857            DVec3::new(2.0, 0.0, 0.0),
858            MoverType::SelfMovement,
859            &world,
860        );
861
862        assert!(
863            result.actual_movement.x > 0.69 && result.actual_movement.x < 0.71,
864            "world border should clip movement at its outside shape: {:?}",
865            result.actual_movement
866        );
867        assert!(result.horizontal_collision);
868        assert!(result.x_collision);
869    }
870
871    #[test]
872    fn test_step_up_uses_obstacle_candidate_height() {
873        let state = player_state(DVec3::new(0.0, 1.0, 0.0)).with_on_ground(true);
874
875        let world = BoxWorld {
876            boxes: vec![
877                WorldAabb::new(-10.0, 0.0, -10.0, 10.0, 1.0, 10.0),
878                WorldAabb::new(0.5, 1.0, -1.0, 1.5, 1.5, 1.0),
879            ],
880        };
881
882        let result = move_entity(
883            &state,
884            DVec3::new(1.0, 0.0, 0.0),
885            MoverType::SelfMovement,
886            &world,
887        );
888
889        assert!(
890            result.actual_movement.x > 0.9,
891            "step-up should preserve horizontal movement: {:?}",
892            result.actual_movement
893        );
894        assert!((result.actual_movement.y - 0.5).abs() < ZERO_MOVEMENT_EPSILON);
895    }
896
897    #[test]
898    fn test_step_up_rejects_obstacle_above_max_step() {
899        let state = player_state(DVec3::new(0.0, 1.0, 0.0)).with_on_ground(true);
900
901        let world = BoxWorld {
902            boxes: vec![
903                WorldAabb::new(-10.0, 0.0, -10.0, 10.0, 1.0, 10.0),
904                WorldAabb::new(0.5, 1.0, -1.0, 1.5, 2.0, 1.0),
905            ],
906        };
907
908        let result = move_entity(
909            &state,
910            DVec3::new(1.0, 0.0, 0.0),
911            MoverType::SelfMovement,
912            &world,
913        );
914
915        assert!(
916            result.actual_movement.x < 0.3,
917            "movement should stay clipped by the tall obstacle: {:?}",
918            result.actual_movement
919        );
920        assert!(result.actual_movement.y.abs() < ZERO_MOVEMENT_EPSILON);
921        assert!(result.horizontal_collision);
922    }
923
924    #[test]
925    fn horizontal_collision_uses_vanilla_mth_equal_tolerance() {
926        assert!(!horizontal_axis_collided(1.0, 1.0));
927        assert!(!horizontal_axis_collided(1.0, 1.0 - 0.5e-5));
928        assert!(horizontal_axis_collided(1.0, 1.0 - 1.1e-5));
929        assert!(horizontal_axis_collided(1.0, 1.0 - 2.0e-5));
930    }
931}