use super::aabb::{Aabb, Hit, sweep_aabb};
use super::collider_set::ColliderSet;
use crate::math::Vec2;
const MAX_SLIDE_PASSES: u32 = 2;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CollisionConfig {
pub up: Vec2,
pub skin: f32,
pub snap_distance: f32,
pub normal_threshold: f32,
pub block_epsilon: f32,
}
impl CollisionConfig {
#[must_use]
pub fn new(
up: Vec2,
skin: f32,
snap_distance: f32,
normal_threshold: f32,
block_epsilon: f32,
) -> Self {
assert!(
up.is_finite() && up.length_squared() > 0.0,
"CollisionConfig: up must be a finite non-zero direction, got {up}"
);
assert!(
normal_threshold > 0.0 && normal_threshold < 1.0,
"CollisionConfig: normal threshold must be in (0, 1), got {normal_threshold}"
);
Self {
up: up.normalize(),
skin,
snap_distance,
normal_threshold,
block_epsilon,
}
}
#[must_use]
pub fn is_floor(&self, normal: Vec2) -> bool {
normal.dot(self.up) > self.normal_threshold
}
#[must_use]
pub fn is_ceiling(&self, normal: Vec2) -> bool {
normal.dot(self.up) < -self.normal_threshold
}
}
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct Contacts {
pub floor: bool,
pub ceiling: bool,
pub wall: bool,
pub last_floor_normal: Vec2,
pub last_ceiling_normal: Vec2,
pub desired: Vec2,
pub effective: Vec2,
pub down_blocked: bool,
pub up_blocked: bool,
}
#[must_use]
pub fn move_and_collide(
body: Aabb,
delta: Vec2,
set: &ColliderSet,
config: &CollisionConfig,
) -> (Vec2, Contacts) {
let mut contacts = Contacts {
desired: delta,
..Contacts::default()
};
if !delta.is_finite() {
return (Vec2::ZERO, contacts);
}
let mut body = body;
let mut remaining = delta;
let mut total = Vec2::ZERO;
for _ in 0..MAX_SLIDE_PASSES {
if remaining.abs_diff_eq(Vec2::ZERO, f32::EPSILON) {
break;
}
let Some(hit) = earliest_hit(&body, remaining, set, config, |_| true) else {
total += remaining;
break;
};
let move_to = remaining * hit.t;
let safe = back_off(move_to, hit.normal, config.skin);
total += safe;
body = body.translated(safe);
classify(&mut contacts, hit.normal, config);
let leftover = remaining - move_to;
remaining = leftover - hit.normal * leftover.dot(hit.normal);
}
contacts.effective = total;
let desired_up = delta.dot(config.up);
let effective_up = total.dot(config.up);
contacts.down_blocked = desired_up < 0.0 && effective_up > desired_up + config.block_epsilon;
contacts.up_blocked = desired_up > 0.0 && effective_up < desired_up - config.block_epsilon;
(total, contacts)
}
#[must_use]
pub fn snap_to_ground(body: Aabb, set: &ColliderSet, config: &CollisionConfig) -> (Vec2, Contacts) {
let probe = -config.up * config.snap_distance;
let mut contacts = Contacts {
desired: probe,
..Contacts::default()
};
let Some(hit) = earliest_hit(&body, probe, set, config, |normal| config.is_floor(normal))
else {
return (Vec2::ZERO, contacts);
};
let safe = back_off(probe * hit.t, hit.normal, config.skin);
contacts.effective = safe;
classify(&mut contacts, hit.normal, config);
(safe, contacts)
}
fn earliest_hit(
body: &Aabb,
delta: Vec2,
set: &ColliderSet,
config: &CollisionConfig,
accept: impl Fn(Vec2) -> bool,
) -> Option<Hit> {
let mut best: Option<Hit> = None;
for index in set.query(&swept_region(body, delta)) {
let Some(collider) = set.get(index) else {
continue;
};
if collider.flags.sensor || !collider.flags.solid {
continue;
}
let Some(hit) = sweep_aabb(body, delta, &collider.aabb) else {
continue;
};
if !accept(hit.normal) {
continue;
}
if collider.flags.one_way && !one_way_blocks(hit.normal, delta, config) {
continue;
}
if best.is_none_or(|previous| hit.t < previous.t) {
best = Some(hit);
}
}
best
}
fn one_way_blocks(normal: Vec2, motion: Vec2, config: &CollisionConfig) -> bool {
config.is_floor(normal) && motion.dot(config.up) < 0.0
}
fn classify(contacts: &mut Contacts, normal: Vec2, config: &CollisionConfig) {
if config.is_floor(normal) {
contacts.floor = true;
contacts.last_floor_normal = normal;
} else if config.is_ceiling(normal) {
contacts.ceiling = true;
contacts.last_ceiling_normal = normal;
} else {
contacts.wall = true;
}
}
fn back_off(move_to: Vec2, normal: Vec2, skin: f32) -> Vec2 {
if normal.x.abs() > normal.y.abs() {
Vec2::new(reduce_toward_zero(move_to.x, skin), move_to.y)
} else {
Vec2::new(move_to.x, reduce_toward_zero(move_to.y, skin))
}
}
fn reduce_toward_zero(value: f32, amount: f32) -> f32 {
if value >= 0.0 {
(value - amount).max(0.0)
} else {
(value + amount).min(0.0)
}
}
fn swept_region(body: &Aabb, delta: Vec2) -> Aabb {
let start_min = body.min();
let start_max = body.max();
Aabb::from_min_max(
start_min.min(start_min + delta),
start_max.max(start_max + delta),
)
}
#[cfg(test)]
mod tests {
use super::super::collider_set::ColliderFlags;
use super::*;
use wasm_bindgen_test::wasm_bindgen_test;
#[wasm_bindgen_test(unsupported = test)]
fn contacts_record_the_request_and_classification_is_strict() {
let config = CollisionConfig::new(Vec2::Y, 0.01, 0.1, 0.7, 1e-4);
let set = ColliderSet::new(1.0);
let body = Aabb::new(Vec2::ZERO, Vec2::splat(0.5));
let (_, contacts) = move_and_collide(body, Vec2::new(1.5, -2.0), &set, &config);
assert_eq!(contacts.desired, Vec2::new(1.5, -2.0));
let (_, contacts) = snap_to_ground(body, &set, &config);
assert_eq!(contacts.desired, Vec2::new(0.0, -0.1));
assert!(
!config.is_floor(Vec2::new(0.3, 0.7)),
"exactly at the threshold"
);
assert!(config.is_floor(Vec2::new(0.0, 0.7001)));
assert!(
!config.is_ceiling(Vec2::new(0.3, -0.7)),
"exactly at the threshold"
);
assert!(config.is_ceiling(Vec2::new(0.0, -0.7001)));
}
#[wasm_bindgen_test(unsupported = test)]
fn blocked_flags_need_a_shortfall_beyond_the_epsilon() {
let config = CollisionConfig::new(Vec2::Y, 0.0, 0.1, 0.7, 0.0);
let mut set = ColliderSet::new(1.0);
set.insert(
Aabb::new(Vec2::new(0.0, -3.0), Vec2::splat(1.0)),
ColliderFlags::SOLID,
);
set.insert(
Aabb::new(Vec2::new(0.0, 3.0), Vec2::splat(1.0)),
ColliderFlags::SOLID,
);
let body = Aabb::new(Vec2::ZERO, Vec2::splat(1.0));
let (moved, contacts) = move_and_collide(body, Vec2::new(0.0, -1.0), &set, &config);
assert_eq!(moved, Vec2::new(0.0, -1.0));
assert!(contacts.floor);
assert!(!contacts.down_blocked, "landed exactly where asked");
let (_, contacts) = move_and_collide(body, Vec2::new(0.0, -5.0), &set, &config);
assert!(contacts.down_blocked);
let (moved, contacts) = move_and_collide(body, Vec2::new(0.0, 1.0), &set, &config);
assert_eq!(moved, Vec2::new(0.0, 1.0));
assert!(contacts.ceiling);
assert!(!contacts.up_blocked, "rose exactly as far as asked");
let (_, contacts) = move_and_collide(body, Vec2::new(0.0, 5.0), &set, &config);
assert!(contacts.up_blocked);
}
const SKIN: f32 = 0.01;
const SNAP: f32 = 2.0 / 32.0;
fn aabb(cx: f32, cy: f32, hx: f32, hy: f32) -> Aabb {
Aabb::new(Vec2::new(cx, cy), Vec2::new(hx, hy))
}
fn config(up: Vec2) -> CollisionConfig {
CollisionConfig::new(up, SKIN, SNAP, 0.7, 0.0001)
}
fn set_with(colliders: &[(Aabb, ColliderFlags)]) -> ColliderSet {
let mut set = ColliderSet::new(1.0);
for &(aabb, flags) in colliders {
set.insert(aabb, flags);
}
set
}
#[wasm_bindgen_test(unsupported = test)]
fn landing_rests_one_skin_above_the_floor() {
let set = set_with(&[(aabb(0.0, 0.0, 5.0, 0.5), ColliderFlags::SOLID)]);
let body = aabb(0.0, 2.0, 0.5, 0.5);
let (effective, contacts) =
move_and_collide(body, Vec2::new(0.0, -3.0), &set, &config(Vec2::Y));
let rest = 2.0 + effective.y;
assert!((rest - (1.0 + SKIN)).abs() < 1e-4, "rested at {rest}");
assert!(contacts.floor && contacts.down_blocked);
assert!(!contacts.ceiling && !contacts.wall);
assert_eq!(contacts.last_floor_normal, Vec2::Y);
}
#[wasm_bindgen_test(unsupported = test)]
fn head_bump_stops_below_the_ceiling() {
let set = set_with(&[(aabb(0.0, 0.0, 5.0, 0.5), ColliderFlags::SOLID)]);
let body = aabb(0.0, -2.0, 0.5, 0.5);
let (effective, contacts) =
move_and_collide(body, Vec2::new(0.0, 3.0), &set, &config(Vec2::Y));
let rest = -2.0 + effective.y;
assert!((rest - (-1.0 - SKIN)).abs() < 1e-4, "rested at {rest}");
assert!(contacts.ceiling && contacts.up_blocked);
assert!(!contacts.floor);
assert_eq!(contacts.last_ceiling_normal, Vec2::NEG_Y);
}
#[wasm_bindgen_test(unsupported = test)]
fn wall_blocks_one_axis_and_the_slide_keeps_the_other() {
let set = set_with(&[(aabb(2.0, 0.0, 0.5, 5.0), ColliderFlags::SOLID)]);
let body = aabb(0.0, 0.0, 0.5, 0.5);
let (effective, contacts) =
move_and_collide(body, Vec2::new(3.0, 1.0), &set, &config(Vec2::Y));
assert!((effective.x - 0.99).abs() < 1e-4, "x was {}", effective.x);
assert!((effective.y - 1.0).abs() < 1e-4, "y was {}", effective.y);
assert!(contacts.wall);
assert!(!contacts.floor && !contacts.ceiling);
}
#[wasm_bindgen_test(unsupported = test)]
fn landing_while_running_keeps_the_horizontal_travel() {
let set = set_with(&[(aabb(0.0, 0.0, 10.0, 0.5), ColliderFlags::SOLID)]);
let body = aabb(0.0, 2.0, 0.5, 0.5);
let (effective, contacts) =
move_and_collide(body, Vec2::new(2.0, -3.0), &set, &config(Vec2::Y));
assert!(contacts.floor);
assert!((effective.x - 2.0).abs() < 1e-4, "x was {}", effective.x);
}
#[wasm_bindgen_test(unsupported = test)]
fn the_earliest_contact_wins_over_query_order() {
let set = set_with(&[
(aabb(0.0, -4.0, 5.0, 0.5), ColliderFlags::SOLID),
(aabb(0.0, 0.0, 5.0, 0.5), ColliderFlags::SOLID),
]);
let body = aabb(0.0, 2.0, 0.5, 0.5);
let (effective, contacts) =
move_and_collide(body, Vec2::new(0.0, -6.0), &set, &config(Vec2::Y));
let rest = 2.0 + effective.y;
assert!((rest - (1.0 + SKIN)).abs() < 1e-4, "rested at {rest}");
assert!(contacts.floor);
}
#[wasm_bindgen_test(unsupported = test)]
fn contact_exactly_at_the_end_of_the_delta_is_reported() {
let set = set_with(&[(aabb(0.0, 0.0, 5.0, 0.5), ColliderFlags::SOLID)]);
let body = aabb(0.0, 2.0, 0.5, 0.5);
let (effective, contacts) =
move_and_collide(body, Vec2::new(0.0, -1.0), &set, &config(Vec2::Y));
assert!(contacts.floor && contacts.down_blocked);
assert!(
(effective.y - (-1.0 + SKIN)).abs() < 1e-4,
"y was {}",
effective.y
);
}
#[wasm_bindgen_test(unsupported = test)]
fn the_skin_back_off_never_reverses_the_move() {
let set = set_with(&[(aabb(0.0, 0.0, 0.5, 0.5), ColliderFlags::SOLID)]);
let body = aabb(-1.003, 0.0, 0.5, 0.5);
let (effective, contacts) =
move_and_collide(body, Vec2::new(0.006, 0.0), &set, &config(Vec2::Y));
assert!(contacts.wall);
assert!(effective.x >= 0.0, "moved backwards: {}", effective.x);
assert!(effective.x <= 0.003, "moved past contact: {}", effective.x);
}
#[wasm_bindgen_test(unsupported = test)]
fn sensors_and_non_solid_colliders_do_not_block() {
let set = set_with(&[
(aabb(0.0, 0.0, 5.0, 0.5), ColliderFlags::SENSOR),
(
aabb(0.0, -1.5, 5.0, 0.5),
ColliderFlags {
solid: false,
one_way: false,
sensor: false,
},
),
]);
let body = aabb(0.0, 2.0, 0.5, 0.5);
let (effective, contacts) =
move_and_collide(body, Vec2::new(0.0, -3.0), &set, &config(Vec2::Y));
assert!((effective.y + 3.0).abs() < 1e-6, "y was {}", effective.y);
assert!(!contacts.floor && !contacts.wall && !contacts.down_blocked);
}
#[wasm_bindgen_test(unsupported = test)]
fn one_way_blocks_only_a_landing_from_above() {
let set = set_with(&[(aabb(0.0, 0.0, 5.0, 0.25), ColliderFlags::ONE_WAY)]);
let rising = aabb(0.0, -2.0, 0.5, 0.5);
let (effective, contacts) =
move_and_collide(rising, Vec2::new(0.0, 4.0), &set, &config(Vec2::Y));
assert!(!contacts.ceiling && !contacts.up_blocked);
assert!((effective.y - 4.0).abs() < 1e-6, "y was {}", effective.y);
let falling = aabb(0.0, 2.0, 0.5, 0.5);
let (_, contacts) = move_and_collide(falling, Vec2::new(0.0, -3.0), &set, &config(Vec2::Y));
assert!(contacts.floor && contacts.down_blocked);
}
#[wasm_bindgen_test(unsupported = test)]
fn non_finite_delta_moves_nothing() {
let set = set_with(&[(aabb(0.0, 0.0, 5.0, 0.5), ColliderFlags::SOLID)]);
let body = aabb(0.0, 2.0, 0.5, 0.5);
for delta in [Vec2::new(f32::NAN, -1.0), Vec2::new(0.0, f32::NEG_INFINITY)] {
let (effective, contacts) = move_and_collide(body, delta, &set, &config(Vec2::Y));
assert_eq!(effective, Vec2::ZERO);
assert!(!contacts.floor && !contacts.down_blocked);
}
}
#[wasm_bindgen_test(unsupported = test)]
fn snap_reaches_exactly_as_far_as_it_is_configured_to() {
let set = set_with(&[(aabb(0.0, 0.0, 5.0, 0.5), ColliderFlags::SOLID)]);
let body = aabb(0.0, 1.0 + 1.0 / 32.0, 0.5, 0.5);
let (snap, contacts) = snap_to_ground(body, &set, &config(Vec2::Y));
assert!(contacts.floor, "a 2 px probe must bridge a 1 px gap");
assert!(snap.y < 0.0, "snap pulls the body toward the floor");
assert_eq!(contacts.last_floor_normal, Vec2::Y);
let short = CollisionConfig::new(Vec2::Y, SKIN, 0.5 / 32.0, 0.7, 0.0001);
let (snap, contacts) = snap_to_ground(body, &set, &short);
assert!(!contacts.floor, "a half-pixel probe must not reach");
assert_eq!(snap, Vec2::ZERO);
}
#[wasm_bindgen_test(unsupported = test)]
fn snap_grounds_on_a_one_way_top_but_not_from_under_it() {
let set = set_with(&[(aabb(0.0, 0.0, 5.0, 0.25), ColliderFlags::ONE_WAY)]);
let above = aabb(0.0, 0.75 + 1.0 / 32.0, 0.5, 0.5);
let (snap, contacts) = snap_to_ground(above, &set, &config(Vec2::Y));
assert!(contacts.floor, "a one-way top still grounds a landing");
assert!(snap.y < 0.0);
let below = aabb(0.0, -1.0, 0.5, 0.5);
let (snap, contacts) = snap_to_ground(below, &set, &config(Vec2::Y));
assert!(!contacts.floor);
assert_eq!(snap, Vec2::ZERO);
}
#[wasm_bindgen_test(unsupported = test)]
fn y_down_worlds_classify_floors_and_ceilings_the_same() {
let config = config(Vec2::NEG_Y);
let set = set_with(&[(aabb(0.0, 0.0, 5.0, 0.5), ColliderFlags::SOLID)]);
let above = aabb(0.0, -2.0, 0.5, 0.5);
let (effective, contacts) = move_and_collide(above, Vec2::new(0.0, 3.0), &set, &config);
let rest = -2.0 + effective.y;
assert!((rest - (-1.0 - SKIN)).abs() < 1e-4, "rested at {rest}");
assert!(contacts.floor && contacts.down_blocked);
assert!(!contacts.ceiling && !contacts.up_blocked);
assert_eq!(contacts.last_floor_normal, Vec2::NEG_Y);
let below = aabb(0.0, 2.0, 0.5, 0.5);
let (_, contacts) = move_and_collide(below, Vec2::new(0.0, -3.0), &set, &config);
assert!(contacts.ceiling && contacts.up_blocked);
assert!(!contacts.floor && !contacts.down_blocked);
assert_eq!(contacts.last_ceiling_normal, Vec2::Y);
}
#[wasm_bindgen_test(unsupported = test)]
fn y_down_worlds_mirror_one_way_and_snapping() {
let config = config(Vec2::NEG_Y);
let set = set_with(&[(aabb(0.0, 0.0, 5.0, 0.25), ColliderFlags::ONE_WAY)]);
let rising = aabb(0.0, 2.0, 0.5, 0.5);
let (effective, contacts) = move_and_collide(rising, Vec2::new(0.0, -4.0), &set, &config);
assert!(!contacts.ceiling);
assert!((effective.y + 4.0).abs() < 1e-6, "y was {}", effective.y);
let falling = aabb(0.0, -2.0, 0.5, 0.5);
let (_, contacts) = move_and_collide(falling, Vec2::new(0.0, 3.0), &set, &config);
assert!(contacts.floor && contacts.down_blocked);
let hovering = aabb(0.0, -0.75 - 1.0 / 32.0, 0.5, 0.5);
let (snap, contacts) = snap_to_ground(hovering, &set, &config);
assert!(contacts.floor, "the probe must run along -up, toward +y");
assert!(snap.y > 0.0, "snap pulls the body toward the platform");
}
}