fix(game): restore swept target sensors

Move claw contact and all eleven initially active type-4 circle records onto a
swept millipixel sensor test with per-object enter latching. Preserve the exact
left-bank and top-gate coordinates, radii, and scores from the initialized
object ledger.

Remove the inferred top-bank completion rule. Each original top target now
scores 500 and independently starts recovered magnetic-gate timing instead of
requiring all three for an invented bonus.

Test Plan:
- `cargo test --all-targets` -- 44 passed
- `cargo clippy --all-targets -- -D warnings` -- passed
- `cargo build --profile production` -- passed
- top sensor contact-latch and re-entry test -- passed
- 20-second deterministic launch remained in active table motion
- `git diff --cached --check` -- passed
This commit is contained in:
2026-08-22 21:12:41 +02:00
parent 9d91cc51c7
commit bf5b855f44
5 changed files with 189 additions and 30 deletions
+106 -28
View File
@@ -2,9 +2,9 @@ use crate::{
geometry::{Segment, closest_point},
original_physics::{
GRAVITY_MILLI_PER_STEP, MAXIMUM_SPEED_MILLI_PER_STEP, MilliVec, STEP_SECONDS,
collide_with_circle, collide_with_line,
collide_with_circle, collide_with_line, path_intersects_circle,
},
table::{BUMPERS, PASSIVE_CIRCLES, WALLS},
table::{BUMPERS, PASSIVE_CIRCLES, TARGET_SENSORS, WALLS},
};
use macroquad::prelude::{Rect, Vec2, vec2};
@@ -214,6 +214,7 @@ pub struct Game {
player_entry: PlayerEntry,
claw_rng_state: u32,
pending_flipper_kicks: [bool; 2],
trigger_contacts: [bool; 176],
}
impl Game {
@@ -253,6 +254,7 @@ impl Game {
player_entry: PlayerEntry::Open,
claw_rng_state: seed.max(1),
pending_flipper_kicks: [false; 2],
trigger_contacts: [false; 176],
}
}
@@ -401,6 +403,7 @@ impl Game {
let mut velocity = MilliVec::from_velocity_per_second(self.ball.velocity);
velocity.y += GRAVITY_MILLI_PER_STEP;
velocity.clamp_speed(MAXIMUM_SPEED_MILLI_PER_STEP);
let movement_velocity = velocity;
let mut position = old_position.add(velocity);
let mut hit_wall = None;
let mut hit_circle = None;
@@ -487,6 +490,11 @@ impl Game {
events.push(Event::Bumper);
}
self.check_sensor_objects(old_position, movement_velocity, events);
if self.claw.ball_suspended {
return;
}
if !self.tilted {
self.check_targets(events);
self.check_media(events);
@@ -505,35 +513,11 @@ impl Game {
}
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 {
return;
}
let position = self.ball.position;
let top = [vec2(205.0, 47.0), vec2(234.0, 50.0), vec2(262.0, 53.0)];
for (index, center) in top.into_iter().enumerate() {
if position.distance_squared(center) < 11.0_f32.powi(2) && !self.top_targets[index] {
self.top_targets[index] = true;
self.add_score(1_500);
self.ball.velocity.y = self.ball.velocity.y.abs() + 65.0;
self.target_cooldown = 0.12;
events.push(Event::Target);
}
}
if self.top_targets.iter().all(|target| *target) {
self.top_targets.fill(false);
self.magnets = 12.0;
self.add_score(5_000);
}
let side = [180.0, 195.0, 210.0, 225.0, 240.0];
for (index, y) in side.into_iter().enumerate() {
if position.distance_squared(vec2(286.0, y)) < 9.0_f32.powi(2)
@@ -623,6 +607,53 @@ impl Game {
}
}
fn check_sensor_objects(
&mut self,
old_position: MilliVec,
movement_velocity: MilliVec,
events: &mut Vec<Event>,
) {
if !self.claw.active
&& path_intersects_circle(
old_position,
movement_velocity,
CLAW_TRIGGER_CENTER,
CLAW_TRIGGER_RADIUS,
)
{
let terminal_frame = self.next_claw_terminal_frame();
self.begin_claw_capture(terminal_frame, events);
return;
}
let current_position = MilliVec::from_position(self.ball.position);
for sensor in TARGET_SENSORS {
let touched = path_intersects_circle(
old_position,
movement_velocity,
sensor.center,
sensor.radius,
);
let contact_index = usize::from(sensor.id);
let entered = touched && !self.trigger_contacts[contact_index];
self.trigger_contacts[contact_index] = path_intersects_circle(
current_position,
MilliVec::default(),
sensor.center,
sensor.radius,
);
if !entered || self.tilted {
continue;
}
self.add_score(sensor.score);
events.push(Event::Target);
if (150..=152).contains(&sensor.id) {
self.top_targets[usize::from(sensor.id - 150)] = true;
self.magnets = self.magnets.max(0.3);
}
}
}
fn next_claw_terminal_frame(&mut self) -> u8 {
let value = self.next_random_value();
CLAW_TERMINAL_FRAMES[value as usize % CLAW_TERMINAL_FRAMES.len()]
@@ -763,6 +794,7 @@ impl Game {
self.bumper_flash.fill(0.0);
self.wheel_animation = 0.0;
self.claw = Claw::default();
self.trigger_contacts.fill(false);
self.nudge_shake = 0.0;
self.launcher_charge = 0.0;
self.launcher_was_down = false;
@@ -1300,17 +1332,63 @@ mod tests {
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());
game.check_sensor_objects(
MilliVec::from_position(game.ball.position),
MilliVec::default(),
&mut Vec::new(),
);
assert!(!game.claw.active);
game.ball.position = CLAW_TRIGGER_CENTER;
let mut events = Vec::new();
game.check_targets(&mut events);
game.check_sensor_objects(
MilliVec::from_position(game.ball.position),
MilliVec::default(),
&mut events,
);
assert!(game.claw.active);
assert_eq!(events, [Event::ClawCapture]);
assert!(CLAW_TERMINAL_FRAMES.contains(&game.claw.target_frame));
}
#[test]
fn top_sensor_uses_the_original_score_and_contact_latch() {
let mut game = Game::new(1);
game.ball.in_launcher = false;
game.ball.position = vec2(205.0, 55.0);
let mut events = Vec::new();
game.check_sensor_objects(
MilliVec::from_position(vec2(205.0, 60.0)),
MilliVec { x: 0, y: -5_000 },
&mut events,
);
assert_eq!(game.player().score, 500);
assert_eq!(events, [Event::Target]);
assert!((game.magnets - 0.3).abs() < f32::EPSILON);
game.check_sensor_objects(
MilliVec::from_position(game.ball.position),
MilliVec::default(),
&mut events,
);
assert_eq!(game.player().score, 500, "contact must score only once");
game.ball.position = vec2(205.0, 70.0);
game.check_sensor_objects(
MilliVec::from_position(game.ball.position),
MilliVec::default(),
&mut events,
);
game.ball.position = vec2(205.0, 55.0);
game.check_sensor_objects(
MilliVec::from_position(vec2(205.0, 60.0)),
MilliVec { x: 0, y: -5_000 },
&mut events,
);
assert_eq!(game.player().score, 1_000);
}
#[test]
fn claw_release_table_decodes_the_original_thousandth_pixel_coordinates() {
for (frame, expected) in [
+39
View File
@@ -204,6 +204,45 @@ pub fn collide_with_circle(
true
}
/// Test a non-physical circle record against the complete ball-center path.
pub fn path_intersects_circle(
old_position: MilliVec,
velocity: MilliVec,
center: Vec2,
radius: f32,
) -> bool {
let center = MilliVec::from_position(center);
let offset = subtract(old_position, center);
let radius_milli = (radius * 1_000.0).round() as i32;
let radius_squared = i64::from(radius_milli).pow(2);
let old_distance_squared = i64::from(offset.x).pow(2) + i64::from(offset.y).pow(2);
if old_distance_squared <= radius_squared {
return true;
}
let next = offset.add(velocity);
let next_distance_squared = i64::from(next.x).pow(2) + i64::from(next.y).pow(2);
if next_distance_squared <= radius_squared {
return true;
}
let vx = f64::from(velocity.x);
let vy = f64::from(velocity.y);
let offset_x = f64::from(offset.x);
let offset_y = f64::from(offset.y);
let quadratic_a = vx * vx + vy * vy;
if quadratic_a == 0.0 {
return false;
}
let quadratic_b = 2.0 * (offset_x * vx + offset_y * vy);
let quadratic_c = old_distance_squared as f64 - f64::from(radius_milli).powi(2);
let discriminant = quadratic_b * quadratic_b - 4.0 * quadratic_a * quadratic_c;
if discriminant < 0.0 {
return false;
}
let progress = (-quadratic_b - discriminant.sqrt()) / (2.0 * quadratic_a);
0.0 < progress && progress <= 1.0
}
#[cfg(test)]
mod tests {
use super::*;
+35
View File
@@ -59,6 +59,25 @@ pub struct StaticCircle {
pub normal_kick: f32,
}
#[derive(Clone, Copy, Debug)]
pub struct TargetSensor {
pub id: u8,
pub center: Vec2,
pub radius: f32,
pub score: u32,
}
impl TargetSensor {
const fn new(id: u8, center: Vec2, radius: f32, score: u32) -> Self {
Self {
id,
center,
radius,
score,
}
}
}
impl StaticCircle {
const fn new(id: u8, center: Vec2, contact_radius: f32) -> Self {
let (normal_rebound, tangent_coupling, normal_kick) = match id {
@@ -242,6 +261,22 @@ pub const BUMPERS: [StaticCircle; 3] = [
StaticCircle::new(53, Vec2::new(219.0, 165.0), 23.0),
];
/// Initially active type-4 circle records. Objects 149, 153, and 154 are
/// mechanism-controlled and begin disabled in the original table.
pub const TARGET_SENSORS: [TargetSensor; 11] = [
TargetSensor::new(140, Vec2::new(24.0, 182.0), 12.0, 1_000),
TargetSensor::new(141, Vec2::new(18.0, 162.0), 12.0, 1_000),
TargetSensor::new(142, Vec2::new(17.0, 144.0), 12.0, 1_500),
TargetSensor::new(143, Vec2::new(17.0, 125.0), 12.0, 1_500),
TargetSensor::new(144, Vec2::new(17.0, 106.0), 12.0, 2_000),
TargetSensor::new(145, Vec2::new(17.0, 87.0), 12.0, 2_000),
TargetSensor::new(146, Vec2::new(17.0, 67.0), 12.0, 2_500),
TargetSensor::new(147, Vec2::new(17.0, 48.0), 12.0, 3_000),
TargetSensor::new(150, Vec2::new(205.0, 47.0), 11.0, 500),
TargetSensor::new(151, Vec2::new(233.0, 46.0), 11.0, 500),
TargetSensor::new(152, Vec2::new(264.0, 46.0), 11.0, 500),
];
#[cfg(test)]
mod tests {
use super::*;