diff --git a/tdkpin-rs/src/app.rs b/tdkpin-rs/src/app.rs index 3be0fd9..9865595 100644 --- a/tdkpin-rs/src/app.rs +++ b/tdkpin-rs/src/app.rs @@ -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() { if game.bumper_flash[index] > 0.0 { draw_circle_lines(bumper.center.x, bumper.center.y, 17.0, 3.0, WHITE); @@ -466,12 +451,27 @@ impl App { ); } } - draw_texture( - &self.assets.ball, - game.ball.position.x - 8.0, - game.ball.position.y - 8.0, - WHITE, - ); + if !game.claw.ball_suspended { + draw_texture( + &self.assets.ball, + game.ball.position.x - 8.0, + 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); if game.tilted { diff --git a/tdkpin-rs/src/game.rs b/tdkpin-rs/src/game.rs index d778e0b..18396b5 100644 --- a/tdkpin-rs/src/game.rs +++ b/tdkpin-rs/src/game.rs @@ -2,7 +2,7 @@ use crate::{ geometry::{Segment, circle_collision, segment_collision}, table::{BUMPERS, PASSIVE_CIRCLES, WALLS}, }; -use macroquad::prelude::{Vec2, vec2}; +use macroquad::prelude::{Rect, Vec2, vec2}; const FLIPPER_CONTACT_RADIUS: f32 = 9.0; // 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 LAUNCH_SPEED_MIN: f32 = 330.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)] pub struct Controls { @@ -47,6 +55,7 @@ pub enum Event { Target, Wheel, ClawCapture, + ClawRelease, Lock, Media, ExtraBall, @@ -67,6 +76,7 @@ impl Event { Self::Target => Some(2006), Self::Wheel => Some(2011), Self::ClawCapture => Some(2015), + Self::ClawRelease => Some(2016), Self::Lock => Some(2017), Self::Media => Some(2022), Self::ExtraBall => Some(2007), @@ -84,6 +94,59 @@ pub struct Flippers { 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 { + 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)] pub struct Player { pub score: u32, @@ -143,9 +206,9 @@ pub struct Game { pub magnets: f32, pub tilted: bool, pub flippers: Flippers, + pub claw: Claw, pub bumper_flash: [f32; 3], pub wheel_animation: f32, - pub robot_animation: f32, pub nudge_shake: f32, pub launcher_charge: f32, pub finished: bool, @@ -157,10 +220,15 @@ pub struct Game { stalled_for: f32, launcher_was_down: bool, player_entry: PlayerEntry, + claw_rng_state: u32, } impl Game { 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 { players: vec![Player::default(); player_count.clamp(1, 4)], current_player: 0, @@ -173,9 +241,9 @@ impl Game { magnets: 0.0, tilted: false, flippers: Flippers::default(), + claw: Claw::default(), bumper_flash: [0.0; 3], wheel_animation: 0.0, - robot_animation: 0.0, nudge_shake: 0.0, launcher_charge: 0.0, finished: false, @@ -187,6 +255,7 @@ impl Game { stalled_for: 0.0, launcher_was_down: false, 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.magnets = (self.magnets - 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); for flash in &mut self.bumper_flash { *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.update_claw(dt, events); + if self.claw.ball_suspended { + self.stalled_for = 0.0; + return; + } + if self.ball.in_launcher { self.ball.position = LAUNCHER_POSITION; self.ball.velocity = Vec2::ZERO; @@ -313,6 +387,9 @@ impl Game { let mut drained = false; 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. // Their live shapes are handled as moving capsules below. if matches!(wall.id, 66 | 68 | 81 | 83) { @@ -420,6 +497,10 @@ impl Game { self.check_targets(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.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) { + 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 { 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) { self.lock_lights = (self.lock_lights + 1).min(4); self.add_score(u32::from(self.lock_lights) * 5_000); self.ball.position = vec2(185.0, 181.0); self.ball.velocity = vec2(-40.0 + f32::from(self.lock_lights) * 18.0, -120.0); self.target_cooldown = 0.4; - self.robot_animation = 0.8; 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) { + 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) { + 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) { + 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) { let multiplier = if self.player().diamond_segments == 9 { 2 @@ -602,7 +743,7 @@ impl Game { self.stalled_for = 0.0; self.bumper_flash.fill(0.0); self.wheel_animation = 0.0; - self.robot_animation = 0.0; + self.claw = Claw::default(); self.nudge_shake = 0.0; self.launcher_charge = 0.0; 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 { Segment::new(pivot, tip, 0.88) } @@ -952,9 +1110,123 @@ mod tests { assert_eq!(Event::Tilt.sound_resource(), Some(2020)); assert_eq!(Event::Drain.sound_resource(), Some(2008)); assert_eq!(Event::ClawCapture.sound_resource(), Some(2015)); + assert_eq!(Event::ClawRelease.sound_resource(), Some(2016)); 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] fn drain_clears_latched_tilt() { let mut game = Game::new(1);