From 72ffa154893e2f93b1dd42130f95ef4d0f247955 Mon Sep 17 00:00:00 2001 From: Jason Dekarske Date: Fri, 10 Jul 2026 23:32:03 -0700 Subject: [PATCH] =?UTF-8?q?feat(physics):=20=C2=B5(v)=20friction,=20pure?= =?UTF-8?q?=20velocity,=20open=20boundaries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/src/game.rs | 8 +- backend/src/physics.rs | 290 ++++++++++++++++++++++++++++++----------- 2 files changed, 222 insertions(+), 76 deletions(-) diff --git a/backend/src/game.rs b/backend/src/game.rs index cf6b2ad..5ba2778 100644 --- a/backend/src/game.rs +++ b/backend/src/game.rs @@ -77,7 +77,13 @@ impl Game { } 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.phase = GamePhase::Simulating; diff --git a/backend/src/physics.rs b/backend/src/physics.rs index 625c342..2c4da92 100644 --- a/backend/src/physics.rs +++ b/backend/src/physics.rs @@ -2,14 +2,50 @@ use rapier2d::prelude::*; use crate::protocol::*; -const LINEAR_DAMPING: f32 = 0.142; -const ANGULAR_DAMPING: f32 = 0.18; const MAX_SIM_TIME: f32 = 30.0; const REST_SPEED: f32 = 0.04; const REST_ANGULAR_SPEED: f32 = 0.05; // Rotation rate of the velocity vector, in rad/s. // Positive curl_sign = right curl -> curves toward +x when moving up-sheet. 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 { gravity: Vector, @@ -24,7 +60,8 @@ pub struct PhysicsWorld { multibody_joints: MultibodyJointSet, ccd_solver: CCDSolver, 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 { @@ -75,52 +112,39 @@ impl PhysicsWorld { self.next_stone_id = 1; } + /// No wall colliders: stones leave play via prune_out_of_play only. fn build_sheet(&mut self) { - let half = SHEET_WIDTH / 2.0 + 0.1; - 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); + // Intentionally empty — open boundaries (no left/right/back bounce). } + /// Throw a stone with pure initial velocity (m/s). + /// `friction_scalar` is clamped to 0.5..=1.5 and multiplies µ. pub fn throw( &mut self, team: Team, broom_x: f32, broom_y: f32, - weight: u8, + velocity: f32, curl: i8, - friction: f32, + friction_scalar: f32, ) -> Result, String> { - let weight = weight.clamp(1, 10) as f32; - let t = (weight - 1.0) / 9.0; - let speed = MIN_SPEED + t * (MAX_SPEED - MIN_SPEED); + let speed = velocity.max(0.0); let dx = broom_x; let dy = broom_y - HACK_Y; let len = (dx * dx + dy * dy).sqrt().max(0.01); let vx = dx / len * speed; let vy = dy / len * speed; - let curl_sign = if curl < 0 { -1 } else { 1 }; - let damping_mult = friction.clamp(0.5, 2.0); + let curl_sign = if curl < 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( @@ -131,7 +155,7 @@ impl PhysicsWorld { vx: f32, vy: f32, curl_sign: i8, - damping_mult: f32, + friction_scalar: f32, ) -> Result, String> { let id = self.next_stone_id; self.next_stone_id += 1; @@ -140,8 +164,8 @@ impl PhysicsWorld { .translation(Vector::new(x, y)) .linvel(Vector::new(vx, vy)) .angvel(0.0) - .linear_damping(LINEAR_DAMPING * damping_mult) - .angular_damping(ANGULAR_DAMPING) + .linear_damping(0.0) + .angular_damping(0.0) .can_sleep(false) .build(); @@ -155,8 +179,10 @@ impl PhysicsWorld { .density(STONE_MASS / (std::f32::consts::PI * STONE_RADIUS * STONE_RADIUS)) .build(); - self.colliders.insert_with_parent(collider, handle, &mut self.bodies); - self.stone_handles.push((id, handle, team, curl_sign)); + self.colliders + .insert_with_parent(collider, handle, &mut self.bodies); + self.stone_handles + .push((id, handle, team, curl_sign, friction_scalar)); self.simulate_until_rest(id) } @@ -174,7 +200,7 @@ impl PhysicsWorld { let mut paths: Vec<(u32, RigidBodyHandle, Vec<(f32, f32, f32)>)> = self .stone_handles .iter() - .map(|(id, handle, _, _)| (*id, *handle, Vec::new())) + .map(|(id, handle, _, _, _)| (*id, *handle, Vec::new())) .collect(); // Record the initial sample at t=0 for every stone. @@ -190,6 +216,7 @@ impl PhysicsWorld { loop { self.step(); + self.apply_ice_friction(); self.apply_curl(); time += PHYSICS_DT; sample_accum += PHYSICS_DT; @@ -234,10 +261,38 @@ impl PhysicsWorld { .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. // Right curl (curl_sign = +1) curves toward +x when moving up-sheet (positive y). 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) { Some(b) => b, None => continue, @@ -260,16 +315,23 @@ impl PhysicsWorld { fn prune_out_of_play(&mut self) { 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) { let pos = body.translation(); let beyond_back = pos.y > BACK_LINE_Y; let short_of_hog = pos.y < HOG_LINE_Y; let outside = pos.x.abs() > SHEET_WIDTH / 2.0; 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 { - 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 { - for (_, handle, _, _) in &self.stone_handles { + for (_, handle, _, _, _) in &self.stone_handles { if let Some(body) = self.bodies.get(*handle) { let v = body.linvel(); let speed = (v.x * v.x + v.y * v.y).sqrt(); @@ -308,7 +370,7 @@ impl PhysicsWorld { pub fn current_stones(&self) -> Vec { 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) { let pos = body.translation(); states.push(StoneState { @@ -326,7 +388,7 @@ impl PhysicsWorld { pub fn stone_states_for_scoring(&self) -> Vec<(u32, Team, f32, f32)> { 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) { let pos = body.translation(); out.push((*id, *team, pos.x, pos.y)); @@ -341,9 +403,11 @@ mod tests { use super::*; fn final_y(world: &PhysicsWorld, id: u32) -> f32 { - world.stone_handles.iter() - .find(|(sid, _, _, _)| *sid == id) - .map(|(_, h, _, _)| { + world + .stone_handles + .iter() + .find(|(sid, _, _, _, _)| *sid == id) + .map(|(_, h, _, _, _)| { let b = &world.bodies[*h]; b.translation().y }) @@ -351,9 +415,11 @@ mod tests { } fn final_x(world: &PhysicsWorld, id: u32) -> f32 { - world.stone_handles.iter() - .find(|(sid, _, _, _)| *sid == id) - .map(|(_, h, _, _)| { + world + .stone_handles + .iter() + .find(|(sid, _, _, _, _)| *sid == id) + .map(|(_, h, _, _, _)| { let b = &world.bodies[*h]; b.translation().x }) @@ -361,30 +427,95 @@ mod tests { } #[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(); - 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 y = final_y(&world, id); - println!("weight 7 final y={}", y); + println!("DRAW_VELOCITY={} final y={}", DRAW_VELOCITY, y); assert!( - (y - HOUSE_CENTER.1).abs() <= 0.5, - "weight-7 draw shot should finish on the tee line, got y={}", - y + (y - HOUSE_CENTER.1).abs() <= 0.8, + "draw shot should finish near tee line, got y={} (tee={})", + 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::>() ); } #[test] fn curl_direction_mirrors_x_offset() { + // Use trajectory last sample (pre-prune): strong curl can exit the sheet. let mut right = PhysicsWorld::new(); - right.throw(Team::Red, 0.0, HOUSE_CENTER.1, 7, 1, 1.0).unwrap(); - let right_id = right.next_stone_id - 1; - let right_x = final_x(&right, right_id); + let right_traj = right + .throw(Team::Red, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, 1, 1.0) + .unwrap(); + let right_x = right_traj[0].path.last().map(|p| p.0).unwrap_or(f32::NAN); let mut left = PhysicsWorld::new(); - left.throw(Team::Red, 0.0, HOUSE_CENTER.1, 7, -1, 1.0).unwrap(); - let left_id = left.next_stone_id - 1; - let left_x = final_x(&left, left_id); + let left_traj = left + .throw(Team::Red, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, -1, 1.0) + .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); assert!( @@ -398,8 +529,13 @@ mod tests { #[test] fn stones_persist_after_multiple_throws() { let mut world = PhysicsWorld::new(); - world.throw(Team::Red, 0.0, HOUSE_CENTER.1, 7, 1, 1.0).unwrap(); - world.throw(Team::Red, 0.0, HOUSE_CENTER.1, 7, -1, 1.0).unwrap(); + world + .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(); assert_eq!(stones.len(), 2, "both stones should remain in the physics world"); @@ -409,9 +545,11 @@ mod tests { #[test] 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(); - 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(); 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() { // 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. - 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 - .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(); 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_x = final_x(&world, first_id); 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(); let second_id = world.next_stone_id - 1; // Re-run the collision throw and capture trajectories. let mut world = PhysicsWorld::new(); 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(); let first_id = world.next_stone_id - 1; let target_y = final_y(&world, first_id); let target_x = final_x(&world, first_id); 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(); let by_id: std::collections::HashMap> = trajectories @@ -479,6 +616,9 @@ mod tests { // 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!(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" + ); } }