fix(game): reopen the drain and recover stalled balls
The recovered bottom apron had been represented as one solid collision segment, which prevented the ball from reaching the drain threshold. Split that apron around the visible center drain so a lost ball decrements the ball count and allows player and game-over progression to continue. Add a conservative ball-search impulse after three seconds below the movement threshold. This protects long-running games from other low-energy rests without changing active trajectories, and reuses the original nudge sound as feedback. Test Plan: - `cargo fmt -- --check` -- passed - `cargo test --all-targets` -- passed, including drain and ball-search regressions - `cargo clippy --all-targets -- -D warnings` -- passed - `git diff --check` -- passed
This commit is contained in:
@@ -22,7 +22,7 @@ implementation.
|
|||||||
| Artwork | Exact | All 34 custom DIB images, three standard bitmaps, icon, and palette derivatives are preserved in `assets/original/`. The game uses the original 640x460 table, loading, help, media, and diamond frames. |
|
| Artwork | Exact | All 34 custom DIB images, three standard bitmaps, icon, and palette derivatives are preserved in `assets/original/`. The game uses the original 640x460 table, loading, help, media, and diamond frames. |
|
||||||
| Audio | Exact samples | All 16 mono PCM WAV resources are embedded unchanged. Their trigger roles were recovered from resource use and gameplay context. |
|
| Audio | Exact samples | All 16 mono PCM WAV resources are embedded unchanged. Their trigger roles were recovered from resource use and gameplay context. |
|
||||||
| Help and languages | Exact | Original resource images 1001-1005 are displayed directly. |
|
| Help and languages | Exact | Original resource images 1001-1005 are displayed directly. |
|
||||||
| Playfield collision layout | Recovered | 101 active static line segments are transcribed from the original 175-object registration table. The original sideways coordinates are transformed with `screen = (y, 478 - x)`. Moving flippers and round bumpers use equivalent native Rust bodies. |
|
| Playfield collision layout | Recovered | The active static line segments are transcribed from the original 175-object registration table. The original sideways coordinates are transformed with `screen = (y, 478 - x)`. The lower apron is split around the visible center drain; moving flippers and round bumpers use equivalent native Rust bodies. |
|
||||||
| Physics arithmetic | Reimplemented | The Win16 fixed-point/timer engine is replaced by deterministic fixed-step floating-point integration. Restitution and impulses are tuned to the recovered table but are not instruction-for-instruction equivalents. |
|
| Physics arithmetic | Reimplemented | The Win16 fixed-point/timer engine is replaced by deterministic fixed-step floating-point integration. Restitution and impulses are tuned to the recovered table but are not instruction-for-instruction equivalents. |
|
||||||
| Rules | Behaviorally recovered | Player count, controls, wheel holes, magnetic saves, robot grip, four-position ball lock, target banks, increasing bumper value, nine-part TDK diamond, permanent double scoring for a completed diamond, KByte media progression, and media extra balls follow the original help and code paths. |
|
| Rules | Behaviorally recovered | Player count, controls, wheel holes, magnetic saves, robot grip, four-position ball lock, target banks, increasing bumper value, nine-part TDK diamond, permanent double scoring for a completed diamond, KByte media progression, and media extra balls follow the original help and code paths. |
|
||||||
| Numeric scoring | Partly inferred | Visible 2000-6000 target values and recovered registration values are preserved. Some bumper, bank-completion, robot, wheel, lock, and media thresholds are best-evidence reconstructions because the decompiler did not recover meaningful names or a clean rule table. |
|
| Numeric scoring | Partly inferred | Visible 2000-6000 target values and recovered registration values are preserved. Some bumper, bank-completion, robot, wheel, lock, and media thresholds are best-evidence reconstructions because the decompiler did not recover meaningful names or a clean rule table. |
|
||||||
@@ -48,7 +48,7 @@ decoded, build-ready subset; it does not replace that evidence archive.
|
|||||||
## Validation levels
|
## Validation levels
|
||||||
|
|
||||||
- Static coverage: all intended decoded visual/audio resources are preserved;
|
- Static coverage: all intended decoded visual/audio resources are preserved;
|
||||||
the 101 recovered static collision segments are represented in Rust.
|
the recovered static collision layout is represented in Rust.
|
||||||
- Build coverage: `cargo check`, unit tests, and strict Clippy complete on the
|
- Build coverage: `cargo check`, unit tests, and strict Clippy complete on the
|
||||||
host. Cross-target checks pass for `x86_64-pc-windows-gnu` and
|
host. Cross-target checks pass for `x86_64-pc-windows-gnu` and
|
||||||
`x86_64-apple-darwin`; CI is configured to build and test natively on Linux,
|
`x86_64-apple-darwin`; CI is configured to build and test natively on Linux,
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ impl App {
|
|||||||
Event::Lock => 2017,
|
Event::Lock => 2017,
|
||||||
Event::Media => 2022,
|
Event::Media => 2022,
|
||||||
Event::ExtraBall => 2019,
|
Event::ExtraBall => 2019,
|
||||||
Event::Nudge => 2021,
|
Event::Nudge | Event::BallSearch => 2021,
|
||||||
Event::Drain => 2013,
|
Event::Drain => 2013,
|
||||||
};
|
};
|
||||||
self.assets.play(id, self.saved.settings.sounds);
|
self.assets.play(id, self.saved.settings.sounds);
|
||||||
|
|||||||
+61
-1
@@ -5,6 +5,8 @@ use std::sync::LazyLock;
|
|||||||
const BALL_RADIUS: f32 = 5.0;
|
const BALL_RADIUS: f32 = 5.0;
|
||||||
const GRAVITY: f32 = 135.0;
|
const GRAVITY: f32 = 135.0;
|
||||||
const MAX_SPEED: f32 = 430.0;
|
const MAX_SPEED: f32 = 430.0;
|
||||||
|
const BALL_SEARCH_DELAY: f32 = 3.0;
|
||||||
|
const BALL_SEARCH_SPEED: f32 = 24.0;
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Default)]
|
#[derive(Clone, Copy, Debug, Default)]
|
||||||
pub struct Controls {
|
pub struct Controls {
|
||||||
@@ -26,6 +28,7 @@ pub enum Event {
|
|||||||
Media,
|
Media,
|
||||||
ExtraBall,
|
ExtraBall,
|
||||||
Nudge,
|
Nudge,
|
||||||
|
BallSearch,
|
||||||
Drain,
|
Drain,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,6 +91,7 @@ pub struct Game {
|
|||||||
target_cooldown: f32,
|
target_cooldown: f32,
|
||||||
bumper_cooldown: f32,
|
bumper_cooldown: f32,
|
||||||
nudge_cooldown: f32,
|
nudge_cooldown: f32,
|
||||||
|
stalled_for: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Game {
|
impl Game {
|
||||||
@@ -110,6 +114,7 @@ impl Game {
|
|||||||
target_cooldown: 0.0,
|
target_cooldown: 0.0,
|
||||||
bumper_cooldown: 0.0,
|
bumper_cooldown: 0.0,
|
||||||
nudge_cooldown: 0.0,
|
nudge_cooldown: 0.0,
|
||||||
|
stalled_for: 0.0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,6 +145,7 @@ impl Game {
|
|||||||
if controls.launch_pressed && self.ball.in_launcher {
|
if controls.launch_pressed && self.ball.in_launcher {
|
||||||
self.ball.in_launcher = false;
|
self.ball.in_launcher = false;
|
||||||
self.ball.velocity = vec2(12.0, -330.0);
|
self.ball.velocity = vec2(12.0, -330.0);
|
||||||
|
self.stalled_for = 0.0;
|
||||||
events.push(Event::Launch);
|
events.push(Event::Launch);
|
||||||
}
|
}
|
||||||
if controls.nudge.abs() > 0.1 && self.nudge_cooldown <= 0.0 {
|
if controls.nudge.abs() > 0.1 && self.nudge_cooldown <= 0.0 {
|
||||||
@@ -170,6 +176,7 @@ impl Game {
|
|||||||
if self.ball.in_launcher {
|
if self.ball.in_launcher {
|
||||||
self.ball.position = vec2(157.0, 426.0);
|
self.ball.position = vec2(157.0, 426.0);
|
||||||
self.ball.velocity = Vec2::ZERO;
|
self.ball.velocity = Vec2::ZERO;
|
||||||
|
self.stalled_for = 0.0;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,6 +244,23 @@ impl Game {
|
|||||||
} else {
|
} else {
|
||||||
self.drain(events);
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,6 +409,7 @@ impl Game {
|
|||||||
self.lock_lights = 0;
|
self.lock_lights = 0;
|
||||||
self.magnets = 0.0;
|
self.magnets = 0.0;
|
||||||
self.tilt = 0.0;
|
self.tilt = 0.0;
|
||||||
|
self.stalled_for = 0.0;
|
||||||
|
|
||||||
let mut next = (self.current_player + 1) % self.players.len();
|
let mut next = (self.current_player + 1) % self.players.len();
|
||||||
for _ in 0..self.players.len() {
|
for _ in 0..self.players.len() {
|
||||||
@@ -438,7 +463,9 @@ fn table_walls() -> &'static [Segment] {
|
|||||||
Segment::new(Vec2::new(307.0, 437.0), Vec2::new(316.0, 430.0), 0.82),
|
Segment::new(Vec2::new(307.0, 437.0), Vec2::new(316.0, 430.0), 0.82),
|
||||||
Segment::new(Vec2::new(296.0, 442.0), Vec2::new(307.0, 437.0), 0.82),
|
Segment::new(Vec2::new(296.0, 442.0), Vec2::new(307.0, 437.0), 0.82),
|
||||||
Segment::new(Vec2::new(285.0, 443.0), Vec2::new(296.0, 442.0), 0.82),
|
Segment::new(Vec2::new(285.0, 443.0), Vec2::new(296.0, 442.0), 0.82),
|
||||||
Segment::new(Vec2::new(112.0, 443.0), Vec2::new(285.0, 443.0), 0.82),
|
// Preserve the recovered lower apron on either side of the center drain.
|
||||||
|
Segment::new(Vec2::new(112.0, 443.0), Vec2::new(141.0, 443.0), 0.82),
|
||||||
|
Segment::new(Vec2::new(173.0, 443.0), Vec2::new(285.0, 443.0), 0.82),
|
||||||
Segment::new(Vec2::new(99.0, 438.0), Vec2::new(121.0, 444.0), 0.82),
|
Segment::new(Vec2::new(99.0, 438.0), Vec2::new(121.0, 444.0), 0.82),
|
||||||
Segment::new(Vec2::new(78.0, 427.0), Vec2::new(100.0, 438.0), 0.82),
|
Segment::new(Vec2::new(78.0, 427.0), Vec2::new(100.0, 438.0), 0.82),
|
||||||
Segment::new(Vec2::new(66.0, 409.0), Vec2::new(79.0, 428.0), 0.82),
|
Segment::new(Vec2::new(66.0, 409.0), Vec2::new(79.0, 428.0), 0.82),
|
||||||
@@ -553,4 +580,37 @@ mod tests {
|
|||||||
game.add_score(1_000);
|
game.add_score(1_000);
|
||||||
assert_eq!(game.players[0].score, 2_000);
|
assert_eq!(game.players[0].score, 2_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn center_drain_advances_to_a_fresh_ball() {
|
||||||
|
let mut game = Game::new(1);
|
||||||
|
game.ball.in_launcher = false;
|
||||||
|
game.ball.position = vec2(157.0, 450.0);
|
||||||
|
game.ball.velocity = vec2(0.0, 300.0);
|
||||||
|
|
||||||
|
let events = game.update(1.0 / 30.0, 5, Controls::default());
|
||||||
|
|
||||||
|
assert!(events.contains(&Event::Drain));
|
||||||
|
assert_eq!(game.players[0].balls, 2);
|
||||||
|
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(220.0, 437.0);
|
||||||
|
game.ball.velocity = Vec2::ZERO;
|
||||||
|
|
||||||
|
let mut events = Vec::new();
|
||||||
|
for _ in 0..600 {
|
||||||
|
events.extend(game.update(1.0 / 120.0, 3, Controls::default()));
|
||||||
|
if events.contains(&Event::BallSearch) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(events.contains(&Event::BallSearch));
|
||||||
|
assert!(game.ball.velocity.y < 0.0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user