From a8cc0fc5fc46bcf72dd8932e161f7e1231fbcf54 Mon Sep 17 00:00:00 2001 From: ddidderr Date: Sat, 10 Oct 2026 11:15:11 +0200 Subject: [PATCH] feat(divergence): make every ball identical and bind crazy mode to 0 Crazy mode first shipped as a side list of "crazy balls" with reduced physics (walls, bumpers, flippers only) next to the original primary and secondary slots. That is not what was wanted: a ball in the machine is a ball, with no main/second/extra distinction. This replaces the two-slot engine with one ordered list and moves the shortcut to a single key. Ball model (src/game.rs) - Game::balls: Vec replaces `ball`, `secondary_ball`, and `crazy_balls`. The list is never empty while the game runs; a waiting ball is simply a ball with in_launcher set. - advance_ball_slots runs one timer callback: every ball, in slot order, integrates up to `substeps` steps through the single integrator step_ball (record scan, ball-to-ball contact, rule dispatch, drain) and stops after its first collision, exactly like the original slot pass. - BallPass carries the ball's current index plus the slot number and ball count from the start of the callback. The original keeps slot two numbered 2 for the rest of a callback even after slot one drained, and type-three contact records publish that number as their owner; keeping it per pass preserves that. - Effect seven's ball goes to `spawned_balls` and joins after all slot passes, as the original publishes slot two only after slot one's pass. - lose_ball removes a drained ball while others remain (Drain event, normal scoring once one ball is left) and only the last ball takes the special respawn or ends the player's ball. remove_ball keeps the claw's captured/entry indices and the claw and lock-hole contact owners pointing at the same physical ball when an earlier slot leaves. - The claw stores a ball index instead of a Primary/Secondary enum. - Nudge, flipper kicks, multiball scoring, and the "no third ball from effect seven" guard are expressed over the list. With one or two balls the original order, random draws, and ownership rules are unchanged; all existing parity and multiball tests pass after a mechanical port (test-only shims fixed_update, advance_secondary_ball, drain, secondary/set_secondary/take_secondary keep them readable). - Crazy mode adds ten ordinary balls (capped at MAX_BALLS = 64 because contact records reserve 99 as a sentinel) and marks the player. Deliberate differences from the original (also in PARITY_AUDIT.md), mostly because there is no longer a special slot one: - A ball resting on the shooter stop returns to an empty launcher from any slot; the original deleted a slot-two ball there. If the launcher is occupied the ball leaves the table without costing a ball. - Flipper kicks, record countdowns, and target rotation keep running for balls in play while another ball waits in the launcher. - Ball-to-ball contact skips only the claw-held ball; every ball's hit is published as last_collision_id. - Stress runs with 10-64 balls showed balls trapped forever: two balls settle into one lock hole, one fills it (contact 99), and the rim then reflects the other from the inside. A filled hole's rim now only repels balls moving towards its center. Lock-hole owners of a removed ball are cleared/renumbered like the claw owner. Input (src/app.rs) - Crazy mode is the bare main-row 0 key. It overlaps no original or modern binding and has no browser default action, so the WASM build receives it. The Ctrl+Shift+0 combo and its Shift-release nudge suppression are gone. - Rendering draws every ball not held by the claw. Simulator: snapshots report `balls: [...]` instead of `ball` and `secondary_ball`; autoplay flips for any ball near a flipper. Test Plan: - cargo test (160 tests), including new ones: a_ball_moves_the_same_in_any_slot (same ball in slot one vs. behind a waiting ball follows the identical trajectory), crazy_mode_adds_ten_ordinary_balls_and_marks_the_player, the_turn_ends_only_when_the_last_ball_drains, shooter_stop_rearms_a_returning_ball_from_any_slot, crazy_mode_is_capped_and_rejected_after_the_game. - A temporary release-mode stress test (not committed) ran 12 seeded two-player games with crazy mode, autoplay flippers, and nudges to completion, and 4 seeds at the 64-ball cap for 30k frames, asserting finite state, a non-empty ball list, and valid claw indices. - Manual: during a game press 0 (also in the browser build); ten balls appear, each can be captured by the claw, lock holes, and targets, and the turn ends only after the last ball drains. --- tdkpin-rs/CHANGELOG.md | 22 +- tdkpin-rs/PARITY_AUDIT.md | 22 + tdkpin-rs/README.md | 12 +- tdkpin-rs/RECONSTRUCTION.md | 15 +- tdkpin-rs/src/app.rs | 49 +- tdkpin-rs/src/game.rs | 1939 +++++++++++++++++------------------ tdkpin-rs/src/simulation.rs | 68 +- 7 files changed, 1043 insertions(+), 1084 deletions(-) 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()); } } }