Use the recovered rail rebound coefficient and let a launched ball cross object 22 in the exit direction after the upper curve. Strengthen the launch regression so a momentary lane crossing cannot hide a later rebound. Test Plan: - cargo fmt --check - cargo test - cargo clippy --all-targets --all-features -- -D warnings - verify the charged launch trajectory at detail levels 1 through 5
810 lines
26 KiB
Rust
810 lines
26 KiB
Rust
use crate::{
|
|
geometry::{Segment, circle_collision, segment_collision},
|
|
table::{BUMPERS, PASSIVE_CIRCLES, WALLS},
|
|
};
|
|
use macroquad::prelude::{Vec2, vec2};
|
|
|
|
const FLIPPER_CONTACT_RADIUS: f32 = 9.0;
|
|
// The Win16 engine sweeps the ball center through its pre-expanded object
|
|
// geometry. A small contact epsilon preserves thin-line hits without adding
|
|
// the rendered ball radius to every recovered boundary.
|
|
const TABLE_LINE_RADIUS: f32 = 1.0;
|
|
const SHOOTER_EXIT_GATE_ID: u8 = 22;
|
|
const GRAVITY: f32 = 135.0;
|
|
const MAX_SPEED: f32 = 430.0;
|
|
const BALL_SEARCH_DELAY: f32 = 3.0;
|
|
const BALL_SEARCH_SPEED: f32 = 24.0;
|
|
const TILT_THRESHOLD: f32 = 1.15;
|
|
const LAUNCHER_POSITION: Vec2 = Vec2::new(325.0, 413.0);
|
|
const LAUNCHER_CHARGE_SECONDS: f32 = 0.55;
|
|
const LAUNCH_SPEED_MIN: f32 = 330.0;
|
|
const LAUNCH_SPEED_RANGE: f32 = 100.0;
|
|
|
|
#[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 {
|
|
Flipper,
|
|
Launch,
|
|
Bumper,
|
|
Target,
|
|
Wheel,
|
|
Robot,
|
|
Lock,
|
|
Media,
|
|
ExtraBall,
|
|
Nudge,
|
|
Tilt,
|
|
BallSearch,
|
|
Drain,
|
|
}
|
|
|
|
#[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,
|
|
}
|
|
|
|
impl Default for Ball {
|
|
fn default() -> Self {
|
|
Self {
|
|
position: LAUNCHER_POSITION,
|
|
velocity: Vec2::ZERO,
|
|
in_launcher: true,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(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 left_flipper_angle: f32,
|
|
pub right_flipper_angle: f32,
|
|
pub bumper_flash: [f32; 3],
|
|
pub wheel_animation: f32,
|
|
pub robot_animation: f32,
|
|
pub nudge_shake: f32,
|
|
pub launcher_charge: f32,
|
|
pub finished: bool,
|
|
accumulator: f32,
|
|
target_cooldown: f32,
|
|
bumper_cooldown: f32,
|
|
nudge_cooldown: f32,
|
|
nudge_meter: f32,
|
|
stalled_for: f32,
|
|
launcher_was_down: bool,
|
|
}
|
|
|
|
impl Game {
|
|
pub fn new(player_count: usize) -> 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,
|
|
left_flipper_angle: -2.72,
|
|
right_flipper_angle: -0.42,
|
|
bumper_flash: [0.0; 3],
|
|
wheel_animation: 0.0,
|
|
robot_animation: 0.0,
|
|
nudge_shake: 0.0,
|
|
launcher_charge: 0.0,
|
|
finished: false,
|
|
accumulator: 0.0,
|
|
target_cooldown: 0.0,
|
|
bumper_cooldown: 0.0,
|
|
nudge_cooldown: 0.0,
|
|
nudge_meter: 0.0,
|
|
stalled_for: 0.0,
|
|
launcher_was_down: false,
|
|
}
|
|
}
|
|
|
|
pub fn player(&self) -> &Player {
|
|
&self.players[self.current_player]
|
|
}
|
|
|
|
fn fire_launcher(&mut self) {
|
|
let launch_speed = LAUNCH_SPEED_MIN + self.launcher_charge * LAUNCH_SPEED_RANGE;
|
|
self.ball.in_launcher = false;
|
|
self.ball.velocity = vec2(0.0, -launch_speed);
|
|
self.launcher_charge = 0.0;
|
|
self.launcher_was_down = false;
|
|
self.stalled_for = 0.0;
|
|
}
|
|
|
|
pub fn update(&mut self, frame_time: f32, detail: u8, controls: Controls) -> Vec<Event> {
|
|
if self.finished {
|
|
return Vec::new();
|
|
}
|
|
let mut events = Vec::new();
|
|
let desired_left = if controls.left_flipper && !self.tilted {
|
|
-2.18
|
|
} else {
|
|
-2.72
|
|
};
|
|
let desired_right = if controls.right_flipper && !self.tilted {
|
|
-0.96
|
|
} else {
|
|
-0.42
|
|
};
|
|
let flipper_rate = 14.0 * frame_time;
|
|
let old_left = self.left_flipper_angle;
|
|
let old_right = self.right_flipper_angle;
|
|
self.left_flipper_angle +=
|
|
(desired_left - self.left_flipper_angle).clamp(-flipper_rate, flipper_rate);
|
|
self.right_flipper_angle +=
|
|
(desired_right - self.right_flipper_angle).clamp(-flipper_rate, flipper_rate);
|
|
if !self.tilted
|
|
&& ((controls.left_flipper && old_left < -2.68)
|
|
|| (controls.right_flipper && old_right > -0.46))
|
|
{
|
|
events.push(Event::Flipper);
|
|
}
|
|
|
|
if self.ball.in_launcher && !self.tilted {
|
|
if controls.launch_down {
|
|
let charge_step = frame_time.min(0.05) / LAUNCHER_CHARGE_SECONDS;
|
|
self.launcher_charge = (self.launcher_charge + charge_step).min(1.0);
|
|
self.launcher_was_down = true;
|
|
} 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);
|
|
let steps_per_second =
|
|
[60.0, 90.0, 120.0, 180.0, 240.0][usize::from(detail.clamp(1, 5) - 1)];
|
|
let step = 1.0 / steps_per_second;
|
|
while self.accumulator >= step {
|
|
self.fixed_update(step, &mut events);
|
|
self.accumulator -= step;
|
|
}
|
|
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.robot_animation = (self.robot_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);
|
|
}
|
|
|
|
if self.ball.in_launcher {
|
|
self.ball.position = LAUNCHER_POSITION;
|
|
self.ball.velocity = Vec2::ZERO;
|
|
self.stalled_for = 0.0;
|
|
return;
|
|
}
|
|
|
|
let travel = self.ball.velocity.length() * dt;
|
|
let maximum_step_travel = TABLE_LINE_RADIUS;
|
|
let mut substeps = 1_u8;
|
|
while f32::from(substeps) * maximum_step_travel < travel && substeps < 12 {
|
|
substeps += 1;
|
|
}
|
|
let substep = dt / f32::from(substeps);
|
|
for _ in 0..substeps {
|
|
self.ball.velocity.y += GRAVITY * substep;
|
|
self.ball.velocity *= 1.0 - substep * 0.055;
|
|
self.ball.velocity = self.ball.velocity.clamp_length_max(MAX_SPEED);
|
|
self.ball.position += self.ball.velocity * substep;
|
|
|
|
let mut drained = false;
|
|
for wall in WALLS {
|
|
// The upper shooter exit is a one-way gate. After the outer
|
|
// curve turns the launched ball down and left, object 22 must
|
|
// let it enter the playfield. A ball approaching from below
|
|
// still collides with the gate.
|
|
if wall.id == SHOOTER_EXIT_GATE_ID
|
|
&& self.ball.velocity.x < 0.0
|
|
&& self.ball.velocity.y > 0.0
|
|
&& self.ball.position.x < 290.0
|
|
&& self.ball.position.y < 35.0
|
|
{
|
|
continue;
|
|
}
|
|
// Object 25 is the one-way shooter stop. It catches a returning
|
|
// ball, but must not reflect an upward launch from the line.
|
|
if wall.id == 25 && self.ball.velocity.y < 0.0 {
|
|
continue;
|
|
}
|
|
let hit = segment_collision(
|
|
&mut self.ball.position,
|
|
&mut self.ball.velocity,
|
|
TABLE_LINE_RADIUS,
|
|
wall.segment,
|
|
);
|
|
if hit {
|
|
if wall.id == 2 {
|
|
drained = true;
|
|
break;
|
|
}
|
|
if wall.id == 25 {
|
|
self.ball = Ball::default();
|
|
self.launcher_charge = 0.0;
|
|
self.launcher_was_down = false;
|
|
self.stalled_for = 0.0;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
if drained {
|
|
self.drain(events);
|
|
return;
|
|
}
|
|
|
|
for circle in PASSIVE_CIRCLES {
|
|
debug_assert_ne!(circle.id, 174, "the live ball is not a static obstacle");
|
|
circle_collision(
|
|
&mut self.ball.position,
|
|
&mut self.ball.velocity,
|
|
0.0,
|
|
circle.center,
|
|
circle.contact_radius,
|
|
0.0,
|
|
);
|
|
}
|
|
|
|
let left_flipper = flipper_segment(vec2(103.0, 397.0), self.left_flipper_angle);
|
|
let right_flipper = flipper_segment(vec2(210.0, 397.0), self.right_flipper_angle);
|
|
if segment_collision(
|
|
&mut self.ball.position,
|
|
&mut self.ball.velocity,
|
|
FLIPPER_CONTACT_RADIUS,
|
|
left_flipper,
|
|
) && self.left_flipper_angle > -2.55
|
|
{
|
|
self.ball.velocity += vec2(-20.0, -115.0);
|
|
}
|
|
if segment_collision(
|
|
&mut self.ball.position,
|
|
&mut self.ball.velocity,
|
|
FLIPPER_CONTACT_RADIUS,
|
|
right_flipper,
|
|
) && self.right_flipper_angle < -0.58
|
|
{
|
|
self.ball.velocity += vec2(20.0, -115.0);
|
|
}
|
|
|
|
for (index, bumper) in BUMPERS.into_iter().enumerate() {
|
|
let hit = circle_collision(
|
|
&mut self.ball.position,
|
|
&mut self.ball.velocity,
|
|
0.0,
|
|
bumper.center,
|
|
bumper.contact_radius,
|
|
105.0,
|
|
);
|
|
if hit && !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);
|
|
}
|
|
}
|
|
}
|
|
|
|
if !self.tilted {
|
|
self.check_targets(events);
|
|
self.check_media(events);
|
|
}
|
|
|
|
if self.ball.position.y > 454.0 {
|
|
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 self.ball.velocity.length_squared() < BALL_SEARCH_SPEED.powi(2) {
|
|
self.stalled_for += dt;
|
|
if self.stalled_for >= BALL_SEARCH_DELAY {
|
|
let horizontal = if self.ball.position.x < 160.0 {
|
|
72.0
|
|
} else {
|
|
-72.0
|
|
};
|
|
self.ball.velocity = vec2(horizontal, -210.0);
|
|
self.stalled_for = 0.0;
|
|
events.push(Event::BallSearch);
|
|
}
|
|
} else {
|
|
self.stalled_for = 0.0;
|
|
}
|
|
}
|
|
|
|
fn check_targets(&mut self, events: &mut Vec<Event>) {
|
|
if self.target_cooldown > 0.0 {
|
|
return;
|
|
}
|
|
let position = self.ball.position;
|
|
|
|
let top = [vec2(205.0, 47.0), vec2(234.0, 50.0), vec2(262.0, 53.0)];
|
|
for (index, center) in top.into_iter().enumerate() {
|
|
if position.distance_squared(center) < 11.0_f32.powi(2) && !self.top_targets[index] {
|
|
self.top_targets[index] = true;
|
|
self.add_score(1_500);
|
|
self.ball.velocity.y = self.ball.velocity.y.abs() + 65.0;
|
|
self.target_cooldown = 0.12;
|
|
events.push(Event::Target);
|
|
}
|
|
}
|
|
if self.top_targets.iter().all(|target| *target) {
|
|
self.top_targets.fill(false);
|
|
self.magnets = 12.0;
|
|
self.add_score(5_000);
|
|
}
|
|
|
|
let side = [180.0, 195.0, 210.0, 225.0, 240.0];
|
|
for (index, y) in side.into_iter().enumerate() {
|
|
if position.distance_squared(vec2(286.0, y)) < 9.0_f32.powi(2)
|
|
&& !self.side_targets[index]
|
|
{
|
|
self.side_targets[index] = true;
|
|
self.add_score(2_000 + u32::try_from(index).unwrap_or(0) * 1_000);
|
|
self.ball.velocity.x = -self.ball.velocity.x.abs() - 55.0;
|
|
self.target_cooldown = 0.10;
|
|
events.push(Event::Target);
|
|
}
|
|
}
|
|
if self.side_targets.iter().all(|target| *target) {
|
|
self.side_targets.fill(false);
|
|
let player = &mut self.players[self.current_player];
|
|
player.bumper_value = (player.bumper_value + 1_000).min(10_000);
|
|
player.diamond_segments = (player.diamond_segments + 1).min(9);
|
|
self.add_score(10_000);
|
|
}
|
|
|
|
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 (270.0..=302.0).contains(&position.x) && (45.0..=105.0).contains(&position.y) {
|
|
self.add_score(7_500);
|
|
self.ball.velocity = vec2(-125.0, 60.0);
|
|
self.target_cooldown = 0.3;
|
|
self.robot_animation = 1.0;
|
|
events.push(Event::Robot);
|
|
}
|
|
|
|
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;
|
|
self.robot_animation = 0.8;
|
|
events.push(Event::Lock);
|
|
}
|
|
|
|
for (center, value) in [
|
|
(vec2(113.0, 285.0), 2_000),
|
|
(vec2(132.0, 270.0), 3_000),
|
|
(vec2(155.0, 258.0), 4_000),
|
|
(vec2(181.0, 270.0), 5_000),
|
|
(vec2(201.0, 285.0), 6_000),
|
|
] {
|
|
if position.distance_squared(center) < 10.0_f32.powi(2) {
|
|
self.add_score(value);
|
|
self.ball.velocity.y = -self.ball.velocity.y.abs() - 40.0;
|
|
self.target_cooldown = 0.13;
|
|
events.push(Event::Target);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
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 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.stalled_for = 0.0;
|
|
self.bumper_flash.fill(0.0);
|
|
self.wheel_animation = 0.0;
|
|
self.robot_animation = 0.0;
|
|
self.nudge_shake = 0.0;
|
|
self.launcher_charge = 0.0;
|
|
self.launcher_was_down = false;
|
|
|
|
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 flipper_segment(pivot: Vec2, angle: f32) -> Segment {
|
|
Segment::new(pivot, pivot + vec2(angle.cos(), angle.sin()) * 49.0, 0.88)
|
|
}
|
|
|
|
#[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 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, 33);
|
|
let charged_speed = -charged.ball.velocity.y;
|
|
|
|
assert!(charged_speed > quick_speed + 80.0);
|
|
}
|
|
|
|
#[test]
|
|
fn charged_ball_clears_the_shooter_lane() {
|
|
for detail in 1..=5 {
|
|
let mut game = Game::new(1);
|
|
launch_ball(&mut game, 33);
|
|
let mut entered_table = false;
|
|
let mut returned_to_launcher = false;
|
|
let mut minimum_y = game.ball.position.y;
|
|
|
|
for _ in 0..240 {
|
|
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 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 stalled_ball_search_restores_motion() {
|
|
let mut game = Game::new(1);
|
|
game.ball.in_launcher = false;
|
|
game.ball.position = vec2(250.0, 300.0);
|
|
game.ball.velocity = Vec2::ZERO;
|
|
game.stalled_for = BALL_SEARCH_DELAY - 1.0 / 120.0;
|
|
|
|
let events = game.update(1.0 / 120.0, 3, Controls::default());
|
|
|
|
assert!(events.contains(&Event::BallSearch));
|
|
assert!(game.ball.velocity.y < 0.0);
|
|
}
|
|
|
|
#[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, -MAX_SPEED);
|
|
|
|
game.fixed_update(1.0 / 30.0, &mut Vec::new());
|
|
|
|
assert!(game.ball.position.y >= 15.0 + TABLE_LINE_RADIUS - 0.01);
|
|
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.left_flipper_angle = -2.18;
|
|
game.right_flipper_angle = -0.96;
|
|
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.left_flipper_angle < -2.18);
|
|
assert!(game.right_flipper_angle > -0.96);
|
|
assert_eq!(game.player().score, score);
|
|
}
|
|
|
|
#[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, 455.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));
|
|
}
|
|
}
|