Build TDK Pinball / build (macos-latest) (push) Canceled after 0s
Build TDK Pinball / build (ubuntu-latest) (push) Canceled after 0s
Build TDK Pinball / build (windows-latest) (push) Canceled after 0s
1020 lines
30 KiB
Rust
1020 lines
30 KiB
Rust
//! 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 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 {
|
|
#[allow(dead_code)]
|
|
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;
|
|
}
|
|
// The original computes the excess fraction and subtracts the
|
|
// rounded component from each axis. Scaling directly by
|
|
// `maximum / speed` is mathematically equivalent, but can differ by
|
|
// one millipixel because each Real48 operation is rounded
|
|
// independently.
|
|
let excess = Real48::from_i32(speed.wrapping_sub(maximum)).divide(Real48::from_i32(speed));
|
|
self.x = self
|
|
.x
|
|
.wrapping_sub(Real48::from_i32(self.x).multiply(excess).round_i32());
|
|
self.y = self
|
|
.y
|
|
.wrapping_sub(Real48::from_i32(self.y).multiply(excess).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 tangent_velocity(velocity: MilliVec, normal_x: Real48, normal_y: Real48, length: i32) -> i32 {
|
|
if length <= 0 {
|
|
return 0;
|
|
}
|
|
Real48::from_i32(velocity.x)
|
|
.multiply(normal_x)
|
|
.add(Real48::from_i32(velocity.y).multiply(normal_y))
|
|
.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(),
|
|
});
|
|
// Raw 1000:ef19..f16a uses the standard dot-product tangent both to
|
|
// oppose the current tangential motion and to update retained spin.
|
|
let tangent_velocity = tangent_velocity(velocity, normal_x, normal_y, length);
|
|
let spin_delta = Real48::from_i32(tangent_velocity)
|
|
.multiply(Real48::from_bytes([0x7b, 0x71, 0x3d, 0x0a, 0xd7, 0x23]))
|
|
.multiply(coefficients.tangent);
|
|
let mut tangent_response = Real48::from_i32(collision_velocity.wrapping_abs())
|
|
.multiply(coefficients.tangent)
|
|
.negate();
|
|
if tangent_velocity < 0 {
|
|
tangent_response = tangent_response.negate();
|
|
}
|
|
let projection = spin
|
|
.multiply(Real48::from_bytes([0x7f, 0xcd, 0xcc, 0xcc, 0xcc, 0x4c]))
|
|
.add(tangent_response)
|
|
.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.
|
|
#[cfg(test)]
|
|
pub fn line_collision_candidate(
|
|
old_position: MilliVec,
|
|
velocity: MilliVec,
|
|
line_start: Vec2,
|
|
line_end: Vec2,
|
|
material: CollisionMaterial,
|
|
) -> Option<StaticCollisionCandidate> {
|
|
line_collision_candidate_at(
|
|
old_position,
|
|
old_position.add(velocity),
|
|
velocity,
|
|
line_start,
|
|
line_end,
|
|
material,
|
|
)
|
|
}
|
|
|
|
pub fn line_collision_candidate_at(
|
|
old_position: MilliVec,
|
|
predicted_position: MilliVec,
|
|
velocity: MilliVec,
|
|
line_start: Vec2,
|
|
line_end: Vec2,
|
|
material: CollisionMaterial,
|
|
) -> Option<StaticCollisionCandidate> {
|
|
let start = MilliVec::from_position(line_start);
|
|
let end = MilliVec::from_position(line_end);
|
|
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_position) < -10
|
|
|| cross_at_endpoint(end, old_position, predicted_position) > 10
|
|
{
|
|
return None;
|
|
}
|
|
Some(StaticCollisionCandidate {
|
|
surface_distance: distance,
|
|
normal_x,
|
|
normal_y,
|
|
normal_velocity: collision_velocity,
|
|
material,
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub fn line_collision_response(
|
|
old_position: MilliVec,
|
|
velocity: MilliVec,
|
|
start: Vec2,
|
|
end: Vec2,
|
|
material: CollisionMaterial,
|
|
spin: Real48,
|
|
) -> Option<CollisionResponse> {
|
|
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.
|
|
#[cfg(test)]
|
|
pub fn circle_collision_candidate(
|
|
old_position: MilliVec,
|
|
velocity: MilliVec,
|
|
center: Vec2,
|
|
radius: f32,
|
|
material: CollisionMaterial,
|
|
) -> Option<StaticCollisionCandidate> {
|
|
circle_collision_candidate_at(
|
|
old_position,
|
|
old_position.add(velocity),
|
|
velocity,
|
|
center,
|
|
radius,
|
|
material,
|
|
)
|
|
}
|
|
|
|
pub fn circle_collision_candidate_at(
|
|
old_position: MilliVec,
|
|
predicted_position: MilliVec,
|
|
velocity: MilliVec,
|
|
center: Vec2,
|
|
radius: f32,
|
|
material: CollisionMaterial,
|
|
) -> Option<StaticCollisionCandidate> {
|
|
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 middle = MilliVec {
|
|
x: old_position.x.wrapping_add(predicted_position.x) / 2,
|
|
y: old_position.y.wrapping_add(predicted_position.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,
|
|
})
|
|
}
|
|
|
|
/// Detect the non-capture boundary response of a type-3 record.
|
|
pub fn capture_collision_candidate(
|
|
old_position: MilliVec,
|
|
velocity: MilliVec,
|
|
center: Vec2,
|
|
radius: f32,
|
|
material: CollisionMaterial,
|
|
) -> Option<StaticCollisionCandidate> {
|
|
let center = MilliVec::from_position(center);
|
|
let radius_milli = (radius * 1_000.0).round() as i32;
|
|
let delta = MilliVec {
|
|
x: center.x.wrapping_sub(old_position.x),
|
|
y: center.y.wrapping_sub(old_position.y).wrapping_sub(2_000),
|
|
};
|
|
let surface_distance = milli_distance(delta).wrapping_sub(radius_milli);
|
|
let normal_x = Real48::from_i32(center.y.wrapping_sub(old_position.y));
|
|
let normal_y = Real48::from_i32(old_position.x.wrapping_sub(center.x));
|
|
let length = milli_distance(MilliVec {
|
|
x: normal_x.round_i32(),
|
|
y: normal_y.round_i32(),
|
|
});
|
|
if length == 0 {
|
|
return None;
|
|
}
|
|
let normal_velocity = normal_velocity(velocity, normal_x, normal_y, length);
|
|
if normal_velocity >= 0 || normal_velocity.wrapping_abs() < surface_distance.wrapping_abs() {
|
|
return None;
|
|
}
|
|
Some(StaticCollisionCandidate {
|
|
surface_distance,
|
|
normal_x,
|
|
normal_y,
|
|
normal_velocity,
|
|
material,
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub fn circle_collision_response(
|
|
old_position: MilliVec,
|
|
velocity: MilliVec,
|
|
center: Vec2,
|
|
radius: f32,
|
|
material: CollisionMaterial,
|
|
spin: Real48,
|
|
) -> Option<CollisionResponse> {
|
|
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,
|
|
transfer_enabled: bool,
|
|
) -> Option<BallCollisionResponse> {
|
|
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 other_velocity = if transfer_enabled {
|
|
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();
|
|
MilliVec {
|
|
x: other_velocity.x.wrapping_add(transfer_x),
|
|
y: other_velocity.y.wrapping_sub(transfer_y),
|
|
}
|
|
} else {
|
|
other_velocity
|
|
};
|
|
let (moving_velocity, moving_spin, _) = apply_response(
|
|
velocity,
|
|
spin,
|
|
normal_x,
|
|
normal_y,
|
|
if transfer_enabled {
|
|
collision_velocity.wrapping_sub(1_000)
|
|
} else {
|
|
collision_velocity
|
|
},
|
|
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 speed_clamp_matches_reconstructed_formula() {
|
|
let maximum = 3_800;
|
|
let mut mismatches = Vec::new();
|
|
for x in (-10_000..=10_000).step_by(37) {
|
|
for y in (-10_000..=10_000).step_by(41) {
|
|
let input = MilliVec { x, y };
|
|
let speed = milli_distance(input);
|
|
if speed <= maximum {
|
|
continue;
|
|
}
|
|
let excess = Real48::from_i32(speed - maximum).divide(Real48::from_i32(speed));
|
|
let expected = MilliVec {
|
|
x: x.wrapping_sub(Real48::from_i32(x).multiply(excess).round_i32()),
|
|
y: y.wrapping_sub(Real48::from_i32(y).multiply(excess).round_i32()),
|
|
};
|
|
let mut actual = input;
|
|
actual.clamp_speed(maximum);
|
|
if actual != expected {
|
|
mismatches.push((input, speed, actual, expected));
|
|
if mismatches.len() == 5 {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if mismatches.len() == 5 {
|
|
break;
|
|
}
|
|
}
|
|
assert!(
|
|
mismatches.is_empty(),
|
|
"speed-clamp mismatches: {mismatches:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn outer_shooter_wall_matches_the_live_tangent_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: -255 });
|
|
assert_eq!(
|
|
old.add(velocity),
|
|
MilliVec {
|
|
x: 324_200,
|
|
y: 199_790
|
|
}
|
|
);
|
|
}
|
|
|
|
#[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: 700 });
|
|
assert_eq!(response.surface_distance, 2_000);
|
|
}
|
|
|
|
#[test]
|
|
fn type_three_boundary_builds_the_recovered_radial_candidate() {
|
|
let candidate = capture_collision_candidate(
|
|
MilliVec {
|
|
x: 100_000,
|
|
y: 100_000,
|
|
},
|
|
MilliVec { x: 1_000, y: 0 },
|
|
vec2(103.0, 102.0),
|
|
3.8,
|
|
CollisionMaterial::line(0.6, 0.0),
|
|
)
|
|
.expect("the contacted type-three rim must retain a candidate");
|
|
|
|
assert_eq!(candidate.surface_distance, -800);
|
|
assert_ne!(
|
|
candidate
|
|
.resolve(MilliVec { x: 1_000, y: 0 }, Real48::ZERO)
|
|
.velocity,
|
|
MilliVec { x: 1_000, y: 0 }
|
|
);
|
|
}
|
|
|
|
#[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_live_tangent_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: -30 });
|
|
assert_eq!(
|
|
old.add(velocity),
|
|
MilliVec {
|
|
x: 321_680,
|
|
y: 199_295
|
|
}
|
|
);
|
|
}
|
|
|
|
#[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_live_zero_spin_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: 94, y: 564 });
|
|
assert_eq!(
|
|
old.add(velocity),
|
|
MilliVec {
|
|
x: 183_094,
|
|
y: 70_654
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn bumper_circle_matches_the_live_zero_spin_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: 197, y: 3_096 });
|
|
assert_eq!(
|
|
old.add(velocity),
|
|
MilliVec {
|
|
x: 165_197,
|
|
y: 175_111
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn retained_spin_adds_to_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: 188, 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(),
|
|
true,
|
|
)
|
|
.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);
|
|
|
|
let held_other = 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(),
|
|
false,
|
|
)
|
|
.expect("the held slot still presents a dynamic collision record");
|
|
assert_eq!(held_other.other_velocity, MilliVec::default());
|
|
assert_eq!(held_other.moving_velocity, MilliVec { x: -2_700, y: 0 });
|
|
}
|
|
}
|