diff --git a/tdkpin-rs/README.md b/tdkpin-rs/README.md index 8215b70..c7ced5d 100644 --- a/tdkpin-rs/README.md +++ b/tdkpin-rs/README.md @@ -36,7 +36,7 @@ command-line tools. | Action | Original key | Additional modern key | | --- | --- | --- | | Choose 1-4 players | `+` | `=` | -| Start or launch ball | Down arrow | Enter starts a game | +| Start game / charge launcher | Hold Down arrow, release to launch | Enter starts a game | | Left flipper | Left Ctrl | `A` or Left arrow | | Right flipper | Keypad Enter | Right Ctrl, `D`, or Right arrow | | Nudge | Space, either Shift, keypad `3` | - | diff --git a/tdkpin-rs/RECONSTRUCTION.md b/tdkpin-rs/RECONSTRUCTION.md index 545a99c..1a7ee85 100644 --- a/tdkpin-rs/RECONSTRUCTION.md +++ b/tdkpin-rs/RECONSTRUCTION.md @@ -19,10 +19,11 @@ implementation. | Subsystem | Rust status | Evidence and boundary | | --- | --- | --- | -| 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, ball, wheel, robot, magnet, 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, ball, wheel, robot, plunger, 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 | All 109 active type-2 line objects and 40 static active type-1 circles are transcribed from the original 175-object registration table. The registration routine converts its sideways inputs with `screen = (y, x - 20)` and accumulates explicitly relative objects. Object 174 is omitted because the original overwrites it with the live ball every frame. Moving flippers use equivalent native Rust bodies. | +| Playfield collision layout | Recovered | All 109 active type-2 line objects and 40 static active type-1 circles are transcribed from the original 175-object registration table. The registration routine converts its sideways inputs with `screen = (y, x - 20)` and accumulates explicitly relative objects. Its circle sizes are complete center-contact extents, so the rendered ball radius is not added a second time. Object 174 is omitted because the original overwrites it with the live ball every frame. Moving flippers use equivalent native Rust bodies. | +| Ball launcher | Recovered | The initial 32-bit fixed-point coordinates decode to `(325, 413)` in the right shooter lane. Scan code `0x50` compresses the eleven frames in resource 901 while Down is held; the key-release routine activates the ball with the accumulated vertical launch velocity. | | 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. | diff --git a/tdkpin-rs/src/app.rs b/tdkpin-rs/src/app.rs index 64002d1..8a6fbbf 100644 --- a/tdkpin-rs/src/app.rs +++ b/tdkpin-rs/src/app.rs @@ -136,14 +136,9 @@ impl App { self.assets.play(2012, self.saved.settings.sounds); } if is_key_pressed(KeyCode::Down) || is_key_pressed(KeyCode::Enter) { - let mut game = Game::new(self.player_count); - let launched = game.launch(); - self.game = Some(game); + self.game = Some(Game::new(self.player_count)); self.screen = Screen::Playing; self.assets.play(2008, self.saved.settings.sounds); - if launched { - self.assets.play(2016, self.saved.settings.sounds); - } } } @@ -175,7 +170,7 @@ impl App { let controls = Controls { left_flipper, right_flipper, - launch_pressed: is_key_pressed(KeyCode::Down), + launch_down: is_key_down(KeyCode::Down), nudge, }; let events = self.game.as_mut().map_or_else(Vec::new, |game| { @@ -486,15 +481,18 @@ impl App { YELLOW, ); } - let frame = animation_frame(get_time(), 18.0, 10); + } + if game.ball.in_launcher { + let frame = [0.05, 0.15, 0.25, 0.35, 0.45, 0.55, 0.65, 0.75, 0.85, 0.95] + .partition_point(|threshold| game.launcher_charge >= *threshold); draw_texture_ex( - &self.assets.magnet, - 306.0, - 411.0, + &self.assets.plunger, + 314.0, + 406.0, WHITE, DrawTextureParams { - dest_size: Some(vec2(23.0, 17.0)), - source: Some(Rect::new(frame as f32 * 23.0, 0.0, 23.0, 17.0)), + dest_size: Some(vec2(21.0, 18.0)), + source: Some(Rect::new(frame as f32 * 21.0, 0.0, 20.0, 17.0)), ..Default::default() }, ); diff --git a/tdkpin-rs/src/assets.rs b/tdkpin-rs/src/assets.rs index 2725c6d..89148ec 100644 --- a/tdkpin-rs/src/assets.rs +++ b/tdkpin-rs/src/assets.rs @@ -12,7 +12,7 @@ pub struct Assets { pub diamond: [Texture2D; 9], pub wheel: Texture2D, pub robot: Texture2D, - pub magnet: Texture2D, + pub plunger: Texture2D, pub ball: Texture2D, pub digits: Texture2D, sounds: Vec<(u16, Sound)>, @@ -50,7 +50,7 @@ impl Assets { ]; let wheel = texture(include_bytes!("../assets/original/images/dat_00600.png")); let robot = texture(include_bytes!("../assets/original/images/dat_00900.png")); - let magnet = texture(include_bytes!("../assets/original/images/dat_00901.png")); + let plunger = texture(include_bytes!("../assets/original/images/dat_00901.png")); let ball = masked_texture( include_bytes!("../assets/original/images/bitmap_00101.png"), include_bytes!("../assets/original/images/bitmap_00102.png"), @@ -140,7 +140,7 @@ impl Assets { diamond, wheel, robot, - magnet, + plunger, ball, digits, sounds, diff --git a/tdkpin-rs/src/game.rs b/tdkpin-rs/src/game.rs index ef3cf59..9db9dce 100644 --- a/tdkpin-rs/src/game.rs +++ b/tdkpin-rs/src/game.rs @@ -4,18 +4,26 @@ use crate::{ }; use macroquad::prelude::{Vec2, vec2}; -const BALL_RADIUS: f32 = 7.0; +const FLIPPER_CONTACT_RADIUS: f32 = 9.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 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_CHARGE_SECONDS: f32 = 0.55; +const LAUNCH_SPEED_MIN: f32 = 330.0; +const LAUNCH_SPEED_RANGE: f32 = 100.0; #[derive(Clone, Copy, Debug, Default)] pub struct Controls { pub left_flipper: bool, pub right_flipper: bool, - pub launch_pressed: bool, + pub launch_down: bool, pub nudge: f32, } @@ -69,7 +77,7 @@ pub struct Ball { impl Default for Ball { fn default() -> Self { Self { - position: vec2(157.0, 426.0), + position: LAUNCHER_POSITION, velocity: Vec2::ZERO, in_launcher: true, } @@ -94,6 +102,7 @@ pub struct Game { pub wheel_animation: f32, pub robot_animation: f32, pub nudge_shake: f32, + pub launcher_charge: f32, pub finished: bool, accumulator: f32, target_cooldown: f32, @@ -101,6 +110,7 @@ pub struct Game { nudge_cooldown: f32, nudge_meter: f32, stalled_for: f32, + launcher_was_down: bool, } impl Game { @@ -122,6 +132,7 @@ impl Game { wheel_animation: 0.0, robot_animation: 0.0, nudge_shake: 0.0, + launcher_charge: 0.0, finished: false, accumulator: 0.0, target_cooldown: 0.0, @@ -129,6 +140,7 @@ impl Game { nudge_cooldown: 0.0, nudge_meter: 0.0, stalled_for: 0.0, + launcher_was_down: false, } } @@ -136,14 +148,13 @@ impl Game { &self.players[self.current_player] } - pub fn launch(&mut self) -> bool { - if !self.ball.in_launcher || self.tilted { - return false; - } + fn fire_launcher(&mut self) { + let launch_speed = LAUNCH_SPEED_MIN + self.launcher_charge * LAUNCH_SPEED_RANGE; self.ball.in_launcher = false; - self.ball.velocity = vec2(12.0, -330.0); + self.ball.velocity = vec2(0.0, -launch_speed); + self.launcher_charge = 0.0; + self.launcher_was_down = false; self.stalled_for = 0.0; - true } pub fn update(&mut self, frame_time: f32, detail: u8, controls: Controls) -> Vec { @@ -175,8 +186,15 @@ impl Game { events.push(Event::Flipper); } - if controls.launch_pressed && self.launch() { - events.push(Event::Launch); + if self.ball.in_launcher && !self.tilted { + if controls.launch_down { + let charge_step = frame_time.min(0.05) / LAUNCHER_CHARGE_SECONDS; + self.launcher_charge = (self.launcher_charge + charge_step).min(1.0); + self.launcher_was_down = true; + } else if self.launcher_was_down { + self.fire_launcher(); + events.push(Event::Launch); + } } if !self.tilted && !self.ball.in_launcher @@ -224,14 +242,14 @@ impl Game { } if self.ball.in_launcher { - self.ball.position = vec2(157.0, 426.0); + 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 = BALL_RADIUS * 0.45; + let maximum_step_travel = TABLE_LINE_RADIUS; let mut substeps = 1_u8; while f32::from(substeps) * maximum_step_travel < travel && substeps < 12 { substeps += 1; @@ -253,12 +271,21 @@ impl Game { let hit = segment_collision( &mut self.ball.position, &mut self.ball.velocity, - BALL_RADIUS, + TABLE_LINE_RADIUS, wall.segment, ); - if hit && wall.id == 2 { - drained = true; - break; + 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; + } } } if drained { @@ -271,9 +298,9 @@ impl Game { circle_collision( &mut self.ball.position, &mut self.ball.velocity, - BALL_RADIUS, + 0.0, circle.center, - circle.radius, + circle.contact_radius, 0.0, ); } @@ -283,7 +310,7 @@ impl Game { if segment_collision( &mut self.ball.position, &mut self.ball.velocity, - BALL_RADIUS + 2.0, + FLIPPER_CONTACT_RADIUS, left_flipper, ) && self.left_flipper_angle > -2.55 { @@ -292,7 +319,7 @@ impl Game { if segment_collision( &mut self.ball.position, &mut self.ball.velocity, - BALL_RADIUS + 2.0, + FLIPPER_CONTACT_RADIUS, right_flipper, ) && self.right_flipper_angle < -0.58 { @@ -303,9 +330,9 @@ impl Game { let hit = circle_collision( &mut self.ball.position, &mut self.ball.velocity, - BALL_RADIUS, + 0.0, bumper.center, - bumper.radius, + bumper.contact_radius, 105.0, ); if hit && !self.tilted && self.bumper_cooldown <= 0.0 { @@ -506,6 +533,8 @@ impl Game { self.wheel_animation = 0.0; self.robot_animation = 0.0; self.nudge_shake = 0.0; + self.launcher_charge = 0.0; + self.launcher_was_down = false; let mut next = (self.current_player + 1) % self.players.len(); for _ in 0..self.players.len() { @@ -536,6 +565,20 @@ fn flipper_segment(pivot: Vec2, angle: f32) -> Segment { mod tests { use super::*; + fn launch_ball(game: &mut Game, held_frames: usize) -> Vec { + for _ in 0..held_frames { + game.update( + 1.0 / 60.0, + 3, + Controls { + launch_down: true, + ..Controls::default() + }, + ); + } + game.update(1.0 / 60.0, 3, Controls::default()) + } + #[test] fn player_count_is_bounded() { assert_eq!(Game::new(0).players.len(), 1); @@ -543,21 +586,90 @@ mod tests { } #[test] - fn launch_is_deterministic() { + fn ball_starts_in_the_recovered_shooter_lane() { + let game = Game::new(1); + + assert_eq!(game.ball.position, Vec2::new(325.0, 413.0)); + assert!(game.ball.in_launcher); + } + + #[test] + fn launcher_charges_while_held_and_fires_on_release() { let mut game = Game::new(1); - let events = game.update( + game.update( 1.0 / 60.0, 3, Controls { - launch_pressed: true, + launch_down: true, ..Controls::default() }, ); + + assert!(game.ball.in_launcher); + assert!(game.launcher_charge > 0.0); + + let events = game.update(1.0 / 60.0, 3, Controls::default()); + assert!(events.contains(&Event::Launch)); assert!(!game.ball.in_launcher); assert!(game.ball.velocity.y < 0.0); } + #[test] + fn holding_the_launcher_produces_a_faster_shot() { + let mut quick = Game::new(1); + launch_ball(&mut quick, 1); + let quick_speed = -quick.ball.velocity.y; + + let mut charged = Game::new(1); + launch_ball(&mut charged, 33); + let charged_speed = -charged.ball.velocity.y; + + assert!(charged_speed > quick_speed + 80.0); + } + + #[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 _ 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; + } + } + + 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 + ); + } + + #[test] + fn shooter_stop_rearms_a_returning_ball() { + let mut game = Game::new(1); + game.ball.in_launcher = false; + game.ball.position = Vec2::new(325.0, 410.0); + game.ball.velocity = Vec2::new(0.0, 180.0); + + for _ in 0..8 { + game.update(1.0 / 120.0, 5, Controls::default()); + if game.ball.in_launcher { + break; + } + } + + assert!(game.ball.in_launcher); + assert_eq!(game.ball.position, LAUNCHER_POSITION); + assert_eq!(game.ball.velocity, Vec2::ZERO); + } + #[test] fn completed_diamond_doubles_score() { let mut game = Game::new(1); @@ -603,14 +715,14 @@ mod tests { game.fixed_update(1.0 / 30.0, &mut Vec::new()); - assert!(game.ball.position.y >= 15.0 + BALL_RADIUS - 0.01); + assert!(game.ball.position.y >= 15.0 + TABLE_LINE_RADIUS - 0.01); assert!(game.ball.velocity.y > 0.0); } #[test] fn tilt_latches_and_disables_flippers_and_scoring() { let mut game = Game::new(1); - assert!(game.launch()); + assert!(launch_ball(&mut game, 1).contains(&Event::Launch)); game.bonus = 4_000; for _ in 0..4 { game.nudge_cooldown = 0.0; @@ -679,6 +791,6 @@ mod tests { } assert!(!game.tilted); - assert!(game.launch()); + assert!(launch_ball(&mut game, 1).contains(&Event::Launch)); } } diff --git a/tdkpin-rs/src/table.rs b/tdkpin-rs/src/table.rs index 25ba5bc..216b8f1 100644 --- a/tdkpin-rs/src/table.rs +++ b/tdkpin-rs/src/table.rs @@ -5,6 +5,9 @@ //! objects use `(param_34, param_32 - 20)` and `(param_30, param_28 - 20)`; //! relative objects accumulate those values from the preceding endpoint. //! The old port used `478 - x`, which mirrored the complete table vertically. +//! Circle sizes are already center-contact extents: `FUN_1008_0138` expands +//! each object's bounds by `param_26`. They must not be enlarged again by the +//! radius of the rendered ball. use crate::geometry::Segment; use macroquad::prelude::Vec2; @@ -30,12 +33,16 @@ impl TableSegment { pub struct StaticCircle { pub id: u8, pub center: Vec2, - pub radius: f32, + pub contact_radius: f32, } impl StaticCircle { - const fn new(id: u8, center: Vec2, radius: f32) -> Self { - Self { id, center, radius } + const fn new(id: u8, center: Vec2, contact_radius: f32) -> Self { + Self { + id, + center, + contact_radius, + } } }