Collision playback needs all stones sampled on a shared t=0 release clock. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
485 lines
16 KiB
Rust
485 lines
16 KiB
Rust
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;
|
|
|
|
pub struct PhysicsWorld {
|
|
gravity: Vector,
|
|
integration_parameters: IntegrationParameters,
|
|
pipeline: PhysicsPipeline,
|
|
islands: IslandManager,
|
|
broad_phase: DefaultBroadPhase,
|
|
narrow_phase: NarrowPhase,
|
|
bodies: RigidBodySet,
|
|
colliders: ColliderSet,
|
|
impulse_joints: ImpulseJointSet,
|
|
multibody_joints: MultibodyJointSet,
|
|
ccd_solver: CCDSolver,
|
|
next_stone_id: u32,
|
|
stone_handles: Vec<(u32, RigidBodyHandle, Team, i8)>,
|
|
}
|
|
|
|
impl Default for PhysicsWorld {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl PhysicsWorld {
|
|
pub fn new() -> Self {
|
|
let mut integration_parameters = IntegrationParameters::default();
|
|
integration_parameters.dt = PHYSICS_DT;
|
|
integration_parameters.num_solver_iterations = 8;
|
|
|
|
let mut world = Self {
|
|
gravity: Vector::new(0.0, 0.0),
|
|
integration_parameters,
|
|
pipeline: PhysicsPipeline::new(),
|
|
islands: IslandManager::new(),
|
|
broad_phase: DefaultBroadPhase::new(),
|
|
narrow_phase: NarrowPhase::new(),
|
|
bodies: RigidBodySet::new(),
|
|
colliders: ColliderSet::new(),
|
|
impulse_joints: ImpulseJointSet::new(),
|
|
multibody_joints: MultibodyJointSet::new(),
|
|
ccd_solver: CCDSolver::new(),
|
|
next_stone_id: 1,
|
|
stone_handles: Vec::new(),
|
|
};
|
|
world.build_sheet();
|
|
world
|
|
}
|
|
|
|
pub fn reset(&mut self) {
|
|
self.bodies = RigidBodySet::new();
|
|
self.colliders = ColliderSet::new();
|
|
self.islands = IslandManager::new();
|
|
self.broad_phase = DefaultBroadPhase::new();
|
|
self.narrow_phase = NarrowPhase::new();
|
|
self.impulse_joints = ImpulseJointSet::new();
|
|
self.multibody_joints = MultibodyJointSet::new();
|
|
self.ccd_solver = CCDSolver::new();
|
|
self.stone_handles.clear();
|
|
self.build_sheet();
|
|
}
|
|
|
|
pub fn reset_stone_ids(&mut self) {
|
|
self.next_stone_id = 1;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
pub fn throw(
|
|
&mut self,
|
|
team: Team,
|
|
broom_x: f32,
|
|
broom_y: f32,
|
|
weight: u8,
|
|
curl: i8,
|
|
friction: f32,
|
|
) -> Result<Vec<StoneTrajectory>, 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 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);
|
|
|
|
self.spawn_stone(team, 0.0, HACK_Y, vx, vy, curl_sign, damping_mult)
|
|
}
|
|
|
|
fn spawn_stone(
|
|
&mut self,
|
|
team: Team,
|
|
x: f32,
|
|
y: f32,
|
|
vx: f32,
|
|
vy: f32,
|
|
curl_sign: i8,
|
|
damping_mult: f32,
|
|
) -> Result<Vec<StoneTrajectory>, String> {
|
|
let id = self.next_stone_id;
|
|
self.next_stone_id += 1;
|
|
|
|
let body = RigidBodyBuilder::dynamic()
|
|
.translation(Vector::new(x, y))
|
|
.linvel(Vector::new(vx, vy))
|
|
.angvel(0.0)
|
|
.linear_damping(LINEAR_DAMPING * damping_mult)
|
|
.angular_damping(ANGULAR_DAMPING)
|
|
.can_sleep(false)
|
|
.build();
|
|
|
|
let handle = self.bodies.insert(body);
|
|
|
|
let collider = ColliderBuilder::ball(STONE_RADIUS)
|
|
.friction(STONE_FRICTION)
|
|
.friction_combine_rule(CoefficientCombineRule::Average)
|
|
.restitution(STONE_RESTITUTION)
|
|
.restitution_combine_rule(CoefficientCombineRule::Average)
|
|
.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.simulate_until_rest(id)
|
|
}
|
|
|
|
fn simulate_until_rest(&mut self, thrown_id: u32) -> Result<Vec<StoneTrajectory>, String> {
|
|
// All paths share the thrown stone's release instant as t=0. This keeps the
|
|
// frontend's existing trajectory helpers (which expect the thrown stone to
|
|
// start at x=0, y=HACK_Y with t=0) working unchanged while also giving every
|
|
// other stone a consistent timeline.
|
|
let sample_step = 1.0 / SAMPLE_RATE_HZ as f32;
|
|
let mut sample_accum: f32 = 0.0;
|
|
let mut time: f32 = 0.0;
|
|
|
|
// Pre-allocate a path buffer for every stone currently in the world.
|
|
let mut paths: Vec<(u32, RigidBodyHandle, Vec<(f32, f32, f32)>)> = self
|
|
.stone_handles
|
|
.iter()
|
|
.map(|(id, handle, _, _)| (*id, *handle, Vec::new()))
|
|
.collect();
|
|
|
|
// Record the initial sample at t=0 for every stone.
|
|
for (id, handle, path) in &mut paths {
|
|
if let Some(body) = self.bodies.get(*handle) {
|
|
let pos = body.translation();
|
|
path.push((pos.x, pos.y, time));
|
|
} else {
|
|
// Body missing for an tracked stone; this should not happen.
|
|
return Err(format!("stone {} has no rigid body", id));
|
|
}
|
|
}
|
|
|
|
loop {
|
|
self.step();
|
|
self.apply_curl();
|
|
time += PHYSICS_DT;
|
|
sample_accum += PHYSICS_DT;
|
|
|
|
if sample_accum >= sample_step {
|
|
sample_accum -= sample_step;
|
|
for (_, handle, path) in &mut paths {
|
|
if let Some(body) = self.bodies.get(*handle) {
|
|
let pos = body.translation();
|
|
path.push((pos.x, pos.y, time));
|
|
}
|
|
}
|
|
}
|
|
|
|
if self.all_stones_at_rest() || time > MAX_SIM_TIME {
|
|
break;
|
|
}
|
|
}
|
|
|
|
self.prune_out_of_play();
|
|
|
|
// The thrown stone is released at (0.0, HACK_Y). Shift every path in time so
|
|
// that t=0 corresponds to that release instant. Because we already started
|
|
// sampling at the release instant, the first sample time is 0.0 and no shift
|
|
// is required; this comment documents the invariant.
|
|
let thrown_first_t = paths
|
|
.iter()
|
|
.find(|(id, _, _)| *id == thrown_id)
|
|
.and_then(|(_, _, path)| path.first().map(|(_, _, t)| *t))
|
|
.unwrap_or(0.0);
|
|
|
|
Ok(paths
|
|
.into_iter()
|
|
.map(|(id, _, mut path)| {
|
|
if thrown_first_t != 0.0 {
|
|
for (_, _, t) in &mut path {
|
|
*t -= thrown_first_t;
|
|
}
|
|
}
|
|
StoneTrajectory { stone_id: id, path }
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
// 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 {
|
|
let body = match self.bodies.get_mut(*handle) {
|
|
Some(b) => b,
|
|
None => continue,
|
|
};
|
|
|
|
let v = body.linvel();
|
|
let speed_sq = v.x * v.x + v.y * v.y;
|
|
let speed = speed_sq.sqrt();
|
|
if speed < 1e-4 {
|
|
continue;
|
|
}
|
|
|
|
let angle = -(*curl_sign as f32) * CURL_RATE * PHYSICS_DT;
|
|
let cos = angle.cos();
|
|
let sin = angle.sin();
|
|
let new_v = Vector::new(v.x * cos - v.y * sin, v.x * sin + v.y * cos);
|
|
body.set_linvel(new_v, true);
|
|
}
|
|
}
|
|
|
|
fn prune_out_of_play(&mut self) {
|
|
let mut keep = Vec::new();
|
|
for (id, handle, team, curl) 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);
|
|
} else {
|
|
keep.push((id, handle, team, curl));
|
|
}
|
|
}
|
|
}
|
|
self.stone_handles = keep;
|
|
}
|
|
|
|
fn step(&mut self) {
|
|
self.pipeline.step(
|
|
self.gravity,
|
|
&self.integration_parameters,
|
|
&mut self.islands,
|
|
&mut self.broad_phase,
|
|
&mut self.narrow_phase,
|
|
&mut self.bodies,
|
|
&mut self.colliders,
|
|
&mut self.impulse_joints,
|
|
&mut self.multibody_joints,
|
|
&mut self.ccd_solver,
|
|
&(),
|
|
&(),
|
|
);
|
|
}
|
|
|
|
fn all_stones_at_rest(&self) -> bool {
|
|
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();
|
|
if speed > REST_SPEED || body.angvel().abs() > REST_ANGULAR_SPEED {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
true
|
|
}
|
|
|
|
pub fn current_stones(&self) -> Vec<StoneState> {
|
|
let mut states = Vec::new();
|
|
for (id, handle, team, _) in &self.stone_handles {
|
|
if let Some(body) = self.bodies.get(*handle) {
|
|
let pos = body.translation();
|
|
states.push(StoneState {
|
|
id: *id,
|
|
team: *team,
|
|
x: pos.x,
|
|
y: pos.y,
|
|
rotation: body.rotation().angle(),
|
|
active: false,
|
|
});
|
|
}
|
|
}
|
|
states
|
|
}
|
|
|
|
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 {
|
|
if let Some(body) = self.bodies.get(*handle) {
|
|
let pos = body.translation();
|
|
out.push((*id, *team, pos.x, pos.y));
|
|
}
|
|
}
|
|
out
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn final_y(world: &PhysicsWorld, id: u32) -> f32 {
|
|
world.stone_handles.iter()
|
|
.find(|(sid, _, _, _)| *sid == id)
|
|
.map(|(_, h, _, _)| {
|
|
let b = &world.bodies[*h];
|
|
b.translation().y
|
|
})
|
|
.unwrap_or(f32::NAN)
|
|
}
|
|
|
|
fn final_x(world: &PhysicsWorld, id: u32) -> f32 {
|
|
world.stone_handles.iter()
|
|
.find(|(sid, _, _, _)| *sid == id)
|
|
.map(|(_, h, _, _)| {
|
|
let b = &world.bodies[*h];
|
|
b.translation().x
|
|
})
|
|
.unwrap_or(f32::NAN)
|
|
}
|
|
|
|
#[test]
|
|
fn weight_7_lands_on_tee_line() {
|
|
let mut world = PhysicsWorld::new();
|
|
world.throw(Team::Red, 0.0, HOUSE_CENTER.1, 7, 1, 1.0).unwrap();
|
|
let id = world.next_stone_id - 1;
|
|
let y = final_y(&world, id);
|
|
println!("weight 7 final y={}", y);
|
|
assert!(
|
|
(y - HOUSE_CENTER.1).abs() <= 0.5,
|
|
"weight-7 draw shot should finish on the tee line, got y={}",
|
|
y
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn curl_direction_mirrors_x_offset() {
|
|
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 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);
|
|
|
|
println!("right curl final x={} left curl final x={}", right_x, left_x);
|
|
assert!(
|
|
right_x > left_x + 0.05,
|
|
"right curl should finish to the right of left curl: right={} left={}",
|
|
right_x,
|
|
left_x
|
|
);
|
|
}
|
|
|
|
#[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();
|
|
|
|
let stones = world.current_stones();
|
|
assert_eq!(stones.len(), 2, "both stones should remain in the physics world");
|
|
assert_eq!(stones[0].id, 1);
|
|
assert_eq!(stones[1].id, 2);
|
|
}
|
|
|
|
#[test]
|
|
fn out_of_play_stone_is_pruned() {
|
|
// A very light, 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();
|
|
let stones = world.current_stones();
|
|
assert!(stones.is_empty(), "stones short of the hog line should be pruned");
|
|
}
|
|
|
|
#[test]
|
|
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();
|
|
|
|
// First stone: place it far enough up-sheet to stay in play after impact.
|
|
world
|
|
.throw(Team::Red, 0.0, HOUSE_CENTER.1, 7, 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)
|
|
.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)
|
|
.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)
|
|
.unwrap();
|
|
|
|
let by_id: std::collections::HashMap<u32, Vec<(f32, f32, f32)>> = trajectories
|
|
.into_iter()
|
|
.map(|st| (st.stone_id, st.path))
|
|
.collect();
|
|
|
|
assert!(
|
|
by_id.contains_key(&first_id),
|
|
"trajectories should contain the first stone (id={})",
|
|
first_id
|
|
);
|
|
assert!(
|
|
by_id.contains_key(&second_id),
|
|
"trajectories should contain the thrown stone (id={})",
|
|
second_id
|
|
);
|
|
|
|
let first_path = by_id.get(&first_id).unwrap();
|
|
let second_path = by_id.get(&second_id).unwrap();
|
|
assert!(
|
|
first_path.len() > 1,
|
|
"first stone path should have multiple samples, got {}",
|
|
first_path.len()
|
|
);
|
|
assert!(
|
|
second_path.len() > 1,
|
|
"thrown stone path should have multiple samples, got {}",
|
|
second_path.len()
|
|
);
|
|
|
|
// 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");
|
|
}
|
|
}
|