1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum MoverType {
29 SelfMovement,
31 Player,
33 Piston,
35 ShulkerBox,
37 Shulker,
39}
40
41#[derive(Debug, Clone)]
43pub struct MoveResult {
44 pub final_position: DVec3,
46
47 pub actual_movement: DVec3,
49
50 pub on_ground: bool,
52
53 pub horizontal_collision: bool,
55
56 pub vertical_collision: bool,
58
59 pub x_collision: bool,
61
62 pub z_collision: bool,
64
65 pub final_aabb: WorldAabb,
67}
68
69pub(crate) fn move_entity(
86 state: &EntityPhysicsState,
87 delta: DVec3,
88 mover_type: MoverType,
89 world: &(impl CollisionWorld + ?Sized),
90) -> MoveResult {
91 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 let aabb = state.bounding_box();
112
113 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 let collision_result = collide_with_world(state, movement, &aabb, world, &entity_collisions);
127
128 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
143fn 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
242fn 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#[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 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 let on_ground = resolved.y != movement.y && movement.y < 0.0;
287
288 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
306fn collide_with_shapes(
308 movement: DVec3,
309 aabb: &WorldAabb,
310 collisions: &[WorldAabb],
311) -> (DVec3, WorldAabb) {
312 let axes = axis_step_order(movement);
314
315 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, ¤t_aabb, collisions, axis_movement);
328
329 match axis {
331 Axis::X => resolved.x = collision,
332 Axis::Y => resolved.y = collision,
333 Axis::Z => resolved.z = collision,
334 }
335
336 current_aabb = move_aabb(¤t_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
363fn 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 (actual - requested).abs() >= MTH_EQUAL_EPSILON
375}
376
377fn should_try_step_up(
379 state: &EntityPhysicsState,
380 collision_result: &MoveResult,
381 mover_type: MoverType,
382) -> bool {
383 if !matches!(mover_type, MoverType::SelfMovement | MoverType::Player) {
385 return false;
386 }
387
388 if state.max_up_step() <= 0.0 {
390 return false;
391 }
392
393 if !collision_result.horizontal_collision {
395 return false;
396 }
397
398 if !state.on_ground() && !collision_result.on_ground {
400 return false;
401 }
402
403 true
404}
405
406#[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
549fn 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 struct MockWorld {
565 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 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); 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 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 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 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 let state = item_state(DVec3::new(0.0, 1.00001, 0.0));
759
760 let world = MockWorld { has_floor: true };
761
762 let velocity = DVec3::new(0.0, -0.04, 0.0);
764
765 let result = move_entity(&state, velocity, MoverType::SelfMovement, &world);
766
767 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}