fix(physics): restore the one-way shooter exit

Use the recovered rail rebound coefficient and let a launched ball cross object
22 in the exit direction after the upper curve. Strengthen the launch
regression so a momentary lane crossing cannot hide a later rebound.

Test Plan:
- cargo fmt --check
- cargo test
- cargo clippy --all-targets --all-features -- -D warnings
- verify the charged launch trajectory at detail levels 1 through 5
This commit is contained in:
2026-08-22 18:10:55 +02:00
parent aeae63aabe
commit bb53f88fb3
2 changed files with 40 additions and 17 deletions
+36 -16
View File
@@ -9,6 +9,7 @@ const FLIPPER_CONTACT_RADIUS: f32 = 9.0;
// geometry. A small contact epsilon preserves thin-line hits without adding
// the rendered ball radius to every recovered boundary.
const TABLE_LINE_RADIUS: f32 = 1.0;
const SHOOTER_EXIT_GATE_ID: u8 = 22;
const GRAVITY: f32 = 135.0;
const MAX_SPEED: f32 = 430.0;
const BALL_SEARCH_DELAY: f32 = 3.0;
@@ -263,6 +264,18 @@ impl Game {
let mut drained = false;
for wall in WALLS {
// The upper shooter exit is a one-way gate. After the outer
// curve turns the launched ball down and left, object 22 must
// let it enter the playfield. A ball approaching from below
// still collides with the gate.
if wall.id == SHOOTER_EXIT_GATE_ID
&& self.ball.velocity.x < 0.0
&& self.ball.velocity.y > 0.0
&& self.ball.position.x < 290.0
&& self.ball.position.y < 35.0
{
continue;
}
// Object 25 is the one-way shooter stop. It catches a returning
// ball, but must not reflect an upward launch from the line.
if wall.id == 25 && self.ball.velocity.y < 0.0 {
@@ -623,25 +636,32 @@ mod tests {
#[test]
fn charged_ball_clears_the_shooter_lane() {
let mut game = Game::new(1);
launch_ball(&mut game, 33);
let mut entered_table = false;
let mut minimum_y = game.ball.position.y;
for detail in 1..=5 {
let mut game = Game::new(1);
launch_ball(&mut game, 33);
let mut entered_table = false;
let mut returned_to_launcher = false;
let mut minimum_y = game.ball.position.y;
for _ in 0..240 {
game.update(1.0 / 120.0, 5, Controls::default());
minimum_y = minimum_y.min(game.ball.position.y);
if game.ball.position.x < 298.0 {
entered_table = true;
break;
for _ in 0..240 {
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,
"the shooter curve should guide the ball onto the table; position={:?}, velocity={:?}, minimum_y={minimum_y}",
game.ball.position, game.ball.velocity
);
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]
+4 -1
View File
@@ -12,7 +12,10 @@
use crate::geometry::Segment;
use macroquad::prelude::Vec2;
const WALL_BOUNCE: f32 = 0.82;
// The ordinary rail registrations in `FUN_1000_34ab` use the recovered 0.1
// rebound coefficient. The previous 0.82 value made the shooter curve behave
// like a rubber bumper and sent the ball back down the launch lane.
const WALL_BOUNCE: f32 = 0.10;
#[derive(Clone, Copy, Debug)]
pub struct TableSegment {