fix(game): restore the original claw sequence

Replace the broad robot bounce rectangle with the recovered object-89 capture state machine. Suspend the live ball, advance the exact sprite rows, release from decoded original coordinates and directions, and keep the surrounding collision records disabled until the arm returns home.

Render the 900x296 sheet as 100x74 frames at the original destination and suppress the duplicate ball while it is embedded in the capture animation.

Test Plan:
- cargo fmt --check
- cargo test
- cargo clippy --all-targets --all-features -- -D warnings
- git diff --cached --check
This commit is contained in:
2026-08-22 18:44:09 +02:00
parent 445bb22fb4
commit 99a6082b2a
2 changed files with 307 additions and 35 deletions
+21 -21
View File
@@ -383,21 +383,6 @@ impl App {
}, },
); );
} }
if game.robot_animation > 0.0 {
let frame = animation_frame(f64::from(1.0 - game.robot_animation), 14.0, 10);
draw_texture_ex(
&self.assets.robot,
238.0,
31.0,
WHITE,
DrawTextureParams {
dest_size: Some(vec2(90.0, 74.0)),
source: Some(Rect::new(frame as f32 * 90.0, 0.0, 90.0, 74.0)),
..Default::default()
},
);
}
for (index, bumper) in BUMPERS.into_iter().enumerate() { for (index, bumper) in BUMPERS.into_iter().enumerate() {
if game.bumper_flash[index] > 0.0 { if game.bumper_flash[index] > 0.0 {
draw_circle_lines(bumper.center.x, bumper.center.y, 17.0, 3.0, WHITE); draw_circle_lines(bumper.center.x, bumper.center.y, 17.0, 3.0, WHITE);
@@ -466,12 +451,27 @@ impl App {
); );
} }
} }
draw_texture( if !game.claw.ball_suspended {
&self.assets.ball, draw_texture(
game.ball.position.x - 8.0, &self.assets.ball,
game.ball.position.y - 8.0, game.ball.position.x - 8.0,
WHITE, game.ball.position.y - 8.0,
); WHITE,
);
}
if let Some(source) = game.claw.sprite_source() {
draw_texture_ex(
&self.assets.robot,
238.0,
47.0,
WHITE,
DrawTextureParams {
dest_size: Some(vec2(100.0, 74.0)),
source: Some(source),
..Default::default()
},
);
}
self.draw_displays(game); self.draw_displays(game);
if game.tilted { if game.tilted {
+286 -14
View File
@@ -2,7 +2,7 @@ use crate::{
geometry::{Segment, circle_collision, segment_collision}, geometry::{Segment, circle_collision, segment_collision},
table::{BUMPERS, PASSIVE_CIRCLES, WALLS}, table::{BUMPERS, PASSIVE_CIRCLES, WALLS},
}; };
use macroquad::prelude::{Vec2, vec2}; use macroquad::prelude::{Rect, Vec2, vec2};
const FLIPPER_CONTACT_RADIUS: f32 = 9.0; const FLIPPER_CONTACT_RADIUS: f32 = 9.0;
// The Win16 engine sweeps the ball center through its pre-expanded object // The Win16 engine sweeps the ball center through its pre-expanded object
@@ -30,6 +30,14 @@ const LAUNCHER_FRAME_THRESHOLDS: [f32; 10] =
const LAUNCHER_CHARGE_SECONDS: f32 = 1.0; const LAUNCHER_CHARGE_SECONDS: f32 = 1.0;
const LAUNCH_SPEED_MIN: f32 = 330.0; const LAUNCH_SPEED_MIN: f32 = 330.0;
const LAUNCH_SPEED_RANGE: f32 = 100.0; const LAUNCH_SPEED_RANGE: f32 = 100.0;
const CLAW_TRIGGER_CENTER: Vec2 = Vec2::new(289.0, 94.0);
const CLAW_TRIGGER_RADIUS: f32 = 19.0;
// The default original timer fires every 30 ms and advances the claw by one
// frame. Keeping that cadence independent of render rate makes captures
// deterministic on modern machines.
const CLAW_FRAME_SECONDS: f32 = 0.030;
const CLAW_TERMINAL_FRAMES: [u8; 4] = [1, 6, 7, 18];
const ORIGINAL_BALL_SPEED_PER_SECOND: f32 = 3.8 / CLAW_FRAME_SECONDS;
#[derive(Clone, Copy, Debug, Default)] #[derive(Clone, Copy, Debug, Default)]
pub struct Controls { pub struct Controls {
@@ -47,6 +55,7 @@ pub enum Event {
Target, Target,
Wheel, Wheel,
ClawCapture, ClawCapture,
ClawRelease,
Lock, Lock,
Media, Media,
ExtraBall, ExtraBall,
@@ -67,6 +76,7 @@ impl Event {
Self::Target => Some(2006), Self::Target => Some(2006),
Self::Wheel => Some(2011), Self::Wheel => Some(2011),
Self::ClawCapture => Some(2015), Self::ClawCapture => Some(2015),
Self::ClawRelease => Some(2016),
Self::Lock => Some(2017), Self::Lock => Some(2017),
Self::Media => Some(2022), Self::Media => Some(2022),
Self::ExtraBall => Some(2007), Self::ExtraBall => Some(2007),
@@ -84,6 +94,59 @@ pub struct Flippers {
pub right_raised: bool, pub right_raised: bool,
} }
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ClawSpriteBank {
Closing,
#[default]
Opening,
}
#[derive(Clone, Copy, Debug)]
pub struct Claw {
pub active: bool,
pub frame: u8,
pub bank: ClawSpriteBank,
pub ball_suspended: bool,
target_frame: u8,
frame_accumulator: f32,
}
impl Default for Claw {
fn default() -> Self {
Self {
active: false,
frame: 10,
bank: ClawSpriteBank::Opening,
ball_suspended: false,
target_frame: 10,
frame_accumulator: 0.0,
}
}
}
impl Claw {
pub fn sprite_source(self) -> Option<Rect> {
if !self.active {
return None;
}
let mut column_frame = self.frame;
let mut source_y = match self.bank {
ClawSpriteBank::Closing => 0.0,
ClawSpriteBank::Opening => 148.0,
};
if column_frame > 9 {
column_frame -= 9;
source_y += 74.0;
}
Some(Rect::new(
f32::from(column_frame - 1) * 100.0,
source_y,
100.0,
74.0,
))
}
}
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct Player { pub struct Player {
pub score: u32, pub score: u32,
@@ -143,9 +206,9 @@ pub struct Game {
pub magnets: f32, pub magnets: f32,
pub tilted: bool, pub tilted: bool,
pub flippers: Flippers, pub flippers: Flippers,
pub claw: Claw,
pub bumper_flash: [f32; 3], pub bumper_flash: [f32; 3],
pub wheel_animation: f32, pub wheel_animation: f32,
pub robot_animation: f32,
pub nudge_shake: f32, pub nudge_shake: f32,
pub launcher_charge: f32, pub launcher_charge: f32,
pub finished: bool, pub finished: bool,
@@ -157,10 +220,15 @@ pub struct Game {
stalled_for: f32, stalled_for: f32,
launcher_was_down: bool, launcher_was_down: bool,
player_entry: PlayerEntry, player_entry: PlayerEntry,
claw_rng_state: u32,
} }
impl Game { impl Game {
pub fn new(player_count: usize) -> Self { pub fn new(player_count: usize) -> Self {
Self::new_with_seed(player_count, macroquad::rand::rand())
}
pub fn new_with_seed(player_count: usize, seed: u32) -> Self {
Self { Self {
players: vec![Player::default(); player_count.clamp(1, 4)], players: vec![Player::default(); player_count.clamp(1, 4)],
current_player: 0, current_player: 0,
@@ -173,9 +241,9 @@ impl Game {
magnets: 0.0, magnets: 0.0,
tilted: false, tilted: false,
flippers: Flippers::default(), flippers: Flippers::default(),
claw: Claw::default(),
bumper_flash: [0.0; 3], bumper_flash: [0.0; 3],
wheel_animation: 0.0, wheel_animation: 0.0,
robot_animation: 0.0,
nudge_shake: 0.0, nudge_shake: 0.0,
launcher_charge: 0.0, launcher_charge: 0.0,
finished: false, finished: false,
@@ -187,6 +255,7 @@ impl Game {
stalled_for: 0.0, stalled_for: 0.0,
launcher_was_down: false, launcher_was_down: false,
player_entry: PlayerEntry::Open, player_entry: PlayerEntry::Open,
claw_rng_state: seed.max(1),
} }
} }
@@ -282,7 +351,6 @@ impl Game {
self.nudge_cooldown = (self.nudge_cooldown - dt).max(0.0); self.nudge_cooldown = (self.nudge_cooldown - dt).max(0.0);
self.magnets = (self.magnets - dt).max(0.0); self.magnets = (self.magnets - dt).max(0.0);
self.wheel_animation = (self.wheel_animation - dt).max(0.0); self.wheel_animation = (self.wheel_animation - dt).max(0.0);
self.robot_animation = (self.robot_animation - dt).max(0.0);
self.nudge_shake = (self.nudge_shake - dt).max(0.0); self.nudge_shake = (self.nudge_shake - dt).max(0.0);
for flash in &mut self.bumper_flash { for flash in &mut self.bumper_flash {
*flash = (*flash - dt).max(0.0); *flash = (*flash - dt).max(0.0);
@@ -291,6 +359,12 @@ impl Game {
self.nudge_meter = (self.nudge_meter - dt * 0.34).max(0.0); self.nudge_meter = (self.nudge_meter - dt * 0.34).max(0.0);
} }
self.update_claw(dt, events);
if self.claw.ball_suspended {
self.stalled_for = 0.0;
return;
}
if self.ball.in_launcher { if self.ball.in_launcher {
self.ball.position = LAUNCHER_POSITION; self.ball.position = LAUNCHER_POSITION;
self.ball.velocity = Vec2::ZERO; self.ball.velocity = Vec2::ZERO;
@@ -313,6 +387,9 @@ impl Game {
let mut drained = false; let mut drained = false;
for wall in WALLS { for wall in WALLS {
if self.claw.active && (12..=20).contains(&wall.id) {
continue;
}
// Objects 66/68 and 81/83 are the resting flipper edges. // Objects 66/68 and 81/83 are the resting flipper edges.
// Their live shapes are handled as moving capsules below. // Their live shapes are handled as moving capsules below.
if matches!(wall.id, 66 | 68 | 81 | 83) { if matches!(wall.id, 66 | 68 | 81 | 83) {
@@ -420,6 +497,10 @@ impl Game {
self.check_targets(events); self.check_targets(events);
self.check_media(events); self.check_media(events);
} }
if self.claw.ball_suspended {
self.stalled_for = 0.0;
return;
}
if self.ball.position.y > 454.0 { if self.ball.position.y > 454.0 {
if self.magnets > 0.0 && (self.ball.position.x < 145.0 || self.ball.position.x > 175.0) if self.magnets > 0.0 && (self.ball.position.x < 145.0 || self.ball.position.x > 175.0)
@@ -451,6 +532,14 @@ impl Game {
} }
fn check_targets(&mut self, events: &mut Vec<Event>) { fn check_targets(&mut self, events: &mut Vec<Event>) {
if !self.claw.active
&& self.ball.position.distance_squared(CLAW_TRIGGER_CENTER)
<= CLAW_TRIGGER_RADIUS.powi(2)
{
let terminal_frame = self.next_claw_terminal_frame();
self.begin_claw_capture(terminal_frame, events);
return;
}
if self.target_cooldown > 0.0 { if self.target_cooldown > 0.0 {
return; return;
} }
@@ -521,21 +610,12 @@ impl Game {
} }
} }
if (270.0..=302.0).contains(&position.x) && (45.0..=105.0).contains(&position.y) {
self.add_score(7_500);
self.ball.velocity = vec2(-125.0, 60.0);
self.target_cooldown = 0.3;
self.robot_animation = 1.0;
events.push(Event::ClawCapture);
}
if (151.0..=220.0).contains(&position.x) && (187.0..=205.0).contains(&position.y) { if (151.0..=220.0).contains(&position.x) && (187.0..=205.0).contains(&position.y) {
self.lock_lights = (self.lock_lights + 1).min(4); self.lock_lights = (self.lock_lights + 1).min(4);
self.add_score(u32::from(self.lock_lights) * 5_000); self.add_score(u32::from(self.lock_lights) * 5_000);
self.ball.position = vec2(185.0, 181.0); self.ball.position = vec2(185.0, 181.0);
self.ball.velocity = vec2(-40.0 + f32::from(self.lock_lights) * 18.0, -120.0); self.ball.velocity = vec2(-40.0 + f32::from(self.lock_lights) * 18.0, -120.0);
self.target_cooldown = 0.4; self.target_cooldown = 0.4;
self.robot_animation = 0.8;
events.push(Event::Lock); events.push(Event::Lock);
} }
@@ -570,6 +650,67 @@ impl Game {
} }
} }
fn next_claw_terminal_frame(&mut self) -> u8 {
let mut value = self.claw_rng_state;
value ^= value << 13;
value ^= value >> 17;
value ^= value << 5;
self.claw_rng_state = value;
CLAW_TERMINAL_FRAMES[value as usize % CLAW_TERMINAL_FRAMES.len()]
}
fn begin_claw_capture(&mut self, terminal_frame: u8, events: &mut Vec<Event>) {
debug_assert!(CLAW_TERMINAL_FRAMES.contains(&terminal_frame));
if self.claw.active {
return;
}
self.claw.active = true;
self.claw.target_frame = terminal_frame;
self.claw.bank = ClawSpriteBank::Closing;
self.claw.ball_suspended = true;
self.claw.frame_accumulator = 0.0;
self.ball.velocity = Vec2::ZERO;
self.stalled_for = 0.0;
events.push(Event::ClawCapture);
}
fn update_claw(&mut self, dt: f32, events: &mut Vec<Event>) {
if !self.claw.active {
return;
}
self.claw.frame_accumulator += dt;
while self.claw.active && self.claw.frame_accumulator >= CLAW_FRAME_SECONDS {
self.claw.frame_accumulator -= CLAW_FRAME_SECONDS;
self.advance_claw(events);
}
}
fn advance_claw(&mut self, events: &mut Vec<Event>) {
if self.claw.frame != self.claw.target_frame {
if self.claw.frame < self.claw.target_frame {
self.claw.frame += 1;
} else {
self.claw.frame -= 1;
}
return;
}
if self.claw.frame == 10 {
if self.claw.bank == ClawSpriteBank::Opening {
self.claw = Claw::default();
}
return;
}
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);
self.claw.ball_suspended = false;
self.stalled_for = 0.0;
events.push(Event::ClawRelease);
}
fn add_score(&mut self, points: u32) { fn add_score(&mut self, points: u32) {
let multiplier = if self.player().diamond_segments == 9 { let multiplier = if self.player().diamond_segments == 9 {
2 2
@@ -602,7 +743,7 @@ impl Game {
self.stalled_for = 0.0; self.stalled_for = 0.0;
self.bumper_flash.fill(0.0); self.bumper_flash.fill(0.0);
self.wheel_animation = 0.0; self.wheel_animation = 0.0;
self.robot_animation = 0.0; self.claw = Claw::default();
self.nudge_shake = 0.0; self.nudge_shake = 0.0;
self.launcher_charge = 0.0; self.launcher_charge = 0.0;
self.launcher_was_down = false; self.launcher_was_down = false;
@@ -637,6 +778,23 @@ impl Game {
} }
} }
fn claw_release(frame: u8) -> (Vec2, Vec2) {
let (position, direction) = match frame {
1 => (vec2(258.0, 78.0), vec2(-0.60, 0.00)),
2 => (vec2(259.0, 81.0), vec2(-0.45, 0.05)),
3 => (vec2(258.0, 78.0), vec2(-0.70, 0.30)),
4 => (vec2(261.0, 86.0), vec2(-0.30, 0.20)),
5 => (vec2(266.0, 90.0), vec2(-0.32, 0.22)),
6 => (vec2(270.0, 94.0), vec2(-0.40, 0.60)),
7 => (vec2(275.0, 97.0), vec2(-0.31, 0.70)),
8 => (vec2(278.0, 98.0), vec2(-0.20, 0.80)),
9 => (vec2(282.0, 101.0), vec2(-0.10, 0.90)),
18 => return (vec2(325.0, 92.0), vec2(0.0, 1.0 / CLAW_FRAME_SECONDS)),
_ => unreachable!("the original claw only releases from a terminal frame"),
};
(position, direction * ORIGINAL_BALL_SPEED_PER_SECOND)
}
fn flipper_segment(pivot: Vec2, tip: Vec2) -> Segment { fn flipper_segment(pivot: Vec2, tip: Vec2) -> Segment {
Segment::new(pivot, tip, 0.88) Segment::new(pivot, tip, 0.88)
} }
@@ -952,9 +1110,123 @@ mod tests {
assert_eq!(Event::Tilt.sound_resource(), Some(2020)); assert_eq!(Event::Tilt.sound_resource(), Some(2020));
assert_eq!(Event::Drain.sound_resource(), Some(2008)); assert_eq!(Event::Drain.sound_resource(), Some(2008));
assert_eq!(Event::ClawCapture.sound_resource(), Some(2015)); assert_eq!(Event::ClawCapture.sound_resource(), Some(2015));
assert_eq!(Event::ClawRelease.sound_resource(), Some(2016));
assert_eq!(Event::BallSearch.sound_resource(), None); assert_eq!(Event::BallSearch.sound_resource(), None);
} }
#[test]
fn claw_sprite_rects_follow_the_recovered_four_row_sheet() {
let source = |frame, bank| {
Claw {
active: true,
frame,
bank,
..Claw::default()
}
.sprite_source()
.map(|rect| (rect.x, rect.y, rect.w, rect.h))
};
assert_eq!(
source(1, ClawSpriteBank::Closing),
Some((0.0, 0.0, 100.0, 74.0))
);
assert_eq!(
source(9, ClawSpriteBank::Closing),
Some((800.0, 0.0, 100.0, 74.0))
);
assert_eq!(
source(10, ClawSpriteBank::Closing),
Some((0.0, 74.0, 100.0, 74.0))
);
assert_eq!(
source(18, ClawSpriteBank::Closing),
Some((800.0, 74.0, 100.0, 74.0))
);
assert_eq!(
source(1, ClawSpriteBank::Opening),
Some((0.0, 148.0, 100.0, 74.0))
);
assert_eq!(
source(18, ClawSpriteBank::Opening),
Some((800.0, 222.0, 100.0, 74.0))
);
assert_eq!(Claw::default().sprite_source(), None);
}
#[test]
fn claw_capture_holds_releases_and_returns_in_original_frame_order() {
let mut game = Game::new(1);
game.ball.in_launcher = false;
game.ball.position = CLAW_TRIGGER_CENTER;
game.ball.velocity = vec2(120.0, -40.0);
let held_position = game.ball.position;
let mut events = Vec::new();
game.begin_claw_capture(6, &mut events);
assert_eq!(events, [Event::ClawCapture]);
assert_eq!(game.claw.frame, 10);
assert_eq!(game.claw.bank, ClawSpriteBank::Closing);
assert!(game.claw.ball_suspended);
assert_eq!(game.ball.velocity, Vec2::ZERO);
game.fixed_update(CLAW_FRAME_SECONDS / 3.0, &mut events);
assert_eq!(game.ball.position, held_position);
assert_eq!(game.claw.frame, 10);
for expected_frame in [9, 8, 7, 6] {
game.update_claw(CLAW_FRAME_SECONDS, &mut events);
assert_eq!(game.claw.frame, expected_frame);
assert!(game.claw.ball_suspended);
}
game.update_claw(CLAW_FRAME_SECONDS, &mut events);
assert_eq!(events.last(), Some(&Event::ClawRelease));
assert_eq!(game.claw.frame, 6);
assert_eq!(game.claw.bank, ClawSpriteBank::Opening);
assert!(!game.claw.ball_suspended);
assert_eq!(game.ball.position, vec2(270.0, 94.0));
assert!((game.ball.velocity.x - -50.666_668).abs() < 0.001);
assert!((game.ball.velocity.y - 76.0).abs() < 0.001);
for expected_frame in [7, 8, 9, 10] {
game.update_claw(CLAW_FRAME_SECONDS, &mut events);
assert_eq!(game.claw.frame, expected_frame);
assert!(game.claw.active);
}
game.update_claw(CLAW_FRAME_SECONDS, &mut events);
assert_eq!(game.claw.frame, 10);
assert!(!game.claw.active);
}
#[test]
fn claw_uses_the_original_circular_trigger_not_a_broad_rectangle() {
let mut game = Game::new(1);
game.ball.in_launcher = false;
game.ball.position = CLAW_TRIGGER_CENTER + vec2(CLAW_TRIGGER_RADIUS + 0.1, 0.0);
game.check_targets(&mut Vec::new());
assert!(!game.claw.active);
game.ball.position = CLAW_TRIGGER_CENTER;
let mut events = Vec::new();
game.check_targets(&mut events);
assert!(game.claw.active);
assert_eq!(events, [Event::ClawCapture]);
assert!(CLAW_TERMINAL_FRAMES.contains(&game.claw.target_frame));
}
#[test]
fn claw_release_table_decodes_the_original_thousandth_pixel_coordinates() {
for (frame, expected) in [
(1, vec2(258.0, 78.0)),
(6, vec2(270.0, 94.0)),
(7, vec2(275.0, 97.0)),
(18, vec2(325.0, 92.0)),
] {
assert_eq!(claw_release(frame).0, expected);
}
}
#[test] #[test]
fn drain_clears_latched_tilt() { fn drain_clears_latched_tilt() {
let mut game = Game::new(1); let mut game = Game::new(1);