diff --git a/tdkpin-rs/src/game.rs b/tdkpin-rs/src/game.rs index dffa097..d120bb4 100644 --- a/tdkpin-rs/src/game.rs +++ b/tdkpin-rs/src/game.rs @@ -1,10 +1,11 @@ use crate::{ - geometry::{Segment, circle_collision, segment_collision}, + geometry::{Segment, circle_collision, closest_point, segment_collision}, table::{BUMPERS, PASSIVE_CIRCLES, WALLS}, }; use macroquad::prelude::{Rect, Vec2, vec2}; const FLIPPER_CONTACT_RADIUS: f32 = 9.0; +const FLIPPER_EDGE_KICK: f32 = 118.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. @@ -221,6 +222,7 @@ pub struct Game { launcher_was_down: bool, player_entry: PlayerEntry, claw_rng_state: u32, + pending_flipper_kicks: [bool; 2], } impl Game { @@ -256,6 +258,7 @@ impl Game { launcher_was_down: false, player_entry: PlayerEntry::Open, claw_rng_state: seed.max(1), + pending_flipper_kicks: [false; 2], } } @@ -304,9 +307,11 @@ impl Game { 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 { @@ -370,10 +375,13 @@ impl Game { self.update_claw(dt, events); if self.claw.ball_suspended { + self.pending_flipper_kicks.fill(false); self.stalled_for = 0.0; return; } + self.apply_flipper_kicks(); + if self.ball.in_launcher { self.ball.position = LAUNCHER_POSITION; self.ball.velocity = Vec2::ZERO; @@ -464,24 +472,18 @@ impl Game { } let (left_flipper, right_flipper) = self.flipper_segments(); - if segment_collision( + segment_collision( &mut self.ball.position, &mut self.ball.velocity, FLIPPER_CONTACT_RADIUS, left_flipper, - ) && self.flippers.left_raised - { - self.ball.velocity += vec2(20.0, -115.0); - } - if segment_collision( + ); + segment_collision( &mut self.ball.position, &mut self.ball.velocity, FLIPPER_CONTACT_RADIUS, right_flipper, - ) && self.flippers.right_raised - { - self.ball.velocity += vec2(-20.0, -115.0); - } + ); for (index, bumper) in BUMPERS.into_iter().enumerate() { let hit = circle_collision( @@ -668,6 +670,44 @@ impl Game { CLAW_TERMINAL_FRAMES[value as usize % CLAW_TERMINAL_FRAMES.len()] } + 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, clockwise) in [ + ( + pending[0], + LEFT_FLIPPER_PIVOT, + LEFT_FLIPPER_REST_TIP, + LEFT_FLIPPER_RAISED_TIP, + true, + ), + ( + pending[1], + RIGHT_FLIPPER_PIVOT, + RIGHT_FLIPPER_REST_TIP, + RIGHT_FLIPPER_RAISED_TIP, + false, + ), + ] { + if !should_kick + || !point_in_flipper_sweep(self.ball.position, pivot, rest_tip, raised_tip) + { + continue; + } + let radial = (self.ball.position - pivot).normalize_or_zero(); + let tangent = if clockwise { + vec2(radial.y, -radial.x) + } else { + vec2(-radial.y, radial.x) + }; + self.ball.velocity += tangent * FLIPPER_EDGE_KICK; + } + } + fn begin_claw_capture(&mut self, terminal_frame: u8, events: &mut Vec) { debug_assert!(CLAW_TERMINAL_FRAMES.contains(&terminal_frame)); if self.claw.active { @@ -808,6 +848,28 @@ fn flipper_segment(pivot: Vec2, tip: Vec2) -> Segment { Segment::new(pivot, tip, 0.88) } +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 { @@ -1111,6 +1173,38 @@ mod tests { assert_eq!(Event::FlipperMove.sound_resource(), Some(2021)); } + #[test] + fn raising_flipper_applies_one_swept_tangential_kick() { + let mut game = Game::new(1); + game.ball.in_launcher = false; + game.ball.position = (LEFT_FLIPPER_PIVOT + LEFT_FLIPPER_REST_TIP) * 0.5; + game.ball.velocity = Vec2::ZERO; + game.pending_flipper_kicks[0] = true; + + game.apply_flipper_kicks(); + let velocity_after_edge = game.ball.velocity; + assert!((velocity_after_edge.length() - FLIPPER_EDGE_KICK).abs() < 0.001); + assert!(velocity_after_edge.y < 0.0); + 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(), Some(2002));