diff --git a/tdkpin-rs/RECONSTRUCTION.md b/tdkpin-rs/RECONSTRUCTION.md index 464a9c9..e3e3f78 100644 --- a/tdkpin-rs/RECONSTRUCTION.md +++ b/tdkpin-rs/RECONSTRUCTION.md @@ -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. | | 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. | -| 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. | | 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. | @@ -48,7 +48,7 @@ decoded, build-ready subset; it does not replace that evidence archive. ## Validation levels - 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 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, diff --git a/tdkpin-rs/src/app.rs b/tdkpin-rs/src/app.rs index 7bbf05c..3cf0928 100644 --- a/tdkpin-rs/src/app.rs +++ b/tdkpin-rs/src/app.rs @@ -195,7 +195,7 @@ impl App { Event::Lock => 2017, Event::Media => 2022, Event::ExtraBall => 2019, - Event::Nudge => 2021, + Event::Nudge | Event::BallSearch => 2021, Event::Drain => 2013, }; self.assets.play(id, self.saved.settings.sounds); diff --git a/tdkpin-rs/src/game.rs b/tdkpin-rs/src/game.rs index 07b4309..847a841 100644 --- a/tdkpin-rs/src/game.rs +++ b/tdkpin-rs/src/game.rs @@ -5,6 +5,8 @@ use std::sync::LazyLock; const BALL_RADIUS: f32 = 5.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; #[derive(Clone, Copy, Debug, Default)] pub struct Controls { @@ -26,6 +28,7 @@ pub enum Event { Media, ExtraBall, Nudge, + BallSearch, Drain, } @@ -88,6 +91,7 @@ pub struct Game { target_cooldown: f32, bumper_cooldown: f32, nudge_cooldown: f32, + stalled_for: f32, } impl Game { @@ -110,6 +114,7 @@ impl Game { target_cooldown: 0.0, bumper_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 { self.ball.in_launcher = false; self.ball.velocity = vec2(12.0, -330.0); + self.stalled_for = 0.0; events.push(Event::Launch); } if controls.nudge.abs() > 0.1 && self.nudge_cooldown <= 0.0 { @@ -170,6 +176,7 @@ impl Game { if self.ball.in_launcher { self.ball.position = vec2(157.0, 426.0); self.ball.velocity = Vec2::ZERO; + self.stalled_for = 0.0; return; } @@ -237,6 +244,23 @@ impl Game { } 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; } } @@ -385,6 +409,7 @@ impl Game { self.lock_lights = 0; self.magnets = 0.0; self.tilt = 0.0; + self.stalled_for = 0.0; let mut next = (self.current_player + 1) % 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(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(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(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), @@ -553,4 +580,37 @@ mod tests { game.add_score(1_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); + } }