The rewrite waited for the whole game to finish, selected only the maximum player score, and sorted entries as ordinary unsigned integers. The original checks each player immediately when their own last ball is lost and compares scores by signed high word then unsigned low word. Emit a candidate at each player elimination, prompt qualifying players with the original `TDK Pinball Player` value, show the table, and then either resume the next player or return to attract mode. Preserve source table order on import and insert only strictly qualifying scores with the original word-wise comparator; keep JSON as the host persistence representation. Test Plan: - `cargo test --all-targets` -- passed, 60 tests - `cargo clippy --all-targets -- -D warnings` -- passed - `rumdl check tdkpin-rs/CHANGELOG.md tdkpin-rs/RECONSTRUCTION.md` -- passed - `git diff --cached --check` -- passed
2211 lines
76 KiB
Rust
2211 lines
76 KiB
Rust
use crate::{
|
|
borland_random::BorlandRandom,
|
|
geometry::{Segment, closest_point},
|
|
original_physics::{
|
|
CollisionResponse, GRAVITY_MILLI_PER_STEP, MAXIMUM_SPEED_MILLI_PER_STEP, MilliVec,
|
|
STEP_SECONDS, circle_collision_response, line_collision_response, path_intersects_circle,
|
|
},
|
|
table::{
|
|
BUMPERS, EFFECT_SENSOR, LOCK_HOLES, PASSIVE_CIRCLES, TARGET_SENSORS, WALLS,
|
|
WHEEL_RESET_SENSOR,
|
|
},
|
|
};
|
|
use macroquad::prelude::{Rect, Vec2, vec2};
|
|
|
|
const FLIPPER_CONTACT_RADIUS: f32 = 9.0;
|
|
const LEFT_FLIPPER_PIVOT: Vec2 = Vec2::new(103.0, 397.0);
|
|
const LEFT_FLIPPER_REST_TIP: Vec2 = Vec2::new(134.0, 419.0);
|
|
const LEFT_FLIPPER_RAISED_TIP: Vec2 = Vec2::new(133.0, 377.0);
|
|
const RIGHT_FLIPPER_PIVOT: Vec2 = Vec2::new(210.0, 397.0);
|
|
const RIGHT_FLIPPER_REST_TIP: Vec2 = Vec2::new(179.0, 419.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_FRAME_THRESHOLDS: [f32; 10] =
|
|
[0.05, 0.15, 0.25, 0.35, 0.45, 0.55, 0.65, 0.75, 0.85, 0.95];
|
|
const LAUNCHER_IMPULSE_MILLI: i32 = 375;
|
|
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 CLAW_TERMINAL_FRAMES: [u8; 4] = [1, 6, 7, 18];
|
|
const ORIGINAL_BALL_SPEED_PER_SECOND: f32 = 380.0;
|
|
|
|
#[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),
|
|
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(_) => 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)]
|
|
pub struct Claw {
|
|
pub active: bool,
|
|
pub frame: u8,
|
|
pub bank: ClawSpriteBank,
|
|
pub ball_suspended: 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,
|
|
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 double_score: bool,
|
|
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,
|
|
double_score: false,
|
|
media_level: 0,
|
|
rules: RuleState::default(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
pub struct Ball {
|
|
pub position: Vec2,
|
|
pub velocity: Vec2,
|
|
pub in_launcher: bool,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
enum PlayerEntry {
|
|
Open,
|
|
Closed,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
|
enum MultiballState {
|
|
#[default]
|
|
Unavailable,
|
|
Ready,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
struct RuleState {
|
|
wheel_holes: [bool; 5],
|
|
top_targets: [bool; 3],
|
|
trigger_contacts: [bool; 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],
|
|
trigger_contacts: [false; 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,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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,
|
|
pub claw: Claw,
|
|
pub bumper_flash: [f32; 3],
|
|
pub wheel_animation: f32,
|
|
pub nudge_shake: f32,
|
|
pub launcher_charge: f32,
|
|
pub finished: bool,
|
|
pub last_collision_id: Option<u8>,
|
|
accumulator: f32,
|
|
bumper_cooldown: f32,
|
|
tilt_counter: u16,
|
|
tilt_counter_accumulator: f32,
|
|
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],
|
|
trigger_contacts: [bool; 176],
|
|
object_active: [bool; 176],
|
|
target_effect: u8,
|
|
claw_frame_seconds: f32,
|
|
multiball_state: MultiballState,
|
|
}
|
|
|
|
impl Game {
|
|
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(),
|
|
claw: Claw::default(),
|
|
bumper_flash: [0.0; 3],
|
|
wheel_animation: 0.0,
|
|
nudge_shake: 0.0,
|
|
launcher_charge: 0.0,
|
|
finished: false,
|
|
last_collision_id: None,
|
|
accumulator: 0.0,
|
|
bumper_cooldown: 0.0,
|
|
tilt_counter: 0,
|
|
tilt_counter_accumulator: 0.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],
|
|
trigger_contacts: [false; 176],
|
|
object_active: initial_object_activity(),
|
|
target_effect: 0,
|
|
claw_frame_seconds: CLAW_FRAME_SECONDS,
|
|
multiball_state: MultiballState::Unavailable,
|
|
}
|
|
}
|
|
|
|
pub fn player(&self) -> &Player {
|
|
&self.players[self.current_player]
|
|
}
|
|
|
|
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 {
|
|
LAUNCHER_FRAME_THRESHOLDS.partition_point(|threshold| self.launcher_charge >= *threshold)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
fn fire_launcher(&mut self) {
|
|
self.launcher_velocity_milli -= LAUNCHER_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);
|
|
}
|
|
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)];
|
|
self.advance_tilt_counter(frame_time.min(0.05));
|
|
let mut events = Vec::new();
|
|
let old_flippers = self.flippers;
|
|
self.flippers.left_raised = controls.left_flipper && !self.tilted;
|
|
self.flippers.right_raised = controls.right_flipper && !self.tilted;
|
|
if self.flippers.left_raised != old_flippers.left_raised {
|
|
events.push(Event::FlipperMove);
|
|
events.push(Event::Sound(2021));
|
|
self.pending_flipper_edges[0] = if self.flippers.left_raised { 1 } else { -1 };
|
|
}
|
|
if self.flippers.right_raised != old_flippers.right_raised {
|
|
events.push(Event::FlipperMove);
|
|
events.push(Event::Sound(2021));
|
|
self.pending_flipper_edges[1] = if self.flippers.right_raised { 1 } else { -1 };
|
|
}
|
|
|
|
if self.ball.in_launcher && !self.tilted {
|
|
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 -= LAUNCHER_IMPULSE_MILLI;
|
|
self.launcher_next_repeat += LAUNCHER_REPEAT_SECONDS;
|
|
}
|
|
} else {
|
|
self.launcher_velocity_milli -= LAUNCHER_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);
|
|
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);
|
|
while self.accumulator >= STEP_SECONDS {
|
|
self.fixed_update(STEP_SECONDS, &mut events);
|
|
self.advance_secondary_ball(&mut events);
|
|
self.accumulator -= STEP_SECONDS;
|
|
}
|
|
events
|
|
}
|
|
|
|
fn advance_tilt_counter(&mut self, elapsed: f32) {
|
|
self.tilt_counter_accumulator += elapsed;
|
|
while self.tilt_counter_accumulator >= self.claw_frame_seconds {
|
|
self.tilt_counter_accumulator -= self.claw_frame_seconds;
|
|
if self.tilt_counter != 0 {
|
|
self.tilt_counter -= 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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 =
|
|
(f64::from(SCALAR) * (self.random.unit_interval() * 20.0 - 10.0)).round()
|
|
as 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,
|
|
}
|
|
self.ball.velocity = velocity.to_velocity_per_second();
|
|
|
|
if let Some(secondary) = &mut self.secondary_ball {
|
|
let mut slot_velocity = MilliVec::from_velocity_per_second(secondary.velocity);
|
|
match nudge {
|
|
Nudge::Left | Nudge::Right => {
|
|
let amount = if nudge == Nudge::Left { -600 } else { 600 };
|
|
slot_velocity.x = slot_velocity.x.wrapping_add(amount);
|
|
}
|
|
Nudge::Center => {
|
|
let horizontal = (i32::from(self.random.below(21)) - 10) * SCALAR;
|
|
let vertical = -(i32::from(self.random.below(100)) + 50) * SCALAR;
|
|
slot_velocity.x = slot_velocity.x.wrapping_add(horizontal);
|
|
slot_velocity.y = slot_velocity.y.wrapping_add(vertical);
|
|
}
|
|
Nudge::None => {}
|
|
}
|
|
secondary.velocity = slot_velocity.to_velocity_per_second();
|
|
}
|
|
|
|
self.nudge_shake = 0.18;
|
|
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.flippers = 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, dt: f32, events: &mut Vec<Event>) {
|
|
self.bumper_cooldown = (self.bumper_cooldown - dt).max(0.0);
|
|
self.wheel_animation = (self.wheel_animation - dt).max(0.0);
|
|
self.nudge_shake = (self.nudge_shake - dt).max(0.0);
|
|
for flash in &mut self.bumper_flash {
|
|
*flash = (*flash - dt).max(0.0);
|
|
}
|
|
self.update_claw(dt, events);
|
|
if self.claw.ball_suspended {
|
|
self.pending_flipper_edges.fill(0);
|
|
return;
|
|
}
|
|
|
|
if self.ball.in_launcher {
|
|
self.ball.position = LAUNCHER_POSITION;
|
|
self.ball.velocity = Vec2::ZERO;
|
|
return;
|
|
}
|
|
|
|
let old_position = MilliVec::from_position(self.ball.position);
|
|
let mut velocity = MilliVec::from_velocity_per_second(self.ball.velocity);
|
|
velocity.y += GRAVITY_MILLI_PER_STEP;
|
|
velocity.clamp_speed(MAXIMUM_SPEED_MILLI_PER_STEP);
|
|
self.apply_magnetic_fields(old_position, &mut velocity);
|
|
let movement_velocity = velocity;
|
|
let mut position = old_position.add(velocity);
|
|
let mut best_collision: Option<(u8, bool, CollisionResponse)> = None;
|
|
for object_id in 1..=175 {
|
|
if !self.object_active[usize::from(object_id)] {
|
|
continue;
|
|
}
|
|
if self.claw.active && (12..=20).contains(&object_id) {
|
|
continue;
|
|
}
|
|
if let Some(wall) = WALLS.iter().find(|wall| wall.id == object_id) {
|
|
let segment = self.live_wall_segment(wall.id, wall.segment);
|
|
if let Some(response) = line_collision_response(
|
|
old_position,
|
|
velocity,
|
|
segment.start,
|
|
segment.end,
|
|
f64::from(wall.normal_rebound),
|
|
f64::from(wall.tangent_coupling),
|
|
) && best_collision
|
|
.is_none_or(|(_, _, closest)| response.progress < closest.progress)
|
|
{
|
|
best_collision = Some((wall.id, true, response));
|
|
}
|
|
}
|
|
let circle = PASSIVE_CIRCLES
|
|
.iter()
|
|
.chain(BUMPERS.iter())
|
|
.find(|circle| circle.id == object_id);
|
|
if let Some(circle) = circle
|
|
&& let Some(response) = circle_collision_response(
|
|
old_position,
|
|
velocity,
|
|
self.live_circle_center(circle.id, circle.center),
|
|
circle.contact_radius,
|
|
f64::from(circle.normal_rebound),
|
|
f64::from(circle.tangent_coupling),
|
|
f64::from(circle.normal_kick),
|
|
)
|
|
&& best_collision.is_none_or(|(_, _, closest)| response.progress < closest.progress)
|
|
{
|
|
best_collision = Some((circle.id, false, response));
|
|
}
|
|
}
|
|
let (hit_wall, hit_circle) = if let Some((object_id, is_wall, response)) = best_collision {
|
|
velocity = response.velocity;
|
|
position = old_position.add(velocity);
|
|
self.last_collision_id = Some(object_id);
|
|
if is_wall {
|
|
(Some(object_id), None)
|
|
} else {
|
|
(None, Some(object_id))
|
|
}
|
|
} else {
|
|
(None, None)
|
|
};
|
|
self.ball.position = position.to_position();
|
|
self.ball.velocity = velocity.to_velocity_per_second();
|
|
|
|
if let Some(wall_id) = hit_wall {
|
|
if wall_id == 2 {
|
|
self.drain(events);
|
|
return;
|
|
}
|
|
if wall_id == 25
|
|
&& i64::from(velocity.x).pow(2) + i64::from(velocity.y).pow(2) < 1_000_i64.pow(2)
|
|
{
|
|
self.ball = Ball::default();
|
|
self.launcher_charge = 0.0;
|
|
self.launcher_was_down = false;
|
|
return;
|
|
}
|
|
self.apply_wall_rule(wall_id, events);
|
|
}
|
|
|
|
if let Some(index) = hit_circle
|
|
.and_then(|circle_id| BUMPERS.iter().position(|bumper| bumper.id == circle_id))
|
|
&& !self.tilted
|
|
&& self.bumper_cooldown <= 0.0
|
|
{
|
|
self.add_score(self.player().bumper_value, events);
|
|
self.bumper_cooldown = 0.08;
|
|
self.bumper_flash[index] = 0.16;
|
|
events.push(Event::Bumper);
|
|
events.push(Event::Sound(2006));
|
|
}
|
|
|
|
self.check_sensor_objects(old_position, movement_velocity, events);
|
|
if self.claw.ball_suspended {
|
|
return;
|
|
}
|
|
|
|
self.apply_flipper_kicks();
|
|
|
|
if self.ball.position.y > 470.0 {
|
|
// Only malformed/out-of-table states reach this guard; the real
|
|
// drain is collision object 2 at y=455.
|
|
self.drain(events);
|
|
}
|
|
}
|
|
|
|
fn advance_secondary_ball(&mut self, events: &mut Vec<Event>) {
|
|
let Some(mut ball) = self.secondary_ball.take() else {
|
|
return;
|
|
};
|
|
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);
|
|
self.apply_magnetic_fields(old_position, &mut velocity);
|
|
let mut best_collision: Option<(u8, bool, CollisionResponse)> = None;
|
|
|
|
for object_id in 1..=175 {
|
|
if !self.object_active[usize::from(object_id)] {
|
|
continue;
|
|
}
|
|
if let Some(wall) = WALLS.iter().find(|wall| wall.id == object_id) {
|
|
let segment = self.live_wall_segment(wall.id, wall.segment);
|
|
if let Some(response) = line_collision_response(
|
|
old_position,
|
|
velocity,
|
|
segment.start,
|
|
segment.end,
|
|
f64::from(wall.normal_rebound),
|
|
f64::from(wall.tangent_coupling),
|
|
) && best_collision
|
|
.is_none_or(|(_, _, closest)| response.progress < closest.progress)
|
|
{
|
|
best_collision = Some((wall.id, true, response));
|
|
}
|
|
}
|
|
let circle = PASSIVE_CIRCLES
|
|
.iter()
|
|
.chain(BUMPERS.iter())
|
|
.find(|circle| circle.id == object_id);
|
|
if let Some(circle) = circle
|
|
&& let Some(response) = circle_collision_response(
|
|
old_position,
|
|
velocity,
|
|
self.live_circle_center(circle.id, circle.center),
|
|
circle.contact_radius,
|
|
f64::from(circle.normal_rebound),
|
|
f64::from(circle.tangent_coupling),
|
|
f64::from(circle.normal_kick),
|
|
)
|
|
&& best_collision.is_none_or(|(_, _, closest)| response.progress < closest.progress)
|
|
{
|
|
best_collision = Some((circle.id, false, response));
|
|
}
|
|
}
|
|
|
|
if let Some(response) = circle_collision_response(
|
|
old_position,
|
|
velocity,
|
|
self.ball.position,
|
|
17.0,
|
|
0.9,
|
|
0.0,
|
|
0.0,
|
|
) && best_collision.is_none_or(|(_, _, closest)| response.progress < closest.progress)
|
|
{
|
|
best_collision = Some((174, false, response));
|
|
}
|
|
let mut hit = None;
|
|
if let Some((object_id, is_wall, response)) = best_collision {
|
|
velocity = response.velocity;
|
|
hit = Some((object_id, is_wall));
|
|
}
|
|
ball.position = old_position.add(velocity).to_position();
|
|
ball.velocity = velocity.to_velocity_per_second();
|
|
if hit == Some((2, true)) || ball.position.y > 470.0 {
|
|
events.push(Event::Drain);
|
|
return;
|
|
}
|
|
if let Some((object_id, true)) = hit {
|
|
self.apply_wall_rule(object_id, events);
|
|
} else if let Some((object_id, false)) = hit
|
|
&& let Some(index) = BUMPERS.iter().position(|bumper| bumper.id == object_id)
|
|
&& !self.tilted
|
|
&& self.bumper_cooldown <= 0.0
|
|
{
|
|
self.add_score(self.player().bumper_value, events);
|
|
self.bumper_cooldown = 0.08;
|
|
self.bumper_flash[index] = 0.16;
|
|
events.push(Event::Bumper);
|
|
events.push(Event::Sound(2006));
|
|
}
|
|
self.secondary_ball = Some(ball);
|
|
}
|
|
|
|
fn apply_wall_rule(&mut self, object_id: u8, events: &mut Vec<Event>) {
|
|
let wall = WALLS
|
|
.iter()
|
|
.find(|wall| wall.id == object_id)
|
|
.expect("a wall collision id must reference a recovered wall");
|
|
if !self.tilted && wall.score != 0 {
|
|
self.add_score(wall.score, events);
|
|
events.push(if object_id == 121 {
|
|
Event::Wheel
|
|
} else {
|
|
Event::Target
|
|
});
|
|
}
|
|
|
|
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
|
|
};
|
|
if completion_bonus != 0 {
|
|
self.add_score(completion_bonus, events);
|
|
}
|
|
events.push(Event::Sound(2017));
|
|
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
|
|
};
|
|
self.add_score(award, events);
|
|
events.push(Event::Sound(2017));
|
|
} else {
|
|
let player = &mut self.players[self.current_player];
|
|
if player.double_score {
|
|
player.secondary_score = player.secondary_score.wrapping_add(100_000);
|
|
} else {
|
|
player.double_score = true;
|
|
}
|
|
events.push(Event::Sound(2007));
|
|
}
|
|
for active in &mut self.object_active[109..=120] {
|
|
*active = true;
|
|
}
|
|
}
|
|
}
|
|
if wall.flags & 0x0400 != 0 {
|
|
self.wheel_animation = 0.65;
|
|
events.push(Event::Sound(2011));
|
|
}
|
|
if wall.flags & 0x0004 != 0 {
|
|
events.push(Event::Sound(2012));
|
|
if self.multiball_state == MultiballState::Ready {
|
|
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;
|
|
}
|
|
}
|
|
|
|
fn check_sensor_objects(
|
|
&mut self,
|
|
old_position: MilliVec,
|
|
movement_velocity: MilliVec,
|
|
events: &mut Vec<Event>,
|
|
) {
|
|
if !self.claw.active
|
|
&& path_intersects_circle(
|
|
old_position,
|
|
movement_velocity,
|
|
CLAW_TRIGGER_CENTER,
|
|
CLAW_TRIGGER_RADIUS,
|
|
)
|
|
{
|
|
let terminal_frame = self.next_claw_terminal_frame();
|
|
self.begin_claw_capture(terminal_frame, events);
|
|
return;
|
|
}
|
|
|
|
let current_position = MilliVec::from_position(self.ball.position);
|
|
if self.check_lock_holes(old_position, current_position, movement_velocity, events)
|
|
|| self.check_wheel_reset(old_position, current_position, movement_velocity, events)
|
|
{
|
|
return;
|
|
}
|
|
self.check_effect_sensor(old_position, current_position, movement_velocity, events);
|
|
self.check_target_sensors(old_position, current_position, movement_velocity, events);
|
|
}
|
|
|
|
fn check_lock_holes(
|
|
&mut self,
|
|
old_position: MilliVec,
|
|
current_position: MilliVec,
|
|
movement_velocity: MilliVec,
|
|
events: &mut Vec<Event>,
|
|
) -> bool {
|
|
for (index, sensor) in LOCK_HOLES.into_iter().enumerate() {
|
|
let touched = path_intersects_circle(
|
|
old_position,
|
|
movement_velocity,
|
|
sensor.center,
|
|
sensor.radius,
|
|
);
|
|
let contact_index = usize::from(sensor.id);
|
|
let entered =
|
|
touched && !self.trigger_contacts[contact_index] && !self.wheel_holes[index];
|
|
self.trigger_contacts[contact_index] = path_intersects_circle(
|
|
current_position,
|
|
MilliVec::default(),
|
|
sensor.center,
|
|
sensor.radius,
|
|
);
|
|
if !entered || self.tilted {
|
|
continue;
|
|
}
|
|
let filled_before = self.wheel_holes.iter().filter(|filled| **filled).count();
|
|
self.wheel_holes[index] = true;
|
|
let award = 10_000_u32 << u32::try_from(filled_before).unwrap_or_default();
|
|
let player = &mut self.players[self.current_player];
|
|
player.secondary_score = player.secondary_score.wrapping_add(award);
|
|
if self.wheel_holes.iter().all(|filled| *filled) {
|
|
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);
|
|
}
|
|
self.reset_ball_to_launcher();
|
|
events.push(Event::Lock);
|
|
events.push(Event::Sound(2015));
|
|
return true;
|
|
}
|
|
false
|
|
}
|
|
|
|
fn check_wheel_reset(
|
|
&mut self,
|
|
old_position: MilliVec,
|
|
current_position: MilliVec,
|
|
movement_velocity: MilliVec,
|
|
events: &mut Vec<Event>,
|
|
) -> bool {
|
|
let reset_touched = path_intersects_circle(
|
|
old_position,
|
|
movement_velocity,
|
|
WHEEL_RESET_SENSOR.center,
|
|
WHEEL_RESET_SENSOR.radius,
|
|
);
|
|
let reset_index = usize::from(WHEEL_RESET_SENSOR.id);
|
|
let reset_entered = reset_touched && !self.trigger_contacts[reset_index];
|
|
self.trigger_contacts[reset_index] = path_intersects_circle(
|
|
current_position,
|
|
MilliVec::default(),
|
|
WHEEL_RESET_SENSOR.center,
|
|
WHEEL_RESET_SENSOR.radius,
|
|
);
|
|
if reset_entered && !self.tilted {
|
|
self.wheel_holes.fill(false);
|
|
self.wheel_animation = 0.65;
|
|
self.multiball_state = MultiballState::Ready;
|
|
self.reset_ball_to_launcher();
|
|
events.push(Event::Wheel);
|
|
events.push(Event::Sound(2015));
|
|
return true;
|
|
}
|
|
false
|
|
}
|
|
|
|
fn check_effect_sensor(
|
|
&mut self,
|
|
old_position: MilliVec,
|
|
current_position: MilliVec,
|
|
movement_velocity: MilliVec,
|
|
events: &mut Vec<Event>,
|
|
) {
|
|
let effect_index = usize::from(EFFECT_SENSOR.id);
|
|
if self.object_active[effect_index] {
|
|
let touched = path_intersects_circle(
|
|
old_position,
|
|
movement_velocity,
|
|
EFFECT_SENSOR.center,
|
|
EFFECT_SENSOR.radius,
|
|
);
|
|
let entered = touched && !self.trigger_contacts[effect_index];
|
|
self.trigger_contacts[effect_index] = path_intersects_circle(
|
|
current_position,
|
|
MilliVec::default(),
|
|
EFFECT_SENSOR.center,
|
|
EFFECT_SENSOR.radius,
|
|
);
|
|
if entered && !self.tilted {
|
|
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 => {
|
|
self.secondary_ball = Some(Ball {
|
|
position: LAUNCHER_POSITION,
|
|
velocity: vec2(0.0, -300.0),
|
|
in_launcher: false,
|
|
});
|
|
self.multiball_state = MultiballState::Unavailable;
|
|
}
|
|
_ => {}
|
|
}
|
|
self.target_effect = 0;
|
|
self.object_active[effect_index] = false;
|
|
events.push(Event::Target);
|
|
events.push(Event::Sound(2004));
|
|
}
|
|
}
|
|
}
|
|
|
|
fn check_target_sensors(
|
|
&mut self,
|
|
old_position: MilliVec,
|
|
current_position: MilliVec,
|
|
movement_velocity: MilliVec,
|
|
events: &mut Vec<Event>,
|
|
) {
|
|
for sensor in TARGET_SENSORS {
|
|
let touched = path_intersects_circle(
|
|
old_position,
|
|
movement_velocity,
|
|
sensor.center,
|
|
sensor.radius,
|
|
);
|
|
let contact_index = usize::from(sensor.id);
|
|
let entered = touched && !self.trigger_contacts[contact_index];
|
|
self.trigger_contacts[contact_index] = path_intersects_circle(
|
|
current_position,
|
|
MilliVec::default(),
|
|
sensor.center,
|
|
sensor.radius,
|
|
);
|
|
if !entered || self.tilted {
|
|
continue;
|
|
}
|
|
self.add_score(sensor.score, events);
|
|
events.push(Event::Target);
|
|
events.push(Event::Sound(2004));
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
fn reset_ball_to_launcher(&mut self) {
|
|
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;
|
|
}
|
|
|
|
#[allow(clippy::cast_possible_truncation)]
|
|
fn apply_magnetic_fields(&mut self, old_position: MilliVec, velocity: &mut MilliVec) {
|
|
for (object_id, min_x, min_y, max_x, max_y) in [
|
|
(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),
|
|
] {
|
|
if !self.object_active[object_id] {
|
|
continue;
|
|
}
|
|
let predicted = old_position.add(*velocity);
|
|
let sweep_min_x = old_position.x.min(predicted.x);
|
|
let sweep_max_x = old_position.x.max(predicted.x);
|
|
let sweep_min_y = old_position.y.min(predicted.y);
|
|
let sweep_max_y = old_position.y.max(predicted.y);
|
|
if sweep_max_x < min_x
|
|
|| sweep_min_x > max_x
|
|
|| sweep_max_y < min_y
|
|
|| sweep_min_y > max_y
|
|
{
|
|
continue;
|
|
}
|
|
velocity.x = (f64::from(velocity.x) * 0.9).round() as i32;
|
|
let vertical_factor = 1.0 - self.random.unit_interval() * 0.3;
|
|
velocity.y = -(f64::from(MAXIMUM_SPEED_MILLI_PER_STEP) * vertical_factor).round() as i32;
|
|
if old_position.add(*velocity).y < min_y {
|
|
self.object_active[object_id] = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
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 effect_target_active(&self) -> bool {
|
|
self.object_active[usize::from(EFFECT_SENSOR.id)]
|
|
}
|
|
|
|
fn next_claw_terminal_frame(&mut self) -> u8 {
|
|
CLAW_TERMINAL_FRAMES[usize::from(self.random.below(4))]
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
for (edge, pivot, rest_tip, raised_tip, horizontal_sign) in [
|
|
(
|
|
pending[0],
|
|
LEFT_FLIPPER_PIVOT,
|
|
LEFT_FLIPPER_REST_TIP,
|
|
LEFT_FLIPPER_RAISED_TIP,
|
|
1.0,
|
|
),
|
|
(
|
|
pending[1],
|
|
RIGHT_FLIPPER_PIVOT,
|
|
RIGHT_FLIPPER_REST_TIP,
|
|
RIGHT_FLIPPER_RAISED_TIP,
|
|
-1.0,
|
|
),
|
|
] {
|
|
if edge == 0 || !point_in_flipper_sweep(self.ball.position, pivot, rest_tip, raised_tip)
|
|
{
|
|
continue;
|
|
}
|
|
let local_x = (self.ball.position.x - pivot.x) * horizontal_sign;
|
|
let local_y = self.ball.position.y - pivot.y;
|
|
if edge < 0 {
|
|
let velocity_milli = MilliVec::from_millipixels(vec2(
|
|
horizontal_sign * local_x * 159.95,
|
|
local_x * 286.45,
|
|
));
|
|
self.ball.velocity = velocity_milli.to_velocity_per_second();
|
|
continue;
|
|
}
|
|
let horizontal_displacement =
|
|
0.002_12 * local_x.powi(2) - 0.125_68 * local_x + 14.921 - (local_y + 7.0);
|
|
let vertical_displacement = -(local_x + 13.0);
|
|
let velocity_scale = -0.026 * local_x.powi(2) + 3.614 * local_x + 101.327;
|
|
let displacement = vec2(
|
|
horizontal_displacement * horizontal_sign,
|
|
vertical_displacement,
|
|
);
|
|
self.ball.position += displacement;
|
|
let velocity_milli = MilliVec::from_millipixels(displacement * velocity_scale);
|
|
self.ball.velocity = velocity_milli.to_velocity_per_second();
|
|
}
|
|
}
|
|
|
|
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.frame_accumulator = 0.0;
|
|
self.ball.velocity = Vec2::ZERO;
|
|
events.push(Event::ClawCapture);
|
|
events.push(Event::Sound(2015));
|
|
}
|
|
|
|
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;
|
|
(self.ball.position, self.ball.velocity) = claw_release(release_frame);
|
|
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];
|
|
let multiplier = if self.player().double_score {
|
|
2
|
|
} else {
|
|
1
|
|
};
|
|
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() && 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 let Some(remaining_ball) = self.secondary_ball.take() {
|
|
self.ball = remaining_ball;
|
|
events.push(Event::Drain);
|
|
return;
|
|
}
|
|
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);
|
|
events.push(Event::Sound(2008));
|
|
if let Some(score) = high_score_candidate {
|
|
events.push(Event::HighScoreCandidate(score));
|
|
}
|
|
self.tilted = false;
|
|
self.tilt_counter = 0;
|
|
self.tilt_counter_accumulator = 0.0;
|
|
self.bumper_flash.fill(0.0);
|
|
self.wheel_animation = 0.0;
|
|
self.claw = Claw::default();
|
|
self.secondary_ball = None;
|
|
self.nudge_shake = 0.0;
|
|
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 save_current_rule_state(&mut self) {
|
|
self.players[self.current_player].rules = RuleState {
|
|
wheel_holes: self.wheel_holes,
|
|
top_targets: self.top_targets,
|
|
trigger_contacts: self.trigger_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.trigger_contacts = state.trigger_contacts;
|
|
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 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 point_in_flipper_sweep(point: Vec2, pivot: Vec2, rest_tip: Vec2, raised_tip: Vec2) -> bool {
|
|
let boundary_distance_squared = [
|
|
Segment::new(pivot, rest_tip, 0.0),
|
|
Segment::new(pivot, raised_tip, 0.0),
|
|
Segment::new(rest_tip, raised_tip, 0.0),
|
|
]
|
|
.into_iter()
|
|
.map(|segment| point.distance_squared(closest_point(point, segment)))
|
|
.fold(f32::INFINITY, f32::min);
|
|
if boundary_distance_squared <= FLIPPER_CONTACT_RADIUS.powi(2) {
|
|
return true;
|
|
}
|
|
|
|
let cross = |a: Vec2, b: Vec2, p: Vec2| (b - a).perp_dot(p - a);
|
|
let signs = [
|
|
cross(pivot, rest_tip, point),
|
|
cross(rest_tip, raised_tip, point),
|
|
cross(raised_tip, pivot, point),
|
|
];
|
|
signs.iter().all(|value| *value >= 0.0) || signs.iter().all(|value| *value <= 0.0)
|
|
}
|
|
|
|
#[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())
|
|
}
|
|
|
|
#[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 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(1);
|
|
launch_ball(&mut quick, 1);
|
|
let quick_speed = -quick.ball.velocity.y;
|
|
|
|
let mut charged = Game::new(1);
|
|
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.09..0.11).contains(&game.launcher_charge));
|
|
assert_eq!(game.launcher_frame(), 1);
|
|
|
|
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 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, 180.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 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();
|
|
|
|
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, 4 * 1_500 + 24_464);
|
|
assert!(!game.player().double_score);
|
|
|
|
for object_id in [109, 112, 115, 118] {
|
|
game.apply_wall_rule(object_id, &mut events);
|
|
}
|
|
assert!(game.player().double_score);
|
|
let score = game.player().score;
|
|
game.add_score(1_000, &mut events);
|
|
assert_eq!(game.player().score, score + 2_000);
|
|
|
|
for object_id in [109, 112, 115, 118] {
|
|
game.apply_wall_rule(object_id, &mut events);
|
|
}
|
|
assert_eq!(game.player().secondary_score, 100_000);
|
|
}
|
|
|
|
#[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, 102] {
|
|
game.apply_wall_rule(object_id, &mut events);
|
|
}
|
|
assert_eq!(game.player().bumper_value, 2_000);
|
|
assert!(game.object_active[90..=104].iter().all(|active| *active));
|
|
|
|
for object_id in [109, 112, 115, 118] {
|
|
game.apply_wall_rule(object_id, &mut events);
|
|
}
|
|
assert_eq!(game.player().diamond_segments, 1);
|
|
assert!(game.object_active[109..=120].iter().all(|active| *active));
|
|
}
|
|
|
|
#[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;
|
|
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!(!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]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn wheel_reset_arms_effect_seven_multiball() {
|
|
let mut game = Game::new_with_seed(1, 7);
|
|
let mut events = Vec::new();
|
|
game.multiball_state = MultiballState::Ready;
|
|
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!(spawned.position, LAUNCHER_POSITION);
|
|
assert_eq!(spawned.velocity, vec2(0.0, -300.0));
|
|
|
|
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);
|
|
}
|
|
|
|
#[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 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.trigger_contacts[149] = true;
|
|
game.object_active[90] = false;
|
|
game.target_effect = 3;
|
|
game.multiball_state = MultiballState::Ready;
|
|
|
|
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!(!game.trigger_contacts[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!(game.trigger_contacts[149]);
|
|
assert!(!game.object_active[90]);
|
|
assert_eq!(game.target_effect, 3);
|
|
assert_eq!(game.multiball_state, MultiballState::Ready);
|
|
}
|
|
|
|
#[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(STEP_SECONDS, &mut Vec::new());
|
|
}
|
|
|
|
assert!(game.ball.position.y >= 15.0);
|
|
assert!(game.ball.velocity.y > 0.0);
|
|
}
|
|
|
|
#[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(
|
|
1.0 / 60.0,
|
|
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, LEFT_FLIPPER_REST_TIP),
|
|
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, RIGHT_FLIPPER_REST_TIP),
|
|
vec2(181.0, 376.0)
|
|
);
|
|
}
|
|
|
|
#[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()
|
|
};
|
|
|
|
let press_events = game.update(1.0 / 60.0, 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(1.0 / 60.0, 3, raised).is_empty());
|
|
|
|
let release_events = game.update(1.0 / 60.0, 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_matches_the_live_swept_transfer_probe() {
|
|
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!(game.ball.position.distance(vec2(138.182, 355.0)) < 0.002);
|
|
let raw_velocity = MilliVec::from_velocity_per_second(velocity_after_edge);
|
|
assert!((raw_velocity.x - 2_217).abs() <= 2);
|
|
assert!((raw_velocity.y - -5_889).abs() <= 2);
|
|
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_matches_the_live_downstroke_probe() {
|
|
let mut game = Game::new(1);
|
|
game.flippers.left_raised = true;
|
|
|
|
let events = game.update(0.0, 3, Controls::default());
|
|
|
|
assert_eq!(events, [Event::FlipperMove, Event::Sound(2021)]);
|
|
assert_eq!(game.pending_flipper_edges, [-1, 0]);
|
|
game.ball.in_launcher = false;
|
|
game.ball.position = vec2(125.0, 390.0);
|
|
game.ball.velocity = Vec2::ZERO;
|
|
game.apply_flipper_kicks();
|
|
let raw_velocity = MilliVec::from_velocity_per_second(game.ball.velocity);
|
|
assert!((raw_velocity.x - 3_519).abs() <= 1);
|
|
assert!((raw_velocity.y - 6_302).abs() <= 1);
|
|
assert_eq!(game.ball.position, vec2(125.0, 390.0));
|
|
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 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.fixed_update(CLAW_FRAME_SECONDS / 3.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 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::ClawCapture, Event::Sound(2015)]);
|
|
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);
|
|
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]);
|
|
|
|
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_550 });
|
|
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, 70.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 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() {
|
|
game.ball.position = sensor.center;
|
|
game.check_sensor_objects(
|
|
MilliVec::from_position(sensor.center),
|
|
MilliVec::default(),
|
|
&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
|
|
);
|
|
|
|
game.ball.in_launcher = false;
|
|
game.ball.position = WHEEL_RESET_SENSOR.center;
|
|
game.check_sensor_objects(
|
|
MilliVec::from_position(WHEEL_RESET_SENSOR.center),
|
|
MilliVec::default(),
|
|
&mut events,
|
|
);
|
|
assert!(game.wheel_holes.iter().all(|filled| !*filled));
|
|
assert!(events.ends_with(&[Event::Wheel, Event::Sound(2015)]));
|
|
}
|
|
|
|
#[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);
|
|
|
|
game.update(1.0 / 60.0, 3, Controls::default());
|
|
|
|
assert!(!game.tilted);
|
|
assert!(game.ball.in_launcher);
|
|
}
|
|
|
|
#[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);
|
|
}
|
|
}
|