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:
2026-08-22 18:53:03 +02:00
parent b7326f7a13
commit d020de613c
+105 -11
View File
@@ -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<Event>) {
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));