forked from eros/curltastic
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:
parent
2f68325801
commit
5eeaf8929d
@ -107,7 +107,7 @@ impl PhysicsWorld {
|
|||||||
weight: u8,
|
weight: u8,
|
||||||
curl: i8,
|
curl: i8,
|
||||||
friction: f32,
|
friction: f32,
|
||||||
) -> Result<Vec<(f32, f32, f32)>, String> {
|
) -> Result<Vec<StoneTrajectory>, String> {
|
||||||
let weight = weight.clamp(1, 10) as f32;
|
let weight = weight.clamp(1, 10) as f32;
|
||||||
let t = (weight - 1.0) / 9.0;
|
let t = (weight - 1.0) / 9.0;
|
||||||
let speed = MIN_SPEED + t * (MAX_SPEED - MIN_SPEED);
|
let speed = MIN_SPEED + t * (MAX_SPEED - MIN_SPEED);
|
||||||
@ -132,7 +132,7 @@ impl PhysicsWorld {
|
|||||||
vy: f32,
|
vy: f32,
|
||||||
curl_sign: i8,
|
curl_sign: i8,
|
||||||
damping_mult: f32,
|
damping_mult: f32,
|
||||||
) -> Result<Vec<(f32, f32, f32)>, 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;
|
||||||
|
|
||||||
@ -161,15 +161,31 @@ impl PhysicsWorld {
|
|||||||
self.simulate_until_rest(id)
|
self.simulate_until_rest(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn simulate_until_rest(&mut self, thrown_id: u32) -> Result<Vec<(f32, f32, f32)>, String> {
|
fn simulate_until_rest(&mut self, thrown_id: u32) -> Result<Vec<StoneTrajectory>, String> {
|
||||||
let mut path: Vec<(f32, f32, f32)> = Vec::new();
|
// 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 sample_step = 1.0 / SAMPLE_RATE_HZ as f32;
|
||||||
let mut sample_accum: f32 = 0.0;
|
let mut sample_accum: f32 = 0.0;
|
||||||
let mut time: f32 = 0.0;
|
let mut time: f32 = 0.0;
|
||||||
|
|
||||||
if let Some((_, h, _, _)) = self.stone_handles.iter().find(|(id, _, _, _)| *id == thrown_id) {
|
// Pre-allocate a path buffer for every stone currently in the world.
|
||||||
let body = &self.bodies[*h];
|
let mut paths: Vec<(u32, RigidBodyHandle, Vec<(f32, f32, f32)>)> = self
|
||||||
path.push((body.translation().x, body.translation().y, time));
|
.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 {
|
loop {
|
||||||
@ -180,9 +196,11 @@ impl PhysicsWorld {
|
|||||||
|
|
||||||
if sample_accum >= sample_step {
|
if sample_accum >= sample_step {
|
||||||
sample_accum -= sample_step;
|
sample_accum -= sample_step;
|
||||||
if let Some((_, h, _, _)) = self.stone_handles.iter().find(|(id, _, _, _)| *id == thrown_id) {
|
for (_, handle, path) in &mut paths {
|
||||||
let body = &self.bodies[*h];
|
if let Some(body) = self.bodies.get(*handle) {
|
||||||
path.push((body.translation().x, body.translation().y, time));
|
let pos = body.translation();
|
||||||
|
path.push((pos.x, pos.y, time));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -193,7 +211,27 @@ impl PhysicsWorld {
|
|||||||
|
|
||||||
self.prune_out_of_play();
|
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.
|
// Rotate each stone's velocity slightly based on its selected curl direction.
|
||||||
@ -377,4 +415,70 @@ mod tests {
|
|||||||
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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user