Files
tdkpin/tdkpin-rs/src/game.rs
T
ddidderr c1d8e5755f fix(input): restore launcher press and release impulses
The rewrite used a 375-millipixel impulse for both charging and release. The
reconstructed key handlers use the setup scalar 15: every repeated Down keydown
subtracts 15*50, and release subtracts a further 15*100 before clamping.

Use 750-millipixel press increments and the 1,500-millipixel release impulse,
then reproduce the randomized -3800 lower clamp and weak -2280 upper branch.
Derive the ten launcher decoration frames from the original strict multiples of
750 and keep integration/gravity assertions separate from the immediate release
state.

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
2026-08-23 17:03:35 +02:00

2252 lines
78 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_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 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 {
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
}
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 = 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)];
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 = 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);
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));
self.randomize_trigger_velocity();
}
}
}
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;
}
self.randomize_trigger_velocity();
}
}
#[allow(clippy::cast_possible_truncation)]
fn randomize_trigger_velocity(&mut self) {
let mut velocity = MilliVec::from_velocity_per_second(self.ball.velocity);
let x_factor = 1.03 - self.random.unit_interval() * 0.08;
let y_factor = 1.03 - self.random.unit_interval() * 0.08;
velocity.x = (f64::from(velocity.x) * x_factor).round() as i32;
velocity.y = (f64::from(velocity.y) * y_factor).round() as i32;
self.ball.velocity = velocity.to_velocity_per_second();
}
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_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 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);
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, 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);
}
}