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::{
|
||||
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));
|
||||
|
||||
Reference in New Issue
Block a user