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: &dyn CollisionWorld,
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: &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
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: &dyn CollisionWorld,
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: &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
357fn 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 (actual - requested).abs() >= MTH_EQUAL_EPSILON
369}
370
371fn should_try_step_up(
373 state: &EntityPhysicsState,
374 collision_result: &MoveResult,
375 mover_type: MoverType,
376) -> bool {
377 if !matches!(mover_type, MoverType::SelfMovement | MoverType::Player) {
379 return false;
380 }
381
382 if state.max_up_step() <= 0.0 {
384 return false;
385 }
386
387 if !collision_result.horizontal_collision {
389 return false;
390 }
391
392 if !state.on_ground() && !collision_result.on_ground {
394 return false;
395 }
396
397 true
398}
399
400#[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
543fn 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 struct MockWorld {
559 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 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); 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 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 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 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 let state = item_state(DVec3::new(0.0, 1.00001, 0.0));
753
754 let world = MockWorld { has_floor: true };
755
756 let velocity = DVec3::new(0.0, -0.04, 0.0);
758
759 let result = move_entity(&state, velocity, MoverType::SelfMovement, &world);
760
761 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}