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:
@@ -493,6 +493,14 @@ impl App {
|
||||
WHITE,
|
||||
);
|
||||
}
|
||||
if let Some(ball) = game.secondary_ball {
|
||||
draw_texture(
|
||||
&self.assets.ball,
|
||||
ball.position.x - 8.0,
|
||||
ball.position.y - 8.0,
|
||||
WHITE,
|
||||
);
|
||||
}
|
||||
if let Some(source) = game.claw.sprite_source() {
|
||||
draw_texture_ex(
|
||||
&self.assets.robot,
|
||||
|
||||
+161
-5
@@ -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);
|
||||
|
||||
@@ -152,6 +152,7 @@ pub struct Snapshot {
|
||||
pub step: u64,
|
||||
pub seconds: f64,
|
||||
pub ball: BallSnapshot,
|
||||
pub secondary_ball: Option<BallSnapshot>,
|
||||
pub claw: ClawSnapshot,
|
||||
pub flippers: FlipperSnapshot,
|
||||
pub launcher_charge: f32,
|
||||
@@ -254,6 +255,13 @@ impl Simulation {
|
||||
velocity_y: self.game.ball.velocity.y,
|
||||
in_launcher: self.game.ball.in_launcher,
|
||||
},
|
||||
secondary_ball: self.game.secondary_ball.map(|ball| BallSnapshot {
|
||||
x: ball.position.x,
|
||||
y: ball.position.y,
|
||||
velocity_x: ball.velocity.x,
|
||||
velocity_y: ball.velocity.y,
|
||||
in_launcher: ball.in_launcher,
|
||||
}),
|
||||
claw: ClawSnapshot {
|
||||
active: self.game.claw.active,
|
||||
frame: self.game.claw.frame,
|
||||
@@ -389,6 +397,12 @@ mod tests {
|
||||
&& snapshot.ball.y.is_finite()
|
||||
&& snapshot.ball.velocity_x.is_finite()
|
||||
&& snapshot.ball.velocity_y.is_finite()
|
||||
&& snapshot.secondary_ball.as_ref().is_none_or(|ball| {
|
||||
ball.x.is_finite()
|
||||
&& ball.y.is_finite()
|
||||
&& ball.velocity_x.is_finite()
|
||||
&& ball.velocity_y.is_finite()
|
||||
})
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user