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);