From 5eeaf8929d1340c2978c3d8a3e956e95a64966aa Mon Sep 17 00:00:00 2001 From: Jason Dekarske Date: Fri, 10 Jul 2026 23:16:06 -0700 Subject: [PATCH] feat(physics): record trajectories for every moving stone 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 --- backend/src/physics.rs | 126 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 115 insertions(+), 11 deletions(-) diff --git a/backend/src/physics.rs b/backend/src/physics.rs index 4142952..625c342 100644 --- a/backend/src/physics.rs +++ b/backend/src/physics.rs @@ -107,7 +107,7 @@ impl PhysicsWorld { weight: u8, curl: i8, friction: f32, - ) -> Result, String> { + ) -> 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); @@ -132,7 +132,7 @@ impl PhysicsWorld { vy: f32, curl_sign: i8, damping_mult: f32, - ) -> Result, String> { + ) -> Result, String> { let id = self.next_stone_id; self.next_stone_id += 1; @@ -161,15 +161,31 @@ impl PhysicsWorld { self.simulate_until_rest(id) } - fn simulate_until_rest(&mut self, thrown_id: u32) -> Result, String> { - let mut path: Vec<(f32, f32, f32)> = Vec::new(); + fn simulate_until_rest(&mut self, thrown_id: u32) -> Result, 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; - if let Some((_, h, _, _)) = self.stone_handles.iter().find(|(id, _, _, _)| *id == thrown_id) { - let body = &self.bodies[*h]; - path.push((body.translation().x, body.translation().y, time)); + // 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 { @@ -180,9 +196,11 @@ impl PhysicsWorld { if sample_accum >= sample_step { sample_accum -= sample_step; - if let Some((_, h, _, _)) = self.stone_handles.iter().find(|(id, _, _, _)| *id == thrown_id) { - let body = &self.bodies[*h]; - path.push((body.translation().x, body.translation().y, time)); + 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)); + } } } @@ -193,7 +211,27 @@ impl PhysicsWorld { self.prune_out_of_play(); - Ok(path) + // 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. @@ -377,4 +415,70 @@ mod tests { 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> = 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"); + } }