fix(game): restore the original plunger launch

Start each ball at the recovered right-lane coordinates and charge the
plunger while Down is held, firing only on release. Render all eleven frames
of resource 901, preserve object 25 as the returning-ball catch, and use the
object table's center-contact extents without adding the art radius twice.

Test Plan:
- cargo fmt --all -- --check
- cargo test
- cargo clippy --all-targets --all-features -- -D warnings
- runtime smoke: start, hold Down, release, and inspect the right shooter lane
This commit is contained in:
2026-08-22 17:37:28 +02:00
parent f904977ae4
commit 91ae8cdfd8
6 changed files with 169 additions and 51 deletions
+1 -1
View File
@@ -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` | - |
+3 -2
View File
@@ -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. |
+11 -13
View File
@@ -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()
},
);
+3 -3
View File
@@ -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,
+141 -29
View File
@@ -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<Event> {
@@ -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<Event> {
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));
}
}
+10 -3
View File
@@ -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,
}
}
}