//! Fixed-point primitives recovered from the original Win16 physics loop. #![allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)] use macroquad::prelude::{Vec2, vec2}; use crate::real48::Real48; pub const STEP_SECONDS: f32 = 0.010; pub const GRAVITY_MILLI_PER_STEP: i32 = 15; pub const MAXIMUM_SPEED_MILLI_PER_STEP: i32 = 3_800; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct MilliVec { pub x: i32, pub y: i32, } #[derive(Clone, Copy, Debug)] pub struct CollisionResponse { pub surface_distance: i32, pub velocity: MilliVec, pub spin: Real48, pub auxiliary_fired: bool, } #[derive(Clone, Copy, Debug)] pub struct StaticCollisionCandidate { pub surface_distance: i32, normal_x: Real48, normal_y: Real48, normal_velocity: i32, material: CollisionMaterial, } #[derive(Clone, Copy, Debug)] pub struct CollisionMaterial { pub normal_rebound: f64, pub tangent_coupling: f64, pub response_auxiliary: f64, pub normal_kick: f64, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct BallCollisionResponse { pub moving_velocity: MilliVec, pub moving_spin: Real48, pub other_velocity: MilliVec, pub surface_distance: i32, } impl CollisionMaterial { pub const fn line(normal_rebound: f64, tangent_coupling: f64) -> Self { Self { normal_rebound, tangent_coupling, response_auxiliary: 0.0, normal_kick: 0.0, } } pub const fn line_with_kick( normal_rebound: f64, tangent_coupling: f64, response_auxiliary: f64, normal_kick: f64, ) -> Self { Self { normal_rebound, tangent_coupling, response_auxiliary, normal_kick, } } pub const fn circle( normal_rebound: f64, tangent_coupling: f64, normal_kick: f64, ) -> Self { Self { normal_rebound, tangent_coupling, response_auxiliary: if normal_kick == 0.0 { 0.0 } else { -0.1 }, normal_kick, } } fn real48(self) -> ResponseCoefficients { ResponseCoefficients { normal: coefficient(self.normal_rebound), tangent: coefficient(self.tangent_coupling), auxiliary: coefficient(self.response_auxiliary), kick: coefficient(self.normal_kick), } } } impl StaticCollisionCandidate { pub fn resolve(self, velocity: MilliVec, spin: Real48) -> CollisionResponse { let (velocity, spin, auxiliary_fired) = apply_response( velocity, spin, self.normal_x, self.normal_y, self.normal_velocity, self.material.real48(), ); CollisionResponse { surface_distance: self.surface_distance, velocity, spin, auxiliary_fired, } } } #[derive(Clone, Copy)] struct ResponseCoefficients { normal: Real48, tangent: Real48, auxiliary: Real48, kick: Real48, } const ZERO: Real48 = Real48::ZERO; const ONE: Real48 = Real48::from_bytes([0x81, 0, 0, 0, 0, 0]); const THOUSAND: Real48 = Real48::from_bytes([0x8a, 0, 0, 0, 0, 0x7a]); impl MilliVec { pub fn from_position(position: Vec2) -> Self { Self { x: (position.x * 1_000.0).round() as i32, y: (position.y * 1_000.0).round() as i32, } } pub fn from_velocity_per_second(velocity: Vec2) -> Self { Self { x: (velocity.x * 10.0).round() as i32, y: (velocity.y * 10.0).round() as i32, } } pub fn to_position(self) -> Vec2 { vec2( scaled_f32_with_exact_roundtrip(self.x, 1_000.0), scaled_f32_with_exact_roundtrip(self.y, 1_000.0), ) } pub fn to_velocity_per_second(self) -> Vec2 { vec2( scaled_f32_with_exact_roundtrip(self.x, 10.0), scaled_f32_with_exact_roundtrip(self.y, 10.0), ) } pub const fn add(self, other: Self) -> Self { Self { x: self.x + other.x, y: self.y + other.y, } } pub fn clamp_speed(&mut self, maximum: i32) { let speed = milli_distance(*self); if speed <= maximum { return; } let scale = Real48::from_i32(maximum).divide(Real48::from_i32(speed)); self.x = Real48::from_i32(self.x).multiply(scale).round_i32(); self.y = Real48::from_i32(self.y).multiply(scale).round_i32(); } } fn scaled_f32_with_exact_roundtrip(value: i32, scale: f32) -> f32 { let mut projected = value as f32 / scale; for _ in 0..4 { let recovered = (projected * scale).round() as i32; if recovered == value { return projected; } projected = if recovered < value { projected.next_up() } else { projected.next_down() }; } debug_assert_eq!((projected * scale).round() as i32, value); projected } const fn subtract(left: MilliVec, right: MilliVec) -> MilliVec { MilliVec { x: left.x - right.x, y: left.y - right.y, } } fn coefficient(value: f64) -> Real48 { let scaled = (value * 100.0).round() as i32; let coefficient = match scaled.wrapping_abs() { 0 => ZERO, 5 => Real48::from_bytes([0x7c, 0xcd, 0xcc, 0xcc, 0xcc, 0x4c]), 10 => Real48::from_bytes([0x7d, 0xcd, 0xcc, 0xcc, 0xcc, 0x4c]), 20 => Real48::from_bytes([0x7e, 0xcd, 0xcc, 0xcc, 0xcc, 0x4c]), 30 => Real48::from_bytes([0x7f, 0x9a, 0x99, 0x99, 0x99, 0x19]), 40 => Real48::from_bytes([0x7f, 0xcd, 0xcc, 0xcc, 0xcc, 0x4c]), 50 => Real48::from_bytes([0x80, 0, 0, 0, 0, 0]), 60 => Real48::from_bytes([0x80, 0x9a, 0x99, 0x99, 0x99, 0x19]), 80 => Real48::from_bytes([0x80, 0xcd, 0xcc, 0xcc, 0xcc, 0x4c]), 90 => Real48::from_bytes([0x80, 0x66, 0x66, 0x66, 0x66, 0x66]), other => panic!("unsupported binary Real48 coefficient {other}"), }; if scaled < 0 { coefficient.negate() } else { coefficient } } pub fn milli_distance(delta: MilliVec) -> i32 { let x = Real48::from_i32(delta.x).divide(THOUSAND); let y = Real48::from_i32(delta.y).divide(THOUSAND); x.square() .add(y.square()) .sqrt() .multiply(THOUSAND) .round_i32() } fn normal_velocity(velocity: MilliVec, normal_x: Real48, normal_y: Real48, length: i32) -> i32 { if length <= 0 { return 0; } Real48::from_i32(velocity.x) .multiply(normal_y) .subtract(Real48::from_i32(velocity.y).multiply(normal_x)) .divide(Real48::from_i32(length)) .round_i32() } fn cross_at_endpoint( point: MilliVec, current: MilliVec, predicted: MilliVec, ) -> i32 { predicted .x .wrapping_sub(point.x) .wrapping_mul(point.y.wrapping_sub(current.y)) .wrapping_sub( predicted .y .wrapping_sub(point.y) .wrapping_mul(point.x.wrapping_sub(current.x)), ) } fn apply_response( velocity: MilliVec, spin: Real48, normal_x: Real48, normal_y: Real48, collision_velocity: i32, coefficients: ResponseCoefficients, ) -> (MilliVec, Real48, bool) { let length = milli_distance(MilliVec { x: normal_x.round_i32(), y: normal_y.round_i32(), }); let initial_projection = normal_velocity(velocity, normal_x, normal_y, length); let mut spin_delta = Real48::from_i32(initial_projection) .multiply(Real48::from_bytes([0x7b, 0x71, 0x3d, 0x0a, 0xd7, 0x23])) .multiply(coefficients.tangent) .multiply(Real48::from_i32(collision_velocity.wrapping_abs())); if initial_projection < 0 { spin_delta = spin_delta.negate(); } let projection = spin .multiply(Real48::from_bytes([0x7f, 0xcd, 0xcc, 0xcc, 0xcc, 0x4c])) .subtract(coefficients.tangent) .round_i32(); let spin = spin .multiply(Real48::from_bytes([0x80, 0x9a, 0x99, 0x99, 0x99, 0x19])) .add(spin_delta); let mut impulse = Real48::from_i32(collision_velocity) .multiply(ONE.add(coefficients.normal)) .round_i32(); let auxiliary_fired = coefficients.auxiliary.compare(ZERO).is_lt() && Real48::from_i32(MAXIMUM_SPEED_MILLI_PER_STEP) .multiply(coefficients.auxiliary) .round_i32() > collision_velocity; if auxiliary_fired { impulse = impulse.wrapping_sub( Real48::from_i32(MAXIMUM_SPEED_MILLI_PER_STEP) .multiply(coefficients.kick) .round_i32(), ); } let delta_x = Real48::from_i32(projection) .multiply(normal_x) .subtract(Real48::from_i32(impulse).multiply(normal_y)) .divide(Real48::from_i32(length)) .round_i32(); let delta_y = Real48::from_i32(impulse) .multiply(normal_x) .add(Real48::from_i32(projection).multiply(normal_y)) .divide(Real48::from_i32(length)) .round_i32(); ( MilliVec { x: velocity.x.wrapping_add(delta_x), y: velocity.y.wrapping_add(delta_y), }, spin, auxiliary_fired, ) } /// Calculate the original type-2 response in the registered segment's basis. pub fn line_collision_candidate( old_position: MilliVec, velocity: MilliVec, line_start: Vec2, line_end: Vec2, material: CollisionMaterial, ) -> Option { let start = MilliVec::from_position(line_start); let end = MilliVec::from_position(line_end); let predicted = old_position.add(velocity); let segment = subtract(end, start); let normal_x = Real48::from_i32(segment.x); let normal_y = Real48::from_i32(segment.y); let length = milli_distance(segment); let length_units = Real48::from_i32(length).divide(THOUSAND); let horizontal_units = normal_x.divide(THOUSAND); let vertical_units = normal_y.divide(THOUSAND); let distance = Real48::from_i32(old_position.x.wrapping_sub(start.x)) .multiply(vertical_units) .subtract( Real48::from_i32(old_position.y.wrapping_sub(start.y)).multiply(horizontal_units), ) .divide(length_units); let distance = if distance.compare(ZERO).is_lt() { ZERO.subtract(distance) } else { distance } .round_i32(); let collision_velocity = Real48::from_i32(velocity.x) .multiply(vertical_units) .subtract(Real48::from_i32(velocity.y).multiply(horizontal_units)) .divide(length_units) .round_i32(); let speed = milli_distance(velocity); if distance > speed || collision_velocity > 10 || distance.wrapping_sub(10) > collision_velocity.wrapping_abs() || cross_at_endpoint(start, old_position, predicted) < -10 || cross_at_endpoint(end, old_position, predicted) > 10 { return None; } Some(StaticCollisionCandidate { surface_distance: distance, normal_x, normal_y, normal_velocity: collision_velocity, material, }) } pub fn line_collision_response( old_position: MilliVec, velocity: MilliVec, start: Vec2, end: Vec2, material: CollisionMaterial, spin: Real48, ) -> Option { line_collision_candidate(old_position, velocity, start, end, material) .map(|candidate| candidate.resolve(velocity, spin)) } #[cfg(test)] pub fn collide_with_line( old_position: MilliVec, velocity: &mut MilliVec, line_start: Vec2, line_end: Vec2, normal_rebound: f64, tangent_coupling: f64, ) -> bool { let Some(response) = line_collision_response( old_position, *velocity, line_start, line_end, CollisionMaterial::line(normal_rebound, tangent_coupling), Real48::ZERO, ) else { return false; }; *velocity = response.velocity; true } /// Calculate the original type-1 circle response for a path entering it. pub fn circle_collision_candidate( old_position: MilliVec, velocity: MilliVec, center: Vec2, radius: f32, material: CollisionMaterial, ) -> Option { let center = MilliVec::from_position(center); let radius_milli = (radius * 1_000.0).round() as i32; let mut surface_distance = milli_distance(subtract(center, old_position)).wrapping_sub(radius_milli); let speed = milli_distance(velocity); if surface_distance > speed { return None; } let predicted = old_position.add(velocity); let middle = MilliVec { x: old_position.x.wrapping_add(predicted.x) / 2, y: old_position.y.wrapping_add(predicted.y) / 2, }; let normal_x = Real48::from_i32(center.y.wrapping_sub(middle.y)); let normal_y = Real48::from_i32(middle.x.wrapping_sub(center.x)); let length = milli_distance(MilliVec { x: normal_x.round_i32(), y: normal_y.round_i32(), }); let collision_velocity = normal_velocity(velocity, normal_x, normal_y, length); if collision_velocity >= 0 || surface_distance.wrapping_abs() > collision_velocity.wrapping_abs() { return None; } surface_distance = surface_distance.wrapping_add(3_000); Some(StaticCollisionCandidate { surface_distance, normal_x, normal_y, normal_velocity: collision_velocity, material, }) } pub fn circle_collision_response( old_position: MilliVec, velocity: MilliVec, center: Vec2, radius: f32, material: CollisionMaterial, spin: Real48, ) -> Option { circle_collision_candidate(old_position, velocity, center, radius, material) .map(|candidate| candidate.resolve(velocity, spin)) } pub fn ball_collision_response( old_position: MilliVec, velocity: MilliVec, spin: Real48, other_position: MilliVec, other_velocity: MilliVec, ) -> Option { let mut surface_distance = milli_distance(subtract(other_position, old_position)).wrapping_sub(17_000); let speed = milli_distance(velocity); if surface_distance > speed { return None; } let predicted = old_position.add(velocity); let middle = MilliVec { x: old_position.x.wrapping_add(predicted.x) / 2, y: old_position.y.wrapping_add(predicted.y) / 2, }; let normal_x = Real48::from_i32(other_position.y.wrapping_sub(middle.y)); let normal_y = Real48::from_i32(middle.x.wrapping_sub(other_position.x)); let length = milli_distance(MilliVec { x: normal_x.round_i32(), y: normal_y.round_i32(), }); let collision_velocity = normal_velocity(velocity, normal_x, normal_y, length); if collision_velocity >= 0 || surface_distance.wrapping_abs() > collision_velocity.wrapping_abs() { return None; } surface_distance = surface_distance.wrapping_add(3_000); let transfer_x = Real48::from_i32(collision_velocity) .multiply(normal_y) .divide(Real48::from_i32(length)) .round_i32(); let transfer_y = Real48::from_i32(collision_velocity) .multiply(normal_x) .divide(Real48::from_i32(length)) .round_i32(); let other_velocity = MilliVec { x: other_velocity.x.wrapping_add(transfer_x), y: other_velocity.y.wrapping_sub(transfer_y), }; let (moving_velocity, moving_spin, _) = apply_response( velocity, spin, normal_x, normal_y, collision_velocity.wrapping_sub(1_000), CollisionMaterial::circle(0.9, 0.0, 0.0).real48(), ); Some(BallCollisionResponse { moving_velocity, moving_spin, other_velocity, surface_distance, }) } #[cfg(test)] pub fn collide_with_circle( old_position: MilliVec, velocity: &mut MilliVec, center: Vec2, radius: f32, normal_rebound: f64, tangent_coupling: f64, normal_kick: f64, ) -> bool { let Some(response) = circle_collision_response( old_position, *velocity, center, radius, CollisionMaterial::circle(normal_rebound, tangent_coupling, normal_kick), Real48::ZERO, ) else { return false; }; *velocity = response.velocity; true } /// Test a type-4 record against the original predicted ball position. 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.add(velocity), center); let radius_milli = (radius * 1_000.0).round() as i32; let radius_squared = i64::from(radius_milli).pow(2); i64::from(offset.x).pow(2) + i64::from(offset.y).pow(2) <= radius_squared } #[cfg(test)] mod tests { use super::*; #[test] fn float_views_roundtrip_every_gameplay_velocity_millipixel() { for value in -10_000..=10_000 { let milli = MilliVec { x: value, y: -value }; assert_eq!(MilliVec::from_velocity_per_second(milli.to_velocity_per_second()), milli); } } #[test] fn outer_shooter_wall_matches_the_zero_spin_c_response() { let old = MilliVec { x: 326_000, y: 200_045, }; let mut velocity = MilliVec { x: 3_000, y: 45 }; assert!(collide_with_line( old, &mut velocity, vec2(328.0, 422.0), vec2(328.0, 58.0), 0.6, 0.1, )); assert_eq!(velocity, MilliVec { x: -1_800, y: 45 }); assert_eq!( old.add(velocity), MilliVec { x: 324_200, y: 200_090 } ); } #[test] fn detected_candidate_resolves_against_the_later_motion_state() { let old = MilliVec { x: 326_000, y: 200_000, }; let detected_velocity = MilliVec { x: 3_000, y: 0 }; let candidate = line_collision_candidate( old, detected_velocity, vec2(328.0, 422.0), vec2(328.0, 58.0), CollisionMaterial::line(0.6, 0.1), ) .expect("the candidate must be retained during the record scan"); let response = candidate.resolve(MilliVec { x: 3_000, y: 1_000 }, Real48::ZERO); assert_eq!(response.velocity, MilliVec { x: -1_800, y: 1_000 }); assert_eq!(response.surface_distance, 2_000); } #[test] fn auxiliary_kick_uses_the_recovered_negative_speed_threshold() { let material = CollisionMaterial::line_with_kick(0.5, 0.1, -0.4, 0.4); let fast = line_collision_response( MilliVec { x: 326_000, y: 200_000, }, MilliVec { x: 3_000, y: 0 }, vec2(328.0, 422.0), vec2(328.0, 58.0), material, Real48::ZERO, ) .expect("the fast path must hit the vertical rail"); let slow = line_collision_response( MilliVec { x: 327_500, y: 200_000, }, MilliVec { x: 1_000, y: 0 }, vec2(328.0, 422.0), vec2(328.0, 58.0), material, Real48::ZERO, ) .expect("the slow path must hit the same vertical rail"); assert!(fast.auxiliary_fired); assert!(!slow.auxiliary_fired); assert_ne!(fast.velocity.x, slow.velocity.x); } #[test] fn inner_shooter_wall_matches_the_zero_spin_c_response() { let old = MilliVec { x: 320_600, y: 199_325, }; let mut velocity = MilliVec { x: -1_800, y: -210 }; assert!(collide_with_line( old, &mut velocity, vec2(320.0, 48.0), vec2(320.0, 437.0), 0.6, 0.1, )); assert_eq!(velocity, MilliVec { x: 1_080, y: -210 }); assert_eq!( old.add(velocity), MilliVec { x: 321_680, y: 199_115 } ); } #[test] fn contact_at_the_old_position_does_not_block_launching_away() { let old = MilliVec { x: 325_000, y: 413_000, }; let mut velocity = MilliVec { x: 0, y: -3_000 }; assert!(!collide_with_line( old, &mut velocity, vec2(315.0, 413.0), vec2(332.0, 413.0), 0.1, 0.1, )); } #[test] fn crossing_a_rail_from_its_back_side_is_allowed() { let old = MilliVec { x: 283_708, y: 18_775, }; let mut velocity = MilliVec { x: -1_672, y: -66 }; assert!(!collide_with_line( old, &mut velocity, vec2(277.0, 31.0), vec2(287.0, 12.0), 0.6, 0.1, )); } #[test] fn surface_distance_orders_candidate_contacts() { let old = MilliVec::default(); let velocity = MilliVec { x: 10_000, y: 0 }; let near = line_collision_response( old, velocity, vec2(2.0, 1.0), vec2(2.0, -1.0), CollisionMaterial::line(0.6, 0.1), Real48::ZERO, ) .expect("near rail should be crossed"); let far = line_collision_response( old, velocity, vec2(8.0, 1.0), vec2(8.0, -1.0), CollisionMaterial::line(0.6, 0.1), Real48::ZERO, ) .expect("far rail should be crossed"); assert_eq!(near.surface_distance, 2_000); assert_eq!(far.surface_distance, 8_000); assert!(near.surface_distance < far.surface_distance); } #[test] fn ordinary_circle_matches_the_zero_spin_c_response() { let old = MilliVec { x: 183_000, y: 70_090, }; let mut velocity = MilliVec { x: 0, y: -940 }; assert!(collide_with_circle( old, &mut velocity, vec2(183.0, 59.0), 11.0, 0.6, 0.1, 0.0, )); assert_eq!(velocity, MilliVec { x: 0, y: 564 }); assert_eq!( old.add(velocity), MilliVec { x: 183_000, y: 70_654 } ); } #[test] fn bumper_circle_matches_the_zero_spin_c_response() { let old = MilliVec { x: 165_000, y: 172_015, }; let mut velocity = MilliVec { x: 0, y: -1_970 }; assert!(collide_with_circle( old, &mut velocity, vec2(165.0, 148.0), 23.0, 0.8, 0.1, 0.4, )); assert_eq!(velocity, MilliVec { x: 0, y: 3_096 }); assert_eq!( old.add(velocity), MilliVec { x: 165_000, y: 175_111 } ); } #[test] fn retained_spin_reproduces_the_live_circle_tangent() { let response = circle_collision_response( MilliVec { x: 183_000, y: 70_090, }, MilliVec { x: 0, y: -940 }, vec2(183.0, 59.0), 11.0, CollisionMaterial::circle(0.6, 0.1, 0.0), Real48::from_i32(-235), ) .expect("live probe enters object 155"); assert_eq!(response.velocity, MilliVec { x: 94, y: 564 }); } #[test] fn dynamic_ball_record_transfers_impulse_to_the_other_slot() { let response = ball_collision_response( MilliVec { x: 100_000, y: 100_000, }, MilliVec { x: 3_000, y: 0 }, Real48::ZERO, MilliVec { x: 118_000, y: 100_000, }, MilliVec::default(), ) .expect("moving ball enters the 17-pixel dynamic record"); assert_eq!(response.surface_distance, 4_000); assert_eq!(response.other_velocity, MilliVec { x: 3_000, y: 0 }); assert_eq!(response.moving_velocity, MilliVec { x: -4_600, y: 0 }); assert_eq!(response.moving_spin, Real48::ZERO); } }