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:
+73
-71
@@ -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);
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user