fix(physics): kick only on flipper movement edges
Model the recovered move_flipper call boundary: raising sweeps the occupied region once and applies one tangential impulse, while a held flipper remains solid without accumulating another boost on every collision substep. Returning still updates geometry and emits its movement sound but cannot kick the ball. Test Plan: - cargo fmt --check - cargo test - cargo clippy --all-targets --all-features -- -D warnings - git diff --cached --check
This commit is contained in:
+105
-11
@@ -1,10 +1,11 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
geometry::{Segment, circle_collision, segment_collision},
|
geometry::{Segment, circle_collision, closest_point, segment_collision},
|
||||||
table::{BUMPERS, PASSIVE_CIRCLES, WALLS},
|
table::{BUMPERS, PASSIVE_CIRCLES, WALLS},
|
||||||
};
|
};
|
||||||
use macroquad::prelude::{Rect, Vec2, vec2};
|
use macroquad::prelude::{Rect, Vec2, vec2};
|
||||||
|
|
||||||
const FLIPPER_CONTACT_RADIUS: f32 = 9.0;
|
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
|
// The Win16 engine sweeps the ball center through its pre-expanded object
|
||||||
// geometry. A small contact epsilon preserves thin-line hits without adding
|
// geometry. A small contact epsilon preserves thin-line hits without adding
|
||||||
// the rendered ball radius to every recovered boundary.
|
// the rendered ball radius to every recovered boundary.
|
||||||
@@ -221,6 +222,7 @@ pub struct Game {
|
|||||||
launcher_was_down: bool,
|
launcher_was_down: bool,
|
||||||
player_entry: PlayerEntry,
|
player_entry: PlayerEntry,
|
||||||
claw_rng_state: u32,
|
claw_rng_state: u32,
|
||||||
|
pending_flipper_kicks: [bool; 2],
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Game {
|
impl Game {
|
||||||
@@ -256,6 +258,7 @@ impl Game {
|
|||||||
launcher_was_down: false,
|
launcher_was_down: false,
|
||||||
player_entry: PlayerEntry::Open,
|
player_entry: PlayerEntry::Open,
|
||||||
claw_rng_state: seed.max(1),
|
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;
|
self.flippers.right_raised = controls.right_flipper && !self.tilted;
|
||||||
if self.flippers.left_raised != old_flippers.left_raised {
|
if self.flippers.left_raised != old_flippers.left_raised {
|
||||||
events.push(Event::FlipperMove);
|
events.push(Event::FlipperMove);
|
||||||
|
self.pending_flipper_kicks[0] = self.flippers.left_raised;
|
||||||
}
|
}
|
||||||
if self.flippers.right_raised != old_flippers.right_raised {
|
if self.flippers.right_raised != old_flippers.right_raised {
|
||||||
events.push(Event::FlipperMove);
|
events.push(Event::FlipperMove);
|
||||||
|
self.pending_flipper_kicks[1] = self.flippers.right_raised;
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.ball.in_launcher && !self.tilted {
|
if self.ball.in_launcher && !self.tilted {
|
||||||
@@ -370,10 +375,13 @@ impl Game {
|
|||||||
|
|
||||||
self.update_claw(dt, events);
|
self.update_claw(dt, events);
|
||||||
if self.claw.ball_suspended {
|
if self.claw.ball_suspended {
|
||||||
|
self.pending_flipper_kicks.fill(false);
|
||||||
self.stalled_for = 0.0;
|
self.stalled_for = 0.0;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.apply_flipper_kicks();
|
||||||
|
|
||||||
if self.ball.in_launcher {
|
if self.ball.in_launcher {
|
||||||
self.ball.position = LAUNCHER_POSITION;
|
self.ball.position = LAUNCHER_POSITION;
|
||||||
self.ball.velocity = Vec2::ZERO;
|
self.ball.velocity = Vec2::ZERO;
|
||||||
@@ -464,24 +472,18 @@ impl Game {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let (left_flipper, right_flipper) = self.flipper_segments();
|
let (left_flipper, right_flipper) = self.flipper_segments();
|
||||||
if segment_collision(
|
segment_collision(
|
||||||
&mut self.ball.position,
|
&mut self.ball.position,
|
||||||
&mut self.ball.velocity,
|
&mut self.ball.velocity,
|
||||||
FLIPPER_CONTACT_RADIUS,
|
FLIPPER_CONTACT_RADIUS,
|
||||||
left_flipper,
|
left_flipper,
|
||||||
) && self.flippers.left_raised
|
);
|
||||||
{
|
segment_collision(
|
||||||
self.ball.velocity += vec2(20.0, -115.0);
|
|
||||||
}
|
|
||||||
if segment_collision(
|
|
||||||
&mut self.ball.position,
|
&mut self.ball.position,
|
||||||
&mut self.ball.velocity,
|
&mut self.ball.velocity,
|
||||||
FLIPPER_CONTACT_RADIUS,
|
FLIPPER_CONTACT_RADIUS,
|
||||||
right_flipper,
|
right_flipper,
|
||||||
) && self.flippers.right_raised
|
);
|
||||||
{
|
|
||||||
self.ball.velocity += vec2(-20.0, -115.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (index, bumper) in BUMPERS.into_iter().enumerate() {
|
for (index, bumper) in BUMPERS.into_iter().enumerate() {
|
||||||
let hit = circle_collision(
|
let hit = circle_collision(
|
||||||
@@ -668,6 +670,44 @@ impl Game {
|
|||||||
CLAW_TERMINAL_FRAMES[value as usize % CLAW_TERMINAL_FRAMES.len()]
|
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<Event>) {
|
fn begin_claw_capture(&mut self, terminal_frame: u8, events: &mut Vec<Event>) {
|
||||||
debug_assert!(CLAW_TERMINAL_FRAMES.contains(&terminal_frame));
|
debug_assert!(CLAW_TERMINAL_FRAMES.contains(&terminal_frame));
|
||||||
if self.claw.active {
|
if self.claw.active {
|
||||||
@@ -808,6 +848,28 @@ fn flipper_segment(pivot: Vec2, tip: Vec2) -> Segment {
|
|||||||
Segment::new(pivot, tip, 0.88)
|
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)]
|
#[allow(clippy::too_many_lines)]
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
@@ -1111,6 +1173,38 @@ mod tests {
|
|||||||
assert_eq!(Event::FlipperMove.sound_resource(), Some(2021));
|
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]
|
#[test]
|
||||||
fn recovered_control_sounds_use_the_original_resource_numbers() {
|
fn recovered_control_sounds_use_the_original_resource_numbers() {
|
||||||
assert_eq!(Event::Launch.sound_resource(), Some(2002));
|
assert_eq!(Event::Launch.sound_resource(), Some(2002));
|
||||||
|
|||||||
Reference in New Issue
Block a user