pr2-features #2

Merged
eros merged 9 commits from pr2-features into main 2026-07-11 13:15:51 -07:00
2 changed files with 222 additions and 76 deletions
Showing only changes of commit 72ffa15489 - Show all commits

View File

@ -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;

View File

@ -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"
);
} }
} }