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: &(impl CollisionWorld + ?Sized),
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: &(impl CollisionWorld + ?Sized),
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: &(impl CollisionWorld + ?Sized),
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: &(impl CollisionWorld + ?Sized),
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: &(impl CollisionWorld + ?Sized),
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: &(impl CollisionWorld + ?Sized),
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    let block_collisions = world.get_block_collisions_with_context(aabb, context);
354
355    if collisions.is_empty() {
356        return block_collisions;
357    }
358
359    collisions.extend(block_collisions);
360    collisions
361}
362
363/// Moves an AABB along a single axis by the given amount.
364fn move_aabb(aabb: &WorldAabb, axis: Axis, amount: f64) -> WorldAabb {
365    match axis {
366        Axis::X => aabb.translate(DVec3::ZERO.with_x(amount)),
367        Axis::Y => aabb.translate(DVec3::ZERO.with_y(amount)),
368        Axis::Z => aabb.translate(DVec3::ZERO.with_z(amount)),
369    }
370}
371
372fn horizontal_axis_collided(requested: f64, actual: f64) -> bool {
373    // Vanilla reports horizontal collision with `!Mth.equal(requested, actual)`.
374    (actual - requested).abs() >= MTH_EQUAL_EPSILON
375}
376
377/// Checks if step-up should be attempted.
378fn should_try_step_up(
379    state: &EntityPhysicsState,
380    collision_result: &MoveResult,
381    mover_type: MoverType,
382) -> bool {
383    // Only try step-up for normal entity/player movement.
384    if !matches!(mover_type, MoverType::SelfMovement | MoverType::Player) {
385        return false;
386    }
387
388    // Must have step height > 0
389    if state.max_up_step() <= 0.0 {
390        return false;
391    }
392
393    // Must have horizontal collision
394    if !collision_result.horizontal_collision {
395        return false;
396    }
397
398    // Must be on ground or just landed
399    if !state.on_ground() && !collision_result.on_ground {
400        return false;
401    }
402
403    true
404}
405
406/// Attempts to step up over an obstacle.
407///
408/// This implements vanilla's step-up algorithm from `Entity.collide()`.
409///
410/// Vanilla calls `collideWithShapes(Vec3(movement.x, stepHeight, movement.z), groundedAABB, colliders)`
411/// which uses the dynamic axis order. If step-up gives more horizontal progress, use it.
412///
413/// Matches: `Entity.collide()` lines 1077-1095
414#[expect(
415    clippy::float_cmp,
416    reason = "intentional: checking if collision clipped the movement value"
417)]
418fn try_step_up(
419    state: &EntityPhysicsState,
420    movement: DVec3,
421    aabb: &WorldAabb,
422    ground_result: &MoveResult,
423    entity_collisions: &[WorldAabb],
424    world: &(impl CollisionWorld + ?Sized),
425) -> MoveResult {
426    let max_step = f64::from(state.max_up_step());
427    let on_ground_after_collision = ground_result.vertical_collision && movement.y < 0.0;
428    let grounded_aabb = if on_ground_after_collision {
429        aabb.translate(DVec3::ZERO.with_y(ground_result.actual_movement.y))
430    } else {
431        *aabb
432    };
433
434    let mut step_sweep_aabb =
435        grounded_aabb.expand_towards(DVec3::new(movement.x, max_step, movement.z));
436    if !on_ground_after_collision {
437        step_sweep_aabb =
438            step_sweep_aabb.expand_towards(DVec3::new(0.0, -STEP_HEIGHT_COLLISION_EPSILON, 0.0));
439    }
440    let collisions = collect_collisions_with_context(
441        world,
442        &step_sweep_aabb,
443        state.block_collision_context(),
444        entity_collisions,
445    );
446    let candidates = collect_candidate_step_up_heights(
447        &grounded_aabb,
448        &collisions,
449        max_step,
450        ground_result.actual_movement.y,
451    );
452
453    let ground_dist_sq =
454        ground_result.actual_movement.x.powi(2) + ground_result.actual_movement.z.powi(2);
455
456    for candidate in candidates {
457        let step_movement = DVec3::new(movement.x, candidate, movement.z);
458        let (step_from_ground, stepped_aabb) =
459            collide_with_shapes(step_movement, &grounded_aabb, &collisions);
460        let step_dist_sq = step_from_ground.x.powi(2) + step_from_ground.z.powi(2);
461
462        if step_dist_sq <= ground_dist_sq {
463            continue;
464        }
465
466        let distance_to_ground = aabb.min_y() - grounded_aabb.min_y();
467        let actual_movement = step_from_ground - DVec3::new(0.0, distance_to_ground, 0.0);
468        let final_aabb = stepped_aabb.translate(DVec3::ZERO.with_y(-distance_to_ground));
469        let x_collision = horizontal_axis_collided(movement.x, actual_movement.x);
470        let z_collision = horizontal_axis_collided(movement.z, actual_movement.z);
471        let vertical_collision = actual_movement.y != movement.y;
472
473        return MoveResult {
474            final_position: state.position() + actual_movement,
475            actual_movement,
476            on_ground: vertical_collision && movement.y < 0.0,
477            horizontal_collision: x_collision || z_collision,
478            vertical_collision,
479            x_collision,
480            z_collision,
481            final_aabb,
482        };
483    }
484
485    MoveResult {
486        final_position: ground_result.final_position,
487        actual_movement: ground_result.actual_movement,
488        on_ground: ground_result.on_ground,
489        horizontal_collision: ground_result.horizontal_collision,
490        vertical_collision: ground_result.vertical_collision,
491        x_collision: ground_result.x_collision,
492        z_collision: ground_result.z_collision,
493        final_aabb: ground_result.final_aabb,
494    }
495}
496
497#[expect(
498    clippy::float_cmp,
499    reason = "intentional: vanilla candidate filtering uses exact float equality"
500)]
501fn collect_candidate_step_up_heights(
502    grounded_aabb: &WorldAabb,
503    collisions: &[WorldAabb],
504    max_step_height: f64,
505    step_height_to_skip: f64,
506) -> Vec<f64> {
507    let mut candidates = Vec::new();
508
509    for collider in collisions {
510        push_step_height_candidate(
511            &mut candidates,
512            collider.min_y() - grounded_aabb.min_y(),
513            max_step_height,
514            step_height_to_skip,
515        );
516        push_step_height_candidate(
517            &mut candidates,
518            collider.max_y() - grounded_aabb.min_y(),
519            max_step_height,
520            step_height_to_skip,
521        );
522    }
523
524    candidates.sort_by(f64::total_cmp);
525    candidates.dedup_by(|a, b| *a == *b);
526    candidates
527}
528
529#[expect(
530    clippy::float_cmp,
531    reason = "intentional: vanilla candidate filtering uses exact float equality"
532)]
533fn push_step_height_candidate(
534    candidates: &mut Vec<f64>,
535    relative_height: f64,
536    max_step_height: f64,
537    step_height_to_skip: f64,
538) {
539    if relative_height < 0.0
540        || relative_height > max_step_height
541        || relative_height == step_height_to_skip
542    {
543        return;
544    }
545
546    candidates.push(relative_height);
547}
548
549/// Creates an AABB that encompasses the start and end positions of a movement.
550fn sweep_aabb(aabb: &WorldAabb, movement: DVec3) -> WorldAabb {
551    aabb.expand_towards(movement)
552}
553
554#[cfg(test)]
555mod tests {
556    use super::*;
557    use crate::physics::collision::CollisionWorld;
558    use steel_registry::REGISTRY;
559    use steel_registry::vanilla_blocks;
560    use steel_registry::vanilla_entities;
561    use steel_utils::BlockPos;
562
563    /// Mock collision world for testing
564    struct MockWorld {
565        // Block at Y=0 (floor)
566        has_floor: bool,
567    }
568
569    impl CollisionWorld for MockWorld {
570        fn get_block_state(&self, pos: BlockPos) -> steel_utils::BlockStateId {
571            if self.has_floor && pos.y() == 0 {
572                REGISTRY.blocks.get_base_state_id(&vanilla_blocks::STONE)
573            } else {
574                REGISTRY.blocks.get_base_state_id(&vanilla_blocks::AIR)
575            }
576        }
577
578        fn get_block_collisions(&self, aabb: &WorldAabb) -> Vec<WorldAabb> {
579            let mut collisions = Vec::new();
580
581            if self.has_floor && aabb.min_y() <= 1.0 {
582                // Full block at Y=0
583                collisions.push(WorldAabb::new(-10.0, 0.0, -10.0, 10.0, 1.0, 10.0));
584            }
585
586            collisions
587        }
588
589        fn get_pre_move_collisions(
590            &self,
591            _aabb: &WorldAabb,
592            _old_pos: DVec3,
593            _descending: bool,
594        ) -> Vec<WorldAabb> {
595            Vec::new()
596        }
597    }
598
599    struct BoxWorld {
600        boxes: Vec<WorldAabb>,
601    }
602
603    impl CollisionWorld for BoxWorld {
604        fn get_block_state(&self, _pos: BlockPos) -> steel_utils::BlockStateId {
605            REGISTRY.blocks.get_base_state_id(&vanilla_blocks::AIR)
606        }
607
608        fn get_block_collisions(&self, aabb: &WorldAabb) -> Vec<WorldAabb> {
609            self.boxes
610                .iter()
611                .copied()
612                .filter(|collision| collision.intersects(*aabb))
613                .collect()
614        }
615
616        fn get_pre_move_collisions(
617            &self,
618            _aabb: &WorldAabb,
619            _old_pos: DVec3,
620            _descending: bool,
621        ) -> Vec<WorldAabb> {
622            Vec::new()
623        }
624    }
625
626    struct EntityBoxWorld {
627        boxes: Vec<WorldAabb>,
628    }
629
630    impl CollisionWorld for EntityBoxWorld {
631        fn get_block_state(&self, _pos: BlockPos) -> steel_utils::BlockStateId {
632            REGISTRY.blocks.get_base_state_id(&vanilla_blocks::AIR)
633        }
634
635        fn get_block_collisions(&self, _aabb: &WorldAabb) -> Vec<WorldAabb> {
636            Vec::new()
637        }
638
639        fn get_entity_collisions(&self, aabb: &WorldAabb) -> Vec<WorldAabb> {
640            self.boxes
641                .iter()
642                .copied()
643                .filter(|collision| collision.intersects(*aabb))
644                .collect()
645        }
646    }
647
648    struct BorderWorld {
649        boxes: Vec<WorldAabb>,
650    }
651
652    impl CollisionWorld for BorderWorld {
653        fn get_block_state(&self, _pos: BlockPos) -> steel_utils::BlockStateId {
654            REGISTRY.blocks.get_base_state_id(&vanilla_blocks::AIR)
655        }
656
657        fn get_block_collisions(&self, _aabb: &WorldAabb) -> Vec<WorldAabb> {
658            Vec::new()
659        }
660
661        fn get_world_border_collisions(&self, aabb: &WorldAabb) -> Vec<WorldAabb> {
662            self.boxes
663                .iter()
664                .copied()
665                .filter(|collision| collision.intersects(*aabb))
666                .collect()
667        }
668    }
669
670    fn player_state(position: DVec3) -> EntityPhysicsState {
671        EntityPhysicsState::with_dimensions(position, vanilla_entities::PLAYER.dimensions, 0.6)
672    }
673
674    fn item_state(position: DVec3) -> EntityPhysicsState {
675        EntityPhysicsState::with_dimensions(position, vanilla_entities::ITEM.dimensions, 0.6)
676    }
677
678    #[test]
679    fn test_move_entity_free_fall() {
680        let state = player_state(DVec3::new(0.0, 10.0, 0.0));
681
682        let world = MockWorld { has_floor: true };
683        let gravity = DVec3::new(0.0, -0.08, 0.0); // Vanilla gravity per tick
684
685        let result = move_entity(&state, gravity, MoverType::SelfMovement, &world);
686
687        assert!(result.final_position.y < 10.0, "Should fall down");
688        assert!(
689            !result.on_ground,
690            "Should not be on ground yet (only fell 0.08)"
691        );
692    }
693
694    #[test]
695    fn test_move_entity_land_on_ground() {
696        let state = player_state(DVec3::new(0.0, 5.0, 0.0));
697
698        let world = MockWorld { has_floor: true };
699        let large_fall = DVec3::new(0.0, -10.0, 0.0);
700
701        let result = move_entity(&state, large_fall, MoverType::SelfMovement, &world);
702
703        assert!(result.on_ground, "Should be on ground after landing");
704
705        assert!(
706            result.vertical_collision,
707            "Should detect vertical collision"
708        );
709    }
710
711    #[test]
712    fn test_move_entity_no_collision_in_air() {
713        let state = player_state(DVec3::new(0.0, 10.0, 0.0));
714
715        let world = MockWorld { has_floor: false };
716        let movement = DVec3::new(1.0, 0.0, 1.0);
717
718        let result = move_entity(&state, movement, MoverType::SelfMovement, &world);
719
720        assert_eq!(
721            result.actual_movement, movement,
722            "Should move freely in air"
723        );
724        assert!(!result.horizontal_collision, "Should have no collision");
725    }
726
727    #[test]
728    fn test_item_on_ground_with_accumulated_velocity() {
729        // Simulates an item that's on the ground (Y=1.0 on top of floor)
730        // and has accumulated negative velocity from gravity
731        let state = item_state(DVec3::new(0.0, 1.0, 0.0)).with_on_ground(true);
732
733        let world = MockWorld { has_floor: true };
734
735        // Simulate accumulated velocity from 25 ticks of gravity (0.04 per tick)
736        let accumulated_velocity = DVec3::new(0.0, -1.0, 0.0);
737
738        let result = move_entity(
739            &state,
740            accumulated_velocity,
741            MoverType::SelfMovement,
742            &world,
743        );
744
745        // Item should NOT fall through the floor
746        assert!(
747            result.final_position.y >= 0.99,
748            "Item should stay on floor, but Y = {}",
749            result.final_position.y
750        );
751        assert!(result.on_ground, "Item should still be on ground");
752    }
753
754    #[test]
755    fn test_item_slightly_above_ground() {
756        // Simulates an item that's slightly above the ground due to floating point
757        // Floor at Y=1.0, item at Y=1.00001 (just above)
758        let state = item_state(DVec3::new(0.0, 1.00001, 0.0));
759
760        let world = MockWorld { has_floor: true };
761
762        // Small downward velocity
763        let velocity = DVec3::new(0.0, -0.04, 0.0);
764
765        let result = move_entity(&state, velocity, MoverType::SelfMovement, &world);
766
767        // Item should land on the floor, not fall through
768        assert!(
769            result.final_position.y >= 0.99,
770            "Item should land on floor, but Y = {}",
771            result.final_position.y
772        );
773    }
774
775    #[test]
776    fn test_crouching_backs_off_from_edge_incrementally() {
777        let state = player_state(DVec3::new(0.0, 1.0, 0.0))
778            .with_on_ground(true)
779            .with_backs_off_from_edge(true);
780
781        let world = BoxWorld {
782            boxes: vec![WorldAabb::new(-2.0, 0.0, -2.0, 0.5, 1.0, 2.0)],
783        };
784
785        let result = move_entity(&state, DVec3::new(1.0, 0.0, 0.0), MoverType::Player, &world);
786
787        assert!(
788            result.actual_movement.x > 0.0 && result.actual_movement.x < 1.0,
789            "sneak edge should trim movement instead of fully allowing or fully blocking it: {:?}",
790            result.actual_movement
791        );
792        assert!(result.actual_movement.y.abs() < ZERO_MOVEMENT_EPSILON);
793    }
794
795    #[test]
796    fn test_sneak_edge_treats_entity_collision_as_support() {
797        let state = player_state(DVec3::new(0.0, 1.0, 0.0))
798            .with_on_ground(true)
799            .with_backs_off_from_edge(true);
800        let world = EntityBoxWorld {
801            boxes: vec![WorldAabb::new(0.7, 0.4, -0.3, 1.3, 1.0, 0.3)],
802        };
803        let movement = DVec3::new(1.0, 0.0, 0.0);
804
805        let result = move_entity(&state, movement, MoverType::Player, &world);
806
807        assert_eq!(result.actual_movement, movement);
808    }
809
810    #[test]
811    fn test_not_crouching_can_move_off_edge() {
812        let state = player_state(DVec3::new(0.0, 1.0, 0.0)).with_on_ground(true);
813
814        let world = BoxWorld {
815            boxes: vec![WorldAabb::new(-2.0, 0.0, -2.0, 0.5, 1.0, 2.0)],
816        };
817        let movement = DVec3::new(1.0, 0.0, 0.0);
818
819        let result = move_entity(&state, movement, MoverType::Player, &world);
820
821        assert_eq!(result.actual_movement, movement);
822    }
823
824    #[test]
825    fn test_entity_collision_clips_horizontal_movement() {
826        let state = player_state(DVec3::new(0.0, 1.0, 0.0));
827        let world = EntityBoxWorld {
828            boxes: vec![WorldAabb::new(0.7, 1.0, -0.3, 1.7, 2.8, 0.3)],
829        };
830
831        let result = move_entity(
832            &state,
833            DVec3::new(1.0, 0.0, 0.0),
834            MoverType::SelfMovement,
835            &world,
836        );
837
838        assert!(
839            result.actual_movement.x > 0.39 && result.actual_movement.x < 0.41,
840            "entity collision should clip movement at the other entity's box: {:?}",
841            result.actual_movement
842        );
843        assert!(result.horizontal_collision);
844        assert!(result.x_collision);
845    }
846
847    #[test]
848    fn test_world_border_collision_clips_horizontal_movement() {
849        let state = player_state(DVec3::new(0.0, 1.0, 0.0));
850        let world = BorderWorld {
851            boxes: vec![WorldAabb::new(
852                1.0,
853                f64::NEG_INFINITY,
854                f64::NEG_INFINITY,
855                f64::INFINITY,
856                f64::INFINITY,
857                f64::INFINITY,
858            )],
859        };
860
861        let result = move_entity(
862            &state,
863            DVec3::new(2.0, 0.0, 0.0),
864            MoverType::SelfMovement,
865            &world,
866        );
867
868        assert!(
869            result.actual_movement.x > 0.69 && result.actual_movement.x < 0.71,
870            "world border should clip movement at its outside shape: {:?}",
871            result.actual_movement
872        );
873        assert!(result.horizontal_collision);
874        assert!(result.x_collision);
875    }
876
877    #[test]
878    fn test_step_up_uses_obstacle_candidate_height() {
879        let state = player_state(DVec3::new(0.0, 1.0, 0.0)).with_on_ground(true);
880
881        let world = BoxWorld {
882            boxes: vec![
883                WorldAabb::new(-10.0, 0.0, -10.0, 10.0, 1.0, 10.0),
884                WorldAabb::new(0.5, 1.0, -1.0, 1.5, 1.5, 1.0),
885            ],
886        };
887
888        let result = move_entity(
889            &state,
890            DVec3::new(1.0, 0.0, 0.0),
891            MoverType::SelfMovement,
892            &world,
893        );
894
895        assert!(
896            result.actual_movement.x > 0.9,
897            "step-up should preserve horizontal movement: {:?}",
898            result.actual_movement
899        );
900        assert!((result.actual_movement.y - 0.5).abs() < ZERO_MOVEMENT_EPSILON);
901    }
902
903    #[test]
904    fn test_step_up_rejects_obstacle_above_max_step() {
905        let state = player_state(DVec3::new(0.0, 1.0, 0.0)).with_on_ground(true);
906
907        let world = BoxWorld {
908            boxes: vec![
909                WorldAabb::new(-10.0, 0.0, -10.0, 10.0, 1.0, 10.0),
910                WorldAabb::new(0.5, 1.0, -1.0, 1.5, 2.0, 1.0),
911            ],
912        };
913
914        let result = move_entity(
915            &state,
916            DVec3::new(1.0, 0.0, 0.0),
917            MoverType::SelfMovement,
918            &world,
919        );
920
921        assert!(
922            result.actual_movement.x < 0.3,
923            "movement should stay clipped by the tall obstacle: {:?}",
924            result.actual_movement
925        );
926        assert!(result.actual_movement.y.abs() < ZERO_MOVEMENT_EPSILON);
927        assert!(result.horizontal_collision);
928    }
929
930    #[test]
931    fn horizontal_collision_uses_vanilla_mth_equal_tolerance() {
932        assert!(!horizontal_axis_collided(1.0, 1.0));
933        assert!(!horizontal_axis_collided(1.0, 1.0 - 0.5e-5));
934        assert!(horizontal_axis_collided(1.0, 1.0 - 1.1e-5));
935        assert!(horizontal_axis_collided(1.0, 1.0 - 2.0e-5));
936    }
937}