fix(physics): restore four-sided board bounds

Finish a ball before record scanning when its clamped prediction leaves x=(0,340000] or y=(0,460000], and remove only the escaping slot during multiball. Delete the invented post-move y>470 shortcut so collision responses that land outside persist until the original next-substep check.

Test Plan:
- cargo test --all-targets
- cargo clippy --all-targets --all-features -- -D warnings
- rumdl check CHANGELOG.md RECONSTRUCTION.md README.md
- git diff --check
This commit is contained in:
2026-08-23 19:25:17 +02:00
parent f4e6a5a372
commit d80f823d4b
3 changed files with 65 additions and 8 deletions
+61 -7
View File
@@ -243,6 +243,10 @@ fn retain_first_collision(
}
}
const fn predicted_outside_board(position: MilliVec) -> bool {
position.x <= 0 || position.x > 340_000 || position.y <= 0 || position.y > 460_000
}
#[derive(Clone, Copy, Debug)]
struct RuleState {
wheel_holes: [bool; 5],
@@ -703,6 +707,10 @@ impl Game {
let mut velocity = MilliVec::from_velocity_per_second(ball.velocity);
velocity.y += GRAVITY_MILLI_PER_STEP;
velocity.clamp_speed(MAXIMUM_SPEED_MILLI_PER_STEP);
if predicted_outside_board(old_position.add(velocity)) {
self.drain(events);
return true;
}
let (best_static, scan, predicted) = self.scan_ordered_records_for_ball(
&mut ball,
1,
@@ -824,12 +832,6 @@ impl Game {
}
}
if self.ball.position.y > 470.0 {
// Only malformed/out-of-table states reach this guard; the real
// drain is collision object 2 at y=455.
self.drain(events);
return true;
}
collided
}
@@ -1110,6 +1112,11 @@ impl Game {
let mut velocity = MilliVec::from_velocity_per_second(ball.velocity);
velocity.y += GRAVITY_MILLI_PER_STEP;
velocity.clamp_speed(MAXIMUM_SPEED_MILLI_PER_STEP);
if predicted_outside_board(old_position.add(velocity)) {
self.score_mode = ScoreMode::Normal;
events.push(Event::Drain);
return true;
}
let (best_static, scan, predicted) = self.scan_ordered_records_for_ball(
&mut ball,
2,
@@ -1170,7 +1177,7 @@ impl Game {
velocity.clamp_speed(MAXIMUM_SPEED_MILLI_PER_STEP);
ball.position = old_position.add(velocity).to_position();
ball.velocity = velocity.to_velocity_per_second();
if hit == Some((2, true)) || ball.position.y > 470.0 {
if hit == Some((2, true)) {
self.score_mode = ScoreMode::Normal;
events.push(Event::Drain);
return true;
@@ -2690,6 +2697,53 @@ mod tests {
assert!(game.ball.in_launcher);
}
#[test]
fn four_sided_prediction_bounds_finish_the_ball_before_record_scan() {
for (position, velocity) in [
(vec2(1.0, 200.0), MilliVec { x: -2_000, y: 0 }),
(vec2(339.0, 200.0), MilliVec { x: 2_000, y: 0 }),
(vec2(200.0, 1.0), MilliVec { x: 0, y: -2_000 }),
(vec2(200.0, 459.0), MilliVec { x: 0, y: 2_000 }),
] {
let mut game = Game::new(1);
game.object_active.fill(false);
game.ball.in_launcher = false;
game.ball.position = position;
game.ball.velocity = velocity.to_velocity_per_second();
let mut events = Vec::new();
assert!(game.fixed_update(&mut events));
assert!(game.ball.in_launcher);
assert_eq!(game.player().balls, 2);
assert!(events.contains(&Event::Drain));
}
}
#[test]
fn prediction_bounds_remove_only_the_escaping_multiball_slot() {
let mut game = Game::new(1);
game.object_active.fill(false);
game.ball.in_launcher = false;
game.ball.position = vec2(200.0, 200.0);
let second = Ball {
position: vec2(1.0, 200.0),
velocity: MilliVec { x: -2_000, y: 0 }.to_velocity_per_second(),
in_launcher: false,
spin: Real48::ZERO,
capture_age: 0,
};
game.secondary_ball = Some(second);
game.score_mode = ScoreMode::Multiball;
assert!(game.advance_secondary_ball(&mut Vec::new()));
assert!(game.secondary_ball.is_none());
assert_eq!(game.player().balls, 3);
assert_eq!(game.score_mode, ScoreMode::Normal);
}
#[test]
fn player_turns_save_and_restore_all_rule_record_state() {
let mut game = Game::new(2);