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 { 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, 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, 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 { 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 { 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) { 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) { 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) { 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) { 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, ) { 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) { 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) { 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) { 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) { 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 { 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)); } }