Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5f2aab005 | ||
|
|
d37d327a02 | ||
|
|
71e5450a84 | ||
|
|
26a2567c91 | ||
|
|
25147ad58e |
@@ -8,6 +8,18 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.2.1] - 2026-09-01
|
||||
|
||||
### Fixed
|
||||
|
||||
- Keep a ball captured by the Greifarm during multiball instead of consuming it;
|
||||
latch the two-ball capture rule at each entry, exclude the launcher release
|
||||
while that capture is active, and let later single-ball re-entries use all
|
||||
four original release choices.
|
||||
- Preserve that entry decision through the complete pull phase, scope it to the
|
||||
entering slot until that ball exits the capture zone, and migrate Greifarm
|
||||
state when a held secondary ball becomes the primary ball.
|
||||
|
||||
## [1.2.0] - 2026-08-31
|
||||
|
||||
### Added
|
||||
|
||||
Generated
+1
-1
@@ -551,7 +551,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tdkpin-rs"
|
||||
version = "1.2.0"
|
||||
version = "1.2.1"
|
||||
dependencies = [
|
||||
"directories",
|
||||
"futures-util",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "tdkpin-rs"
|
||||
version = "1.2.0"
|
||||
version = "1.2.1"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -901,7 +901,7 @@ impl App {
|
||||
);
|
||||
}
|
||||
}
|
||||
if !game.claw.ball_suspended {
|
||||
if !game.primary_ball_suspended() {
|
||||
draw_texture(
|
||||
&self.assets.ball,
|
||||
game.ball.position.x - 8.0,
|
||||
@@ -909,7 +909,9 @@ impl App {
|
||||
WHITE,
|
||||
);
|
||||
}
|
||||
if let Some(ball) = game.secondary_ball {
|
||||
if let Some(ball) = game.secondary_ball
|
||||
&& !game.secondary_ball_suspended()
|
||||
{
|
||||
draw_texture(
|
||||
&self.assets.ball,
|
||||
ball.position.x - 8.0,
|
||||
|
||||
+703
-38
@@ -46,6 +46,7 @@ const CLAW_FRAME_SECONDS: f32 = 0.030;
|
||||
const DETAIL_TIMER_SECONDS: [f32; 5] = [0.050, 0.040, 0.030, 0.020, 0.010];
|
||||
const DETAIL_SUBSTEPS: [u8; 5] = [5, 4, 3, 2, 1];
|
||||
const CLAW_TERMINAL_FRAMES: [u8; 4] = [1, 6, 7, 18];
|
||||
const CLAW_TABLE_TERMINAL_FRAMES: [u8; 3] = [1, 6, 7];
|
||||
const ORIGINAL_BALL_SPEED_PER_SECOND: f32 = 380.0;
|
||||
|
||||
#[allow(clippy::cast_possible_wrap)]
|
||||
@@ -129,12 +130,32 @@ pub enum ClawSpriteBank {
|
||||
Opening,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum ClawBallSlot {
|
||||
Primary,
|
||||
Secondary,
|
||||
}
|
||||
|
||||
impl ClawBallSlot {
|
||||
const fn from_ball_number(ball_number: u16) -> Self {
|
||||
if ball_number == 2 {
|
||||
Self::Secondary
|
||||
} else {
|
||||
Self::Primary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Claw {
|
||||
pub active: bool,
|
||||
pub frame: u8,
|
||||
pub bank: ClawSpriteBank,
|
||||
pub ball_suspended: bool,
|
||||
captured_slot: Option<ClawBallSlot>,
|
||||
// The deep zone is less than 16 px wide while ball contact is 17 px, so
|
||||
// only one physically non-overlapping ball can own a pending entry.
|
||||
capture_entry: Option<(ClawBallSlot, bool)>,
|
||||
target_frame: u8,
|
||||
frame_accumulator: f32,
|
||||
}
|
||||
@@ -146,6 +167,8 @@ impl Default for Claw {
|
||||
frame: 10,
|
||||
bank: ClawSpriteBank::Opening,
|
||||
ball_suspended: false,
|
||||
captured_slot: None,
|
||||
capture_entry: None,
|
||||
target_frame: 10,
|
||||
frame_accumulator: 0.0,
|
||||
}
|
||||
@@ -592,19 +615,23 @@ impl Game {
|
||||
self.update_target_rotation(events);
|
||||
}
|
||||
let panel_active = self.update_panel_completion(events);
|
||||
// The original collapses an inactive two-ball slot immediately before
|
||||
// the next physics pass. Capture removal does not clear multiball
|
||||
// scoring until that collapse, so the surviving slot retains double
|
||||
// scoring for the remainder of the callback in which it was captured.
|
||||
if self.secondary_ball.is_none() && self.score_mode == ScoreMode::Multiball {
|
||||
// A held claw ball still counts as the second live ball until the
|
||||
// other slot disappears or the claw releases it.
|
||||
if self.secondary_ball.is_none()
|
||||
&& self.claw.captured_slot != Some(ClawBallSlot::Secondary)
|
||||
&& self.score_mode == ScoreMode::Multiball
|
||||
{
|
||||
self.score_mode = ScoreMode::Normal;
|
||||
}
|
||||
if !self.claw.ball_suspended && !panel_active {
|
||||
if !panel_active {
|
||||
let had_secondary_ball = self.secondary_ball.is_some();
|
||||
let mut spawned_secondary = None;
|
||||
let primary_held = self.claw_holds_slot(ClawBallSlot::Primary);
|
||||
let secondary_held = self.claw_holds_slot(ClawBallSlot::Secondary);
|
||||
if had_secondary_ball {
|
||||
self.score_mode = ScoreMode::Multiball;
|
||||
}
|
||||
if !primary_held {
|
||||
for _ in 0..substeps {
|
||||
let stop = self.fixed_update(events);
|
||||
// Effect seven publishes its spawn request during record 149,
|
||||
@@ -613,18 +640,21 @@ impl Game {
|
||||
if !had_secondary_ball && spawned_secondary.is_none() {
|
||||
spawned_secondary = self.secondary_ball.take();
|
||||
}
|
||||
if stop || self.finished || self.claw.ball_suspended {
|
||||
if stop || self.finished || self.claw_holds_slot(ClawBallSlot::Primary) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if had_secondary_ball && !self.finished && !self.claw.ball_suspended {
|
||||
if self.secondary_ball.is_some() {
|
||||
}
|
||||
if had_secondary_ball && !self.finished {
|
||||
if self.secondary_ball.is_some() && !self.claw_holds_slot(ClawBallSlot::Secondary) {
|
||||
for _ in 0..substeps {
|
||||
if self.advance_secondary_ball(events) {
|
||||
if self.advance_secondary_ball_slot(events, !primary_held) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
} else if !self.claw_holds_slot(ClawBallSlot::Primary)
|
||||
&& !self.claw_holds_slot(ClawBallSlot::Secondary)
|
||||
{
|
||||
self.secondary_ball = Some(self.ball);
|
||||
for _ in 0..substeps {
|
||||
if self.advance_secondary_ball_slot(events, false) {
|
||||
@@ -642,6 +672,10 @@ impl Game {
|
||||
debug_assert!(self.secondary_ball.is_none());
|
||||
self.secondary_ball = Some(spawned);
|
||||
}
|
||||
|
||||
// The two slots are mutually exclusive while the claw is holding
|
||||
// a ball.
|
||||
debug_assert!(!(primary_held && secondary_held));
|
||||
}
|
||||
|
||||
self.pending_flipper_edges[0] =
|
||||
@@ -759,6 +793,8 @@ impl Game {
|
||||
}
|
||||
Nudge::None => return,
|
||||
}
|
||||
let primary_held = self.claw_holds_slot(ClawBallSlot::Primary);
|
||||
let secondary_held = self.claw_holds_slot(ClawBallSlot::Secondary);
|
||||
if let Some(secondary) = &mut self.secondary_ball {
|
||||
let slot_impulse = match nudge {
|
||||
Nudge::Left | Nudge::Right => MilliVec {
|
||||
@@ -778,12 +814,16 @@ impl Game {
|
||||
let mut primary_slot = MilliVec::from_velocity_per_second(self.ball.velocity);
|
||||
primary_slot.x = primary_slot.x.wrapping_add(slot_impulse.x);
|
||||
primary_slot.y = primary_slot.y.wrapping_add(slot_impulse.y);
|
||||
if !primary_held {
|
||||
self.ball.velocity = primary_slot.to_velocity_per_second();
|
||||
}
|
||||
let mut secondary_slot = MilliVec::from_velocity_per_second(secondary.velocity);
|
||||
secondary_slot.x = secondary_slot.x.wrapping_add(slot_impulse.x);
|
||||
secondary_slot.y = secondary_slot.y.wrapping_add(slot_impulse.y);
|
||||
if !secondary_held {
|
||||
secondary.velocity = secondary_slot.to_velocity_per_second();
|
||||
} else {
|
||||
}
|
||||
} else if !primary_held {
|
||||
self.ball.velocity = velocity.to_velocity_per_second();
|
||||
}
|
||||
|
||||
@@ -832,8 +872,13 @@ impl Game {
|
||||
);
|
||||
let mut best_collision = best_static
|
||||
.map(|(id, is_wall, candidate)| (id, is_wall, candidate.resolve(velocity, ball.spin)));
|
||||
let primary_held = self.claw_holds_slot(ClawBallSlot::Primary);
|
||||
let secondary_held = self.claw_holds_slot(ClawBallSlot::Secondary);
|
||||
let mut transferred_secondary_velocity = None;
|
||||
if let Some(secondary) = initial_secondary {
|
||||
if let Some(secondary) = initial_secondary
|
||||
&& !primary_held
|
||||
&& !secondary_held
|
||||
{
|
||||
let other_position = MilliVec::from_position(secondary.position);
|
||||
let response = ball_collision_response(
|
||||
old_position,
|
||||
@@ -876,6 +921,8 @@ impl Game {
|
||||
if object_id == 175
|
||||
&& let (Some(secondary), Some(transferred)) =
|
||||
(&mut self.secondary_ball, transferred_secondary_velocity)
|
||||
&& !secondary_held
|
||||
&& !primary_held
|
||||
{
|
||||
secondary.velocity = transferred.to_velocity_per_second();
|
||||
}
|
||||
@@ -944,6 +991,7 @@ impl Game {
|
||||
.secondary_ball
|
||||
.take()
|
||||
.expect("a multiball capture must leave the other slot active");
|
||||
self.promote_claw_secondary_to_primary();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1008,12 +1056,12 @@ impl Game {
|
||||
&mut capture_candidate,
|
||||
events,
|
||||
) {
|
||||
CaptureStep::Complete if ball_count == 1 => {
|
||||
let terminal_frame = self.next_claw_terminal_frame();
|
||||
self.begin_claw_capture_after_hold(ball, terminal_frame, events);
|
||||
CaptureStep::Complete => {
|
||||
let terminal_frame = self
|
||||
.next_claw_terminal_frame(self.claw_capture_restricted_for(ball_number));
|
||||
self.begin_claw_capture_after_hold(ball, ball_number, terminal_frame, events);
|
||||
action = BallAction::Suspend;
|
||||
}
|
||||
CaptureStep::Complete => action = BallAction::Remove,
|
||||
CaptureStep::Outside | CaptureStep::Holding => {}
|
||||
}
|
||||
*velocity = MilliVec::from_velocity_per_second(ball.velocity);
|
||||
@@ -1227,8 +1275,9 @@ impl Game {
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn advance_secondary_ball(&mut self, events: &mut Vec<Event>) -> bool {
|
||||
self.advance_secondary_ball_slot(events, true)
|
||||
self.advance_secondary_ball_slot(events, !self.claw_holds_slot(ClawBallSlot::Primary))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
@@ -1267,6 +1316,8 @@ impl Game {
|
||||
let mut transferred_primary_velocity = None;
|
||||
let other_position = MilliVec::from_position(primary_position);
|
||||
if primary_slot_active
|
||||
&& !self.claw_holds_slot(ClawBallSlot::Primary)
|
||||
&& !self.claw_holds_slot(ClawBallSlot::Secondary)
|
||||
&& predicted.x >= other_position.x.wrapping_sub(21_000)
|
||||
&& predicted.x <= other_position.x.wrapping_add(21_000)
|
||||
&& predicted.y >= other_position.y.wrapping_sub(21_000)
|
||||
@@ -1556,13 +1607,10 @@ impl Game {
|
||||
) {
|
||||
CaptureStep::Holding | CaptureStep::Outside => {}
|
||||
CaptureStep::Complete => {
|
||||
if ball_count == 1 {
|
||||
let terminal_frame = self.next_claw_terminal_frame();
|
||||
self.begin_claw_capture_after_hold(ball, terminal_frame, events);
|
||||
let terminal_frame = self
|
||||
.next_claw_terminal_frame(self.claw_capture_restricted_for(ball_number));
|
||||
self.begin_claw_capture_after_hold(ball, ball_number, terminal_frame, events);
|
||||
action = BallAction::Suspend;
|
||||
} else {
|
||||
action = BallAction::Remove;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1595,7 +1643,11 @@ impl Game {
|
||||
SensorScanResult { action }
|
||||
}
|
||||
|
||||
#[allow(clippy::cast_possible_truncation, clippy::too_many_arguments)]
|
||||
#[allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::too_many_arguments,
|
||||
clippy::too_many_lines
|
||||
)]
|
||||
fn capture_record_step(
|
||||
&mut self,
|
||||
ball: &mut Ball,
|
||||
@@ -1620,6 +1672,12 @@ impl Game {
|
||||
|| predicted_position.y < center.y.wrapping_sub(broadphase_margin)
|
||||
|| predicted_position.y > center.y.wrapping_add(broadphase_margin)
|
||||
{
|
||||
if record_id == 89
|
||||
&& ball.capture_age == 0
|
||||
&& self.record_contacts[usize::from(record_id)] == 0
|
||||
{
|
||||
self.clear_claw_capture_entry_for(ball_number);
|
||||
}
|
||||
return CaptureStep::Outside;
|
||||
}
|
||||
let dx = center.x.wrapping_sub(current_position.x);
|
||||
@@ -1637,6 +1695,13 @@ impl Game {
|
||||
&& contact_allowed
|
||||
&& !(record_id == 148 && self.special_hole_gate == SpecialHoleGate::Suppressed)
|
||||
{
|
||||
if record_id == 89 && ball.capture_age == 0 && self.claw.capture_entry.is_none() {
|
||||
// Latch the restriction when the ball first enters the deep
|
||||
// capture zone, before the pull/hold phase can outlive the
|
||||
// other active ball.
|
||||
self.claw.capture_entry =
|
||||
Some((ClawBallSlot::from_ball_number(ball_number), ball_count > 1));
|
||||
}
|
||||
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
|
||||
@@ -1679,13 +1744,21 @@ impl Game {
|
||||
return CaptureStep::Complete;
|
||||
}
|
||||
ball.capture_age = 0;
|
||||
self.record_contacts[contact_index] = if record_id == 148 { 2 } else { 99 };
|
||||
if ball_count > 1 {
|
||||
self.record_contacts[contact_index] = 2;
|
||||
}
|
||||
self.record_contacts[contact_index] = if record_id == 89 && ball_count > 1 {
|
||||
// Keep ownership with the held slot so its later release can
|
||||
// clear the claw contact before a fresh entry.
|
||||
ball_number
|
||||
} else if record_id == 148 || ball_count > 1 {
|
||||
2
|
||||
} else {
|
||||
99
|
||||
};
|
||||
return CaptureStep::Complete;
|
||||
}
|
||||
|
||||
if record_id == 89 && surface_distance >= -11_000 {
|
||||
self.clear_claw_capture_entry_for(ball_number);
|
||||
}
|
||||
if surface_distance > -11_000 && contact == ball_number {
|
||||
self.record_contacts[contact_index] = 0;
|
||||
ball.capture_age = 0;
|
||||
@@ -2052,9 +2125,55 @@ impl Game {
|
||||
self.target_effect.min(7)
|
||||
}
|
||||
|
||||
fn next_claw_terminal_frame(&mut self) -> u8 {
|
||||
fn next_claw_terminal_frame(&mut self, restricted: bool) -> u8 {
|
||||
if restricted {
|
||||
// Frame 18 sends the released ball down the shooter lane. During
|
||||
// a capture that began with two live balls, keep it on the table.
|
||||
CLAW_TABLE_TERMINAL_FRAMES[usize::from(self.random.below(3))]
|
||||
} else {
|
||||
CLAW_TERMINAL_FRAMES[usize::from(self.random.below(4))]
|
||||
}
|
||||
}
|
||||
|
||||
fn claw_holds_slot(&self, slot: ClawBallSlot) -> bool {
|
||||
self.claw.ball_suspended && self.claw.captured_slot == Some(slot)
|
||||
}
|
||||
|
||||
fn claw_capture_restricted_for(&self, ball_number: u16) -> bool {
|
||||
self.claw.capture_entry == Some((ClawBallSlot::from_ball_number(ball_number), true))
|
||||
}
|
||||
|
||||
fn clear_claw_capture_entry_for(&mut self, ball_number: u16) {
|
||||
let slot = ClawBallSlot::from_ball_number(ball_number);
|
||||
if self
|
||||
.claw
|
||||
.capture_entry
|
||||
.is_some_and(|(owner, _)| owner == slot)
|
||||
{
|
||||
self.claw.capture_entry = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn promote_claw_secondary_to_primary(&mut self) {
|
||||
if self.claw.captured_slot == Some(ClawBallSlot::Secondary) {
|
||||
self.claw.captured_slot = Some(ClawBallSlot::Primary);
|
||||
if let Some((ClawBallSlot::Secondary, restricted)) = self.claw.capture_entry {
|
||||
self.claw.capture_entry = Some((ClawBallSlot::Primary, restricted));
|
||||
}
|
||||
if self.record_contacts[89] == 2 {
|
||||
self.record_contacts[89] = 1;
|
||||
}
|
||||
self.score_mode = ScoreMode::Normal;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn primary_ball_suspended(&self) -> bool {
|
||||
self.claw_holds_slot(ClawBallSlot::Primary)
|
||||
}
|
||||
|
||||
pub fn secondary_ball_suspended(&self) -> bool {
|
||||
self.claw_holds_slot(ClawBallSlot::Secondary)
|
||||
}
|
||||
|
||||
fn apply_flipper_kicks(&mut self) {
|
||||
let pending = self.pending_flipper_edges;
|
||||
@@ -2062,6 +2181,8 @@ impl Game {
|
||||
if self.ball.in_launcher || self.tilted {
|
||||
return;
|
||||
}
|
||||
let primary_held = self.claw_holds_slot(ClawBallSlot::Primary);
|
||||
let secondary_held = self.claw_holds_slot(ClawBallSlot::Secondary);
|
||||
|
||||
for (delta, side) in [
|
||||
(i32::from(pending[0]), FlipperSide::Left),
|
||||
@@ -2070,8 +2191,12 @@ impl Game {
|
||||
if delta == 0 {
|
||||
continue;
|
||||
}
|
||||
if !primary_held {
|
||||
apply_flipper_response_to_ball(&mut self.ball, delta, side);
|
||||
if let Some(secondary) = &mut self.secondary_ball {
|
||||
}
|
||||
if let Some(secondary) = &mut self.secondary_ball
|
||||
&& !secondary_held
|
||||
{
|
||||
apply_flipper_response_to_ball(secondary, delta, side);
|
||||
}
|
||||
}
|
||||
@@ -2087,6 +2212,8 @@ impl Game {
|
||||
self.claw.target_frame = terminal_frame;
|
||||
self.claw.bank = ClawSpriteBank::Closing;
|
||||
self.claw.ball_suspended = true;
|
||||
self.claw.captured_slot = Some(ClawBallSlot::Primary);
|
||||
self.claw.capture_entry = None;
|
||||
self.claw.frame_accumulator = 0.0;
|
||||
self.ball.velocity = Vec2::ZERO;
|
||||
self.ball.spin = Real48::ZERO;
|
||||
@@ -2097,6 +2224,7 @@ impl Game {
|
||||
fn begin_claw_capture_after_hold(
|
||||
&mut self,
|
||||
ball: &mut Ball,
|
||||
ball_number: u16,
|
||||
terminal_frame: u8,
|
||||
events: &mut Vec<Event>,
|
||||
) {
|
||||
@@ -2108,6 +2236,7 @@ impl Game {
|
||||
self.claw.target_frame = terminal_frame;
|
||||
self.claw.bank = ClawSpriteBank::Closing;
|
||||
self.claw.ball_suspended = true;
|
||||
self.claw.captured_slot = Some(ClawBallSlot::from_ball_number(ball_number));
|
||||
self.claw.frame_accumulator = 0.0;
|
||||
ball.velocity = Vec2::ZERO;
|
||||
ball.spin = Real48::ZERO;
|
||||
@@ -2115,7 +2244,7 @@ impl Game {
|
||||
}
|
||||
|
||||
fn update_claw(&mut self, dt: f32, events: &mut Vec<Event>) {
|
||||
if !self.claw.active || self.score_mode == ScoreMode::Multiball {
|
||||
if !self.claw.active {
|
||||
return;
|
||||
}
|
||||
self.claw.frame_accumulator += dt;
|
||||
@@ -2145,7 +2274,24 @@ impl Game {
|
||||
let release_frame = self.claw.frame;
|
||||
self.claw.target_frame = 10;
|
||||
self.claw.bank = ClawSpriteBank::Opening;
|
||||
(self.ball.position, self.ball.velocity) = claw_release(release_frame);
|
||||
let release = claw_release(release_frame);
|
||||
match self.claw.captured_slot {
|
||||
Some(ClawBallSlot::Primary) => {
|
||||
(self.ball.position, self.ball.velocity) = release;
|
||||
}
|
||||
Some(ClawBallSlot::Secondary) => {
|
||||
if let Some(ball) = &mut self.secondary_ball {
|
||||
(ball.position, ball.velocity) = release;
|
||||
} else {
|
||||
(self.ball.position, self.ball.velocity) = release;
|
||||
self.promote_claw_secondary_to_primary();
|
||||
}
|
||||
}
|
||||
None => {
|
||||
(self.ball.position, self.ball.velocity) = release;
|
||||
self.claw.captured_slot = Some(ClawBallSlot::Primary);
|
||||
}
|
||||
}
|
||||
self.claw.ball_suspended = false;
|
||||
events.push(Event::ClawRelease);
|
||||
events.push(Event::Sound(2016));
|
||||
@@ -2180,6 +2326,15 @@ impl Game {
|
||||
}
|
||||
|
||||
fn drain(&mut self, events: &mut Vec<Event>) {
|
||||
if self.claw_holds_slot(ClawBallSlot::Secondary) {
|
||||
self.ball = self
|
||||
.secondary_ball
|
||||
.take()
|
||||
.expect("the claw-held secondary ball must remain in slot two");
|
||||
self.promote_claw_secondary_to_primary();
|
||||
events.push(Event::Drain);
|
||||
return;
|
||||
}
|
||||
if let Some(remaining_ball) = self.secondary_ball.take() {
|
||||
self.ball = remaining_ball;
|
||||
self.score_mode = ScoreMode::Normal;
|
||||
@@ -3496,20 +3651,18 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_multiball_pauses_the_returning_claw_until_slot_collapse() {
|
||||
fn active_multiball_advances_the_returning_claw_while_both_balls_live() {
|
||||
let mut game = Game::new(1);
|
||||
game.claw.active = true;
|
||||
game.claw.frame = 1;
|
||||
game.claw.target_frame = 10;
|
||||
game.claw.bank = ClawSpriteBank::Opening;
|
||||
game.claw.frame_accumulator = 0.0;
|
||||
game.claw.ball_suspended = true;
|
||||
game.claw.captured_slot = Some(ClawBallSlot::Primary);
|
||||
game.score_mode = ScoreMode::Multiball;
|
||||
let mut events = Vec::new();
|
||||
|
||||
game.update_claw(CLAW_FRAME_SECONDS, &mut events);
|
||||
assert_eq!(game.claw.frame, 1);
|
||||
|
||||
game.score_mode = ScoreMode::Normal;
|
||||
game.update_claw(CLAW_FRAME_SECONDS, &mut events);
|
||||
assert_eq!(game.claw.frame, 2);
|
||||
}
|
||||
@@ -4365,6 +4518,518 @@ mod tests {
|
||||
assert!(!game.claw.active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiball_claw_capture_keeps_the_ball_and_uses_only_table_releases() {
|
||||
let mut game = Game::new_with_seed(1, 7);
|
||||
game.ball.in_launcher = false;
|
||||
game.ball.position = CLAW_TRIGGER_CENTER;
|
||||
game.ball.velocity = Vec2::ZERO;
|
||||
game.secondary_ball = Some(Ball {
|
||||
position: vec2(210.0, 240.0),
|
||||
velocity: vec2(12.0, -34.0),
|
||||
in_launcher: false,
|
||||
spin: Real48::ZERO,
|
||||
capture_age: 0,
|
||||
});
|
||||
game.object_active.fill(false);
|
||||
game.score_mode = ScoreMode::Multiball;
|
||||
let claw_position = MilliVec::from_position(CLAW_TRIGGER_CENTER);
|
||||
let mut events = Vec::new();
|
||||
|
||||
assert_eq!(
|
||||
game.check_sensor_objects(claw_position, MilliVec::default(), &mut events),
|
||||
BallAction::Keep
|
||||
);
|
||||
assert_eq!(game.claw.capture_entry, Some((ClawBallSlot::Primary, true)));
|
||||
|
||||
assert_eq!(
|
||||
game.check_sensor_objects(claw_position, MilliVec::default(), &mut events),
|
||||
BallAction::Suspend
|
||||
);
|
||||
assert!(game.claw.active);
|
||||
assert!(game.primary_ball_suspended());
|
||||
assert!(!game.secondary_ball_suspended());
|
||||
assert!(matches!(game.claw.target_frame, 1 | 6 | 7));
|
||||
assert_eq!(game.record_contacts[89], 1);
|
||||
|
||||
let survivor_before = game.secondary_ball.expect("survivor must remain").position;
|
||||
game.timer_tick(CLAW_FRAME_SECONDS, 1, &mut events);
|
||||
assert_ne!(
|
||||
game.secondary_ball.expect("survivor must remain").position,
|
||||
survivor_before
|
||||
);
|
||||
|
||||
while game.claw.active {
|
||||
game.update_claw(CLAW_FRAME_SECONDS, &mut events);
|
||||
}
|
||||
assert!(events.contains(&Event::ClawRelease));
|
||||
assert!(!game.primary_ball_suspended());
|
||||
assert!(game.secondary_ball.is_some());
|
||||
|
||||
game.ball.position = CLAW_TRIGGER_CENTER + vec2(20.0, 0.0);
|
||||
game.ball.velocity = Vec2::ZERO;
|
||||
game.ball.capture_age = 0;
|
||||
assert_eq!(
|
||||
game.check_sensor_objects(
|
||||
MilliVec::from_position(game.ball.position),
|
||||
MilliVec::default(),
|
||||
&mut events,
|
||||
),
|
||||
BallAction::Keep
|
||||
);
|
||||
assert_eq!(game.record_contacts[89], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiball_claw_restriction_is_latched_before_the_other_ball_drains() {
|
||||
let mut game = Game::new_with_seed(1, 7);
|
||||
game.ball.in_launcher = false;
|
||||
game.ball.position = CLAW_TRIGGER_CENTER;
|
||||
game.ball.velocity = vec2(100.0, 0.0);
|
||||
game.secondary_ball = Some(Ball {
|
||||
position: vec2(210.0, 240.0),
|
||||
velocity: Vec2::ZERO,
|
||||
in_launcher: false,
|
||||
spin: Real48::ZERO,
|
||||
capture_age: 0,
|
||||
});
|
||||
game.object_active.fill(false);
|
||||
game.score_mode = ScoreMode::Multiball;
|
||||
let claw_position = MilliVec::from_position(CLAW_TRIGGER_CENTER);
|
||||
let mut events = Vec::new();
|
||||
|
||||
assert_eq!(
|
||||
game.check_sensor_objects(claw_position, MilliVec::default(), &mut events),
|
||||
BallAction::Keep
|
||||
);
|
||||
game.secondary_ball = None;
|
||||
game.score_mode = ScoreMode::Normal;
|
||||
|
||||
game.ball.velocity = vec2(100.0, 0.0);
|
||||
assert_eq!(
|
||||
game.check_sensor_objects(claw_position, MilliVec::default(), &mut events),
|
||||
BallAction::Keep
|
||||
);
|
||||
assert_eq!(game.claw.capture_entry, Some((ClawBallSlot::Primary, true)));
|
||||
|
||||
game.ball.velocity = Vec2::ZERO;
|
||||
game.ball.capture_age = 300;
|
||||
|
||||
assert_eq!(
|
||||
game.check_sensor_objects(claw_position, MilliVec::default(), &mut events),
|
||||
BallAction::Suspend
|
||||
);
|
||||
assert_eq!(game.claw.capture_entry, Some((ClawBallSlot::Primary, true)));
|
||||
assert!(matches!(game.claw.target_frame, 1 | 6 | 7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_multiball_slot_cannot_clear_the_claw_entry_latch() {
|
||||
let mut game = Game::new_with_seed(1, 7);
|
||||
game.ball.in_launcher = false;
|
||||
game.ball.position = CLAW_TRIGGER_CENTER;
|
||||
game.ball.velocity = vec2(100.0, 0.0);
|
||||
game.secondary_ball = Some(Ball {
|
||||
position: vec2(210.0, 100.0),
|
||||
velocity: Vec2::ZERO,
|
||||
in_launcher: false,
|
||||
spin: Real48::ZERO,
|
||||
capture_age: 0,
|
||||
});
|
||||
game.object_active.fill(false);
|
||||
game.score_mode = ScoreMode::Multiball;
|
||||
let mut events = Vec::new();
|
||||
|
||||
assert_eq!(
|
||||
game.check_sensor_objects(
|
||||
MilliVec::from_position(CLAW_TRIGGER_CENTER),
|
||||
MilliVec::default(),
|
||||
&mut events,
|
||||
),
|
||||
BallAction::Keep
|
||||
);
|
||||
assert_eq!(game.claw.capture_entry, Some((ClawBallSlot::Primary, true)));
|
||||
|
||||
let mut secondary = game.secondary_ball.take().expect("secondary ball");
|
||||
let secondary_position = MilliVec::from_position(secondary.position);
|
||||
assert_eq!(
|
||||
game.check_sensor_objects_for_ball(
|
||||
&mut secondary,
|
||||
2,
|
||||
2,
|
||||
secondary_position,
|
||||
MilliVec::default(),
|
||||
&mut events,
|
||||
)
|
||||
.action,
|
||||
BallAction::Keep
|
||||
);
|
||||
game.secondary_ball = Some(secondary);
|
||||
assert_eq!(game.claw.capture_entry, Some((ClawBallSlot::Primary, true)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shallow_claw_exit_clears_only_the_owning_entry_latch() {
|
||||
let mut game = Game::new_with_seed(1, 7);
|
||||
game.ball.in_launcher = false;
|
||||
game.ball.position = CLAW_TRIGGER_CENTER;
|
||||
game.ball.velocity = vec2(100.0, 0.0);
|
||||
game.secondary_ball = Some(Ball {
|
||||
position: vec2(210.0, 240.0),
|
||||
velocity: Vec2::ZERO,
|
||||
in_launcher: false,
|
||||
spin: Real48::ZERO,
|
||||
capture_age: 0,
|
||||
});
|
||||
game.object_active.fill(false);
|
||||
game.score_mode = ScoreMode::Multiball;
|
||||
let mut events = Vec::new();
|
||||
|
||||
assert_eq!(
|
||||
game.check_sensor_objects(
|
||||
MilliVec::from_position(CLAW_TRIGGER_CENTER),
|
||||
MilliVec::default(),
|
||||
&mut events,
|
||||
),
|
||||
BallAction::Keep
|
||||
);
|
||||
assert_eq!(game.claw.capture_entry, Some((ClawBallSlot::Primary, true)));
|
||||
|
||||
game.ball.position = CLAW_TRIGGER_CENTER + vec2(20.0, 0.0);
|
||||
game.ball.velocity = Vec2::ZERO;
|
||||
assert_eq!(
|
||||
game.check_sensor_objects(
|
||||
MilliVec::from_position(game.ball.position),
|
||||
MilliVec::default(),
|
||||
&mut events,
|
||||
),
|
||||
BallAction::Keep
|
||||
);
|
||||
assert_eq!(game.claw.capture_entry, None);
|
||||
|
||||
game.secondary_ball = None;
|
||||
game.score_mode = ScoreMode::Normal;
|
||||
game.ball.position = CLAW_TRIGGER_CENTER;
|
||||
game.ball.velocity = vec2(100.0, 0.0);
|
||||
assert_eq!(
|
||||
game.check_sensor_objects(
|
||||
MilliVec::from_position(CLAW_TRIGGER_CENTER),
|
||||
MilliVec::default(),
|
||||
&mut events,
|
||||
),
|
||||
BallAction::Keep
|
||||
);
|
||||
assert_eq!(
|
||||
game.claw.capture_entry,
|
||||
Some((ClawBallSlot::Primary, false))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonoverlapping_two_ball_claw_approaches_keep_capture_ownership() {
|
||||
// Exercise the sequential solver from non-overlapping cardinal
|
||||
// approaches around the sub-8 px deep zone. Ball contact begins at
|
||||
// 17 px, so a second ball must be resolved before both can settle.
|
||||
let capture_center = CLAW_TRIGGER_CENTER - vec2(0.0, 2.0);
|
||||
let directions = [
|
||||
vec2(1.0, 0.0),
|
||||
vec2(0.0, 1.0),
|
||||
vec2(-1.0, 0.0),
|
||||
vec2(0.0, -1.0),
|
||||
];
|
||||
let radii = [10.0, 18.0];
|
||||
let speeds = [100.0, 300.0];
|
||||
let mut captures = 0;
|
||||
|
||||
for first_direction in directions {
|
||||
for second_direction in directions {
|
||||
for first_radius in radii {
|
||||
for second_radius in radii {
|
||||
let first_position = capture_center + first_direction * first_radius;
|
||||
let second_position = capture_center + second_direction * second_radius;
|
||||
if first_position.distance(second_position) < 17.0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
for first_speed in speeds {
|
||||
for second_speed in speeds {
|
||||
let mut game = Game::new_with_seed(1, 7);
|
||||
game.ball = Ball {
|
||||
position: first_position,
|
||||
velocity: (capture_center - first_position).normalize()
|
||||
* first_speed,
|
||||
in_launcher: false,
|
||||
spin: Real48::ZERO,
|
||||
capture_age: 0,
|
||||
};
|
||||
game.secondary_ball = Some(Ball {
|
||||
position: second_position,
|
||||
velocity: (capture_center - second_position).normalize()
|
||||
* second_speed,
|
||||
in_launcher: false,
|
||||
spin: Real48::ZERO,
|
||||
capture_age: 0,
|
||||
});
|
||||
game.object_active.fill(false);
|
||||
game.score_mode = ScoreMode::Multiball;
|
||||
let mut events = Vec::new();
|
||||
|
||||
for _ in 0..100 {
|
||||
game.timer_tick(0.01, 1, &mut events);
|
||||
if game.claw.active {
|
||||
let Some((owner, true)) = game.claw.capture_entry else {
|
||||
break;
|
||||
};
|
||||
captures += 1;
|
||||
assert_eq!(
|
||||
Some(owner),
|
||||
game.claw.captured_slot,
|
||||
"positions {first_position:?}/{second_position:?}, speeds {first_speed}/{second_speed}"
|
||||
);
|
||||
break;
|
||||
}
|
||||
if game.secondary_ball.is_none() || game.finished {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(captures > 0, "the sweep must exercise claw captures");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secondary_multiball_ball_can_be_held_by_the_claw_without_hiding_primary() {
|
||||
let mut game = Game::new_with_seed(1, 7);
|
||||
game.ball.in_launcher = false;
|
||||
game.ball.position = vec2(210.0, 240.0);
|
||||
game.ball.velocity = Vec2::ZERO;
|
||||
game.secondary_ball = Some(Ball {
|
||||
position: CLAW_TRIGGER_CENTER,
|
||||
velocity: Vec2::ZERO,
|
||||
in_launcher: false,
|
||||
spin: Real48::ZERO,
|
||||
capture_age: 0,
|
||||
});
|
||||
game.object_active.fill(false);
|
||||
game.score_mode = ScoreMode::Multiball;
|
||||
let claw_position = MilliVec::from_position(CLAW_TRIGGER_CENTER);
|
||||
let mut events = Vec::new();
|
||||
let mut secondary = game.secondary_ball.take().expect("secondary ball");
|
||||
|
||||
assert_eq!(
|
||||
game.check_sensor_objects_for_ball(
|
||||
&mut secondary,
|
||||
2,
|
||||
2,
|
||||
claw_position,
|
||||
MilliVec::default(),
|
||||
&mut events,
|
||||
)
|
||||
.action,
|
||||
BallAction::Keep
|
||||
);
|
||||
game.secondary_ball = Some(secondary);
|
||||
let mut secondary = game.secondary_ball.take().expect("secondary ball");
|
||||
assert_eq!(
|
||||
game.check_sensor_objects_for_ball(
|
||||
&mut secondary,
|
||||
2,
|
||||
2,
|
||||
claw_position,
|
||||
MilliVec::default(),
|
||||
&mut events,
|
||||
)
|
||||
.action,
|
||||
BallAction::Suspend
|
||||
);
|
||||
game.secondary_ball = Some(secondary);
|
||||
|
||||
assert!(!game.primary_ball_suspended());
|
||||
assert!(game.secondary_ball_suspended());
|
||||
assert!(matches!(game.claw.target_frame, 1 | 6 | 7));
|
||||
let primary_before = game.ball.position;
|
||||
game.timer_tick(CLAW_FRAME_SECONDS, 1, &mut events);
|
||||
assert_ne!(game.ball.position, primary_before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn held_claw_ball_survives_the_other_ball_draining() {
|
||||
let mut game = Game::new_with_seed(1, 7);
|
||||
game.ball.in_launcher = false;
|
||||
game.ball.position = CLAW_TRIGGER_CENTER;
|
||||
game.ball.velocity = Vec2::ZERO;
|
||||
game.secondary_ball = Some(Ball {
|
||||
position: vec2(340.0, 100.0),
|
||||
velocity: vec2(1.0, 0.0),
|
||||
in_launcher: false,
|
||||
spin: Real48::ZERO,
|
||||
capture_age: 0,
|
||||
});
|
||||
game.object_active.fill(false);
|
||||
game.score_mode = ScoreMode::Multiball;
|
||||
let claw_position = MilliVec::from_position(CLAW_TRIGGER_CENTER);
|
||||
let mut events = Vec::new();
|
||||
assert_eq!(
|
||||
game.check_sensor_objects(claw_position, MilliVec::default(), &mut events),
|
||||
BallAction::Keep
|
||||
);
|
||||
assert_eq!(
|
||||
game.check_sensor_objects(claw_position, MilliVec::default(), &mut events),
|
||||
BallAction::Suspend
|
||||
);
|
||||
let balls_before = game.player().balls;
|
||||
|
||||
game.timer_tick(CLAW_FRAME_SECONDS, 1, &mut events);
|
||||
assert!(game.secondary_ball.is_none());
|
||||
assert_eq!(game.player().balls, balls_before);
|
||||
assert_eq!(game.score_mode, ScoreMode::Normal);
|
||||
assert!(game.claw.ball_suspended);
|
||||
|
||||
while game.claw.ball_suspended {
|
||||
game.update_claw(CLAW_FRAME_SECONDS, &mut events);
|
||||
}
|
||||
assert!(events.contains(&Event::ClawRelease));
|
||||
assert!(!game.ball.in_launcher);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn held_secondary_claw_ball_is_promoted_if_primary_drains() {
|
||||
let mut game = Game::new_with_seed(1, 7);
|
||||
game.ball.in_launcher = false;
|
||||
game.ball.position = vec2(340.0, 100.0);
|
||||
game.ball.velocity = vec2(1.0, 0.0);
|
||||
game.secondary_ball = Some(Ball {
|
||||
position: CLAW_TRIGGER_CENTER,
|
||||
velocity: Vec2::ZERO,
|
||||
in_launcher: false,
|
||||
spin: Real48::ZERO,
|
||||
capture_age: 0,
|
||||
});
|
||||
game.object_active.fill(false);
|
||||
game.score_mode = ScoreMode::Multiball;
|
||||
let claw_position = MilliVec::from_position(CLAW_TRIGGER_CENTER);
|
||||
let mut events = Vec::new();
|
||||
let mut secondary = game.secondary_ball.take().expect("secondary ball");
|
||||
assert_eq!(
|
||||
game.check_sensor_objects_for_ball(
|
||||
&mut secondary,
|
||||
2,
|
||||
2,
|
||||
claw_position,
|
||||
MilliVec::default(),
|
||||
&mut events,
|
||||
)
|
||||
.action,
|
||||
BallAction::Keep
|
||||
);
|
||||
game.secondary_ball = Some(secondary);
|
||||
let mut secondary = game.secondary_ball.take().expect("secondary ball");
|
||||
assert_eq!(
|
||||
game.check_sensor_objects_for_ball(
|
||||
&mut secondary,
|
||||
2,
|
||||
2,
|
||||
claw_position,
|
||||
MilliVec::default(),
|
||||
&mut events,
|
||||
)
|
||||
.action,
|
||||
BallAction::Suspend
|
||||
);
|
||||
game.secondary_ball = Some(secondary);
|
||||
assert_eq!(game.record_contacts[89], 2);
|
||||
assert_eq!(
|
||||
game.claw.capture_entry,
|
||||
Some((ClawBallSlot::Secondary, true))
|
||||
);
|
||||
|
||||
game.timer_tick(CLAW_FRAME_SECONDS, 1, &mut events);
|
||||
assert!(game.secondary_ball.is_none());
|
||||
assert!(game.primary_ball_suspended());
|
||||
assert_eq!(game.record_contacts[89], 1);
|
||||
assert_eq!(game.claw.capture_entry, Some((ClawBallSlot::Primary, true)));
|
||||
assert_eq!(game.score_mode, ScoreMode::Normal);
|
||||
assert!(events.contains(&Event::Drain));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrestricted_claw_capture_retains_the_launcher_release_choice() {
|
||||
let mut saw_launcher_release = false;
|
||||
for seed in 0..64 {
|
||||
let mut game = Game::new_with_seed(1, seed);
|
||||
if game.next_claw_terminal_frame(false) == 18 {
|
||||
saw_launcher_release = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(saw_launcher_release);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claw_reentry_after_multiball_uses_the_current_ball_count() {
|
||||
let mut game = Game::new_with_seed(1, 7);
|
||||
game.ball.in_launcher = false;
|
||||
game.ball.position = CLAW_TRIGGER_CENTER;
|
||||
game.ball.velocity = Vec2::ZERO;
|
||||
game.secondary_ball = Some(Ball {
|
||||
position: vec2(210.0, 240.0),
|
||||
velocity: Vec2::ZERO,
|
||||
in_launcher: false,
|
||||
spin: Real48::ZERO,
|
||||
capture_age: 0,
|
||||
});
|
||||
game.object_active.fill(false);
|
||||
game.score_mode = ScoreMode::Multiball;
|
||||
let claw_position = MilliVec::from_position(CLAW_TRIGGER_CENTER);
|
||||
let mut events = Vec::new();
|
||||
assert_eq!(
|
||||
game.check_sensor_objects(claw_position, MilliVec::default(), &mut events),
|
||||
BallAction::Keep
|
||||
);
|
||||
assert_eq!(
|
||||
game.check_sensor_objects(claw_position, MilliVec::default(), &mut events),
|
||||
BallAction::Suspend
|
||||
);
|
||||
assert_eq!(game.claw.capture_entry, Some((ClawBallSlot::Primary, true)));
|
||||
|
||||
game.secondary_ball = None;
|
||||
game.score_mode = ScoreMode::Normal;
|
||||
while game.claw.active {
|
||||
game.update_claw(CLAW_FRAME_SECONDS, &mut events);
|
||||
}
|
||||
|
||||
let unrestricted_seed = (0..64)
|
||||
.find(|seed| {
|
||||
let mut candidate = Game::new_with_seed(1, *seed);
|
||||
candidate.next_claw_terminal_frame(false) == 18
|
||||
})
|
||||
.expect("a seed must select the launcher release");
|
||||
game.random = BorlandRandom::new(unrestricted_seed);
|
||||
game.ball.position = CLAW_TRIGGER_CENTER;
|
||||
game.ball.velocity = Vec2::ZERO;
|
||||
game.ball.capture_age = 0;
|
||||
game.record_contacts[89] = 0;
|
||||
|
||||
assert_eq!(
|
||||
game.check_sensor_objects(claw_position, MilliVec::default(), &mut events),
|
||||
BallAction::Keep
|
||||
);
|
||||
assert_eq!(
|
||||
game.claw.capture_entry,
|
||||
Some((ClawBallSlot::Primary, false))
|
||||
);
|
||||
assert_eq!(
|
||||
game.check_sensor_objects(claw_position, MilliVec::default(), &mut events),
|
||||
BallAction::Suspend
|
||||
);
|
||||
assert_eq!(game.claw.target_frame, 18);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claw_frame_cadence_follows_the_five_original_timer_choices() {
|
||||
for (detail, frame_seconds) in DETAIL_TIMER_SECONDS.into_iter().enumerate() {
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user