The Greifarm entry restriction was still shared across both multiball slots. An unrelated upper-playfield ball could clear the entering ball's latch during its own record-89 scan, while a shallow aborted entry could retain a stale choice. Store the latch owner with the capture-time restriction, permit only that slot to clear it when leaving the deep capture zone, and migrate the owner when a held secondary ball becomes primary. Test Plan: - `just --justfile tdkpin-rs/justfile test` -- passed (151 game, 8 service tests) - `just --justfile tdkpin-rs/justfile clippy` -- passed - `just --justfile tdkpin-rs/justfile build-production` -- passed - `just --justfile tdkpin-rs/justfile web-build` -- passed - `cargo +nightly fmt --manifest-path tdkpin-rs/Cargo.toml --all -- --check` -- passed - `rumdl check --flavor commonmark tdkpin-rs/CHANGELOG.md tdkpin-rs/AGENTS.md` -- passed - `git diff --cached --check` -- passed
5683 lines
197 KiB
Rust
5683 lines
197 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>,
|
|
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 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,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct Game {
|
|
pub players: Vec<Player>,
|
|
pub current_player: usize,
|
|
pub ball: Ball,
|
|
pub secondary_ball: Option<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,
|
|
}
|
|
|
|
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,
|
|
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,
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
#[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));
|
|
}
|
|
|
|
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_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 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);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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;
|
|
}
|
|
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 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 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);
|
|
}
|
|
}
|