fix(game): restore effect-seven special respawn

Track required effect-seven state through active multiball and implement 1000:ae6e's final single-ball respawn at record 148 without consuming a player marker. Preserve velocity, clear required state, and hold the 0875-style record-148 gate until a broadphase-active type-four record reenables it.

Test Plan:
- cargo test --all-targets
- cargo clippy --all-targets --all-features -- -D warnings
- rumdl check CHANGELOG.md RECONSTRUCTION.md README.md
- git diff --check
This commit is contained in:
2026-08-23 19:30:38 +02:00
parent d80f823d4b
commit 6c879a3f91
3 changed files with 125 additions and 7 deletions
+120 -6
View File
@@ -205,6 +205,7 @@ enum MultiballState {
#[default]
Unavailable,
Ready,
Active,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
@@ -214,6 +215,13 @@ enum ScoreMode {
Multiball,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
enum WheelResetGate {
#[default]
Enabled,
Suppressed,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum CaptureStep {
Outside,
@@ -247,6 +255,15 @@ const fn predicted_outside_board(position: MilliVec) -> bool {
position.x <= 0 || position.x > 340_000 || position.y <= 0 || position.y > 460_000
}
fn trigger_broadphase_contains(predicted: MilliVec, center: Vec2, radius: f32) -> bool {
let center = MilliVec::from_position(center);
let margin = MilliVec::from_position(vec2(radius + 5.0, 0.0)).x;
predicted.x >= center.x.wrapping_sub(margin)
&& predicted.x <= center.x.wrapping_add(margin)
&& predicted.y >= center.y.wrapping_sub(margin)
&& predicted.y <= center.y.wrapping_add(margin)
}
#[derive(Clone, Copy, Debug)]
struct RuleState {
wheel_holes: [bool; 5],
@@ -324,6 +341,7 @@ pub struct Game {
target_effect: u8,
claw_frame_seconds: f32,
multiball_state: MultiballState,
wheel_reset_gate: WheelResetGate,
score_mode: ScoreMode,
}
@@ -365,6 +383,7 @@ impl Game {
target_effect: 0,
claw_frame_seconds: CLAW_FRAME_SECONDS,
multiball_state: MultiballState::Unavailable,
wheel_reset_gate: WheelResetGate::Enabled,
score_mode: ScoreMode::Normal,
}
}
@@ -927,6 +946,19 @@ impl Game {
x: predicted.x.wrapping_sub(old_position.x),
y: predicted.y.wrapping_sub(old_position.y),
};
if !self.tilted
&& (TARGET_SENSORS.into_iter().any(|sensor| {
self.object_active[usize::from(sensor.id)]
&& trigger_broadphase_contains(predicted, sensor.center, sensor.radius)
}) || self.object_active[usize::from(EFFECT_SENSOR.id)]
&& trigger_broadphase_contains(
predicted,
EFFECT_SENSOR.center,
EFFECT_SENSOR.radius,
))
{
self.wheel_reset_gate = WheelResetGate::Enabled;
}
self.check_target_sensors(
ball,
old_position,
@@ -1294,7 +1326,10 @@ impl Game {
}
if wall.flags & 0x0004 != 0 {
events.push(Event::Sound(2012));
if self.multiball_state == MultiballState::Ready {
if matches!(
self.multiball_state,
MultiballState::Ready | MultiballState::Active
) {
self.target_effect = 7;
} else {
let previous = self.target_effect;
@@ -1352,6 +1387,23 @@ impl Game {
let mut capture_candidate = None;
let mut action = BallAction::Keep;
let predicted_position = old_position.add(movement_velocity);
if !self.tilted
&& (TARGET_SENSORS.into_iter().any(|sensor| {
self.object_active[usize::from(sensor.id)]
&& trigger_broadphase_contains(
predicted_position,
sensor.center,
sensor.radius,
)
}) || self.object_active[usize::from(EFFECT_SENSOR.id)]
&& trigger_broadphase_contains(
predicted_position,
EFFECT_SENSOR.center,
EFFECT_SENSOR.radius,
))
{
self.wheel_reset_gate = WheelResetGate::Enabled;
}
if old_position.y >= 250_000 {
return SensorScanResult {
action: BallAction::Keep,
@@ -1473,7 +1525,10 @@ impl Game {
let contact_allowed =
contact == 0 || (contact != 99 && ball_count == 1) || contact == ball_number;
if surface_distance < -11_000 && contact_allowed {
if surface_distance < -11_000
&& contact_allowed
&& !(record_id == 148 && self.wheel_reset_gate == WheelResetGate::Suppressed)
{
if ball.capture_age < 300 {
let mut velocity = MilliVec::from_velocity_per_second(ball.velocity);
let stationary = center.x.wrapping_sub(previous_position.x).wrapping_abs() < 500
@@ -1667,7 +1722,12 @@ impl Game {
self.add_score(transfer, events);
self.players[self.current_player].secondary_score = 0;
}
7 if ball_number != 2 && self.multiball_state == MultiballState::Ready => {
7 if ball_number != 2
&& matches!(
self.multiball_state,
MultiballState::Ready | MultiballState::Active
) =>
{
self.secondary_ball = Some(Ball {
position: vec2(17.0, 23.0),
velocity: MilliVec { x: 0, y: 3_040 }.to_velocity_per_second(),
@@ -1675,7 +1735,7 @@ impl Game {
spin: Real48::ZERO,
capture_age: 0,
});
self.multiball_state = MultiballState::Unavailable;
self.multiball_state = MultiballState::Active;
self.score_mode = ScoreMode::Multiball;
}
_ => {}
@@ -1778,6 +1838,7 @@ impl Game {
{
return false;
}
self.wheel_reset_gate = WheelResetGate::Enabled;
let damping = Real48::from_bytes([0x80, 0x66, 0x66, 0x66, 0x66, 0x66]);
velocity.x = Real48::from_i32(velocity.x).multiply(damping).round_i32();
let vertical_factor = Real48::from_i32(1).subtract(
@@ -1942,6 +2003,21 @@ impl Game {
events.push(Event::Drain);
return;
}
if self.score_mode == ScoreMode::Normal
&& matches!(self.multiball_state, MultiballState::Ready | MultiballState::Active)
{
self.ball = Ball {
position: vec2(17.0, 23.0),
velocity: MilliVec { x: 0, y: 3_040 }.to_velocity_per_second(),
in_launcher: false,
spin: Real48::ZERO,
capture_age: 0,
};
self.multiball_state = MultiballState::Unavailable;
self.wheel_reset_gate = WheelResetGate::Suppressed;
return;
}
self.wheel_reset_gate = WheelResetGate::Enabled;
let player = &mut self.players[self.current_player];
if player.extra_balls > 0 {
player.extra_balls -= 1;
@@ -2528,6 +2604,7 @@ mod tests {
let spawned = game
.secondary_ball
.expect("effect seven should spawn a second ball");
assert_eq!(game.multiball_state, MultiballState::Active);
assert_eq!(spawned.position, vec2(17.0, 23.0));
assert_eq!(
MilliVec::from_velocity_per_second(spawned.velocity),
@@ -2545,6 +2622,44 @@ mod tests {
assert!(game.secondary_ball.is_none());
assert_eq!(game.player().balls, balls_before);
assert_eq!(game.ball.position, moving_ball.position);
events.clear();
game.drain(&mut events);
assert!(events.is_empty());
assert_eq!(game.player().balls, balls_before);
assert_eq!(game.ball.position, vec2(17.0, 23.0));
assert_eq!(
MilliVec::from_velocity_per_second(game.ball.velocity),
MilliVec { x: 0, y: 3_040 }
);
assert_eq!(game.multiball_state, MultiballState::Unavailable);
assert_eq!(game.wheel_reset_gate, WheelResetGate::Suppressed);
game.fixed_update(&mut events);
assert!(!game.ball.in_launcher);
assert!(game.ball.position.y > 23.0);
assert_eq!(game.wheel_reset_gate, WheelResetGate::Suppressed);
game.ball.position = vec2(17.0, 31.0);
game.ball.velocity = Vec2::ZERO;
game.fixed_update(&mut events);
assert_eq!(game.wheel_reset_gate, WheelResetGate::Enabled);
}
#[test]
fn armed_effect_seven_also_special_respawns_before_multiball_starts() {
let mut game = Game::new(1);
game.multiball_state = MultiballState::Ready;
let balls = game.player().balls;
let mut events = Vec::new();
game.drain(&mut events);
assert!(events.is_empty());
assert_eq!(game.player().balls, balls);
assert_eq!(game.ball.position, vec2(17.0, 23.0));
assert_eq!(game.multiball_state, MultiballState::Unavailable);
assert_eq!(game.wheel_reset_gate, WheelResetGate::Suppressed);
}
#[test]
@@ -2753,7 +2868,6 @@ mod tests {
game.trigger_flags[149] = true;
game.object_active[90] = false;
game.target_effect = 3;
game.multiball_state = MultiballState::Ready;
game.drain(&mut Vec::new());
assert_eq!(game.current_player, 1);
@@ -2775,7 +2889,7 @@ mod tests {
assert!(!game.trigger_flags[149]);
assert!(!game.object_active[90]);
assert_eq!(game.target_effect, 3);
assert_eq!(game.multiball_state, MultiballState::Ready);
assert_eq!(game.multiball_state, MultiballState::Unavailable);
}
#[test]