diff --git a/tdkpin-rs/CHANGELOG.md b/tdkpin-rs/CHANGELOG.md index 8aed67b..12d0cdf 100644 --- a/tdkpin-rs/CHANGELOG.md +++ b/tdkpin-rs/CHANGELOG.md @@ -10,14 +10,28 @@ and this project adheres to ### Added -- Crazy mode: Ctrl+Shift+0 drops ten extra balls onto the playfield. They bounce - and score off walls, bumpers, and flippers and replace the main ball when it - drains. High-score entries of players who used it show a red `CRAZY` label in - the table and are shared like any other score. +- Crazy mode: pressing the main-row `0` key during a game drops ten more balls + onto the playfield, up to 64 balls in play. High-score entries reached with + crazy mode show a red `CRAZY` label in the table. - The high-score service stores a crazy-mode flag per entry; it adds the column to existing databases on startup and treats submissions from older clients as normal scores. +### Changed + +- Replace the separate primary and secondary ball slots with one ordered list + of identical balls. Every ball runs the same physics, sensors, holes, claw, + ball-to-ball contact, and drain rules; the player's ball ends only when the + last ball drains. One- and two-ball behavior keeps the recovered slot order. +- A ball resting on the shooter stop returns to an empty launcher from any slot, + and flippers keep kicking balls in play while another ball waits there. +- The simulator trace now reports every ball in a `balls` array. + +### Fixed + +- Free a ball that was settling into a lock hole when another ball filled it, + and release a lock hole whose settling ball left the table. + ## [1.2.1] - 2026-09-01 ### Fixed diff --git a/tdkpin-rs/PARITY_AUDIT.md b/tdkpin-rs/PARITY_AUDIT.md index ac11d4d..b4b6d8a 100644 --- a/tdkpin-rs/PARITY_AUDIT.md +++ b/tdkpin-rs/PARITY_AUDIT.md @@ -66,6 +66,28 @@ ledger in `../original/C_RECONSTRUCTION_FINAL_AUDIT.md`. on those hosts is delegated to the repository's three-OS CI matrix. The semantic core is platform-independent and has no target-specific branches. +## Modern additions and their divergences + +Crazy mode (main-row `0`) adds up to 64 live balls. To make every ball behave +identically, the two original timer slots became one ordered ball list. One- +and two-ball play keeps the recovered slot order, slot-numbered contact +ownership, deferred effect-seven spawn, survivor identity, nudge draws, and +multiball scoring, and all multiball tests above still pass. These deliberate +differences follow from treating every ball alike: + +- A ball resting on the shooter stop returns to the launcher from any slot (the + original deleted a slot-two ball there) unless another ball already waits. +- Flipper kicks, record countdowns, and target rotation continue for balls in + play while another ball waits in the launcher. +- Ball-to-ball contact ignores only the claw-held ball, and every ball's + collision is published as the last collision record. +- When a ball leaves, claw and lock-hole contact owners move with their ball, + and a lock hole the leaving ball was settling into is freed. +- The rim of a filled lock hole only repels balls from the outside, so a ball + that was settling into it when another ball filled it rolls back out. +- High-score entries carry a `crazy` flag that both local storage and the + shared service persist; older entries default to normal scores. + ## Reproducible final gates ```sh diff --git a/tdkpin-rs/README.md b/tdkpin-rs/README.md index 5d5c291..5f0c6f3 100644 --- a/tdkpin-rs/README.md +++ b/tdkpin-rs/README.md @@ -77,13 +77,13 @@ for long end-to-end validation runs. | Settings | `TDKPIN.INI` before launch | F10 opens the portable settings screen | | High scores | Automatic when a player finishes | F9 opens the portable table viewer | | Sound | F12 | - | -| Crazy mode: drop 10 extra balls | - | Ctrl+Shift+0 | +| Crazy mode: drop 10 more balls | - | `0` (main row) | -Crazy mode is a modern addition. Its balls bounce off walls, bumpers, and -flippers and score through them, but skip sensors, holes, and the claw. When -the main ball drains, a crazy ball takes its place before the turn ends. A -player who used crazy mode still enters the high-score table, with their entry -labelled `CRAZY`. +Crazy mode is a modern addition. Each press drops ten balls at fixed points on +the playfield, up to 64 balls in play. They are ordinary balls: every ball runs +the same physics, sensors, holes, claw, and scoring, and the player's ball ends +only when the last one drains. A player who used crazy mode still enters the +high-score table, with their entry labelled `CRAZY`. The five original speed choices remain available in settings. Physics now uses the original invariant 100 Hz millipixel substep, batched as 5/4/3/2/1 steps on diff --git a/tdkpin-rs/RECONSTRUCTION.md b/tdkpin-rs/RECONSTRUCTION.md index b6ec48a..662be9f 100644 --- a/tdkpin-rs/RECONSTRUCTION.md +++ b/tdkpin-rs/RECONSTRUCTION.md @@ -88,8 +88,19 @@ decoded, build-ready subset; it does not replace that evidence archive. - `app.rs`: screen flow, controls, rendering, audio dispatch, and overlays; - `assets.rs`: compile-time original asset embedding; - `game.rs`: fixed-step game state, recovered walls, rules, and scoring; - - crazy mode (a modern addition): extra balls outside the original two ball - slots, with static collisions, wall/bumper rules, and flipper kicks only; + - ball model: `Game::balls` is one ordered list of identical balls. Each + timer callback (`advance_ball_slots`) runs every ball's substep batch in + slot order through the single integrator `step_ball`: record scan, + ball-to-ball contact, rule dispatch, and drain handling. A ball keeps the + slot number it started the callback with, which the type-three contact + records publish as their owner. With one or two balls this is the + original slot-one/slot-two timer; more balls extend the same order; + - ball lifetime: `lose_ball` takes a drained ball off the table while + others remain and ends the player's ball only for the last one; + `remove_ball` keeps the claw and contact-record owners pointing at the + same physical ball; effect seven's ball joins after all slot passes; + - crazy mode (a modern addition): `add_crazy_balls` drops ten ordinary balls + and marks the current player, whose high score is labelled; - `geometry.rs`: segment/circle collision primitives; - `original_physics.rs`: millipixel integration and recovered type-2 responses; - `real48.rs`: bit-exact Borland six-byte arithmetic, conversion, and square root; diff --git a/tdkpin-rs/src/app.rs b/tdkpin-rs/src/app.rs index 1a94f0a..6c4a3a6 100644 --- a/tdkpin-rs/src/app.rs +++ b/tdkpin-rs/src/app.rs @@ -20,6 +20,9 @@ const WIDTH: f32 = 640.0; const HEIGHT: f32 = 460.0; const DETAIL_TIMER_SECONDS: [f32; 5] = [0.050, 0.040, 0.030, 0.020, 0.010]; const ADD_PLAYER_KEYS: [KeyCode; 3] = [KeyCode::KpAdd, KeyCode::RightBracket, KeyCode::Equal]; +/// Modern addition: drops ten crazy-mode balls. A bare main-row `0` is free +/// in the original key map and has no browser default action. +const CRAZY_MODE_KEY: KeyCode = KeyCode::Key0; const TARGET_POSITIONS: [[(f32, f32); 5]; 6] = [ [ (9.0, 41.0), @@ -224,8 +227,6 @@ pub struct App { pending_score: u32, pending_crazy: bool, attract: AttractAnimation, - /// Ctrl+Shift+0 holds Shift, whose release would otherwise nudge. - crazy_shift_nudge_suppressed: bool, } impl App { @@ -255,7 +256,6 @@ impl App { pending_score: 0, pending_crazy: false, attract: AttractAnimation::default(), - crazy_shift_nudge_suppressed: false, } } @@ -403,21 +403,10 @@ impl App { || is_key_down(KeyCode::Enter) || is_key_down(KeyCode::D) || is_key_down(KeyCode::Right); - let shift_down = is_key_down(KeyCode::LeftShift) || is_key_down(KeyCode::RightShift); - let ctrl_down = is_key_down(KeyCode::LeftControl) || is_key_down(KeyCode::RightControl); - if ctrl_down - && shift_down - && is_key_pressed(KeyCode::Key0) - && self.game.as_mut().is_some_and(Game::add_crazy_balls) - { - self.crazy_shift_nudge_suppressed = true; + if is_key_pressed(CRAZY_MODE_KEY) && self.game.as_mut().is_some_and(Game::add_crazy_balls) { self.assets.play(2007, self.sounds_enabled); } - let nudge = if self.crazy_shift_nudge_suppressed - && (is_key_released(KeyCode::LeftShift) || is_key_released(KeyCode::RightShift)) - { - Nudge::None - } else if is_key_released(KeyCode::LeftShift) { + let nudge = if is_key_released(KeyCode::LeftShift) { Nudge::Left } else if is_key_released(KeyCode::RightShift) || is_key_released(KeyCode::Kp3) { Nudge::Right @@ -426,9 +415,6 @@ impl App { } else { Nudge::None }; - if !shift_down { - self.crazy_shift_nudge_suppressed = false; - } let controls = Controls { left_flipper, right_flipper, @@ -883,7 +869,7 @@ impl App { if game.special_hole_active() { self.draw_active_table_region(9, 14, 17, 17); } - if game.ball.in_launcher { + if game.balls.iter().any(|ball| ball.in_launcher) { draw_texture_ex( &self.assets.plunger, 314.0, @@ -925,25 +911,10 @@ impl App { ); } } - if !game.primary_ball_suspended() { - draw_texture( - &self.assets.ball, - game.ball.position.x - 8.0, - game.ball.position.y - 8.0, - WHITE, - ); - } - if let Some(ball) = game.secondary_ball - && !game.secondary_ball_suspended() - { - draw_texture( - &self.assets.ball, - ball.position.x - 8.0, - ball.position.y - 8.0, - WHITE, - ); - } - for ball in &game.crazy_balls { + for (index, ball) in game.balls.iter().enumerate() { + if game.ball_suspended(index) { + continue; + } draw_texture( &self.assets.ball, ball.position.x - 8.0, diff --git a/tdkpin-rs/src/game.rs b/tdkpin-rs/src/game.rs index 02ef9df..16708ae 100644 --- a/tdkpin-rs/src/game.rs +++ b/tdkpin-rs/src/game.rs @@ -130,32 +130,18 @@ pub enum ClawSpriteBank { Opening, } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum ClawBallSlot { - Primary, - Secondary, -} - -impl ClawBallSlot { - const fn from_ball_number(ball_number: u16) -> Self { - if ball_number == 2 { - Self::Secondary - } else { - Self::Primary - } - } -} - #[derive(Clone, Copy, Debug)] pub struct Claw { pub active: bool, pub frame: u8, pub bank: ClawSpriteBank, pub ball_suspended: bool, - captured_slot: Option, + /// Index into `Game::balls` of the captured ball. Kept in step with the + /// list when an earlier ball leaves the table. + captured_slot: Option, // The deep zone is less than 16 px wide while ball contact is 17 px, so // only one physically non-overlapping ball can own a pending entry. - capture_entry: Option<(ClawBallSlot, bool)>, + capture_entry: Option<(usize, bool)>, target_frame: u8, frame_accumulator: f32, } @@ -304,6 +290,29 @@ enum BallAction { Finish, } +/// Identity of one ball for the duration of its slot pass in a timer callback. +#[derive(Clone, Copy, Debug)] +struct BallPass { + /// Current position of the ball in `Game::balls`. + index: usize, + /// One-based slot number the ball held when the callback started. The + /// original publishes it as the owner in type-three contact records. + number: u16, + /// Number of live balls when the callback started. + count: u16, +} + +/// Result of one physics substep for one ball. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum BallStep { + /// Keep integrating this ball in the current batch. + Continue, + /// The ball is still at its index but its batch ends here. + Stop, + /// The ball left `Game::balls`; later balls moved down one index. + Removed, +} + #[derive(Clone, Copy, Debug)] struct SensorScanResult { action: BallAction, @@ -382,8 +391,12 @@ impl Default for Ball { } } -/// Drop points for the Ctrl+Shift+0 crazy mode, spread over open playfield -/// between the bumpers, slingshots, and lanes. +/// Upper bound on balls in play. Type-three contact records store a ball's +/// one-based slot number and reserve 99 as a sentinel, so slots stay below it. +pub const MAX_BALLS: usize = 64; + +/// Drop points for crazy mode, spread over open playfield between the +/// bumpers, slingshots, and lanes. pub const CRAZY_BALL_POSITIONS: [Vec2; 10] = [ vec2(170.0, 112.0), vec2(250.0, 140.0), @@ -401,12 +414,15 @@ pub const CRAZY_BALL_POSITIONS: [Vec2; 10] = [ pub struct Game { pub players: Vec, pub current_player: usize, - pub ball: Ball, - pub secondary_ball: Option, - /// Extra balls added by crazy mode. They are not part of the original - /// two-slot engine: they collide with static records, score through the - /// wall and bumper rules, and replace the primary ball when it drains. - pub crazy_balls: Vec, + /// Every ball in the machine, in timer slot order. All balls run the + /// same physics and rules; the order only decides who moves first in a + /// callback and which slot number owns a contact record. The list holds + /// at least one ball (possibly waiting in the launcher) until the game + /// finishes. + pub balls: Vec, + /// Balls created during the current callback. The original publishes + /// effect seven's ball only after every slot pass has returned. + spawned_balls: Vec, pub wheel_holes: [bool; 5], pub top_targets: [bool; 3], pub tilted: bool, @@ -450,9 +466,8 @@ impl Game { Self { players: vec![Player::default(); player_count.clamp(1, 4)], current_player: 0, - ball: Ball::default(), - secondary_ball: None, - crazy_balls: Vec::new(), + balls: vec![Ball::default()], + spawned_balls: Vec::new(), wheel_holes: [false; 5], top_targets: [false; 3], tilted: false, @@ -521,27 +536,43 @@ impl Game { true } - /// Crazy mode: drop ten extra balls onto the playfield. + /// Crazy mode: drop ten more balls onto the playfield. They are ordinary + /// balls in every respect; the current player's score is marked as a + /// crazy-mode score. Returns false when nothing was added. pub fn add_crazy_balls(&mut self) -> bool { - if self.finished { + if self.finished || self.balls.len() >= MAX_BALLS { return false; } self.players[self.current_player].crazy_mode = CrazyMode::Used; - self.crazy_balls - .extend(CRAZY_BALL_POSITIONS.into_iter().map(|position| Ball { - position, - velocity: Vec2::ZERO, - in_launcher: false, - ..Ball::default() - })); + let room = MAX_BALLS - self.balls.len(); + self.balls.extend( + CRAZY_BALL_POSITIONS + .into_iter() + .take(room) + .map(|position| Ball { + position, + velocity: Vec2::ZERO, + in_launcher: false, + ..Ball::default() + }), + ); true } + /// Whether the claw currently holds the ball at `index` off the table. + pub fn ball_suspended(&self, index: usize) -> bool { + self.claw_holds(index) + } + + fn launcher_ball(&self) -> Option { + self.balls.iter().position(|ball| ball.in_launcher) + } + #[cfg(not(target_arch = "wasm32"))] pub(crate) fn begin_claw_scenario(&mut self, terminal_frame: u8) -> Vec { - self.ball.in_launcher = false; - self.ball.position = CLAW_TRIGGER_CENTER; - self.ball.velocity = Vec2::ZERO; + self.balls[0].in_launcher = false; + self.balls[0].position = CLAW_TRIGGER_CENTER; + self.balls[0].velocity = Vec2::ZERO; let mut events = Vec::new(); self.begin_claw_capture(terminal_frame, &mut events); events @@ -559,8 +590,8 @@ impl Game { #[cfg(not(target_arch = "wasm32"))] pub(crate) fn begin_target_scenario(&mut self) { - self.ball.in_launcher = false; - self.ball.position = vec2(170.0, 230.0); + self.balls[0].in_launcher = false; + self.balls[0].position = vec2(170.0, 230.0); self.players[0].bumper_value = 6_000; self.wheel_holes = [true, false, true, false, false]; self.target_rotation_state = Some(0); @@ -568,7 +599,7 @@ impl Game { self.object_active[109] = false; } - fn fire_launcher(&mut self) { + fn fire_launcher(&mut self, index: usize) { self.launcher_velocity_milli = self .launcher_velocity_milli .wrapping_sub(LAUNCHER_RELEASE_IMPULSE_MILLI); @@ -580,8 +611,9 @@ impl Game { launch_velocity = launch_velocity.wrapping_sub(i32::from(self.random.below(3_800) / 40)); } - self.ball.in_launcher = false; - self.ball.velocity = MilliVec { + let ball = &mut self.balls[index]; + ball.in_launcher = false; + ball.velocity = MilliVec { x: 0, y: launch_velocity, } @@ -619,7 +651,7 @@ impl Game { // The original key handlers keep accepting the launcher key while // tilted; Tilt disables flippers and scoring, but must not strand a // ball that is still waiting in the shooter lane. - if self.ball.in_launcher { + if let Some(launcher_ball) = self.launcher_ball() { if controls.launch_down { if self.launcher_was_down { self.launcher_hold_seconds += frame_time.min(0.05); @@ -640,7 +672,7 @@ impl Game { self.launcher_charge = f32::from(i16::try_from(charge_milli).unwrap_or(3_800)) / f32::from(3_800_i16); } else if self.launcher_was_down { - self.fire_launcher(); + self.fire_launcher(launcher_ball); events.push(Event::Launch); if !self.tilted { events.push(Event::Sound(2002)); @@ -663,75 +695,18 @@ impl Game { fn timer_tick(&mut self, elapsed: f32, substeps: u8, events: &mut Vec) { self.update_claw(elapsed, events); - if !self.ball.in_launcher { + if self.balls.iter().any(|ball| !ball.in_launcher) { self.update_record_countdowns(); self.update_target_rotation(events); } let panel_active = self.update_panel_completion(events); - // A held claw ball still counts as the second live ball until the - // other slot disappears or the claw releases it. - if self.secondary_ball.is_none() - && self.claw.captured_slot != Some(ClawBallSlot::Secondary) - && self.score_mode == ScoreMode::Multiball - { + // A held claw ball stays in the list, so it still counts as a live + // ball until the claw releases it or the others drain. + if self.balls.len() < 2 { self.score_mode = ScoreMode::Normal; } if !panel_active { - let had_secondary_ball = self.secondary_ball.is_some(); - let mut spawned_secondary = None; - let primary_held = self.claw_holds_slot(ClawBallSlot::Primary); - let secondary_held = self.claw_holds_slot(ClawBallSlot::Secondary); - if had_secondary_ball { - self.score_mode = ScoreMode::Multiball; - } - if !primary_held { - for _ in 0..substeps { - let stop = self.fixed_update(events); - // Effect seven publishes its spawn request during record 149, - // but the original timer does not create slot two until the - // complete slot-one simulation pass has returned. - if !had_secondary_ball && spawned_secondary.is_none() { - spawned_secondary = self.secondary_ball.take(); - } - if stop || self.finished || self.claw_holds_slot(ClawBallSlot::Primary) { - break; - } - } - } - if had_secondary_ball && !self.finished { - if self.secondary_ball.is_some() && !self.claw_holds_slot(ClawBallSlot::Secondary) { - for _ in 0..substeps { - if self.advance_secondary_ball_slot(events, !primary_held) { - break; - } - } - } else if !self.claw_holds_slot(ClawBallSlot::Primary) - && !self.claw_holds_slot(ClawBallSlot::Secondary) - { - 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); - } - } - } - if let Some(spawned) = spawned_secondary { - debug_assert!(self.secondary_ball.is_none()); - self.secondary_ball = Some(spawned); - } - - // The two slots are mutually exclusive while the claw is holding - // a ball. - debug_assert!(!(primary_held && secondary_held)); - if !self.finished { - self.advance_crazy_balls(substeps, events); - } + self.advance_ball_slots(substeps, events); } self.pending_flipper_edges[0] = @@ -759,6 +734,54 @@ impl Game { } } + /// Runs one timer callback's physics batch for every ball, in slot + /// order. Each ball integrates up to `substeps` steps and stops after its + /// first collision, exactly like the original single-slot integrator. + fn advance_ball_slots(&mut self, substeps: u8, events: &mut Vec) { + let ball_count = self.balls.len(); + if ball_count > 1 { + self.score_mode = ScoreMode::Multiball; + } + let count = u16::try_from(ball_count).unwrap_or(u16::MAX); + let mut index = 0; + for number in 1..=count { + if self.finished || index >= self.balls.len() { + break; + } + if self.claw_holds(index) { + index += 1; + continue; + } + // A ball keeps the slot number it started the callback with, + // even when an earlier slot drained during this callback. + let pass = BallPass { + index, + number, + count, + }; + let mut removed = false; + for _ in 0..substeps { + match self.step_ball(pass, events) { + BallStep::Continue => {} + BallStep::Stop => break, + BallStep::Removed => { + removed = true; + break; + } + } + if self.finished || self.claw_holds(index) { + break; + } + } + if !removed { + index += 1; + } + } + // Effect seven publishes its spawn request during a slot pass, but + // the new ball only joins once every slot pass has returned. + self.balls.append(&mut self.spawned_balls); + } + fn update_record_countdowns(&mut self) { for countdown in &mut self.record_countdowns[1..] { if *countdown != 0 { @@ -823,7 +846,7 @@ impl Game { fn apply_nudge(&mut self, nudge: Nudge, events: &mut Vec) { const SCALAR: i32 = 15; events.push(Event::Sound(2019)); - let mut velocity = MilliVec::from_velocity_per_second(self.ball.velocity); + let mut velocity = MilliVec::from_velocity_per_second(self.balls[0].velocity); match nudge { Nudge::Left | Nudge::Right => { let amount = (50 - i32::from(self.random.below(20))) * SCALAR; @@ -849,19 +872,8 @@ impl Game { } Nudge::None => return, } - let primary_velocity = MilliVec::from_velocity_per_second(self.ball.velocity); - let impulse = MilliVec { - x: velocity.x.wrapping_sub(primary_velocity.x), - y: velocity.y.wrapping_sub(primary_velocity.y), - }; - for ball in &mut self.crazy_balls { - ball.velocity = MilliVec::from_velocity_per_second(ball.velocity) - .add(impulse) - .to_velocity_per_second(); - } - let primary_held = self.claw_holds_slot(ClawBallSlot::Primary); - let secondary_held = self.claw_holds_slot(ClawBallSlot::Secondary); - if let Some(secondary) = &mut self.secondary_ball { + let held = self.held_ball(); + if self.balls.len() > 1 { let slot_impulse = match nudge { Nudge::Left | Nudge::Right => MilliVec { x: if nudge == Nudge::Left { -600 } else { 600 }, @@ -877,20 +889,17 @@ impl Game { } Nudge::None => MilliVec::default(), }; - let mut primary_slot = MilliVec::from_velocity_per_second(self.ball.velocity); - primary_slot.x = primary_slot.x.wrapping_add(slot_impulse.x); - primary_slot.y = primary_slot.y.wrapping_add(slot_impulse.y); - if !primary_held { - self.ball.velocity = primary_slot.to_velocity_per_second(); + for (index, ball) in self.balls.iter_mut().enumerate() { + if held == Some(index) { + continue; + } + let mut slot = MilliVec::from_velocity_per_second(ball.velocity); + slot.x = slot.x.wrapping_add(slot_impulse.x); + slot.y = slot.y.wrapping_add(slot_impulse.y); + ball.velocity = slot.to_velocity_per_second(); } - let mut secondary_slot = MilliVec::from_velocity_per_second(secondary.velocity); - secondary_slot.x = secondary_slot.x.wrapping_add(slot_impulse.x); - secondary_slot.y = secondary_slot.y.wrapping_add(slot_impulse.y); - if !secondary_held { - secondary.velocity = secondary_slot.to_velocity_per_second(); - } - } else if !primary_held { - self.ball.velocity = velocity.to_velocity_per_second(); + } else if held != Some(0) { + self.balls[0].velocity = velocity.to_velocity_per_second(); } self.tilt_counter = self.tilt_counter.wrapping_add(25); @@ -908,70 +917,76 @@ impl Game { } } + /// Integrates one physics substep for the ball described by `pass`. + /// + /// This is the single per-ball integrator: every ball, whichever slot it + /// occupies, runs the same record scan, ball-to-ball contact, rule + /// dispatch, and drain handling. #[allow(clippy::too_many_lines)] - fn fixed_update(&mut self, events: &mut Vec) -> bool { - if self.ball.in_launcher { - self.ball.position = LAUNCHER_POSITION; - self.ball.velocity = Vec2::ZERO; - return true; + fn step_ball(&mut self, pass: BallPass, events: &mut Vec) -> BallStep { + let index = pass.index; + let mut ball = self.balls[index]; + if ball.in_launcher { + ball.position = LAUNCHER_POSITION; + ball.velocity = Vec2::ZERO; + self.balls[index] = ball; + return BallStep::Stop; } - let old_position = MilliVec::from_position(self.ball.position); - let initial_secondary = self.secondary_ball; - let ball_count = if initial_secondary.is_some() { 2 } else { 1 }; - let mut ball = self.ball; + 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); let speed_before_collision = milli_distance(velocity); if predicted_outside_board(old_position.add(velocity)) { - self.drain(events); - return true; + return self.lose_ball(index, events); } let (best_static, scan, predicted) = self.scan_ordered_records_for_ball( &mut ball, - 1, - ball_count, + pass, old_position, &mut velocity, events, ); let mut best_collision = best_static .map(|(id, is_wall, candidate)| (id, is_wall, candidate.resolve(velocity, ball.spin))); - let primary_held = self.claw_holds_slot(ClawBallSlot::Primary); - let secondary_held = self.claw_holds_slot(ClawBallSlot::Secondary); - let mut transferred_secondary_velocity = None; - if let Some(secondary) = initial_secondary - && !primary_held - && !secondary_held - { - let other_position = MilliVec::from_position(secondary.position); - let response = ball_collision_response( - old_position, - velocity, - ball.spin, - other_position, - MilliVec::from_velocity_per_second(secondary.velocity), - secondary.capture_age <= 0, - ); - if predicted.x >= other_position.x.wrapping_sub(21_000) - && predicted.x <= other_position.x.wrapping_add(21_000) - && predicted.y >= other_position.y.wrapping_sub(21_000) - && predicted.y <= other_position.y.wrapping_add(21_000) - && let Some(response) = response - && best_collision.is_none() - { - transferred_secondary_velocity = Some(response.other_velocity); - best_collision = Some(( - 175, - false, - CollisionResponse { - surface_distance: response.surface_distance, - velocity: response.moving_velocity, - spin: response.moving_spin, - auxiliary_fired: false, - }, - )); + + // Ball-to-ball contact only competes when no static record was hit. + // Objects 174 and 175 are the original's moving circle records for + // slot one and every later slot. + let mut transferred_velocity = None; + if best_collision.is_none() { + for (other_index, other) in self.balls.iter().enumerate() { + if other_index == index || self.claw_holds(other_index) { + continue; + } + let other_position = MilliVec::from_position(other.position); + if predicted.x >= other_position.x.wrapping_sub(21_000) + && predicted.x <= other_position.x.wrapping_add(21_000) + && predicted.y >= other_position.y.wrapping_sub(21_000) + && predicted.y <= other_position.y.wrapping_add(21_000) + && let Some(response) = ball_collision_response( + old_position, + velocity, + ball.spin, + other_position, + MilliVec::from_velocity_per_second(other.velocity), + other.capture_age <= 0, + ) + { + transferred_velocity = Some((other_index, response.other_velocity)); + best_collision = Some(( + if other_index == 0 { 174 } else { 175 }, + false, + CollisionResponse { + surface_distance: response.surface_distance, + velocity: response.moving_velocity, + spin: response.moving_spin, + auxiliary_fired: false, + }, + )); + break; + } } } let collided = best_collision.is_some(); @@ -979,90 +994,93 @@ impl Game { ball.capture_age = 0; } let mut auxiliary_fired = false; - let (hit_wall, hit_circle) = if let Some((object_id, is_wall, response)) = best_collision { + let mut hit = None; + if let Some((object_id, is_wall, response)) = best_collision { velocity = response.velocity; ball.spin = response.spin; auxiliary_fired = response.auxiliary_fired; self.last_collision_id = Some(object_id); - if object_id == 175 - && let (Some(secondary), Some(transferred)) = - (&mut self.secondary_ball, transferred_secondary_velocity) - && !secondary_held - && !primary_held - { - secondary.velocity = transferred.to_velocity_per_second(); - } - if is_wall { - (Some(object_id), None) - } else { - (None, Some(object_id)) - } - } else { - (None, None) - }; - if hit_wall == Some(25) && speed_before_collision >= 1_000 { - velocity.x = 0; + hit = Some((object_id, is_wall)); } - if hit_wall == Some(25) && speed_before_collision < 1_000 { + if let Some((other_index, transferred)) = transferred_velocity { + self.balls[other_index].velocity = transferred.to_velocity_per_second(); + } + if hit == Some((25, true)) { velocity.x = 0; - velocity.y = 0; + if speed_before_collision < 1_000 { + velocity.y = 0; + } } velocity.clamp_speed(MAXIMUM_SPEED_MILLI_PER_STEP); ball.position = old_position.add(velocity).to_position(); ball.velocity = velocity.to_velocity_per_second(); - self.ball = ball; + self.balls[index] = ball; - if let Some(wall_id) = hit_wall { - if wall_id == 2 { + match hit { + Some((2, true)) => { let special_respawn = self.special_respawn_pending(); - let response_spin = self.ball.spin; - self.drain(events); + let response_spin = ball.spin; + let step = self.lose_ball(index, events); if special_respawn { - let origin = MilliVec::from_position(self.ball.position); - self.ball.position = origin.add(velocity).to_position(); - self.ball.velocity = velocity.to_velocity_per_second(); - self.ball.spin = response_spin; + let respawned = &mut self.balls[index]; + let origin = MilliVec::from_position(respawned.position); + respawned.position = origin.add(velocity).to_position(); + respawned.velocity = velocity.to_velocity_per_second(); + respawned.spin = response_spin; } - return true; + return step; } - if wall_id == 25 && speed_before_collision < 1_000 { - self.ball = Ball::default(); - self.launcher_charge = 0.0; - self.launcher_was_down = false; - return true; + Some((25, true)) if speed_before_collision < 1_000 => { + return self.return_ball_to_launcher(index); } - self.apply_wall_rule(wall_id, events); - if auxiliary_fired && matches!(wall_id, 55 | 74 | 107) { - events.push(Event::Sound(2019)); + Some((wall_id, true)) => { + self.apply_wall_rule(wall_id, events); + if auxiliary_fired && matches!(wall_id, 55 | 74 | 107) { + events.push(Event::Sound(2019)); + } } - } - - if let Some(circle_id) = hit_circle { - self.apply_bumper_rule(circle_id, auxiliary_fired, events); + Some((circle_id, false)) => self.apply_bumper_rule(circle_id, auxiliary_fired, events), + None => {} } match scan.action { BallAction::Keep => {} - BallAction::Suspend => return true, + BallAction::Suspend => return BallStep::Stop, BallAction::Reset => { - self.reset_ball_to_launcher(); - return true; - } - BallAction::Finish => { - self.drain(events); - return true; + self.reset_ball_to_launcher(index); + return BallStep::Stop; } + BallAction::Finish => return self.lose_ball(index, events), BallAction::Remove => { - self.ball = self - .secondary_ball - .take() - .expect("a multiball capture must leave the other slot active"); - self.promote_claw_secondary_to_primary(); - return true; + // A capture during multiball takes the ball off the table + // without a drain. If the other balls already left during + // this callback, it was the last ball after all. + if self.balls.len() == 1 { + return self.lose_ball(index, events); + } + self.remove_ball(index); + return BallStep::Removed; } } - collided + if collided { + BallStep::Stop + } else { + BallStep::Continue + } + } + + /// Test shim: one substep of slot one, as the original slot-one + /// integrator. Returns whether its batch would stop. + #[cfg(test)] + fn fixed_update(&mut self, events: &mut Vec) -> bool { + let count = u16::try_from(self.balls.len()).unwrap_or(u16::MAX); + let pass = BallPass { + index: 0, + number: 1, + count, + }; + self.step_ball(pass, events) != BallStep::Continue } fn retain_static_range( @@ -1083,12 +1101,11 @@ impl Game { } } - #[allow(clippy::too_many_arguments, clippy::too_many_lines)] + #[allow(clippy::too_many_lines)] fn scan_ordered_records_for_ball( &mut self, ball: &mut Ball, - ball_number: u16, - ball_count: u16, + pass: BallPass, old_position: MilliVec, velocity: &mut MilliVec, events: &mut Vec, @@ -1112,8 +1129,7 @@ impl Game { ball.velocity = velocity.to_velocity_per_second(); match self.capture_record_step( ball, - ball_number, - ball_count, + pass, 89, CLAW_TRIGGER_CENTER, CLAW_TRIGGER_RADIUS, @@ -1123,9 +1139,9 @@ impl Game { events, ) { CaptureStep::Complete => { - let terminal_frame = self - .next_claw_terminal_frame(self.claw_capture_restricted_for(ball_number)); - self.begin_claw_capture_after_hold(ball, ball_number, terminal_frame, events); + let terminal_frame = + self.next_claw_terminal_frame(self.claw_capture_restricted_for(pass.index)); + self.begin_claw_capture_after_hold(ball, pass.index, terminal_frame, events); action = BallAction::Suspend; } CaptureStep::Outside | CaptureStep::Holding => {} @@ -1138,8 +1154,7 @@ impl Game { ball.velocity = velocity.to_velocity_per_second(); if let Some(completed_action) = self.check_lock_holes( ball, - ball_number, - ball_count, + pass, old_position, predicted, &mut capture_candidate, @@ -1169,8 +1184,7 @@ impl Game { } if let Some(completed_action) = self.check_special_hole( ball, - ball_number, - ball_count, + pass, old_position, predicted, &mut capture_candidate, @@ -1181,7 +1195,7 @@ impl Game { *velocity = MilliVec::from_velocity_per_second(ball.velocity); let effect_triggered = self.check_effect_sensor( ball, - ball_number, + pass.number, old_position, movement_from_prediction(old_position, predicted), events, @@ -1341,192 +1355,18 @@ impl Game { ) } + /// Test shim: one substep of slot two during a two-ball callback. #[cfg(test)] fn advance_secondary_ball(&mut self, events: &mut Vec) -> bool { - self.advance_secondary_ball_slot(events, !self.claw_holds_slot(ClawBallSlot::Primary)) - } - - #[allow(clippy::too_many_lines)] - fn advance_secondary_ball_slot( - &mut self, - events: &mut Vec, - primary_slot_active: bool, - ) -> bool { - let Some(mut ball) = self.secondary_ball.take() else { + if self.balls.len() < 2 { return true; + } + let pass = BallPass { + index: 1, + number: 2, + count: 2, }; - let primary_position = self.ball.position; - let primary_velocity = self.ball.velocity; - let primary_capture_age = self.ball.capture_age; - 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); - let speed_before_collision = milli_distance(velocity); - 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, - 2, - old_position, - &mut velocity, - events, - ); - let mut best_collision = best_static - .map(|(id, is_wall, candidate)| (id, is_wall, candidate.resolve(velocity, ball.spin))); - - let mut transferred_primary_velocity = None; - let other_position = MilliVec::from_position(primary_position); - if primary_slot_active - && !self.claw_holds_slot(ClawBallSlot::Primary) - && !self.claw_holds_slot(ClawBallSlot::Secondary) - && predicted.x >= other_position.x.wrapping_sub(21_000) - && predicted.x <= other_position.x.wrapping_add(21_000) - && predicted.y >= other_position.y.wrapping_sub(21_000) - && predicted.y <= other_position.y.wrapping_add(21_000) - && let Some(response) = ball_collision_response( - old_position, - velocity, - ball.spin, - other_position, - MilliVec::from_velocity_per_second(primary_velocity), - primary_capture_age <= 0, - ) - && best_collision.is_none() - { - transferred_primary_velocity = Some(response.other_velocity); - best_collision = Some(( - 174, - false, - CollisionResponse { - surface_distance: response.surface_distance, - velocity: response.moving_velocity, - spin: response.moving_spin, - auxiliary_fired: false, - }, - )); - } - let mut hit = None; - let collided = best_collision.is_some(); - if collided { - ball.capture_age = 0; - } - let mut auxiliary_fired = false; - if let Some((object_id, is_wall, response)) = best_collision { - velocity = response.velocity; - ball.spin = response.spin; - auxiliary_fired = response.auxiliary_fired; - hit = Some((object_id, is_wall)); - if object_id == 174 - && let Some(transferred) = transferred_primary_velocity - { - self.ball.velocity = transferred.to_velocity_per_second(); - } - } - if hit == Some((25, true)) && speed_before_collision >= 1_000 { - velocity.x = 0; - } - if hit == Some((25, true)) && speed_before_collision < 1_000 { - velocity.x = 0; - velocity.y = 0; - } - 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)) { - self.score_mode = ScoreMode::Normal; - events.push(Event::Drain); - return true; - } - if let Some((object_id, true)) = hit { - if object_id == 25 && speed_before_collision < 1_000 { - self.score_mode = ScoreMode::Normal; - return true; - } - self.apply_wall_rule(object_id, events); - if auxiliary_fired && matches!(object_id, 55 | 74 | 107) { - events.push(Event::Sound(2019)); - } - } else if let Some((object_id, false)) = hit { - self.apply_bumper_rule(object_id, auxiliary_fired, events); - } - match scan.action { - BallAction::Keep | BallAction::Suspend => self.secondary_ball = Some(ball), - BallAction::Reset | BallAction::Remove | BallAction::Finish => return true, - } - collided || scan.action == BallAction::Suspend - } - - fn advance_crazy_balls(&mut self, substeps: u8, events: &mut Vec) { - let mut balls = std::mem::take(&mut self.crazy_balls); - balls.retain_mut(|ball| { - for _ in 0..substeps { - match self.advance_crazy_ball(ball, events) { - None => return false, - Some(true) => break, - Some(false) => {} - } - } - true - }); - debug_assert!(self.crazy_balls.is_empty()); - self.crazy_balls = balls; - } - - /// Moves one crazy ball by one physics substep. Returns `None` once the - /// ball has left the table, otherwise whether it collided. - fn advance_crazy_ball(&mut self, ball: &mut Ball, events: &mut Vec) -> Option { - 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); - let speed_before_collision = milli_distance(velocity); - let predicted = old_position.add(velocity); - if predicted_outside_board(predicted) { - return None; - } - let mut best = None; - for record_ids in [1..=5, 7..=88, 90..=128, 134..=139, 155..=173] { - self.retain_static_range(old_position, predicted, velocity, record_ids, &mut best); - } - let mut hit = None; - let mut auxiliary_fired = false; - if let Some((object_id, is_wall, candidate)) = best { - let response = candidate.resolve(velocity, ball.spin); - velocity = response.velocity; - ball.spin = response.spin; - auxiliary_fired = response.auxiliary_fired; - hit = Some((object_id, is_wall)); - } - if hit == Some((25, true)) { - velocity.x = 0; - if speed_before_collision < 1_000 { - // Resting on the shooter stop would park it in the launcher. - return None; - } - } - velocity.clamp_speed(MAXIMUM_SPEED_MILLI_PER_STEP); - ball.position = old_position.add(velocity).to_position(); - ball.velocity = velocity.to_velocity_per_second(); - match hit { - Some((2, true)) => { - events.push(Event::Drain); - return None; - } - Some((object_id, true)) => { - self.apply_wall_rule(object_id, events); - if auxiliary_fired && matches!(object_id, 55 | 74 | 107) { - events.push(Event::Sound(2019)); - } - } - Some((object_id, false)) => self.apply_bumper_rule(object_id, auxiliary_fired, events), - None => {} - } - Some(hit.is_some()) + self.step_ball(pass, events) != BallStep::Continue } fn apply_bumper_rule(&mut self, object_id: u8, auxiliary_fired: bool, events: &mut Vec) { @@ -1668,30 +1508,30 @@ impl Game { movement_velocity: MilliVec, events: &mut Vec, ) -> BallAction { - let ball_count = if self.secondary_ball.is_some() { 2 } else { 1 }; - let mut ball = self.ball; + let count = u16::try_from(self.balls.len()).unwrap_or(u16::MAX); + let pass = BallPass { + index: 0, + number: 1, + count, + }; + let mut ball = self.balls[0]; let scan = self.check_sensor_objects_for_ball( &mut ball, - 1, - ball_count, + pass, old_position, movement_velocity, events, ); match scan.action { - BallAction::Keep | BallAction::Suspend => self.ball = ball, - BallAction::Reset => self.reset_ball_to_launcher(), + BallAction::Keep | BallAction::Suspend => self.balls[0] = ball, + BallAction::Reset => self.reset_ball_to_launcher(0), BallAction::Finish => { - self.ball = ball; - self.drain(events); - } - BallAction::Remove => { - self.ball = self - .secondary_ball - .take() - .expect("a multiball capture must leave the other slot active"); + self.balls[0] = ball; + self.lose_ball(0, events); } + BallAction::Remove => self.remove_ball(0), } + self.balls.append(&mut self.spawned_balls); scan.action } @@ -1699,8 +1539,7 @@ impl Game { fn check_sensor_objects_for_ball( &mut self, ball: &mut Ball, - ball_number: u16, - ball_count: u16, + pass: BallPass, old_position: MilliVec, movement_velocity: MilliVec, events: &mut Vec, @@ -1729,8 +1568,7 @@ impl Game { if !self.claw.active { match self.capture_record_step( ball, - ball_number, - ball_count, + pass, 89, CLAW_TRIGGER_CENTER, CLAW_TRIGGER_RADIUS, @@ -1741,9 +1579,9 @@ impl Game { ) { CaptureStep::Holding | CaptureStep::Outside => {} CaptureStep::Complete => { - let terminal_frame = self - .next_claw_terminal_frame(self.claw_capture_restricted_for(ball_number)); - self.begin_claw_capture_after_hold(ball, ball_number, terminal_frame, events); + let terminal_frame = + self.next_claw_terminal_frame(self.claw_capture_restricted_for(pass.index)); + self.begin_claw_capture_after_hold(ball, pass.index, terminal_frame, events); action = BallAction::Suspend; } } @@ -1751,8 +1589,7 @@ impl Game { if let Some(completed_action) = self.check_lock_holes( ball, - ball_number, - ball_count, + pass, old_position, predicted_position, &mut capture_candidate, @@ -1763,8 +1600,7 @@ impl Game { self.check_target_sensors(ball, old_position, movement_velocity, 140..=147, events); if let Some(completed_action) = self.check_special_hole( ball, - ball_number, - ball_count, + pass, old_position, predicted_position, &mut capture_candidate, @@ -1772,7 +1608,7 @@ impl Game { ) { action = completed_action; } - self.check_effect_sensor(ball, ball_number, old_position, movement_velocity, events); + self.check_effect_sensor(ball, pass.number, old_position, movement_velocity, events); self.check_target_sensors(ball, old_position, movement_velocity, 150..=152, events); SensorScanResult { action } } @@ -1785,8 +1621,7 @@ impl Game { fn capture_record_step( &mut self, ball: &mut Ball, - ball_number: u16, - ball_count: u16, + pass: BallPass, record_id: u8, center: Vec2, radius: f32, @@ -1795,6 +1630,11 @@ impl Game { best_capture: &mut Option<(u8, StaticCollisionCandidate)>, events: &mut Vec, ) -> CaptureStep { + let BallPass { + index: ball_index, + number: ball_number, + count: ball_count, + } = pass; let current_position = MilliVec::from_position(ball.position); let center_view = center; let center = MilliVec::from_position(center_view); @@ -1810,7 +1650,7 @@ impl Game { && ball.capture_age == 0 && self.record_contacts[usize::from(record_id)] == 0 { - self.clear_claw_capture_entry_for(ball_number); + self.clear_claw_capture_entry_for(ball_index); } return CaptureStep::Outside; } @@ -1833,8 +1673,7 @@ impl Game { // Latch the restriction when the ball first enters the deep // capture zone, before the pull/hold phase can outlive the // other active ball. - self.claw.capture_entry = - Some((ClawBallSlot::from_ball_number(ball_number), ball_count > 1)); + self.claw.capture_entry = Some((ball_index, ball_count > 1)); } if ball.capture_age < 300 { let mut velocity = MilliVec::from_velocity_per_second(ball.velocity); @@ -1891,16 +1730,26 @@ impl Game { } if record_id == 89 && surface_distance >= -11_000 { - self.clear_claw_capture_entry_for(ball_number); + self.clear_claw_capture_entry_for(ball_index); } if surface_distance > -11_000 && contact == ball_number { self.record_contacts[contact_index] = 0; ball.capture_age = 0; } + // Deliberate original-game divergence: a ball that was settling into + // a lock hole when another ball filled it would bounce off the rim + // from the inside forever. With many balls that is common, so the + // rim of a filled hole only repels balls moving towards its center. let velocity = MilliVec::from_velocity_per_second(ball.velocity); + let leaving_filled_hole = (129..=133).contains(&record_id) + && contact == 99 + && i64::from(velocity.x) * i64::from(current_position.x.wrapping_sub(center.x)) + + i64::from(velocity.y) * i64::from(current_position.y.wrapping_sub(center.y)) + > 0; if surface_distance <= milli_distance(velocity) && surface_distance >= -11_000 && contact != 0 + && !leaving_filled_hole && let Some(candidate) = capture_collision_candidate( current_position, velocity, @@ -1919,8 +1768,7 @@ impl Game { fn check_lock_holes( &mut self, ball: &mut Ball, - ball_number: u16, - ball_count: u16, + pass: BallPass, old_position: MilliVec, predicted_position: MilliVec, best_capture: &mut Option<(u8, StaticCollisionCandidate)>, @@ -1929,8 +1777,7 @@ impl Game { for (index, sensor) in LOCK_HOLES.into_iter().enumerate() { match self.capture_record_step( ball, - ball_number, - ball_count, + pass, sensor.id, sensor.center, sensor.radius, @@ -1942,7 +1789,7 @@ impl Game { CaptureStep::Outside | CaptureStep::Holding => continue, CaptureStep::Complete => {} } - return Some(self.complete_lock_hole(index, ball_count, events)); + return Some(self.complete_lock_hole(index, pass.count, events)); } None } @@ -1997,8 +1844,7 @@ impl Game { fn check_special_hole( &mut self, ball: &mut Ball, - ball_number: u16, - ball_count: u16, + pass: BallPass, old_position: MilliVec, predicted_position: MilliVec, best_capture: &mut Option<(u8, StaticCollisionCandidate)>, @@ -2006,8 +1852,7 @@ impl Game { ) -> Option { match self.capture_record_step( ball, - ball_number, - ball_count, + pass, SPECIAL_HOLE_SENSOR.id, SPECIAL_HOLE_SENSOR.center, SPECIAL_HOLE_SENSOR.radius, @@ -2021,7 +1866,7 @@ impl Game { } self.multiball_state = MultiballState::Ready; events.push(Event::Wheel); - Some(if ball_count == 1 { + Some(if pass.count == 1 { if self.tilted { BallAction::Finish } else { @@ -2079,13 +1924,14 @@ impl Game { 7 if self.record_contacts[usize::from(SPECIAL_HOLE_SENSOR.id)] != 0 && self.record_contacts[usize::from(SPECIAL_HOLE_SENSOR.id)] != ball_number - && self.secondary_ball.is_none() + && self.balls.len() == 1 + && self.spawned_balls.is_empty() && matches!( self.multiball_state, MultiballState::Ready | MultiballState::Active ) => { - self.secondary_ball = Some(Ball { + self.spawned_balls.push(Ball { position: vec2(17.0, 23.0), velocity: MilliVec { x: 0, y: 3_040 }.to_velocity_per_second(), in_launcher: false, @@ -2170,7 +2016,7 @@ impl Game { ball.velocity = velocity.to_velocity_per_second(); } - fn reset_ball_to_launcher(&mut self) { + fn reset_ball_to_launcher(&mut self, index: usize) { if self.record_contacts[129..=133] .iter() .all(|contact| *contact != 0) @@ -2179,7 +2025,7 @@ impl Game { } self.tilted = false; self.tilt_counter = 0; - self.ball = Ball::default(); + self.balls[index] = Ball::default(); self.launcher_charge = 0.0; self.launcher_was_down = false; self.launcher_hold_seconds = 0.0; @@ -2269,55 +2115,88 @@ impl Game { } } - fn claw_holds_slot(&self, slot: ClawBallSlot) -> bool { - self.claw.ball_suspended && self.claw.captured_slot == Some(slot) + fn held_ball(&self) -> Option { + self.claw + .ball_suspended + .then_some(self.claw.captured_slot) + .flatten() } - fn claw_capture_restricted_for(&self, ball_number: u16) -> bool { - self.claw.capture_entry == Some((ClawBallSlot::from_ball_number(ball_number), true)) + fn claw_holds(&self, index: usize) -> bool { + self.held_ball() == Some(index) } - fn clear_claw_capture_entry_for(&mut self, ball_number: u16) { - let slot = ClawBallSlot::from_ball_number(ball_number); + fn claw_capture_restricted_for(&self, index: usize) -> bool { + self.claw.capture_entry == Some((index, true)) + } + + fn clear_claw_capture_entry_for(&mut self, index: usize) { if self .claw .capture_entry - .is_some_and(|(owner, _)| owner == slot) + .is_some_and(|(owner, _)| owner == index) { self.claw.capture_entry = None; } } - fn promote_claw_secondary_to_primary(&mut self) { - if self.claw.captured_slot == Some(ClawBallSlot::Secondary) { - self.claw.captured_slot = Some(ClawBallSlot::Primary); - if let Some((ClawBallSlot::Secondary, restricted)) = self.claw.capture_entry { - self.claw.capture_entry = Some((ClawBallSlot::Primary, restricted)); + /// Takes the ball at `index` out of play without any drain rules and + /// keeps every index-based reference pointing at the same physical ball. + fn remove_ball(&mut self, index: usize) { + self.balls.remove(index); + let shift = |slot: Option| match slot { + Some(slot) if slot == index => None, + Some(slot) if slot > index => Some(slot - 1), + slot => slot, + }; + self.claw.captured_slot = shift(self.claw.captured_slot); + self.claw.capture_entry = self + .claw + .capture_entry + .and_then(|(owner, restricted)| shift(Some(owner)).map(|owner| (owner, restricted))); + // The claw and lock-hole contacts name their owner by slot number; + // later slots move down one number with their ball. A lock hole the + // removed ball was still settling into is free again. + let removed_number = index + 1; + let claw_contact = &mut self.record_contacts[89]; + if *claw_contact != 99 && usize::from(*claw_contact) > removed_number { + *claw_contact -= 1; + } + for contact in &mut self.record_contacts[129..=133] { + if *contact == 99 || *contact == 0 { + continue; } - if self.record_contacts[89] == 2 { - self.record_contacts[89] = 1; + match usize::from(*contact).cmp(&removed_number) { + std::cmp::Ordering::Equal => *contact = 0, + std::cmp::Ordering::Greater => *contact -= 1, + std::cmp::Ordering::Less => {} } - self.score_mode = ScoreMode::Normal; } } - pub fn primary_ball_suspended(&self) -> bool { - self.claw_holds_slot(ClawBallSlot::Primary) - } - - pub fn secondary_ball_suspended(&self) -> bool { - self.claw_holds_slot(ClawBallSlot::Secondary) + /// A ball came to rest on the shooter stop. It waits in the launcher + /// unless another ball already occupies it. + fn return_ball_to_launcher(&mut self, index: usize) -> BallStep { + if self.launcher_ball().is_some_and(|waiting| waiting != index) { + self.remove_ball(index); + if self.balls.len() == 1 { + self.score_mode = ScoreMode::Normal; + } + return BallStep::Removed; + } + self.balls[index] = Ball::default(); + self.launcher_charge = 0.0; + self.launcher_was_down = false; + BallStep::Stop } fn apply_flipper_kicks(&mut self) { let pending = self.pending_flipper_edges; self.pending_flipper_edges.fill(0); - if self.ball.in_launcher || self.tilted { + if self.tilted { return; } - let primary_held = self.claw_holds_slot(ClawBallSlot::Primary); - let secondary_held = self.claw_holds_slot(ClawBallSlot::Secondary); - + let held = self.held_ball(); for (delta, side) in [ (i32::from(pending[0]), FlipperSide::Left), (i32::from(pending[1]), FlipperSide::Right), @@ -2325,16 +2204,10 @@ impl Game { if delta == 0 { continue; } - if !primary_held { - apply_flipper_response_to_ball(&mut self.ball, delta, side); - } - if let Some(secondary) = &mut self.secondary_ball - && !secondary_held - { - apply_flipper_response_to_ball(secondary, delta, side); - } - for ball in &mut self.crazy_balls { - apply_flipper_response_to_ball(ball, delta, side); + for (index, ball) in self.balls.iter_mut().enumerate() { + if !ball.in_launcher && held != Some(index) { + apply_flipper_response_to_ball(ball, delta, side); + } } } } @@ -2349,11 +2222,11 @@ impl Game { self.claw.target_frame = terminal_frame; self.claw.bank = ClawSpriteBank::Closing; self.claw.ball_suspended = true; - self.claw.captured_slot = Some(ClawBallSlot::Primary); + self.claw.captured_slot = Some(0); self.claw.capture_entry = None; self.claw.frame_accumulator = 0.0; - self.ball.velocity = Vec2::ZERO; - self.ball.spin = Real48::ZERO; + self.balls[0].velocity = Vec2::ZERO; + self.balls[0].spin = Real48::ZERO; events.push(Event::ClawCapture); events.push(Event::Sound(2015)); } @@ -2361,7 +2234,7 @@ impl Game { fn begin_claw_capture_after_hold( &mut self, ball: &mut Ball, - ball_number: u16, + index: usize, terminal_frame: u8, events: &mut Vec, ) { @@ -2373,7 +2246,7 @@ impl Game { self.claw.target_frame = terminal_frame; self.claw.bank = ClawSpriteBank::Closing; self.claw.ball_suspended = true; - self.claw.captured_slot = Some(ClawBallSlot::from_ball_number(ball_number)); + self.claw.captured_slot = Some(index); self.claw.frame_accumulator = 0.0; ball.velocity = Vec2::ZERO; ball.spin = Real48::ZERO; @@ -2412,23 +2285,12 @@ impl Game { self.claw.target_frame = 10; self.claw.bank = ClawSpriteBank::Opening; let release = claw_release(release_frame); - match self.claw.captured_slot { - Some(ClawBallSlot::Primary) => { - (self.ball.position, self.ball.velocity) = release; - } - Some(ClawBallSlot::Secondary) => { - if let Some(ball) = &mut self.secondary_ball { - (ball.position, ball.velocity) = release; - } else { - (self.ball.position, self.ball.velocity) = release; - self.promote_claw_secondary_to_primary(); - } - } - None => { - (self.ball.position, self.ball.velocity) = release; - self.claw.captured_slot = Some(ClawBallSlot::Primary); - } - } + let index = match self.claw.captured_slot { + Some(index) if index < self.balls.len() => index, + _ => 0, + }; + self.claw.captured_slot = Some(index); + (self.balls[index].position, self.balls[index].velocity) = release; self.claw.ball_suspended = false; events.push(Event::ClawRelease); events.push(Event::Sound(2016)); @@ -2462,24 +2324,20 @@ impl Game { } } - fn drain(&mut self, events: &mut Vec) { - if self.claw_holds_slot(ClawBallSlot::Secondary) { - self.ball = self - .secondary_ball - .take() - .expect("the claw-held secondary ball must remain in slot two"); - self.promote_claw_secondary_to_primary(); + /// The ball at `index` drained. While other balls remain it simply + /// leaves the table; the last ball either takes the special respawn or + /// ends the player's ball. + fn lose_ball(&mut self, index: usize, events: &mut Vec) -> BallStep { + if self.balls.len() > 1 { + self.remove_ball(index); + if self.balls.len() == 1 { + self.score_mode = ScoreMode::Normal; + } events.push(Event::Drain); - return; - } - if let Some(remaining_ball) = self.secondary_ball.take() { - self.ball = remaining_ball; - self.score_mode = ScoreMode::Normal; - events.push(Event::Drain); - return; + return BallStep::Removed; } if self.special_respawn_pending() { - self.ball = Ball { + self.balls[index] = Ball { position: vec2(17.0, 23.0), velocity: MilliVec { x: 0, y: 3_040 }.to_velocity_per_second(), in_launcher: false, @@ -2489,13 +2347,20 @@ impl Game { self.multiball_state = MultiballState::Unavailable; self.record_contacts[148] = 0; self.special_hole_gate = SpecialHoleGate::Suppressed; - return; - } - if let Some(crazy_ball) = self.crazy_balls.pop() { - self.ball = crazy_ball; - events.push(Event::Drain); - return; + return BallStep::Stop; } + self.end_ball(events); + BallStep::Stop + } + + /// Test shim: slot one drains. + #[cfg(test)] + fn drain(&mut self, events: &mut Vec) { + self.lose_ball(0, events); + } + + fn end_ball(&mut self, events: &mut Vec) { + self.spawned_balls.clear(); self.ball_double = BallDoubleState::Inactive; self.multiball_state = MultiballState::Unavailable; self.special_hole_gate = SpecialHoleGate::Enabled; @@ -2523,7 +2388,6 @@ impl Game { } self.tilted = false; self.tilt_counter = 0; - self.secondary_ball = None; self.score_mode = ScoreMode::Normal; self.launcher_charge = 0.0; self.launcher_was_down = false; @@ -2536,7 +2400,7 @@ impl Game { if self.players[next].balls > 0 || self.players[next].extra_balls > 0 { self.current_player = next; self.load_current_rule_state(); - self.ball = Ball::default(); + self.balls = vec![Ball::default()]; return; } next = (next + 1) % self.players.len(); @@ -2545,7 +2409,7 @@ impl Game { } fn special_respawn_pending(&self) -> bool { - self.secondary_ball.is_none() + self.balls.len() == 1 && self.score_mode == ScoreMode::Normal && self.record_contacts[usize::from(SPECIAL_HOLE_SENSOR.id)] != 0 && matches!( @@ -2675,6 +2539,33 @@ fn apply_flipper_response_to_ball(ball: &mut Ball, delta: i32, side: FlipperSide } } +#[cfg(test)] +impl BallPass { + fn slot(number: u16, count: u16) -> Self { + Self { + index: usize::from(number) - 1, + number, + count, + } + } +} + +#[cfg(test)] +impl Game { + fn secondary(&self) -> Option { + self.balls.get(1).copied() + } + + fn set_secondary(&mut self, ball: Option) { + self.balls.truncate(1); + self.balls.extend(ball); + } + + fn take_secondary(&mut self) -> Option { + (self.balls.len() > 1).then(|| self.balls.remove(1)) + } +} + #[allow(clippy::too_many_lines)] #[cfg(test)] mod tests { @@ -2700,9 +2591,9 @@ mod tests { events: &mut Vec, ) { for _ in 0..=60 { - game.ball.in_launcher = false; - game.ball.position = center; - game.ball.velocity = Vec2::ZERO; + game.balls[0].in_launcher = false; + game.balls[0].position = center; + game.balls[0].velocity = Vec2::ZERO; game.check_sensor_objects(MilliVec::from_position(center), MilliVec::default(), events); } } @@ -2715,14 +2606,13 @@ mod tests { previous_position: MilliVec, events: &mut Vec, ) -> CaptureStep { - let mut ball = game.ball; + let mut ball = game.balls[0]; let mut capture_candidate = None; let predicted_position = previous_position.add(MilliVec::from_velocity_per_second(ball.velocity)); let result = game.capture_record_step( &mut ball, - 1, - if game.secondary_ball.is_some() { 2 } else { 1 }, + BallPass::slot(1, if game.secondary().is_some() { 2 } else { 1 }), record_id, center, radius, @@ -2731,7 +2621,7 @@ mod tests { &mut capture_candidate, events, ); - game.ball = ball; + game.balls[0] = ball; result } @@ -2751,52 +2641,78 @@ mod tests { } #[test] - fn crazy_mode_adds_ten_balls_that_fall_and_score() { + fn crazy_mode_adds_ten_ordinary_balls_and_marks_the_player() { let mut game = Game::new_with_seed(1, 7); assert!(game.add_crazy_balls()); assert!(game.player().crazy_mode_used()); - assert_eq!(game.crazy_balls.len(), 10); - let start: Vec<_> = game.crazy_balls.iter().map(|ball| ball.position).collect(); + assert_eq!(game.balls.len(), 11); + assert!(game.balls[0].in_launcher); + let start: Vec<_> = game.balls.iter().map(|ball| ball.position).collect(); for _ in 0..20 { game.update(0.03, 3, Controls::default()); } - assert!(game.ball.in_launcher); + assert!(game.balls[0].in_launcher); assert!( - game.crazy_balls + game.balls[1..] .iter() - .zip(&start) + .zip(&start[1..]) .all(|(ball, start)| ball.position != *start) ); - for _ in 0..2_000 { + for _ in 0..3_000 { game.update(0.03, 3, Controls::default()); } - assert!(game.crazy_balls.is_empty()); + // Every dropped ball drained; the waiting ball never counted as lost. + assert_eq!(game.balls.len(), 1); + assert!(game.balls[0].in_launcher); assert!(game.player().score > 0); assert_eq!(game.player().balls, 3); } #[test] - fn draining_primary_ball_promotes_a_crazy_ball() { + fn a_ball_moves_the_same_in_any_slot() { + let ball = Ball { + position: vec2(120.0, 160.0), + velocity: vec2(40.0, -120.0), + in_launcher: false, + ..Ball::default() + }; + let mut alone = Game::new_with_seed(1, 7); + alone.balls = vec![ball]; + let mut behind_launcher = Game::new_with_seed(1, 7); + behind_launcher.balls = vec![Ball::default(), ball]; + for _ in 0..150 { + alone.update(0.03, 3, Controls::default()); + behind_launcher.update(0.03, 3, Controls::default()); + if alone.balls[0].in_launcher || behind_launcher.balls.len() < 2 { + break; + } + assert_eq!(alone.balls[0].position, behind_launcher.balls[1].position); + assert_eq!(alone.balls[0].velocity, behind_launcher.balls[1].velocity); + } + } + + #[test] + fn the_turn_ends_only_when_the_last_ball_drains() { let mut game = Game::new_with_seed(1, 7); - game.ball.in_launcher = false; + game.balls[0].in_launcher = false; game.add_crazy_balls(); let mut events = Vec::new(); game.drain(&mut events); - assert_eq!(game.crazy_balls.len(), 9); - assert_eq!(game.ball.position, CRAZY_BALL_POSITIONS[9]); - assert!(!game.ball.in_launcher); + assert_eq!(game.balls.len(), 10); + assert_eq!(game.balls[0].position, CRAZY_BALL_POSITIONS[0]); assert_eq!(game.player().balls, 3); - game.crazy_balls.clear(); + game.balls.truncate(1); game.drain(&mut events); assert_eq!(game.player().balls, 2); - assert!(game.ball.in_launcher); + assert_eq!(game.balls.len(), 1); + assert!(game.balls[0].in_launcher); } #[test] fn crazy_scores_are_flagged_on_the_high_score_candidate() { let mut game = Game::new_with_seed(1, 7); game.add_crazy_balls(); - game.crazy_balls.clear(); + game.balls.truncate(1); game.players[0].balls = 1; game.players[0].score = 1_234; let mut events = Vec::new(); @@ -2808,12 +2724,16 @@ mod tests { } #[test] - fn finished_game_rejects_crazy_mode() { + fn crazy_mode_is_capped_and_rejected_after_the_game() { let mut game = Game::new_with_seed(1, 7); - game.finished = true; - assert!(!game.add_crazy_balls()); - assert!(game.crazy_balls.is_empty()); - assert!(!game.player().crazy_mode_used()); + while game.add_crazy_balls() {} + assert_eq!(game.balls.len(), MAX_BALLS); + + let mut finished = Game::new_with_seed(1, 7); + finished.finished = true; + assert!(!finished.add_crazy_balls()); + assert_eq!(finished.balls.len(), 1); + assert!(!finished.player().crazy_mode_used()); } #[test] @@ -2842,8 +2762,8 @@ mod tests { fn ball_starts_in_the_recovered_shooter_lane() { let game = Game::new(1); - assert_eq!(game.ball.position, Vec2::new(325.0, 413.0)); - assert!(game.ball.in_launcher); + assert_eq!(game.balls[0].position, Vec2::new(325.0, 413.0)); + assert!(game.balls[0].in_launcher); } #[test] @@ -2856,9 +2776,9 @@ mod tests { let mut game = Game::new(1); game.object_active.fill(false); game.object_active[usize::from(wall.id)] = true; - game.ball.in_launcher = false; - game.ball.position = midpoint - normal; - game.ball.velocity = + game.balls[0].in_launcher = false; + game.balls[0].position = midpoint - normal; + game.balls[0].velocity = MilliVec::from_velocity_per_second(normal * 200.0).to_velocity_per_second(); game.fixed_update(&mut Vec::new()); if game.last_collision_id != Some(wall.id) { @@ -2871,9 +2791,9 @@ mod tests { let mut game = Game::new(1); game.object_active.fill(false); game.object_active[usize::from(circle.id)] = true; - game.ball.in_launcher = false; - game.ball.position = circle.center + vec2(circle.contact_radius + 1.0, 0.0); - game.ball.velocity = MilliVec { x: -2_000, y: 0 }.to_velocity_per_second(); + game.balls[0].in_launcher = false; + game.balls[0].position = circle.center + vec2(circle.contact_radius + 1.0, 0.0); + game.balls[0].velocity = MilliVec { x: -2_000, y: 0 }.to_velocity_per_second(); game.fixed_update(&mut Vec::new()); if game.last_collision_id != Some(circle.id) { missed_circles.push(circle.id); @@ -2900,18 +2820,18 @@ mod tests { (5, 0.010, 1), ] { let mut game = Game::new(1); - game.ball.in_launcher = false; - game.ball.position = vec2(200.0, 250.0); - game.ball.velocity = Vec2::ZERO; + game.balls[0].in_launcher = false; + game.balls[0].position = vec2(200.0, 250.0); + game.balls[0].velocity = Vec2::ZERO; game.object_active.fill(false); game.update(interval - 0.001, detail, Controls::default()); - assert_eq!(game.ball.position, vec2(200.0, 250.0)); - assert_eq!(game.ball.velocity, Vec2::ZERO); + assert_eq!(game.balls[0].position, vec2(200.0, 250.0)); + assert_eq!(game.balls[0].velocity, Vec2::ZERO); game.update(0.001_1, detail, Controls::default()); - let velocity = MilliVec::from_velocity_per_second(game.ball.velocity); - let position = MilliVec::from_position(game.ball.position); + let velocity = MilliVec::from_velocity_per_second(game.balls[0].velocity); + let position = MilliVec::from_position(game.balls[0].position); assert_eq!(velocity.y, 15 * substeps); assert_eq!(position.y, 250_000 + 15 * substeps * (substeps + 1) / 2); } @@ -2920,9 +2840,9 @@ 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); + game.balls[0].in_launcher = false; + game.balls[0].position = vec2(270.0, 30.0); + game.balls[0].velocity = vec2(0.0, -380.0); let mut expected = game.clone(); let mut expected_events = Vec::new(); let mut stopped = false; @@ -2937,17 +2857,17 @@ mod tests { 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!(game.balls[0].position, expected.balls[0].position); + assert_eq!(game.balls[0].velocity, expected.balls[0].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.balls[0].in_launcher = false; + game.balls[0].position = EFFECT_SENSOR.center; + game.balls[0].velocity = Vec2::ZERO; game.object_active.fill(false); game.object_active[usize::from(EFFECT_SENSOR.id)] = true; game.multiball_state = MultiballState::Ready; @@ -2957,7 +2877,7 @@ mod tests { game.timer_tick(0.050, 5, &mut Vec::new()); let spawned = game - .secondary_ball + .secondary() .expect("the effect-seven request must create slot two after simulation"); assert_eq!(spawned.position, vec2(17.0, 23.0)); assert_eq!( @@ -2970,30 +2890,30 @@ mod tests { 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.balls[0].in_launcher = false; + game.balls[0].position = lock.center; + game.balls[0].velocity = Vec2::ZERO; + game.balls[0].capture_age = 300; game.record_contacts[usize::from(lock.id)] = 1; - game.secondary_ball = Some(Ball { + game.set_secondary(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!(game.secondary().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); + assert!(game.balls[0].position.y > EFFECT_SENSOR.center.y); } #[test] @@ -3008,14 +2928,14 @@ mod tests { }, ); - assert!(game.ball.in_launcher); + assert!(game.balls[0].in_launcher); assert!(game.launcher_charge > 0.0); let events = game.update(1.0 / 60.0, 3, Controls::default()); assert!(events.contains(&Event::Launch)); - assert!(!game.ball.in_launcher); - assert!(game.ball.velocity.y < 0.0); + assert!(!game.balls[0].in_launcher); + assert!(game.balls[0].velocity.y < 0.0); } #[test] @@ -3027,15 +2947,15 @@ mod tests { }; quick.update(0.0, 3, held); quick.update(0.0, 3, Controls::default()); - let quick_speed = -quick.ball.velocity.y; + let quick_speed = -quick.balls[0].velocity.y; assert_eq!( - MilliVec::from_velocity_per_second(quick.ball.velocity), + MilliVec::from_velocity_per_second(quick.balls[0].velocity), MilliVec { x: 0, y: -2_270 } ); let mut charged = Game::new_with_seed(1, 7); launch_ball(&mut charged, 60); - let charged_speed = -charged.ball.velocity.y; + let charged_speed = -charged.balls[0].velocity.y; assert!(charged_speed > quick_speed + 80.0); } @@ -3069,7 +2989,7 @@ mod tests { launch_ball(&mut first, 60); launch_ball(&mut second, 60); - assert_ne!(first.ball.velocity, second.ball.velocity); + assert_ne!(first.balls[0].velocity, second.balls[0].velocity); } #[test] @@ -3079,21 +2999,21 @@ mod tests { launch_ball(&mut game, 60); let mut entered_table = false; let mut returned_to_launcher = false; - let mut minimum_y = game.ball.position.y; + let mut minimum_y = game.balls[0].position.y; for _ in 0..480 { game.update(1.0 / 120.0, detail, Controls::default()); - minimum_y = minimum_y.min(game.ball.position.y); - if game.ball.position.x < 280.0 && game.ball.position.y > 30.0 { + minimum_y = minimum_y.min(game.balls[0].position.y); + if game.balls[0].position.x < 280.0 && game.balls[0].position.y > 30.0 { entered_table = true; } - returned_to_launcher |= game.ball.in_launcher && !entered_table; + returned_to_launcher |= game.balls[0].in_launcher && !entered_table; } assert!( entered_table, "detail {detail}: the shooter curve should guide the ball onto the table; position={:?}, velocity={:?}, minimum_y={minimum_y}", - game.ball.position, game.ball.velocity + game.balls[0].position, game.balls[0].velocity ); assert!( !returned_to_launcher, @@ -3105,20 +3025,54 @@ mod tests { #[test] fn shooter_stop_rearms_a_returning_ball() { let mut game = Game::new(1); - game.ball.in_launcher = false; - game.ball.position = Vec2::new(325.0, 410.0); - game.ball.velocity = Vec2::new(0.0, 80.0); + game.balls[0].in_launcher = false; + game.balls[0].position = Vec2::new(325.0, 410.0); + game.balls[0].velocity = Vec2::new(0.0, 80.0); for _ in 0..8 { game.update(1.0 / 120.0, 5, Controls::default()); - if game.ball.in_launcher { + if game.balls[0].in_launcher { break; } } - assert!(game.ball.in_launcher); - assert_eq!(game.ball.position, LAUNCHER_POSITION); - assert_eq!(game.ball.velocity, Vec2::ZERO); + assert!(game.balls[0].in_launcher); + assert_eq!(game.balls[0].position, LAUNCHER_POSITION); + assert_eq!(game.balls[0].velocity, Vec2::ZERO); + } + + #[test] + fn shooter_stop_rearms_a_returning_ball_from_any_slot() { + let returning = Ball { + position: Vec2::new(325.0, 410.0), + velocity: Vec2::new(0.0, 80.0), + in_launcher: false, + ..Ball::default() + }; + let mut game = Game::new_with_seed(1, 7); + game.balls = vec![ + Ball { + position: vec2(170.0, 112.0), + in_launcher: false, + ..Ball::default() + }, + returning, + ]; + for _ in 0..8 { + game.update(1.0 / 120.0, 5, Controls::default()); + } + assert_eq!(game.balls.len(), 2); + assert!(game.balls[1].in_launcher); + + // With the launcher already occupied the ball leaves the table. + let mut occupied = Game::new_with_seed(1, 7); + occupied.balls = vec![Ball::default(), returning]; + for _ in 0..8 { + occupied.update(1.0 / 120.0, 5, Controls::default()); + } + assert_eq!(occupied.balls.len(), 1); + assert!(occupied.balls[0].in_launcher); + assert_eq!(occupied.player().balls, 3); } #[test] @@ -3126,16 +3080,19 @@ mod tests { let mut game = Game::new(1); game.object_active.fill(false); game.object_active[25] = true; - game.ball.in_launcher = false; - game.ball.position = Vec2::new(320.0, 412.0); - game.ball.velocity = MilliVec { x: 500, y: 1_000 }.to_velocity_per_second(); + game.balls[0].in_launcher = false; + game.balls[0].position = Vec2::new(320.0, 412.0); + game.balls[0].velocity = MilliVec { x: 500, y: 1_000 }.to_velocity_per_second(); assert!(game.fixed_update(&mut Vec::new())); - assert!(!game.ball.in_launcher); - assert!(game.ball.velocity.y < 0.0); - assert_eq!(MilliVec::from_velocity_per_second(game.ball.velocity).x, 0); - assert_eq!(MilliVec::from_position(game.ball.position).x, 320_000); + assert!(!game.balls[0].in_launcher); + assert!(game.balls[0].velocity.y < 0.0); + assert_eq!( + MilliVec::from_velocity_per_second(game.balls[0].velocity).x, + 0 + ); + assert_eq!(MilliVec::from_position(game.balls[0].position).x, 320_000); } #[test] @@ -3144,11 +3101,11 @@ mod tests { game.tilted = true; game.tilt_counter = 39; - game.reset_ball_to_launcher(); + game.reset_ball_to_launcher(0); assert!(!game.tilted); assert_eq!(game.tilt_counter, 0); - assert!(game.ball.in_launcher); + assert!(game.balls[0].in_launcher); } #[test] @@ -3221,7 +3178,7 @@ mod tests { special.record_contacts[148] = 2; special.drain(&mut Vec::new()); assert_eq!(special.ball_double, BallDoubleState::Active); - assert_eq!(special.ball.position, vec2(17.0, 23.0)); + assert_eq!(special.balls[0].position, vec2(17.0, 23.0)); } #[test] @@ -3244,10 +3201,10 @@ mod tests { fn tilted_type_three_completion_finishes_or_special_respawns() { let mut lock = Game::new(1); lock.tilted = true; - lock.ball.in_launcher = false; - lock.ball.position = LOCK_HOLES[0].center; - lock.ball.velocity = Vec2::ZERO; - lock.ball.capture_age = 300; + lock.balls[0].in_launcher = false; + lock.balls[0].position = LOCK_HOLES[0].center; + lock.balls[0].velocity = Vec2::ZERO; + lock.balls[0].capture_age = 300; let mut lock_events = Vec::new(); assert_eq!( lock.check_sensor_objects( @@ -3259,17 +3216,17 @@ mod tests { ); assert_eq!(lock.player().balls, 2); assert_eq!(lock.player().secondary_score, 10_000); - assert!(lock.ball.in_launcher); + assert!(lock.balls[0].in_launcher); assert!(!lock.tilted); assert!(lock_events.contains(&Event::Drain)); assert!(!lock_events.contains(&Event::Sound(2008))); let mut special = Game::new(1); special.tilted = true; - special.ball.in_launcher = false; - special.ball.position = SPECIAL_HOLE_SENSOR.center; - special.ball.velocity = Vec2::ZERO; - special.ball.capture_age = 300; + special.balls[0].in_launcher = false; + special.balls[0].position = SPECIAL_HOLE_SENSOR.center; + special.balls[0].velocity = Vec2::ZERO; + special.balls[0].capture_age = 300; let balls = special.player().balls; let mut special_events = Vec::new(); assert_eq!( @@ -3281,7 +3238,7 @@ mod tests { BallAction::Finish ); assert_eq!(special.player().balls, balls); - assert_eq!(special.ball.position, vec2(17.0, 23.0)); + assert_eq!(special.balls[0].position, vec2(17.0, 23.0)); assert!(special.tilted); assert_eq!(special.multiball_state, MultiballState::Unavailable); assert_eq!(special.record_contacts[148], 0); @@ -3331,8 +3288,8 @@ mod tests { assert!(game.effect_target_active()); game.target_effect = 3; assert_eq!(game.player_effect(), 3); - game.ball.in_launcher = false; - game.ball.position = EFFECT_SENSOR.center; + game.balls[0].in_launcher = false; + game.balls[0].position = EFFECT_SENSOR.center; game.check_sensor_objects( MilliVec::from_position(EFFECT_SENSOR.center), MilliVec::default(), @@ -3395,7 +3352,7 @@ mod tests { .into_iter() .find(|sensor| sensor.id == 150) .expect("record 150 must be present"); - let mut target_ball = target_game.ball; + let mut target_ball = target_game.balls[0]; target_ball.position = sensor.center; let mut target_events = Vec::new(); target_game.check_target_sensors( @@ -3419,7 +3376,7 @@ mod tests { let mut effect_game = Game::new_with_seed(1, 7); effect_game.players[0].score = 139_500; effect_game.begin_effect_scenario(1); - let mut effect_ball = effect_game.ball; + let mut effect_ball = effect_game.balls[0]; effect_ball.position = EFFECT_SENSOR.center; let mut effect_events = Vec::new(); effect_game.check_effect_sensor( @@ -3464,8 +3421,7 @@ mod tests { let (_, _, predicted) = game.scan_ordered_records_for_ball( &mut ball, - 1, - 1, + BallPass::slot(1, 1), old_position, &mut velocity, &mut events, @@ -3543,15 +3499,15 @@ mod tests { 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.balls[0].in_launcher = false; + game.balls[0].position = EFFECT_SENSOR.center; game.check_sensor_objects( MilliVec::from_position(EFFECT_SENSOR.center), MilliVec::default(), &mut events, ); let spawned = game - .secondary_ball + .secondary() .expect("effect seven should spawn a second ball"); assert_eq!(game.multiball_state, MultiballState::Active); assert!(!game.special_hole_active()); @@ -3562,24 +3518,22 @@ mod tests { ); game.advance_secondary_ball(&mut events); - let moving_ball = game - .secondary_ball - .expect("second ball should remain active"); + let moving_ball = game.secondary().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!(game.secondary().is_none()); assert_eq!(game.player().balls, balls_before); - assert_eq!(game.ball.position, moving_ball.position); + assert_eq!(game.balls[0].position, moving_ball.position); events.clear(); game.drain(&mut events); assert!(events.is_empty()); assert_eq!(game.player().balls, balls_before); - assert_eq!(game.ball.position, vec2(17.0, 23.0)); + assert_eq!(game.balls[0].position, vec2(17.0, 23.0)); assert_eq!( - MilliVec::from_velocity_per_second(game.ball.velocity), + MilliVec::from_velocity_per_second(game.balls[0].velocity), MilliVec { x: 0, y: 3_040 } ); assert_eq!(game.multiball_state, MultiballState::Unavailable); @@ -3588,22 +3542,22 @@ mod tests { assert_eq!(game.special_hole_gate, SpecialHoleGate::Suppressed); game.fixed_update(&mut events); - assert!(!game.ball.in_launcher); + assert!(!game.balls[0].in_launcher); assert_eq!( - MilliVec::from_position(game.ball.position), + MilliVec::from_position(game.balls[0].position), MilliVec { x: 17_000, y: 26_055, } ); assert_eq!( - MilliVec::from_velocity_per_second(game.ball.velocity), + MilliVec::from_velocity_per_second(game.balls[0].velocity), MilliVec { x: 0, y: 3_055 } ); assert_eq!(game.special_hole_gate, SpecialHoleGate::Suppressed); - game.ball.position = vec2(17.0, 31.0); - game.ball.velocity = Vec2::ZERO; + game.balls[0].position = vec2(17.0, 31.0); + game.balls[0].velocity = Vec2::ZERO; game.fixed_update(&mut events); assert_eq!(game.special_hole_gate, SpecialHoleGate::Enabled); } @@ -3618,14 +3572,14 @@ mod tests { assert_eq!(game.target_effect, 7); assert!(game.special_hole_active()); - game.ball.in_launcher = false; - game.ball.position = EFFECT_SENSOR.center; + game.balls[0].in_launcher = false; + game.balls[0].position = EFFECT_SENSOR.center; game.check_sensor_objects( MilliVec::from_position(EFFECT_SENSOR.center), MilliVec::default(), &mut events, ); - assert!(game.secondary_ball.is_some()); + assert!(game.secondary().is_some()); assert_eq!(game.target_effect, 0); assert!(!game.special_hole_active()); @@ -3634,9 +3588,9 @@ mod tests { } assert_eq!(game.record_contacts[148], 0); game.drain(&mut events); - assert!(game.secondary_ball.is_none()); + assert!(game.secondary().is_none()); game.drain(&mut events); - assert!(game.ball.in_launcher); + assert!(game.balls[0].in_launcher); assert_eq!(game.player().balls, 2); assert_eq!(game.multiball_state, MultiballState::Unavailable); @@ -3647,13 +3601,13 @@ mod tests { game.target_effect = 7; game.trigger_flags[usize::from(EFFECT_SENSOR.id)] = false; - game.ball.position = EFFECT_SENSOR.center; + game.balls[0].position = EFFECT_SENSOR.center; game.check_sensor_objects( MilliVec::from_position(EFFECT_SENSOR.center), MilliVec::default(), &mut events, ); - assert!(game.secondary_ball.is_none()); + assert!(game.secondary().is_none()); } #[test] @@ -3661,21 +3615,21 @@ mod tests { let mut game = Game::new(1); game.multiball_state = MultiballState::Active; game.record_contacts[148] = 2; - game.ball.in_launcher = false; - game.ball.position = vec2(157.0, 453.0); - game.ball.velocity = MilliVec { x: 0, y: 3_000 }.to_velocity_per_second(); + game.balls[0].in_launcher = false; + game.balls[0].position = vec2(157.0, 453.0); + game.balls[0].velocity = MilliVec { x: 0, y: 3_000 }.to_velocity_per_second(); assert!(game.fixed_update(&mut Vec::new())); assert_eq!( - MilliVec::from_position(game.ball.position), + MilliVec::from_position(game.balls[0].position), MilliVec { x: 16_698, y: 21_191, } ); assert_eq!( - MilliVec::from_velocity_per_second(game.ball.velocity), + MilliVec::from_velocity_per_second(game.balls[0].velocity), MilliVec { x: -302, y: -1_809 } ); assert_eq!(game.multiball_state, MultiballState::Unavailable); @@ -3694,7 +3648,7 @@ mod tests { assert!(events.is_empty()); assert_eq!(game.player().balls, balls); - assert_eq!(game.ball.position, vec2(17.0, 23.0)); + assert_eq!(game.balls[0].position, vec2(17.0, 23.0)); assert_eq!(game.multiball_state, MultiballState::Unavailable); assert_eq!(game.record_contacts[148], 0); assert_eq!(game.special_hole_gate, SpecialHoleGate::Suppressed); @@ -3707,7 +3661,7 @@ mod tests { .find(|sensor| sensor.id == 150) .expect("record 150 must be present"); let mut game = Game::new_with_seed(1, 7); - let primary_velocity = game.ball.velocity; + let primary_velocity = game.balls[0].velocity; let mut second = Ball { position: sensor.center, velocity: vec2(12.0, -34.0), @@ -3726,8 +3680,7 @@ mod tests { assert_eq!( game.check_sensor_objects_for_ball( &mut second, - 2, - 2, + BallPass::slot(2, 2), old_position, movement, &mut events, @@ -3739,7 +3692,7 @@ mod tests { assert!(game.top_targets[0]); assert!(game.magnetic_field_active(153)); assert_eq!(game.player().score, sensor.score); - assert_eq!(game.ball.velocity, primary_velocity); + assert_eq!(game.balls[0].velocity, primary_velocity); assert_ne!(second.velocity, vec2(12.0, -34.0)); } @@ -3761,8 +3714,7 @@ mod tests { assert_eq!( game.check_sensor_objects_for_ball( &mut second, - 2, - 2, + BallPass::slot(2, 2), MilliVec::from_position(EFFECT_SENSOR.center), MilliVec::default(), &mut events, @@ -3771,7 +3723,7 @@ mod tests { BallAction::Keep ); - assert!(game.secondary_ball.is_none()); + assert!(game.secondary().is_none()); assert_eq!(game.multiball_state, MultiballState::Ready); assert_eq!(game.target_effect, 0); assert!(!game.effect_target_active()); @@ -3790,15 +3742,14 @@ mod tests { spin: Real48::ZERO, capture_age: 0, }; - game.secondary_ball = Some(secondary); - let mut primary = game.ball; + game.set_secondary(Some(secondary)); + let mut primary = game.balls[0]; let mut events = Vec::new(); assert_eq!( game.check_sensor_objects_for_ball( &mut primary, - 1, - 2, + BallPass::slot(1, 2), MilliVec::from_position(EFFECT_SENSOR.center), MilliVec::default(), &mut events, @@ -3808,7 +3759,7 @@ mod tests { ); assert_eq!( - game.secondary_ball.map(|ball| ball.position), + game.secondary().map(|ball| ball.position), Some(secondary.position) ); assert_eq!(game.multiball_state, MultiballState::Active); @@ -3819,23 +3770,23 @@ mod tests { fn multiball_capture_age_is_per_slot_and_removes_only_ball_two() { let sensor = LOCK_HOLES[0]; let mut game = Game::new(1); - game.ball.capture_age = 17; + game.balls[0].capture_age = 17; game.record_contacts[usize::from(sensor.id)] = 2; - game.secondary_ball = Some(Ball { + game.set_secondary(Some(Ball { position: sensor.center, velocity: Vec2::ZERO, in_launcher: false, spin: Real48::ZERO, capture_age: 300, - }); + })); game.score_mode = ScoreMode::Multiball; let mut events = Vec::new(); game.advance_secondary_ball(&mut events); - assert!(game.secondary_ball.is_none()); + assert!(game.secondary().is_none()); assert_eq!(game.score_mode, ScoreMode::Multiball); - assert_eq!(game.ball.capture_age, 17); + assert_eq!(game.balls[0].capture_age, 17); assert_eq!(game.record_contacts[usize::from(sensor.id)], 99); assert!(game.wheel_holes[0]); assert!(events.contains(&Event::Lock)); @@ -3848,18 +3799,18 @@ mod tests { fn captured_ball_one_keeps_multiball_scoring_for_ball_two_slot_pass() { let sensor = LOCK_HOLES[0]; let mut game = Game::new_with_seed(1, 7); - game.ball.in_launcher = false; - game.ball.position = sensor.center; - game.ball.velocity = Vec2::ZERO; - game.ball.capture_age = 300; + game.balls[0].in_launcher = false; + game.balls[0].position = sensor.center; + game.balls[0].velocity = Vec2::ZERO; + game.balls[0].capture_age = 300; game.record_contacts[usize::from(sensor.id)] = 1; - game.secondary_ball = Some(Ball { + game.set_secondary(Some(Ball { position: EFFECT_SENSOR.center, velocity: Vec2::ZERO, in_launcher: false, spin: Real48::ZERO, capture_age: 0, - }); + })); game.target_effect = 1; game.object_active[usize::from(EFFECT_SENSOR.id)] = true; game.score_mode = ScoreMode::Multiball; @@ -3867,7 +3818,7 @@ mod tests { game.timer_tick(0.01, 1, &mut events); - assert!(game.secondary_ball.is_none()); + assert!(game.secondary().is_none()); assert_eq!(game.player().score, 1_000); assert_eq!(game.player().secondary_score, 20_000); assert_eq!(game.score_mode, ScoreMode::Multiball); @@ -3885,7 +3836,7 @@ mod tests { game.claw.bank = ClawSpriteBank::Opening; game.claw.frame_accumulator = 0.0; game.claw.ball_suspended = true; - game.claw.captured_slot = Some(ClawBallSlot::Primary); + game.claw.captured_slot = Some(0); game.score_mode = ScoreMode::Multiball; let mut events = Vec::new(); @@ -3897,10 +3848,10 @@ mod tests { fn capturing_ball_one_during_multiball_promotes_ball_two() { let sensor = LOCK_HOLES[0]; let mut game = Game::new(1); - game.ball.in_launcher = false; - game.ball.position = sensor.center; - game.ball.velocity = Vec2::ZERO; - game.ball.capture_age = 300; + game.balls[0].in_launcher = false; + game.balls[0].position = sensor.center; + game.balls[0].velocity = Vec2::ZERO; + game.balls[0].capture_age = 300; game.record_contacts[usize::from(sensor.id)] = 1; let survivor = Ball { position: vec2(210.0, 240.0), @@ -3909,7 +3860,7 @@ mod tests { spin: Real48::ZERO, capture_age: 23, }; - game.secondary_ball = Some(survivor); + game.set_secondary(Some(survivor)); let mut events = Vec::new(); game.check_sensor_objects( @@ -3918,10 +3869,10 @@ mod tests { &mut events, ); - assert!(game.secondary_ball.is_none()); - assert_eq!(game.ball.position, survivor.position); - assert_eq!(game.ball.velocity, survivor.velocity); - assert_eq!(game.ball.capture_age, survivor.capture_age); + assert!(game.secondary().is_none()); + assert_eq!(game.balls[0].position, survivor.position); + assert_eq!(game.balls[0].velocity, survivor.velocity); + assert_eq!(game.balls[0].capture_age, survivor.capture_age); assert_eq!(game.record_contacts[usize::from(sensor.id)], 99); assert!(game.wheel_holes[0]); } @@ -3930,10 +3881,10 @@ mod tests { fn occupied_multiball_wheel_hole_rejects_the_surviving_ball() { let sensor = LOCK_HOLES[0]; let mut game = Game::new(1); - game.ball.in_launcher = false; - game.ball.position = sensor.center; - game.ball.velocity = Vec2::ZERO; - game.ball.capture_age = 300; + game.balls[0].in_launcher = false; + game.balls[0].position = sensor.center; + game.balls[0].velocity = Vec2::ZERO; + game.balls[0].capture_age = 300; game.record_contacts[usize::from(sensor.id)] = 99; game.wheel_holes[0] = true; let mut events = Vec::new(); @@ -3948,21 +3899,21 @@ mod tests { assert_eq!(game.record_contacts[usize::from(sensor.id)], 99); assert_eq!(game.player().secondary_score, 0); assert!(!events.contains(&Event::Lock)); - assert!(!game.ball.in_launcher); + assert!(!game.balls[0].in_launcher); } #[test] fn center_drain_advances_to_a_fresh_ball() { let mut game = Game::new(1); - game.ball.in_launcher = false; - game.ball.position = vec2(157.0, 450.0); - game.ball.velocity = vec2(0.0, 300.0); + game.balls[0].in_launcher = false; + game.balls[0].position = vec2(157.0, 450.0); + game.balls[0].velocity = vec2(0.0, 300.0); let events = game.update(1.0 / 30.0, 5, Controls::default()); assert!(events.contains(&Event::Drain)); assert_eq!(game.players[0].balls, 2); - assert!(game.ball.in_launcher); + assert!(game.balls[0].in_launcher); } #[test] @@ -3975,14 +3926,14 @@ mod tests { ] { 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(); + game.balls[0].in_launcher = false; + game.balls[0].position = position; + game.balls[0].velocity = velocity.to_velocity_per_second(); let mut events = Vec::new(); assert!(game.fixed_update(&mut events)); - assert!(game.ball.in_launcher); + assert!(game.balls[0].in_launcher); assert_eq!(game.player().balls, 2); assert!(events.contains(&Event::Drain)); } @@ -3992,8 +3943,8 @@ mod tests { 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); + game.balls[0].in_launcher = false; + game.balls[0].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(), @@ -4001,12 +3952,12 @@ mod tests { spin: Real48::ZERO, capture_age: 0, }; - game.secondary_ball = Some(second); + game.set_secondary(Some(second)); game.score_mode = ScoreMode::Multiball; assert!(game.advance_secondary_ball(&mut Vec::new())); - assert!(game.secondary_ball.is_none()); + assert!(game.secondary().is_none()); assert_eq!(game.player().balls, 3); assert_eq!(game.score_mode, ScoreMode::Normal); } @@ -4054,7 +4005,7 @@ mod tests { game.drain(&mut events); assert!(events.contains(&Event::HighScoreCandidate { score: 123_456, - crazy: false, + crazy: false })); assert_eq!(game.current_player, 1); assert!(!game.finished); @@ -4065,7 +4016,7 @@ mod tests { game.drain(&mut events); assert!(events.contains(&Event::HighScoreCandidate { score: 654_321, - crazy: false, + crazy: false })); assert!(game.finished); } @@ -4073,51 +4024,54 @@ mod tests { #[test] fn fast_ball_cannot_tunnel_through_the_top_rail() { 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); + game.balls[0].in_launcher = false; + game.balls[0].position = vec2(270.0, 30.0); + game.balls[0].velocity = vec2(0.0, -380.0); for _ in 0..5 { game.fixed_update(&mut Vec::new()); } - assert!(game.ball.position.y >= 15.0); - assert!(game.ball.velocity.y > 0.0); + assert!(game.balls[0].position.y >= 15.0); + assert!(game.balls[0].velocity.y > 0.0); } #[test] fn relative_record_57_matches_live_two_substep_transition() { let mut game = Game::new(1); - game.ball.in_launcher = false; - game.ball.position = vec2(72.530, 354.750); - game.ball.velocity = MilliVec { x: 3_000, y: 0 }.to_velocity_per_second(); + game.balls[0].in_launcher = false; + game.balls[0].position = vec2(72.530, 354.750); + game.balls[0].velocity = MilliVec { x: 3_000, y: 0 }.to_velocity_per_second(); let mut events = Vec::new(); assert!(!game.fixed_update(&mut events)); assert_eq!( - MilliVec::from_position(game.ball.position), + MilliVec::from_position(game.balls[0].position), MilliVec { x: 75_530, y: 354_765, } ); assert_eq!( - MilliVec::from_velocity_per_second(game.ball.velocity), + MilliVec::from_velocity_per_second(game.balls[0].velocity), MilliVec { x: 3_000, y: 15 } ); assert!(game.fixed_update(&mut events)); assert_eq!(game.last_collision_id, Some(57)); - assert_eq!(game.ball.spin.bytes(), [0x83, 0x5d, 0x8f, 0xc2, 0xf5, 0xa8]); assert_eq!( - MilliVec::from_position(game.ball.position), + game.balls[0].spin.bytes(), + [0x83, 0x5d, 0x8f, 0xc2, 0xf5, 0xa8] + ); + assert_eq!( + MilliVec::from_position(game.balls[0].position), MilliVec { x: 77_369, y: 356_597, } ); assert_eq!( - MilliVec::from_velocity_per_second(game.ball.velocity), + MilliVec::from_velocity_per_second(game.balls[0].velocity), MilliVec { x: 1_839, y: 1_832 } ); } @@ -4125,36 +4079,39 @@ mod tests { #[test] fn relative_record_72_matches_live_two_substep_transition() { let mut game = Game::new(1); - game.ball.in_launcher = false; - game.ball.position = vec2(238.348, 356.810); - game.ball.velocity = MilliVec { x: -3_000, y: 0 }.to_velocity_per_second(); + game.balls[0].in_launcher = false; + game.balls[0].position = vec2(238.348, 356.810); + game.balls[0].velocity = MilliVec { x: -3_000, y: 0 }.to_velocity_per_second(); let mut events = Vec::new(); assert!(!game.fixed_update(&mut events)); assert_eq!( - MilliVec::from_position(game.ball.position), + MilliVec::from_position(game.balls[0].position), MilliVec { x: 235_348, y: 356_825, } ); assert_eq!( - MilliVec::from_velocity_per_second(game.ball.velocity), + MilliVec::from_velocity_per_second(game.balls[0].velocity), MilliVec { x: -3_000, y: 15 } ); assert!(game.fixed_update(&mut events)); assert_eq!(game.last_collision_id, Some(72)); - assert_eq!(game.ball.spin.bytes(), [0x83, 0x86, 0xeb, 0x51, 0xb8, 0x2e]); assert_eq!( - MilliVec::from_position(game.ball.position), + game.balls[0].spin.bytes(), + [0x83, 0x86, 0xeb, 0x51, 0xb8, 0x2e] + ); + assert_eq!( + MilliVec::from_position(game.balls[0].position), MilliVec { x: 233_253, y: 358_494, } ); assert_eq!( - MilliVec::from_velocity_per_second(game.ball.velocity), + MilliVec::from_velocity_per_second(game.balls[0].velocity), MilliVec { x: -2_095, y: 1_669 @@ -4165,10 +4122,10 @@ mod tests { #[test] fn retained_collision_candidate_clears_the_slot_capture_age() { 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); - game.ball.capture_age = 42; + game.balls[0].in_launcher = false; + game.balls[0].position = vec2(270.0, 30.0); + game.balls[0].velocity = vec2(0.0, -380.0); + game.balls[0].capture_age = 42; for _ in 0..5 { if game.fixed_update(&mut Vec::new()) { @@ -4176,7 +4133,7 @@ mod tests { } } - assert_eq!(game.ball.capture_age, 0); + assert_eq!(game.balls[0].capture_age, 0); } #[test] @@ -4261,9 +4218,9 @@ mod tests { weak.object_active.fill(false); weak.object_active[51] = true; weak.players[0].bumper_value = 2_000; - weak.ball.in_launcher = false; - weak.ball.position = vec2(165.0, 171.1); - weak.ball.velocity = vec2(0.0, -20.0); + weak.balls[0].in_launcher = false; + weak.balls[0].position = vec2(165.0, 171.1); + weak.balls[0].velocity = vec2(0.0, -20.0); let mut weak_events = Vec::new(); assert!(weak.fixed_update(&mut weak_events)); @@ -4271,9 +4228,9 @@ mod tests { fast.object_active.fill(false); fast.object_active[51] = true; fast.players[0].bumper_value = 2_000; - fast.ball.in_launcher = false; - fast.ball.position = vec2(165.0, 172.015); - fast.ball.velocity = vec2(0.0, -198.5); + fast.balls[0].in_launcher = false; + fast.balls[0].position = vec2(165.0, 172.015); + fast.balls[0].velocity = vec2(0.0, -198.5); let mut tilted = fast.clone(); tilted.tilted = true; let mut fast_events = Vec::new(); @@ -4287,11 +4244,11 @@ mod tests { assert!(fast_events.contains(&Event::Sound(2006))); assert_eq!(weak.record_countdown(51), 5); assert_eq!(fast.record_countdown(51), 5); - assert!(fast.ball.velocity.y > weak.ball.velocity.y); + assert!(fast.balls[0].velocity.y > weak.balls[0].velocity.y); assert_eq!(tilted.player().score, 0); assert_eq!(tilted.record_countdown(51), 0); assert!(!tilted_events.contains(&Event::Sound(2006))); - assert!(fast.ball.velocity.y > tilted.ball.velocity.y); + assert!(fast.balls[0].velocity.y > tilted.balls[0].velocity.y); } #[test] @@ -4315,9 +4272,9 @@ mod tests { game.flippers.left_raised = true; game.flippers.right_raised = true; let score = game.player().score; - game.ball.in_launcher = false; - game.ball.position = vec2(201.0, 285.0); - game.ball.velocity = Vec2::ZERO; + game.balls[0].in_launcher = false; + game.balls[0].position = vec2(201.0, 285.0); + game.balls[0].velocity = Vec2::ZERO; game.update( 1.0 / 30.0, 5, @@ -4502,14 +4459,14 @@ mod tests { #[test] fn raising_flipper_uses_the_reconstructed_moving_response() { let mut game = Game::new(1); - game.ball.in_launcher = false; - game.ball.position = vec2(125.0, 390.0); - game.ball.velocity = Vec2::ZERO; + game.balls[0].in_launcher = false; + game.balls[0].position = vec2(125.0, 390.0); + game.balls[0].velocity = Vec2::ZERO; game.pending_flipper_edges[0] = -1; game.apply_flipper_kicks(); - let velocity_after_edge = game.ball.velocity; - assert_eq!(game.ball.position, vec2(138.124, 355.0)); + let velocity_after_edge = game.balls[0].velocity; + assert_eq!(game.balls[0].position, vec2(138.124, 355.0)); let raw_velocity = MilliVec::from_velocity_per_second(velocity_after_edge); assert_eq!( raw_velocity, @@ -4522,7 +4479,7 @@ mod tests { game.apply_flipper_kicks(); assert_eq!( - game.ball.velocity, velocity_after_edge, + game.balls[0].velocity, velocity_after_edge, "holding a raised flipper must not add another impulse" ); } @@ -4531,16 +4488,16 @@ mod tests { fn returning_flipper_uses_the_raised_record_geometry() { let mut game = Game::new(1); game.flippers.left_raised = true; - game.ball.in_launcher = false; - game.ball.position = vec2(110.0, 410.0); - game.ball.velocity = vec2(100.0, 200.0); + game.balls[0].in_launcher = false; + game.balls[0].position = vec2(110.0, 410.0); + game.balls[0].velocity = vec2(100.0, 200.0); let mut events = Vec::new(); game.timer_tick(0.030, 0, &mut events); assert_eq!(events, [Event::FlipperMove, Event::Sound(2021)]); - let raw_velocity = MilliVec::from_velocity_per_second(game.ball.velocity); + let raw_velocity = MilliVec::from_velocity_per_second(game.balls[0].velocity); assert_eq!(raw_velocity, MilliVec { x: 765, y: 3_997 }); - assert_eq!(game.ball.position, vec2(110.380, 411.984)); + assert_eq!(game.balls[0].position, vec2(110.380, 411.984)); assert_eq!(game.pending_flipper_edges, [0, 0]); } @@ -4558,7 +4515,7 @@ mod tests { #[test] fn nudge_and_tilt_emit_the_original_ordered_sound_sequence() { let mut game = Game::new_with_seed(1, 7); - game.ball.in_launcher = false; + game.balls[0].in_launcher = false; let controls = Controls { nudge: Nudge::Right, ..Controls::default() @@ -4569,7 +4526,7 @@ mod tests { [Event::Sound(2019), Event::Nudge] ); assert_eq!( - MilliVec::from_velocity_per_second(game.ball.velocity), + MilliVec::from_velocity_per_second(game.balls[0].velocity), MilliVec { x: 690, y: 0 } ); assert_eq!(game.tilt_counter, 25); @@ -4594,7 +4551,7 @@ mod tests { ); assert_eq!(events, [Event::Sound(2019), Event::Nudge]); assert_eq!( - MilliVec::from_velocity_per_second(game.ball.velocity), + MilliVec::from_velocity_per_second(game.balls[0].velocity), MilliVec { x: -84, y: -660 } ); assert_eq!(game.tilt_counter, 25); @@ -4627,15 +4584,15 @@ mod tests { #[test] fn multiball_nudge_consumes_active_draws_then_updates_both_saved_slots() { let mut game = Game::new_with_seed(1, 7); - game.ball.in_launcher = false; - game.ball.velocity = MilliVec { x: 100, y: 200 }.to_velocity_per_second(); - game.secondary_ball = Some(Ball { + game.balls[0].in_launcher = false; + game.balls[0].velocity = MilliVec { x: 100, y: 200 }.to_velocity_per_second(); + game.set_secondary(Some(Ball { position: vec2(200.0, 200.0), velocity: MilliVec { x: -300, y: 400 }.to_velocity_per_second(), in_launcher: false, spin: Real48::ZERO, capture_age: 0, - }); + })); let mut reference = BorlandRandom::new(7); let _discarded_active_x = reference.real48(); let _discarded_active_y = reference.below(20); @@ -4648,13 +4605,13 @@ mod tests { game.apply_nudge(Nudge::Center, &mut Vec::new()); assert_eq!( - MilliVec::from_velocity_per_second(game.ball.velocity), + MilliVec::from_velocity_per_second(game.balls[0].velocity), MilliVec { x: 100 + shared_impulse.x, y: 200 + shared_impulse.y, } ); - let secondary = game.secondary_ball.expect("slot two must remain active"); + let secondary = game.secondary().expect("slot two must remain active"); assert_eq!( MilliVec::from_velocity_per_second(secondary.velocity), MilliVec { @@ -4708,10 +4665,10 @@ mod tests { #[test] fn claw_capture_holds_releases_and_returns_in_original_frame_order() { let mut game = Game::new(1); - game.ball.in_launcher = false; - game.ball.position = CLAW_TRIGGER_CENTER; - game.ball.velocity = vec2(120.0, -40.0); - let held_position = game.ball.position; + game.balls[0].in_launcher = false; + game.balls[0].position = CLAW_TRIGGER_CENTER; + game.balls[0].velocity = vec2(120.0, -40.0); + let held_position = game.balls[0].position; let mut events = Vec::new(); game.begin_claw_capture(6, &mut events); @@ -4719,10 +4676,10 @@ mod tests { assert_eq!(game.claw.frame, 10); assert_eq!(game.claw.bank, ClawSpriteBank::Closing); assert!(game.claw.ball_suspended); - assert_eq!(game.ball.velocity, Vec2::ZERO); + assert_eq!(game.balls[0].velocity, Vec2::ZERO); game.timer_tick(CLAW_FRAME_SECONDS / 3.0, 0, &mut events); - assert_eq!(game.ball.position, held_position); + assert_eq!(game.balls[0].position, held_position); assert_eq!(game.claw.frame, 10); for expected_frame in [9, 8, 7, 6] { @@ -4736,9 +4693,9 @@ mod tests { assert_eq!(game.claw.frame, 6); assert_eq!(game.claw.bank, ClawSpriteBank::Opening); assert!(!game.claw.ball_suspended); - assert_eq!(game.ball.position, vec2(270.0, 94.0)); - assert!((game.ball.velocity.x - -152.0).abs() < 0.001); - assert!((game.ball.velocity.y - 228.0).abs() < 0.001); + assert_eq!(game.balls[0].position, vec2(270.0, 94.0)); + assert!((game.balls[0].velocity.x - -152.0).abs() < 0.001); + assert!((game.balls[0].velocity.y - 228.0).abs() < 0.001); for expected_frame in [7, 8, 9, 10] { game.update_claw(CLAW_FRAME_SECONDS, &mut events); @@ -4753,16 +4710,16 @@ mod tests { #[test] fn multiball_claw_capture_keeps_the_ball_and_uses_only_table_releases() { let mut game = Game::new_with_seed(1, 7); - game.ball.in_launcher = false; - game.ball.position = CLAW_TRIGGER_CENTER; - game.ball.velocity = Vec2::ZERO; - game.secondary_ball = Some(Ball { + game.balls[0].in_launcher = false; + game.balls[0].position = CLAW_TRIGGER_CENTER; + game.balls[0].velocity = Vec2::ZERO; + game.set_secondary(Some(Ball { position: vec2(210.0, 240.0), velocity: vec2(12.0, -34.0), in_launcher: false, spin: Real48::ZERO, capture_age: 0, - }); + })); game.object_active.fill(false); game.score_mode = ScoreMode::Multiball; let claw_position = MilliVec::from_position(CLAW_TRIGGER_CENTER); @@ -4772,22 +4729,22 @@ mod tests { game.check_sensor_objects(claw_position, MilliVec::default(), &mut events), BallAction::Keep ); - assert_eq!(game.claw.capture_entry, Some((ClawBallSlot::Primary, true))); + assert_eq!(game.claw.capture_entry, Some((0, true))); assert_eq!( game.check_sensor_objects(claw_position, MilliVec::default(), &mut events), BallAction::Suspend ); assert!(game.claw.active); - assert!(game.primary_ball_suspended()); - assert!(!game.secondary_ball_suspended()); + assert!(game.ball_suspended(0)); + assert!(!game.ball_suspended(1)); assert!(matches!(game.claw.target_frame, 1 | 6 | 7)); assert_eq!(game.record_contacts[89], 1); - let survivor_before = game.secondary_ball.expect("survivor must remain").position; + let survivor_before = game.secondary().expect("survivor must remain").position; game.timer_tick(CLAW_FRAME_SECONDS, 1, &mut events); assert_ne!( - game.secondary_ball.expect("survivor must remain").position, + game.secondary().expect("survivor must remain").position, survivor_before ); @@ -4795,15 +4752,15 @@ mod tests { game.update_claw(CLAW_FRAME_SECONDS, &mut events); } assert!(events.contains(&Event::ClawRelease)); - assert!(!game.primary_ball_suspended()); - assert!(game.secondary_ball.is_some()); + assert!(!game.ball_suspended(0)); + assert!(game.secondary().is_some()); - game.ball.position = CLAW_TRIGGER_CENTER + vec2(20.0, 0.0); - game.ball.velocity = Vec2::ZERO; - game.ball.capture_age = 0; + game.balls[0].position = CLAW_TRIGGER_CENTER + vec2(20.0, 0.0); + game.balls[0].velocity = Vec2::ZERO; + game.balls[0].capture_age = 0; assert_eq!( game.check_sensor_objects( - MilliVec::from_position(game.ball.position), + MilliVec::from_position(game.balls[0].position), MilliVec::default(), &mut events, ), @@ -4815,16 +4772,16 @@ mod tests { #[test] fn multiball_claw_restriction_is_latched_before_the_other_ball_drains() { let mut game = Game::new_with_seed(1, 7); - game.ball.in_launcher = false; - game.ball.position = CLAW_TRIGGER_CENTER; - game.ball.velocity = vec2(100.0, 0.0); - game.secondary_ball = Some(Ball { + game.balls[0].in_launcher = false; + game.balls[0].position = CLAW_TRIGGER_CENTER; + game.balls[0].velocity = vec2(100.0, 0.0); + game.set_secondary(Some(Ball { position: vec2(210.0, 240.0), velocity: Vec2::ZERO, in_launcher: false, spin: Real48::ZERO, capture_age: 0, - }); + })); game.object_active.fill(false); game.score_mode = ScoreMode::Multiball; let claw_position = MilliVec::from_position(CLAW_TRIGGER_CENTER); @@ -4834,40 +4791,40 @@ mod tests { game.check_sensor_objects(claw_position, MilliVec::default(), &mut events), BallAction::Keep ); - game.secondary_ball = None; + game.set_secondary(None); game.score_mode = ScoreMode::Normal; - game.ball.velocity = vec2(100.0, 0.0); + game.balls[0].velocity = vec2(100.0, 0.0); assert_eq!( game.check_sensor_objects(claw_position, MilliVec::default(), &mut events), BallAction::Keep ); - assert_eq!(game.claw.capture_entry, Some((ClawBallSlot::Primary, true))); + assert_eq!(game.claw.capture_entry, Some((0, true))); - game.ball.velocity = Vec2::ZERO; - game.ball.capture_age = 300; + game.balls[0].velocity = Vec2::ZERO; + game.balls[0].capture_age = 300; assert_eq!( game.check_sensor_objects(claw_position, MilliVec::default(), &mut events), BallAction::Suspend ); - assert_eq!(game.claw.capture_entry, Some((ClawBallSlot::Primary, true))); + assert_eq!(game.claw.capture_entry, Some((0, true))); assert!(matches!(game.claw.target_frame, 1 | 6 | 7)); } #[test] fn other_multiball_slot_cannot_clear_the_claw_entry_latch() { let mut game = Game::new_with_seed(1, 7); - game.ball.in_launcher = false; - game.ball.position = CLAW_TRIGGER_CENTER; - game.ball.velocity = vec2(100.0, 0.0); - game.secondary_ball = Some(Ball { + game.balls[0].in_launcher = false; + game.balls[0].position = CLAW_TRIGGER_CENTER; + game.balls[0].velocity = vec2(100.0, 0.0); + game.set_secondary(Some(Ball { position: vec2(210.0, 100.0), velocity: Vec2::ZERO, in_launcher: false, spin: Real48::ZERO, capture_age: 0, - }); + })); game.object_active.fill(false); game.score_mode = ScoreMode::Multiball; let mut events = Vec::new(); @@ -4880,15 +4837,14 @@ mod tests { ), BallAction::Keep ); - assert_eq!(game.claw.capture_entry, Some((ClawBallSlot::Primary, true))); + assert_eq!(game.claw.capture_entry, Some((0, true))); - let mut secondary = game.secondary_ball.take().expect("secondary ball"); + let mut secondary = game.take_secondary().expect("secondary ball"); let secondary_position = MilliVec::from_position(secondary.position); assert_eq!( game.check_sensor_objects_for_ball( &mut secondary, - 2, - 2, + BallPass::slot(2, 2), secondary_position, MilliVec::default(), &mut events, @@ -4896,23 +4852,23 @@ mod tests { .action, BallAction::Keep ); - game.secondary_ball = Some(secondary); - assert_eq!(game.claw.capture_entry, Some((ClawBallSlot::Primary, true))); + game.set_secondary(Some(secondary)); + assert_eq!(game.claw.capture_entry, Some((0, true))); } #[test] fn shallow_claw_exit_clears_only_the_owning_entry_latch() { let mut game = Game::new_with_seed(1, 7); - game.ball.in_launcher = false; - game.ball.position = CLAW_TRIGGER_CENTER; - game.ball.velocity = vec2(100.0, 0.0); - game.secondary_ball = Some(Ball { + game.balls[0].in_launcher = false; + game.balls[0].position = CLAW_TRIGGER_CENTER; + game.balls[0].velocity = vec2(100.0, 0.0); + game.set_secondary(Some(Ball { position: vec2(210.0, 240.0), velocity: Vec2::ZERO, in_launcher: false, spin: Real48::ZERO, capture_age: 0, - }); + })); game.object_active.fill(false); game.score_mode = ScoreMode::Multiball; let mut events = Vec::new(); @@ -4925,13 +4881,13 @@ mod tests { ), BallAction::Keep ); - assert_eq!(game.claw.capture_entry, Some((ClawBallSlot::Primary, true))); + assert_eq!(game.claw.capture_entry, Some((0, true))); - game.ball.position = CLAW_TRIGGER_CENTER + vec2(20.0, 0.0); - game.ball.velocity = Vec2::ZERO; + game.balls[0].position = CLAW_TRIGGER_CENTER + vec2(20.0, 0.0); + game.balls[0].velocity = Vec2::ZERO; assert_eq!( game.check_sensor_objects( - MilliVec::from_position(game.ball.position), + MilliVec::from_position(game.balls[0].position), MilliVec::default(), &mut events, ), @@ -4939,10 +4895,10 @@ mod tests { ); assert_eq!(game.claw.capture_entry, None); - game.secondary_ball = None; + game.set_secondary(None); game.score_mode = ScoreMode::Normal; - game.ball.position = CLAW_TRIGGER_CENTER; - game.ball.velocity = vec2(100.0, 0.0); + game.balls[0].position = CLAW_TRIGGER_CENTER; + game.balls[0].velocity = vec2(100.0, 0.0); assert_eq!( game.check_sensor_objects( MilliVec::from_position(CLAW_TRIGGER_CENTER), @@ -4951,10 +4907,7 @@ mod tests { ), BallAction::Keep ); - assert_eq!( - game.claw.capture_entry, - Some((ClawBallSlot::Primary, false)) - ); + assert_eq!(game.claw.capture_entry, Some((0, false))); } #[test] @@ -4986,7 +4939,7 @@ mod tests { for first_speed in speeds { for second_speed in speeds { let mut game = Game::new_with_seed(1, 7); - game.ball = Ball { + game.balls[0] = Ball { position: first_position, velocity: (capture_center - first_position).normalize() * first_speed, @@ -4994,14 +4947,14 @@ mod tests { spin: Real48::ZERO, capture_age: 0, }; - game.secondary_ball = Some(Ball { + game.set_secondary(Some(Ball { position: second_position, velocity: (capture_center - second_position).normalize() * second_speed, in_launcher: false, spin: Real48::ZERO, capture_age: 0, - }); + })); game.object_active.fill(false); game.score_mode = ScoreMode::Multiball; let mut events = Vec::new(); @@ -5020,7 +4973,7 @@ mod tests { ); break; } - if game.secondary_ball.is_none() || game.finished { + if game.secondary().is_none() || game.finished { break; } } @@ -5037,27 +4990,26 @@ mod tests { #[test] fn secondary_multiball_ball_can_be_held_by_the_claw_without_hiding_primary() { let mut game = Game::new_with_seed(1, 7); - game.ball.in_launcher = false; - game.ball.position = vec2(210.0, 240.0); - game.ball.velocity = Vec2::ZERO; - game.secondary_ball = Some(Ball { + game.balls[0].in_launcher = false; + game.balls[0].position = vec2(210.0, 240.0); + game.balls[0].velocity = Vec2::ZERO; + game.set_secondary(Some(Ball { position: CLAW_TRIGGER_CENTER, velocity: Vec2::ZERO, in_launcher: false, spin: Real48::ZERO, capture_age: 0, - }); + })); game.object_active.fill(false); game.score_mode = ScoreMode::Multiball; let claw_position = MilliVec::from_position(CLAW_TRIGGER_CENTER); let mut events = Vec::new(); - let mut secondary = game.secondary_ball.take().expect("secondary ball"); + let mut secondary = game.take_secondary().expect("secondary ball"); assert_eq!( game.check_sensor_objects_for_ball( &mut secondary, - 2, - 2, + BallPass::slot(2, 2), claw_position, MilliVec::default(), &mut events, @@ -5065,13 +5017,12 @@ mod tests { .action, BallAction::Keep ); - game.secondary_ball = Some(secondary); - let mut secondary = game.secondary_ball.take().expect("secondary ball"); + game.set_secondary(Some(secondary)); + let mut secondary = game.take_secondary().expect("secondary ball"); assert_eq!( game.check_sensor_objects_for_ball( &mut secondary, - 2, - 2, + BallPass::slot(2, 2), claw_position, MilliVec::default(), &mut events, @@ -5079,29 +5030,29 @@ mod tests { .action, BallAction::Suspend ); - game.secondary_ball = Some(secondary); + game.set_secondary(Some(secondary)); - assert!(!game.primary_ball_suspended()); - assert!(game.secondary_ball_suspended()); + assert!(!game.ball_suspended(0)); + assert!(game.ball_suspended(1)); assert!(matches!(game.claw.target_frame, 1 | 6 | 7)); - let primary_before = game.ball.position; + let primary_before = game.balls[0].position; game.timer_tick(CLAW_FRAME_SECONDS, 1, &mut events); - assert_ne!(game.ball.position, primary_before); + assert_ne!(game.balls[0].position, primary_before); } #[test] fn held_claw_ball_survives_the_other_ball_draining() { let mut game = Game::new_with_seed(1, 7); - game.ball.in_launcher = false; - game.ball.position = CLAW_TRIGGER_CENTER; - game.ball.velocity = Vec2::ZERO; - game.secondary_ball = Some(Ball { + game.balls[0].in_launcher = false; + game.balls[0].position = CLAW_TRIGGER_CENTER; + game.balls[0].velocity = Vec2::ZERO; + game.set_secondary(Some(Ball { position: vec2(340.0, 100.0), velocity: vec2(1.0, 0.0), in_launcher: false, spin: Real48::ZERO, capture_age: 0, - }); + })); game.object_active.fill(false); game.score_mode = ScoreMode::Multiball; let claw_position = MilliVec::from_position(CLAW_TRIGGER_CENTER); @@ -5117,7 +5068,7 @@ mod tests { let balls_before = game.player().balls; game.timer_tick(CLAW_FRAME_SECONDS, 1, &mut events); - assert!(game.secondary_ball.is_none()); + assert!(game.secondary().is_none()); assert_eq!(game.player().balls, balls_before); assert_eq!(game.score_mode, ScoreMode::Normal); assert!(game.claw.ball_suspended); @@ -5126,32 +5077,31 @@ mod tests { game.update_claw(CLAW_FRAME_SECONDS, &mut events); } assert!(events.contains(&Event::ClawRelease)); - assert!(!game.ball.in_launcher); + assert!(!game.balls[0].in_launcher); } #[test] fn held_secondary_claw_ball_is_promoted_if_primary_drains() { let mut game = Game::new_with_seed(1, 7); - game.ball.in_launcher = false; - game.ball.position = vec2(340.0, 100.0); - game.ball.velocity = vec2(1.0, 0.0); - game.secondary_ball = Some(Ball { + game.balls[0].in_launcher = false; + game.balls[0].position = vec2(340.0, 100.0); + game.balls[0].velocity = vec2(1.0, 0.0); + game.set_secondary(Some(Ball { position: CLAW_TRIGGER_CENTER, velocity: Vec2::ZERO, in_launcher: false, spin: Real48::ZERO, capture_age: 0, - }); + })); game.object_active.fill(false); game.score_mode = ScoreMode::Multiball; let claw_position = MilliVec::from_position(CLAW_TRIGGER_CENTER); let mut events = Vec::new(); - let mut secondary = game.secondary_ball.take().expect("secondary ball"); + let mut secondary = game.take_secondary().expect("secondary ball"); assert_eq!( game.check_sensor_objects_for_ball( &mut secondary, - 2, - 2, + BallPass::slot(2, 2), claw_position, MilliVec::default(), &mut events, @@ -5159,13 +5109,12 @@ mod tests { .action, BallAction::Keep ); - game.secondary_ball = Some(secondary); - let mut secondary = game.secondary_ball.take().expect("secondary ball"); + game.set_secondary(Some(secondary)); + let mut secondary = game.take_secondary().expect("secondary ball"); assert_eq!( game.check_sensor_objects_for_ball( &mut secondary, - 2, - 2, + BallPass::slot(2, 2), claw_position, MilliVec::default(), &mut events, @@ -5173,18 +5122,15 @@ mod tests { .action, BallAction::Suspend ); - game.secondary_ball = Some(secondary); + game.set_secondary(Some(secondary)); assert_eq!(game.record_contacts[89], 2); - assert_eq!( - game.claw.capture_entry, - Some((ClawBallSlot::Secondary, true)) - ); + assert_eq!(game.claw.capture_entry, Some((1, true))); game.timer_tick(CLAW_FRAME_SECONDS, 1, &mut events); - assert!(game.secondary_ball.is_none()); - assert!(game.primary_ball_suspended()); + assert!(game.secondary().is_none()); + assert!(game.ball_suspended(0)); assert_eq!(game.record_contacts[89], 1); - assert_eq!(game.claw.capture_entry, Some((ClawBallSlot::Primary, true))); + assert_eq!(game.claw.capture_entry, Some((0, true))); assert_eq!(game.score_mode, ScoreMode::Normal); assert!(events.contains(&Event::Drain)); } @@ -5205,16 +5151,16 @@ mod tests { #[test] fn claw_reentry_after_multiball_uses_the_current_ball_count() { let mut game = Game::new_with_seed(1, 7); - game.ball.in_launcher = false; - game.ball.position = CLAW_TRIGGER_CENTER; - game.ball.velocity = Vec2::ZERO; - game.secondary_ball = Some(Ball { + game.balls[0].in_launcher = false; + game.balls[0].position = CLAW_TRIGGER_CENTER; + game.balls[0].velocity = Vec2::ZERO; + game.set_secondary(Some(Ball { position: vec2(210.0, 240.0), velocity: Vec2::ZERO, in_launcher: false, spin: Real48::ZERO, capture_age: 0, - }); + })); game.object_active.fill(false); game.score_mode = ScoreMode::Multiball; let claw_position = MilliVec::from_position(CLAW_TRIGGER_CENTER); @@ -5227,9 +5173,9 @@ mod tests { game.check_sensor_objects(claw_position, MilliVec::default(), &mut events), BallAction::Suspend ); - assert_eq!(game.claw.capture_entry, Some((ClawBallSlot::Primary, true))); + assert_eq!(game.claw.capture_entry, Some((0, true))); - game.secondary_ball = None; + game.set_secondary(None); game.score_mode = ScoreMode::Normal; while game.claw.active { game.update_claw(CLAW_FRAME_SECONDS, &mut events); @@ -5242,19 +5188,16 @@ mod tests { }) .expect("a seed must select the launcher release"); game.random = BorlandRandom::new(unrestricted_seed); - game.ball.position = CLAW_TRIGGER_CENTER; - game.ball.velocity = Vec2::ZERO; - game.ball.capture_age = 0; + game.balls[0].position = CLAW_TRIGGER_CENTER; + game.balls[0].velocity = Vec2::ZERO; + game.balls[0].capture_age = 0; game.record_contacts[89] = 0; assert_eq!( game.check_sensor_objects(claw_position, MilliVec::default(), &mut events), BallAction::Keep ); - assert_eq!( - game.claw.capture_entry, - Some((ClawBallSlot::Primary, false)) - ); + assert_eq!(game.claw.capture_entry, Some((0, false))); assert_eq!( game.check_sensor_objects(claw_position, MilliVec::default(), &mut events), BallAction::Suspend @@ -5281,26 +5224,26 @@ mod tests { #[test] fn claw_uses_the_original_circular_trigger_not_a_broad_rectangle() { let mut game = Game::new(1); - game.ball.in_launcher = false; - game.ball.position = CLAW_TRIGGER_CENTER + vec2(CLAW_TRIGGER_RADIUS + 0.1, 0.0); + game.balls[0].in_launcher = false; + game.balls[0].position = CLAW_TRIGGER_CENTER + vec2(CLAW_TRIGGER_RADIUS + 0.1, 0.0); game.check_sensor_objects( - MilliVec::from_position(game.ball.position), + MilliVec::from_position(game.balls[0].position), MilliVec::default(), &mut Vec::new(), ); assert!(!game.claw.active); - game.ball.position = CLAW_TRIGGER_CENTER; + game.balls[0].position = CLAW_TRIGGER_CENTER; let mut events = Vec::new(); game.check_sensor_objects( - MilliVec::from_position(game.ball.position), + MilliVec::from_position(game.balls[0].position), MilliVec::default(), &mut events, ); assert!(!game.claw.active); assert_eq!(events, [Event::Sound(2015)]); game.check_sensor_objects( - MilliVec::from_position(game.ball.position), + MilliVec::from_position(game.balls[0].position), MilliVec::default(), &mut events, ); @@ -5312,9 +5255,9 @@ mod tests { #[test] fn top_sensor_uses_the_original_score_and_contact_latch() { let mut game = Game::new_with_seed(1, 7); - game.ball.in_launcher = false; - game.ball.position = vec2(205.0, 55.0); - game.ball.velocity = vec2(100.0, 200.0); + game.balls[0].in_launcher = false; + game.balls[0].position = vec2(205.0, 55.0); + game.balls[0].velocity = vec2(100.0, 200.0); let mut events = Vec::new(); game.check_sensor_objects( @@ -5328,7 +5271,7 @@ mod tests { assert!(!game.object_active[6]); assert!(!game.object_active[154]); assert_eq!( - MilliVec::from_velocity_per_second(game.ball.velocity), + MilliVec::from_velocity_per_second(game.balls[0].velocity), MilliVec { x: 1_012, y: 1_926 } ); @@ -5351,19 +5294,19 @@ mod tests { assert!(!game.object_active[153]); game.check_sensor_objects( - MilliVec::from_position(game.ball.position), + MilliVec::from_position(game.balls[0].position), MilliVec::default(), &mut events, ); assert_eq!(game.player().score, 500, "contact must score only once"); - game.ball.position = vec2(205.0, 62.0); + game.balls[0].position = vec2(205.0, 62.0); game.check_sensor_objects( - MilliVec::from_position(game.ball.position), + MilliVec::from_position(game.balls[0].position), MilliVec::default(), &mut events, ); - game.ball.position = vec2(205.0, 55.0); + game.balls[0].position = vec2(205.0, 55.0); game.check_sensor_objects( MilliVec::from_position(vec2(205.0, 60.0)), MilliVec { x: 0, y: -5_000 }, @@ -5379,7 +5322,7 @@ mod tests { .find(|sensor| sensor.id == 140) .expect("record 140 must be present"); let mut game = Game::new(1); - let mut primary = game.ball; + let mut primary = game.balls[0]; primary.position = sensor.center; let mut events = Vec::new(); @@ -5466,10 +5409,10 @@ mod tests { game.object_active.fill(false); game.object_active[5] = true; game.object_active[6] = true; - game.ball.in_launcher = false; - game.ball.position = vec2(149.0, 430.0); - game.ball.velocity = MilliVec { x: -2_000, y: 0 }.to_velocity_per_second(); - let old_position = MilliVec::from_position(game.ball.position); + game.balls[0].in_launcher = false; + game.balls[0].position = vec2(149.0, 430.0); + game.balls[0].velocity = MilliVec { x: -2_000, y: 0 }.to_velocity_per_second(); + let old_position = MilliVec::from_position(game.balls[0].position); let mut expected_game = game.clone(); let mut expected_motion = MilliVec { x: -2_000, y: 15 }; @@ -5485,7 +5428,7 @@ mod tests { assert!(expected_game.apply_magnetic_record(6, old_position, &mut expected_motion)); let expected_response = candidate .2 - .resolve(expected_motion, expected_game.ball.spin); + .resolve(expected_motion, expected_game.balls[0].spin); let mut expected_velocity = expected_response.velocity; expected_velocity.clamp_speed(MAXIMUM_SPEED_MILLI_PER_STEP); @@ -5493,7 +5436,7 @@ mod tests { assert_eq!(game.last_collision_id, Some(5)); assert_eq!( - MilliVec::from_velocity_per_second(game.ball.velocity), + MilliVec::from_velocity_per_second(game.balls[0].velocity), expected_velocity ); assert_eq!(game.random.seed(), expected_game.random.seed()); @@ -5502,14 +5445,14 @@ mod tests { #[test] fn wheel_holes_accumulate_and_transfer_the_per_player_secondary_score() { let mut game = Game::new(1); - game.ball.in_launcher = false; + game.balls[0].in_launcher = false; let mut events = Vec::new(); let expected_bonus = [10_000, 30_000, 70_000, 150_000, 0]; for (index, sensor) in LOCK_HOLES.into_iter().enumerate() { complete_stationary_type_three_capture(&mut game, sensor.center, &mut events); assert_eq!(game.player().secondary_score, expected_bonus[index]); - assert!(game.ball.in_launcher); + assert!(game.balls[0].in_launcher); } assert_eq!(game.player().score, 310_000); assert_eq!(game.player().score_multiplier, 2); @@ -5571,7 +5514,7 @@ mod tests { assert_eq!(game.score_mode, ScoreMode::Normal); let mut reset_by_special_hole = game.clone(); - reset_by_special_hole.reset_ball_to_launcher(); + reset_by_special_hole.reset_ball_to_launcher(0); assert_eq!(reset_by_special_hole.panel_frame, Some(0)); game.score_mode = ScoreMode::Normal; @@ -5589,10 +5532,10 @@ mod tests { fn type_three_capture_pulls_holds_and_publishes_contact_sentinels() { let sensor = LOCK_HOLES[0]; let mut game = Game::new(1); - game.ball.in_launcher = false; - game.ball.position = sensor.center + vec2(5.0, 0.0); - game.ball.velocity = vec2(100.0, 0.0); - let previous = MilliVec::from_position(game.ball.position); + game.balls[0].in_launcher = false; + game.balls[0].position = sensor.center + vec2(5.0, 0.0); + game.balls[0].velocity = vec2(100.0, 0.0); + let previous = MilliVec::from_position(game.balls[0].position); let mut events = Vec::new(); assert_eq!( @@ -5607,14 +5550,14 @@ mod tests { CaptureStep::Holding ); assert_eq!( - MilliVec::from_velocity_per_second(game.ball.velocity), + MilliVec::from_velocity_per_second(game.balls[0].velocity), MilliVec { x: 750, y: -150 } ); assert_eq!(game.record_contacts[usize::from(sensor.id)], 0); - assert_eq!(game.ball.capture_age, 0); + assert_eq!(game.balls[0].capture_age, 0); - game.ball.position = sensor.center; - game.ball.velocity = Vec2::ZERO; + game.balls[0].position = sensor.center; + game.balls[0].velocity = Vec2::ZERO; assert_eq!( capture_primary_record_step( &mut game, @@ -5627,10 +5570,10 @@ mod tests { CaptureStep::Holding ); assert_eq!(game.record_contacts[usize::from(sensor.id)], 1); - assert_eq!(game.ball.capture_age, 5); + assert_eq!(game.balls[0].capture_age, 5); assert_eq!(events, [Event::Sound(2015)]); - game.ball.capture_age = 300; + game.balls[0].capture_age = 300; assert_eq!( capture_primary_record_step( &mut game, @@ -5643,7 +5586,7 @@ mod tests { CaptureStep::Complete ); assert_eq!(game.record_contacts[usize::from(sensor.id)], 99); - assert_eq!(game.ball.capture_age, 0); + assert_eq!(game.balls[0].capture_age, 0); } #[test] @@ -5652,10 +5595,10 @@ mod tests { let mut game = Game::new(1); game.object_active.fill(false); game.object_active[usize::from(sensor.id)] = true; - game.ball.in_launcher = false; - game.ball.position = sensor.center + vec2(7.5, 0.0); - game.ball.velocity = MilliVec { x: 3_800, y: 0 }.to_velocity_per_second(); - let old_position = MilliVec::from_position(game.ball.position); + game.balls[0].in_launcher = false; + game.balls[0].position = sensor.center + vec2(7.5, 0.0); + game.balls[0].velocity = MilliVec { x: 3_800, y: 0 }.to_velocity_per_second(); + let old_position = MilliVec::from_position(game.balls[0].position); let mut expected_velocity = MilliVec { x: 3_800, y: 15 }; expected_velocity.clamp_speed(MAXIMUM_SPEED_MILLI_PER_STEP); let damping = Real48::from_bytes([0x80, 0x66, 0x66, 0x66, 0x66, 0x66]); @@ -5671,11 +5614,11 @@ mod tests { assert!(!game.fixed_update(&mut Vec::new())); assert_eq!( - MilliVec::from_velocity_per_second(game.ball.velocity), + MilliVec::from_velocity_per_second(game.balls[0].velocity), expected_velocity ); assert_eq!( - MilliVec::from_position(game.ball.position), + MilliVec::from_position(game.balls[0].position), old_position.add(expected_velocity) ); } @@ -5687,18 +5630,18 @@ mod tests { game.object_active.fill(false); game.object_active[usize::from(sensor.id)] = true; game.record_contacts[usize::from(sensor.id)] = 99; - game.ball.in_launcher = false; - game.ball.position = sensor.center + vec2(17.0, 0.0); - game.ball.velocity = MilliVec { x: -3_000, y: 0 }.to_velocity_per_second(); - let old_position = MilliVec::from_position(game.ball.position); + game.balls[0].in_launcher = false; + game.balls[0].position = sensor.center + vec2(17.0, 0.0); + game.balls[0].velocity = MilliVec { x: -3_000, y: 0 }.to_velocity_per_second(); + let old_position = MilliVec::from_position(game.balls[0].position); assert!(game.fixed_update(&mut Vec::new())); - let velocity = MilliVec::from_velocity_per_second(game.ball.velocity); + let velocity = MilliVec::from_velocity_per_second(game.balls[0].velocity); assert!(velocity.x > 0, "the type-three rim must reflect the ball"); assert_eq!(game.last_collision_id, Some(sensor.id)); assert_eq!( - MilliVec::from_position(game.ball.position), + MilliVec::from_position(game.balls[0].position), old_position.add(velocity) ); } @@ -5710,9 +5653,9 @@ mod tests { game.object_active.fill(false); game.object_active[usize::from(sensor.id)] = true; game.record_contacts[usize::from(sensor.id)] = 1; - game.ball.in_launcher = false; - game.ball.position = sensor.center + vec2(23.0, 0.0); - game.ball.velocity = MilliVec { x: 3_800, y: 0 }.to_velocity_per_second(); + game.balls[0].in_launcher = false; + game.balls[0].position = sensor.center + vec2(23.0, 0.0); + game.balls[0].velocity = MilliVec { x: 3_800, y: 0 }.to_velocity_per_second(); game.fixed_update(&mut Vec::new()); @@ -5728,12 +5671,12 @@ mod tests { let mut game = Game::new_with_seed(1, 7); game.object_active.fill(false); game.object_active[usize::from(sensor.id)] = true; - game.ball.in_launcher = false; - game.ball.position = vec2(205.0, 58.0); - game.ball.velocity = MilliVec { x: 0, y: -2_000 }.to_velocity_per_second(); - let old_position = MilliVec::from_position(game.ball.position); + game.balls[0].in_launcher = false; + game.balls[0].position = vec2(205.0, 58.0); + game.balls[0].velocity = MilliVec { x: 0, y: -2_000 }.to_velocity_per_second(); + let old_position = MilliVec::from_position(game.balls[0].position); let mut expected_game = game.clone(); - let mut expected_ball = game.ball; + let mut expected_ball = game.balls[0]; expected_ball.velocity = MilliVec { x: 0, y: -1_985 }.to_velocity_per_second(); expected_game.randomize_trigger_velocity(&mut expected_ball); let expected_velocity = MilliVec::from_velocity_per_second(expected_ball.velocity); @@ -5742,11 +5685,11 @@ mod tests { assert_eq!(game.player().score, 500); assert_eq!( - MilliVec::from_velocity_per_second(game.ball.velocity), + MilliVec::from_velocity_per_second(game.balls[0].velocity), expected_velocity ); assert_eq!( - MilliVec::from_position(game.ball.position), + MilliVec::from_position(game.balls[0].position), old_position.add(expected_velocity) ); assert_eq!(game.random.seed(), expected_game.random.seed()); @@ -5792,7 +5735,7 @@ mod tests { #[test] fn target_rotation_uses_six_callbacks_and_rotates_player_state() { let mut game = Game::new(1); - game.ball.in_launcher = false; + game.balls[0].in_launcher = false; game.wheel_holes = [true, false, true, false, false]; game.record_contacts[129..=133].copy_from_slice(&[11, 22, 33, 44, 55]); game.target_rotation_state = Some(0); @@ -5818,7 +5761,7 @@ mod tests { game.timer_tick(0.030, 0, &mut Vec::new()); assert_eq!(game.target_rotation_state, Some(2)); - game.ball.in_launcher = false; + game.balls[0].in_launcher = false; game.timer_tick(0.030, 0, &mut Vec::new()); assert_eq!(game.target_rotation_state, Some(3)); @@ -5840,7 +5783,7 @@ mod tests { "launcher idle pauses countdowns" ); - game.ball.in_launcher = false; + game.balls[0].in_launcher = false; for expected in (0..5).rev() { game.timer_tick(0.030, 0, &mut Vec::new()); assert_eq!(game.record_countdown(51), expected); @@ -5882,9 +5825,9 @@ mod tests { fn drain_clears_latched_tilt() { let mut game = Game::new(1); game.tilted = true; - game.ball.in_launcher = false; - game.ball.position = vec2(157.0, 454.0); - game.ball.velocity = vec2(0.0, 200.0); + game.balls[0].in_launcher = false; + game.balls[0].position = vec2(157.0, 454.0); + game.balls[0].velocity = vec2(0.0, 200.0); let held = Controls { left_flipper: true, @@ -5893,7 +5836,7 @@ mod tests { let events = game.update(0.030, 3, held); assert!(!game.tilted); - assert!(game.ball.in_launcher); + assert!(game.balls[0].in_launcher); assert!(!events.contains(&Event::Sound(2008))); assert!(game.flipper_release_latch[0]); game.update(0.0, 3, held); @@ -5954,7 +5897,7 @@ mod tests { } assert!(game.tilted); - assert!(game.ball.in_launcher); + assert!(game.balls[0].in_launcher); } #[test] @@ -5972,7 +5915,7 @@ mod tests { ); } assert!(game.tilted); - assert!(game.ball.in_launcher); + assert!(game.balls[0].in_launcher); game.update( 0.0, @@ -5987,7 +5930,7 @@ mod tests { let events = game.update(0.0, 3, Controls::default()); assert!(events.contains(&Event::Launch)); assert!(!events.contains(&Event::Sound(2002))); - assert!(!game.ball.in_launcher); + assert!(!game.balls[0].in_launcher); assert!(game.tilted); } } diff --git a/tdkpin-rs/src/simulation.rs b/tdkpin-rs/src/simulation.rs index 0459536..4c3606a 100644 --- a/tdkpin-rs/src/simulation.rs +++ b/tdkpin-rs/src/simulation.rs @@ -187,8 +187,8 @@ pub struct Snapshot { pub scenario: &'static str, pub step: u64, pub seconds: f64, - pub ball: BallSnapshot, - pub secondary_ball: Option, + /// Every ball in slot order, including one held by the claw. + pub balls: Vec, pub claw: ClawSnapshot, pub flippers: FlipperSnapshot, pub launcher_charge: f32, @@ -305,20 +305,18 @@ impl Simulation { scenario: self.scenario.name(), step: self.step, seconds: f64::from(step) / SIMULATION_HZ_F64, - ball: BallSnapshot { - x: self.game.ball.position.x, - y: self.game.ball.position.y, - velocity_x: self.game.ball.velocity.x, - 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, - }), + balls: self + .game + .balls + .iter() + .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, + }) + .collect(), claw: ClawSnapshot { active: self.game.claw.active, frame: self.game.claw.frame, @@ -340,7 +338,7 @@ impl Simulation { } fn autoplay_controls(&mut self) -> Controls { - if self.game.ball.in_launcher { + if self.game.balls.iter().any(|ball| ball.in_launcher) { let launch_down = self.autoplay_launcher_frames < 132; self.autoplay_launcher_frames += 1; Controls { @@ -349,11 +347,15 @@ impl Simulation { } } else { self.autoplay_launcher_frames = 0; + let near_flipper = |x_range: std::ops::Range| { + self.game + .balls + .iter() + .any(|ball| ball.position.y > 340.0 && x_range.contains(&ball.position.x)) + }; Controls { - left_flipper: self.game.ball.position.y > 340.0 - && self.game.ball.position.x < 175.0, - right_flipper: self.game.ball.position.y > 340.0 - && self.game.ball.position.x > 140.0, + left_flipper: near_flipper(0.0..175.0), + right_flipper: near_flipper(140.0..f32::INFINITY), ..Controls::default() } } @@ -456,16 +458,12 @@ mod tests { assert!(event_count("Target") >= 1); assert!(event_count("Bumper") >= 1); assert!(simulation.trace.iter().all(|snapshot| { - snapshot.ball.x.is_finite() - && 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() - }) + snapshot.balls.iter().all(|ball| { + ball.x.is_finite() + && ball.y.is_finite() + && ball.velocity_x.is_finite() + && ball.velocity_y.is_finite() + }) })); } @@ -473,11 +471,11 @@ mod tests { fn launcher_scenario_holds_for_one_second_then_releases() { let mut simulation = Simulation::new(Scenario::Launcher, 1); simulation.advance_to(60); - assert!(simulation.game.ball.in_launcher); + assert!(simulation.game.balls[0].in_launcher); assert!((0.19..=0.21).contains(&simulation.game.launcher_charge)); simulation.advance_to(121); - assert!(!simulation.game.ball.in_launcher); + assert!(!simulation.game.balls[0].in_launcher); assert!(simulation.trace[121].events.contains(&"Launch".to_owned())); } @@ -594,8 +592,8 @@ mod tests { scenario.name() ); assert!(captures >= releases); - assert!(simulation.game.ball.position.is_finite()); - assert!(simulation.game.ball.velocity.is_finite()); + assert!(simulation.game.balls[0].position.is_finite()); + assert!(simulation.game.balls[0].velocity.is_finite()); } } }