fix(physics): restore slot-major timer batches

Freeze the active ball count at each detail callback, integrate every substep for slot one before slot two, and stop a slot after its first collision or lifecycle action. Effect-seven spawns are now deferred until the next callback, while a surviving slot two retains its original ball number for the remainder of a callback after slot one exits.

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 18:08:21 +02:00
parent 445af4a030
commit 692e0087f9
3 changed files with 155 additions and 32 deletions
+150 -31
View File
@@ -216,6 +216,7 @@ enum CaptureStep {
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum BallAction {
Keep,
Suspend,
Reset,
Remove,
}
@@ -467,13 +468,36 @@ impl Game {
self.update_target_rotation(events);
let panel_active = self.update_panel_completion(events);
if !self.claw.ball_suspended && !panel_active {
let had_secondary_ball = self.secondary_ball.is_some();
for _ in 0..substeps {
self.fixed_update(STEP_SECONDS, events);
self.advance_secondary_ball(events);
if self.finished || self.claw.ball_suspended {
if self.fixed_update(STEP_SECONDS, events)
|| self.finished
|| self.claw.ball_suspended
{
break;
}
}
if had_secondary_ball && !self.finished && !self.claw.ball_suspended {
if self.secondary_ball.is_some() {
for _ in 0..substeps {
if self.advance_secondary_ball(events) {
break;
}
}
} else {
self.secondary_ball = Some(self.ball);
for _ in 0..substeps {
if self.advance_secondary_ball_slot(events, false) {
break;
}
}
if let Some(survivor) = self.secondary_ball.take() {
self.ball = survivor;
} else {
self.drain(events);
}
}
}
}
self.pending_flipper_edges[0] = match (
@@ -633,12 +657,12 @@ impl Game {
}
#[allow(clippy::too_many_lines)]
fn fixed_update(&mut self, dt: f32, events: &mut Vec<Event>) {
fn fixed_update(&mut self, dt: f32, events: &mut Vec<Event>) -> bool {
self.nudge_shake = (self.nudge_shake - dt).max(0.0);
if self.ball.in_launcher {
self.ball.position = LAUNCHER_POSITION;
self.ball.velocity = Vec2::ZERO;
return;
return true;
}
let old_position = MilliVec::from_position(self.ball.position);
@@ -675,6 +699,7 @@ impl Game {
));
}
}
let collided = best_collision.is_some();
let (hit_wall, hit_circle) = if let Some((object_id, is_wall, response)) = best_collision {
velocity = response.velocity;
self.ball.spin = response.spin;
@@ -701,7 +726,7 @@ impl Game {
if let Some(wall_id) = hit_wall {
if wall_id == 2 {
self.drain(events);
return;
return true;
}
if wall_id == 25
&& i64::from(velocity.x).pow(2) + i64::from(velocity.y).pow(2) < 1_000_i64.pow(2)
@@ -709,7 +734,7 @@ impl Game {
self.ball = Ball::default();
self.launcher_charge = 0.0;
self.launcher_was_down = false;
return;
return true;
}
self.apply_wall_rule(wall_id, events);
}
@@ -724,16 +749,17 @@ impl Game {
events.push(Event::Sound(2006));
}
self.check_sensor_objects(old_position, movement_velocity, events);
if self.claw.ball_suspended {
return;
if self.check_sensor_objects(old_position, movement_velocity, events) != BallAction::Keep {
return true;
}
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
}
fn find_static_collision(
@@ -794,9 +820,17 @@ impl Game {
best
}
fn advance_secondary_ball(&mut self, events: &mut Vec<Event>) {
fn advance_secondary_ball(&mut self, events: &mut Vec<Event>) -> bool {
self.advance_secondary_ball_slot(events, true)
}
fn advance_secondary_ball_slot(
&mut self,
events: &mut Vec<Event>,
primary_slot_active: bool,
) -> bool {
let Some(mut ball) = self.secondary_ball.take() else {
return;
return true;
};
let old_position = MilliVec::from_position(ball.position);
let mut velocity = MilliVec::from_velocity_per_second(ball.velocity);
@@ -807,15 +841,17 @@ impl Game {
let mut best_collision = self.find_static_collision(old_position, velocity, ball.spin);
let mut transferred_primary_velocity = None;
if let Some(response) = ball_collision_response(
old_position,
velocity,
ball.spin,
MilliVec::from_position(self.ball.position),
MilliVec::from_velocity_per_second(self.ball.velocity),
) && best_collision.is_none_or(|(_, _, closest)| {
response.surface_distance <= closest.surface_distance
})
if primary_slot_active
&& let Some(response) = ball_collision_response(
old_position,
velocity,
ball.spin,
MilliVec::from_position(self.ball.position),
MilliVec::from_velocity_per_second(self.ball.velocity),
)
&& best_collision.is_none_or(|(_, _, closest)| {
response.surface_distance <= closest.surface_distance
})
{
transferred_primary_velocity = Some(response.other_velocity);
best_collision = Some((
@@ -829,6 +865,7 @@ impl Game {
));
}
let mut hit = None;
let collided = best_collision.is_some();
if let Some((object_id, is_wall, response)) = best_collision {
velocity = response.velocity;
ball.spin = response.spin;
@@ -844,7 +881,7 @@ impl Game {
ball.velocity = velocity.to_velocity_per_second();
if hit == Some((2, true)) || ball.position.y > 470.0 {
events.push(Event::Drain);
return;
return true;
}
if let Some((object_id, true)) = hit {
self.apply_wall_rule(object_id, events);
@@ -857,17 +894,19 @@ impl Game {
events.push(Event::Bumper);
events.push(Event::Sound(2006));
}
if self.check_sensor_objects_for_ball(
let action = self.check_sensor_objects_for_ball(
&mut ball,
2,
2,
old_position,
movement_velocity,
events,
) == BallAction::Keep
{
self.secondary_ball = Some(ball);
);
match action {
BallAction::Keep | BallAction::Suspend => self.secondary_ball = Some(ball),
BallAction::Reset | BallAction::Remove => return true,
}
collided || action == BallAction::Suspend
}
fn apply_wall_rule(&mut self, object_id: u8, events: &mut Vec<Event>) {
@@ -972,18 +1011,19 @@ impl Game {
old_position: MilliVec,
movement_velocity: MilliVec,
events: &mut Vec<Event>,
) {
) -> BallAction {
let ball_count = if self.secondary_ball.is_some() { 2 } else { 1 };
let mut ball = self.ball;
match self.check_sensor_objects_for_ball(
let action = self.check_sensor_objects_for_ball(
&mut ball,
1,
ball_count,
old_position,
movement_velocity,
events,
) {
BallAction::Keep => self.ball = ball,
);
match action {
BallAction::Keep | BallAction::Suspend => self.ball = ball,
BallAction::Reset => self.reset_ball_to_launcher(),
BallAction::Remove => {
self.ball = self
@@ -992,6 +1032,7 @@ impl Game {
.expect("a multiball capture must leave the other slot active");
}
}
action
}
fn check_sensor_objects_for_ball(
@@ -1019,7 +1060,7 @@ impl Game {
if ball_count == 1 {
let terminal_frame = self.next_claw_terminal_frame();
self.begin_claw_capture_after_hold(ball, terminal_frame, events);
return BallAction::Keep;
return BallAction::Suspend;
}
return BallAction::Remove;
}
@@ -1741,6 +1782,84 @@ mod tests {
}
}
#[test]
fn detail_batch_stops_after_the_first_collision_like_integrate_one_ball() {
let mut game = Game::new(1);
game.ball.in_launcher = false;
game.ball.position = vec2(270.0, 30.0);
game.ball.velocity = vec2(0.0, -380.0);
let mut expected = game.clone();
let mut expected_events = Vec::new();
let mut stopped = false;
for _ in 0..5 {
if expected.fixed_update(STEP_SECONDS, &mut expected_events) {
stopped = true;
break;
}
}
assert!(stopped, "the recovered top rail must end this detail batch");
let mut events = Vec::new();
game.timer_tick(0.050, 5, &mut events);
assert_eq!(game.ball.position, expected.ball.position);
assert_eq!(game.ball.velocity, expected.ball.velocity);
assert_eq!(events, expected_events);
}
#[test]
fn effect_seven_spawn_waits_until_the_next_timer_callback() {
let mut game = Game::new_with_seed(1, 7);
game.ball.in_launcher = false;
game.ball.position = EFFECT_SENSOR.center;
game.ball.velocity = Vec2::ZERO;
game.object_active.fill(false);
game.object_active[usize::from(EFFECT_SENSOR.id)] = true;
game.multiball_state = MultiballState::Ready;
game.target_effect = 7;
game.timer_tick(0.050, 5, &mut Vec::new());
let spawned = game
.secondary_ball
.expect("the effect-seven request must create slot two after simulation");
assert_eq!(spawned.position, vec2(17.0, 23.0));
assert_eq!(
MilliVec::from_velocity_per_second(spawned.velocity),
MilliVec { x: 0, y: 3_040 }
);
}
#[test]
fn survivor_keeps_ball_two_identity_for_the_rest_of_the_callback() {
let lock = LOCK_HOLES[0];
let mut game = Game::new_with_seed(1, 7);
game.ball.in_launcher = false;
game.ball.position = lock.center;
game.ball.velocity = Vec2::ZERO;
game.ball.capture_age = 300;
game.record_contacts[usize::from(lock.id)] = 1;
game.secondary_ball = Some(Ball {
position: EFFECT_SENSOR.center,
velocity: Vec2::ZERO,
in_launcher: false,
spin: Real48::ZERO,
capture_age: 0,
});
game.object_active[usize::from(EFFECT_SENSOR.id)] = true;
game.multiball_state = MultiballState::Ready;
game.target_effect = 7;
game.timer_tick(0.050, 5, &mut Vec::new());
assert!(game.secondary_ball.is_none());
assert_eq!(game.target_effect, 0);
assert!(!game.effect_target_active());
assert_eq!(game.multiball_state, MultiballState::Ready);
assert!(game.wheel_holes[0]);
assert!(game.ball.position.y > EFFECT_SENSOR.center.y);
}
#[test]
fn launcher_charges_while_held_and_fires_on_release() {
let mut game = Game::new(1);