fix(physics): restore fixed-point circle responses

Replace the overlap-and-separate circle approximation with the original swept
ball-center entry test and radial/tangent fixed-point response. Preserve every
type-1 record's Real48 rebound and tangent coefficients, plus the bumper normal
kick derived from the 3800-unit speed scalar.

Unify line and circle traversal in original object-id order and remove the old
floating passive-circle and bumper impulses. Add exact tests for live object 155
and bumper 51 transitions, and keep moving flippers as the explicit remaining
collision approximation.

Test Plan:
- `cargo test --all-targets` -- 44 passed
- `cargo clippy --all-targets -- -D warnings` -- passed
- `cargo build --profile production` -- passed
- 20-second deterministic launch traversed bumpers and drained without rescue
- live object 155 and bumper 51 fixed-point transitions matched exactly
- `git diff --cached --check` -- passed
This commit is contained in:
2026-08-22 20:52:58 +02:00
parent 5c2a80cfa5
commit c886a93788
6 changed files with 226 additions and 116 deletions
+73 -71
View File
@@ -1,8 +1,8 @@
use crate::{
geometry::{Segment, circle_collision, closest_point, segment_collision},
geometry::{Segment, closest_point, segment_collision},
original_physics::{
GRAVITY_MILLI_PER_STEP, MAXIMUM_SPEED_MILLI_PER_STEP, MilliVec, STEP_SECONDS,
collide_with_line,
collide_with_circle, collide_with_line,
},
table::{BUMPERS, PASSIVE_CIRCLES, WALLS},
};
@@ -406,28 +406,57 @@ impl Game {
velocity.clamp_speed(MAXIMUM_SPEED_MILLI_PER_STEP);
let mut position = old_position.add(velocity);
let mut hit_wall = None;
for wall in WALLS {
if self.claw.active && (12..=20).contains(&wall.id) {
let mut hit_circle = None;
for object_id in 1..=175 {
if self.claw.active && (12..=20).contains(&object_id) {
continue;
}
// The original moves these four records with the flipper bodies.
if matches!(wall.id, 66 | 68 | 81 | 83) {
continue;
if let Some(wall) = WALLS.iter().find(|wall| wall.id == object_id) {
// The original moves these four records with the flipper bodies.
if matches!(wall.id, 66 | 68 | 81 | 83) {
continue;
}
let mut response = velocity;
if collide_with_line(
old_position,
&mut response,
wall.segment.start,
wall.segment.end,
f64::from(wall.normal_rebound),
f64::from(wall.tangent_coupling),
) {
velocity = response;
position = old_position.add(velocity);
hit_wall = Some(wall.id);
self.last_collision_id = Some(wall.id);
break;
}
}
let mut response = velocity;
if collide_with_line(
old_position,
&mut response,
wall.segment.start,
wall.segment.end,
f64::from(wall.normal_rebound),
f64::from(wall.tangent_coupling),
) {
velocity = response;
position = old_position.add(velocity);
hit_wall = Some(wall.id);
self.last_collision_id = Some(wall.id);
break;
let circle = PASSIVE_CIRCLES
.iter()
.chain(BUMPERS.iter())
.find(|circle| circle.id == object_id);
if let Some(circle) = circle {
// Objects 67 and 82 are the movable flipper tips.
if matches!(circle.id, 67 | 82) {
continue;
}
let mut response = velocity;
if collide_with_circle(
old_position,
&mut response,
circle.center,
circle.contact_radius,
f64::from(circle.normal_rebound),
f64::from(circle.tangent_coupling),
f64::from(circle.normal_kick),
) {
velocity = response;
position = old_position.add(velocity);
hit_circle = Some(circle.id);
self.last_collision_id = Some(circle.id);
break;
}
}
}
self.ball.position = position.to_position();
@@ -456,64 +485,37 @@ impl Game {
}
}
for circle in PASSIVE_CIRCLES {
debug_assert_ne!(circle.id, 174, "the live ball is not a static obstacle");
// Objects 67 and 82 are the movable flipper tips. The fixed
// pivot circles (65 and 80) remain valid in either position.
if matches!(circle.id, 67 | 82) {
continue;
}
if circle_collision(
if hit_wall.is_none() && hit_circle.is_none() {
let (left_flipper, right_flipper) = self.flipper_segments();
if segment_collision(
&mut self.ball.position,
&mut self.ball.velocity,
0.0,
circle.center,
circle.contact_radius,
0.0,
FLIPPER_CONTACT_RADIUS,
left_flipper,
) {
self.last_collision_id = Some(circle.id);
}
}
let (left_flipper, right_flipper) = self.flipper_segments();
if segment_collision(
&mut self.ball.position,
&mut self.ball.velocity,
FLIPPER_CONTACT_RADIUS,
left_flipper,
) {
self.last_collision_id = Some(66);
}
if segment_collision(
&mut self.ball.position,
&mut self.ball.velocity,
FLIPPER_CONTACT_RADIUS,
right_flipper,
) {
self.last_collision_id = Some(81);
}
for (index, bumper) in BUMPERS.into_iter().enumerate() {
let hit = circle_collision(
self.last_collision_id = Some(66);
} else if segment_collision(
&mut self.ball.position,
&mut self.ball.velocity,
0.0,
bumper.center,
bumper.contact_radius,
105.0,
);
if hit {
self.last_collision_id = Some(bumper.id);
}
if hit && !self.tilted && self.bumper_cooldown <= 0.0 {
self.add_score(self.player().bumper_value);
self.bonus = self.bonus.saturating_add(100);
self.bumper_cooldown = 0.08;
self.bumper_flash[index] = 0.16;
events.push(Event::Bumper);
FLIPPER_CONTACT_RADIUS,
right_flipper,
) {
self.last_collision_id = Some(81);
}
}
if let Some(index) = hit_circle
.and_then(|circle_id| BUMPERS.iter().position(|bumper| bumper.id == circle_id))
&& !self.tilted
&& self.bumper_cooldown <= 0.0
{
self.add_score(self.player().bumper_value);
self.bonus = self.bonus.saturating_add(100);
self.bumper_cooldown = 0.08;
self.bumper_flash[index] = 0.16;
events.push(Event::Bumper);
}
if !self.tilted {
self.check_targets(events);
self.check_media(events);
-27
View File
@@ -50,33 +50,6 @@ pub fn segment_collision(
true
}
pub fn circle_collision(
position: &mut Vec2,
velocity: &mut Vec2,
radius: f32,
center: Vec2,
obstacle_radius: f32,
kick: f32,
) -> bool {
let offset = *position - center;
let minimum = radius + obstacle_radius;
if offset.length_squared() >= minimum * minimum {
return false;
}
let normal = if offset.length_squared() > 0.000_001 {
offset.normalize()
} else {
Vec2::Y
};
*position = center + normal * minimum;
let inward_speed = velocity.dot(normal);
if inward_speed < 0.0 {
*velocity -= normal * inward_speed * 1.8;
}
*velocity += normal * kick;
true
}
#[cfg(test)]
mod tests {
use super::*;
+115
View File
@@ -136,6 +136,67 @@ pub fn collide_with_line(
true
}
/// Apply the original type-1 circle response to a path entering the circle.
pub fn collide_with_circle(
old_position: MilliVec,
velocity: &mut MilliVec,
center: Vec2,
radius: f32,
normal_rebound: f64,
tangent_coupling: f64,
normal_kick: f64,
) -> bool {
let center = MilliVec::from_position(center);
let radius_milli = (radius * 1_000.0).round() as i32;
let from_center = subtract(old_position, center);
let radius_squared = i64::from(radius_milli).pow(2);
let old_distance_squared = i64::from(from_center.x).pow(2) + i64::from(from_center.y).pow(2);
if old_distance_squared <= radius_squared {
return false;
}
let vx = f64::from(velocity.x);
let vy = f64::from(velocity.y);
let offset_x = f64::from(from_center.x);
let offset_y = f64::from(from_center.y);
let quadratic_a = vx * vx + vy * vy;
if quadratic_a == 0.0 {
return false;
}
let quadratic_b = 2.0 * (offset_x * vx + offset_y * vy);
let quadratic_c = old_distance_squared as f64 - f64::from(radius_milli).powi(2);
let discriminant = quadratic_b * quadratic_b - 4.0 * quadratic_a * quadratic_c;
if discriminant < 0.0 {
return false;
}
let progress = (-quadratic_b - discriminant.sqrt()) / (2.0 * quadratic_a);
if !(0.0 < progress && progress <= 1.0) {
return false;
}
let hit_x = offset_x + vx * progress;
let hit_y = offset_y + vy * progress;
let hit_length = hit_x.hypot(hit_y);
if hit_length == 0.0 {
return false;
}
let normal_x = hit_x / hit_length;
let normal_y = hit_y / hit_length;
let tangent_x = normal_y;
let tangent_y = -normal_x;
let normal_speed = vx * normal_x + vy * normal_y;
if normal_speed >= 0.0 {
return false;
}
let tangent_speed = vx * tangent_x + vy * tangent_y;
let outgoing_normal =
-normal_rebound * normal_speed + normal_kick * f64::from(MAXIMUM_SPEED_MILLI_PER_STEP);
let outgoing_tangent = tangent_speed - tangent_coupling * normal_speed;
velocity.x = (normal_x * outgoing_normal + tangent_x * outgoing_tangent).round() as i32;
velocity.y = (normal_y * outgoing_normal + tangent_y * outgoing_tangent).round() as i32;
true
}
#[cfg(test)]
mod tests {
use super::*;
@@ -227,4 +288,58 @@ mod tests {
0.1,
));
}
#[test]
fn ordinary_circle_matches_the_live_object_155_probe() {
let old = MilliVec {
x: 183_000,
y: 70_090,
};
let mut velocity = MilliVec { x: 0, y: -940 };
assert!(collide_with_circle(
old,
&mut velocity,
vec2(183.0, 59.0),
11.0,
0.6,
0.1,
0.0,
));
assert_eq!(velocity, MilliVec { x: 94, y: 564 });
assert_eq!(
old.add(velocity),
MilliVec {
x: 183_094,
y: 70_654
}
);
}
#[test]
fn bumper_circle_matches_the_live_object_51_probe() {
let old = MilliVec {
x: 165_000,
y: 172_015,
};
let mut velocity = MilliVec { x: 0, y: -1_970 };
assert!(collide_with_circle(
old,
&mut velocity,
vec2(165.0, 148.0),
23.0,
0.8,
0.1,
0.4,
));
assert_eq!(velocity, MilliVec { x: 197, y: 3_096 });
assert_eq!(
old.add(velocity),
MilliVec {
x: 165_197,
y: 175_111
}
);
}
}
+29 -16
View File
@@ -54,14 +54,27 @@ pub struct StaticCircle {
pub id: u8,
pub center: Vec2,
pub contact_radius: f32,
pub normal_rebound: f32,
pub tangent_coupling: f32,
pub normal_kick: f32,
}
impl StaticCircle {
const fn new(id: u8, center: Vec2, contact_radius: f32) -> Self {
let (normal_rebound, tangent_coupling, normal_kick) = match id {
54 | 56 | 58 | 65 | 69 | 71 | 73 => (0.50, 0.10, 0.0),
67 | 80 | 82 => (0.50, 0.20, 0.0),
167 | 172 => (0.60, 0.0, 0.0),
51..=53 => (0.80, 0.10, 0.40),
_ => (0.60, 0.10, 0.0),
};
Self {
id,
center,
contact_radius,
normal_rebound,
tangent_coupling,
normal_kick,
}
}
}
@@ -232,10 +245,7 @@ pub const BUMPERS: [StaticCircle; 3] = [
#[cfg(test)]
mod tests {
use super::*;
use crate::{
geometry::circle_collision,
original_physics::{MilliVec, collide_with_line},
};
use crate::original_physics::{MilliVec, collide_with_circle, collide_with_line};
#[test]
fn recovered_object_inventory_is_complete() {
@@ -302,28 +312,31 @@ mod tests {
}
#[test]
fn every_recovered_circle_separates_and_reflects_the_ball() {
fn every_recovered_circle_uses_its_fixed_point_response() {
for circle in PASSIVE_CIRCLES.iter().chain(BUMPERS.iter()) {
let mut position = circle.center + Vec2::new(circle.contact_radius - 0.5, 0.0);
let mut velocity = Vec2::new(-430.0, 0.0);
let hit = circle_collision(
&mut position,
let old_position = MilliVec::from_position(
circle.center + Vec2::new(circle.contact_radius + 1.0, 0.0),
);
let mut velocity = MilliVec { x: -2_000, y: 0 };
let hit = collide_with_circle(
old_position,
&mut velocity,
0.0,
circle.center,
circle.contact_radius,
0.0,
f64::from(circle.normal_rebound),
f64::from(circle.tangent_coupling),
f64::from(circle.normal_kick),
);
assert!(hit, "object {} missed an overlapping ball", circle.id);
assert!(hit, "object {} missed an entering path", circle.id);
assert!(
position.x >= circle.center.x + circle.contact_radius,
"object {} did not separate the ball",
velocity.x > 0,
"object {} did not reflect inward velocity",
circle.id
);
assert!(
velocity.x > 0.0,
"object {} did not reflect inward velocity",
old_position.add(velocity).x > old_position.x,
"object {} did not advance away from its surface",
circle.id
);
}