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 <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Jason Dekarske 2026-07-10 23:16:06 -07:00
parent 2f68325801
commit 5eeaf8929d

View File

@ -107,7 +107,7 @@ impl PhysicsWorld {
weight: u8,
curl: i8,
friction: f32,
) -> Result<Vec<(f32, f32, f32)>, String> {
) -> 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);
@ -132,7 +132,7 @@ impl PhysicsWorld {
vy: f32,
curl_sign: i8,
damping_mult: f32,
) -> Result<Vec<(f32, f32, f32)>, String> {
) -> Result<Vec<StoneTrajectory>, 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<Vec<(f32, f32, f32)>, String> {
let mut path: Vec<(f32, f32, f32)> = Vec::new();
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;
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<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");
}
}