feat(physics): µ(v) friction, pure velocity, open boundaries
Replace weight-scaled rapier damping with pure initial velocity (m/s), piecewise-linear ice µ(v) table, and post-step a=−µ_eff·g·unit(v) drag. Remove left/right/back wall colliders; OOB stones leave via prune only. game.rs maps legacy weight→velocity via MIN/MAX until B3. Calibrated DRAW_VELOCITY=2.38 m/s lands near tee (±0.8 m). Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
parent
473e18a751
commit
72ffa15489
@ -77,7 +77,13 @@ impl Game {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.active_stones.clear();
|
self.active_stones.clear();
|
||||||
let trajectory = self.physics.throw(self.turn_team, broom_x, broom_y, weight, curl, friction)?;
|
// B1: physics takes pure velocity (m/s). Map legacy weight 1..=10 until B3.
|
||||||
|
let w = weight.clamp(1, 10) as f32;
|
||||||
|
let t = (w - 1.0) / 9.0;
|
||||||
|
let velocity = MIN_SPEED + t * (MAX_SPEED - MIN_SPEED);
|
||||||
|
let trajectory =
|
||||||
|
self.physics
|
||||||
|
.throw(self.turn_team, broom_x, broom_y, velocity, curl, friction)?;
|
||||||
self.active_stones = self.physics.current_stones();
|
self.active_stones = self.physics.current_stones();
|
||||||
self.phase = GamePhase::Simulating;
|
self.phase = GamePhase::Simulating;
|
||||||
|
|
||||||
|
|||||||
@ -2,14 +2,50 @@ use rapier2d::prelude::*;
|
|||||||
|
|
||||||
use crate::protocol::*;
|
use crate::protocol::*;
|
||||||
|
|
||||||
const LINEAR_DAMPING: f32 = 0.142;
|
|
||||||
const ANGULAR_DAMPING: f32 = 0.18;
|
|
||||||
const MAX_SIM_TIME: f32 = 30.0;
|
const MAX_SIM_TIME: f32 = 30.0;
|
||||||
const REST_SPEED: f32 = 0.04;
|
const REST_SPEED: f32 = 0.04;
|
||||||
const REST_ANGULAR_SPEED: f32 = 0.05;
|
const REST_ANGULAR_SPEED: f32 = 0.05;
|
||||||
// Rotation rate of the velocity vector, in rad/s.
|
// Rotation rate of the velocity vector, in rad/s.
|
||||||
// Positive curl_sign = right curl -> curves toward +x when moving up-sheet.
|
// Positive curl_sign = right curl -> curves toward +x when moving up-sheet.
|
||||||
const CURL_RATE: f32 = 0.010;
|
const CURL_RATE: f32 = 0.010;
|
||||||
|
const G: f32 = 9.80665;
|
||||||
|
|
||||||
|
/// Calibrated initial speed (m/s) for a mid draw that stops near the tee line
|
||||||
|
/// with friction_scalar = 1.0, curl = 0, broom aimed at HOUSE_CENTER.
|
||||||
|
pub const DRAW_VELOCITY: f32 = 2.38;
|
||||||
|
|
||||||
|
/// Ice friction coefficient µ as a function of speed (m/s).
|
||||||
|
/// Piecewise-linear interpolation of the binding table; µ(v≥2.5) = 0.0081.
|
||||||
|
pub fn mu(v: f32) -> f32 {
|
||||||
|
// Binding µ(v) table (v_m/s, µ)
|
||||||
|
const KNOTS: [(f32, f32); 7] = [
|
||||||
|
(0.0, 0.016),
|
||||||
|
(0.1482, 0.014),
|
||||||
|
(0.3005, 0.0116),
|
||||||
|
(0.4486, 0.0098),
|
||||||
|
(0.7371, 0.0079),
|
||||||
|
(1.0098, 0.0073),
|
||||||
|
(2.5, 0.0081),
|
||||||
|
];
|
||||||
|
|
||||||
|
let speed = v.abs();
|
||||||
|
if speed >= 2.5 {
|
||||||
|
return 0.0081;
|
||||||
|
}
|
||||||
|
for i in 0..KNOTS.len() - 1 {
|
||||||
|
let (v0, mu0) = KNOTS[i];
|
||||||
|
let (v1, mu1) = KNOTS[i + 1];
|
||||||
|
if speed >= v0 && speed <= v1 {
|
||||||
|
let t = if (v1 - v0).abs() < f32::EPSILON {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
(speed - v0) / (v1 - v0)
|
||||||
|
};
|
||||||
|
return mu0 + t * (mu1 - mu0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
0.016
|
||||||
|
}
|
||||||
|
|
||||||
pub struct PhysicsWorld {
|
pub struct PhysicsWorld {
|
||||||
gravity: Vector,
|
gravity: Vector,
|
||||||
@ -24,7 +60,8 @@ pub struct PhysicsWorld {
|
|||||||
multibody_joints: MultibodyJointSet,
|
multibody_joints: MultibodyJointSet,
|
||||||
ccd_solver: CCDSolver,
|
ccd_solver: CCDSolver,
|
||||||
next_stone_id: u32,
|
next_stone_id: u32,
|
||||||
stone_handles: Vec<(u32, RigidBodyHandle, Team, i8)>,
|
/// (id, handle, team, curl_sign, friction_scalar)
|
||||||
|
stone_handles: Vec<(u32, RigidBodyHandle, Team, i8, f32)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for PhysicsWorld {
|
impl Default for PhysicsWorld {
|
||||||
@ -75,52 +112,39 @@ impl PhysicsWorld {
|
|||||||
self.next_stone_id = 1;
|
self.next_stone_id = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// No wall colliders: stones leave play via prune_out_of_play only.
|
||||||
fn build_sheet(&mut self) {
|
fn build_sheet(&mut self) {
|
||||||
let half = SHEET_WIDTH / 2.0 + 0.1;
|
// Intentionally empty — open boundaries (no left/right/back bounce).
|
||||||
let left = ColliderBuilder::cuboid(0.1, SHEET_LENGTH / 2.0 + 1.0)
|
|
||||||
.translation(Vector::new(-half, SHEET_LENGTH / 2.0))
|
|
||||||
.friction(0.0)
|
|
||||||
.restitution(0.0)
|
|
||||||
.build();
|
|
||||||
self.colliders.insert(left);
|
|
||||||
|
|
||||||
let right = ColliderBuilder::cuboid(0.1, SHEET_LENGTH / 2.0 + 1.0)
|
|
||||||
.translation(Vector::new(half, SHEET_LENGTH / 2.0))
|
|
||||||
.friction(0.0)
|
|
||||||
.restitution(0.0)
|
|
||||||
.build();
|
|
||||||
self.colliders.insert(right);
|
|
||||||
|
|
||||||
let back = ColliderBuilder::cuboid(SHEET_WIDTH / 2.0 + 1.0, 0.1)
|
|
||||||
.translation(Vector::new(0.0, SHEET_LENGTH + 0.1))
|
|
||||||
.friction(0.0)
|
|
||||||
.restitution(0.1)
|
|
||||||
.build();
|
|
||||||
self.colliders.insert(back);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Throw a stone with pure initial velocity (m/s).
|
||||||
|
/// `friction_scalar` is clamped to 0.5..=1.5 and multiplies µ.
|
||||||
pub fn throw(
|
pub fn throw(
|
||||||
&mut self,
|
&mut self,
|
||||||
team: Team,
|
team: Team,
|
||||||
broom_x: f32,
|
broom_x: f32,
|
||||||
broom_y: f32,
|
broom_y: f32,
|
||||||
weight: u8,
|
velocity: f32,
|
||||||
curl: i8,
|
curl: i8,
|
||||||
friction: f32,
|
friction_scalar: f32,
|
||||||
) -> Result<Vec<StoneTrajectory>, String> {
|
) -> Result<Vec<StoneTrajectory>, String> {
|
||||||
let weight = weight.clamp(1, 10) as f32;
|
let speed = velocity.max(0.0);
|
||||||
let t = (weight - 1.0) / 9.0;
|
|
||||||
let speed = MIN_SPEED + t * (MAX_SPEED - MIN_SPEED);
|
|
||||||
let dx = broom_x;
|
let dx = broom_x;
|
||||||
let dy = broom_y - HACK_Y;
|
let dy = broom_y - HACK_Y;
|
||||||
let len = (dx * dx + dy * dy).sqrt().max(0.01);
|
let len = (dx * dx + dy * dy).sqrt().max(0.01);
|
||||||
let vx = dx / len * speed;
|
let vx = dx / len * speed;
|
||||||
let vy = dy / len * speed;
|
let vy = dy / len * speed;
|
||||||
|
|
||||||
let curl_sign = if curl < 0 { -1 } else { 1 };
|
let curl_sign = if curl < 0 {
|
||||||
let damping_mult = friction.clamp(0.5, 2.0);
|
-1
|
||||||
|
} else if curl > 0 {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
let friction_scalar = friction_scalar.clamp(0.5, 1.5);
|
||||||
|
|
||||||
self.spawn_stone(team, 0.0, HACK_Y, vx, vy, curl_sign, damping_mult)
|
self.spawn_stone(team, 0.0, HACK_Y, vx, vy, curl_sign, friction_scalar)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_stone(
|
fn spawn_stone(
|
||||||
@ -131,7 +155,7 @@ impl PhysicsWorld {
|
|||||||
vx: f32,
|
vx: f32,
|
||||||
vy: f32,
|
vy: f32,
|
||||||
curl_sign: i8,
|
curl_sign: i8,
|
||||||
damping_mult: f32,
|
friction_scalar: f32,
|
||||||
) -> Result<Vec<StoneTrajectory>, String> {
|
) -> Result<Vec<StoneTrajectory>, String> {
|
||||||
let id = self.next_stone_id;
|
let id = self.next_stone_id;
|
||||||
self.next_stone_id += 1;
|
self.next_stone_id += 1;
|
||||||
@ -140,8 +164,8 @@ impl PhysicsWorld {
|
|||||||
.translation(Vector::new(x, y))
|
.translation(Vector::new(x, y))
|
||||||
.linvel(Vector::new(vx, vy))
|
.linvel(Vector::new(vx, vy))
|
||||||
.angvel(0.0)
|
.angvel(0.0)
|
||||||
.linear_damping(LINEAR_DAMPING * damping_mult)
|
.linear_damping(0.0)
|
||||||
.angular_damping(ANGULAR_DAMPING)
|
.angular_damping(0.0)
|
||||||
.can_sleep(false)
|
.can_sleep(false)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
@ -155,8 +179,10 @@ impl PhysicsWorld {
|
|||||||
.density(STONE_MASS / (std::f32::consts::PI * STONE_RADIUS * STONE_RADIUS))
|
.density(STONE_MASS / (std::f32::consts::PI * STONE_RADIUS * STONE_RADIUS))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
self.colliders.insert_with_parent(collider, handle, &mut self.bodies);
|
self.colliders
|
||||||
self.stone_handles.push((id, handle, team, curl_sign));
|
.insert_with_parent(collider, handle, &mut self.bodies);
|
||||||
|
self.stone_handles
|
||||||
|
.push((id, handle, team, curl_sign, friction_scalar));
|
||||||
|
|
||||||
self.simulate_until_rest(id)
|
self.simulate_until_rest(id)
|
||||||
}
|
}
|
||||||
@ -174,7 +200,7 @@ impl PhysicsWorld {
|
|||||||
let mut paths: Vec<(u32, RigidBodyHandle, Vec<(f32, f32, f32)>)> = self
|
let mut paths: Vec<(u32, RigidBodyHandle, Vec<(f32, f32, f32)>)> = self
|
||||||
.stone_handles
|
.stone_handles
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(id, handle, _, _)| (*id, *handle, Vec::new()))
|
.map(|(id, handle, _, _, _)| (*id, *handle, Vec::new()))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Record the initial sample at t=0 for every stone.
|
// Record the initial sample at t=0 for every stone.
|
||||||
@ -190,6 +216,7 @@ impl PhysicsWorld {
|
|||||||
|
|
||||||
loop {
|
loop {
|
||||||
self.step();
|
self.step();
|
||||||
|
self.apply_ice_friction();
|
||||||
self.apply_curl();
|
self.apply_curl();
|
||||||
time += PHYSICS_DT;
|
time += PHYSICS_DT;
|
||||||
sample_accum += PHYSICS_DT;
|
sample_accum += PHYSICS_DT;
|
||||||
@ -234,10 +261,38 @@ impl PhysicsWorld {
|
|||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Apply a = −µ_eff * g * unit(v) after each physics step.
|
||||||
|
/// µ_eff = mu(|v|) * friction_scalar. If velocity would reverse, stop.
|
||||||
|
fn apply_ice_friction(&mut self) {
|
||||||
|
for (_, handle, _, _, friction_scalar) in &self.stone_handles {
|
||||||
|
let body = match self.bodies.get_mut(*handle) {
|
||||||
|
Some(b) => b,
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
let v = body.linvel();
|
||||||
|
let speed = (v.x * v.x + v.y * v.y).sqrt();
|
||||||
|
if speed < 1e-6 {
|
||||||
|
body.set_linvel(Vector::new(0.0, 0.0), true);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mu_eff = mu(speed) * *friction_scalar;
|
||||||
|
let a = mu_eff * G;
|
||||||
|
let dv = a * PHYSICS_DT;
|
||||||
|
if dv >= speed {
|
||||||
|
body.set_linvel(Vector::new(0.0, 0.0), true);
|
||||||
|
} else {
|
||||||
|
let scale = (speed - dv) / speed;
|
||||||
|
body.set_linvel(Vector::new(v.x * scale, v.y * scale), true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Rotate each stone's velocity slightly based on its selected curl direction.
|
// Rotate each stone's velocity slightly based on its selected curl direction.
|
||||||
// Right curl (curl_sign = +1) curves toward +x when moving up-sheet (positive y).
|
// Right curl (curl_sign = +1) curves toward +x when moving up-sheet (positive y).
|
||||||
fn apply_curl(&mut self) {
|
fn apply_curl(&mut self) {
|
||||||
for (_, handle, _, curl_sign) in &self.stone_handles {
|
for (_, handle, _, curl_sign, _) in &self.stone_handles {
|
||||||
let body = match self.bodies.get_mut(*handle) {
|
let body = match self.bodies.get_mut(*handle) {
|
||||||
Some(b) => b,
|
Some(b) => b,
|
||||||
None => continue,
|
None => continue,
|
||||||
@ -260,16 +315,23 @@ impl PhysicsWorld {
|
|||||||
|
|
||||||
fn prune_out_of_play(&mut self) {
|
fn prune_out_of_play(&mut self) {
|
||||||
let mut keep = Vec::new();
|
let mut keep = Vec::new();
|
||||||
for (id, handle, team, curl) in self.stone_handles.drain(..) {
|
for (id, handle, team, curl, friction_scalar) in self.stone_handles.drain(..) {
|
||||||
if let Some(body) = self.bodies.get(handle) {
|
if let Some(body) = self.bodies.get(handle) {
|
||||||
let pos = body.translation();
|
let pos = body.translation();
|
||||||
let beyond_back = pos.y > BACK_LINE_Y;
|
let beyond_back = pos.y > BACK_LINE_Y;
|
||||||
let short_of_hog = pos.y < HOG_LINE_Y;
|
let short_of_hog = pos.y < HOG_LINE_Y;
|
||||||
let outside = pos.x.abs() > SHEET_WIDTH / 2.0;
|
let outside = pos.x.abs() > SHEET_WIDTH / 2.0;
|
||||||
if beyond_back || short_of_hog || outside {
|
if beyond_back || short_of_hog || outside {
|
||||||
self.bodies.remove(handle, &mut self.islands, &mut self.colliders, &mut self.impulse_joints, &mut self.multibody_joints, true);
|
self.bodies.remove(
|
||||||
|
handle,
|
||||||
|
&mut self.islands,
|
||||||
|
&mut self.colliders,
|
||||||
|
&mut self.impulse_joints,
|
||||||
|
&mut self.multibody_joints,
|
||||||
|
true,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
keep.push((id, handle, team, curl));
|
keep.push((id, handle, team, curl, friction_scalar));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -294,7 +356,7 @@ impl PhysicsWorld {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn all_stones_at_rest(&self) -> bool {
|
fn all_stones_at_rest(&self) -> bool {
|
||||||
for (_, handle, _, _) in &self.stone_handles {
|
for (_, handle, _, _, _) in &self.stone_handles {
|
||||||
if let Some(body) = self.bodies.get(*handle) {
|
if let Some(body) = self.bodies.get(*handle) {
|
||||||
let v = body.linvel();
|
let v = body.linvel();
|
||||||
let speed = (v.x * v.x + v.y * v.y).sqrt();
|
let speed = (v.x * v.x + v.y * v.y).sqrt();
|
||||||
@ -308,7 +370,7 @@ impl PhysicsWorld {
|
|||||||
|
|
||||||
pub fn current_stones(&self) -> Vec<StoneState> {
|
pub fn current_stones(&self) -> Vec<StoneState> {
|
||||||
let mut states = Vec::new();
|
let mut states = Vec::new();
|
||||||
for (id, handle, team, _) in &self.stone_handles {
|
for (id, handle, team, _, _) in &self.stone_handles {
|
||||||
if let Some(body) = self.bodies.get(*handle) {
|
if let Some(body) = self.bodies.get(*handle) {
|
||||||
let pos = body.translation();
|
let pos = body.translation();
|
||||||
states.push(StoneState {
|
states.push(StoneState {
|
||||||
@ -326,7 +388,7 @@ impl PhysicsWorld {
|
|||||||
|
|
||||||
pub fn stone_states_for_scoring(&self) -> Vec<(u32, Team, f32, f32)> {
|
pub fn stone_states_for_scoring(&self) -> Vec<(u32, Team, f32, f32)> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for (id, handle, team, _) in &self.stone_handles {
|
for (id, handle, team, _, _) in &self.stone_handles {
|
||||||
if let Some(body) = self.bodies.get(*handle) {
|
if let Some(body) = self.bodies.get(*handle) {
|
||||||
let pos = body.translation();
|
let pos = body.translation();
|
||||||
out.push((*id, *team, pos.x, pos.y));
|
out.push((*id, *team, pos.x, pos.y));
|
||||||
@ -341,9 +403,11 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn final_y(world: &PhysicsWorld, id: u32) -> f32 {
|
fn final_y(world: &PhysicsWorld, id: u32) -> f32 {
|
||||||
world.stone_handles.iter()
|
world
|
||||||
.find(|(sid, _, _, _)| *sid == id)
|
.stone_handles
|
||||||
.map(|(_, h, _, _)| {
|
.iter()
|
||||||
|
.find(|(sid, _, _, _, _)| *sid == id)
|
||||||
|
.map(|(_, h, _, _, _)| {
|
||||||
let b = &world.bodies[*h];
|
let b = &world.bodies[*h];
|
||||||
b.translation().y
|
b.translation().y
|
||||||
})
|
})
|
||||||
@ -351,9 +415,11 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn final_x(world: &PhysicsWorld, id: u32) -> f32 {
|
fn final_x(world: &PhysicsWorld, id: u32) -> f32 {
|
||||||
world.stone_handles.iter()
|
world
|
||||||
.find(|(sid, _, _, _)| *sid == id)
|
.stone_handles
|
||||||
.map(|(_, h, _, _)| {
|
.iter()
|
||||||
|
.find(|(sid, _, _, _, _)| *sid == id)
|
||||||
|
.map(|(_, h, _, _, _)| {
|
||||||
let b = &world.bodies[*h];
|
let b = &world.bodies[*h];
|
||||||
b.translation().x
|
b.translation().x
|
||||||
})
|
})
|
||||||
@ -361,30 +427,95 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn weight_7_lands_on_tee_line() {
|
fn mu_at_rest_is_0_016() {
|
||||||
|
assert!((mu(0.0) - 0.016).abs() < 1e-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mu_interpolates_between_knots() {
|
||||||
|
// Midpoint between 0.1482 (0.014) and 0.3005 (0.0116)
|
||||||
|
let v = (0.1482 + 0.3005) / 2.0;
|
||||||
|
let expected = (0.014 + 0.0116) / 2.0;
|
||||||
|
let got = mu(v);
|
||||||
|
assert!(
|
||||||
|
(got - expected).abs() < 1e-5,
|
||||||
|
"mu({}) = {}, expected ~{}",
|
||||||
|
v,
|
||||||
|
got,
|
||||||
|
expected
|
||||||
|
);
|
||||||
|
// High-speed plateau
|
||||||
|
assert!((mu(2.5) - 0.0081).abs() < 1e-6);
|
||||||
|
assert!((mu(5.0) - 0.0081).abs() < 1e-6);
|
||||||
|
// Exact knot
|
||||||
|
assert!((mu(1.0098) - 0.0073).abs() < 1e-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn velocity_draw_lands_on_tee_line() {
|
||||||
let mut world = PhysicsWorld::new();
|
let mut world = PhysicsWorld::new();
|
||||||
world.throw(Team::Red, 0.0, HOUSE_CENTER.1, 7, 1, 1.0).unwrap();
|
// curl=0 so lateral drift does not push the stone OOB before rest.
|
||||||
|
world
|
||||||
|
.throw(Team::Red, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, 0, 1.0)
|
||||||
|
.unwrap();
|
||||||
let id = world.next_stone_id - 1;
|
let id = world.next_stone_id - 1;
|
||||||
let y = final_y(&world, id);
|
let y = final_y(&world, id);
|
||||||
println!("weight 7 final y={}", y);
|
println!("DRAW_VELOCITY={} final y={}", DRAW_VELOCITY, y);
|
||||||
assert!(
|
assert!(
|
||||||
(y - HOUSE_CENTER.1).abs() <= 0.5,
|
(y - HOUSE_CENTER.1).abs() <= 0.8,
|
||||||
"weight-7 draw shot should finish on the tee line, got y={}",
|
"draw shot should finish near tee line, got y={} (tee={})",
|
||||||
y
|
y,
|
||||||
|
HOUSE_CENTER.1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn high_friction_or_low_v_prunes_before_hog() {
|
||||||
|
// Low velocity + high friction_scalar ⇒ short of hog, pruned.
|
||||||
|
let mut world = PhysicsWorld::new();
|
||||||
|
world
|
||||||
|
.throw(Team::Red, 0.0, HOUSE_CENTER.1, 1.0, 0, 1.5)
|
||||||
|
.unwrap();
|
||||||
|
let stones = world.current_stones();
|
||||||
|
assert!(
|
||||||
|
stones.is_empty(),
|
||||||
|
"low-v high-friction throw should be pruned short of hog"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sideline_aim_goes_out_not_bounce() {
|
||||||
|
// Aim so the stone crosses |x| > SHEET_WIDTH/2; with open boundaries it
|
||||||
|
// must be pruned (not bounce off a wall and remain in play).
|
||||||
|
let mut world = PhysicsWorld::new();
|
||||||
|
world
|
||||||
|
.throw(Team::Red, 4.0, 15.0, DRAW_VELOCITY, 0, 1.0)
|
||||||
|
.unwrap();
|
||||||
|
let stones = world.current_stones();
|
||||||
|
assert!(
|
||||||
|
stones.is_empty(),
|
||||||
|
"sideline-bound stone should be pruned, not bounce; remaining={:?}",
|
||||||
|
stones
|
||||||
|
.iter()
|
||||||
|
.map(|s| (s.x, s.y))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn curl_direction_mirrors_x_offset() {
|
fn curl_direction_mirrors_x_offset() {
|
||||||
|
// Use trajectory last sample (pre-prune): strong curl can exit the sheet.
|
||||||
let mut right = PhysicsWorld::new();
|
let mut right = PhysicsWorld::new();
|
||||||
right.throw(Team::Red, 0.0, HOUSE_CENTER.1, 7, 1, 1.0).unwrap();
|
let right_traj = right
|
||||||
let right_id = right.next_stone_id - 1;
|
.throw(Team::Red, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, 1, 1.0)
|
||||||
let right_x = final_x(&right, right_id);
|
.unwrap();
|
||||||
|
let right_x = right_traj[0].path.last().map(|p| p.0).unwrap_or(f32::NAN);
|
||||||
|
|
||||||
let mut left = PhysicsWorld::new();
|
let mut left = PhysicsWorld::new();
|
||||||
left.throw(Team::Red, 0.0, HOUSE_CENTER.1, 7, -1, 1.0).unwrap();
|
let left_traj = left
|
||||||
let left_id = left.next_stone_id - 1;
|
.throw(Team::Red, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, -1, 1.0)
|
||||||
let left_x = final_x(&left, left_id);
|
.unwrap();
|
||||||
|
let left_x = left_traj[0].path.last().map(|p| p.0).unwrap_or(f32::NAN);
|
||||||
|
|
||||||
println!("right curl final x={} left curl final x={}", right_x, left_x);
|
println!("right curl final x={} left curl final x={}", right_x, left_x);
|
||||||
assert!(
|
assert!(
|
||||||
@ -398,8 +529,13 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn stones_persist_after_multiple_throws() {
|
fn stones_persist_after_multiple_throws() {
|
||||||
let mut world = PhysicsWorld::new();
|
let mut world = PhysicsWorld::new();
|
||||||
world.throw(Team::Red, 0.0, HOUSE_CENTER.1, 7, 1, 1.0).unwrap();
|
world
|
||||||
world.throw(Team::Red, 0.0, HOUSE_CENTER.1, 7, -1, 1.0).unwrap();
|
.throw(Team::Red, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, 0, 1.0)
|
||||||
|
.unwrap();
|
||||||
|
// Slight lateral aim so stones don't stack identically; still in-bounds.
|
||||||
|
world
|
||||||
|
.throw(Team::Red, 0.3, HOUSE_CENTER.1, DRAW_VELOCITY, 0, 1.0)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let stones = world.current_stones();
|
let stones = world.current_stones();
|
||||||
assert_eq!(stones.len(), 2, "both stones should remain in the physics world");
|
assert_eq!(stones.len(), 2, "both stones should remain in the physics world");
|
||||||
@ -409,9 +545,11 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn out_of_play_stone_is_pruned() {
|
fn out_of_play_stone_is_pruned() {
|
||||||
// A very light, high-friction throw should stop short of the hog line and be removed.
|
// A very slow, high-friction throw should stop short of the hog line and be removed.
|
||||||
let mut world = PhysicsWorld::new();
|
let mut world = PhysicsWorld::new();
|
||||||
world.throw(Team::Red, 0.0, HOUSE_CENTER.1, 1, 0, 2.0).unwrap();
|
world
|
||||||
|
.throw(Team::Red, 0.0, HOUSE_CENTER.1, 0.8, 0, 1.5)
|
||||||
|
.unwrap();
|
||||||
let stones = world.current_stones();
|
let stones = world.current_stones();
|
||||||
assert!(stones.is_empty(), "stones short of the hog line should be pruned");
|
assert!(stones.is_empty(), "stones short of the hog line should be pruned");
|
||||||
}
|
}
|
||||||
@ -420,32 +558,31 @@ mod tests {
|
|||||||
fn collision_records_trajectories_for_both_stones() {
|
fn collision_records_trajectories_for_both_stones() {
|
||||||
// Place a stationary stone on the center line and throw a second stone
|
// Place a stationary stone on the center line and throw a second stone
|
||||||
// straight at it so they collide. Both stones must have sampled paths.
|
// straight at it so they collide. Both stones must have sampled paths.
|
||||||
let mut world = PhysicsWorld::new();
|
let takeout_v = DRAW_VELOCITY * 1.4;
|
||||||
|
|
||||||
// First stone: place it far enough up-sheet to stay in play after impact.
|
let mut world = PhysicsWorld::new();
|
||||||
world
|
world
|
||||||
.throw(Team::Red, 0.0, HOUSE_CENTER.1, 7, 0, 1.0)
|
.throw(Team::Red, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, 0, 1.0)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let first_id = world.next_stone_id - 1;
|
let first_id = world.next_stone_id - 1;
|
||||||
|
|
||||||
// Second stone: aimed directly at the first stone's final position.
|
|
||||||
let target_y = final_y(&world, first_id);
|
let target_y = final_y(&world, first_id);
|
||||||
let target_x = final_x(&world, first_id);
|
let target_x = final_x(&world, first_id);
|
||||||
world
|
world
|
||||||
.throw(Team::Yellow, target_x, target_y, 10, 0, 1.0)
|
.throw(Team::Yellow, target_x, target_y, takeout_v, 0, 1.0)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let second_id = world.next_stone_id - 1;
|
let second_id = world.next_stone_id - 1;
|
||||||
|
|
||||||
// Re-run the collision throw and capture trajectories.
|
// Re-run the collision throw and capture trajectories.
|
||||||
let mut world = PhysicsWorld::new();
|
let mut world = PhysicsWorld::new();
|
||||||
world
|
world
|
||||||
.throw(Team::Red, 0.0, HOUSE_CENTER.1, 7, 0, 1.0)
|
.throw(Team::Red, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, 0, 1.0)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let first_id = world.next_stone_id - 1;
|
let first_id = world.next_stone_id - 1;
|
||||||
let target_y = final_y(&world, first_id);
|
let target_y = final_y(&world, first_id);
|
||||||
let target_x = final_x(&world, first_id);
|
let target_x = final_x(&world, first_id);
|
||||||
let trajectories = world
|
let trajectories = world
|
||||||
.throw(Team::Yellow, target_x, target_y, 10, 0, 1.0)
|
.throw(Team::Yellow, target_x, target_y, takeout_v, 0, 1.0)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let by_id: std::collections::HashMap<u32, Vec<(f32, f32, f32)>> = trajectories
|
let by_id: std::collections::HashMap<u32, Vec<(f32, f32, f32)>> = trajectories
|
||||||
@ -479,6 +616,9 @@ mod tests {
|
|||||||
|
|
||||||
// Both paths should share the same t=0 reference (the thrown stone's release).
|
// Both paths should share the same t=0 reference (the thrown stone's release).
|
||||||
assert_eq!(first_path[0].2, 0.0, "first stone path should start at t=0");
|
assert_eq!(first_path[0].2, 0.0, "first stone path should start at t=0");
|
||||||
assert_eq!(second_path[0].2, 0.0, "thrown stone path should start at t=0");
|
assert_eq!(
|
||||||
|
second_path[0].2, 0.0,
|
||||||
|
"thrown stone path should start at t=0"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user