Attach recovered score and rule flags to type-2 collision records. Deactivate complete three-line groups through their contact owner, rearm all five bumper-value groups after completion, and rearm all four central groups after advancing the TDK diamond. Remove the broad side and colored-target coordinate checks those physical records replace. Preserve exact 5,000, 100,000, bank, and wheel-trigger scores from the initialized object ledger. Test Plan: - `cargo test --all-targets` -- 46 passed - `cargo clippy --all-targets -- -D warnings` -- passed - `cargo build --profile production` -- passed - target-bank deactivation, completion, and rearm test -- passed - `git diff --cached --check` -- passed
1500 lines
50 KiB
Rust
1500 lines
50 KiB
Rust
use crate::{
|
|
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, PASSIVE_CIRCLES, TARGET_SENSORS, WALLS},
|
|
};
|
|
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 TILT_THRESHOLD: f32 = 1.15;
|
|
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 CLAW_TERMINAL_FRAMES: [u8; 4] = [1, 6, 7, 18];
|
|
const ORIGINAL_BALL_SPEED_PER_SECOND: f32 = 3.8 / CLAW_FRAME_SECONDS;
|
|
|
|
#[derive(Clone, Copy, Debug, Default)]
|
|
pub struct Controls {
|
|
pub left_flipper: bool,
|
|
pub right_flipper: bool,
|
|
pub launch_down: bool,
|
|
pub nudge: f32,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum Event {
|
|
FlipperMove,
|
|
Launch,
|
|
Bumper,
|
|
Target,
|
|
Wheel,
|
|
ClawCapture,
|
|
ClawRelease,
|
|
Lock,
|
|
Media,
|
|
ExtraBall,
|
|
Nudge,
|
|
Tilt,
|
|
Drain,
|
|
}
|
|
|
|
impl Event {
|
|
/// Original Win16 WAVE resource selected by this gameplay transition.
|
|
pub const fn sound_resource(self) -> u16 {
|
|
match self {
|
|
Self::FlipperMove => 2021,
|
|
Self::Launch => 2002,
|
|
Self::Bumper => 2004,
|
|
Self::Target => 2006,
|
|
Self::Wheel => 2011,
|
|
Self::ClawCapture => 2015,
|
|
Self::ClawRelease => 2016,
|
|
Self::Lock => 2017,
|
|
Self::Media => 2022,
|
|
Self::ExtraBall => 2007,
|
|
Self::Nudge => 2019,
|
|
Self::Tilt => 2020,
|
|
Self::Drain => 2008,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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 balls: u8,
|
|
pub extra_balls: u8,
|
|
pub bumper_value: u32,
|
|
pub diamond_segments: u8,
|
|
pub media_level: u8,
|
|
}
|
|
|
|
impl Default for Player {
|
|
fn default() -> Self {
|
|
Self {
|
|
score: 0,
|
|
balls: 3,
|
|
extra_balls: 0,
|
|
bumper_value: 1_000,
|
|
diamond_segments: 0,
|
|
media_level: 0,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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,
|
|
}
|
|
|
|
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 bonus: u32,
|
|
pub wheel_holes: [bool; 8],
|
|
pub side_targets: [bool; 5],
|
|
pub top_targets: [bool; 3],
|
|
pub lock_lights: u8,
|
|
pub magnets: f32,
|
|
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,
|
|
target_cooldown: f32,
|
|
bumper_cooldown: f32,
|
|
nudge_cooldown: f32,
|
|
nudge_meter: f32,
|
|
launcher_was_down: bool,
|
|
launcher_hold_seconds: f32,
|
|
launcher_next_repeat: f32,
|
|
launcher_velocity_milli: i32,
|
|
player_entry: PlayerEntry,
|
|
claw_rng_state: u32,
|
|
pending_flipper_kicks: [bool; 2],
|
|
trigger_contacts: [bool; 176],
|
|
object_active: [bool; 176],
|
|
}
|
|
|
|
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(),
|
|
bonus: 0,
|
|
wheel_holes: [false; 8],
|
|
side_targets: [false; 5],
|
|
top_targets: [false; 3],
|
|
lock_lights: 0,
|
|
magnets: 0.0,
|
|
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,
|
|
target_cooldown: 0.0,
|
|
bumper_cooldown: 0.0,
|
|
nudge_cooldown: 0.0,
|
|
nudge_meter: 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,
|
|
claw_rng_state: seed.max(1),
|
|
pending_flipper_kicks: [false; 2],
|
|
trigger_contacts: [false; 176],
|
|
object_active: initial_object_activity(),
|
|
}
|
|
}
|
|
|
|
pub fn player(&self) -> &Player {
|
|
&self.players[self.current_player]
|
|
}
|
|
|
|
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.next_random_value()
|
|
% u32::try_from(MAXIMUM_SPEED_MILLI_PER_STEP).unwrap_or(3_800))
|
|
/ 40;
|
|
launch_velocity =
|
|
-MAXIMUM_SPEED_MILLI_PER_STEP + i32::try_from(variation).unwrap_or_default();
|
|
}
|
|
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;
|
|
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);
|
|
self.pending_flipper_kicks[0] = self.flippers.left_raised;
|
|
}
|
|
if self.flippers.right_raised != old_flippers.right_raised {
|
|
events.push(Event::FlipperMove);
|
|
self.pending_flipper_kicks[1] = self.flippers.right_raised;
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
if !self.tilted
|
|
&& !self.ball.in_launcher
|
|
&& controls.nudge.abs() > 0.1
|
|
&& self.nudge_cooldown <= 0.0
|
|
{
|
|
self.ball.velocity.x += controls.nudge * 55.0;
|
|
self.nudge_meter += controls.nudge.abs() * 0.34;
|
|
self.nudge_cooldown = 0.22;
|
|
self.nudge_shake = 0.18;
|
|
if self.nudge_meter >= TILT_THRESHOLD {
|
|
self.tilted = true;
|
|
self.bonus = 0;
|
|
events.push(Event::Tilt);
|
|
} else {
|
|
events.push(Event::Nudge);
|
|
}
|
|
}
|
|
|
|
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.accumulator -= STEP_SECONDS;
|
|
}
|
|
events
|
|
}
|
|
|
|
#[allow(clippy::too_many_lines)]
|
|
fn fixed_update(&mut self, dt: f32, events: &mut Vec<Event>) {
|
|
self.target_cooldown = (self.target_cooldown - dt).max(0.0);
|
|
self.bumper_cooldown = (self.bumper_cooldown - dt).max(0.0);
|
|
self.nudge_cooldown = (self.nudge_cooldown - dt).max(0.0);
|
|
self.magnets = (self.magnets - 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);
|
|
}
|
|
if !self.tilted {
|
|
self.nudge_meter = (self.nudge_meter - dt * 0.34).max(0.0);
|
|
}
|
|
|
|
self.update_claw(dt, events);
|
|
if self.claw.ball_suspended {
|
|
self.pending_flipper_kicks.fill(false);
|
|
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);
|
|
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 {
|
|
if self.magnets > 0.0
|
|
&& (self.ball.position.x < 145.0 || self.ball.position.x > 175.0)
|
|
{
|
|
self.ball.position.y = 410.0;
|
|
self.ball.velocity = vec2((157.0 - self.ball.position.x) * 2.0, -245.0);
|
|
self.magnets = 0.0;
|
|
} else {
|
|
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);
|
|
self.bonus = self.bonus.saturating_add(100);
|
|
self.bumper_cooldown = 0.08;
|
|
self.bumper_flash[index] = 0.16;
|
|
events.push(Event::Bumper);
|
|
}
|
|
|
|
self.check_sensor_objects(old_position, movement_velocity, events);
|
|
if self.claw.ball_suspended {
|
|
return;
|
|
}
|
|
|
|
if !self.tilted {
|
|
self.check_targets(events);
|
|
self.check_media(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 check_targets(&mut self, events: &mut Vec<Event>) {
|
|
if self.target_cooldown > 0.0 {
|
|
return;
|
|
}
|
|
let position = self.ball.position;
|
|
|
|
let wheel_center = vec2(114.0, 79.0);
|
|
let from_wheel = position - wheel_center;
|
|
if (25.0..=43.0).contains(&from_wheel.length()) {
|
|
let angle =
|
|
(from_wheel.y.atan2(from_wheel.x) + std::f32::consts::TAU) % std::f32::consts::TAU;
|
|
let sector_boundaries = [
|
|
std::f32::consts::TAU / 16.0,
|
|
std::f32::consts::TAU * 3.0 / 16.0,
|
|
std::f32::consts::TAU * 5.0 / 16.0,
|
|
std::f32::consts::TAU * 7.0 / 16.0,
|
|
std::f32::consts::TAU * 9.0 / 16.0,
|
|
std::f32::consts::TAU * 11.0 / 16.0,
|
|
std::f32::consts::TAU * 13.0 / 16.0,
|
|
std::f32::consts::TAU * 15.0 / 16.0,
|
|
];
|
|
let hole = sector_boundaries.partition_point(|boundary| angle >= *boundary) % 8;
|
|
if !self.wheel_holes[hole] {
|
|
self.wheel_holes[hole] = true;
|
|
self.add_score(2_500);
|
|
self.target_cooldown = 0.15;
|
|
self.wheel_animation = 0.65;
|
|
events.push(Event::Wheel);
|
|
}
|
|
if self.wheel_holes.iter().all(|hole| *hole) {
|
|
self.wheel_holes.fill(false);
|
|
self.add_score(25_000);
|
|
}
|
|
}
|
|
|
|
if (151.0..=220.0).contains(&position.x) && (187.0..=205.0).contains(&position.y) {
|
|
self.lock_lights = (self.lock_lights + 1).min(4);
|
|
self.add_score(u32::from(self.lock_lights) * 5_000);
|
|
self.ball.position = vec2(185.0, 181.0);
|
|
self.ball.velocity = vec2(-40.0 + f32::from(self.lock_lights) * 18.0, -120.0);
|
|
self.target_cooldown = 0.4;
|
|
events.push(Event::Lock);
|
|
}
|
|
}
|
|
|
|
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.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
|
|
&& [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
|
|
};
|
|
self.add_score(completion_bonus);
|
|
self.side_targets.fill(false);
|
|
for active in &mut self.object_active[90..=104] {
|
|
*active = true;
|
|
}
|
|
}
|
|
if wall.flags & 0x0010 != 0
|
|
&& [109, 112, 115, 118]
|
|
.into_iter()
|
|
.all(|id| !self.object_active[id])
|
|
{
|
|
let player = &mut self.players[self.current_player];
|
|
player.diamond_segments = (player.diamond_segments + 1).min(9);
|
|
let value = u32::from(player.diamond_segments) * 10_000;
|
|
self.add_score(value);
|
|
for active in &mut self.object_active[109..=120] {
|
|
*active = true;
|
|
}
|
|
}
|
|
if wall.flags & 0x0400 != 0 {
|
|
self.wheel_animation = 0.65;
|
|
}
|
|
}
|
|
|
|
fn check_media(&mut self, events: &mut Vec<Event>) {
|
|
const THRESHOLDS: [u32; 5] = [360_000, 720_000, 1_440_000, 2_880_000, 5_760_000];
|
|
let score = self.player().score;
|
|
let level =
|
|
u8::try_from(THRESHOLDS.partition_point(|threshold| score >= *threshold)).unwrap_or(5);
|
|
if level > self.player().media_level {
|
|
let player = &mut self.players[self.current_player];
|
|
player.media_level = level;
|
|
player.extra_balls = player.extra_balls.saturating_add(1);
|
|
events.push(Event::Media);
|
|
events.push(Event::ExtraBall);
|
|
}
|
|
}
|
|
|
|
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);
|
|
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.push(Event::Target);
|
|
if (150..=152).contains(&sensor.id) {
|
|
self.top_targets[usize::from(sensor.id - 150)] = true;
|
|
self.magnets = self.magnets.max(0.3);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn next_claw_terminal_frame(&mut self) -> u8 {
|
|
let value = self.next_random_value();
|
|
CLAW_TERMINAL_FRAMES[value as usize % CLAW_TERMINAL_FRAMES.len()]
|
|
}
|
|
|
|
fn next_random_value(&mut self) -> u32 {
|
|
let mut value = self.claw_rng_state;
|
|
value ^= value << 13;
|
|
value ^= value >> 17;
|
|
value ^= value << 5;
|
|
self.claw_rng_state = value;
|
|
value
|
|
}
|
|
|
|
fn apply_flipper_kicks(&mut self) {
|
|
let pending = self.pending_flipper_kicks;
|
|
self.pending_flipper_kicks.fill(false);
|
|
if self.ball.in_launcher || self.tilted {
|
|
return;
|
|
}
|
|
|
|
for (should_kick, 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 !should_kick
|
|
|| !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;
|
|
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);
|
|
}
|
|
|
|
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 >= CLAW_FRAME_SECONDS {
|
|
self.claw.frame_accumulator -= 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);
|
|
}
|
|
|
|
fn add_score(&mut self, points: u32) {
|
|
let multiplier = if self.player().diamond_segments == 9 {
|
|
2
|
|
} else {
|
|
1
|
|
};
|
|
let player = &mut self.players[self.current_player];
|
|
player.score = player
|
|
.score
|
|
.saturating_add(points.saturating_mul(multiplier));
|
|
}
|
|
|
|
fn drain(&mut self, events: &mut Vec<Event>) {
|
|
let player = &mut self.players[self.current_player];
|
|
player.score = player.score.saturating_add(self.bonus);
|
|
if player.extra_balls > 0 {
|
|
player.extra_balls -= 1;
|
|
} else {
|
|
player.balls = player.balls.saturating_sub(1);
|
|
}
|
|
events.push(Event::Drain);
|
|
self.bonus = 0;
|
|
self.side_targets.fill(false);
|
|
self.top_targets.fill(false);
|
|
self.wheel_holes.fill(false);
|
|
self.lock_lights = 0;
|
|
self.magnets = 0.0;
|
|
self.tilted = false;
|
|
self.nudge_meter = 0.0;
|
|
self.bumper_flash.fill(0.0);
|
|
self.wheel_animation = 0.0;
|
|
self.claw = Claw::default();
|
|
self.trigger_contacts.fill(false);
|
|
self.object_active = initial_object_activity();
|
|
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.ball = Ball::default();
|
|
return;
|
|
}
|
|
next = (next + 1) % self.players.len();
|
|
}
|
|
self.finished = true;
|
|
}
|
|
|
|
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, 1.0 / CLAW_FRAME_SECONDS)),
|
|
_ => 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(1);
|
|
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;
|
|
}
|
|
|
|
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 completed_diamond_doubles_score() {
|
|
let mut game = Game::new(1);
|
|
game.players[0].diamond_segments = 9;
|
|
game.add_score(1_000);
|
|
assert_eq!(game.players[0].score, 2_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 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 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(1);
|
|
assert!(launch_ball(&mut game, 1).contains(&Event::Launch));
|
|
game.bonus = 4_000;
|
|
for _ in 0..4 {
|
|
game.nudge_cooldown = 0.0;
|
|
game.update(
|
|
1.0 / 60.0,
|
|
3,
|
|
Controls {
|
|
nudge: 1.0,
|
|
..Controls::default()
|
|
},
|
|
);
|
|
}
|
|
assert!(game.tilted);
|
|
assert_eq!(game.bonus, 0);
|
|
|
|
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::FlipperMove],
|
|
"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::FlipperMove],
|
|
"the original also plays the sound while returning"
|
|
);
|
|
assert_eq!(Event::FlipperMove.sound_resource(), 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_kicks[0] = true;
|
|
|
|
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_kicks, [false, false]);
|
|
|
|
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_moves_and_sounds_without_queueing_a_kick() {
|
|
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]);
|
|
assert_eq!(game.pending_flipper_kicks, [false, false]);
|
|
}
|
|
|
|
#[test]
|
|
fn recovered_control_sounds_use_the_original_resource_numbers() {
|
|
assert_eq!(Event::Launch.sound_resource(), 2002);
|
|
assert_eq!(Event::Wheel.sound_resource(), 2011);
|
|
assert_eq!(Event::Nudge.sound_resource(), 2019);
|
|
assert_eq!(Event::Tilt.sound_resource(), 2020);
|
|
assert_eq!(Event::Drain.sound_resource(), 2008);
|
|
assert_eq!(Event::ClawCapture.sound_resource(), 2015);
|
|
assert_eq!(Event::ClawRelease.sound_resource(), 2016);
|
|
}
|
|
|
|
#[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]);
|
|
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_eq!(events.last(), Some(&Event::ClawRelease));
|
|
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 - -50.666_668).abs() < 0.001);
|
|
assert!((game.ball.velocity.y - 76.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_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]);
|
|
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(1);
|
|
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]);
|
|
assert!((game.magnets - 0.3).abs() < f32::EPSILON);
|
|
|
|
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 claw_release_table_decodes_the_original_thousandth_pixel_coordinates() {
|
|
for (frame, expected) in [
|
|
(1, vec2(258.0, 78.0)),
|
|
(6, vec2(270.0, 94.0)),
|
|
(7, vec2(275.0, 97.0)),
|
|
(18, vec2(325.0, 92.0)),
|
|
] {
|
|
assert_eq!(claw_release(frame).0, expected);
|
|
}
|
|
}
|
|
|
|
#[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_cannot_be_tilted() {
|
|
let mut game = Game::new(1);
|
|
|
|
for _ in 0..12 {
|
|
game.nudge_cooldown = 0.0;
|
|
let events = game.update(
|
|
1.0 / 60.0,
|
|
3,
|
|
Controls {
|
|
nudge: 1.0,
|
|
..Controls::default()
|
|
},
|
|
);
|
|
assert!(!events.contains(&Event::Nudge));
|
|
assert!(!events.contains(&Event::Tilt));
|
|
}
|
|
|
|
assert!(!game.tilted);
|
|
assert!(launch_ball(&mut game, 1).contains(&Event::Launch));
|
|
}
|
|
}
|