fix(game): restore startup and multiball edge behavior
Build TDK Pinball / build (macos-latest) (push) Canceled after 0s
Build TDK Pinball / build (ubuntu-latest) (push) Canceled after 0s
Build TDK Pinball / build (windows-latest) (push) Canceled after 0s
Build TDK Pinball / build (macos-latest) (push) Canceled after 0s
Build TDK Pinball / build (ubuntu-latest) (push) Canceled after 0s
Build TDK Pinball / build (windows-latest) (push) Canceled after 0s
The interactive build inherited Macroquad's fixed zero random state, making maximum-power launcher shots repeat across fresh runs. Seed the platform generator from the startup clock before taking the program-global gameplay seed. Require record 148's still-live contact before the effect-seven special respawn and clear active multiball state on an ordinary ball drain, so a released reserve ball cannot be recreated after both slots are lost. Type-4 target and effect handlers now honor each record's predicted broadphase before changing its entry latch. This preserves the latch while another multiball slot is elsewhere, preventing repeated upper-left corridor scoring. The regressions and regenerated browser artifact stay with the implementation. Test Plan: - `just test` -- passed (137 tests) - `just clippy` -- passed - `cargo build --profile production` -- passed - `just web-build` -- passed - `cargo +nightly fmt --check` -- passed - `rumdl check --flavor commonmark CHANGELOG.md` -- passed - `git diff --cached --check` -- passed
This commit is contained in:
@@ -8,6 +8,17 @@ and this project adheres to
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Seed the initial gameplay random stream from the startup clock so the first
|
||||||
|
maximum-power launcher shot is not identical on every run.
|
||||||
|
- Require an unconsumed record-148 contact before the effect-seven special
|
||||||
|
respawn, preventing a released reserve ball from being duplicated after
|
||||||
|
both multiball balls drain.
|
||||||
|
- Apply type-4 entry latches only inside each target's registered broadphase,
|
||||||
|
preventing an unrelated multiball slot from clearing an upper-left target's
|
||||||
|
latch and causing repeated scoring while the ball remains there.
|
||||||
|
|
||||||
## [1.0.0] - 2026-08-29
|
## [1.0.0] - 2026-08-29
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -94,6 +94,13 @@ fn add_player_released() -> bool {
|
|||||||
ADD_PLAYER_KEYS.into_iter().any(is_key_released)
|
ADD_PLAYER_KEYS.into_iter().any(is_key_released)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn initial_game_random_seed() -> u32 {
|
||||||
|
// The original seeds RandSeed from the startup clock. Macroquad's random
|
||||||
|
// generator starts from a fixed zero state unless the application seeds it.
|
||||||
|
macroquad::rand::srand(macroquad::miniquad::date::now().to_bits());
|
||||||
|
macroquad::rand::rand()
|
||||||
|
}
|
||||||
|
|
||||||
fn visible_score_digits(mut value: u32) -> Vec<(u8, u8)> {
|
fn visible_score_digits(mut value: u32) -> Vec<(u8, u8)> {
|
||||||
let mut digits = Vec::with_capacity(8);
|
let mut digits = Vec::with_capacity(8);
|
||||||
for position in 0..8 {
|
for position in 0..8 {
|
||||||
@@ -236,7 +243,7 @@ impl App {
|
|||||||
screen: Screen::Loading,
|
screen: Screen::Loading,
|
||||||
return_screen: Screen::Attract,
|
return_screen: Screen::Attract,
|
||||||
game: None,
|
game: None,
|
||||||
game_random_seed: macroquad::rand::rand(),
|
game_random_seed: initial_game_random_seed(),
|
||||||
setting_row: 0,
|
setting_row: 0,
|
||||||
loading_started,
|
loading_started,
|
||||||
loading_until: loading_started + 1.1,
|
loading_until: loading_started + 1.1,
|
||||||
|
|||||||
+80
-3
@@ -1805,7 +1805,14 @@ impl Game {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let effect_index = usize::from(EFFECT_SENSOR.id);
|
let effect_index = usize::from(EFFECT_SENSOR.id);
|
||||||
if self.object_active[effect_index] {
|
let predicted_position = old_position.add(movement_velocity);
|
||||||
|
if self.object_active[effect_index]
|
||||||
|
&& trigger_broadphase_contains(
|
||||||
|
predicted_position,
|
||||||
|
EFFECT_SENSOR.center,
|
||||||
|
EFFECT_SENSOR.radius,
|
||||||
|
)
|
||||||
|
{
|
||||||
let touched = path_intersects_circle(
|
let touched = path_intersects_circle(
|
||||||
old_position,
|
old_position,
|
||||||
movement_velocity,
|
movement_velocity,
|
||||||
@@ -1872,17 +1879,27 @@ impl Game {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let mut triggered = false;
|
let mut triggered = false;
|
||||||
|
let predicted_position = old_position.add(movement_velocity);
|
||||||
for sensor in TARGET_SENSORS {
|
for sensor in TARGET_SENSORS {
|
||||||
if !record_ids.contains(&sensor.id) {
|
if !record_ids.contains(&sensor.id) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
let contact_index = usize::from(sensor.id);
|
||||||
|
// The original scanner does not call a type-4 handler until the
|
||||||
|
// predicted point is inside that record's registered bounds. In
|
||||||
|
// multiball this also prevents an unrelated ball outside the
|
||||||
|
// corridor from clearing the ball that is currently inside it.
|
||||||
|
if !self.object_active[contact_index]
|
||||||
|
|| !trigger_broadphase_contains(predicted_position, sensor.center, sensor.radius)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let touched = path_intersects_circle(
|
let touched = path_intersects_circle(
|
||||||
old_position,
|
old_position,
|
||||||
movement_velocity,
|
movement_velocity,
|
||||||
sensor.center,
|
sensor.center,
|
||||||
sensor.radius,
|
sensor.radius,
|
||||||
);
|
);
|
||||||
let contact_index = usize::from(sensor.id);
|
|
||||||
let entered = touched && !self.trigger_flags[contact_index];
|
let entered = touched && !self.trigger_flags[contact_index];
|
||||||
self.trigger_flags[contact_index] = touched;
|
self.trigger_flags[contact_index] = touched;
|
||||||
if !entered {
|
if !entered {
|
||||||
@@ -2151,6 +2168,7 @@ impl Game {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.ball_double = BallDoubleState::Inactive;
|
self.ball_double = BallDoubleState::Inactive;
|
||||||
|
self.multiball_state = MultiballState::Unavailable;
|
||||||
self.special_hole_gate = SpecialHoleGate::Enabled;
|
self.special_hole_gate = SpecialHoleGate::Enabled;
|
||||||
let player = &mut self.players[self.current_player];
|
let player = &mut self.players[self.current_player];
|
||||||
if player.extra_balls > 0 {
|
if player.extra_balls > 0 {
|
||||||
@@ -2197,6 +2215,7 @@ impl Game {
|
|||||||
fn special_respawn_pending(&self) -> bool {
|
fn special_respawn_pending(&self) -> bool {
|
||||||
self.secondary_ball.is_none()
|
self.secondary_ball.is_none()
|
||||||
&& self.score_mode == ScoreMode::Normal
|
&& self.score_mode == ScoreMode::Normal
|
||||||
|
&& self.record_contacts[usize::from(SPECIAL_HOLE_SENSOR.id)] != 0
|
||||||
&& matches!(
|
&& matches!(
|
||||||
self.multiball_state,
|
self.multiball_state,
|
||||||
MultiballState::Ready | MultiballState::Active
|
MultiballState::Ready | MultiballState::Active
|
||||||
@@ -2629,6 +2648,17 @@ mod tests {
|
|||||||
assert_eq!(game.launcher_frame(), 10);
|
assert_eq!(game.launcher_frame(), 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn maximum_launch_uses_the_initial_random_seed_for_trajectory_variation() {
|
||||||
|
let mut first = Game::new_with_seed(1, 1);
|
||||||
|
let mut second = Game::new_with_seed(1, 2);
|
||||||
|
|
||||||
|
launch_ball(&mut first, 60);
|
||||||
|
launch_ball(&mut second, 60);
|
||||||
|
|
||||||
|
assert_ne!(first.ball.velocity, second.ball.velocity);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn charged_ball_clears_the_shooter_lane() {
|
fn charged_ball_clears_the_shooter_lane() {
|
||||||
for detail in 1..=5 {
|
for detail in 1..=5 {
|
||||||
@@ -2775,6 +2805,7 @@ mod tests {
|
|||||||
let mut special = Game::new(1);
|
let mut special = Game::new(1);
|
||||||
special.ball_double = BallDoubleState::Active;
|
special.ball_double = BallDoubleState::Active;
|
||||||
special.multiball_state = MultiballState::Ready;
|
special.multiball_state = MultiballState::Ready;
|
||||||
|
special.record_contacts[148] = 2;
|
||||||
special.drain(&mut Vec::new());
|
special.drain(&mut Vec::new());
|
||||||
assert_eq!(special.ball_double, BallDoubleState::Active);
|
assert_eq!(special.ball_double, BallDoubleState::Active);
|
||||||
assert_eq!(special.ball.position, vec2(17.0, 23.0));
|
assert_eq!(special.ball.position, vec2(17.0, 23.0));
|
||||||
@@ -3191,6 +3222,10 @@ mod tests {
|
|||||||
assert_eq!(game.record_contacts[148], 0);
|
assert_eq!(game.record_contacts[148], 0);
|
||||||
game.drain(&mut events);
|
game.drain(&mut events);
|
||||||
assert!(game.secondary_ball.is_none());
|
assert!(game.secondary_ball.is_none());
|
||||||
|
game.drain(&mut events);
|
||||||
|
assert!(game.ball.in_launcher);
|
||||||
|
assert_eq!(game.player().balls, 2);
|
||||||
|
assert_eq!(game.multiball_state, MultiballState::Unavailable);
|
||||||
|
|
||||||
events.clear();
|
events.clear();
|
||||||
game.apply_wall_rule(84, &mut events);
|
game.apply_wall_rule(84, &mut events);
|
||||||
@@ -3212,6 +3247,7 @@ mod tests {
|
|||||||
fn record_two_response_continues_after_special_respawn_like_live_original() {
|
fn record_two_response_continues_after_special_respawn_like_live_original() {
|
||||||
let mut game = Game::new(1);
|
let mut game = Game::new(1);
|
||||||
game.multiball_state = MultiballState::Active;
|
game.multiball_state = MultiballState::Active;
|
||||||
|
game.record_contacts[148] = 2;
|
||||||
game.ball.in_launcher = false;
|
game.ball.in_launcher = false;
|
||||||
game.ball.position = vec2(157.0, 453.0);
|
game.ball.position = vec2(157.0, 453.0);
|
||||||
game.ball.velocity = MilliVec { x: 0, y: 3_000 }.to_velocity_per_second();
|
game.ball.velocity = MilliVec { x: 0, y: 3_000 }.to_velocity_per_second();
|
||||||
@@ -4311,7 +4347,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(game.player().score, 500, "contact must score only once");
|
assert_eq!(game.player().score, 500, "contact must score only once");
|
||||||
|
|
||||||
game.ball.position = vec2(205.0, 70.0);
|
game.ball.position = vec2(205.0, 62.0);
|
||||||
game.check_sensor_objects(
|
game.check_sensor_objects(
|
||||||
MilliVec::from_position(game.ball.position),
|
MilliVec::from_position(game.ball.position),
|
||||||
MilliVec::default(),
|
MilliVec::default(),
|
||||||
@@ -4326,6 +4362,47 @@ mod tests {
|
|||||||
assert_eq!(game.player().score, 1_000);
|
assert_eq!(game.player().score, 1_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn type_four_latch_is_not_cleared_by_a_ball_outside_its_broadphase() {
|
||||||
|
let sensor = TARGET_SENSORS
|
||||||
|
.into_iter()
|
||||||
|
.find(|sensor| sensor.id == 140)
|
||||||
|
.expect("record 140 must be present");
|
||||||
|
let mut game = Game::new(1);
|
||||||
|
let mut primary = game.ball;
|
||||||
|
primary.position = sensor.center;
|
||||||
|
let mut events = Vec::new();
|
||||||
|
|
||||||
|
game.check_target_sensors(
|
||||||
|
&mut primary,
|
||||||
|
MilliVec::from_position(sensor.center),
|
||||||
|
MilliVec::default(),
|
||||||
|
140..=147,
|
||||||
|
&mut events,
|
||||||
|
);
|
||||||
|
assert_eq!(game.player().score, sensor.score);
|
||||||
|
|
||||||
|
let mut unrelated = primary;
|
||||||
|
unrelated.position = vec2(200.0, 200.0);
|
||||||
|
let unrelated_position = MilliVec::from_position(unrelated.position);
|
||||||
|
game.check_target_sensors(
|
||||||
|
&mut unrelated,
|
||||||
|
unrelated_position,
|
||||||
|
MilliVec::default(),
|
||||||
|
140..=147,
|
||||||
|
&mut events,
|
||||||
|
);
|
||||||
|
game.check_target_sensors(
|
||||||
|
&mut primary,
|
||||||
|
MilliVec::from_position(sensor.center),
|
||||||
|
MilliVec::default(),
|
||||||
|
140..=147,
|
||||||
|
&mut events,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(game.player().score, sensor.score);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn magnetic_rectangles_use_old_position_for_entry_and_prediction_for_exit() {
|
fn magnetic_rectangles_use_old_position_for_entry_and_prediction_for_exit() {
|
||||||
let mut game = Game::new_with_seed(1, 7);
|
let mut game = Game::new_with_seed(1, 7);
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user