#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Hit {
Rect {
x: f32,
y: f32,
w: f32,
h: f32,
},
Circle {
x: f32,
y: f32,
r: f32,
},
}
impl Hit {
pub fn rect(x: f32, y: f32, w: f32, h: f32) -> Hit {
Hit::Rect {
x,
y,
w: size(w),
h: size(h),
}
}
pub fn circle(x: f32, y: f32, r: f32) -> Hit {
Hit::Circle { x, y, r: size(r) }
}
pub fn moved(&self, dx: f32, dy: f32) -> Hit {
match *self {
Hit::Rect { x, y, w, h } => Hit::Rect {
x: x + dx,
y: y + dy,
w,
h,
},
Hit::Circle { x, y, r } => Hit::Circle {
x: x + dx,
y: y + dy,
r,
},
}
}
pub fn contains(&self, x: f32, y: f32) -> bool {
match *self {
Hit::Rect { x: rx, y: ry, w, h } => {
x >= rx && x < rx + size(w) && y >= ry && y < ry + size(h)
}
Hit::Circle { x: cx, y: cy, r } => {
let r = size(r);
let (dx, dy) = (x - cx, y - cy);
dx * dx + dy * dy < r * r
}
}
}
pub fn overlaps(&self, other: &Hit) -> bool {
match (*self, *other) {
(
Hit::Rect {
x: ax,
y: ay,
w: aw,
h: ah,
},
Hit::Rect {
x: bx,
y: by,
w: bw,
h: bh,
},
) => {
let a_has_area = size(aw) > 0.0 && size(ah) > 0.0;
let b_has_area = size(bw) > 0.0 && size(bh) > 0.0;
a_has_area
&& b_has_area
&& ax < bx + size(bw)
&& bx < ax + size(aw)
&& ay < by + size(bh)
&& by < ay + size(ah)
}
(
Hit::Circle {
x: ax,
y: ay,
r: ar,
},
Hit::Circle {
x: bx,
y: by,
r: br,
},
) => {
let (ar, br) = (size(ar), size(br));
let (dx, dy) = (ax - bx, ay - by);
let reach = ar + br;
ar > 0.0 && br > 0.0 && dx * dx + dy * dy < reach * reach
}
(Hit::Rect { .. }, Hit::Circle { .. }) => rect_circle(self, other),
(Hit::Circle { .. }, Hit::Rect { .. }) => rect_circle(other, self),
}
}
}
fn rect_circle(rect: &Hit, circle: &Hit) -> bool {
let (Hit::Rect { x, y, w, h }, Hit::Circle { x: cx, y: cy, r }) = (*rect, *circle) else {
return false;
};
let (w, h, r) = (size(w), size(h), size(r));
if !(w > 0.0 && h > 0.0 && r > 0.0) {
return false;
}
let nearest_x = clamp_to(cx, x, x + w);
let nearest_y = clamp_to(cy, y, y + h);
let (dx, dy) = (cx - nearest_x, cy - nearest_y);
dx * dx + dy * dy < r * r
}
fn clamp_to(value: f32, lo: f32, hi: f32) -> f32 {
if value < lo {
lo
} else if value > hi {
hi
} else {
value
}
}
fn size(value: f32) -> f32 {
if value > 0.0 {
value
} else {
0.0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn overlapping_rects_overlap() {
let a = Hit::rect(0.0, 0.0, 10.0, 10.0);
let b = Hit::rect(5.0, 5.0, 10.0, 10.0);
assert!(a.overlaps(&b));
assert!(!Hit::rect(0.0, 0.0, 2.0, 2.0).overlaps(&Hit::rect(5.0, 5.0, 2.0, 2.0)));
}
#[test]
fn circles_overlap_when_they_are_closer_than_the_sum_of_radii() {
let a = Hit::circle(0.0, 0.0, 5.0);
assert!(a.overlaps(&Hit::circle(6.0, 0.0, 2.0)));
assert!(!a.overlaps(&Hit::circle(8.0, 0.0, 2.0)), "apart");
assert!(!a.overlaps(&Hit::circle(7.0, 0.0, 2.0)), "touching");
}
#[test]
fn a_circle_overlaps_a_rect_by_its_nearest_point_not_its_centre() {
let strip = Hit::rect(0.0, 0.0, 100.0, 1.0);
let ball = Hit::circle(50.0, 3.0, 2.5);
assert!(strip.overlaps(&ball));
assert!(ball.overlaps(&strip));
assert!(
!strip.overlaps(&Hit::circle(50.0, 3.5, 2.5)),
"too far from the edge"
);
}
#[test]
fn a_shape_inside_another_overlaps_it() {
let big = Hit::rect(0.0, 0.0, 10.0, 10.0);
assert!(big.overlaps(&Hit::rect(2.0, 2.0, 1.0, 1.0)), "rect in rect");
assert!(big.overlaps(&Hit::circle(5.0, 5.0, 1.0)), "circle in rect");
assert!(
Hit::circle(5.0, 5.0, 20.0).overlaps(&Hit::rect(2.0, 2.0, 1.0, 1.0)),
"rect in circle"
);
assert!(
Hit::circle(0.0, 0.0, 10.0).overlaps(&Hit::circle(0.0, 0.0, 1.0)),
"circle in circle"
);
}
#[test]
fn a_circle_touching_a_rect_edge_does_not_overlap_it() {
let rect = Hit::rect(0.0, 0.0, 10.0, 10.0);
assert!(!rect.overlaps(&Hit::circle(12.0, 5.0, 2.0)));
assert!(!Hit::circle(12.0, 5.0, 2.0).overlaps(&rect));
}
#[test]
fn contains_counts_the_left_and_top_edge_of_a_rect_but_not_the_right_and_bottom() {
let rect = Hit::rect(2.0, 3.0, 4.0, 2.0);
assert!(rect.contains(2.0, 3.0));
assert!(rect.contains(5.5, 4.5));
assert!(
!rect.contains(6.0, 4.0),
"right edge belongs to the next cell"
);
assert!(
!rect.contains(3.0, 5.0),
"bottom edge belongs to the next row"
);
assert!(!rect.contains(1.9, 4.0));
}
#[test]
fn contains_is_strictly_inside_a_circle() {
let ball = Hit::circle(0.0, 0.0, 2.0);
assert!(ball.contains(0.0, 0.0));
assert!(ball.contains(1.0, 1.0));
assert!(!ball.contains(2.0, 0.0), "on the edge is outside");
assert!(!ball.contains(f32::NAN, 0.0));
}
#[test]
fn moved_shifts_the_shape_and_keeps_its_size() {
assert_eq!(
Hit::rect(1.0, 2.0, 3.0, 4.0).moved(10.0, -1.0),
Hit::rect(11.0, 1.0, 3.0, 4.0)
);
assert_eq!(
Hit::circle(0.0, 0.0, 2.0).moved(1.5, 2.5),
Hit::circle(1.5, 2.5, 2.0)
);
}
#[test]
fn negative_sizes_count_as_empty_and_nan_never_overlaps() {
let wall = Hit::rect(0.0, 0.0, 10.0, 10.0);
assert!(!wall.overlaps(&Hit::rect(1.0, 1.0, -5.0, 2.0)));
assert!(!wall.overlaps(&Hit::circle(5.0, 5.0, -1.0)));
assert!(!Hit::circle(5.0, 5.0, -1.0).contains(5.0, 5.0));
assert!(!wall.contains(f32::NAN, 1.0));
assert!(!wall.overlaps(&Hit::rect(f32::NAN, 1.0, 2.0, 2.0)));
assert!(!wall.overlaps(&Hit::circle(f32::NAN, 5.0, 2.0)));
assert!(!Hit::circle(f32::NAN, 0.0, 1.0).overlaps(&Hit::circle(0.0, 0.0, 1.0)));
let raw = Hit::Rect {
x: 0.0,
y: 0.0,
w: -4.0,
h: 4.0,
};
assert!(!raw.overlaps(&wall));
assert!(!raw.contains(-1.0, 1.0));
}
fn random_shape(rng: &mut crate::game::Rng) -> Hit {
let (x, y) = (rng.range_f32(-20.0, 20.0), rng.range_f32(-20.0, 20.0));
let size = rng.range_f32(-2.0, 12.0);
if rng.chance(0.5) {
Hit::rect(x, y, size, rng.range_f32(-2.0, 12.0))
} else {
Hit::circle(x, y, size)
}
}
#[test]
fn overlap_is_symmetric_over_many_random_pairs() {
let mut rng = crate::game::Rng::new(2026);
let mut overlapping = 0;
for _ in 0..5_000 {
let a = random_shape(&mut rng);
let b = random_shape(&mut rng);
assert_eq!(a.overlaps(&b), b.overlaps(&a), "{a:?} vs {b:?}");
if a.overlaps(&b) {
overlapping += 1;
}
}
assert!(
(200..4_800).contains(&overlapping),
"overlapping pairs: {overlapping}"
);
}
#[test]
fn rects_that_only_touch_at_an_edge_do_not_overlap() {
let a = Hit::rect(0.0, 0.0, 10.0, 10.0);
assert!(!a.overlaps(&Hit::rect(10.0, 0.0, 5.0, 5.0)), "right edge");
assert!(!a.overlaps(&Hit::rect(0.0, 10.0, 5.0, 5.0)), "bottom edge");
assert!(!a.overlaps(&Hit::rect(10.0, 10.0, 5.0, 5.0)), "corner");
}
}