fix(physics): restore fixed-point rail movement

Replace the guessed variable-rate floating integration with the original 10 ms
millipixel step, 15-unit gravity, and 3800-unit speed limit. Restore every
registered type-2 Real48 response pair, one-sided start-to-end orientation, and
the original normal/tangent velocity transform validated against live shooter
wall traces.

Restore repeat-driven plunger charging and its release-time randomized clamp.
The shooter route now follows table orientation instead of coordinate-specific
gate exceptions. Remove the synthetic ball-search impulse so future stuck balls
remain visible reconstruction failures, and include collision ids in deterministic
traces for differential work on circles and triggers.

Test Plan:
- `cargo test --all-targets` -- 42 passed
- `cargo clippy --all-targets -- -D warnings` -- passed
- `cargo build --profile production` -- passed
- charged launcher scenario cleared the shooter lane -- passed
- all recovered rails accepted front-side and rejected back-side probes
- `git diff --cached --check` -- passed
This commit is contained in:
2026-08-22 20:42:47 +02:00
parent c50ed39a35
commit 7caf460022
8 changed files with 502 additions and 260 deletions
+197 -217
View File
@@ -1,36 +1,28 @@
use crate::{
geometry::{Segment, circle_collision, closest_point, segment_collision},
original_physics::{
GRAVITY_MILLI_PER_STEP, MAXIMUM_SPEED_MILLI_PER_STEP, MilliVec, STEP_SECONDS,
collide_with_line,
},
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.
const TABLE_LINE_RADIUS: f32 = 1.0;
const SHOOTER_EXIT_GATE_ID: u8 = 22;
const LEFT_FLIPPER_PIVOT: Vec2 = Vec2::new(103.0, 397.0);
const LEFT_FLIPPER_REST_TIP: Vec2 = Vec2::new(134.0, 419.0);
const LEFT_FLIPPER_RAISED_TIP: Vec2 = Vec2::new(134.0, 376.0);
const RIGHT_FLIPPER_PIVOT: Vec2 = Vec2::new(210.0, 397.0);
const RIGHT_FLIPPER_REST_TIP: Vec2 = Vec2::new(179.0, 419.0);
const RIGHT_FLIPPER_RAISED_TIP: Vec2 = Vec2::new(179.0, 376.0);
const GRAVITY: f32 = 135.0;
const MAX_SPEED: f32 = 430.0;
const BALL_SEARCH_DELAY: f32 = 3.0;
const BALL_SEARCH_SPEED: f32 = 24.0;
const TILT_THRESHOLD: f32 = 1.15;
const LAUNCHER_POSITION: Vec2 = Vec2::new(325.0, 413.0);
const LAUNCHER_FRAME_THRESHOLDS: [f32; 10] =
[0.05, 0.15, 0.25, 0.35, 0.45, 0.55, 0.65, 0.75, 0.85, 0.95];
// The original advances resource 901 through repeated Down-key events. A
// fixed duration keeps that deliberate hold consistent across modern systems
// whose keyboard-repeat delay and rate differ.
const LAUNCHER_CHARGE_SECONDS: f32 = 1.0;
const LAUNCH_SPEED_MIN: f32 = 330.0;
const LAUNCH_SPEED_RANGE: f32 = 100.0;
const LAUNCHER_IMPULSE_MILLI: i32 = 375;
const LAUNCHER_REPEAT_DELAY_SECONDS: f32 = 0.650;
const LAUNCHER_REPEAT_SECONDS: f32 = 0.040;
const CLAW_TRIGGER_CENTER: Vec2 = Vec2::new(289.0, 94.0);
const CLAW_TRIGGER_RADIUS: f32 = 19.0;
// The default original timer fires every 30 ms and advances the claw by one
@@ -62,29 +54,26 @@ pub enum Event {
ExtraBall,
Nudge,
Tilt,
BallSearch,
Drain,
}
impl Event {
/// Original Win16 WAVE resource selected by this gameplay transition.
/// Events without an evidenced original sound deliberately return `None`.
pub const fn sound_resource(self) -> Option<u16> {
pub const fn sound_resource(self) -> u16 {
match self {
Self::FlipperMove => Some(2021),
Self::Launch => Some(2002),
Self::Bumper => Some(2004),
Self::Target => Some(2006),
Self::Wheel => Some(2011),
Self::ClawCapture => Some(2015),
Self::ClawRelease => Some(2016),
Self::Lock => Some(2017),
Self::Media => Some(2022),
Self::ExtraBall => Some(2007),
Self::Nudge => Some(2019),
Self::Tilt => Some(2020),
Self::Drain => Some(2008),
Self::BallSearch => None,
Self::FlipperMove => 2021,
Self::Launch => 2002,
Self::Bumper => 2004,
Self::Target => 2006,
Self::Wheel => 2011,
Self::ClawCapture => 2015,
Self::ClawRelease => 2016,
Self::Lock => 2017,
Self::Media => 2022,
Self::ExtraBall => 2007,
Self::Nudge => 2019,
Self::Tilt => 2020,
Self::Drain => 2008,
}
}
}
@@ -213,13 +202,16 @@ pub struct Game {
pub nudge_shake: f32,
pub launcher_charge: f32,
pub finished: bool,
pub last_collision_id: Option<u8>,
accumulator: f32,
target_cooldown: f32,
bumper_cooldown: f32,
nudge_cooldown: f32,
nudge_meter: f32,
stalled_for: f32,
launcher_was_down: bool,
launcher_hold_seconds: f32,
launcher_next_repeat: f32,
launcher_velocity_milli: i32,
player_entry: PlayerEntry,
claw_rng_state: u32,
pending_flipper_kicks: [bool; 2],
@@ -249,13 +241,16 @@ impl Game {
nudge_shake: 0.0,
launcher_charge: 0.0,
finished: false,
last_collision_id: None,
accumulator: 0.0,
target_cooldown: 0.0,
bumper_cooldown: 0.0,
nudge_cooldown: 0.0,
nudge_meter: 0.0,
stalled_for: 0.0,
launcher_was_down: false,
launcher_hold_seconds: 0.0,
launcher_next_repeat: LAUNCHER_REPEAT_DELAY_SECONDS,
launcher_velocity_milli: 0,
player_entry: PlayerEntry::Open,
claw_rng_state: seed.max(1),
pending_flipper_kicks: [false; 2],
@@ -288,19 +283,34 @@ impl Game {
}
fn fire_launcher(&mut self) {
let launch_speed = LAUNCH_SPEED_MIN + self.launcher_charge * LAUNCH_SPEED_RANGE;
self.launcher_velocity_milli -= LAUNCHER_IMPULSE_MILLI;
let mut launch_velocity = self.launcher_velocity_milli;
if launch_velocity < -MAXIMUM_SPEED_MILLI_PER_STEP {
let variation = (self.next_random_value()
% u32::try_from(MAXIMUM_SPEED_MILLI_PER_STEP).unwrap_or(3_800))
/ 40;
launch_velocity =
-MAXIMUM_SPEED_MILLI_PER_STEP + i32::try_from(variation).unwrap_or_default();
}
self.ball.in_launcher = false;
self.ball.velocity = vec2(0.0, -launch_speed);
self.ball.velocity = MilliVec {
x: 0,
y: launch_velocity,
}
.to_velocity_per_second();
self.launcher_charge = 0.0;
self.launcher_was_down = false;
self.launcher_hold_seconds = 0.0;
self.launcher_next_repeat = LAUNCHER_REPEAT_DELAY_SECONDS;
self.launcher_velocity_milli = 0;
self.player_entry = PlayerEntry::Closed;
self.stalled_for = 0.0;
}
pub fn update(&mut self, frame_time: f32, detail: u8, controls: Controls) -> Vec<Event> {
pub fn update(&mut self, frame_time: f32, _detail: u8, controls: Controls) -> Vec<Event> {
if self.finished {
return Vec::new();
}
self.last_collision_id = None;
let mut events = Vec::new();
let old_flippers = self.flippers;
self.flippers.left_raised = controls.left_flipper && !self.tilted;
@@ -316,14 +326,20 @@ impl Game {
if self.ball.in_launcher && !self.tilted {
if controls.launch_down {
let charge_step = frame_time.min(0.05) / LAUNCHER_CHARGE_SECONDS;
let next_charge = self.launcher_charge + charge_step;
self.launcher_charge = if next_charge >= 1.0 - f32::EPSILON * 4.0 {
1.0
if self.launcher_was_down {
self.launcher_hold_seconds += frame_time.min(0.05);
while self.launcher_hold_seconds >= self.launcher_next_repeat {
self.launcher_velocity_milli -= LAUNCHER_IMPULSE_MILLI;
self.launcher_next_repeat += LAUNCHER_REPEAT_SECONDS;
}
} else {
next_charge
};
self.launcher_was_down = true;
self.launcher_velocity_milli -= LAUNCHER_IMPULSE_MILLI;
self.launcher_was_down = true;
}
let charge_milli =
(-self.launcher_velocity_milli).clamp(0, MAXIMUM_SPEED_MILLI_PER_STEP);
self.launcher_charge =
f32::from(i16::try_from(charge_milli).unwrap_or(3_800)) / f32::from(3_800_i16);
} else if self.launcher_was_down {
self.fire_launcher();
events.push(Event::Launch);
@@ -348,12 +364,9 @@ impl Game {
}
self.accumulator = (self.accumulator + frame_time.min(0.05)).min(0.1);
let steps_per_second =
[60.0, 90.0, 120.0, 180.0, 240.0][usize::from(detail.clamp(1, 5) - 1)];
let step = 1.0 / steps_per_second;
while self.accumulator >= step {
self.fixed_update(step, &mut events);
self.accumulator -= step;
while self.accumulator >= STEP_SECONDS {
self.fixed_update(STEP_SECONDS, &mut events);
self.accumulator -= STEP_SECONDS;
}
events
}
@@ -376,7 +389,6 @@ impl Game {
self.update_claw(dt, events);
if self.claw.ball_suspended {
self.pending_flipper_kicks.fill(false);
self.stalled_for = 0.0;
return;
}
@@ -385,122 +397,120 @@ impl Game {
if self.ball.in_launcher {
self.ball.position = LAUNCHER_POSITION;
self.ball.velocity = Vec2::ZERO;
self.stalled_for = 0.0;
return;
}
let travel = self.ball.velocity.length() * dt;
let maximum_step_travel = TABLE_LINE_RADIUS;
let mut substeps = 1_u8;
while f32::from(substeps) * maximum_step_travel < travel && substeps < 12 {
substeps += 1;
}
let substep = dt / f32::from(substeps);
for _ in 0..substeps {
self.ball.velocity.y += GRAVITY * substep;
self.ball.velocity *= 1.0 - substep * 0.055;
self.ball.velocity = self.ball.velocity.clamp_length_max(MAX_SPEED);
self.ball.position += self.ball.velocity * substep;
let mut drained = false;
for wall in WALLS {
if self.claw.active && (12..=20).contains(&wall.id) {
continue;
}
// Objects 66/68 and 81/83 are the resting flipper edges.
// Their live shapes are handled as moving capsules below.
if matches!(wall.id, 66 | 68 | 81 | 83) {
continue;
}
// 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 {
continue;
}
let hit = segment_collision(
&mut self.ball.position,
&mut self.ball.velocity,
TABLE_LINE_RADIUS,
wall.segment,
);
if hit {
if wall.id == 2 {
drained = true;
break;
}
if wall.id == 25 {
self.ball = Ball::default();
self.launcher_charge = 0.0;
self.launcher_was_down = false;
self.stalled_for = 0.0;
return;
}
}
let old_position = MilliVec::from_position(self.ball.position);
let mut velocity = MilliVec::from_velocity_per_second(self.ball.velocity);
velocity.y += GRAVITY_MILLI_PER_STEP;
velocity.clamp_speed(MAXIMUM_SPEED_MILLI_PER_STEP);
let mut position = old_position.add(velocity);
let mut hit_wall = None;
for wall in WALLS {
if self.claw.active && (12..=20).contains(&wall.id) {
continue;
}
if drained {
self.drain(events);
// The original moves these four records with the flipper bodies.
if matches!(wall.id, 66 | 68 | 81 | 83) {
continue;
}
let mut response = velocity;
if collide_with_line(
old_position,
&mut response,
wall.segment.start,
wall.segment.end,
f64::from(wall.normal_rebound),
f64::from(wall.tangent_coupling),
) {
velocity = response;
position = old_position.add(velocity);
hit_wall = Some(wall.id);
self.last_collision_id = Some(wall.id);
break;
}
}
self.ball.position = position.to_position();
self.ball.velocity = velocity.to_velocity_per_second();
if let Some(wall_id) = hit_wall {
if wall_id == 2 {
if self.magnets > 0.0
&& (self.ball.position.x < 145.0 || self.ball.position.x > 175.0)
{
self.ball.position.y = 410.0;
self.ball.velocity = vec2((157.0 - self.ball.position.x) * 2.0, -245.0);
self.magnets = 0.0;
} else {
self.drain(events);
}
return;
}
for circle in PASSIVE_CIRCLES {
debug_assert_ne!(circle.id, 174, "the live ball is not a static obstacle");
// Objects 67 and 82 are the movable flipper tips. The fixed
// pivot circles (65 and 80) remain valid in either position.
if matches!(circle.id, 67 | 82) {
continue;
}
circle_collision(
&mut self.ball.position,
&mut self.ball.velocity,
0.0,
circle.center,
circle.contact_radius,
0.0,
);
if wall_id == 25
&& i64::from(velocity.x).pow(2) + i64::from(velocity.y).pow(2) < 1_000_i64.pow(2)
{
self.ball = Ball::default();
self.launcher_charge = 0.0;
self.launcher_was_down = false;
return;
}
}
let (left_flipper, right_flipper) = self.flipper_segments();
segment_collision(
for circle in PASSIVE_CIRCLES {
debug_assert_ne!(circle.id, 174, "the live ball is not a static obstacle");
// Objects 67 and 82 are the movable flipper tips. The fixed
// pivot circles (65 and 80) remain valid in either position.
if matches!(circle.id, 67 | 82) {
continue;
}
if circle_collision(
&mut self.ball.position,
&mut self.ball.velocity,
FLIPPER_CONTACT_RADIUS,
left_flipper,
);
segment_collision(
0.0,
circle.center,
circle.contact_radius,
0.0,
) {
self.last_collision_id = Some(circle.id);
}
}
let (left_flipper, right_flipper) = self.flipper_segments();
if segment_collision(
&mut self.ball.position,
&mut self.ball.velocity,
FLIPPER_CONTACT_RADIUS,
left_flipper,
) {
self.last_collision_id = Some(66);
}
if segment_collision(
&mut self.ball.position,
&mut self.ball.velocity,
FLIPPER_CONTACT_RADIUS,
right_flipper,
) {
self.last_collision_id = Some(81);
}
for (index, bumper) in BUMPERS.into_iter().enumerate() {
let hit = circle_collision(
&mut self.ball.position,
&mut self.ball.velocity,
FLIPPER_CONTACT_RADIUS,
right_flipper,
0.0,
bumper.center,
bumper.contact_radius,
105.0,
);
for (index, bumper) in BUMPERS.into_iter().enumerate() {
let hit = circle_collision(
&mut self.ball.position,
&mut self.ball.velocity,
0.0,
bumper.center,
bumper.contact_radius,
105.0,
);
if hit && !self.tilted && self.bumper_cooldown <= 0.0 {
self.add_score(self.player().bumper_value);
self.bonus = self.bonus.saturating_add(100);
self.bumper_cooldown = 0.08;
self.bumper_flash[index] = 0.16;
events.push(Event::Bumper);
}
if hit {
self.last_collision_id = Some(bumper.id);
}
if hit && !self.tilted && self.bumper_cooldown <= 0.0 {
self.add_score(self.player().bumper_value);
self.bonus = self.bonus.saturating_add(100);
self.bumper_cooldown = 0.08;
self.bumper_flash[index] = 0.16;
events.push(Event::Bumper);
}
}
@@ -509,36 +519,13 @@ impl Game {
self.check_media(events);
}
if self.claw.ball_suspended {
self.stalled_for = 0.0;
return;
}
if self.ball.position.y > 454.0 {
if self.magnets > 0.0 && (self.ball.position.x < 145.0 || self.ball.position.x > 175.0)
{
self.ball.position.y = 410.0;
self.ball.velocity = vec2((157.0 - self.ball.position.x) * 2.0, -245.0);
self.magnets = 0.0;
} else {
self.drain(events);
}
return;
}
if self.ball.velocity.length_squared() < BALL_SEARCH_SPEED.powi(2) {
self.stalled_for += dt;
if self.stalled_for >= BALL_SEARCH_DELAY {
let horizontal = if self.ball.position.x < 160.0 {
72.0
} else {
-72.0
};
self.ball.velocity = vec2(horizontal, -210.0);
self.stalled_for = 0.0;
events.push(Event::BallSearch);
}
} else {
self.stalled_for = 0.0;
if self.ball.position.y > 470.0 {
// Only malformed/out-of-table states reach this guard; the real
// drain is collision object 2 at y=455.
self.drain(events);
}
}
@@ -662,12 +649,17 @@ impl Game {
}
fn next_claw_terminal_frame(&mut self) -> u8 {
let value = self.next_random_value();
CLAW_TERMINAL_FRAMES[value as usize % CLAW_TERMINAL_FRAMES.len()]
}
fn next_random_value(&mut self) -> u32 {
let mut value = self.claw_rng_state;
value ^= value << 13;
value ^= value >> 17;
value ^= value << 5;
self.claw_rng_state = value;
CLAW_TERMINAL_FRAMES[value as usize % CLAW_TERMINAL_FRAMES.len()]
value
}
fn apply_flipper_kicks(&mut self) {
@@ -719,7 +711,6 @@ impl Game {
self.claw.ball_suspended = true;
self.claw.frame_accumulator = 0.0;
self.ball.velocity = Vec2::ZERO;
self.stalled_for = 0.0;
events.push(Event::ClawCapture);
}
@@ -756,7 +747,6 @@ impl Game {
self.claw.bank = ClawSpriteBank::Opening;
(self.ball.position, self.ball.velocity) = claw_release(release_frame);
self.claw.ball_suspended = false;
self.stalled_for = 0.0;
events.push(Event::ClawRelease);
}
@@ -789,13 +779,15 @@ impl Game {
self.magnets = 0.0;
self.tilted = false;
self.nudge_meter = 0.0;
self.stalled_for = 0.0;
self.bumper_flash.fill(0.0);
self.wheel_animation = 0.0;
self.claw = Claw::default();
self.nudge_shake = 0.0;
self.launcher_charge = 0.0;
self.launcher_was_down = false;
self.launcher_hold_seconds = 0.0;
self.launcher_next_repeat = LAUNCHER_REPEAT_DELAY_SECONDS;
self.launcher_velocity_milli = 0;
let mut next = (self.current_player + 1) % self.players.len();
for _ in 0..self.players.len() {
@@ -965,13 +957,13 @@ mod tests {
for _ in 0..30 {
game.update(1.0 / 60.0, 3, held);
}
assert!((0.45..0.55).contains(&game.launcher_charge));
assert_eq!(game.launcher_frame(), 5);
assert!((0.09..0.11).contains(&game.launcher_charge));
assert_eq!(game.launcher_frame(), 1);
for _ in 0..30 {
game.update(1.0 / 60.0, 3, held);
}
assert!((game.launcher_charge - 1.0).abs() <= f32::EPSILON);
assert!(game.launcher_charge >= 0.95);
assert_eq!(game.launcher_frame(), 10);
}
@@ -984,7 +976,7 @@ mod tests {
let mut returned_to_launcher = false;
let mut minimum_y = game.ball.position.y;
for _ in 0..240 {
for _ in 0..480 {
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 {
@@ -1046,30 +1038,18 @@ mod tests {
assert!(game.ball.in_launcher);
}
#[test]
fn stalled_ball_search_restores_motion() {
let mut game = Game::new(1);
game.ball.in_launcher = false;
game.ball.position = vec2(250.0, 300.0);
game.ball.velocity = Vec2::ZERO;
game.stalled_for = BALL_SEARCH_DELAY - 1.0 / 120.0;
let events = game.update(1.0 / 120.0, 3, Controls::default());
assert!(events.contains(&Event::BallSearch));
assert!(game.ball.velocity.y < 0.0);
}
#[test]
fn fast_ball_cannot_tunnel_through_the_top_rail() {
let mut game = Game::new(1);
game.ball.in_launcher = false;
game.ball.position = vec2(270.0, 30.0);
game.ball.velocity = vec2(0.0, -MAX_SPEED);
game.ball.velocity = vec2(0.0, -380.0);
game.fixed_update(1.0 / 30.0, &mut Vec::new());
for _ in 0..5 {
game.fixed_update(STEP_SECONDS, &mut Vec::new());
}
assert!(game.ball.position.y >= 15.0 + TABLE_LINE_RADIUS - 0.01);
assert!(game.ball.position.y >= 15.0);
assert!(game.ball.velocity.y > 0.0);
}
@@ -1170,7 +1150,7 @@ mod tests {
[Event::FlipperMove, Event::FlipperMove],
"the original also plays the sound while returning"
);
assert_eq!(Event::FlipperMove.sound_resource(), Some(2021));
assert_eq!(Event::FlipperMove.sound_resource(), 2021);
}
#[test]
@@ -1207,14 +1187,13 @@ mod tests {
#[test]
fn recovered_control_sounds_use_the_original_resource_numbers() {
assert_eq!(Event::Launch.sound_resource(), Some(2002));
assert_eq!(Event::Wheel.sound_resource(), Some(2011));
assert_eq!(Event::Nudge.sound_resource(), Some(2019));
assert_eq!(Event::Tilt.sound_resource(), Some(2020));
assert_eq!(Event::Drain.sound_resource(), Some(2008));
assert_eq!(Event::ClawCapture.sound_resource(), Some(2015));
assert_eq!(Event::ClawRelease.sound_resource(), Some(2016));
assert_eq!(Event::BallSearch.sound_resource(), None);
assert_eq!(Event::Launch.sound_resource(), 2002);
assert_eq!(Event::Wheel.sound_resource(), 2011);
assert_eq!(Event::Nudge.sound_resource(), 2019);
assert_eq!(Event::Tilt.sound_resource(), 2020);
assert_eq!(Event::Drain.sound_resource(), 2008);
assert_eq!(Event::ClawCapture.sound_resource(), 2015);
assert_eq!(Event::ClawRelease.sound_resource(), 2016);
}
#[test]
@@ -1335,7 +1314,8 @@ mod tests {
let mut game = Game::new(1);
game.tilted = true;
game.ball.in_launcher = false;
game.ball.position = vec2(157.0, 455.0);
game.ball.position = vec2(157.0, 454.0);
game.ball.velocity = vec2(0.0, 200.0);
game.update(1.0 / 60.0, 3, Controls::default());