diff --git a/tdkpin-rs/CHANGELOG.md b/tdkpin-rs/CHANGELOG.md index b634d97..c92d43f 100644 --- a/tdkpin-rs/CHANGELOG.md +++ b/tdkpin-rs/CHANGELOG.md @@ -8,6 +8,12 @@ and this project adheres to ## [Unreleased] +### Added + +- Crazy mode: Ctrl+Shift+0 drops ten extra balls onto the playfield. They bounce + and score off walls, bumpers, and flippers, replace the main ball when it + drains, and keep the game's scores off the online high-score table. + ## [1.2.1] - 2026-09-01 ### Fixed diff --git a/tdkpin-rs/README.md b/tdkpin-rs/README.md index 3299251..7dbfbcc 100644 --- a/tdkpin-rs/README.md +++ b/tdkpin-rs/README.md @@ -60,7 +60,7 @@ cargo run -- --simulate claw-6 --at 0.15 \ Use `--step N` instead of `--at SECONDS` to reproduce one exact update. The available scenarios are `loading`, `attract`, `highscores`, `name-entry`, `effect-7`, `autoplay`, `launcher`, `flippers`, `panel`, `targets`, `claw-1`, `claw-6`, -`claw-7`, and `claw-18`; `--seed N` fixes random choices. +`claw-7`, `claw-18`, and `crazy`; `--seed N` fixes random choices. `autoplay` charges each ball and operates the flippers from live ball position for long end-to-end validation runs. @@ -77,6 +77,13 @@ for long end-to-end validation runs. | Settings | `TDKPIN.INI` before launch | F10 opens the portable settings screen | | High scores | Automatic when a player finishes | F9 opens the portable table viewer | | Sound | F12 | - | +| Crazy mode: drop 10 extra balls | - | Ctrl+Shift+0 | + +Crazy mode is a modern addition. Its balls bounce off walls, bumpers, and +flippers and score through them, but skip sensors, holes, and the claw. When +the main ball drains, a crazy ball takes its place before the turn ends. Scores +from games that used crazy mode are not submitted to the online high-score +table. The five original speed choices remain available in settings. Physics now uses the original invariant 100 Hz millipixel substep, batched as 5/4/3/2/1 steps on diff --git a/tdkpin-rs/RECONSTRUCTION.md b/tdkpin-rs/RECONSTRUCTION.md index 9f4fa30..26e4be5 100644 --- a/tdkpin-rs/RECONSTRUCTION.md +++ b/tdkpin-rs/RECONSTRUCTION.md @@ -88,6 +88,8 @@ decoded, build-ready subset; it does not replace that evidence archive. - `app.rs`: screen flow, controls, rendering, audio dispatch, and overlays; - `assets.rs`: compile-time original asset embedding; - `game.rs`: fixed-step game state, recovered walls, rules, and scoring; + - crazy mode (a modern addition): extra balls outside the original two ball + slots, with static collisions, wall/bumper rules, and flipper kicks only; - `geometry.rs`: segment/circle collision primitives; - `original_physics.rs`: millipixel integration and recovered type-2 responses; - `real48.rs`: bit-exact Borland six-byte arithmetic, conversion, and square root; diff --git a/tdkpin-rs/src/app.rs b/tdkpin-rs/src/app.rs index 4f9d320..0b39dff 100644 --- a/tdkpin-rs/src/app.rs +++ b/tdkpin-rs/src/app.rs @@ -223,6 +223,8 @@ pub struct App { name: String, pending_score: u32, attract: AttractAnimation, + /// Ctrl+Shift+0 holds Shift, whose release would otherwise nudge. + crazy_shift_nudge_suppressed: bool, } impl App { @@ -251,6 +253,7 @@ impl App { name: String::new(), pending_score: 0, attract: AttractAnimation::default(), + crazy_shift_nudge_suppressed: false, } } @@ -398,7 +401,21 @@ impl App { || is_key_down(KeyCode::Enter) || is_key_down(KeyCode::D) || is_key_down(KeyCode::Right); - let nudge = if is_key_released(KeyCode::LeftShift) { + let shift_down = is_key_down(KeyCode::LeftShift) || is_key_down(KeyCode::RightShift); + let ctrl_down = is_key_down(KeyCode::LeftControl) || is_key_down(KeyCode::RightControl); + if ctrl_down + && shift_down + && is_key_pressed(KeyCode::Key0) + && self.game.as_mut().is_some_and(Game::add_crazy_balls) + { + self.crazy_shift_nudge_suppressed = true; + self.assets.play(2007, self.sounds_enabled); + } + let nudge = if self.crazy_shift_nudge_suppressed + && (is_key_released(KeyCode::LeftShift) || is_key_released(KeyCode::RightShift)) + { + Nudge::None + } else if is_key_released(KeyCode::LeftShift) { Nudge::Left } else if is_key_released(KeyCode::RightShift) || is_key_released(KeyCode::Kp3) { Nudge::Right @@ -407,6 +424,9 @@ impl App { } else { Nudge::None }; + if !shift_down { + self.crazy_shift_nudge_suppressed = false; + } let controls = Controls { left_flipper, right_flipper, @@ -565,7 +585,9 @@ impl App { }; insert_high_score(&mut self.saved.high_scores, entry.clone()); #[cfg(target_arch = "wasm32")] - self.persistence.submit_high_score(&entry); + if !self.game.as_ref().is_some_and(Game::crazy_mode_used) { + self.persistence.submit_high_score(&entry); + } self.save(); if self.game.as_ref().is_some_and(|game| game.finished) { self.take_game_and_preserve_random_seed(); @@ -919,6 +941,14 @@ impl App { WHITE, ); } + for ball in &game.crazy_balls { + draw_texture( + &self.assets.ball, + ball.position.x - 8.0, + ball.position.y - 8.0, + WHITE, + ); + } if let Some(source) = game.claw.sprite_source() { draw_texture_ex( &self.assets.robot, diff --git a/tdkpin-rs/src/game.rs b/tdkpin-rs/src/game.rs index f260333..7dd1723 100644 --- a/tdkpin-rs/src/game.rs +++ b/tdkpin-rs/src/game.rs @@ -264,6 +264,13 @@ enum BallDoubleState { Active, } +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum CrazyMode { + #[default] + Unused, + Used, +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] enum SpecialHoleGate { #[default] @@ -365,12 +372,31 @@ impl Default for Ball { } } +/// Drop points for the Ctrl+Shift+0 crazy mode, spread over open playfield +/// between the bumpers, slingshots, and lanes. +pub const CRAZY_BALL_POSITIONS: [Vec2; 10] = [ + vec2(170.0, 112.0), + vec2(250.0, 140.0), + vec2(120.0, 160.0), + vec2(90.0, 215.0), + vec2(250.0, 215.0), + vec2(110.0, 260.0), + vec2(215.0, 260.0), + vec2(160.0, 300.0), + vec2(120.0, 340.0), + vec2(195.0, 335.0), +]; + #[derive(Clone, Debug)] pub struct Game { pub players: Vec, pub current_player: usize, pub ball: Ball, pub secondary_ball: Option, + /// Extra balls added by crazy mode. They are not part of the original + /// two-slot engine: they collide with static records, score through the + /// wall and bumper rules, and replace the primary ball when it drains. + pub crazy_balls: Vec, pub wheel_holes: [bool; 5], pub top_targets: [bool; 3], pub tilted: bool, @@ -402,6 +428,7 @@ pub struct Game { special_hole_gate: SpecialHoleGate, score_mode: ScoreMode, ball_double: BallDoubleState, + crazy_mode: CrazyMode, } impl Game { @@ -416,6 +443,7 @@ impl Game { current_player: 0, ball: Ball::default(), secondary_ball: None, + crazy_balls: Vec::new(), wheel_holes: [false; 5], top_targets: [false; 3], tilted: false, @@ -447,6 +475,7 @@ impl Game { special_hole_gate: SpecialHoleGate::Enabled, score_mode: ScoreMode::Normal, ball_double: BallDoubleState::Inactive, + crazy_mode: CrazyMode::Unused, } } @@ -484,6 +513,28 @@ impl Game { true } + /// Whether crazy mode was used, so its scores stay off the online table. + #[cfg(any(target_arch = "wasm32", test))] + pub fn crazy_mode_used(&self) -> bool { + self.crazy_mode == CrazyMode::Used + } + + /// Crazy mode: drop ten extra balls onto the playfield. + pub fn add_crazy_balls(&mut self) -> bool { + if self.finished { + return false; + } + self.crazy_mode = CrazyMode::Used; + self.crazy_balls + .extend(CRAZY_BALL_POSITIONS.into_iter().map(|position| Ball { + position, + velocity: Vec2::ZERO, + in_launcher: false, + ..Ball::default() + })); + true + } + #[cfg(not(target_arch = "wasm32"))] pub(crate) fn begin_claw_scenario(&mut self, terminal_frame: u8) -> Vec { self.ball.in_launcher = false; @@ -676,6 +727,9 @@ impl Game { // The two slots are mutually exclusive while the claw is holding // a ball. debug_assert!(!(primary_held && secondary_held)); + if !self.finished { + self.advance_crazy_balls(substeps, events); + } } self.pending_flipper_edges[0] = @@ -793,6 +847,16 @@ impl Game { } Nudge::None => return, } + let primary_velocity = MilliVec::from_velocity_per_second(self.ball.velocity); + let impulse = MilliVec { + x: velocity.x.wrapping_sub(primary_velocity.x), + y: velocity.y.wrapping_sub(primary_velocity.y), + }; + for ball in &mut self.crazy_balls { + ball.velocity = MilliVec::from_velocity_per_second(ball.velocity) + .add(impulse) + .to_velocity_per_second(); + } 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 { @@ -1395,6 +1459,74 @@ impl Game { collided || scan.action == BallAction::Suspend } + fn advance_crazy_balls(&mut self, substeps: u8, events: &mut Vec) { + let mut balls = std::mem::take(&mut self.crazy_balls); + balls.retain_mut(|ball| { + for _ in 0..substeps { + match self.advance_crazy_ball(ball, events) { + None => return false, + Some(true) => break, + Some(false) => {} + } + } + true + }); + debug_assert!(self.crazy_balls.is_empty()); + self.crazy_balls = balls; + } + + /// Moves one crazy ball by one physics substep. Returns `None` once the + /// ball has left the table, otherwise whether it collided. + fn advance_crazy_ball(&mut self, ball: &mut Ball, events: &mut Vec) -> Option { + let old_position = MilliVec::from_position(ball.position); + let mut velocity = MilliVec::from_velocity_per_second(ball.velocity); + velocity.y += GRAVITY_MILLI_PER_STEP; + velocity.clamp_speed(MAXIMUM_SPEED_MILLI_PER_STEP); + let speed_before_collision = milli_distance(velocity); + let predicted = old_position.add(velocity); + if predicted_outside_board(predicted) { + return None; + } + let mut best = None; + for record_ids in [1..=5, 7..=88, 90..=128, 134..=139, 155..=173] { + self.retain_static_range(old_position, predicted, velocity, record_ids, &mut best); + } + let mut hit = None; + let mut auxiliary_fired = false; + if let Some((object_id, is_wall, candidate)) = best { + let response = candidate.resolve(velocity, ball.spin); + velocity = response.velocity; + ball.spin = response.spin; + auxiliary_fired = response.auxiliary_fired; + hit = Some((object_id, is_wall)); + } + if hit == Some((25, true)) { + velocity.x = 0; + if speed_before_collision < 1_000 { + // Resting on the shooter stop would park it in the launcher. + return None; + } + } + velocity.clamp_speed(MAXIMUM_SPEED_MILLI_PER_STEP); + ball.position = old_position.add(velocity).to_position(); + ball.velocity = velocity.to_velocity_per_second(); + match hit { + Some((2, true)) => { + events.push(Event::Drain); + return None; + } + Some((object_id, true)) => { + self.apply_wall_rule(object_id, events); + if auxiliary_fired && matches!(object_id, 55 | 74 | 107) { + events.push(Event::Sound(2019)); + } + } + Some((object_id, false)) => self.apply_bumper_rule(object_id, auxiliary_fired, events), + None => {} + } + Some(hit.is_some()) + } + fn apply_bumper_rule(&mut self, object_id: u8, auxiliary_fired: bool, events: &mut Vec) { if self.tilted || !BUMPERS.iter().any(|bumper| bumper.id == object_id) { return; @@ -2199,6 +2331,9 @@ impl Game { { apply_flipper_response_to_ball(secondary, delta, side); } + for ball in &mut self.crazy_balls { + apply_flipper_response_to_ball(ball, delta, side); + } } } @@ -2354,6 +2489,11 @@ impl Game { self.special_hole_gate = SpecialHoleGate::Suppressed; return; } + if let Some(crazy_ball) = self.crazy_balls.pop() { + self.ball = crazy_ball; + events.push(Event::Drain); + return; + } self.ball_double = BallDoubleState::Inactive; self.multiball_state = MultiballState::Unavailable; self.special_hole_gate = SpecialHoleGate::Enabled; @@ -2590,6 +2730,72 @@ mod tests { result } + #[test] + fn crazy_balls_start_clear_of_every_static_record() { + let game = Game::new_with_seed(1, 7); + for position in CRAZY_BALL_POSITIONS { + let origin = MilliVec::from_position(position); + for (x, y) in [(3_000, 0), (-3_000, 0), (0, 3_000), (0, -3_000)] { + assert!( + game.find_static_collision_candidate(origin, MilliVec { x, y }) + .is_none(), + "{position:?} touches a record moving ({x}, {y})" + ); + } + } + } + + #[test] + fn crazy_mode_adds_ten_balls_that_fall_and_score() { + let mut game = Game::new_with_seed(1, 7); + assert!(game.add_crazy_balls()); + assert!(game.crazy_mode_used()); + assert_eq!(game.crazy_balls.len(), 10); + let start: Vec<_> = game.crazy_balls.iter().map(|ball| ball.position).collect(); + for _ in 0..20 { + game.update(0.03, 3, Controls::default()); + } + assert!(game.ball.in_launcher); + assert!( + game.crazy_balls + .iter() + .zip(&start) + .all(|(ball, start)| ball.position != *start) + ); + for _ in 0..2_000 { + game.update(0.03, 3, Controls::default()); + } + assert!(game.crazy_balls.is_empty()); + assert!(game.player().score > 0); + assert_eq!(game.player().balls, 3); + } + + #[test] + fn draining_primary_ball_promotes_a_crazy_ball() { + let mut game = Game::new_with_seed(1, 7); + game.ball.in_launcher = false; + game.add_crazy_balls(); + let mut events = Vec::new(); + game.drain(&mut events); + assert_eq!(game.crazy_balls.len(), 9); + assert_eq!(game.ball.position, CRAZY_BALL_POSITIONS[9]); + assert!(!game.ball.in_launcher); + assert_eq!(game.player().balls, 3); + game.crazy_balls.clear(); + game.drain(&mut events); + assert_eq!(game.player().balls, 2); + assert!(game.ball.in_launcher); + } + + #[test] + fn finished_game_rejects_crazy_mode() { + let mut game = Game::new_with_seed(1, 7); + game.finished = true; + assert!(!game.add_crazy_balls()); + assert!(game.crazy_balls.is_empty()); + assert!(!game.crazy_mode_used()); + } + #[test] fn player_count_is_bounded() { assert_eq!(Game::new(0).players.len(), 1); diff --git a/tdkpin-rs/src/simulation.rs b/tdkpin-rs/src/simulation.rs index f072057..0459536 100644 --- a/tdkpin-rs/src/simulation.rs +++ b/tdkpin-rs/src/simulation.rs @@ -29,6 +29,7 @@ pub enum Scenario { Claw6, Claw7, Claw18, + Crazy, } impl Scenario { @@ -48,6 +49,7 @@ impl Scenario { Self::Claw6 => "claw-6", Self::Claw7 => "claw-7", Self::Claw18 => "claw-18", + Self::Crazy => "crazy", } } @@ -66,7 +68,8 @@ impl Scenario { | Self::Launcher | Self::Flippers | Self::Panel - | Self::Targets => None, + | Self::Targets + | Self::Crazy => None, } } } @@ -90,8 +93,9 @@ impl FromStr for Scenario { "claw-6" => Ok(Self::Claw6), "claw-7" => Ok(Self::Claw7), "claw-18" => Ok(Self::Claw18), + "crazy" => Ok(Self::Crazy), _ => Err(format!( - "unknown scenario {value:?}; expected loading, attract, highscores, name-entry, effect-7, autoplay, launcher, flippers, panel, targets, claw-1, claw-6, claw-7, or claw-18" + "unknown scenario {value:?}; expected loading, attract, highscores, name-entry, effect-7, autoplay, launcher, flippers, panel, targets, claw-1, claw-6, claw-7, claw-18, or crazy" )), } } @@ -175,7 +179,7 @@ fn seconds_to_step(value: &str) -> Result { } pub const fn usage() -> &'static str { - "Usage:\n tdkpin-rs\n tdkpin-rs --simulate SCENARIO [--at SECONDS | --step N] [--screenshot FILE.png] [--trace FILE.json] [--seed N]\n\nScenarios: loading, attract, highscores, name-entry, effect-7, autoplay, launcher, flippers, panel, targets, claw-1, claw-6, claw-7, claw-18" + "Usage:\n tdkpin-rs\n tdkpin-rs --simulate SCENARIO [--at SECONDS | --step N] [--screenshot FILE.png] [--trace FILE.json] [--seed N]\n\nScenarios: loading, attract, highscores, name-entry, effect-7, autoplay, launcher, flippers, panel, targets, claw-1, claw-6, claw-7, claw-18, crazy" } #[derive(Debug, Serialize, PartialEq)] @@ -237,6 +241,9 @@ impl Simulation { if scenario == Scenario::Effect7 { game.begin_effect_scenario(7); } + if scenario == Scenario::Crazy { + game.add_crazy_balls(); + } let initial_events = scenario .claw_terminal() .map_or_else(Vec::new, |terminal| game.begin_claw_scenario(terminal)); @@ -378,7 +385,8 @@ fn controls_for(scenario: Scenario, step: u64) -> Controls { | Scenario::Claw1 | Scenario::Claw6 | Scenario::Claw7 - | Scenario::Claw18 => Controls::default(), + | Scenario::Claw18 + | Scenario::Crazy => Controls::default(), } }