fix(game): restore effect-seven multiball

Arm effect 7 after the wheel-reset target, select it on the next effect line,
and spawn a second visible live ball when record 149 is consumed. Advance the
second ball through recovered line, circle, ball-ball, magnetic, and scoring
collisions independently.

During multiball, draining one ball leaves the other active without consuming a
remaining player ball. Include secondary state in deterministic traces and
finite-state validation.

Test Plan:
- `cargo test --all-targets` -- 52 passed
- `cargo clippy --all-targets -- -D warnings` -- passed
- production build plus Windows GNU and macOS checks -- passed
- effect-seven spawn, movement, and single-drain lifecycle test -- passed
- `git diff --cached --check` -- passed
This commit is contained in:
2026-08-22 22:02:50 +02:00
parent 25e32b2c06
commit f2560ee2f7
5 changed files with 190 additions and 6 deletions
+161 -5
View File
@@ -176,6 +176,13 @@ enum PlayerEntry {
Closed,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
enum MultiballState {
#[default]
Unavailable,
Ready,
}
fn initial_object_activity() -> [bool; 176] {
let mut active = [true; 176];
active[0] = false;
@@ -200,6 +207,7 @@ pub struct Game {
pub players: Vec<Player>,
pub current_player: usize,
pub ball: Ball,
pub secondary_ball: Option<Ball>,
pub bonus: u32,
pub wheel_holes: [bool; 5],
pub top_targets: [bool; 3],
@@ -227,6 +235,7 @@ pub struct Game {
object_active: [bool; 176],
target_effect: u8,
claw_frame_seconds: f32,
multiball_state: MultiballState,
}
impl Game {
@@ -239,6 +248,7 @@ impl Game {
players: vec![Player::default(); player_count.clamp(1, 4)],
current_player: 0,
ball: Ball::default(),
secondary_ball: None,
bonus: 0,
wheel_holes: [false; 5],
top_targets: [false; 3],
@@ -266,6 +276,7 @@ impl Game {
object_active: initial_object_activity(),
target_effect: 0,
claw_frame_seconds: CLAW_FRAME_SECONDS,
multiball_state: MultiballState::Unavailable,
}
}
@@ -379,6 +390,7 @@ impl Game {
self.accumulator = (self.accumulator + frame_time.min(0.05)).min(0.1);
while self.accumulator >= STEP_SECONDS {
self.fixed_update(STEP_SECONDS, &mut events);
self.advance_secondary_ball(&mut events);
self.accumulator -= STEP_SECONDS;
}
events
@@ -522,6 +534,95 @@ impl Game {
}
}
fn advance_secondary_ball(&mut self, events: &mut Vec<Event>) {
let Some(mut ball) = self.secondary_ball.take() else {
return;
};
let old_position = MilliVec::from_position(ball.position);
let mut velocity = MilliVec::from_velocity_per_second(ball.velocity);
velocity.y += GRAVITY_MILLI_PER_STEP;
velocity.clamp_speed(MAXIMUM_SPEED_MILLI_PER_STEP);
self.apply_magnetic_fields(old_position, &mut velocity);
let mut best_collision: Option<(u8, bool, CollisionResponse)> = None;
for object_id in 1..=175 {
if !self.object_active[usize::from(object_id)] {
continue;
}
if let Some(wall) = WALLS.iter().find(|wall| wall.id == object_id) {
let segment = self.live_wall_segment(wall.id, wall.segment);
if let Some(response) = line_collision_response(
old_position,
velocity,
segment.start,
segment.end,
f64::from(wall.normal_rebound),
f64::from(wall.tangent_coupling),
) && best_collision
.is_none_or(|(_, _, closest)| response.progress < closest.progress)
{
best_collision = Some((wall.id, true, response));
}
}
let circle = PASSIVE_CIRCLES
.iter()
.chain(BUMPERS.iter())
.find(|circle| circle.id == object_id);
if let Some(circle) = circle
&& let Some(response) = circle_collision_response(
old_position,
velocity,
self.live_circle_center(circle.id, circle.center),
circle.contact_radius,
f64::from(circle.normal_rebound),
f64::from(circle.tangent_coupling),
f64::from(circle.normal_kick),
)
&& best_collision.is_none_or(|(_, _, closest)| response.progress < closest.progress)
{
best_collision = Some((circle.id, false, response));
}
}
if let Some(response) = circle_collision_response(
old_position,
velocity,
self.ball.position,
17.0,
0.9,
0.0,
0.0,
) && best_collision.is_none_or(|(_, _, closest)| response.progress < closest.progress)
{
best_collision = Some((174, false, response));
}
let mut hit = None;
if let Some((object_id, is_wall, response)) = best_collision {
velocity = response.velocity;
hit = Some((object_id, is_wall));
}
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 {
events.push(Event::Drain);
return;
}
if let Some((object_id, true)) = hit {
self.apply_wall_rule(object_id, events);
} else if let Some((object_id, false)) = hit
&& let Some(index) = BUMPERS.iter().position(|bumper| bumper.id == object_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);
}
self.secondary_ball = Some(ball);
}
fn apply_wall_rule(&mut self, object_id: u8, events: &mut Vec<Event>) {
let wall = WALLS
.iter()
@@ -578,11 +679,16 @@ impl Game {
self.wheel_animation = 0.65;
}
if wall.flags & 0x0004 != 0 {
let previous = self.target_effect;
loop {
self.target_effect = u8::try_from(self.next_random_value() % 6 + 1).unwrap_or(1);
if self.target_effect != previous {
break;
if self.multiball_state == MultiballState::Ready {
self.target_effect = 7;
} else {
let previous = self.target_effect;
loop {
self.target_effect =
u8::try_from(self.next_random_value() % 6 + 1).unwrap_or(1);
if self.target_effect != previous {
break;
}
}
}
self.object_active[usize::from(EFFECT_SENSOR.id)] = true;
@@ -698,6 +804,7 @@ impl Game {
if reset_entered && !self.tilted {
self.wheel_holes.fill(false);
self.wheel_animation = 0.65;
self.multiball_state = MultiballState::Ready;
self.reset_ball_to_launcher();
events.push(Event::Wheel);
return true;
@@ -739,6 +846,14 @@ impl Game {
self.add_score(self.bonus);
self.bonus = 0;
}
7 => {
self.secondary_ball = Some(Ball {
position: LAUNCHER_POSITION,
velocity: vec2(0.0, -300.0),
in_launcher: false,
});
self.multiball_state = MultiballState::Unavailable;
}
_ => {}
}
self.target_effect = 0;
@@ -957,6 +1072,11 @@ impl Game {
}
fn drain(&mut self, events: &mut Vec<Event>) {
if let Some(remaining_ball) = self.secondary_ball.take() {
self.ball = remaining_ball;
events.push(Event::Drain);
return;
}
let player = &mut self.players[self.current_player];
player.score = player.score.saturating_add(self.bonus);
if player.extra_balls > 0 {
@@ -976,6 +1096,8 @@ impl Game {
self.trigger_contacts.fill(false);
self.object_active = initial_object_activity();
self.target_effect = 0;
self.multiball_state = MultiballState::Unavailable;
self.secondary_ball = None;
self.nudge_shake = 0.0;
self.launcher_charge = 0.0;
self.launcher_was_down = false;
@@ -1284,6 +1406,40 @@ mod tests {
);
}
#[test]
fn wheel_reset_arms_effect_seven_multiball() {
let mut game = Game::new_with_seed(1, 7);
let mut events = Vec::new();
game.multiball_state = MultiballState::Ready;
game.apply_wall_rule(84, &mut events);
assert_eq!(game.target_effect, 7);
game.ball.in_launcher = false;
game.ball.position = EFFECT_SENSOR.center;
game.check_sensor_objects(
MilliVec::from_position(EFFECT_SENSOR.center),
MilliVec::default(),
&mut events,
);
let spawned = game
.secondary_ball
.expect("effect seven should spawn a second ball");
assert_eq!(spawned.position, LAUNCHER_POSITION);
assert_eq!(spawned.velocity, vec2(0.0, -300.0));
game.advance_secondary_ball(&mut events);
let moving_ball = game
.secondary_ball
.expect("second ball should remain active");
assert!(moving_ball.position.y < spawned.position.y);
let balls_before = game.player().balls;
game.drain(&mut events);
assert!(game.secondary_ball.is_none());
assert_eq!(game.player().balls, balls_before);
assert_eq!(game.ball.position, moving_ball.position);
}
#[test]
fn center_drain_advances_to_a_fresh_ball() {
let mut game = Game::new(1);