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<Ball> 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.
This commit is contained in:
ddidderr committed 2026-10-10 11:15:11 +02:00
1 parent 3a00be5661
commit a8cc0fc5fc
7 files changed
+1043 -1084

No files matched your search

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