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:
@@ -209,9 +209,8 @@ impl App {
|
||||
}
|
||||
|
||||
fn play_event(&self, event: Event) {
|
||||
if let Some(id) = event.sound_resource() {
|
||||
self.assets.play(id, self.saved.settings.sounds);
|
||||
}
|
||||
self.assets
|
||||
.play(event.sound_resource(), self.saved.settings.sounds);
|
||||
}
|
||||
|
||||
fn finish_game(&mut self) {
|
||||
|
||||
+197
-217
@@ -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());
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ mod app;
|
||||
mod assets;
|
||||
mod game;
|
||||
mod geometry;
|
||||
mod original_physics;
|
||||
mod persistence;
|
||||
mod simulation;
|
||||
mod table;
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
//! Fixed-point primitives recovered from the original Win16 physics loop.
|
||||
|
||||
#![allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
|
||||
|
||||
use macroquad::prelude::{Vec2, vec2};
|
||||
|
||||
pub const STEP_SECONDS: f32 = 0.010;
|
||||
pub const GRAVITY_MILLI_PER_STEP: i32 = 15;
|
||||
pub const MAXIMUM_SPEED_MILLI_PER_STEP: i32 = 3_800;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct MilliVec {
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
}
|
||||
|
||||
impl MilliVec {
|
||||
pub fn from_position(position: Vec2) -> Self {
|
||||
Self {
|
||||
x: (position.x * 1_000.0).round() as i32,
|
||||
y: (position.y * 1_000.0).round() as i32,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_velocity_per_second(velocity: Vec2) -> Self {
|
||||
Self {
|
||||
x: (velocity.x * 10.0).round() as i32,
|
||||
y: (velocity.y * 10.0).round() as i32,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_position(self) -> Vec2 {
|
||||
vec2(self.x as f32 / 1_000.0, self.y as f32 / 1_000.0)
|
||||
}
|
||||
|
||||
pub fn to_velocity_per_second(self) -> Vec2 {
|
||||
vec2(self.x as f32 / 10.0, self.y as f32 / 10.0)
|
||||
}
|
||||
|
||||
pub const fn add(self, other: Self) -> Self {
|
||||
Self {
|
||||
x: self.x + other.x,
|
||||
y: self.y + other.y,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clamp_speed(&mut self, maximum: i32) {
|
||||
let speed_squared = i64::from(self.x).pow(2) + i64::from(self.y).pow(2);
|
||||
if speed_squared <= i64::from(maximum).pow(2) {
|
||||
return;
|
||||
}
|
||||
let scale = f64::from(maximum) / (speed_squared as f64).sqrt();
|
||||
self.x = (f64::from(self.x) * scale).round() as i32;
|
||||
self.y = (f64::from(self.y) * scale).round() as i32;
|
||||
}
|
||||
}
|
||||
|
||||
const fn cross(left: MilliVec, right: MilliVec) -> i64 {
|
||||
left.x as i64 * right.y as i64 - left.y as i64 * right.x as i64
|
||||
}
|
||||
|
||||
const fn subtract(left: MilliVec, right: MilliVec) -> MilliVec {
|
||||
MilliVec {
|
||||
x: left.x - right.x,
|
||||
y: left.y - right.y,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reports whether the ball-center path crosses the registered line segment.
|
||||
/// A contact at the old position is excluded, matching the shooter-stop path:
|
||||
/// the waiting ball starts on object 25 and must be able to launch away from it.
|
||||
fn paths_intersect(
|
||||
old_position: MilliVec,
|
||||
velocity: MilliVec,
|
||||
line_start: MilliVec,
|
||||
line_end: MilliVec,
|
||||
) -> bool {
|
||||
let line = subtract(line_end, line_start);
|
||||
let from_ball = subtract(line_start, old_position);
|
||||
let denominator = cross(velocity, line);
|
||||
if denominator == 0 {
|
||||
return false;
|
||||
}
|
||||
let path_numerator = cross(from_ball, line);
|
||||
let line_numerator = cross(from_ball, velocity);
|
||||
if denominator > 0 {
|
||||
path_numerator > 0
|
||||
&& path_numerator <= denominator
|
||||
&& line_numerator >= 0
|
||||
&& line_numerator <= denominator
|
||||
} else {
|
||||
path_numerator < 0
|
||||
&& path_numerator >= denominator
|
||||
&& line_numerator <= 0
|
||||
&& line_numerator >= denominator
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply the original type-2 response in the registered segment's basis.
|
||||
pub fn collide_with_line(
|
||||
old_position: MilliVec,
|
||||
velocity: &mut MilliVec,
|
||||
line_start: Vec2,
|
||||
line_end: Vec2,
|
||||
normal_rebound: f64,
|
||||
tangent_coupling: f64,
|
||||
) -> bool {
|
||||
let start = MilliVec::from_position(line_start);
|
||||
let end = MilliVec::from_position(line_end);
|
||||
if !paths_intersect(old_position, *velocity, start, end) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let line_x = f64::from(end.x - start.x);
|
||||
let line_y = f64::from(end.y - start.y);
|
||||
let length = line_x.hypot(line_y);
|
||||
if length == 0.0 {
|
||||
return false;
|
||||
}
|
||||
let tangent_x = line_x / length;
|
||||
let tangent_y = line_y / length;
|
||||
let normal_x = -tangent_y;
|
||||
let normal_y = tangent_x;
|
||||
let incoming_x = f64::from(velocity.x);
|
||||
let incoming_y = f64::from(velocity.y);
|
||||
let normal_speed = incoming_x * normal_x + incoming_y * normal_y;
|
||||
if normal_speed <= 0.0 {
|
||||
return false;
|
||||
}
|
||||
let tangent_speed = incoming_x * tangent_x + incoming_y * tangent_y;
|
||||
let outgoing_normal = -normal_rebound * normal_speed;
|
||||
let outgoing_tangent = tangent_speed + tangent_coupling * normal_speed;
|
||||
|
||||
velocity.x = (normal_x * outgoing_normal + tangent_x * outgoing_tangent).round() as i32;
|
||||
velocity.y = (normal_y * outgoing_normal + tangent_y * outgoing_tangent).round() as i32;
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn outer_shooter_wall_matches_the_live_original_probe() {
|
||||
let old = MilliVec {
|
||||
x: 326_000,
|
||||
y: 200_045,
|
||||
};
|
||||
let mut velocity = MilliVec { x: 3_000, y: 45 };
|
||||
|
||||
assert!(collide_with_line(
|
||||
old,
|
||||
&mut velocity,
|
||||
vec2(328.0, 422.0),
|
||||
vec2(328.0, 58.0),
|
||||
0.6,
|
||||
0.1,
|
||||
));
|
||||
assert_eq!(velocity, MilliVec { x: -1_800, y: -255 });
|
||||
assert_eq!(
|
||||
old.add(velocity),
|
||||
MilliVec {
|
||||
x: 324_200,
|
||||
y: 199_790
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inner_shooter_wall_matches_the_live_original_probe() {
|
||||
let old = MilliVec {
|
||||
x: 320_600,
|
||||
y: 199_325,
|
||||
};
|
||||
let mut velocity = MilliVec { x: -1_800, y: -210 };
|
||||
|
||||
assert!(collide_with_line(
|
||||
old,
|
||||
&mut velocity,
|
||||
vec2(320.0, 48.0),
|
||||
vec2(320.0, 437.0),
|
||||
0.6,
|
||||
0.1,
|
||||
));
|
||||
assert_eq!(velocity, MilliVec { x: 1_080, y: -30 });
|
||||
assert_eq!(
|
||||
old.add(velocity),
|
||||
MilliVec {
|
||||
x: 321_680,
|
||||
y: 199_295
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contact_at_the_old_position_does_not_block_launching_away() {
|
||||
let old = MilliVec {
|
||||
x: 325_000,
|
||||
y: 413_000,
|
||||
};
|
||||
let mut velocity = MilliVec { x: 0, y: -3_000 };
|
||||
|
||||
assert!(!collide_with_line(
|
||||
old,
|
||||
&mut velocity,
|
||||
vec2(315.0, 413.0),
|
||||
vec2(332.0, 413.0),
|
||||
0.1,
|
||||
0.1,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crossing_a_rail_from_its_back_side_is_allowed() {
|
||||
let old = MilliVec {
|
||||
x: 283_708,
|
||||
y: 18_775,
|
||||
};
|
||||
let mut velocity = MilliVec { x: -1_672, y: -66 };
|
||||
|
||||
assert!(!collide_with_line(
|
||||
old,
|
||||
&mut velocity,
|
||||
vec2(277.0, 31.0),
|
||||
vec2(287.0, 12.0),
|
||||
0.6,
|
||||
0.1,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -152,6 +152,7 @@ pub struct Snapshot {
|
||||
pub claw: ClawSnapshot,
|
||||
pub flippers: FlipperSnapshot,
|
||||
pub launcher_charge: f32,
|
||||
pub collision_id: Option<u8>,
|
||||
pub events: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -255,6 +256,7 @@ impl Simulation {
|
||||
right_raised: self.game.flippers.right_raised,
|
||||
},
|
||||
launcher_charge: self.game.launcher_charge,
|
||||
collision_id: self.game.last_collision_id,
|
||||
events: events
|
||||
.into_iter()
|
||||
.map(|event| format!("{event:?}"))
|
||||
@@ -336,7 +338,7 @@ mod tests {
|
||||
let mut simulation = Simulation::new(Scenario::Launcher, 1);
|
||||
simulation.advance_to(60);
|
||||
assert!(simulation.game.ball.in_launcher);
|
||||
assert!((0.49..=0.51).contains(&simulation.game.launcher_charge));
|
||||
assert!((0.09..=0.11).contains(&simulation.game.launcher_charge));
|
||||
|
||||
simulation.advance_to(121);
|
||||
assert!(!simulation.game.ball.in_launcher);
|
||||
|
||||
+54
-26
@@ -12,22 +12,39 @@
|
||||
use crate::geometry::Segment;
|
||||
use macroquad::prelude::Vec2;
|
||||
|
||||
// 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 {
|
||||
pub id: u8,
|
||||
pub segment: Segment,
|
||||
pub normal_rebound: f32,
|
||||
pub tangent_coupling: f32,
|
||||
}
|
||||
|
||||
impl TableSegment {
|
||||
const fn new(id: u8, start: Vec2, end: Vec2) -> Self {
|
||||
// These are the first two Borland Real48 fields in each original
|
||||
// 0x53-byte collision record. They control normal rebound and tangent
|
||||
// coupling in the type-2 response basis.
|
||||
let (normal_rebound, tangent_coupling) = match id {
|
||||
3 | 9 | 25 => (0.10, 0.10),
|
||||
91 | 92 | 94 | 95 | 97 | 98 | 100 | 101 | 103 | 104 => (0.40, 0.0),
|
||||
62 => (0.40, 0.05),
|
||||
15 | 16 | 45 => (0.40, 0.10),
|
||||
78 => (0.50, 0.05),
|
||||
55 | 57 | 59 | 61 | 70 | 72 | 74 | 76 => (0.50, 0.10),
|
||||
66 | 68 | 81 | 83 => (0.50, 0.20),
|
||||
84 | 85 | 86 | 87 | 88 | 90 | 93 | 96 | 99 | 102 | 109 | 110 | 111 | 112 | 113
|
||||
| 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 168 | 170 | 171 | 173 => {
|
||||
(0.60, 0.0)
|
||||
}
|
||||
27 | 28 | 29 | 30 | 31 | 32 | 105 => (0.60, 0.20),
|
||||
_ => (0.60, 0.10),
|
||||
};
|
||||
Self {
|
||||
id,
|
||||
segment: Segment::new(start, end, WALL_BOUNCE),
|
||||
segment: Segment::new(start, end, normal_rebound),
|
||||
normal_rebound,
|
||||
tangent_coupling,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -215,7 +232,10 @@ pub const BUMPERS: [StaticCircle; 3] = [
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::geometry::{circle_collision, segment_collision};
|
||||
use crate::{
|
||||
geometry::circle_collision,
|
||||
original_physics::{MilliVec, collide_with_line},
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn recovered_object_inventory_is_complete() {
|
||||
@@ -241,7 +261,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_recovered_wall_has_a_working_contact_surface_on_both_sides() {
|
||||
fn every_recovered_wall_has_its_registered_one_sided_contact() {
|
||||
for wall in WALLS {
|
||||
let line = wall.segment.end - wall.segment.start;
|
||||
assert!(
|
||||
@@ -251,25 +271,33 @@ mod tests {
|
||||
);
|
||||
let normal = Vec2::new(-line.y, line.x).normalize();
|
||||
let midpoint = (wall.segment.start + wall.segment.end) * 0.5;
|
||||
let mut front_velocity = MilliVec::from_velocity_per_second(normal * 200.0);
|
||||
assert!(
|
||||
collide_with_line(
|
||||
MilliVec::from_position(midpoint - normal),
|
||||
&mut front_velocity,
|
||||
wall.segment.start,
|
||||
wall.segment.end,
|
||||
f64::from(wall.normal_rebound),
|
||||
f64::from(wall.tangent_coupling),
|
||||
),
|
||||
"object {} missed its registered front side",
|
||||
wall.id
|
||||
);
|
||||
|
||||
for side in [-1.0, 1.0] {
|
||||
let outward = normal * side;
|
||||
let mut position = midpoint + outward * 0.5;
|
||||
let mut velocity = -outward * 430.0;
|
||||
let hit = segment_collision(&mut position, &mut velocity, 1.0, wall.segment);
|
||||
|
||||
assert!(hit, "object {} missed its midpoint contact", wall.id);
|
||||
assert!(
|
||||
velocity.dot(outward) >= 0.0,
|
||||
"object {} did not reject an inward trajectory",
|
||||
wall.id
|
||||
);
|
||||
assert!(
|
||||
position.distance(midpoint) >= 0.99,
|
||||
"object {} did not separate the ball from its surface",
|
||||
wall.id
|
||||
);
|
||||
}
|
||||
let mut back_velocity = MilliVec::from_velocity_per_second(-normal * 200.0);
|
||||
assert!(
|
||||
!collide_with_line(
|
||||
MilliVec::from_position(midpoint + normal),
|
||||
&mut back_velocity,
|
||||
wall.segment.start,
|
||||
wall.segment.end,
|
||||
f64::from(wall.normal_rebound),
|
||||
f64::from(wall.tangent_coupling),
|
||||
),
|
||||
"object {} accepted contact from its back side",
|
||||
wall.id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user