Files
tdkpin/tdkpin-rs/src/game.rs
T
ddidderr 139e641005 feat: add Ctrl+Shift+0 crazy mode with ten extra balls
Pressing Ctrl+Shift+0 during a game drops ten balls at fixed points
spread over the open playfield (CRAZY_BALL_POSITIONS): two near the top
bumpers, a row in the middle, and four above the slingshots. Repeated
presses add another ten each time.

The original engine is a faithful two-slot reconstruction (primary and
secondary ball) whose claw, lock-hole, special-hole, and effect-seven
rules all assume at most two balls. Rather than generalising that
engine, crazy balls live in a separate Game::crazy_balls list with a
reduced step:

- gravity, speed clamp, and the same static record ranges as the main
  scan (walls, passive circles, bumpers, flippers);
- wall and bumper rules, so they score, light banks, and kick normally;
- flipper kicks and nudge impulses;
- no sensors, holes, magnets, claw, or ball-to-ball collisions.

A crazy ball that leaves the board, hits the drain, or comes to rest on
the shooter stop is removed. When the primary ball drains (and no
secondary or special respawn takes priority), a crazy ball is promoted
into the primary slot, so the turn only ends once every ball is gone.
The promoted ball then runs the full original rules.

Because the mode is effectively a cheat, Game records that it was used
and the browser build skips the online high-score submission for that
game; the local table still records the score.

The App suppresses the nudge that releasing Shift would otherwise fire
after the combo. Ctrl also raises the left flipper while held, which
is left as is. Browsers may reserve Ctrl+Shift+0 for themselves.

A new `crazy` simulation scenario starts a game with crazy balls.

Test Plan:
- cargo test: new tests check every drop point is clear of all static
  records, that the balls move, score, and eventually drain without
  costing a ball, that a primary drain promotes a crazy ball, and that a
  finished game rejects the mode.
- Manual: start a game, press Ctrl+Shift+0, release Shift, and confirm
  ten balls appear and no nudge/tilt sound plays.
2026-10-10 10:48:50 +02:00

5968 lines
209 KiB
Rust

use macroquad::prelude::{Rect, Vec2, vec2};
use crate::{
borland_random::BorlandRandom,
flipper_physics::{FlipperSide, moving_flipper_response},
geometry::Segment,
original_physics::{
CollisionMaterial,
CollisionResponse,
GRAVITY_MILLI_PER_STEP,
MAXIMUM_SPEED_MILLI_PER_STEP,
MilliVec,
StaticCollisionCandidate,
ball_collision_response,
capture_collision_candidate,
circle_collision_candidate_at,
line_collision_candidate_at,
milli_distance,
path_intersects_circle,
},
real48::Real48,
table::{
BUMPERS,
EFFECT_SENSOR,
LOCK_HOLES,
PASSIVE_CIRCLES,
SPECIAL_HOLE_SENSOR,
TARGET_SENSORS,
WALLS,
},
};
const LEFT_FLIPPER_RAISED_TIP: Vec2 = Vec2::new(133.0, 377.0);
const RIGHT_FLIPPER_RAISED_TIP: Vec2 = Vec2::new(181.0, 376.0);
const LAUNCHER_POSITION: Vec2 = Vec2::new(325.0, 413.0);
const LAUNCHER_PRESS_IMPULSE_MILLI: i32 = 750;
const LAUNCHER_RELEASE_IMPULSE_MILLI: i32 = 1_500;
const LAUNCHER_REPEAT_DELAY_SECONDS: f32 = 0.650;
const LAUNCHER_REPEAT_SECONDS: f32 = 0.040;
const CLAW_TRIGGER_CENTER: Vec2 = Vec2::new(289.0, 94.0);
const CLAW_TRIGGER_RADIUS: f32 = 19.0;
// The default original timer fires every 30 ms and advances the claw by one
// frame. Keeping that cadence independent of render rate makes captures
// deterministic on modern machines.
const CLAW_FRAME_SECONDS: f32 = 0.030;
const DETAIL_TIMER_SECONDS: [f32; 5] = [0.050, 0.040, 0.030, 0.020, 0.010];
const DETAIL_SUBSTEPS: [u8; 5] = [5, 4, 3, 2, 1];
const CLAW_TERMINAL_FRAMES: [u8; 4] = [1, 6, 7, 18];
const CLAW_TABLE_TERMINAL_FRAMES: [u8; 3] = [1, 6, 7];
const ORIGINAL_BALL_SPEED_PER_SECOND: f32 = 380.0;
#[allow(clippy::cast_possible_wrap)]
const fn score_reaches_marker(score: u32, threshold: u32) -> bool {
// 1000:bc36 compares signed high words and, when equal, unsigned low
// words. That is exactly a signed 32-bit comparison of the bit patterns.
(score as i32) >= (threshold as i32)
}
#[derive(Clone, Copy, Debug, Default)]
pub struct Controls {
pub left_flipper: bool,
pub right_flipper: bool,
pub launch_down: bool,
pub nudge: Nudge,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Nudge {
#[default]
None,
Left,
Right,
Center,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Event {
FlipperMove,
Launch,
Bumper,
Target,
Wheel,
ClawCapture,
ClawRelease,
Lock,
Media,
ExtraBall,
Nudge,
Tilt,
Drain,
HighScoreCandidate(u32),
StopSound,
Sound(u16),
}
impl Event {
/// Explicit original Win16 WAVE resource selected at this control-flow site.
pub const fn sound_resource(self) -> Option<u16> {
match self {
Self::Sound(resource) => Some(resource),
Self::FlipperMove
| Self::Launch
| Self::Bumper
| Self::Target
| Self::Wheel
| Self::ClawCapture
| Self::ClawRelease
| Self::Lock
| Self::Media
| Self::ExtraBall
| Self::Nudge
| Self::Tilt
| Self::Drain
| Self::HighScoreCandidate(_)
| Self::StopSound => None,
}
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct Flippers {
pub left_raised: bool,
pub right_raised: bool,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ClawSpriteBank {
Closing,
#[default]
Opening,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ClawBallSlot {
Primary,
Secondary,
}
impl ClawBallSlot {
const fn from_ball_number(ball_number: u16) -> Self {
if ball_number == 2 {
Self::Secondary
} else {
Self::Primary
}
}
}
#[derive(Clone, Copy, Debug)]
pub struct Claw {
pub active: bool,
pub frame: u8,
pub bank: ClawSpriteBank,
pub ball_suspended: bool,
captured_slot: Option<ClawBallSlot>,
// The deep zone is less than 16 px wide while ball contact is 17 px, so
// only one physically non-overlapping ball can own a pending entry.
capture_entry: Option<(ClawBallSlot, bool)>,
target_frame: u8,
frame_accumulator: f32,
}
impl Default for Claw {
fn default() -> Self {
Self {
active: false,
frame: 10,
bank: ClawSpriteBank::Opening,
ball_suspended: false,
captured_slot: None,
capture_entry: None,
target_frame: 10,
frame_accumulator: 0.0,
}
}
}
impl Claw {
pub fn sprite_source(self) -> Option<Rect> {
if !self.active {
return None;
}
let mut column_frame = self.frame;
let mut source_y = match self.bank {
ClawSpriteBank::Closing => 0.0,
ClawSpriteBank::Opening => 148.0,
};
if column_frame > 9 {
column_frame -= 9;
source_y += 74.0;
}
Some(Rect::new(
f32::from(column_frame - 1) * 100.0,
source_y,
100.0,
74.0,
))
}
}
#[derive(Clone, Debug)]
pub struct Player {
pub score: u32,
pub secondary_score: u32,
pub score_multiplier: u8,
pub balls: u8,
pub extra_balls: u8,
pub bumper_value: u32,
pub diamond_segments: u8,
pub media_level: u8,
rules: RuleState,
}
impl Default for Player {
fn default() -> Self {
Self {
score: 0,
secondary_score: 0,
score_multiplier: 1,
balls: 3,
extra_balls: 0,
bumper_value: 1_000,
diamond_segments: 0,
media_level: 0,
rules: RuleState::default(),
}
}
}
#[derive(Clone, Copy, Debug)]
pub struct Ball {
pub position: Vec2,
pub velocity: Vec2,
pub in_launcher: bool,
spin: Real48,
capture_age: i32,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum PlayerEntry {
Open,
Closed,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
enum MultiballState {
#[default]
Unavailable,
Ready,
Active,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
enum ScoreMode {
#[default]
Normal,
Multiball,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
enum BallDoubleState {
#[default]
Inactive,
Active,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
enum CrazyMode {
#[default]
Unused,
Used,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
enum SpecialHoleGate {
#[default]
Enabled,
Suppressed,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum CaptureStep {
Outside,
Holding,
Complete,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum BallAction {
Keep,
Suspend,
Reset,
Remove,
Finish,
}
#[derive(Clone, Copy, Debug)]
struct SensorScanResult {
action: BallAction,
}
fn retain_first_collision(
best: &mut Option<(u8, bool, StaticCollisionCandidate)>,
candidate: (u8, bool, StaticCollisionCandidate),
) {
if best.is_none() {
*best = Some(candidate);
}
}
const fn predicted_outside_board(position: MilliVec) -> bool {
position.x <= 0 || position.x > 340_000 || position.y <= 0 || position.y > 460_000
}
fn trigger_broadphase_contains(predicted: MilliVec, center: Vec2, radius: f32) -> bool {
let center = MilliVec::from_position(center);
let margin = MilliVec::from_position(vec2(radius + 5.0, 0.0)).x;
predicted.x >= center.x.wrapping_sub(margin)
&& predicted.x <= center.x.wrapping_add(margin)
&& predicted.y >= center.y.wrapping_sub(margin)
&& predicted.y <= center.y.wrapping_add(margin)
}
const fn movement_from_prediction(old_position: MilliVec, predicted: MilliVec) -> MilliVec {
MilliVec {
x: predicted.x.wrapping_sub(old_position.x),
y: predicted.y.wrapping_sub(old_position.y),
}
}
#[derive(Clone, Copy, Debug)]
struct RuleState {
wheel_holes: [bool; 5],
top_targets: [bool; 3],
record_contacts: [u16; 176],
object_active: [bool; 176],
target_effect: u8,
multiball_state: MultiballState,
}
impl Default for RuleState {
fn default() -> Self {
Self {
wheel_holes: [false; 5],
top_targets: [false; 3],
record_contacts: [0; 176],
object_active: initial_object_activity(),
target_effect: 0,
multiball_state: MultiballState::Unavailable,
}
}
}
fn initial_object_activity() -> [bool; 176] {
let mut active = [true; 176];
active[0] = false;
for object_id in [6, 87, 88, 149, 153, 154, 175] {
active[object_id] = false;
}
active
}
impl Default for Ball {
fn default() -> Self {
Self {
position: LAUNCHER_POSITION,
velocity: Vec2::ZERO,
in_launcher: true,
spin: Real48::ZERO,
capture_age: 0,
}
}
}
/// Drop points for the Ctrl+Shift+0 crazy mode, spread over open playfield
/// between the bumpers, slingshots, and lanes.
pub const CRAZY_BALL_POSITIONS: [Vec2; 10] = [
vec2(170.0, 112.0),
vec2(250.0, 140.0),
vec2(120.0, 160.0),
vec2(90.0, 215.0),
vec2(250.0, 215.0),
vec2(110.0, 260.0),
vec2(215.0, 260.0),
vec2(160.0, 300.0),
vec2(120.0, 340.0),
vec2(195.0, 335.0),
];
#[derive(Clone, Debug)]
pub struct Game {
pub players: Vec<Player>,
pub current_player: usize,
pub ball: Ball,
pub secondary_ball: Option<Ball>,
/// Extra balls added by crazy mode. They are not part of the original
/// two-slot engine: they collide with static records, score through the
/// wall and bumper rules, and replace the primary ball when it drains.
pub crazy_balls: Vec<Ball>,
pub wheel_holes: [bool; 5],
pub top_targets: [bool; 3],
pub tilted: bool,
pub flippers: Flippers,
flipper_inputs: Flippers,
pub claw: Claw,
pub target_rotation_state: Option<u8>,
pub panel_frame: Option<u16>,
pub launcher_charge: f32,
pub finished: bool,
pub last_collision_id: Option<u8>,
accumulator: f32,
record_countdowns: [u8; 176],
tilt_counter: u16,
launcher_was_down: bool,
launcher_hold_seconds: f32,
launcher_next_repeat: f32,
launcher_velocity_milli: i32,
player_entry: PlayerEntry,
random: BorlandRandom,
pending_flipper_edges: [i8; 2],
flipper_release_latch: [bool; 2],
record_contacts: [u16; 176],
trigger_flags: [bool; 176],
object_active: [bool; 176],
target_effect: u8,
claw_frame_seconds: f32,
multiball_state: MultiballState,
special_hole_gate: SpecialHoleGate,
score_mode: ScoreMode,
ball_double: BallDoubleState,
crazy_mode: CrazyMode,
}
impl Game {
#[cfg(not(target_arch = "wasm32"))]
pub fn new(player_count: usize) -> Self {
Self::new_with_seed(player_count, macroquad::rand::rand())
}
pub fn new_with_seed(player_count: usize, seed: u32) -> Self {
Self {
players: vec![Player::default(); player_count.clamp(1, 4)],
current_player: 0,
ball: Ball::default(),
secondary_ball: None,
crazy_balls: Vec::new(),
wheel_holes: [false; 5],
top_targets: [false; 3],
tilted: false,
flippers: Flippers::default(),
flipper_inputs: Flippers::default(),
claw: Claw::default(),
target_rotation_state: None,
panel_frame: None,
launcher_charge: 0.0,
finished: false,
last_collision_id: None,
accumulator: 0.0,
record_countdowns: [0; 176],
tilt_counter: 0,
launcher_was_down: false,
launcher_hold_seconds: 0.0,
launcher_next_repeat: LAUNCHER_REPEAT_DELAY_SECONDS,
launcher_velocity_milli: 0,
player_entry: PlayerEntry::Open,
random: BorlandRandom::new(seed),
pending_flipper_edges: [0; 2],
flipper_release_latch: [false; 2],
record_contacts: [0; 176],
trigger_flags: [false; 176],
object_active: initial_object_activity(),
target_effect: 0,
claw_frame_seconds: CLAW_FRAME_SECONDS,
multiball_state: MultiballState::Unavailable,
special_hole_gate: SpecialHoleGate::Enabled,
score_mode: ScoreMode::Normal,
ball_double: BallDoubleState::Inactive,
crazy_mode: CrazyMode::Unused,
}
}
pub fn player(&self) -> &Player {
&self.players[self.current_player]
}
pub fn random_seed(&self) -> u32 {
self.random.seed()
}
pub fn secondary_score_display(&self) -> u32 {
self.player()
.secondary_score
.wrapping_mul(u32::from(self.player().score_multiplier))
}
pub fn launcher_frame(&self) -> usize {
if !self.launcher_was_down {
return 0;
}
(1..=10)
.find(|multiple| {
-LAUNCHER_PRESS_IMPULSE_MILLI * i32::try_from(*multiple).unwrap_or(10)
< self.launcher_velocity_milli
})
.unwrap_or(10)
}
pub fn add_player_before_launch(&mut self) -> bool {
if self.player_entry == PlayerEntry::Closed || self.players.len() >= 4 {
return false;
}
self.players.push(Player::default());
true
}
/// Whether crazy mode was used, so its scores stay off the online table.
#[cfg(any(target_arch = "wasm32", test))]
pub fn crazy_mode_used(&self) -> bool {
self.crazy_mode == CrazyMode::Used
}
/// Crazy mode: drop ten extra balls onto the playfield.
pub fn add_crazy_balls(&mut self) -> bool {
if self.finished {
return false;
}
self.crazy_mode = CrazyMode::Used;
self.crazy_balls
.extend(CRAZY_BALL_POSITIONS.into_iter().map(|position| Ball {
position,
velocity: Vec2::ZERO,
in_launcher: false,
..Ball::default()
}));
true
}
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn begin_claw_scenario(&mut self, terminal_frame: u8) -> Vec<Event> {
self.ball.in_launcher = false;
self.ball.position = CLAW_TRIGGER_CENTER;
self.ball.velocity = Vec2::ZERO;
let mut events = Vec::new();
self.begin_claw_capture(terminal_frame, &mut events);
events
}
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn begin_effect_scenario(&mut self, effect: u8) {
self.target_effect = effect.min(7);
self.object_active[usize::from(EFFECT_SENSOR.id)] = self.target_effect != 0;
if self.target_effect == 7 {
self.multiball_state = MultiballState::Ready;
self.record_contacts[148] = 2;
}
}
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn begin_target_scenario(&mut self) {
self.ball.in_launcher = false;
self.ball.position = vec2(170.0, 230.0);
self.players[0].bumper_value = 6_000;
self.wheel_holes = [true, false, true, false, false];
self.target_rotation_state = Some(0);
self.object_active[90] = false;
self.object_active[109] = false;
}
fn fire_launcher(&mut self) {
self.launcher_velocity_milli = self
.launcher_velocity_milli
.wrapping_sub(LAUNCHER_RELEASE_IMPULSE_MILLI);
let mut launch_velocity = self.launcher_velocity_milli;
if launch_velocity < -MAXIMUM_SPEED_MILLI_PER_STEP {
let variation = self.random.below(3_800) / 40;
launch_velocity = -MAXIMUM_SPEED_MILLI_PER_STEP + i32::from(variation);
} else if launch_velocity > -2_280 {
launch_velocity =
launch_velocity.wrapping_sub(i32::from(self.random.below(3_800) / 40));
}
self.ball.in_launcher = false;
self.ball.velocity = MilliVec {
x: 0,
y: launch_velocity,
}
.to_velocity_per_second();
self.launcher_charge = 0.0;
self.launcher_was_down = false;
self.launcher_hold_seconds = 0.0;
self.launcher_next_repeat = LAUNCHER_REPEAT_DELAY_SECONDS;
self.launcher_velocity_milli = 0;
self.player_entry = PlayerEntry::Closed;
}
pub fn update(&mut self, frame_time: f32, detail: u8, controls: Controls) -> Vec<Event> {
if self.finished {
return Vec::new();
}
self.last_collision_id = None;
self.claw_frame_seconds = DETAIL_TIMER_SECONDS[usize::from(detail.clamp(1, 5) - 1)];
let mut events = Vec::new();
if !controls.left_flipper {
self.flipper_release_latch[0] = false;
} else if self.tilted {
self.flipper_release_latch[0] = true;
}
if !controls.right_flipper {
self.flipper_release_latch[1] = false;
} else if self.tilted {
self.flipper_release_latch[1] = true;
}
self.flipper_inputs.left_raised =
controls.left_flipper && !self.tilted && !self.flipper_release_latch[0];
self.flipper_inputs.right_raised =
controls.right_flipper && !self.tilted && !self.flipper_release_latch[1];
// The original key handlers keep accepting the launcher key while
// tilted; Tilt disables flippers and scoring, but must not strand a
// ball that is still waiting in the shooter lane.
if self.ball.in_launcher {
if controls.launch_down {
if self.launcher_was_down {
self.launcher_hold_seconds += frame_time.min(0.05);
while self.launcher_hold_seconds >= self.launcher_next_repeat {
self.launcher_velocity_milli = self
.launcher_velocity_milli
.wrapping_sub(LAUNCHER_PRESS_IMPULSE_MILLI);
self.launcher_next_repeat += LAUNCHER_REPEAT_SECONDS;
}
} else {
self.launcher_velocity_milli = self
.launcher_velocity_milli
.wrapping_sub(LAUNCHER_PRESS_IMPULSE_MILLI);
self.launcher_was_down = true;
}
let charge_milli =
(-self.launcher_velocity_milli).clamp(0, MAXIMUM_SPEED_MILLI_PER_STEP);
self.launcher_charge =
f32::from(i16::try_from(charge_milli).unwrap_or(3_800)) / f32::from(3_800_i16);
} else if self.launcher_was_down {
self.fire_launcher();
events.push(Event::Launch);
if !self.tilted {
events.push(Event::Sound(2002));
}
}
}
if !self.tilted && controls.nudge != Nudge::None {
self.apply_nudge(controls.nudge, &mut events);
}
self.accumulator = (self.accumulator + frame_time.min(0.05)).min(0.1);
let detail_index = usize::from(detail.clamp(1, 5) - 1);
let timer_interval = DETAIL_TIMER_SECONDS[detail_index];
while self.accumulator >= timer_interval {
self.timer_tick(timer_interval, DETAIL_SUBSTEPS[detail_index], &mut events);
self.accumulator -= timer_interval;
}
events
}
fn timer_tick(&mut self, elapsed: f32, substeps: u8, events: &mut Vec<Event>) {
self.update_claw(elapsed, events);
if !self.ball.in_launcher {
self.update_record_countdowns();
self.update_target_rotation(events);
}
let panel_active = self.update_panel_completion(events);
// A held claw ball still counts as the second live ball until the
// other slot disappears or the claw releases it.
if self.secondary_ball.is_none()
&& self.claw.captured_slot != Some(ClawBallSlot::Secondary)
&& self.score_mode == ScoreMode::Multiball
{
self.score_mode = ScoreMode::Normal;
}
if !panel_active {
let had_secondary_ball = self.secondary_ball.is_some();
let mut spawned_secondary = None;
let primary_held = self.claw_holds_slot(ClawBallSlot::Primary);
let secondary_held = self.claw_holds_slot(ClawBallSlot::Secondary);
if had_secondary_ball {
self.score_mode = ScoreMode::Multiball;
}
if !primary_held {
for _ in 0..substeps {
let stop = self.fixed_update(events);
// Effect seven publishes its spawn request during record 149,
// but the original timer does not create slot two until the
// complete slot-one simulation pass has returned.
if !had_secondary_ball && spawned_secondary.is_none() {
spawned_secondary = self.secondary_ball.take();
}
if stop || self.finished || self.claw_holds_slot(ClawBallSlot::Primary) {
break;
}
}
}
if had_secondary_ball && !self.finished {
if self.secondary_ball.is_some() && !self.claw_holds_slot(ClawBallSlot::Secondary) {
for _ in 0..substeps {
if self.advance_secondary_ball_slot(events, !primary_held) {
break;
}
}
} else if !self.claw_holds_slot(ClawBallSlot::Primary)
&& !self.claw_holds_slot(ClawBallSlot::Secondary)
{
self.secondary_ball = Some(self.ball);
for _ in 0..substeps {
if self.advance_secondary_ball_slot(events, false) {
break;
}
}
if let Some(survivor) = self.secondary_ball.take() {
self.ball = survivor;
} else {
self.drain(events);
}
}
}
if let Some(spawned) = spawned_secondary {
debug_assert!(self.secondary_ball.is_none());
self.secondary_ball = Some(spawned);
}
// The two slots are mutually exclusive while the claw is holding
// a ball.
debug_assert!(!(primary_held && secondary_held));
if !self.finished {
self.advance_crazy_balls(substeps, events);
}
}
self.pending_flipper_edges[0] =
match (self.flipper_inputs.left_raised, self.flippers.left_raised) {
(true, false) => -1,
(false, true) => 1,
_ => 0,
};
self.pending_flipper_edges[1] =
match (self.flipper_inputs.right_raised, self.flippers.right_raised) {
(true, false) => -1,
(false, true) => 1,
_ => 0,
};
self.flippers = self.flipper_inputs;
for edge in self.pending_flipper_edges {
if edge != 0 {
events.push(Event::FlipperMove);
events.push(Event::Sound(2021));
}
}
self.apply_flipper_kicks();
if self.tilt_counter != 0 {
self.tilt_counter -= 1;
}
}
fn update_record_countdowns(&mut self) {
for countdown in &mut self.record_countdowns[1..] {
if *countdown != 0 {
*countdown -= 1;
}
}
}
pub fn record_countdown(&self, record_id: usize) -> u8 {
self.record_countdowns[record_id]
}
fn update_target_rotation(&mut self, events: &mut Vec<Event>) {
let Some(state) = self.target_rotation_state else {
return;
};
if state >= 6 {
self.target_rotation_state = None;
return;
}
let next = state + 1;
self.target_rotation_state = Some(next);
if next == 1 {
events.push(Event::Sound(2011));
}
if next == 6 {
self.wheel_holes.rotate_left(1);
let first = self.record_contacts[129];
for record_id in 129..133 {
self.record_contacts[record_id] = self.record_contacts[record_id + 1];
}
self.record_contacts[133] = first;
}
}
fn update_panel_completion(&mut self, events: &mut Vec<Event>) -> bool {
let Some(frame) = self.panel_frame else {
return false;
};
if frame >= 281 {
self.panel_frame = None;
self.wheel_holes.fill(false);
return true;
}
if frame == 1 {
for record_id in 129..=133 {
self.record_contacts[record_id] = 0;
}
}
let next = frame + 1;
self.panel_frame = Some(next);
match next {
1 | 66 | 159 | 238 => events.push(Event::Sound(2013)),
45 | 129 | 222 => events.push(Event::StopSound),
58 | 230 => events.push(Event::Sound(2012)),
_ => {}
}
true
}
#[allow(clippy::cast_possible_truncation)]
fn apply_nudge(&mut self, nudge: Nudge, events: &mut Vec<Event>) {
const SCALAR: i32 = 15;
events.push(Event::Sound(2019));
let mut velocity = MilliVec::from_velocity_per_second(self.ball.velocity);
match nudge {
Nudge::Left | Nudge::Right => {
let amount = (50 - i32::from(self.random.below(20))) * SCALAR;
let signed = if nudge == Nudge::Left {
-amount
} else {
amount
};
velocity.x = velocity.x.wrapping_add(signed);
}
Nudge::Center => {
let horizontal = Real48::from_i32(SCALAR)
.multiply(
self.random
.real48()
.multiply(Real48::from_i32(20))
.subtract(Real48::from_i32(10)),
)
.round_i32();
let vertical = -(60 - i32::from(self.random.below(20))) * SCALAR;
velocity.x = velocity.x.wrapping_add(horizontal);
velocity.y = velocity.y.wrapping_add(vertical);
}
Nudge::None => return,
}
let primary_velocity = MilliVec::from_velocity_per_second(self.ball.velocity);
let impulse = MilliVec {
x: velocity.x.wrapping_sub(primary_velocity.x),
y: velocity.y.wrapping_sub(primary_velocity.y),
};
for ball in &mut self.crazy_balls {
ball.velocity = MilliVec::from_velocity_per_second(ball.velocity)
.add(impulse)
.to_velocity_per_second();
}
let primary_held = self.claw_holds_slot(ClawBallSlot::Primary);
let secondary_held = self.claw_holds_slot(ClawBallSlot::Secondary);
if let Some(secondary) = &mut self.secondary_ball {
let slot_impulse = match nudge {
Nudge::Left | Nudge::Right => MilliVec {
x: if nudge == Nudge::Left { -600 } else { 600 },
y: 0,
},
Nudge::Center => {
let horizontal = (i32::from(self.random.below(21)) - 10) * SCALAR;
let vertical = -(i32::from(self.random.below(100)) + 50) * SCALAR;
MilliVec {
x: horizontal,
y: vertical,
}
}
Nudge::None => MilliVec::default(),
};
let mut primary_slot = MilliVec::from_velocity_per_second(self.ball.velocity);
primary_slot.x = primary_slot.x.wrapping_add(slot_impulse.x);
primary_slot.y = primary_slot.y.wrapping_add(slot_impulse.y);
if !primary_held {
self.ball.velocity = primary_slot.to_velocity_per_second();
}
let mut secondary_slot = MilliVec::from_velocity_per_second(secondary.velocity);
secondary_slot.x = secondary_slot.x.wrapping_add(slot_impulse.x);
secondary_slot.y = secondary_slot.y.wrapping_add(slot_impulse.y);
if !secondary_held {
secondary.velocity = secondary_slot.to_velocity_per_second();
}
} else if !primary_held {
self.ball.velocity = velocity.to_velocity_per_second();
}
self.tilt_counter = self.tilt_counter.wrapping_add(25);
let threshold = self.random.below(10) + 30;
if threshold < self.tilt_counter {
self.tilted = true;
self.flipper_release_latch[0] |= self.flipper_inputs.left_raised;
self.flipper_release_latch[1] |= self.flipper_inputs.right_raised;
self.flipper_inputs = Flippers::default();
self.pending_flipper_edges.fill(0);
events.push(Event::Tilt);
events.push(Event::Sound(2020));
} else {
events.push(Event::Nudge);
}
}
#[allow(clippy::too_many_lines)]
fn fixed_update(&mut self, events: &mut Vec<Event>) -> bool {
if self.ball.in_launcher {
self.ball.position = LAUNCHER_POSITION;
self.ball.velocity = Vec2::ZERO;
return true;
}
let old_position = MilliVec::from_position(self.ball.position);
let initial_secondary = self.secondary_ball;
let ball_count = if initial_secondary.is_some() { 2 } else { 1 };
let mut ball = self.ball;
let mut velocity = MilliVec::from_velocity_per_second(ball.velocity);
velocity.y += GRAVITY_MILLI_PER_STEP;
velocity.clamp_speed(MAXIMUM_SPEED_MILLI_PER_STEP);
let speed_before_collision = milli_distance(velocity);
if predicted_outside_board(old_position.add(velocity)) {
self.drain(events);
return true;
}
let (best_static, scan, predicted) = self.scan_ordered_records_for_ball(
&mut ball,
1,
ball_count,
old_position,
&mut velocity,
events,
);
let mut best_collision = best_static
.map(|(id, is_wall, candidate)| (id, is_wall, candidate.resolve(velocity, ball.spin)));
let primary_held = self.claw_holds_slot(ClawBallSlot::Primary);
let secondary_held = self.claw_holds_slot(ClawBallSlot::Secondary);
let mut transferred_secondary_velocity = None;
if let Some(secondary) = initial_secondary
&& !primary_held
&& !secondary_held
{
let other_position = MilliVec::from_position(secondary.position);
let response = ball_collision_response(
old_position,
velocity,
ball.spin,
other_position,
MilliVec::from_velocity_per_second(secondary.velocity),
secondary.capture_age <= 0,
);
if predicted.x >= other_position.x.wrapping_sub(21_000)
&& predicted.x <= other_position.x.wrapping_add(21_000)
&& predicted.y >= other_position.y.wrapping_sub(21_000)
&& predicted.y <= other_position.y.wrapping_add(21_000)
&& let Some(response) = response
&& best_collision.is_none()
{
transferred_secondary_velocity = Some(response.other_velocity);
best_collision = Some((
175,
false,
CollisionResponse {
surface_distance: response.surface_distance,
velocity: response.moving_velocity,
spin: response.moving_spin,
auxiliary_fired: false,
},
));
}
}
let collided = best_collision.is_some();
if collided {
ball.capture_age = 0;
}
let mut auxiliary_fired = false;
let (hit_wall, hit_circle) = if let Some((object_id, is_wall, response)) = best_collision {
velocity = response.velocity;
ball.spin = response.spin;
auxiliary_fired = response.auxiliary_fired;
self.last_collision_id = Some(object_id);
if object_id == 175
&& let (Some(secondary), Some(transferred)) =
(&mut self.secondary_ball, transferred_secondary_velocity)
&& !secondary_held
&& !primary_held
{
secondary.velocity = transferred.to_velocity_per_second();
}
if is_wall {
(Some(object_id), None)
} else {
(None, Some(object_id))
}
} else {
(None, None)
};
if hit_wall == Some(25) && speed_before_collision >= 1_000 {
velocity.x = 0;
}
if hit_wall == Some(25) && 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();
self.ball = ball;
if let Some(wall_id) = hit_wall {
if wall_id == 2 {
let special_respawn = self.special_respawn_pending();
let response_spin = self.ball.spin;
self.drain(events);
if special_respawn {
let origin = MilliVec::from_position(self.ball.position);
self.ball.position = origin.add(velocity).to_position();
self.ball.velocity = velocity.to_velocity_per_second();
self.ball.spin = response_spin;
}
return true;
}
if wall_id == 25 && speed_before_collision < 1_000 {
self.ball = Ball::default();
self.launcher_charge = 0.0;
self.launcher_was_down = false;
return true;
}
self.apply_wall_rule(wall_id, events);
if auxiliary_fired && matches!(wall_id, 55 | 74 | 107) {
events.push(Event::Sound(2019));
}
}
if let Some(circle_id) = hit_circle {
self.apply_bumper_rule(circle_id, auxiliary_fired, events);
}
match scan.action {
BallAction::Keep => {}
BallAction::Suspend => return true,
BallAction::Reset => {
self.reset_ball_to_launcher();
return true;
}
BallAction::Finish => {
self.drain(events);
return true;
}
BallAction::Remove => {
self.ball = self
.secondary_ball
.take()
.expect("a multiball capture must leave the other slot active");
self.promote_claw_secondary_to_primary();
return true;
}
}
collided
}
fn retain_static_range(
&self,
old_position: MilliVec,
predicted: MilliVec,
velocity: MilliVec,
record_ids: std::ops::RangeInclusive<u8>,
best: &mut Option<(u8, bool, StaticCollisionCandidate)>,
) {
if let Some(candidate) = self.find_static_collision_candidate_in_range(
old_position,
predicted,
velocity,
record_ids,
) {
retain_first_collision(best, candidate);
}
}
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
fn scan_ordered_records_for_ball(
&mut self,
ball: &mut Ball,
ball_number: u16,
ball_count: u16,
old_position: MilliVec,
velocity: &mut MilliVec,
events: &mut Vec<Event>,
) -> (
Option<(u8, bool, StaticCollisionCandidate)>,
SensorScanResult,
MilliVec,
) {
let mut predicted = old_position.add(*velocity);
let mut best = None;
let mut capture_candidate = None;
let mut action = BallAction::Keep;
self.retain_static_range(old_position, predicted, *velocity, 1..=5, &mut best);
if self.apply_magnetic_record(6, old_position, velocity) {
predicted = old_position.add(*velocity);
}
self.retain_static_range(old_position, predicted, *velocity, 7..=88, &mut best);
if old_position.y < 250_000 && !self.claw.active {
ball.velocity = velocity.to_velocity_per_second();
match self.capture_record_step(
ball,
ball_number,
ball_count,
89,
CLAW_TRIGGER_CENTER,
CLAW_TRIGGER_RADIUS,
old_position,
predicted,
&mut capture_candidate,
events,
) {
CaptureStep::Complete => {
let terminal_frame = self
.next_claw_terminal_frame(self.claw_capture_restricted_for(ball_number));
self.begin_claw_capture_after_hold(ball, ball_number, terminal_frame, events);
action = BallAction::Suspend;
}
CaptureStep::Outside | CaptureStep::Holding => {}
}
*velocity = MilliVec::from_velocity_per_second(ball.velocity);
}
self.retain_static_range(old_position, predicted, *velocity, 90..=128, &mut best);
if old_position.y < 250_000 {
ball.velocity = velocity.to_velocity_per_second();
if let Some(completed_action) = self.check_lock_holes(
ball,
ball_number,
ball_count,
old_position,
predicted,
&mut capture_candidate,
events,
) {
action = completed_action;
}
*velocity = MilliVec::from_velocity_per_second(ball.velocity);
}
self.retain_static_range(old_position, predicted, *velocity, 134..=139, &mut best);
if old_position.y < 250_000 {
ball.velocity = velocity.to_velocity_per_second();
if self.type4_broadphase_contains(predicted, 140..=147) {
self.special_hole_gate = SpecialHoleGate::Enabled;
}
let target_triggered = self.check_target_sensors(
ball,
old_position,
movement_from_prediction(old_position, predicted),
140..=147,
events,
);
*velocity = MilliVec::from_velocity_per_second(ball.velocity);
if target_triggered {
predicted = old_position.add(*velocity);
}
if let Some(completed_action) = self.check_special_hole(
ball,
ball_number,
ball_count,
old_position,
predicted,
&mut capture_candidate,
events,
) {
action = completed_action;
}
*velocity = MilliVec::from_velocity_per_second(ball.velocity);
let effect_triggered = self.check_effect_sensor(
ball,
ball_number,
old_position,
movement_from_prediction(old_position, predicted),
events,
);
*velocity = MilliVec::from_velocity_per_second(ball.velocity);
if effect_triggered {
predicted = old_position.add(*velocity);
}
let target_triggered = self.check_target_sensors(
ball,
old_position,
movement_from_prediction(old_position, predicted),
150..=152,
events,
);
*velocity = MilliVec::from_velocity_per_second(ball.velocity);
if target_triggered {
predicted = old_position.add(*velocity);
}
}
if self.apply_magnetic_record(153, old_position, velocity) {
predicted = old_position.add(*velocity);
}
if self.apply_magnetic_record(154, old_position, velocity) {
predicted = old_position.add(*velocity);
}
self.retain_static_range(old_position, predicted, *velocity, 155..=173, &mut best);
if let Some((id, candidate)) = capture_candidate
&& best.is_none_or(|(static_id, _, _)| id < static_id)
{
best = Some((id, false, candidate));
}
ball.velocity = velocity.to_velocity_per_second();
(best, SensorScanResult { action }, predicted)
}
fn find_static_collision_candidate_in_range(
&self,
old_position: MilliVec,
predicted: MilliVec,
velocity: MilliVec,
record_ids: std::ops::RangeInclusive<u8>,
) -> Option<(u8, bool, StaticCollisionCandidate)> {
let mut best = None;
let layer = if old_position.y < 250_000 { 0x01 } else { 0x02 };
for object_id in record_ids {
if !self.object_active[usize::from(object_id)]
|| self.claw.active && (12..=20).contains(&object_id)
{
continue;
}
if let Some(wall) = WALLS.iter().find(|wall| wall.id == object_id) {
if wall.layer_mask & layer == 0 {
continue;
}
let segment = self.live_wall_segment(wall.id, wall.segment);
let start = MilliVec::from_position(segment.start);
let end = MilliVec::from_position(segment.end);
if predicted.x < start.x.min(end.x).wrapping_sub(5_000)
|| predicted.x > start.x.max(end.x).wrapping_add(5_000)
|| predicted.y < start.y.min(end.y).wrapping_sub(5_000)
|| predicted.y > start.y.max(end.y).wrapping_add(5_000)
{
continue;
}
let material = if self.tilted {
CollisionMaterial::line(
f64::from(wall.normal_rebound),
f64::from(wall.tangent_coupling),
)
} else {
CollisionMaterial::line_with_kick(
f64::from(wall.normal_rebound),
f64::from(wall.tangent_coupling),
f64::from(wall.response_auxiliary),
f64::from(wall.response_kick),
)
};
if let Some(candidate) = line_collision_candidate_at(
old_position,
predicted,
velocity,
segment.start,
segment.end,
material,
) {
retain_first_collision(&mut best, (wall.id, true, candidate));
}
}
let circle = PASSIVE_CIRCLES
.iter()
.chain(BUMPERS.iter())
.find(|circle| circle.id == object_id);
if let Some(circle) = circle
&& circle.layer_mask & layer != 0
&& {
let (minimum, maximum) = self.live_circle_bounds(
circle.id,
self.live_circle_center(circle.id, circle.center),
circle.contact_radius,
);
predicted.x >= minimum.x
&& predicted.x <= maximum.x
&& predicted.y >= minimum.y
&& predicted.y <= maximum.y
}
&& let Some(candidate) = circle_collision_candidate_at(
old_position,
predicted,
velocity,
self.live_circle_center(circle.id, circle.center),
circle.contact_radius,
CollisionMaterial::circle(
f64::from(circle.normal_rebound),
f64::from(circle.tangent_coupling),
if self.tilted {
0.0
} else {
f64::from(circle.normal_kick)
},
),
)
&& best.is_none()
{
retain_first_collision(&mut best, (circle.id, false, candidate));
}
}
best
}
fn type4_broadphase_contains(
&self,
predicted: MilliVec,
record_ids: std::ops::RangeInclusive<u8>,
) -> bool {
!self.tilted
&& TARGET_SENSORS.into_iter().any(|sensor| {
record_ids.contains(&sensor.id)
&& self.object_active[usize::from(sensor.id)]
&& trigger_broadphase_contains(predicted, sensor.center, sensor.radius)
})
}
#[cfg(test)]
fn find_static_collision_candidate(
&self,
old_position: MilliVec,
velocity: MilliVec,
) -> Option<(u8, bool, StaticCollisionCandidate)> {
self.find_static_collision_candidate_in_range(
old_position,
old_position.add(velocity),
velocity,
1..=175,
)
}
#[cfg(test)]
fn advance_secondary_ball(&mut self, events: &mut Vec<Event>) -> bool {
self.advance_secondary_ball_slot(events, !self.claw_holds_slot(ClawBallSlot::Primary))
}
#[allow(clippy::too_many_lines)]
fn advance_secondary_ball_slot(
&mut self,
events: &mut Vec<Event>,
primary_slot_active: bool,
) -> bool {
let Some(mut ball) = self.secondary_ball.take() else {
return true;
};
let primary_position = self.ball.position;
let primary_velocity = self.ball.velocity;
let primary_capture_age = self.ball.capture_age;
let old_position = MilliVec::from_position(ball.position);
let mut velocity = MilliVec::from_velocity_per_second(ball.velocity);
velocity.y += GRAVITY_MILLI_PER_STEP;
velocity.clamp_speed(MAXIMUM_SPEED_MILLI_PER_STEP);
let speed_before_collision = milli_distance(velocity);
if predicted_outside_board(old_position.add(velocity)) {
self.score_mode = ScoreMode::Normal;
events.push(Event::Drain);
return true;
}
let (best_static, scan, predicted) = self.scan_ordered_records_for_ball(
&mut ball,
2,
2,
old_position,
&mut velocity,
events,
);
let mut best_collision = best_static
.map(|(id, is_wall, candidate)| (id, is_wall, candidate.resolve(velocity, ball.spin)));
let mut transferred_primary_velocity = None;
let other_position = MilliVec::from_position(primary_position);
if primary_slot_active
&& !self.claw_holds_slot(ClawBallSlot::Primary)
&& !self.claw_holds_slot(ClawBallSlot::Secondary)
&& predicted.x >= other_position.x.wrapping_sub(21_000)
&& predicted.x <= other_position.x.wrapping_add(21_000)
&& predicted.y >= other_position.y.wrapping_sub(21_000)
&& predicted.y <= other_position.y.wrapping_add(21_000)
&& let Some(response) = ball_collision_response(
old_position,
velocity,
ball.spin,
other_position,
MilliVec::from_velocity_per_second(primary_velocity),
primary_capture_age <= 0,
)
&& best_collision.is_none()
{
transferred_primary_velocity = Some(response.other_velocity);
best_collision = Some((
174,
false,
CollisionResponse {
surface_distance: response.surface_distance,
velocity: response.moving_velocity,
spin: response.moving_spin,
auxiliary_fired: false,
},
));
}
let mut hit = None;
let collided = best_collision.is_some();
if collided {
ball.capture_age = 0;
}
let mut auxiliary_fired = false;
if let Some((object_id, is_wall, response)) = best_collision {
velocity = response.velocity;
ball.spin = response.spin;
auxiliary_fired = response.auxiliary_fired;
hit = Some((object_id, is_wall));
if object_id == 174
&& let Some(transferred) = transferred_primary_velocity
{
self.ball.velocity = transferred.to_velocity_per_second();
}
}
if hit == Some((25, true)) && speed_before_collision >= 1_000 {
velocity.x = 0;
}
if hit == Some((25, true)) && speed_before_collision < 1_000 {
velocity.x = 0;
velocity.y = 0;
}
velocity.clamp_speed(MAXIMUM_SPEED_MILLI_PER_STEP);
ball.position = old_position.add(velocity).to_position();
ball.velocity = velocity.to_velocity_per_second();
if hit == Some((2, true)) {
self.score_mode = ScoreMode::Normal;
events.push(Event::Drain);
return true;
}
if let Some((object_id, true)) = hit {
if object_id == 25 && speed_before_collision < 1_000 {
self.score_mode = ScoreMode::Normal;
return true;
}
self.apply_wall_rule(object_id, events);
if auxiliary_fired && matches!(object_id, 55 | 74 | 107) {
events.push(Event::Sound(2019));
}
} else if let Some((object_id, false)) = hit {
self.apply_bumper_rule(object_id, auxiliary_fired, events);
}
match scan.action {
BallAction::Keep | BallAction::Suspend => self.secondary_ball = Some(ball),
BallAction::Reset | BallAction::Remove | BallAction::Finish => return true,
}
collided || scan.action == BallAction::Suspend
}
fn advance_crazy_balls(&mut self, substeps: u8, events: &mut Vec<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>) {
if self.tilted || !BUMPERS.iter().any(|bumper| bumper.id == object_id) {
return;
}
self.record_countdowns[usize::from(object_id)] = 5;
events.push(Event::Bumper);
events.push(Event::Sound(2006));
let points = self
.player()
.bumper_value
.saturating_sub(if auxiliary_fired { 0 } else { 1_000 });
if points != 0 {
self.add_score(points, events);
}
}
fn apply_wall_rule(&mut self, object_id: u8, events: &mut Vec<Event>) {
if self.tilted {
return;
}
let wall = WALLS
.iter()
.find(|wall| wall.id == object_id)
.expect("a wall collision id must reference a recovered wall");
if wall.flags & 0x0002 != 0 {
let group = usize::from(wall.contact_group);
self.object_active[group] = false;
if wall.flags & 0x2000 != 0 {
self.object_active[group + 1] = false;
self.object_active[group + 2] = false;
}
}
if wall.flags & 0x0008 != 0 {
events.push(Event::Sound(2012));
if [90, 93, 96, 99, 102]
.into_iter()
.all(|id| !self.object_active[id])
{
let player = &mut self.players[self.current_player];
let completion_bonus = if player.bumper_value >= 6_000 {
50_000
} else {
player.bumper_value += 1_000;
0
};
events.push(Event::Sound(2017));
if completion_bonus != 0 {
self.add_score(completion_bonus, events);
}
for active in &mut self.object_active[90..=104] {
*active = true;
}
}
}
if wall.flags & 0x0010 != 0 {
events.push(Event::Sound(2012));
if [109, 112, 115, 118]
.into_iter()
.all(|id| !self.object_active[id])
{
let segments = self.player().diamond_segments;
if segments < 9 {
self.players[self.current_player].diamond_segments += 1;
let award = if segments == 8 {
24_464
} else {
u32::from(segments + 1) * 10_000
};
events.push(Event::Sound(2017));
self.add_score(award, events);
if segments == 8 {
self.ball_double = BallDoubleState::Active;
for object_id in [153, 6, 154] {
self.object_active[object_id] = true;
}
}
} else {
if self.ball_double == BallDoubleState::Active {
let player = &mut self.players[self.current_player];
player.secondary_score = player.secondary_score.wrapping_add(100_000);
} else {
self.ball_double = BallDoubleState::Active;
}
events.push(Event::Sound(2007));
}
for active in &mut self.object_active[109..=120] {
*active = true;
}
}
}
if wall.flags & 0x0400 != 0
&& self.target_rotation_state.is_none()
&& self.panel_frame.is_none()
{
self.target_rotation_state = Some(0);
}
if wall.flags & 0x0004 != 0 {
events.push(Event::Sound(2012));
if self.record_contacts[usize::from(SPECIAL_HOLE_SENSOR.id)] != 0 {
self.target_effect = 7;
} else {
let previous = self.target_effect;
loop {
self.target_effect = u8::try_from(self.random.below(6) + 1).unwrap_or(1);
if self.target_effect != previous {
break;
}
}
}
self.object_active[usize::from(EFFECT_SENSOR.id)] = true;
}
// 1000:91eb dispatches b476's flag-driven rules before it adds the
// selected record's static score. This is observable when the ninth
// diamond enables ball-scoped double scoring: that collision's 1,500
// points are already doubled, while the preceding 24,464 award is not.
self.apply_static_wall_score(object_id, wall.score, events);
}
fn apply_static_wall_score(&mut self, object_id: u8, score: u32, events: &mut Vec<Event>) {
if score == 0 {
return;
}
self.add_score(score, events);
events.push(if object_id == 121 {
Event::Wheel
} else {
Event::Target
});
}
#[cfg(test)]
fn check_sensor_objects(
&mut self,
old_position: MilliVec,
movement_velocity: MilliVec,
events: &mut Vec<Event>,
) -> BallAction {
let ball_count = if self.secondary_ball.is_some() { 2 } else { 1 };
let mut ball = self.ball;
let scan = self.check_sensor_objects_for_ball(
&mut ball,
1,
ball_count,
old_position,
movement_velocity,
events,
);
match scan.action {
BallAction::Keep | BallAction::Suspend => self.ball = ball,
BallAction::Reset => self.reset_ball_to_launcher(),
BallAction::Finish => {
self.ball = ball;
self.drain(events);
}
BallAction::Remove => {
self.ball = self
.secondary_ball
.take()
.expect("a multiball capture must leave the other slot active");
}
}
scan.action
}
#[cfg(test)]
fn check_sensor_objects_for_ball(
&mut self,
ball: &mut Ball,
ball_number: u16,
ball_count: u16,
old_position: MilliVec,
movement_velocity: MilliVec,
events: &mut Vec<Event>,
) -> SensorScanResult {
let mut capture_candidate = None;
let mut action = BallAction::Keep;
let predicted_position = old_position.add(movement_velocity);
if !self.tilted
&& (TARGET_SENSORS.into_iter().any(|sensor| {
self.object_active[usize::from(sensor.id)]
&& trigger_broadphase_contains(predicted_position, sensor.center, sensor.radius)
}) || self.object_active[usize::from(EFFECT_SENSOR.id)]
&& trigger_broadphase_contains(
predicted_position,
EFFECT_SENSOR.center,
EFFECT_SENSOR.radius,
))
{
self.special_hole_gate = SpecialHoleGate::Enabled;
}
if old_position.y >= 250_000 {
return SensorScanResult {
action: BallAction::Keep,
};
}
if !self.claw.active {
match self.capture_record_step(
ball,
ball_number,
ball_count,
89,
CLAW_TRIGGER_CENTER,
CLAW_TRIGGER_RADIUS,
old_position,
predicted_position,
&mut capture_candidate,
events,
) {
CaptureStep::Holding | CaptureStep::Outside => {}
CaptureStep::Complete => {
let terminal_frame = self
.next_claw_terminal_frame(self.claw_capture_restricted_for(ball_number));
self.begin_claw_capture_after_hold(ball, ball_number, terminal_frame, events);
action = BallAction::Suspend;
}
}
}
if let Some(completed_action) = self.check_lock_holes(
ball,
ball_number,
ball_count,
old_position,
predicted_position,
&mut capture_candidate,
events,
) {
action = completed_action;
}
self.check_target_sensors(ball, old_position, movement_velocity, 140..=147, events);
if let Some(completed_action) = self.check_special_hole(
ball,
ball_number,
ball_count,
old_position,
predicted_position,
&mut capture_candidate,
events,
) {
action = completed_action;
}
self.check_effect_sensor(ball, ball_number, old_position, movement_velocity, events);
self.check_target_sensors(ball, old_position, movement_velocity, 150..=152, events);
SensorScanResult { action }
}
#[allow(
clippy::cast_possible_truncation,
clippy::too_many_arguments,
clippy::too_many_lines
)]
fn capture_record_step(
&mut self,
ball: &mut Ball,
ball_number: u16,
ball_count: u16,
record_id: u8,
center: Vec2,
radius: f32,
previous_position: MilliVec,
predicted_position: MilliVec,
best_capture: &mut Option<(u8, StaticCollisionCandidate)>,
events: &mut Vec<Event>,
) -> CaptureStep {
let current_position = MilliVec::from_position(ball.position);
let center_view = center;
let center = MilliVec::from_position(center_view);
let radius_view = radius;
let radius = (radius_view * 1_000.0).round() as i32;
let broadphase_margin = MilliVec::from_position(vec2(radius_view + 5.0, 0.0)).x;
if predicted_position.x < center.x.wrapping_sub(broadphase_margin)
|| predicted_position.x > center.x.wrapping_add(broadphase_margin)
|| predicted_position.y < center.y.wrapping_sub(broadphase_margin)
|| predicted_position.y > center.y.wrapping_add(broadphase_margin)
{
if record_id == 89
&& ball.capture_age == 0
&& self.record_contacts[usize::from(record_id)] == 0
{
self.clear_claw_capture_entry_for(ball_number);
}
return CaptureStep::Outside;
}
let dx = center.x.wrapping_sub(current_position.x);
let dy = center
.y
.wrapping_sub(current_position.y)
.wrapping_sub(2_000);
let surface_distance = milli_distance(MilliVec { x: dx, y: dy }) - radius;
let contact_index = usize::from(record_id);
let contact = self.record_contacts[contact_index];
let contact_allowed =
contact == 0 || (contact != 99 && ball_count == 1) || contact == ball_number;
if surface_distance < -11_000
&& contact_allowed
&& !(record_id == 148 && self.special_hole_gate == SpecialHoleGate::Suppressed)
{
if record_id == 89 && ball.capture_age == 0 && self.claw.capture_entry.is_none() {
// Latch the restriction when the ball first enters the deep
// capture zone, before the pull/hold phase can outlive the
// other active ball.
self.claw.capture_entry =
Some((ClawBallSlot::from_ball_number(ball_number), ball_count > 1));
}
if ball.capture_age < 300 {
let mut velocity = MilliVec::from_velocity_per_second(ball.velocity);
let stationary = center.x.wrapping_sub(previous_position.x).wrapping_abs() < 500
&& center.y.wrapping_sub(previous_position.y).wrapping_abs() < 500
&& velocity.x.wrapping_abs() < 500
&& velocity.y.wrapping_abs() < 500;
if stationary {
velocity = MilliVec::default();
if ball.capture_age == 0 {
self.record_contacts[contact_index] = ball_number;
if !self.tilted {
events.push(Event::Sound(2015));
}
}
ball.capture_age = if record_id == 89 {
300
} else {
ball.capture_age.wrapping_add(5)
};
} else {
let damping = Real48::from_bytes([0x80, 0x66, 0x66, 0x66, 0x66, 0x66]);
velocity.x = Real48::from_i32(velocity.x).multiply(damping).round_i32();
velocity.y = Real48::from_i32(velocity.y).multiply(damping).round_i32();
velocity.x = if center.x > current_position.x {
velocity.x.wrapping_add(150)
} else {
velocity.x.wrapping_sub(150)
};
velocity.y = if center.y > current_position.y {
velocity.y.wrapping_add(150)
} else {
velocity.y.wrapping_sub(150)
};
}
ball.velocity = velocity.to_velocity_per_second();
return CaptureStep::Holding;
}
if record_id == 89 && ball_count == 1 {
return CaptureStep::Complete;
}
ball.capture_age = 0;
self.record_contacts[contact_index] = if record_id == 89 && ball_count > 1 {
// Keep ownership with the held slot so its later release can
// clear the claw contact before a fresh entry.
ball_number
} else if record_id == 148 || ball_count > 1 {
2
} else {
99
};
return CaptureStep::Complete;
}
if record_id == 89 && surface_distance >= -11_000 {
self.clear_claw_capture_entry_for(ball_number);
}
if surface_distance > -11_000 && contact == ball_number {
self.record_contacts[contact_index] = 0;
ball.capture_age = 0;
}
let velocity = MilliVec::from_velocity_per_second(ball.velocity);
if surface_distance <= milli_distance(velocity)
&& surface_distance >= -11_000
&& contact != 0
&& let Some(candidate) = capture_collision_candidate(
current_position,
velocity,
center_view,
radius_view,
CollisionMaterial::line(0.6, 0.0),
)
&& best_capture.is_none()
{
*best_capture = Some((record_id, candidate));
}
CaptureStep::Outside
}
#[allow(clippy::too_many_arguments)]
fn check_lock_holes(
&mut self,
ball: &mut Ball,
ball_number: u16,
ball_count: u16,
old_position: MilliVec,
predicted_position: MilliVec,
best_capture: &mut Option<(u8, StaticCollisionCandidate)>,
events: &mut Vec<Event>,
) -> Option<BallAction> {
for (index, sensor) in LOCK_HOLES.into_iter().enumerate() {
match self.capture_record_step(
ball,
ball_number,
ball_count,
sensor.id,
sensor.center,
sensor.radius,
old_position,
predicted_position,
best_capture,
events,
) {
CaptureStep::Outside | CaptureStep::Holding => continue,
CaptureStep::Complete => {}
}
return Some(self.complete_lock_hole(index, ball_count, events));
}
None
}
fn complete_lock_hole(
&mut self,
index: usize,
ball_count: u16,
events: &mut Vec<Event>,
) -> BallAction {
self.wheel_holes[index] = true;
// Deliberate original-game divergence: a multiball capture normally
// leaves owner 2 here, which lets the surviving single ball capture
// the visibly occupied hole once more. Occupied wheel holes remain
// permanent in the clone regardless of which ball completed them.
self.record_contacts[129 + index] = 99;
// 1000:967a derives the award from all live type-three contact words,
// including another ball that is still settling into a lock hole.
let filled = self.record_contacts[129..=133]
.iter()
.filter(|contact| **contact != 0)
.count();
let shift = u32::try_from(filled.min(5)).unwrap_or(5);
let award = 5_000_u32 << shift;
let player = &mut self.players[self.current_player];
player.secondary_score = player.secondary_score.wrapping_add(award);
if filled == 5 {
let transfer = self.secondary_score_display();
self.add_score(transfer, events);
let player = &mut self.players[self.current_player];
player.secondary_score = 0;
player.score_multiplier = player.score_multiplier.wrapping_add(1);
player.extra_balls = player.extra_balls.wrapping_add(1);
if ball_count == 1 {
self.panel_frame = Some(0);
}
self.score_mode = ScoreMode::Normal;
}
events.push(Event::Lock);
if ball_count == 1 {
if self.tilted {
BallAction::Finish
} else {
BallAction::Reset
}
} else {
BallAction::Remove
}
}
#[allow(clippy::too_many_arguments)]
fn check_special_hole(
&mut self,
ball: &mut Ball,
ball_number: u16,
ball_count: u16,
old_position: MilliVec,
predicted_position: MilliVec,
best_capture: &mut Option<(u8, StaticCollisionCandidate)>,
events: &mut Vec<Event>,
) -> Option<BallAction> {
match self.capture_record_step(
ball,
ball_number,
ball_count,
SPECIAL_HOLE_SENSOR.id,
SPECIAL_HOLE_SENSOR.center,
SPECIAL_HOLE_SENSOR.radius,
old_position,
predicted_position,
best_capture,
events,
) {
CaptureStep::Outside | CaptureStep::Holding => return None,
CaptureStep::Complete => {}
}
self.multiball_state = MultiballState::Ready;
events.push(Event::Wheel);
Some(if ball_count == 1 {
if self.tilted {
BallAction::Finish
} else {
BallAction::Reset
}
} else {
BallAction::Remove
})
}
fn check_effect_sensor(
&mut self,
ball: &mut Ball,
ball_number: u16,
old_position: MilliVec,
movement_velocity: MilliVec,
events: &mut Vec<Event>,
) -> bool {
if self.tilted {
return false;
}
let effect_index = usize::from(EFFECT_SENSOR.id);
let predicted_position = old_position.add(movement_velocity);
if self.object_active[effect_index]
&& trigger_broadphase_contains(
predicted_position,
EFFECT_SENSOR.center,
EFFECT_SENSOR.radius,
)
{
let touched = path_intersects_circle(
old_position,
movement_velocity,
EFFECT_SENSOR.center,
EFFECT_SENSOR.radius,
);
let entered = touched && !self.trigger_flags[effect_index];
self.trigger_flags[effect_index] = touched;
if entered {
events.push(Event::Target);
events.push(Event::Sound(2004));
self.add_score(EFFECT_SENSOR.score, events);
match self.target_effect {
1..=5 => {
const ADDITIONS: [u32; 5] = [10_000, 20_000, 50_000, 100_000, 200_000];
let addition = ADDITIONS[usize::from(self.target_effect - 1)];
let player = &mut self.players[self.current_player];
player.secondary_score = player.secondary_score.wrapping_add(addition);
}
6 => {
let transfer = self.secondary_score_display();
self.add_score(transfer, events);
self.players[self.current_player].secondary_score = 0;
}
7 if self.record_contacts[usize::from(SPECIAL_HOLE_SENSOR.id)] != 0
&& self.record_contacts[usize::from(SPECIAL_HOLE_SENSOR.id)]
!= ball_number
&& self.secondary_ball.is_none()
&& matches!(
self.multiball_state,
MultiballState::Ready | MultiballState::Active
) =>
{
self.secondary_ball = Some(Ball {
position: vec2(17.0, 23.0),
velocity: MilliVec { x: 0, y: 3_040 }.to_velocity_per_second(),
in_launcher: false,
spin: Real48::ZERO,
capture_age: 0,
});
self.multiball_state = MultiballState::Active;
self.score_mode = ScoreMode::Multiball;
}
_ => {}
}
self.target_effect = 0;
self.object_active[effect_index] = false;
self.randomize_trigger_velocity(ball);
return true;
}
}
false
}
fn check_target_sensors(
&mut self,
ball: &mut Ball,
old_position: MilliVec,
movement_velocity: MilliVec,
record_ids: std::ops::RangeInclusive<u8>,
events: &mut Vec<Event>,
) -> bool {
if self.tilted {
return false;
}
let mut triggered = false;
let predicted_position = old_position.add(movement_velocity);
for sensor in TARGET_SENSORS {
if !record_ids.contains(&sensor.id) {
continue;
}
let contact_index = usize::from(sensor.id);
// The original scanner does not call a type-4 handler until the
// predicted point is inside that record's registered bounds. In
// multiball this also prevents an unrelated ball outside the
// corridor from clearing the ball that is currently inside it.
if !self.object_active[contact_index]
|| !trigger_broadphase_contains(predicted_position, sensor.center, sensor.radius)
{
continue;
}
let touched = path_intersects_circle(
old_position,
movement_velocity,
sensor.center,
sensor.radius,
);
let entered = touched && !self.trigger_flags[contact_index];
self.trigger_flags[contact_index] = touched;
if !entered {
continue;
}
events.push(Event::Target);
events.push(Event::Sound(2004));
self.add_score(sensor.score, events);
self.record_countdowns[usize::from(sensor.id)] = if sensor.id <= 147 { 20 } else { 10 };
if (150..=152).contains(&sensor.id) {
self.top_targets[usize::from(sensor.id - 150)] = true;
let field_id = [153, 6, 154][usize::from(sensor.id - 150)];
self.object_active[field_id] = true;
}
self.randomize_trigger_velocity(ball);
triggered = true;
}
triggered
}
fn randomize_trigger_velocity(&mut self, ball: &mut Ball) {
let mut velocity = MilliVec::from_velocity_per_second(ball.velocity);
let offset = Real48::from_bytes([0x81, 0x71, 0x3d, 0x0a, 0xd7, 0x03]);
let span = Real48::from_bytes([0x7d, 0x71, 0x3d, 0x0a, 0xd7, 0x23]);
let x_factor = offset.subtract(self.random.real48().multiply(span));
let y_factor = offset.subtract(self.random.real48().multiply(span));
velocity.x = Real48::from_i32(velocity.x).multiply(x_factor).round_i32();
velocity.y = Real48::from_i32(velocity.y).multiply(y_factor).round_i32();
ball.velocity = velocity.to_velocity_per_second();
}
fn reset_ball_to_launcher(&mut self) {
if self.record_contacts[129..=133]
.iter()
.all(|contact| *contact != 0)
{
self.panel_frame = Some(0);
}
self.tilted = false;
self.tilt_counter = 0;
self.ball = Ball::default();
self.launcher_charge = 0.0;
self.launcher_was_down = false;
self.launcher_hold_seconds = 0.0;
self.launcher_next_repeat = LAUNCHER_REPEAT_DELAY_SECONDS;
self.launcher_velocity_milli = 0;
}
#[cfg(test)]
fn apply_magnetic_fields(&mut self, old_position: MilliVec, velocity: &mut MilliVec) {
for object_id in [6, 153, 154] {
self.apply_magnetic_record(object_id, old_position, velocity);
}
}
fn apply_magnetic_record(
&mut self,
object_id: usize,
old_position: MilliVec,
velocity: &mut MilliVec,
) -> bool {
if self.tilted || !self.object_active[object_id] {
return false;
}
let (min_x, min_y, max_x, max_y) = match object_id {
6 => (143_000, 421_000, 169_000, 452_000),
153 => (1_000, 315_000, 32_000, 389_000),
154 => (278_000, 315_000, 312_000, 387_000),
_ => unreachable!("only the three rectangular type-four records are magnetic"),
};
if old_position.x < min_x
|| old_position.x > max_x
|| old_position.y < min_y
|| old_position.y > max_y
{
return false;
}
self.special_hole_gate = SpecialHoleGate::Enabled;
let damping = Real48::from_bytes([0x80, 0x66, 0x66, 0x66, 0x66, 0x66]);
velocity.x = Real48::from_i32(velocity.x).multiply(damping).round_i32();
let vertical_factor = Real48::from_i32(1).subtract(
self.random
.real48()
.multiply(Real48::from_bytes([0x7f, 0x9a, 0x99, 0x99, 0x99, 0x19])),
);
velocity.y = Real48::from_i32(MAXIMUM_SPEED_MILLI_PER_STEP)
.multiply(vertical_factor)
.round_i32()
.wrapping_neg();
let predicted = old_position.add(*velocity);
if predicted.x < min_x || predicted.x > max_x || predicted.y < min_y || predicted.y > max_y
{
self.object_active[object_id] = false;
}
true
}
pub fn magnetic_field_active(&self, object_id: usize) -> bool {
debug_assert!([6, 153, 154].contains(&object_id));
self.object_active[object_id]
}
pub fn bank_target_lit(&self, object_id: u8) -> bool {
matches!(object_id, 90 | 93 | 96 | 99 | 102 | 109 | 112 | 115 | 118)
&& !self.object_active[usize::from(object_id)]
}
pub fn effect_target_active(&self) -> bool {
self.object_active[usize::from(EFFECT_SENSOR.id)]
}
pub fn special_hole_active(&self) -> bool {
self.multiball_state == MultiballState::Ready
&& self.record_contacts[usize::from(SPECIAL_HOLE_SENSOR.id)] != 0
}
pub fn player_effect(&self) -> u8 {
self.target_effect.min(7)
}
fn next_claw_terminal_frame(&mut self, restricted: bool) -> u8 {
if restricted {
// Frame 18 sends the released ball down the shooter lane. During
// a capture that began with two live balls, keep it on the table.
CLAW_TABLE_TERMINAL_FRAMES[usize::from(self.random.below(3))]
} else {
CLAW_TERMINAL_FRAMES[usize::from(self.random.below(4))]
}
}
fn claw_holds_slot(&self, slot: ClawBallSlot) -> bool {
self.claw.ball_suspended && self.claw.captured_slot == Some(slot)
}
fn claw_capture_restricted_for(&self, ball_number: u16) -> bool {
self.claw.capture_entry == Some((ClawBallSlot::from_ball_number(ball_number), true))
}
fn clear_claw_capture_entry_for(&mut self, ball_number: u16) {
let slot = ClawBallSlot::from_ball_number(ball_number);
if self
.claw
.capture_entry
.is_some_and(|(owner, _)| owner == slot)
{
self.claw.capture_entry = None;
}
}
fn promote_claw_secondary_to_primary(&mut self) {
if self.claw.captured_slot == Some(ClawBallSlot::Secondary) {
self.claw.captured_slot = Some(ClawBallSlot::Primary);
if let Some((ClawBallSlot::Secondary, restricted)) = self.claw.capture_entry {
self.claw.capture_entry = Some((ClawBallSlot::Primary, restricted));
}
if self.record_contacts[89] == 2 {
self.record_contacts[89] = 1;
}
self.score_mode = ScoreMode::Normal;
}
}
pub fn primary_ball_suspended(&self) -> bool {
self.claw_holds_slot(ClawBallSlot::Primary)
}
pub fn secondary_ball_suspended(&self) -> bool {
self.claw_holds_slot(ClawBallSlot::Secondary)
}
fn apply_flipper_kicks(&mut self) {
let pending = self.pending_flipper_edges;
self.pending_flipper_edges.fill(0);
if self.ball.in_launcher || self.tilted {
return;
}
let primary_held = self.claw_holds_slot(ClawBallSlot::Primary);
let secondary_held = self.claw_holds_slot(ClawBallSlot::Secondary);
for (delta, side) in [
(i32::from(pending[0]), FlipperSide::Left),
(i32::from(pending[1]), FlipperSide::Right),
] {
if delta == 0 {
continue;
}
if !primary_held {
apply_flipper_response_to_ball(&mut self.ball, delta, side);
}
if let Some(secondary) = &mut self.secondary_ball
&& !secondary_held
{
apply_flipper_response_to_ball(secondary, delta, side);
}
for ball in &mut self.crazy_balls {
apply_flipper_response_to_ball(ball, delta, side);
}
}
}
#[cfg(not(target_arch = "wasm32"))]
fn begin_claw_capture(&mut self, terminal_frame: u8, events: &mut Vec<Event>) {
debug_assert!(CLAW_TERMINAL_FRAMES.contains(&terminal_frame));
if self.claw.active {
return;
}
self.claw.active = true;
self.claw.target_frame = terminal_frame;
self.claw.bank = ClawSpriteBank::Closing;
self.claw.ball_suspended = true;
self.claw.captured_slot = Some(ClawBallSlot::Primary);
self.claw.capture_entry = None;
self.claw.frame_accumulator = 0.0;
self.ball.velocity = Vec2::ZERO;
self.ball.spin = Real48::ZERO;
events.push(Event::ClawCapture);
events.push(Event::Sound(2015));
}
fn begin_claw_capture_after_hold(
&mut self,
ball: &mut Ball,
ball_number: u16,
terminal_frame: u8,
events: &mut Vec<Event>,
) {
debug_assert!(CLAW_TERMINAL_FRAMES.contains(&terminal_frame));
if self.claw.active {
return;
}
self.claw.active = true;
self.claw.target_frame = terminal_frame;
self.claw.bank = ClawSpriteBank::Closing;
self.claw.ball_suspended = true;
self.claw.captured_slot = Some(ClawBallSlot::from_ball_number(ball_number));
self.claw.frame_accumulator = 0.0;
ball.velocity = Vec2::ZERO;
ball.spin = Real48::ZERO;
events.push(Event::ClawCapture);
}
fn update_claw(&mut self, dt: f32, events: &mut Vec<Event>) {
if !self.claw.active {
return;
}
self.claw.frame_accumulator += dt;
while self.claw.active && self.claw.frame_accumulator >= self.claw_frame_seconds {
self.claw.frame_accumulator -= self.claw_frame_seconds;
self.advance_claw(events);
}
}
fn advance_claw(&mut self, events: &mut Vec<Event>) {
if self.claw.frame != self.claw.target_frame {
if self.claw.frame < self.claw.target_frame {
self.claw.frame += 1;
} else {
self.claw.frame -= 1;
}
return;
}
if self.claw.frame == 10 {
if self.claw.bank == ClawSpriteBank::Opening {
self.claw = Claw::default();
}
return;
}
let release_frame = self.claw.frame;
self.claw.target_frame = 10;
self.claw.bank = ClawSpriteBank::Opening;
let release = claw_release(release_frame);
match self.claw.captured_slot {
Some(ClawBallSlot::Primary) => {
(self.ball.position, self.ball.velocity) = release;
}
Some(ClawBallSlot::Secondary) => {
if let Some(ball) = &mut self.secondary_ball {
(ball.position, ball.velocity) = release;
} else {
(self.ball.position, self.ball.velocity) = release;
self.promote_claw_secondary_to_primary();
}
}
None => {
(self.ball.position, self.ball.velocity) = release;
self.claw.captured_slot = Some(ClawBallSlot::Primary);
}
}
self.claw.ball_suspended = false;
events.push(Event::ClawRelease);
events.push(Event::Sound(2016));
}
fn add_score(&mut self, points: u32, events: &mut Vec<Event>) {
const THRESHOLDS: [u32; 4] = [140_000, 650_000, 1_300_000, 4_000_000];
if self.tilted {
return;
}
let multiball_multiplier = if self.score_mode == ScoreMode::Multiball {
2
} else {
1
};
let ball_multiplier = if self.ball_double == BallDoubleState::Active {
2
} else {
1
};
let multiplier = multiball_multiplier * ball_multiplier;
let player = &mut self.players[self.current_player];
player.score = player.score.wrapping_add(points.wrapping_mul(multiplier));
let level = usize::from(player.media_level);
if level < THRESHOLDS.len() && score_reaches_marker(player.score, THRESHOLDS[level]) {
player.media_level += 1;
player.extra_balls = player.extra_balls.wrapping_add(1);
events.push(Event::Media);
events.push(Event::ExtraBall);
events.push(Event::Sound(2007));
}
}
fn drain(&mut self, events: &mut Vec<Event>) {
if self.claw_holds_slot(ClawBallSlot::Secondary) {
self.ball = self
.secondary_ball
.take()
.expect("the claw-held secondary ball must remain in slot two");
self.promote_claw_secondary_to_primary();
events.push(Event::Drain);
return;
}
if let Some(remaining_ball) = self.secondary_ball.take() {
self.ball = remaining_ball;
self.score_mode = ScoreMode::Normal;
events.push(Event::Drain);
return;
}
if self.special_respawn_pending() {
self.ball = Ball {
position: vec2(17.0, 23.0),
velocity: MilliVec { x: 0, y: 3_040 }.to_velocity_per_second(),
in_launcher: false,
spin: Real48::ZERO,
capture_age: 0,
};
self.multiball_state = MultiballState::Unavailable;
self.record_contacts[148] = 0;
self.special_hole_gate = SpecialHoleGate::Suppressed;
return;
}
if let Some(crazy_ball) = self.crazy_balls.pop() {
self.ball = crazy_ball;
events.push(Event::Drain);
return;
}
self.ball_double = BallDoubleState::Inactive;
self.multiball_state = MultiballState::Unavailable;
self.special_hole_gate = SpecialHoleGate::Enabled;
let player = &mut self.players[self.current_player];
if player.extra_balls > 0 {
player.extra_balls -= 1;
} else {
player.balls = player.balls.saturating_sub(1);
}
let high_score_candidate =
(player.balls == 0 && player.extra_balls == 0).then_some(player.score);
self.save_current_rule_state();
events.push(Event::Drain);
if !self.tilted {
events.push(Event::Sound(2008));
}
if let Some(score) = high_score_candidate {
events.push(Event::HighScoreCandidate(score));
self.flipper_release_latch[0] |= self.flipper_inputs.left_raised;
self.flipper_release_latch[1] |= self.flipper_inputs.right_raised;
self.flipper_inputs = Flippers::default();
}
self.tilted = false;
self.tilt_counter = 0;
self.secondary_ball = None;
self.score_mode = ScoreMode::Normal;
self.launcher_charge = 0.0;
self.launcher_was_down = false;
self.launcher_hold_seconds = 0.0;
self.launcher_next_repeat = LAUNCHER_REPEAT_DELAY_SECONDS;
self.launcher_velocity_milli = 0;
let mut next = (self.current_player + 1) % self.players.len();
for _ in 0..self.players.len() {
if self.players[next].balls > 0 || self.players[next].extra_balls > 0 {
self.current_player = next;
self.load_current_rule_state();
self.ball = Ball::default();
return;
}
next = (next + 1) % self.players.len();
}
self.finished = true;
}
fn special_respawn_pending(&self) -> bool {
self.secondary_ball.is_none()
&& self.score_mode == ScoreMode::Normal
&& self.record_contacts[usize::from(SPECIAL_HOLE_SENSOR.id)] != 0
&& matches!(
self.multiball_state,
MultiballState::Ready | MultiballState::Active
)
}
fn save_current_rule_state(&mut self) {
self.players[self.current_player].rules = RuleState {
wheel_holes: self.wheel_holes,
top_targets: self.top_targets,
record_contacts: self.record_contacts,
object_active: self.object_active,
target_effect: self.target_effect,
multiball_state: self.multiball_state,
};
}
fn load_current_rule_state(&mut self) {
let state = self.players[self.current_player].rules;
self.wheel_holes = state.wheel_holes;
self.top_targets = state.top_targets;
self.record_contacts = state.record_contacts;
self.trigger_flags.fill(false);
self.object_active = state.object_active;
self.target_effect = state.target_effect;
self.multiball_state = state.multiball_state;
}
fn live_wall_segment(&self, object_id: u8, resting: Segment) -> Segment {
let (start, end) = match object_id {
66 if self.flippers.left_raised => (vec2(98.0, 382.0), vec2(131.0, 369.0)),
68 if self.flippers.left_raised => (vec2(137.0, 384.0), vec2(116.0, 405.0)),
81 if self.flippers.right_raised => (vec2(197.0, 405.0), vec2(171.0, 378.0)),
83 if self.flippers.right_raised => (vec2(183.0, 368.0), vec2(217.0, 383.0)),
_ => return resting,
};
Segment::new(start, end, resting.bounce)
}
fn live_circle_center(&self, object_id: u8, resting: Vec2) -> Vec2 {
match object_id {
67 if self.flippers.left_raised => LEFT_FLIPPER_RAISED_TIP,
82 if self.flippers.right_raised => RIGHT_FLIPPER_RAISED_TIP,
_ => resting,
}
}
fn live_circle_bounds(
&self,
object_id: u8,
center: Vec2,
contact_radius: f32,
) -> (MilliVec, MilliVec) {
// 1000:8b0d moves the tip records through an asymmetric swept box;
// their collision broadphase is not center +/- (radius + 5).
if object_id == 67 && self.flippers.left_raised {
return (
MilliVec {
x: 118_000,
y: 362_000,
},
MilliVec {
x: 150_000,
y: 476_000,
},
);
}
if object_id == 82 && self.flippers.right_raised {
return (
MilliVec {
x: 167_000,
y: 362_000,
},
MilliVec {
x: 191_000,
y: 476_000,
},
);
}
let center = MilliVec::from_position(center);
let margin = MilliVec::from_position(vec2(contact_radius + 5.0, 0.0)).x;
(
MilliVec {
x: center.x.wrapping_sub(margin),
y: center.y.wrapping_sub(margin),
},
MilliVec {
x: center.x.wrapping_add(margin),
y: center.y.wrapping_add(margin),
},
)
}
}
fn claw_release(frame: u8) -> (Vec2, Vec2) {
let (position, direction) = match frame {
1 => (vec2(258.0, 78.0), vec2(-0.60, 0.00)),
2 => (vec2(259.0, 81.0), vec2(-0.45, 0.05)),
3 => (vec2(258.0, 78.0), vec2(-0.70, 0.30)),
4 => (vec2(261.0, 86.0), vec2(-0.30, 0.20)),
5 => (vec2(266.0, 90.0), vec2(-0.32, 0.22)),
6 => (vec2(270.0, 94.0), vec2(-0.40, 0.60)),
7 => (vec2(275.0, 97.0), vec2(-0.31, 0.70)),
8 => (vec2(278.0, 98.0), vec2(-0.20, 0.80)),
9 => (vec2(282.0, 101.0), vec2(-0.10, 0.90)),
18 => return (vec2(325.0, 92.0), vec2(0.0, 100.0)),
_ => unreachable!("the original claw only releases from a terminal frame"),
};
(position, direction * ORIGINAL_BALL_SPEED_PER_SECOND)
}
fn apply_flipper_response_to_ball(ball: &mut Ball, delta: i32, side: FlipperSide) {
let position = MilliVec::from_position(ball.position);
let velocity = MilliVec::from_velocity_per_second(ball.velocity);
if let Some(response) = moving_flipper_response(
position,
velocity,
delta,
side,
Real48::from_bytes([0x80, 0, 0, 0, 0, 0]),
MAXIMUM_SPEED_MILLI_PER_STEP,
) {
ball.position = position.add(response.movement).to_position();
ball.velocity = response.velocity.to_velocity_per_second();
}
}
#[allow(clippy::too_many_lines)]
#[cfg(test)]
mod tests {
use super::*;
fn launch_ball(game: &mut Game, held_frames: usize) -> Vec<Event> {
for _ in 0..held_frames {
game.update(
1.0 / 60.0,
3,
Controls {
launch_down: true,
..Controls::default()
},
);
}
game.update(1.0 / 60.0, 3, Controls::default())
}
fn complete_stationary_type_three_capture(
game: &mut Game,
center: Vec2,
events: &mut Vec<Event>,
) {
for _ in 0..=60 {
game.ball.in_launcher = false;
game.ball.position = center;
game.ball.velocity = Vec2::ZERO;
game.check_sensor_objects(MilliVec::from_position(center), MilliVec::default(), events);
}
}
fn capture_primary_record_step(
game: &mut Game,
record_id: u8,
center: Vec2,
radius: f32,
previous_position: MilliVec,
events: &mut Vec<Event>,
) -> CaptureStep {
let mut ball = game.ball;
let mut capture_candidate = None;
let predicted_position =
previous_position.add(MilliVec::from_velocity_per_second(ball.velocity));
let result = game.capture_record_step(
&mut ball,
1,
if game.secondary_ball.is_some() { 2 } else { 1 },
record_id,
center,
radius,
previous_position,
predicted_position,
&mut capture_candidate,
events,
);
game.ball = ball;
result
}
#[test]
fn crazy_balls_start_clear_of_every_static_record() {
let game = Game::new_with_seed(1, 7);
for position in CRAZY_BALL_POSITIONS {
let origin = MilliVec::from_position(position);
for (x, y) in [(3_000, 0), (-3_000, 0), (0, 3_000), (0, -3_000)] {
assert!(
game.find_static_collision_candidate(origin, MilliVec { x, y })
.is_none(),
"{position:?} touches a record moving ({x}, {y})"
);
}
}
}
#[test]
fn crazy_mode_adds_ten_balls_that_fall_and_score() {
let mut game = Game::new_with_seed(1, 7);
assert!(game.add_crazy_balls());
assert!(game.crazy_mode_used());
assert_eq!(game.crazy_balls.len(), 10);
let start: Vec<_> = game.crazy_balls.iter().map(|ball| ball.position).collect();
for _ in 0..20 {
game.update(0.03, 3, Controls::default());
}
assert!(game.ball.in_launcher);
assert!(
game.crazy_balls
.iter()
.zip(&start)
.all(|(ball, start)| ball.position != *start)
);
for _ in 0..2_000 {
game.update(0.03, 3, Controls::default());
}
assert!(game.crazy_balls.is_empty());
assert!(game.player().score > 0);
assert_eq!(game.player().balls, 3);
}
#[test]
fn draining_primary_ball_promotes_a_crazy_ball() {
let mut game = Game::new_with_seed(1, 7);
game.ball.in_launcher = false;
game.add_crazy_balls();
let mut events = Vec::new();
game.drain(&mut events);
assert_eq!(game.crazy_balls.len(), 9);
assert_eq!(game.ball.position, CRAZY_BALL_POSITIONS[9]);
assert!(!game.ball.in_launcher);
assert_eq!(game.player().balls, 3);
game.crazy_balls.clear();
game.drain(&mut events);
assert_eq!(game.player().balls, 2);
assert!(game.ball.in_launcher);
}
#[test]
fn finished_game_rejects_crazy_mode() {
let mut game = Game::new_with_seed(1, 7);
game.finished = true;
assert!(!game.add_crazy_balls());
assert!(game.crazy_balls.is_empty());
assert!(!game.crazy_mode_used());
}
#[test]
fn player_count_is_bounded() {
assert_eq!(Game::new(0).players.len(), 1);
assert_eq!(Game::new(99).players.len(), 4);
}
#[test]
fn plus_adds_players_only_before_the_first_launch() {
let mut game = Game::new(1);
assert!(game.add_player_before_launch());
assert!(game.add_player_before_launch());
assert!(game.add_player_before_launch());
assert!(!game.add_player_before_launch());
assert_eq!(game.players.len(), 4);
let mut game = Game::new(1);
launch_ball(&mut game, 1);
assert!(!game.add_player_before_launch());
assert_eq!(game.players.len(), 1);
}
#[test]
fn ball_starts_in_the_recovered_shooter_lane() {
let game = Game::new(1);
assert_eq!(game.ball.position, Vec2::new(325.0, 413.0));
assert!(game.ball.in_launcher);
}
#[test]
fn each_static_record_reaches_the_production_scanner() {
let mut missed_walls = Vec::new();
for wall in WALLS {
let direction = wall.segment.end - wall.segment.start;
let normal = vec2(-direction.y, direction.x).normalize();
let midpoint = (wall.segment.start + wall.segment.end) * 0.5;
let mut game = Game::new(1);
game.object_active.fill(false);
game.object_active[usize::from(wall.id)] = true;
game.ball.in_launcher = false;
game.ball.position = midpoint - normal;
game.ball.velocity =
MilliVec::from_velocity_per_second(normal * 200.0).to_velocity_per_second();
game.fixed_update(&mut Vec::new());
if game.last_collision_id != Some(wall.id) {
missed_walls.push(wall.id);
}
}
let mut missed_circles = Vec::new();
for circle in PASSIVE_CIRCLES.iter().chain(BUMPERS.iter()) {
let mut game = Game::new(1);
game.object_active.fill(false);
game.object_active[usize::from(circle.id)] = true;
game.ball.in_launcher = false;
game.ball.position = circle.center + vec2(circle.contact_radius + 1.0, 0.0);
game.ball.velocity = MilliVec { x: -2_000, y: 0 }.to_velocity_per_second();
game.fixed_update(&mut Vec::new());
if game.last_collision_id != Some(circle.id) {
missed_circles.push(circle.id);
}
}
assert!(
missed_walls.is_empty(),
"missed production wall records: {missed_walls:?}"
);
assert!(
missed_circles.is_empty(),
"missed production circle records: {missed_circles:?}"
);
}
#[test]
fn detail_timer_batches_the_original_number_of_substeps() {
for (detail, interval, substeps) in [
(1, 0.050, 5_i32),
(2, 0.040, 4),
(3, 0.030, 3),
(4, 0.020, 2),
(5, 0.010, 1),
] {
let mut game = Game::new(1);
game.ball.in_launcher = false;
game.ball.position = vec2(200.0, 250.0);
game.ball.velocity = Vec2::ZERO;
game.object_active.fill(false);
game.update(interval - 0.001, detail, Controls::default());
assert_eq!(game.ball.position, vec2(200.0, 250.0));
assert_eq!(game.ball.velocity, Vec2::ZERO);
game.update(0.001_1, detail, Controls::default());
let velocity = MilliVec::from_velocity_per_second(game.ball.velocity);
let position = MilliVec::from_position(game.ball.position);
assert_eq!(velocity.y, 15 * substeps);
assert_eq!(position.y, 250_000 + 15 * substeps * (substeps + 1) / 2);
}
}
#[test]
fn detail_batch_stops_after_the_first_collision_like_integrate_one_ball() {
let mut game = Game::new(1);
game.ball.in_launcher = false;
game.ball.position = vec2(270.0, 30.0);
game.ball.velocity = vec2(0.0, -380.0);
let mut expected = game.clone();
let mut expected_events = Vec::new();
let mut stopped = false;
for _ in 0..5 {
if expected.fixed_update(&mut expected_events) {
stopped = true;
break;
}
}
assert!(stopped, "the recovered top rail must end this detail batch");
let mut events = Vec::new();
game.timer_tick(0.050, 5, &mut events);
assert_eq!(game.ball.position, expected.ball.position);
assert_eq!(game.ball.velocity, expected.ball.velocity);
assert_eq!(events, expected_events);
}
#[test]
fn effect_seven_spawn_waits_until_the_next_timer_callback() {
let mut game = Game::new_with_seed(1, 7);
game.ball.in_launcher = false;
game.ball.position = EFFECT_SENSOR.center;
game.ball.velocity = Vec2::ZERO;
game.object_active.fill(false);
game.object_active[usize::from(EFFECT_SENSOR.id)] = true;
game.multiball_state = MultiballState::Ready;
game.record_contacts[usize::from(SPECIAL_HOLE_SENSOR.id)] = 2;
game.target_effect = 7;
game.timer_tick(0.050, 5, &mut Vec::new());
let spawned = game
.secondary_ball
.expect("the effect-seven request must create slot two after simulation");
assert_eq!(spawned.position, vec2(17.0, 23.0));
assert_eq!(
MilliVec::from_velocity_per_second(spawned.velocity),
MilliVec { x: 0, y: 3_040 }
);
}
#[test]
fn survivor_keeps_ball_two_identity_for_the_rest_of_the_callback() {
let lock = LOCK_HOLES[0];
let mut game = Game::new_with_seed(1, 7);
game.ball.in_launcher = false;
game.ball.position = lock.center;
game.ball.velocity = Vec2::ZERO;
game.ball.capture_age = 300;
game.record_contacts[usize::from(lock.id)] = 1;
game.secondary_ball = Some(Ball {
position: EFFECT_SENSOR.center,
velocity: Vec2::ZERO,
in_launcher: false,
spin: Real48::ZERO,
capture_age: 0,
});
game.object_active[usize::from(EFFECT_SENSOR.id)] = true;
game.multiball_state = MultiballState::Ready;
game.target_effect = 7;
game.timer_tick(0.050, 5, &mut Vec::new());
assert!(game.secondary_ball.is_none());
assert_eq!(game.target_effect, 0);
assert!(!game.effect_target_active());
assert_eq!(game.multiball_state, MultiballState::Ready);
assert!(game.wheel_holes[0]);
assert!(game.ball.position.y > EFFECT_SENSOR.center.y);
}
#[test]
fn launcher_charges_while_held_and_fires_on_release() {
let mut game = Game::new(1);
game.update(
1.0 / 60.0,
3,
Controls {
launch_down: true,
..Controls::default()
},
);
assert!(game.ball.in_launcher);
assert!(game.launcher_charge > 0.0);
let events = game.update(1.0 / 60.0, 3, Controls::default());
assert!(events.contains(&Event::Launch));
assert!(!game.ball.in_launcher);
assert!(game.ball.velocity.y < 0.0);
}
#[test]
fn holding_the_launcher_produces_a_faster_shot() {
let mut quick = Game::new_with_seed(1, 7);
let held = Controls {
launch_down: true,
..Controls::default()
};
quick.update(0.0, 3, held);
quick.update(0.0, 3, Controls::default());
let quick_speed = -quick.ball.velocity.y;
assert_eq!(
MilliVec::from_velocity_per_second(quick.ball.velocity),
MilliVec { x: 0, y: -2_270 }
);
let mut charged = Game::new_with_seed(1, 7);
launch_ball(&mut charged, 60);
let charged_speed = -charged.ball.velocity.y;
assert!(charged_speed > quick_speed + 80.0);
}
#[test]
fn maximum_launch_requires_a_deliberate_hold() {
let mut game = Game::new(1);
let held = Controls {
launch_down: true,
..Controls::default()
};
for _ in 0..30 {
game.update(1.0 / 60.0, 3, held);
}
assert!((0.19..0.21).contains(&game.launcher_charge));
assert_eq!(game.launcher_frame(), 2);
for _ in 0..30 {
game.update(1.0 / 60.0, 3, held);
}
assert!(game.launcher_charge >= 0.95);
assert_eq!(game.launcher_frame(), 10);
}
#[test]
fn maximum_launch_uses_the_initial_random_seed_for_trajectory_variation() {
let mut first = Game::new_with_seed(1, 1);
let mut second = Game::new_with_seed(1, 2);
launch_ball(&mut first, 60);
launch_ball(&mut second, 60);
assert_ne!(first.ball.velocity, second.ball.velocity);
}
#[test]
fn charged_ball_clears_the_shooter_lane() {
for detail in 1..=5 {
let mut game = Game::new_with_seed(1, u32::from(detail));
launch_ball(&mut game, 60);
let mut entered_table = false;
let mut returned_to_launcher = false;
let mut minimum_y = game.ball.position.y;
for _ in 0..480 {
game.update(1.0 / 120.0, detail, Controls::default());
minimum_y = minimum_y.min(game.ball.position.y);
if game.ball.position.x < 280.0 && game.ball.position.y > 30.0 {
entered_table = true;
}
returned_to_launcher |= game.ball.in_launcher && !entered_table;
}
assert!(
entered_table,
"detail {detail}: the shooter curve should guide the ball onto the table; position={:?}, velocity={:?}, minimum_y={minimum_y}",
game.ball.position, game.ball.velocity
);
assert!(
!returned_to_launcher,
"detail {detail}: a launched ball must not rebound into the launcher"
);
}
}
#[test]
fn shooter_stop_rearms_a_returning_ball() {
let mut game = Game::new(1);
game.ball.in_launcher = false;
game.ball.position = Vec2::new(325.0, 410.0);
game.ball.velocity = Vec2::new(0.0, 80.0);
for _ in 0..8 {
game.update(1.0 / 120.0, 5, Controls::default());
if game.ball.in_launcher {
break;
}
}
assert!(game.ball.in_launcher);
assert_eq!(game.ball.position, LAUNCHER_POSITION);
assert_eq!(game.ball.velocity, Vec2::ZERO);
}
#[test]
fn fast_shooter_stop_contact_bounces_without_rearming() {
let mut game = Game::new(1);
game.object_active.fill(false);
game.object_active[25] = true;
game.ball.in_launcher = false;
game.ball.position = Vec2::new(320.0, 412.0);
game.ball.velocity = MilliVec { x: 500, y: 1_000 }.to_velocity_per_second();
assert!(game.fixed_update(&mut Vec::new()));
assert!(!game.ball.in_launcher);
assert!(game.ball.velocity.y < 0.0);
assert_eq!(MilliVec::from_velocity_per_second(game.ball.velocity).x, 0);
assert_eq!(MilliVec::from_position(game.ball.position).x, 320_000);
}
#[test]
fn ball_reset_clears_tilt_state_before_the_next_launch() {
let mut game = Game::new(1);
game.tilted = true;
game.tilt_counter = 39;
game.reset_ball_to_launcher();
assert!(!game.tilted);
assert_eq!(game.tilt_counter, 0);
assert!(game.ball.in_launcher);
}
#[test]
fn ninth_diamond_and_followup_banks_match_original_awards() {
let mut game = Game::new(1);
game.players[0].diamond_segments = 8;
let mut events = Vec::new();
assert_eq!(game.ball_double, BallDoubleState::Inactive);
assert!(!game.magnetic_field_active(153));
assert!(!game.magnetic_field_active(6));
assert!(!game.magnetic_field_active(154));
for object_id in [109, 112, 115, 118] {
game.apply_wall_rule(object_id, &mut events);
}
assert_eq!(game.player().diamond_segments, 9);
assert_eq!(game.player().score, 3 * 1_500 + 24_464 + 2 * 1_500);
assert_eq!(game.ball_double, BallDoubleState::Active);
assert!(game.magnetic_field_active(153));
assert!(game.magnetic_field_active(6));
assert!(game.magnetic_field_active(154));
for object_id in [109, 112, 115, 118] {
game.apply_wall_rule(object_id, &mut events);
}
assert_eq!(game.player().score, 3 * 1_500 + 24_464 + 10 * 1_500);
assert_eq!(game.player().secondary_score, 100_000);
for object_id in [109, 112, 115, 118] {
game.apply_wall_rule(object_id, &mut events);
}
assert_eq!(game.player().secondary_score, 200_000);
}
#[test]
fn score_helper_stacks_multiball_and_ball_double_and_honors_tilt() {
let mut game = Game::new(1);
let mut events = Vec::new();
game.score_mode = ScoreMode::Multiball;
game.add_score(100, &mut events);
assert_eq!(game.player().score, 200);
game.ball_double = BallDoubleState::Active;
game.add_score(100, &mut events);
assert_eq!(game.player().score, 600);
game.tilted = true;
game.add_score(100, &mut events);
assert_eq!(game.player().score, 600);
}
#[test]
fn double_score_resets_on_normal_ball_end_but_survives_special_respawn() {
let mut normal = Game::new(1);
normal.players[0].diamond_segments = 9;
normal.players[0].secondary_score = 200_000;
normal.ball_double = BallDoubleState::Active;
normal.drain(&mut Vec::new());
assert_eq!(normal.ball_double, BallDoubleState::Inactive);
for object_id in [109, 112, 115, 118] {
normal.apply_wall_rule(object_id, &mut Vec::new());
}
assert_eq!(normal.ball_double, BallDoubleState::Active);
assert_eq!(normal.player().secondary_score, 200_000);
let mut special = Game::new(1);
special.ball_double = BallDoubleState::Active;
special.multiball_state = MultiballState::Ready;
special.record_contacts[148] = 2;
special.drain(&mut Vec::new());
assert_eq!(special.ball_double, BallDoubleState::Active);
assert_eq!(special.ball.position, vec2(17.0, 23.0));
}
#[test]
fn tilted_collision_dispatch_leaves_rule_records_unchanged() {
let mut game = Game::new(1);
game.tilted = true;
let mut events = Vec::new();
game.apply_wall_rule(90, &mut events);
game.apply_wall_rule(84, &mut events);
assert!(game.object_active[90]);
assert_eq!(game.target_effect, 0);
assert!(!game.effect_target_active());
assert_eq!(game.player().score, 0);
assert!(events.is_empty());
}
#[test]
fn tilted_type_three_completion_finishes_or_special_respawns() {
let mut lock = Game::new(1);
lock.tilted = true;
lock.ball.in_launcher = false;
lock.ball.position = LOCK_HOLES[0].center;
lock.ball.velocity = Vec2::ZERO;
lock.ball.capture_age = 300;
let mut lock_events = Vec::new();
assert_eq!(
lock.check_sensor_objects(
MilliVec::from_position(LOCK_HOLES[0].center),
MilliVec::default(),
&mut lock_events,
),
BallAction::Finish
);
assert_eq!(lock.player().balls, 2);
assert_eq!(lock.player().secondary_score, 10_000);
assert!(lock.ball.in_launcher);
assert!(!lock.tilted);
assert!(lock_events.contains(&Event::Drain));
assert!(!lock_events.contains(&Event::Sound(2008)));
let mut special = Game::new(1);
special.tilted = true;
special.ball.in_launcher = false;
special.ball.position = SPECIAL_HOLE_SENSOR.center;
special.ball.velocity = Vec2::ZERO;
special.ball.capture_age = 300;
let balls = special.player().balls;
let mut special_events = Vec::new();
assert_eq!(
special.check_sensor_objects(
MilliVec::from_position(SPECIAL_HOLE_SENSOR.center),
MilliVec::default(),
&mut special_events,
),
BallAction::Finish
);
assert_eq!(special.player().balls, balls);
assert_eq!(special.ball.position, vec2(17.0, 23.0));
assert!(special.tilted);
assert_eq!(special.multiball_state, MultiballState::Unavailable);
assert_eq!(special.record_contacts[148], 0);
assert!(!special_events.contains(&Event::Drain));
assert!(
!special_events
.iter()
.any(|event| event.sound_resource().is_some())
);
}
#[test]
fn recovered_target_banks_deactivate_and_rearm_their_line_groups() {
let mut game = Game::new(1);
let mut events = Vec::new();
for object_id in [90, 93, 96, 99] {
game.apply_wall_rule(object_id, &mut events);
}
assert!(game.bank_target_lit(90));
assert!(game.bank_target_lit(99));
assert!(!game.bank_target_lit(102));
game.apply_wall_rule(102, &mut events);
assert_eq!(game.player().bumper_value, 2_000);
assert!(game.object_active[90..=104].iter().all(|active| *active));
assert!(!game.bank_target_lit(90));
for object_id in [109, 112, 115] {
game.apply_wall_rule(object_id, &mut events);
}
assert!(game.bank_target_lit(109));
assert!(game.bank_target_lit(115));
assert!(!game.bank_target_lit(118));
game.apply_wall_rule(118, &mut events);
assert_eq!(game.player().diamond_segments, 1);
assert!(game.object_active[109..=120].iter().all(|active| *active));
assert!(!game.bank_target_lit(109));
}
#[test]
fn effect_selector_activates_and_consumes_record_149() {
let mut game = Game::new_with_seed(1, 7);
let mut events = Vec::new();
game.apply_wall_rule(84, &mut events);
assert!((1..=6).contains(&game.target_effect));
assert!(game.effect_target_active());
game.target_effect = 3;
assert_eq!(game.player_effect(), 3);
game.ball.in_launcher = false;
game.ball.position = EFFECT_SENSOR.center;
game.check_sensor_objects(
MilliVec::from_position(EFFECT_SENSOR.center),
MilliVec::default(),
&mut events,
);
assert_eq!(game.player().secondary_score, 50_000);
assert_eq!(game.secondary_score_display(), 50_000);
assert_eq!(game.player().score, 2_000);
assert_eq!(game.target_effect, 0);
assert_eq!(game.player_effect(), 0);
assert!(!game.effect_target_active());
}
#[test]
fn media_extra_balls_use_the_recovered_threshold_table() {
let mut game = Game::new(1);
let mut events = Vec::new();
for (expected_level, threshold) in [140_000, 650_000, 1_300_000, 4_000_000]
.into_iter()
.enumerate()
{
game.players[0].score = threshold - 1;
assert_eq!(usize::from(game.player().media_level), expected_level);
game.add_score(1, &mut events);
assert_eq!(usize::from(game.player().media_level), expected_level + 1);
}
assert_eq!(game.player().extra_balls, 4);
assert_eq!(
events
.iter()
.filter(|event| **event == Event::ExtraBall)
.count(),
4
);
assert_eq!(
events
.iter()
.filter_map(|event| event.sound_resource())
.collect::<Vec<_>>(),
[2007; 4]
);
let mut wrapped = Game::new(1);
wrapped.players[0].score = 0x8000_0000;
let mut wrapped_events = Vec::new();
wrapped.add_score(0, &mut wrapped_events);
assert_eq!(wrapped.player().media_level, 0);
assert_eq!(wrapped.player().extra_balls, 0);
assert!(wrapped_events.is_empty());
}
#[test]
fn type_four_hit_sound_precedes_a_crossed_media_marker() {
let mut target_game = Game::new_with_seed(1, 7);
target_game.players[0].score = 139_500;
let sensor = TARGET_SENSORS
.into_iter()
.find(|sensor| sensor.id == 150)
.expect("record 150 must be present");
let mut target_ball = target_game.ball;
target_ball.position = sensor.center;
let mut target_events = Vec::new();
target_game.check_target_sensors(
&mut target_ball,
MilliVec::from_position(sensor.center),
MilliVec::default(),
150..=152,
&mut target_events,
);
assert_eq!(
target_events,
[
Event::Target,
Event::Sound(2004),
Event::Media,
Event::ExtraBall,
Event::Sound(2007),
]
);
let mut effect_game = Game::new_with_seed(1, 7);
effect_game.players[0].score = 139_500;
effect_game.begin_effect_scenario(1);
let mut effect_ball = effect_game.ball;
effect_ball.position = EFFECT_SENSOR.center;
let mut effect_events = Vec::new();
effect_game.check_effect_sensor(
&mut effect_ball,
1,
MilliVec::from_position(EFFECT_SENSOR.center),
MilliVec::default(),
&mut effect_events,
);
assert_eq!(
effect_events,
[
Event::Target,
Event::Sound(2004),
Event::Media,
Event::ExtraBall,
Event::Sound(2007),
]
);
}
#[test]
fn type_four_motion_response_refreshes_the_remaining_prediction() {
let sensor = TARGET_SENSORS
.into_iter()
.find(|sensor| sensor.id == 150)
.expect("record 150 must be present");
let mut game = Game::new_with_seed(1, 7);
game.object_active.fill(false);
game.object_active[usize::from(sensor.id)] = true;
let old_position = MilliVec::from_position(sensor.center - vec2(1.0, 0.0));
let mut ball = Ball {
position: old_position.to_position(),
velocity: MilliVec { x: 100, y: 0 }.to_velocity_per_second(),
in_launcher: false,
spin: Real48::ZERO,
capture_age: 0,
};
let initial_velocity = MilliVec::from_velocity_per_second(ball.velocity);
let mut velocity = initial_velocity;
let mut events = Vec::new();
let (_, _, predicted) = game.scan_ordered_records_for_ball(
&mut ball,
1,
1,
old_position,
&mut velocity,
&mut events,
);
let updated_velocity = MilliVec::from_velocity_per_second(ball.velocity);
assert_ne!(updated_velocity, initial_velocity);
assert_eq!(predicted, old_position.add(updated_velocity));
}
#[test]
fn bumper_sound_precedes_a_crossed_media_marker() {
let mut game = Game::new(1);
game.players[0].score = 139_000;
let mut events = Vec::new();
game.apply_bumper_rule(51, true, &mut events);
assert_eq!(game.record_countdown(51), 5);
assert_eq!(game.player().score, 140_000);
assert_eq!(
events,
[
Event::Bumper,
Event::Sound(2006),
Event::Media,
Event::ExtraBall,
Event::Sound(2007),
]
);
}
#[test]
fn bank_completion_sounds_precede_crossed_media_markers() {
let mut bumper_bank = Game::new(1);
bumper_bank.players[0].score = 90_000;
bumper_bank.players[0].bumper_value = 6_000;
for object_id in [90, 93, 96, 99] {
bumper_bank.object_active[object_id] = false;
}
let mut bumper_events = Vec::new();
bumper_bank.apply_wall_rule(102, &mut bumper_events);
assert_eq!(bumper_bank.player().score, 141_500);
assert_eq!(
bumper_events
.iter()
.filter_map(|event| event.sound_resource())
.collect::<Vec<_>>(),
[2012, 2017, 2007]
);
let mut diamond_bank = Game::new(1);
diamond_bank.players[0].score = 130_000;
for object_id in [109, 112, 115] {
diamond_bank.object_active[object_id] = false;
}
let mut diamond_events = Vec::new();
diamond_bank.apply_wall_rule(118, &mut diamond_events);
assert_eq!(diamond_bank.player().score, 141_500);
assert_eq!(
diamond_events
.iter()
.filter_map(|event| event.sound_resource())
.collect::<Vec<_>>(),
[2012, 2017, 2007]
);
}
#[test]
fn special_hole_arms_effect_seven_multiball() {
let mut game = Game::new_with_seed(1, 7);
let mut events = Vec::new();
game.multiball_state = MultiballState::Ready;
game.record_contacts[148] = 2;
game.apply_wall_rule(84, &mut events);
assert_eq!(game.target_effect, 7);
game.ball.in_launcher = false;
game.ball.position = EFFECT_SENSOR.center;
game.check_sensor_objects(
MilliVec::from_position(EFFECT_SENSOR.center),
MilliVec::default(),
&mut events,
);
let spawned = game
.secondary_ball
.expect("effect seven should spawn a second ball");
assert_eq!(game.multiball_state, MultiballState::Active);
assert!(!game.special_hole_active());
assert_eq!(spawned.position, vec2(17.0, 23.0));
assert_eq!(
MilliVec::from_velocity_per_second(spawned.velocity),
MilliVec { x: 0, y: 3_040 }
);
game.advance_secondary_ball(&mut events);
let moving_ball = game
.secondary_ball
.expect("second ball should remain active");
assert!(moving_ball.position.y > spawned.position.y);
let balls_before = game.player().balls;
game.drain(&mut events);
assert!(game.secondary_ball.is_none());
assert_eq!(game.player().balls, balls_before);
assert_eq!(game.ball.position, moving_ball.position);
events.clear();
game.drain(&mut events);
assert!(events.is_empty());
assert_eq!(game.player().balls, balls_before);
assert_eq!(game.ball.position, vec2(17.0, 23.0));
assert_eq!(
MilliVec::from_velocity_per_second(game.ball.velocity),
MilliVec { x: 0, y: 3_040 }
);
assert_eq!(game.multiball_state, MultiballState::Unavailable);
assert!(!game.special_hole_active());
assert_eq!(game.record_contacts[148], 0);
assert_eq!(game.special_hole_gate, SpecialHoleGate::Suppressed);
game.fixed_update(&mut events);
assert!(!game.ball.in_launcher);
assert_eq!(
MilliVec::from_position(game.ball.position),
MilliVec {
x: 17_000,
y: 26_055,
}
);
assert_eq!(
MilliVec::from_velocity_per_second(game.ball.velocity),
MilliVec { x: 0, y: 3_055 }
);
assert_eq!(game.special_hole_gate, SpecialHoleGate::Suppressed);
game.ball.position = vec2(17.0, 31.0);
game.ball.velocity = Vec2::ZERO;
game.fixed_update(&mut events);
assert_eq!(game.special_hole_gate, SpecialHoleGate::Enabled);
}
#[test]
fn release_ball_consumes_the_special_hole_and_does_not_rearm_it() {
let mut game = Game::new_with_seed(1, 7);
let mut events = Vec::new();
game.multiball_state = MultiballState::Ready;
game.record_contacts[148] = 2;
game.apply_wall_rule(84, &mut events);
assert_eq!(game.target_effect, 7);
assert!(game.special_hole_active());
game.ball.in_launcher = false;
game.ball.position = EFFECT_SENSOR.center;
game.check_sensor_objects(
MilliVec::from_position(EFFECT_SENSOR.center),
MilliVec::default(),
&mut events,
);
assert!(game.secondary_ball.is_some());
assert_eq!(game.target_effect, 0);
assert!(!game.special_hole_active());
for _ in 0..4 {
game.advance_secondary_ball(&mut events);
}
assert_eq!(game.record_contacts[148], 0);
game.drain(&mut events);
assert!(game.secondary_ball.is_none());
game.drain(&mut events);
assert!(game.ball.in_launcher);
assert_eq!(game.player().balls, 2);
assert_eq!(game.multiball_state, MultiballState::Unavailable);
events.clear();
game.apply_wall_rule(84, &mut events);
assert!((1..=6).contains(&game.target_effect));
assert!(game.effect_target_active());
game.target_effect = 7;
game.trigger_flags[usize::from(EFFECT_SENSOR.id)] = false;
game.ball.position = EFFECT_SENSOR.center;
game.check_sensor_objects(
MilliVec::from_position(EFFECT_SENSOR.center),
MilliVec::default(),
&mut events,
);
assert!(game.secondary_ball.is_none());
}
#[test]
fn record_two_response_continues_after_special_respawn_like_live_original() {
let mut game = Game::new(1);
game.multiball_state = MultiballState::Active;
game.record_contacts[148] = 2;
game.ball.in_launcher = false;
game.ball.position = vec2(157.0, 453.0);
game.ball.velocity = MilliVec { x: 0, y: 3_000 }.to_velocity_per_second();
assert!(game.fixed_update(&mut Vec::new()));
assert_eq!(
MilliVec::from_position(game.ball.position),
MilliVec {
x: 16_698,
y: 21_191,
}
);
assert_eq!(
MilliVec::from_velocity_per_second(game.ball.velocity),
MilliVec { x: -302, y: -1_809 }
);
assert_eq!(game.multiball_state, MultiballState::Unavailable);
assert_eq!(game.special_hole_gate, SpecialHoleGate::Suppressed);
}
#[test]
fn armed_effect_seven_also_special_respawns_before_multiball_starts() {
let mut game = Game::new(1);
game.multiball_state = MultiballState::Ready;
game.record_contacts[148] = 2;
let balls = game.player().balls;
let mut events = Vec::new();
game.drain(&mut events);
assert!(events.is_empty());
assert_eq!(game.player().balls, balls);
assert_eq!(game.ball.position, vec2(17.0, 23.0));
assert_eq!(game.multiball_state, MultiballState::Unavailable);
assert_eq!(game.record_contacts[148], 0);
assert_eq!(game.special_hole_gate, SpecialHoleGate::Suppressed);
}
#[test]
fn second_ball_runs_the_same_type_four_rule_dispatch() {
let sensor = TARGET_SENSORS
.into_iter()
.find(|sensor| sensor.id == 150)
.expect("record 150 must be present");
let mut game = Game::new_with_seed(1, 7);
let primary_velocity = game.ball.velocity;
let mut second = Ball {
position: sensor.center,
velocity: vec2(12.0, -34.0),
in_launcher: false,
spin: Real48::ZERO,
capture_age: 0,
};
let old_position = MilliVec::from_position(sensor.center - vec2(sensor.radius + 1.0, 0.0));
let current_position = MilliVec::from_position(sensor.center);
let movement = MilliVec {
x: current_position.x.wrapping_sub(old_position.x),
y: current_position.y.wrapping_sub(old_position.y),
};
let mut events = Vec::new();
assert_eq!(
game.check_sensor_objects_for_ball(
&mut second,
2,
2,
old_position,
movement,
&mut events,
)
.action,
BallAction::Keep
);
assert!(game.top_targets[0]);
assert!(game.magnetic_field_active(153));
assert_eq!(game.player().score, sensor.score);
assert_eq!(game.ball.velocity, primary_velocity);
assert_ne!(second.velocity, vec2(12.0, -34.0));
}
#[test]
fn ball_two_consumes_effect_seven_without_spawning_a_third_ball() {
let mut game = Game::new_with_seed(1, 7);
game.multiball_state = MultiballState::Ready;
game.target_effect = 7;
game.object_active[usize::from(EFFECT_SENSOR.id)] = true;
let mut second = Ball {
position: EFFECT_SENSOR.center,
velocity: Vec2::ZERO,
in_launcher: false,
spin: Real48::ZERO,
capture_age: 0,
};
let mut events = Vec::new();
assert_eq!(
game.check_sensor_objects_for_ball(
&mut second,
2,
2,
MilliVec::from_position(EFFECT_SENSOR.center),
MilliVec::default(),
&mut events,
)
.action,
BallAction::Keep
);
assert!(game.secondary_ball.is_none());
assert_eq!(game.multiball_state, MultiballState::Ready);
assert_eq!(game.target_effect, 0);
assert!(!game.effect_target_active());
}
#[test]
fn rearmed_effect_seven_does_not_replace_an_active_secondary_ball() {
let mut game = Game::new_with_seed(1, 7);
game.multiball_state = MultiballState::Active;
game.target_effect = 7;
game.object_active[usize::from(EFFECT_SENSOR.id)] = true;
let secondary = Ball {
position: vec2(200.0, 200.0),
velocity: vec2(-12.0, 34.0),
in_launcher: false,
spin: Real48::ZERO,
capture_age: 0,
};
game.secondary_ball = Some(secondary);
let mut primary = game.ball;
let mut events = Vec::new();
assert_eq!(
game.check_sensor_objects_for_ball(
&mut primary,
1,
2,
MilliVec::from_position(EFFECT_SENSOR.center),
MilliVec::default(),
&mut events,
)
.action,
BallAction::Keep
);
assert_eq!(
game.secondary_ball.map(|ball| ball.position),
Some(secondary.position)
);
assert_eq!(game.multiball_state, MultiballState::Active);
assert_eq!(game.target_effect, 0);
}
#[test]
fn multiball_capture_age_is_per_slot_and_removes_only_ball_two() {
let sensor = LOCK_HOLES[0];
let mut game = Game::new(1);
game.ball.capture_age = 17;
game.record_contacts[usize::from(sensor.id)] = 2;
game.secondary_ball = Some(Ball {
position: sensor.center,
velocity: Vec2::ZERO,
in_launcher: false,
spin: Real48::ZERO,
capture_age: 300,
});
game.score_mode = ScoreMode::Multiball;
let mut events = Vec::new();
game.advance_secondary_ball(&mut events);
assert!(game.secondary_ball.is_none());
assert_eq!(game.score_mode, ScoreMode::Multiball);
assert_eq!(game.ball.capture_age, 17);
assert_eq!(game.record_contacts[usize::from(sensor.id)], 99);
assert!(game.wheel_holes[0]);
assert!(events.contains(&Event::Lock));
game.timer_tick(0.0, 0, &mut events);
assert_eq!(game.score_mode, ScoreMode::Normal);
}
#[test]
fn captured_ball_one_keeps_multiball_scoring_for_ball_two_slot_pass() {
let sensor = LOCK_HOLES[0];
let mut game = Game::new_with_seed(1, 7);
game.ball.in_launcher = false;
game.ball.position = sensor.center;
game.ball.velocity = Vec2::ZERO;
game.ball.capture_age = 300;
game.record_contacts[usize::from(sensor.id)] = 1;
game.secondary_ball = Some(Ball {
position: EFFECT_SENSOR.center,
velocity: Vec2::ZERO,
in_launcher: false,
spin: Real48::ZERO,
capture_age: 0,
});
game.target_effect = 1;
game.object_active[usize::from(EFFECT_SENSOR.id)] = true;
game.score_mode = ScoreMode::Multiball;
let mut events = Vec::new();
game.timer_tick(0.01, 1, &mut events);
assert!(game.secondary_ball.is_none());
assert_eq!(game.player().score, 1_000);
assert_eq!(game.player().secondary_score, 20_000);
assert_eq!(game.score_mode, ScoreMode::Multiball);
game.timer_tick(0.0, 0, &mut events);
assert_eq!(game.score_mode, ScoreMode::Normal);
}
#[test]
fn active_multiball_advances_the_returning_claw_while_both_balls_live() {
let mut game = Game::new(1);
game.claw.active = true;
game.claw.frame = 1;
game.claw.target_frame = 10;
game.claw.bank = ClawSpriteBank::Opening;
game.claw.frame_accumulator = 0.0;
game.claw.ball_suspended = true;
game.claw.captured_slot = Some(ClawBallSlot::Primary);
game.score_mode = ScoreMode::Multiball;
let mut events = Vec::new();
game.update_claw(CLAW_FRAME_SECONDS, &mut events);
assert_eq!(game.claw.frame, 2);
}
#[test]
fn capturing_ball_one_during_multiball_promotes_ball_two() {
let sensor = LOCK_HOLES[0];
let mut game = Game::new(1);
game.ball.in_launcher = false;
game.ball.position = sensor.center;
game.ball.velocity = Vec2::ZERO;
game.ball.capture_age = 300;
game.record_contacts[usize::from(sensor.id)] = 1;
let survivor = Ball {
position: vec2(210.0, 240.0),
velocity: vec2(-12.0, 34.0),
in_launcher: false,
spin: Real48::ZERO,
capture_age: 23,
};
game.secondary_ball = Some(survivor);
let mut events = Vec::new();
game.check_sensor_objects(
MilliVec::from_position(sensor.center),
MilliVec::default(),
&mut events,
);
assert!(game.secondary_ball.is_none());
assert_eq!(game.ball.position, survivor.position);
assert_eq!(game.ball.velocity, survivor.velocity);
assert_eq!(game.ball.capture_age, survivor.capture_age);
assert_eq!(game.record_contacts[usize::from(sensor.id)], 99);
assert!(game.wheel_holes[0]);
}
#[test]
fn occupied_multiball_wheel_hole_rejects_the_surviving_ball() {
let sensor = LOCK_HOLES[0];
let mut game = Game::new(1);
game.ball.in_launcher = false;
game.ball.position = sensor.center;
game.ball.velocity = Vec2::ZERO;
game.ball.capture_age = 300;
game.record_contacts[usize::from(sensor.id)] = 99;
game.wheel_holes[0] = true;
let mut events = Vec::new();
let action = game.check_sensor_objects(
MilliVec::from_position(sensor.center),
MilliVec::default(),
&mut events,
);
assert_eq!(action, BallAction::Keep);
assert_eq!(game.record_contacts[usize::from(sensor.id)], 99);
assert_eq!(game.player().secondary_score, 0);
assert!(!events.contains(&Event::Lock));
assert!(!game.ball.in_launcher);
}
#[test]
fn center_drain_advances_to_a_fresh_ball() {
let mut game = Game::new(1);
game.ball.in_launcher = false;
game.ball.position = vec2(157.0, 450.0);
game.ball.velocity = vec2(0.0, 300.0);
let events = game.update(1.0 / 30.0, 5, Controls::default());
assert!(events.contains(&Event::Drain));
assert_eq!(game.players[0].balls, 2);
assert!(game.ball.in_launcher);
}
#[test]
fn four_sided_prediction_bounds_finish_the_ball_before_record_scan() {
for (position, velocity) in [
(vec2(1.0, 200.0), MilliVec { x: -2_000, y: 0 }),
(vec2(339.0, 200.0), MilliVec { x: 2_000, y: 0 }),
(vec2(200.0, 1.0), MilliVec { x: 0, y: -2_000 }),
(vec2(200.0, 459.0), MilliVec { x: 0, y: 2_000 }),
] {
let mut game = Game::new(1);
game.object_active.fill(false);
game.ball.in_launcher = false;
game.ball.position = position;
game.ball.velocity = velocity.to_velocity_per_second();
let mut events = Vec::new();
assert!(game.fixed_update(&mut events));
assert!(game.ball.in_launcher);
assert_eq!(game.player().balls, 2);
assert!(events.contains(&Event::Drain));
}
}
#[test]
fn prediction_bounds_remove_only_the_escaping_multiball_slot() {
let mut game = Game::new(1);
game.object_active.fill(false);
game.ball.in_launcher = false;
game.ball.position = vec2(200.0, 200.0);
let second = Ball {
position: vec2(1.0, 200.0),
velocity: MilliVec { x: -2_000, y: 0 }.to_velocity_per_second(),
in_launcher: false,
spin: Real48::ZERO,
capture_age: 0,
};
game.secondary_ball = Some(second);
game.score_mode = ScoreMode::Multiball;
assert!(game.advance_secondary_ball(&mut Vec::new()));
assert!(game.secondary_ball.is_none());
assert_eq!(game.player().balls, 3);
assert_eq!(game.score_mode, ScoreMode::Normal);
}
#[test]
fn player_turns_save_and_restore_all_rule_record_state() {
let mut game = Game::new(2);
game.wheel_holes[2] = true;
game.top_targets[1] = true;
game.record_contacts[129] = 99;
game.trigger_flags[149] = true;
game.object_active[90] = false;
game.target_effect = 3;
game.drain(&mut Vec::new());
assert_eq!(game.current_player, 1);
assert_eq!(game.wheel_holes, [false; 5]);
assert_eq!(game.top_targets, [false; 3]);
assert_eq!(game.record_contacts[129], 0);
assert!(!game.trigger_flags[149]);
assert!(game.object_active[90]);
assert_eq!(game.target_effect, 0);
assert_eq!(game.multiball_state, MultiballState::Unavailable);
game.wheel_holes[4] = true;
game.drain(&mut Vec::new());
assert_eq!(game.current_player, 0);
assert!(game.wheel_holes[2]);
assert!(!game.wheel_holes[4]);
assert!(game.top_targets[1]);
assert_eq!(game.record_contacts[129], 99);
assert!(!game.trigger_flags[149]);
assert!(!game.object_active[90]);
assert_eq!(game.target_effect, 3);
assert_eq!(game.multiball_state, MultiballState::Unavailable);
}
#[test]
fn each_player_reports_a_high_score_candidate_on_their_last_ball() {
let mut game = Game::new(2);
game.players[0].balls = 1;
game.players[0].score = 123_456;
let mut events = Vec::new();
game.drain(&mut events);
assert!(events.contains(&Event::HighScoreCandidate(123_456)));
assert_eq!(game.current_player, 1);
assert!(!game.finished);
events.clear();
game.players[1].balls = 1;
game.players[1].score = 654_321;
game.drain(&mut events);
assert!(events.contains(&Event::HighScoreCandidate(654_321)));
assert!(game.finished);
}
#[test]
fn fast_ball_cannot_tunnel_through_the_top_rail() {
let mut game = Game::new(1);
game.ball.in_launcher = false;
game.ball.position = vec2(270.0, 30.0);
game.ball.velocity = vec2(0.0, -380.0);
for _ in 0..5 {
game.fixed_update(&mut Vec::new());
}
assert!(game.ball.position.y >= 15.0);
assert!(game.ball.velocity.y > 0.0);
}
#[test]
fn relative_record_57_matches_live_two_substep_transition() {
let mut game = Game::new(1);
game.ball.in_launcher = false;
game.ball.position = vec2(72.530, 354.750);
game.ball.velocity = MilliVec { x: 3_000, y: 0 }.to_velocity_per_second();
let mut events = Vec::new();
assert!(!game.fixed_update(&mut events));
assert_eq!(
MilliVec::from_position(game.ball.position),
MilliVec {
x: 75_530,
y: 354_765,
}
);
assert_eq!(
MilliVec::from_velocity_per_second(game.ball.velocity),
MilliVec { x: 3_000, y: 15 }
);
assert!(game.fixed_update(&mut events));
assert_eq!(game.last_collision_id, Some(57));
assert_eq!(game.ball.spin.bytes(), [0x83, 0x5d, 0x8f, 0xc2, 0xf5, 0xa8]);
assert_eq!(
MilliVec::from_position(game.ball.position),
MilliVec {
x: 77_369,
y: 356_597,
}
);
assert_eq!(
MilliVec::from_velocity_per_second(game.ball.velocity),
MilliVec { x: 1_839, y: 1_832 }
);
}
#[test]
fn relative_record_72_matches_live_two_substep_transition() {
let mut game = Game::new(1);
game.ball.in_launcher = false;
game.ball.position = vec2(238.348, 356.810);
game.ball.velocity = MilliVec { x: -3_000, y: 0 }.to_velocity_per_second();
let mut events = Vec::new();
assert!(!game.fixed_update(&mut events));
assert_eq!(
MilliVec::from_position(game.ball.position),
MilliVec {
x: 235_348,
y: 356_825,
}
);
assert_eq!(
MilliVec::from_velocity_per_second(game.ball.velocity),
MilliVec { x: -3_000, y: 15 }
);
assert!(game.fixed_update(&mut events));
assert_eq!(game.last_collision_id, Some(72));
assert_eq!(game.ball.spin.bytes(), [0x83, 0x86, 0xeb, 0x51, 0xb8, 0x2e]);
assert_eq!(
MilliVec::from_position(game.ball.position),
MilliVec {
x: 233_253,
y: 358_494,
}
);
assert_eq!(
MilliVec::from_velocity_per_second(game.ball.velocity),
MilliVec {
x: -2_095,
y: 1_669
}
);
}
#[test]
fn retained_collision_candidate_clears_the_slot_capture_age() {
let mut game = Game::new(1);
game.ball.in_launcher = false;
game.ball.position = vec2(270.0, 30.0);
game.ball.velocity = vec2(0.0, -380.0);
game.ball.capture_age = 42;
for _ in 0..5 {
if game.fixed_update(&mut Vec::new()) {
break;
}
}
assert_eq!(game.ball.capture_age, 0);
}
#[test]
fn collision_scan_selects_the_layer_from_the_previous_y_position() {
let mut game = Game::new(1);
game.object_active.fill(false);
game.object_active[48] = true;
let velocity = MilliVec { x: 0, y: -15_000 };
assert_eq!(
game.find_static_collision_candidate(
MilliVec {
x: 25_000,
y: 249_000,
},
velocity,
)
.map(|collision| collision.0),
Some(48)
);
assert!(
game.find_static_collision_candidate(
MilliVec {
x: 25_000,
y: 250_000,
},
velocity,
)
.is_none()
);
assert!(
game.find_static_collision_candidate(
MilliVec {
x: 25_000,
y: 249_000,
},
MilliVec { x: 0, y: -40_000 },
)
.is_none(),
"the original predicted-position broadphase rejects an overshoot"
);
}
#[test]
fn first_detected_record_wins_even_when_a_later_candidate_is_nearer() {
let old = MilliVec::default();
let velocity = MilliVec { x: 10_000, y: 0 };
let predicted = old.add(velocity);
let material = CollisionMaterial::line(0.6, 0.1);
let first = line_collision_candidate_at(
old,
predicted,
velocity,
vec2(8.0, 1.0),
vec2(8.0, -1.0),
material,
)
.expect("the first record must detect the farther rail");
let later = line_collision_candidate_at(
old,
predicted,
velocity,
vec2(2.0, 1.0),
vec2(2.0, -1.0),
material,
)
.expect("the later record must detect the nearer rail");
assert!(later.surface_distance < first.surface_distance);
let mut retained = None;
retain_first_collision(&mut retained, (10, true, first));
retain_first_collision(&mut retained, (20, true, later));
let retained = retained.expect("one candidate must remain");
assert_eq!(retained.0, 10);
assert_eq!(retained.2.surface_distance, 8_000);
}
#[test]
fn bumper_base_score_and_kick_require_the_recovered_speed_threshold() {
let mut weak = Game::new(1);
weak.object_active.fill(false);
weak.object_active[51] = true;
weak.players[0].bumper_value = 2_000;
weak.ball.in_launcher = false;
weak.ball.position = vec2(165.0, 171.1);
weak.ball.velocity = vec2(0.0, -20.0);
let mut weak_events = Vec::new();
assert!(weak.fixed_update(&mut weak_events));
let mut fast = Game::new(1);
fast.object_active.fill(false);
fast.object_active[51] = true;
fast.players[0].bumper_value = 2_000;
fast.ball.in_launcher = false;
fast.ball.position = vec2(165.0, 172.015);
fast.ball.velocity = vec2(0.0, -198.5);
let mut tilted = fast.clone();
tilted.tilted = true;
let mut fast_events = Vec::new();
assert!(fast.fixed_update(&mut fast_events));
let mut tilted_events = Vec::new();
assert!(tilted.fixed_update(&mut tilted_events));
assert_eq!(weak.player().score, 1_000);
assert_eq!(fast.player().score, 2_000);
assert!(weak_events.contains(&Event::Sound(2006)));
assert!(fast_events.contains(&Event::Sound(2006)));
assert_eq!(weak.record_countdown(51), 5);
assert_eq!(fast.record_countdown(51), 5);
assert!(fast.ball.velocity.y > weak.ball.velocity.y);
assert_eq!(tilted.player().score, 0);
assert_eq!(tilted.record_countdown(51), 0);
assert!(!tilted_events.contains(&Event::Sound(2006)));
assert!(fast.ball.velocity.y > tilted.ball.velocity.y);
}
#[test]
fn tilt_latches_and_disables_flippers_and_scoring() {
let mut game = Game::new_with_seed(1, 7);
assert!(launch_ball(&mut game, 1).contains(&Event::Launch));
game.players[0].secondary_score = 4_000;
for _ in 0..2 {
game.update(
0.0,
3,
Controls {
nudge: Nudge::Right,
..Controls::default()
},
);
}
assert!(game.tilted);
assert_eq!(game.player().secondary_score, 4_000);
game.flippers.left_raised = true;
game.flippers.right_raised = true;
let score = game.player().score;
game.ball.in_launcher = false;
game.ball.position = vec2(201.0, 285.0);
game.ball.velocity = Vec2::ZERO;
game.update(
1.0 / 30.0,
5,
Controls {
left_flipper: true,
right_flipper: true,
..Controls::default()
},
);
assert!(!game.flippers.left_raised);
assert!(!game.flippers.right_raised);
assert_eq!(game.player().score, score);
}
#[test]
fn flippers_use_the_recovered_binary_positions() {
let mut game = Game::new(1);
let wall = |id| {
WALLS
.iter()
.find(|wall| wall.id == id)
.expect("flipper wall exists")
.segment
};
let left_leading = game.live_wall_segment(66, wall(66));
let right_leading = game.live_wall_segment(81, wall(81));
assert_eq!(
(left_leading.start, left_leading.end),
(vec2(112.0, 386.0), vec2(141.0, 413.0))
);
assert_eq!(
(right_leading.start, right_leading.end),
(vec2(216.0, 411.0), vec2(177.0, 427.0))
);
let events = game.update(
0.030,
3,
Controls {
left_flipper: true,
right_flipper: true,
..Controls::default()
},
);
assert_eq!(
events
.iter()
.filter(|event| **event == Event::FlipperMove)
.count(),
2
);
let left_leading = game.live_wall_segment(66, wall(66));
let left_trailing = game.live_wall_segment(68, wall(68));
let right_leading = game.live_wall_segment(81, wall(81));
let right_trailing = game.live_wall_segment(83, wall(83));
assert_eq!(
(left_leading.start, left_leading.end),
(vec2(98.0, 382.0), vec2(131.0, 369.0))
);
assert_eq!(
(left_trailing.start, left_trailing.end),
(vec2(137.0, 384.0), vec2(116.0, 405.0))
);
assert_eq!(
game.live_circle_center(67, vec2(134.0, 419.0)),
vec2(133.0, 377.0)
);
assert_eq!(
(right_leading.start, right_leading.end),
(vec2(197.0, 405.0), vec2(171.0, 378.0))
);
assert_eq!(
(right_trailing.start, right_trailing.end),
(vec2(183.0, 368.0), vec2(217.0, 383.0))
);
assert_eq!(
game.live_circle_center(82, vec2(179.0, 419.0)),
vec2(181.0, 376.0)
);
assert_eq!(
game.live_circle_bounds(67, vec2(133.0, 377.0), 9.0),
(
MilliVec {
x: 118_000,
y: 362_000,
},
MilliVec {
x: 150_000,
y: 476_000,
},
)
);
assert_eq!(
game.live_circle_bounds(82, vec2(181.0, 376.0), 9.0),
(
MilliVec {
x: 167_000,
y: 362_000,
},
MilliVec {
x: 191_000,
y: 476_000,
},
)
);
}
#[test]
fn raised_flipper_tips_use_their_asymmetric_swept_bounds() {
for (object_id, center) in [(67_u8, vec2(133.0, 377.0)), (82, vec2(181.0, 376.0))] {
let mut game = Game::new(1);
game.object_active.fill(false);
game.object_active[usize::from(object_id)] = true;
if object_id == 67 {
game.flippers.left_raised = true;
} else {
game.flippers.right_raised = true;
}
let old_position = MilliVec::from_position(vec2(center.x, center.y + 12.0));
let velocity = MilliVec { x: 0, y: -3_800 };
// The scanner carries prediction separately from mutable motion;
// keep this point outside the old radius box while the motion
// still approaches the tip.
let predicted = old_position.add(MilliVec { x: 0, y: 4_000 });
let generic_max_y = MilliVec::from_position(vec2(center.x, center.y + 14.0)).y;
assert!(predicted.y > generic_max_y);
assert_eq!(
game.find_static_collision_candidate_in_range(
old_position,
predicted,
velocity,
object_id..=object_id,
)
.map(|candidate| candidate.0),
Some(object_id),
"the original swept tip bounds must admit the path for record {object_id}"
);
}
}
#[test]
fn each_flipper_edge_has_the_recovered_movement_sound() {
let mut game = Game::new(1);
let raised = Controls {
left_flipper: true,
right_flipper: true,
..Controls::default()
};
assert!(game.update(0.029, 3, raised).is_empty());
let press_events = game.update(0.001_1, 3, raised);
assert_eq!(
press_events,
[
Event::FlipperMove,
Event::Sound(2021),
Event::FlipperMove,
Event::Sound(2021),
],
"each original move_flipper call starts WAVE 2021"
);
assert!(game.update(0.030, 3, raised).is_empty());
let release_events = game.update(0.030, 3, Controls::default());
assert_eq!(
release_events,
[
Event::FlipperMove,
Event::Sound(2021),
Event::FlipperMove,
Event::Sound(2021),
],
"the original also plays the sound while returning"
);
assert_eq!(Event::FlipperMove.sound_resource(), None);
assert_eq!(Event::Sound(2021).sound_resource(), Some(2021));
}
#[test]
fn raising_flipper_uses_the_reconstructed_moving_response() {
let mut game = Game::new(1);
game.ball.in_launcher = false;
game.ball.position = vec2(125.0, 390.0);
game.ball.velocity = Vec2::ZERO;
game.pending_flipper_edges[0] = -1;
game.apply_flipper_kicks();
let velocity_after_edge = game.ball.velocity;
assert_eq!(game.ball.position, vec2(138.124, 355.0));
let raw_velocity = MilliVec::from_velocity_per_second(velocity_after_edge);
assert_eq!(
raw_velocity,
MilliVec {
x: 2_209,
y: -5_891
}
);
assert_eq!(game.pending_flipper_edges, [0, 0]);
game.apply_flipper_kicks();
assert_eq!(
game.ball.velocity, velocity_after_edge,
"holding a raised flipper must not add another impulse"
);
}
#[test]
fn returning_flipper_uses_the_raised_record_geometry() {
let mut game = Game::new(1);
game.flippers.left_raised = true;
game.ball.in_launcher = false;
game.ball.position = vec2(110.0, 410.0);
game.ball.velocity = vec2(100.0, 200.0);
let mut events = Vec::new();
game.timer_tick(0.030, 0, &mut events);
assert_eq!(events, [Event::FlipperMove, Event::Sound(2021)]);
let raw_velocity = MilliVec::from_velocity_per_second(game.ball.velocity);
assert_eq!(raw_velocity, MilliVec { x: 765, y: 3_997 });
assert_eq!(game.ball.position, vec2(110.380, 411.984));
assert_eq!(game.pending_flipper_edges, [0, 0]);
}
#[test]
fn recovered_control_sounds_use_the_original_resource_numbers() {
for resource in [
2002, 2006, 2007, 2008, 2011, 2012, 2015, 2016, 2019, 2020, 2021,
] {
assert_eq!(Event::Sound(resource).sound_resource(), Some(resource));
}
assert_eq!(Event::Launch.sound_resource(), None);
assert_eq!(Event::Media.sound_resource(), None);
}
#[test]
fn nudge_and_tilt_emit_the_original_ordered_sound_sequence() {
let mut game = Game::new_with_seed(1, 7);
game.ball.in_launcher = false;
let controls = Controls {
nudge: Nudge::Right,
..Controls::default()
};
assert_eq!(
game.update(0.0, 3, controls),
[Event::Sound(2019), Event::Nudge]
);
assert_eq!(
MilliVec::from_velocity_per_second(game.ball.velocity),
MilliVec { x: 690, y: 0 }
);
assert_eq!(game.tilt_counter, 25);
game.tilt_counter = 15;
assert_eq!(
game.update(0.0, 3, controls),
[Event::Sound(2019), Event::Tilt, Event::Sound(2020)]
);
}
#[test]
fn center_nudge_and_tilt_decay_match_the_detail_timer() {
let mut game = Game::new_with_seed(1, 7);
let events = game.update(
0.0,
3,
Controls {
nudge: Nudge::Center,
..Controls::default()
},
);
assert_eq!(events, [Event::Sound(2019), Event::Nudge]);
assert_eq!(
MilliVec::from_velocity_per_second(game.ball.velocity),
MilliVec { x: -84, y: -660 }
);
assert_eq!(game.tilt_counter, 25);
game.update(0.029, 3, Controls::default());
assert_eq!(game.tilt_counter, 25);
game.update(0.001_1, 3, Controls::default());
assert_eq!(game.tilt_counter, 24);
}
#[test]
fn tilt_counter_decays_once_per_selected_detail_callback() {
for (detail, interval) in [(1, 0.050), (2, 0.040), (3, 0.030), (4, 0.020), (5, 0.010)] {
let mut game = Game::new(1);
game.tilt_counter = 2;
game.update(interval - 0.001, detail, Controls::default());
assert_eq!(
game.tilt_counter, 2,
"detail {detail} decayed before its callback"
);
game.update(0.001_1, detail, Controls::default());
assert_eq!(
game.tilt_counter, 1,
"detail {detail} did not decay at its callback"
);
}
}
#[test]
fn multiball_nudge_consumes_active_draws_then_updates_both_saved_slots() {
let mut game = Game::new_with_seed(1, 7);
game.ball.in_launcher = false;
game.ball.velocity = MilliVec { x: 100, y: 200 }.to_velocity_per_second();
game.secondary_ball = Some(Ball {
position: vec2(200.0, 200.0),
velocity: MilliVec { x: -300, y: 400 }.to_velocity_per_second(),
in_launcher: false,
spin: Real48::ZERO,
capture_age: 0,
});
let mut reference = BorlandRandom::new(7);
let _discarded_active_x = reference.real48();
let _discarded_active_y = reference.below(20);
let shared_impulse = MilliVec {
x: (i32::from(reference.below(21)) - 10) * 15,
y: -(i32::from(reference.below(100)) + 50) * 15,
};
let _tilt_threshold = reference.below(10);
game.apply_nudge(Nudge::Center, &mut Vec::new());
assert_eq!(
MilliVec::from_velocity_per_second(game.ball.velocity),
MilliVec {
x: 100 + shared_impulse.x,
y: 200 + shared_impulse.y,
}
);
let secondary = game.secondary_ball.expect("slot two must remain active");
assert_eq!(
MilliVec::from_velocity_per_second(secondary.velocity),
MilliVec {
x: -300 + shared_impulse.x,
y: 400 + shared_impulse.y,
}
);
assert_eq!(game.random.seed(), reference.seed());
}
#[test]
fn claw_sprite_rects_follow_the_recovered_four_row_sheet() {
let source = |frame, bank| {
Claw {
active: true,
frame,
bank,
..Claw::default()
}
.sprite_source()
.map(|rect| (rect.x, rect.y, rect.w, rect.h))
};
assert_eq!(
source(1, ClawSpriteBank::Closing),
Some((0.0, 0.0, 100.0, 74.0))
);
assert_eq!(
source(9, ClawSpriteBank::Closing),
Some((800.0, 0.0, 100.0, 74.0))
);
assert_eq!(
source(10, ClawSpriteBank::Closing),
Some((0.0, 74.0, 100.0, 74.0))
);
assert_eq!(
source(18, ClawSpriteBank::Closing),
Some((800.0, 74.0, 100.0, 74.0))
);
assert_eq!(
source(1, ClawSpriteBank::Opening),
Some((0.0, 148.0, 100.0, 74.0))
);
assert_eq!(
source(18, ClawSpriteBank::Opening),
Some((800.0, 222.0, 100.0, 74.0))
);
assert_eq!(Claw::default().sprite_source(), None);
}
#[test]
fn claw_capture_holds_releases_and_returns_in_original_frame_order() {
let mut game = Game::new(1);
game.ball.in_launcher = false;
game.ball.position = CLAW_TRIGGER_CENTER;
game.ball.velocity = vec2(120.0, -40.0);
let held_position = game.ball.position;
let mut events = Vec::new();
game.begin_claw_capture(6, &mut events);
assert_eq!(events, [Event::ClawCapture, Event::Sound(2015)]);
assert_eq!(game.claw.frame, 10);
assert_eq!(game.claw.bank, ClawSpriteBank::Closing);
assert!(game.claw.ball_suspended);
assert_eq!(game.ball.velocity, Vec2::ZERO);
game.timer_tick(CLAW_FRAME_SECONDS / 3.0, 0, &mut events);
assert_eq!(game.ball.position, held_position);
assert_eq!(game.claw.frame, 10);
for expected_frame in [9, 8, 7, 6] {
game.update_claw(CLAW_FRAME_SECONDS, &mut events);
assert_eq!(game.claw.frame, expected_frame);
assert!(game.claw.ball_suspended);
}
game.update_claw(CLAW_FRAME_SECONDS, &mut events);
assert!(events.ends_with(&[Event::ClawRelease, Event::Sound(2016)]));
assert_eq!(game.claw.frame, 6);
assert_eq!(game.claw.bank, ClawSpriteBank::Opening);
assert!(!game.claw.ball_suspended);
assert_eq!(game.ball.position, vec2(270.0, 94.0));
assert!((game.ball.velocity.x - -152.0).abs() < 0.001);
assert!((game.ball.velocity.y - 228.0).abs() < 0.001);
for expected_frame in [7, 8, 9, 10] {
game.update_claw(CLAW_FRAME_SECONDS, &mut events);
assert_eq!(game.claw.frame, expected_frame);
assert!(game.claw.active);
}
game.update_claw(CLAW_FRAME_SECONDS, &mut events);
assert_eq!(game.claw.frame, 10);
assert!(!game.claw.active);
}
#[test]
fn multiball_claw_capture_keeps_the_ball_and_uses_only_table_releases() {
let mut game = Game::new_with_seed(1, 7);
game.ball.in_launcher = false;
game.ball.position = CLAW_TRIGGER_CENTER;
game.ball.velocity = Vec2::ZERO;
game.secondary_ball = Some(Ball {
position: vec2(210.0, 240.0),
velocity: vec2(12.0, -34.0),
in_launcher: false,
spin: Real48::ZERO,
capture_age: 0,
});
game.object_active.fill(false);
game.score_mode = ScoreMode::Multiball;
let claw_position = MilliVec::from_position(CLAW_TRIGGER_CENTER);
let mut events = Vec::new();
assert_eq!(
game.check_sensor_objects(claw_position, MilliVec::default(), &mut events),
BallAction::Keep
);
assert_eq!(game.claw.capture_entry, Some((ClawBallSlot::Primary, true)));
assert_eq!(
game.check_sensor_objects(claw_position, MilliVec::default(), &mut events),
BallAction::Suspend
);
assert!(game.claw.active);
assert!(game.primary_ball_suspended());
assert!(!game.secondary_ball_suspended());
assert!(matches!(game.claw.target_frame, 1 | 6 | 7));
assert_eq!(game.record_contacts[89], 1);
let survivor_before = game.secondary_ball.expect("survivor must remain").position;
game.timer_tick(CLAW_FRAME_SECONDS, 1, &mut events);
assert_ne!(
game.secondary_ball.expect("survivor must remain").position,
survivor_before
);
while game.claw.active {
game.update_claw(CLAW_FRAME_SECONDS, &mut events);
}
assert!(events.contains(&Event::ClawRelease));
assert!(!game.primary_ball_suspended());
assert!(game.secondary_ball.is_some());
game.ball.position = CLAW_TRIGGER_CENTER + vec2(20.0, 0.0);
game.ball.velocity = Vec2::ZERO;
game.ball.capture_age = 0;
assert_eq!(
game.check_sensor_objects(
MilliVec::from_position(game.ball.position),
MilliVec::default(),
&mut events,
),
BallAction::Keep
);
assert_eq!(game.record_contacts[89], 0);
}
#[test]
fn multiball_claw_restriction_is_latched_before_the_other_ball_drains() {
let mut game = Game::new_with_seed(1, 7);
game.ball.in_launcher = false;
game.ball.position = CLAW_TRIGGER_CENTER;
game.ball.velocity = vec2(100.0, 0.0);
game.secondary_ball = Some(Ball {
position: vec2(210.0, 240.0),
velocity: Vec2::ZERO,
in_launcher: false,
spin: Real48::ZERO,
capture_age: 0,
});
game.object_active.fill(false);
game.score_mode = ScoreMode::Multiball;
let claw_position = MilliVec::from_position(CLAW_TRIGGER_CENTER);
let mut events = Vec::new();
assert_eq!(
game.check_sensor_objects(claw_position, MilliVec::default(), &mut events),
BallAction::Keep
);
game.secondary_ball = None;
game.score_mode = ScoreMode::Normal;
game.ball.velocity = vec2(100.0, 0.0);
assert_eq!(
game.check_sensor_objects(claw_position, MilliVec::default(), &mut events),
BallAction::Keep
);
assert_eq!(game.claw.capture_entry, Some((ClawBallSlot::Primary, true)));
game.ball.velocity = Vec2::ZERO;
game.ball.capture_age = 300;
assert_eq!(
game.check_sensor_objects(claw_position, MilliVec::default(), &mut events),
BallAction::Suspend
);
assert_eq!(game.claw.capture_entry, Some((ClawBallSlot::Primary, true)));
assert!(matches!(game.claw.target_frame, 1 | 6 | 7));
}
#[test]
fn other_multiball_slot_cannot_clear_the_claw_entry_latch() {
let mut game = Game::new_with_seed(1, 7);
game.ball.in_launcher = false;
game.ball.position = CLAW_TRIGGER_CENTER;
game.ball.velocity = vec2(100.0, 0.0);
game.secondary_ball = Some(Ball {
position: vec2(210.0, 100.0),
velocity: Vec2::ZERO,
in_launcher: false,
spin: Real48::ZERO,
capture_age: 0,
});
game.object_active.fill(false);
game.score_mode = ScoreMode::Multiball;
let mut events = Vec::new();
assert_eq!(
game.check_sensor_objects(
MilliVec::from_position(CLAW_TRIGGER_CENTER),
MilliVec::default(),
&mut events,
),
BallAction::Keep
);
assert_eq!(game.claw.capture_entry, Some((ClawBallSlot::Primary, true)));
let mut secondary = game.secondary_ball.take().expect("secondary ball");
let secondary_position = MilliVec::from_position(secondary.position);
assert_eq!(
game.check_sensor_objects_for_ball(
&mut secondary,
2,
2,
secondary_position,
MilliVec::default(),
&mut events,
)
.action,
BallAction::Keep
);
game.secondary_ball = Some(secondary);
assert_eq!(game.claw.capture_entry, Some((ClawBallSlot::Primary, true)));
}
#[test]
fn shallow_claw_exit_clears_only_the_owning_entry_latch() {
let mut game = Game::new_with_seed(1, 7);
game.ball.in_launcher = false;
game.ball.position = CLAW_TRIGGER_CENTER;
game.ball.velocity = vec2(100.0, 0.0);
game.secondary_ball = Some(Ball {
position: vec2(210.0, 240.0),
velocity: Vec2::ZERO,
in_launcher: false,
spin: Real48::ZERO,
capture_age: 0,
});
game.object_active.fill(false);
game.score_mode = ScoreMode::Multiball;
let mut events = Vec::new();
assert_eq!(
game.check_sensor_objects(
MilliVec::from_position(CLAW_TRIGGER_CENTER),
MilliVec::default(),
&mut events,
),
BallAction::Keep
);
assert_eq!(game.claw.capture_entry, Some((ClawBallSlot::Primary, true)));
game.ball.position = CLAW_TRIGGER_CENTER + vec2(20.0, 0.0);
game.ball.velocity = Vec2::ZERO;
assert_eq!(
game.check_sensor_objects(
MilliVec::from_position(game.ball.position),
MilliVec::default(),
&mut events,
),
BallAction::Keep
);
assert_eq!(game.claw.capture_entry, None);
game.secondary_ball = None;
game.score_mode = ScoreMode::Normal;
game.ball.position = CLAW_TRIGGER_CENTER;
game.ball.velocity = vec2(100.0, 0.0);
assert_eq!(
game.check_sensor_objects(
MilliVec::from_position(CLAW_TRIGGER_CENTER),
MilliVec::default(),
&mut events,
),
BallAction::Keep
);
assert_eq!(
game.claw.capture_entry,
Some((ClawBallSlot::Primary, false))
);
}
#[test]
fn nonoverlapping_two_ball_claw_approaches_keep_capture_ownership() {
// Exercise the sequential solver from non-overlapping cardinal
// approaches around the sub-8 px deep zone. Ball contact begins at
// 17 px, so a second ball must be resolved before both can settle.
let capture_center = CLAW_TRIGGER_CENTER - vec2(0.0, 2.0);
let directions = [
vec2(1.0, 0.0),
vec2(0.0, 1.0),
vec2(-1.0, 0.0),
vec2(0.0, -1.0),
];
let radii = [10.0, 18.0];
let speeds = [100.0, 300.0];
let mut captures = 0;
for first_direction in directions {
for second_direction in directions {
for first_radius in radii {
for second_radius in radii {
let first_position = capture_center + first_direction * first_radius;
let second_position = capture_center + second_direction * second_radius;
if first_position.distance(second_position) < 17.0 {
continue;
}
for first_speed in speeds {
for second_speed in speeds {
let mut game = Game::new_with_seed(1, 7);
game.ball = Ball {
position: first_position,
velocity: (capture_center - first_position).normalize()
* first_speed,
in_launcher: false,
spin: Real48::ZERO,
capture_age: 0,
};
game.secondary_ball = Some(Ball {
position: second_position,
velocity: (capture_center - second_position).normalize()
* second_speed,
in_launcher: false,
spin: Real48::ZERO,
capture_age: 0,
});
game.object_active.fill(false);
game.score_mode = ScoreMode::Multiball;
let mut events = Vec::new();
for _ in 0..100 {
game.timer_tick(0.01, 1, &mut events);
if game.claw.active {
let Some((owner, true)) = game.claw.capture_entry else {
break;
};
captures += 1;
assert_eq!(
Some(owner),
game.claw.captured_slot,
"positions {first_position:?}/{second_position:?}, speeds {first_speed}/{second_speed}"
);
break;
}
if game.secondary_ball.is_none() || game.finished {
break;
}
}
}
}
}
}
}
}
assert!(captures > 0, "the sweep must exercise claw captures");
}
#[test]
fn secondary_multiball_ball_can_be_held_by_the_claw_without_hiding_primary() {
let mut game = Game::new_with_seed(1, 7);
game.ball.in_launcher = false;
game.ball.position = vec2(210.0, 240.0);
game.ball.velocity = Vec2::ZERO;
game.secondary_ball = Some(Ball {
position: CLAW_TRIGGER_CENTER,
velocity: Vec2::ZERO,
in_launcher: false,
spin: Real48::ZERO,
capture_age: 0,
});
game.object_active.fill(false);
game.score_mode = ScoreMode::Multiball;
let claw_position = MilliVec::from_position(CLAW_TRIGGER_CENTER);
let mut events = Vec::new();
let mut secondary = game.secondary_ball.take().expect("secondary ball");
assert_eq!(
game.check_sensor_objects_for_ball(
&mut secondary,
2,
2,
claw_position,
MilliVec::default(),
&mut events,
)
.action,
BallAction::Keep
);
game.secondary_ball = Some(secondary);
let mut secondary = game.secondary_ball.take().expect("secondary ball");
assert_eq!(
game.check_sensor_objects_for_ball(
&mut secondary,
2,
2,
claw_position,
MilliVec::default(),
&mut events,
)
.action,
BallAction::Suspend
);
game.secondary_ball = Some(secondary);
assert!(!game.primary_ball_suspended());
assert!(game.secondary_ball_suspended());
assert!(matches!(game.claw.target_frame, 1 | 6 | 7));
let primary_before = game.ball.position;
game.timer_tick(CLAW_FRAME_SECONDS, 1, &mut events);
assert_ne!(game.ball.position, primary_before);
}
#[test]
fn held_claw_ball_survives_the_other_ball_draining() {
let mut game = Game::new_with_seed(1, 7);
game.ball.in_launcher = false;
game.ball.position = CLAW_TRIGGER_CENTER;
game.ball.velocity = Vec2::ZERO;
game.secondary_ball = Some(Ball {
position: vec2(340.0, 100.0),
velocity: vec2(1.0, 0.0),
in_launcher: false,
spin: Real48::ZERO,
capture_age: 0,
});
game.object_active.fill(false);
game.score_mode = ScoreMode::Multiball;
let claw_position = MilliVec::from_position(CLAW_TRIGGER_CENTER);
let mut events = Vec::new();
assert_eq!(
game.check_sensor_objects(claw_position, MilliVec::default(), &mut events),
BallAction::Keep
);
assert_eq!(
game.check_sensor_objects(claw_position, MilliVec::default(), &mut events),
BallAction::Suspend
);
let balls_before = game.player().balls;
game.timer_tick(CLAW_FRAME_SECONDS, 1, &mut events);
assert!(game.secondary_ball.is_none());
assert_eq!(game.player().balls, balls_before);
assert_eq!(game.score_mode, ScoreMode::Normal);
assert!(game.claw.ball_suspended);
while game.claw.ball_suspended {
game.update_claw(CLAW_FRAME_SECONDS, &mut events);
}
assert!(events.contains(&Event::ClawRelease));
assert!(!game.ball.in_launcher);
}
#[test]
fn held_secondary_claw_ball_is_promoted_if_primary_drains() {
let mut game = Game::new_with_seed(1, 7);
game.ball.in_launcher = false;
game.ball.position = vec2(340.0, 100.0);
game.ball.velocity = vec2(1.0, 0.0);
game.secondary_ball = Some(Ball {
position: CLAW_TRIGGER_CENTER,
velocity: Vec2::ZERO,
in_launcher: false,
spin: Real48::ZERO,
capture_age: 0,
});
game.object_active.fill(false);
game.score_mode = ScoreMode::Multiball;
let claw_position = MilliVec::from_position(CLAW_TRIGGER_CENTER);
let mut events = Vec::new();
let mut secondary = game.secondary_ball.take().expect("secondary ball");
assert_eq!(
game.check_sensor_objects_for_ball(
&mut secondary,
2,
2,
claw_position,
MilliVec::default(),
&mut events,
)
.action,
BallAction::Keep
);
game.secondary_ball = Some(secondary);
let mut secondary = game.secondary_ball.take().expect("secondary ball");
assert_eq!(
game.check_sensor_objects_for_ball(
&mut secondary,
2,
2,
claw_position,
MilliVec::default(),
&mut events,
)
.action,
BallAction::Suspend
);
game.secondary_ball = Some(secondary);
assert_eq!(game.record_contacts[89], 2);
assert_eq!(
game.claw.capture_entry,
Some((ClawBallSlot::Secondary, true))
);
game.timer_tick(CLAW_FRAME_SECONDS, 1, &mut events);
assert!(game.secondary_ball.is_none());
assert!(game.primary_ball_suspended());
assert_eq!(game.record_contacts[89], 1);
assert_eq!(game.claw.capture_entry, Some((ClawBallSlot::Primary, true)));
assert_eq!(game.score_mode, ScoreMode::Normal);
assert!(events.contains(&Event::Drain));
}
#[test]
fn unrestricted_claw_capture_retains_the_launcher_release_choice() {
let mut saw_launcher_release = false;
for seed in 0..64 {
let mut game = Game::new_with_seed(1, seed);
if game.next_claw_terminal_frame(false) == 18 {
saw_launcher_release = true;
break;
}
}
assert!(saw_launcher_release);
}
#[test]
fn claw_reentry_after_multiball_uses_the_current_ball_count() {
let mut game = Game::new_with_seed(1, 7);
game.ball.in_launcher = false;
game.ball.position = CLAW_TRIGGER_CENTER;
game.ball.velocity = Vec2::ZERO;
game.secondary_ball = Some(Ball {
position: vec2(210.0, 240.0),
velocity: Vec2::ZERO,
in_launcher: false,
spin: Real48::ZERO,
capture_age: 0,
});
game.object_active.fill(false);
game.score_mode = ScoreMode::Multiball;
let claw_position = MilliVec::from_position(CLAW_TRIGGER_CENTER);
let mut events = Vec::new();
assert_eq!(
game.check_sensor_objects(claw_position, MilliVec::default(), &mut events),
BallAction::Keep
);
assert_eq!(
game.check_sensor_objects(claw_position, MilliVec::default(), &mut events),
BallAction::Suspend
);
assert_eq!(game.claw.capture_entry, Some((ClawBallSlot::Primary, true)));
game.secondary_ball = None;
game.score_mode = ScoreMode::Normal;
while game.claw.active {
game.update_claw(CLAW_FRAME_SECONDS, &mut events);
}
let unrestricted_seed = (0..64)
.find(|seed| {
let mut candidate = Game::new_with_seed(1, *seed);
candidate.next_claw_terminal_frame(false) == 18
})
.expect("a seed must select the launcher release");
game.random = BorlandRandom::new(unrestricted_seed);
game.ball.position = CLAW_TRIGGER_CENTER;
game.ball.velocity = Vec2::ZERO;
game.ball.capture_age = 0;
game.record_contacts[89] = 0;
assert_eq!(
game.check_sensor_objects(claw_position, MilliVec::default(), &mut events),
BallAction::Keep
);
assert_eq!(
game.claw.capture_entry,
Some((ClawBallSlot::Primary, false))
);
assert_eq!(
game.check_sensor_objects(claw_position, MilliVec::default(), &mut events),
BallAction::Suspend
);
assert_eq!(game.claw.target_frame, 18);
}
#[test]
fn claw_frame_cadence_follows_the_five_original_timer_choices() {
for (detail, frame_seconds) in DETAIL_TIMER_SECONDS.into_iter().enumerate() {
let mut game = Game::new_with_seed(1, 7);
game.begin_claw_scenario(6);
let detail = u8::try_from(detail + 1).expect("five detail levels fit u8");
game.update(0.0, detail, Controls::default());
let mut events = Vec::new();
game.update_claw(frame_seconds - 0.001, &mut events);
assert_eq!(game.claw.frame, 10);
game.update_claw(0.001_1, &mut events);
assert_eq!(game.claw.frame, 9);
}
}
#[test]
fn claw_uses_the_original_circular_trigger_not_a_broad_rectangle() {
let mut game = Game::new(1);
game.ball.in_launcher = false;
game.ball.position = CLAW_TRIGGER_CENTER + vec2(CLAW_TRIGGER_RADIUS + 0.1, 0.0);
game.check_sensor_objects(
MilliVec::from_position(game.ball.position),
MilliVec::default(),
&mut Vec::new(),
);
assert!(!game.claw.active);
game.ball.position = CLAW_TRIGGER_CENTER;
let mut events = Vec::new();
game.check_sensor_objects(
MilliVec::from_position(game.ball.position),
MilliVec::default(),
&mut events,
);
assert!(!game.claw.active);
assert_eq!(events, [Event::Sound(2015)]);
game.check_sensor_objects(
MilliVec::from_position(game.ball.position),
MilliVec::default(),
&mut events,
);
assert!(game.claw.active);
assert_eq!(events, [Event::Sound(2015), Event::ClawCapture]);
assert!(CLAW_TERMINAL_FRAMES.contains(&game.claw.target_frame));
}
#[test]
fn top_sensor_uses_the_original_score_and_contact_latch() {
let mut game = Game::new_with_seed(1, 7);
game.ball.in_launcher = false;
game.ball.position = vec2(205.0, 55.0);
game.ball.velocity = vec2(100.0, 200.0);
let mut events = Vec::new();
game.check_sensor_objects(
MilliVec::from_position(vec2(205.0, 60.0)),
MilliVec { x: 0, y: -5_000 },
&mut events,
);
assert_eq!(game.player().score, 500);
assert_eq!(events, [Event::Target, Event::Sound(2004)]);
assert!(game.object_active[153]);
assert!(!game.object_active[6]);
assert!(!game.object_active[154]);
assert_eq!(
MilliVec::from_velocity_per_second(game.ball.velocity),
MilliVec { x: 1_012, y: 1_926 }
);
let mut field_velocity = MilliVec { x: 0, y: 2_015 };
game.apply_magnetic_fields(
MilliVec {
x: 15_000,
y: 350_000,
},
&mut field_velocity,
);
assert_eq!(field_velocity, MilliVec { x: 0, y: -3_513 });
game.apply_magnetic_fields(
MilliVec {
x: 15_000,
y: 317_000,
},
&mut field_velocity,
);
assert!(!game.object_active[153]);
game.check_sensor_objects(
MilliVec::from_position(game.ball.position),
MilliVec::default(),
&mut events,
);
assert_eq!(game.player().score, 500, "contact must score only once");
game.ball.position = vec2(205.0, 62.0);
game.check_sensor_objects(
MilliVec::from_position(game.ball.position),
MilliVec::default(),
&mut events,
);
game.ball.position = vec2(205.0, 55.0);
game.check_sensor_objects(
MilliVec::from_position(vec2(205.0, 60.0)),
MilliVec { x: 0, y: -5_000 },
&mut events,
);
assert_eq!(game.player().score, 1_000);
}
#[test]
fn type_four_latch_is_not_cleared_by_a_ball_outside_its_broadphase() {
let sensor = TARGET_SENSORS
.into_iter()
.find(|sensor| sensor.id == 140)
.expect("record 140 must be present");
let mut game = Game::new(1);
let mut primary = game.ball;
primary.position = sensor.center;
let mut events = Vec::new();
game.check_target_sensors(
&mut primary,
MilliVec::from_position(sensor.center),
MilliVec::default(),
140..=147,
&mut events,
);
assert_eq!(game.player().score, sensor.score);
let mut unrelated = primary;
unrelated.position = vec2(200.0, 200.0);
let unrelated_position = MilliVec::from_position(unrelated.position);
game.check_target_sensors(
&mut unrelated,
unrelated_position,
MilliVec::default(),
140..=147,
&mut events,
);
game.check_target_sensors(
&mut primary,
MilliVec::from_position(sensor.center),
MilliVec::default(),
140..=147,
&mut events,
);
assert_eq!(game.player().score, sensor.score);
}
#[test]
fn magnetic_rectangles_use_old_position_for_entry_and_prediction_for_exit() {
let mut game = Game::new_with_seed(1, 7);
game.object_active[153] = true;
let seed_before = game.random.seed();
let mut entering_from_outside = MilliVec { x: 0, y: -3_000 };
game.apply_magnetic_fields(
MilliVec {
x: 15_000,
y: 390_000,
},
&mut entering_from_outside,
);
assert_eq!(entering_from_outside, MilliVec { x: 0, y: -3_000 });
assert_eq!(game.random.seed(), seed_before);
assert!(game.object_active[153]);
let mut exiting_sideways = MilliVec { x: -2_000, y: 0 };
game.apply_magnetic_fields(
MilliVec {
x: 1_000,
y: 350_000,
},
&mut exiting_sideways,
);
assert_eq!(exiting_sideways.x, -1_800);
assert!(!game.object_active[153]);
game.object_active[153] = true;
game.tilted = true;
let tilted_seed = game.random.seed();
let mut tilted_velocity = MilliVec { x: 200, y: 300 };
game.apply_magnetic_fields(
MilliVec {
x: 15_000,
y: 350_000,
},
&mut tilted_velocity,
);
assert_eq!(tilted_velocity, MilliVec { x: 200, y: 300 });
assert_eq!(game.random.seed(), tilted_seed);
assert!(game.object_active[153]);
}
#[test]
fn record_six_mutates_motion_after_earlier_candidate_detection() {
let mut game = Game::new_with_seed(1, 7);
game.object_active.fill(false);
game.object_active[5] = true;
game.object_active[6] = true;
game.ball.in_launcher = false;
game.ball.position = vec2(149.0, 430.0);
game.ball.velocity = MilliVec { x: -2_000, y: 0 }.to_velocity_per_second();
let old_position = MilliVec::from_position(game.ball.position);
let mut expected_game = game.clone();
let mut expected_motion = MilliVec { x: -2_000, y: 15 };
let candidate = expected_game
.find_static_collision_candidate_in_range(
old_position,
old_position.add(expected_motion),
expected_motion,
1..=5,
)
.expect("record five must be detected before record six");
assert_eq!(candidate.0, 5);
assert!(expected_game.apply_magnetic_record(6, old_position, &mut expected_motion));
let expected_response = candidate
.2
.resolve(expected_motion, expected_game.ball.spin);
let mut expected_velocity = expected_response.velocity;
expected_velocity.clamp_speed(MAXIMUM_SPEED_MILLI_PER_STEP);
assert!(game.fixed_update(&mut Vec::new()));
assert_eq!(game.last_collision_id, Some(5));
assert_eq!(
MilliVec::from_velocity_per_second(game.ball.velocity),
expected_velocity
);
assert_eq!(game.random.seed(), expected_game.random.seed());
}
#[test]
fn wheel_holes_accumulate_and_transfer_the_per_player_secondary_score() {
let mut game = Game::new(1);
game.ball.in_launcher = false;
let mut events = Vec::new();
let expected_bonus = [10_000, 30_000, 70_000, 150_000, 0];
for (index, sensor) in LOCK_HOLES.into_iter().enumerate() {
complete_stationary_type_three_capture(&mut game, sensor.center, &mut events);
assert_eq!(game.player().secondary_score, expected_bonus[index]);
assert!(game.ball.in_launcher);
}
assert_eq!(game.player().score, 310_000);
assert_eq!(game.player().score_multiplier, 2);
assert_eq!(game.player().extra_balls, 2);
assert!(game.wheel_holes.iter().all(|filled| *filled));
assert_eq!(
events.iter().filter(|event| **event == Event::Lock).count(),
5
);
complete_stationary_type_three_capture(&mut game, SPECIAL_HOLE_SENSOR.center, &mut events);
assert!(game.wheel_holes.iter().all(|filled| *filled));
assert_eq!(game.multiball_state, MultiballState::Ready);
assert_eq!(game.record_contacts[148], 2);
assert_eq!(events.last(), Some(&Event::Wheel));
assert_eq!(
events
.iter()
.filter(|event| **event == Event::Sound(2015))
.count(),
6
);
}
#[test]
fn wheel_award_counts_another_ball_settling_in_a_different_hole() {
let mut game = Game::new(1);
game.record_contacts[129] = 1;
game.record_contacts[130] = 2;
let mut events = Vec::new();
assert_eq!(
game.complete_lock_hole(1, 2, &mut events),
BallAction::Remove
);
assert_eq!(game.player().secondary_score, 20_000);
assert!(!game.wheel_holes[0]);
assert!(game.wheel_holes[1]);
}
#[test]
fn fifth_multiball_lock_defers_panel_and_caps_survivor_reaward() {
let mut game = Game::new(1);
game.wheel_holes = [true, true, true, true, false];
game.record_contacts[129..=133].fill(2);
game.players[0].secondary_score = 150_000;
game.score_mode = ScoreMode::Multiball;
let mut events = Vec::new();
assert_eq!(
game.complete_lock_hole(4, 2, &mut events),
BallAction::Remove
);
assert_eq!(game.player().score, 620_000);
assert_eq!(game.player().secondary_score, 0);
assert_eq!(game.player().score_multiplier, 2);
assert_eq!(game.panel_frame, None);
assert_eq!(game.score_mode, ScoreMode::Normal);
let mut reset_by_special_hole = game.clone();
reset_by_special_hole.reset_ball_to_launcher();
assert_eq!(reset_by_special_hole.panel_frame, Some(0));
game.score_mode = ScoreMode::Normal;
assert_eq!(
game.complete_lock_hole(4, 1, &mut events),
BallAction::Reset
);
assert_eq!(game.player().score, 940_000);
assert_eq!(game.player().secondary_score, 0);
assert_eq!(game.player().score_multiplier, 3);
assert_eq!(game.panel_frame, Some(0));
}
#[test]
fn type_three_capture_pulls_holds_and_publishes_contact_sentinels() {
let sensor = LOCK_HOLES[0];
let mut game = Game::new(1);
game.ball.in_launcher = false;
game.ball.position = sensor.center + vec2(5.0, 0.0);
game.ball.velocity = vec2(100.0, 0.0);
let previous = MilliVec::from_position(game.ball.position);
let mut events = Vec::new();
assert_eq!(
capture_primary_record_step(
&mut game,
sensor.id,
sensor.center,
sensor.radius,
previous,
&mut events,
),
CaptureStep::Holding
);
assert_eq!(
MilliVec::from_velocity_per_second(game.ball.velocity),
MilliVec { x: 750, y: -150 }
);
assert_eq!(game.record_contacts[usize::from(sensor.id)], 0);
assert_eq!(game.ball.capture_age, 0);
game.ball.position = sensor.center;
game.ball.velocity = Vec2::ZERO;
assert_eq!(
capture_primary_record_step(
&mut game,
sensor.id,
sensor.center,
sensor.radius,
MilliVec::from_position(sensor.center),
&mut events,
),
CaptureStep::Holding
);
assert_eq!(game.record_contacts[usize::from(sensor.id)], 1);
assert_eq!(game.ball.capture_age, 5);
assert_eq!(events, [Event::Sound(2015)]);
game.ball.capture_age = 300;
assert_eq!(
capture_primary_record_step(
&mut game,
sensor.id,
sensor.center,
sensor.radius,
MilliVec::from_position(sensor.center),
&mut events,
),
CaptureStep::Complete
);
assert_eq!(game.record_contacts[usize::from(sensor.id)], 99);
assert_eq!(game.ball.capture_age, 0);
}
#[test]
fn production_type_three_pull_tests_the_pre_movement_position() {
let sensor = LOCK_HOLES[0];
let mut game = Game::new(1);
game.object_active.fill(false);
game.object_active[usize::from(sensor.id)] = true;
game.ball.in_launcher = false;
game.ball.position = sensor.center + vec2(7.5, 0.0);
game.ball.velocity = MilliVec { x: 3_800, y: 0 }.to_velocity_per_second();
let old_position = MilliVec::from_position(game.ball.position);
let mut expected_velocity = MilliVec { x: 3_800, y: 15 };
expected_velocity.clamp_speed(MAXIMUM_SPEED_MILLI_PER_STEP);
let damping = Real48::from_bytes([0x80, 0x66, 0x66, 0x66, 0x66, 0x66]);
expected_velocity.x = Real48::from_i32(expected_velocity.x)
.multiply(damping)
.round_i32()
.wrapping_sub(150);
expected_velocity.y = Real48::from_i32(expected_velocity.y)
.multiply(damping)
.round_i32()
.wrapping_sub(150);
assert!(!game.fixed_update(&mut Vec::new()));
assert_eq!(
MilliVec::from_velocity_per_second(game.ball.velocity),
expected_velocity
);
assert_eq!(
MilliVec::from_position(game.ball.position),
old_position.add(expected_velocity)
);
}
#[test]
fn production_type_three_contacted_rim_participates_in_candidate_ordering() {
let sensor = LOCK_HOLES[0];
let mut game = Game::new(1);
game.object_active.fill(false);
game.object_active[usize::from(sensor.id)] = true;
game.record_contacts[usize::from(sensor.id)] = 99;
game.ball.in_launcher = false;
game.ball.position = sensor.center + vec2(17.0, 0.0);
game.ball.velocity = MilliVec { x: -3_000, y: 0 }.to_velocity_per_second();
let old_position = MilliVec::from_position(game.ball.position);
assert!(game.fixed_update(&mut Vec::new()));
let velocity = MilliVec::from_velocity_per_second(game.ball.velocity);
assert!(velocity.x > 0, "the type-three rim must reflect the ball");
assert_eq!(game.last_collision_id, Some(sensor.id));
assert_eq!(
MilliVec::from_position(game.ball.position),
old_position.add(velocity)
);
}
#[test]
fn type_three_broadphase_skip_preserves_contact_state() {
let sensor = LOCK_HOLES[0];
let mut game = Game::new(1);
game.object_active.fill(false);
game.object_active[usize::from(sensor.id)] = true;
game.record_contacts[usize::from(sensor.id)] = 1;
game.ball.in_launcher = false;
game.ball.position = sensor.center + vec2(23.0, 0.0);
game.ball.velocity = MilliVec { x: 3_800, y: 0 }.to_velocity_per_second();
game.fixed_update(&mut Vec::new());
assert_eq!(game.record_contacts[usize::from(sensor.id)], 1);
}
#[test]
fn production_type_four_randomizes_motion_before_position_publication() {
let sensor = TARGET_SENSORS
.into_iter()
.find(|sensor| sensor.id == 150)
.expect("record 150 must be present");
let mut game = Game::new_with_seed(1, 7);
game.object_active.fill(false);
game.object_active[usize::from(sensor.id)] = true;
game.ball.in_launcher = false;
game.ball.position = vec2(205.0, 58.0);
game.ball.velocity = MilliVec { x: 0, y: -2_000 }.to_velocity_per_second();
let old_position = MilliVec::from_position(game.ball.position);
let mut expected_game = game.clone();
let mut expected_ball = game.ball;
expected_ball.velocity = MilliVec { x: 0, y: -1_985 }.to_velocity_per_second();
expected_game.randomize_trigger_velocity(&mut expected_ball);
let expected_velocity = MilliVec::from_velocity_per_second(expected_ball.velocity);
game.fixed_update(&mut Vec::new());
assert_eq!(game.player().score, 500);
assert_eq!(
MilliVec::from_velocity_per_second(game.ball.velocity),
expected_velocity
);
assert_eq!(
MilliVec::from_position(game.ball.position),
old_position.add(expected_velocity)
);
assert_eq!(game.random.seed(), expected_game.random.seed());
}
#[test]
fn panel_completion_uses_all_281_frames_and_original_sound_boundaries() {
let mut game = Game::new(1);
game.panel_frame = Some(0);
game.wheel_holes.fill(true);
for record_id in 129..=133 {
game.record_contacts[record_id] = 99;
}
let mut events = Vec::new();
for _ in 0..282 {
game.timer_tick(0.030, 0, &mut events);
}
assert_eq!(game.panel_frame, None);
assert_eq!(game.wheel_holes, [false; 5]);
assert!(
game.record_contacts[129..=133]
.iter()
.all(|contact| *contact == 0)
);
assert_eq!(
events,
[
Event::Sound(2013),
Event::StopSound,
Event::Sound(2012),
Event::Sound(2013),
Event::StopSound,
Event::Sound(2013),
Event::StopSound,
Event::Sound(2012),
Event::Sound(2013),
]
);
}
#[test]
fn target_rotation_uses_six_callbacks_and_rotates_player_state() {
let mut game = Game::new(1);
game.ball.in_launcher = false;
game.wheel_holes = [true, false, true, false, false];
game.record_contacts[129..=133].copy_from_slice(&[11, 22, 33, 44, 55]);
game.target_rotation_state = Some(0);
let mut events = Vec::new();
for expected in 1..=6 {
game.timer_tick(0.030, 0, &mut events);
assert_eq!(game.target_rotation_state, Some(expected));
}
assert_eq!(events, [Event::Sound(2011)]);
assert_eq!(game.wheel_holes, [false, true, false, false, true]);
assert_eq!(&game.record_contacts[129..=133], &[22, 33, 44, 55, 11]);
game.timer_tick(0.030, 0, &mut events);
assert_eq!(game.target_rotation_state, None);
}
#[test]
fn target_rotation_pauses_in_launcher_and_survives_normal_drain() {
let mut game = Game::new(1);
game.target_rotation_state = Some(2);
game.timer_tick(0.030, 0, &mut Vec::new());
assert_eq!(game.target_rotation_state, Some(2));
game.ball.in_launcher = false;
game.timer_tick(0.030, 0, &mut Vec::new());
assert_eq!(game.target_rotation_state, Some(3));
game.drain(&mut Vec::new());
assert_eq!(game.target_rotation_state, Some(3));
game.timer_tick(0.030, 0, &mut Vec::new());
assert_eq!(game.target_rotation_state, Some(3));
}
#[test]
fn record_countdowns_advance_once_per_active_timer_callback() {
let mut game = Game::new(1);
game.record_countdowns[51] = 5;
game.timer_tick(0.030, 0, &mut Vec::new());
assert_eq!(
game.record_countdown(51),
5,
"launcher idle pauses countdowns"
);
game.ball.in_launcher = false;
for expected in (0..5).rev() {
game.timer_tick(0.030, 0, &mut Vec::new());
assert_eq!(game.record_countdown(51), expected);
}
}
#[test]
fn claw_release_table_decodes_the_original_thousandth_pixel_coordinates() {
for (frame, expected_position, expected_velocity) in [
(1, vec2(258.0, 78.0), MilliVec { x: -2_280, y: 0 }),
(
6,
vec2(270.0, 94.0),
MilliVec {
x: -1_520,
y: 2_280,
},
),
(
7,
vec2(275.0, 97.0),
MilliVec {
x: -1_178,
y: 2_660,
},
),
(18, vec2(325.0, 92.0), MilliVec { x: 0, y: 1_000 }),
] {
let (position, velocity) = claw_release(frame);
assert_eq!(position, expected_position);
assert_eq!(
MilliVec::from_velocity_per_second(velocity),
expected_velocity
);
}
}
#[test]
fn drain_clears_latched_tilt() {
let mut game = Game::new(1);
game.tilted = true;
game.ball.in_launcher = false;
game.ball.position = vec2(157.0, 454.0);
game.ball.velocity = vec2(0.0, 200.0);
let held = Controls {
left_flipper: true,
..Controls::default()
};
let events = game.update(0.030, 3, held);
assert!(!game.tilted);
assert!(game.ball.in_launcher);
assert!(!events.contains(&Event::Sound(2008)));
assert!(game.flipper_release_latch[0]);
game.update(0.0, 3, held);
assert!(!game.flipper_inputs.left_raised);
game.update(0.0, 3, Controls::default());
game.update(0.0, 3, held);
assert!(game.flipper_inputs.left_raised);
}
#[test]
fn ball_end_preserves_or_releases_flippers_like_key_message_latches() {
let mut live_player = Game::new(1);
live_player.flipper_inputs.left_raised = true;
live_player.flippers.left_raised = true;
live_player.drain(&mut Vec::new());
assert!(live_player.flipper_inputs.left_raised);
assert!(live_player.flippers.left_raised);
let mut finished_player = Game::new(2);
finished_player.players[0].balls = 1;
finished_player.flipper_inputs.left_raised = true;
finished_player.flippers.left_raised = true;
finished_player.drain(&mut Vec::new());
assert!(!finished_player.flipper_inputs.left_raised);
assert!(finished_player.flippers.left_raised);
assert!(finished_player.flipper_release_latch[0]);
let mut events = Vec::new();
finished_player.timer_tick(0.030, 0, &mut events);
assert!(!finished_player.flippers.left_raised);
assert_eq!(events, [Event::FlipperMove, Event::Sound(2021)]);
let held = Controls {
left_flipper: true,
..Controls::default()
};
finished_player.update(0.0, 3, held);
assert!(!finished_player.flipper_inputs.left_raised);
finished_player.update(0.0, 3, Controls::default());
finished_player.update(0.0, 3, held);
assert!(finished_player.flipper_inputs.left_raised);
}
#[test]
fn waiting_ball_uses_the_same_nudge_tilt_counter() {
let mut game = Game::new_with_seed(1, 7);
for attempt in 0..2 {
let events = game.update(
0.0,
3,
Controls {
nudge: Nudge::Right,
..Controls::default()
},
);
assert_eq!(events.contains(&Event::Tilt), attempt == 1);
}
assert!(game.tilted);
assert!(game.ball.in_launcher);
}
#[test]
fn tilted_waiting_ball_can_still_be_launched() {
let mut game = Game::new_with_seed(1, 7);
for _ in 0..2 {
game.update(
0.0,
3,
Controls {
nudge: Nudge::Right,
..Controls::default()
},
);
}
assert!(game.tilted);
assert!(game.ball.in_launcher);
game.update(
0.0,
3,
Controls {
launch_down: true,
..Controls::default()
},
);
assert!(game.launcher_frame() > 0);
let events = game.update(0.0, 3, Controls::default());
assert!(events.contains(&Event::Launch));
assert!(!events.contains(&Event::Sound(2002)));
assert!(!game.ball.in_launcher);
assert!(game.tilted);
}
}