pr2-features #2
@ -5,11 +5,16 @@ use crate::protocol::*;
|
|||||||
const MAX_SIM_TIME: f32 = 30.0;
|
const MAX_SIM_TIME: f32 = 30.0;
|
||||||
const REST_SPEED: f32 = 0.04;
|
const REST_SPEED: f32 = 0.04;
|
||||||
const REST_ANGULAR_SPEED: f32 = 0.05;
|
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;
|
const G: f32 = 9.80665;
|
||||||
|
|
||||||
|
/// Initial |ω| for full curl: 5 rotations over 14 s (rad/s).
|
||||||
|
/// Sign follows curl_sign; clockwise (curl>0) uses +ω0 in spawn (see apply_curl).
|
||||||
|
pub const INITIAL_OMEGA: f32 = 5.0 * 2.0 * std::f32::consts::PI / 14.0;
|
||||||
|
|
||||||
|
/// Lateral speed scale: v_lat = curl_sign * CURL_LAT_K / max(v_forward, ε).
|
||||||
|
/// Clockwise curl → right (+x when moving +y). Calibrated for clockwise_curl_moves_right.
|
||||||
|
pub const CURL_LAT_K: f32 = 0.06;
|
||||||
|
|
||||||
/// Calibrated initial speed (m/s) for a mid draw that stops near the tee line
|
/// 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.
|
/// with friction_scalar = 1.0, curl = 0, broom aimed at HOUSE_CENTER.
|
||||||
pub const DRAW_VELOCITY: f32 = 2.38;
|
pub const DRAW_VELOCITY: f32 = 2.38;
|
||||||
@ -160,10 +165,12 @@ impl PhysicsWorld {
|
|||||||
let id = self.next_stone_id;
|
let id = self.next_stone_id;
|
||||||
self.next_stone_id += 1;
|
self.next_stone_id += 1;
|
||||||
|
|
||||||
|
// Clockwise curl (curl_sign > 0) → positive ω0; lateral model maps that to +x.
|
||||||
|
let omega0 = curl_sign as f32 * INITIAL_OMEGA;
|
||||||
let body = RigidBodyBuilder::dynamic()
|
let body = RigidBodyBuilder::dynamic()
|
||||||
.translation(Vector::new(x, y))
|
.translation(Vector::new(x, y))
|
||||||
.linvel(Vector::new(vx, vy))
|
.linvel(Vector::new(vx, vy))
|
||||||
.angvel(0.0)
|
.angvel(omega0)
|
||||||
.linear_damping(0.0)
|
.linear_damping(0.0)
|
||||||
.angular_damping(0.0)
|
.angular_damping(0.0)
|
||||||
.can_sleep(false)
|
.can_sleep(false)
|
||||||
@ -187,11 +194,9 @@ impl PhysicsWorld {
|
|||||||
self.simulate_until_rest(id)
|
self.simulate_until_rest(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn simulate_until_rest(&mut self, thrown_id: u32) -> Result<Vec<StoneTrajectory>, String> {
|
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
|
// Path samples are (x, y, theta). Client time is sample_index / SAMPLE_RATE_HZ.
|
||||||
// frontend's existing trajectory helpers (which expect the thrown stone to
|
// All stones share the same sample clock from the thrown stone's release.
|
||||||
// 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;
|
||||||
@ -203,11 +208,12 @@ impl PhysicsWorld {
|
|||||||
.map(|(id, handle, _, _, _)| (*id, *handle, Vec::new()))
|
.map(|(id, handle, _, _, _)| (*id, *handle, Vec::new()))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Record the initial sample at t=0 for every stone.
|
// Record the initial sample for every stone.
|
||||||
for (id, handle, path) in &mut paths {
|
for (id, handle, path) in &mut paths {
|
||||||
if let Some(body) = self.bodies.get(*handle) {
|
if let Some(body) = self.bodies.get(*handle) {
|
||||||
let pos = body.translation();
|
let pos = body.translation();
|
||||||
path.push((pos.x, pos.y, time));
|
let theta = body.rotation().angle();
|
||||||
|
path.push((pos.x, pos.y, theta));
|
||||||
} else {
|
} else {
|
||||||
// Body missing for an tracked stone; this should not happen.
|
// Body missing for an tracked stone; this should not happen.
|
||||||
return Err(format!("stone {} has no rigid body", id));
|
return Err(format!("stone {} has no rigid body", id));
|
||||||
@ -226,7 +232,8 @@ impl PhysicsWorld {
|
|||||||
for (_, handle, path) in &mut paths {
|
for (_, handle, path) in &mut paths {
|
||||||
if let Some(body) = self.bodies.get(*handle) {
|
if let Some(body) = self.bodies.get(*handle) {
|
||||||
let pos = body.translation();
|
let pos = body.translation();
|
||||||
path.push((pos.x, pos.y, time));
|
let theta = body.rotation().angle();
|
||||||
|
path.push((pos.x, pos.y, theta));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -238,26 +245,9 @@ impl PhysicsWorld {
|
|||||||
|
|
||||||
self.prune_out_of_play();
|
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
|
Ok(paths
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(id, _, mut path)| {
|
.map(|(id, _, path)| StoneTrajectory { stone_id: id, path })
|
||||||
if thrown_first_t != 0.0 {
|
|
||||||
for (_, _, t) in &mut path {
|
|
||||||
*t -= thrown_first_t;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
StoneTrajectory { stone_id: id, path }
|
|
||||||
})
|
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -289,26 +279,50 @@ impl PhysicsWorld {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rotate each stone's velocity slightly based on its selected curl direction.
|
/// Spin-curl model after drag:
|
||||||
// Right curl (curl_sign = +1) curves toward +x when moving up-sheet (positive y).
|
/// - Angular damping from µ(v)*friction_scalar (slower → larger µ → more damp)
|
||||||
|
/// - Lateral speed target v_lat = curl_sign * CURL_LAT_K / max(v_forward, ε)
|
||||||
|
/// - Clockwise (curl_sign > 0) → right: +x when moving +y
|
||||||
fn apply_curl(&mut self) {
|
fn apply_curl(&mut self) {
|
||||||
for (_, handle, _, curl_sign, _) in &self.stone_handles {
|
const EPS: f32 = 1e-3;
|
||||||
|
|
||||||
|
for (_, handle, _, curl_sign, friction_scalar) in &self.stone_handles {
|
||||||
let body = match self.bodies.get_mut(*handle) {
|
let body = match self.bodies.get_mut(*handle) {
|
||||||
Some(b) => b,
|
Some(b) => b,
|
||||||
None => continue,
|
None => continue,
|
||||||
};
|
};
|
||||||
|
|
||||||
let v = body.linvel();
|
let v = body.linvel();
|
||||||
let speed_sq = v.x * v.x + v.y * v.y;
|
let speed = (v.x * v.x + v.y * v.y).sqrt();
|
||||||
let speed = speed_sq.sqrt();
|
let mu_eff = mu(speed) * *friction_scalar;
|
||||||
if speed < 1e-4 {
|
|
||||||
|
// Angular damping: same µ_eff basis as linear ice friction.
|
||||||
|
let omega = body.angvel();
|
||||||
|
if omega.abs() > 1e-8 {
|
||||||
|
let domega = mu_eff * G / STONE_RADIUS * PHYSICS_DT;
|
||||||
|
let new_omega = if domega >= omega.abs() {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
omega - omega.signum() * domega
|
||||||
|
};
|
||||||
|
body.set_angvel(new_omega, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if *curl_sign == 0 || speed < 1e-4 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let angle = -(*curl_sign as f32) * CURL_RATE * PHYSICS_DT;
|
// Unit forward and right (CW 90° from forward): (ux,uy) → (uy, -ux).
|
||||||
let cos = angle.cos();
|
// Moving +y → right = +x.
|
||||||
let sin = angle.sin();
|
let ux = v.x / speed;
|
||||||
let new_v = Vector::new(v.x * cos - v.y * sin, v.x * sin + v.y * cos);
|
let uy = v.y / speed;
|
||||||
|
let rx = uy;
|
||||||
|
let ry = -ux;
|
||||||
|
|
||||||
|
let v_forward = speed;
|
||||||
|
let v_lat_target = (*curl_sign as f32) * CURL_LAT_K / v_forward.max(EPS);
|
||||||
|
let v_fwd = v.x * ux + v.y * uy;
|
||||||
|
let new_v = Vector::new(v_fwd * ux + v_lat_target * rx, v_fwd * uy + v_lat_target * ry);
|
||||||
body.set_linvel(new_v, true);
|
body.set_linvel(new_v, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -526,6 +540,75 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn initial_angvel_magnitude_matches_5_rot_per_14s() {
|
||||||
|
let expected = 5.0 * 2.0 * std::f32::consts::PI / 14.0;
|
||||||
|
assert!(
|
||||||
|
(INITIAL_OMEGA - expected).abs() < 1e-5,
|
||||||
|
"INITIAL_OMEGA={} expected {}",
|
||||||
|
INITIAL_OMEGA,
|
||||||
|
expected
|
||||||
|
);
|
||||||
|
|
||||||
|
// Early path dθ/dt should be near |ω0| before damping eats much spin.
|
||||||
|
let mut world = PhysicsWorld::new();
|
||||||
|
let traj = world
|
||||||
|
.throw(Team::Red, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, 1, 1.0)
|
||||||
|
.unwrap();
|
||||||
|
let path = &traj[0].path;
|
||||||
|
assert!(path.len() >= 3, "need samples to estimate ω");
|
||||||
|
let dt = 1.0 / SAMPLE_RATE_HZ as f32;
|
||||||
|
let omega_est = (path[1].2 - path[0].2) / dt;
|
||||||
|
assert!(
|
||||||
|
(omega_est.abs() - expected).abs() < expected * 0.35,
|
||||||
|
"early |ω|≈{} should be near {} (5 rot / 14s)",
|
||||||
|
omega_est.abs(),
|
||||||
|
expected
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clockwise_curl_moves_right() {
|
||||||
|
// Clockwise curl (curl > 0) must finish to the right of counterclockwise.
|
||||||
|
let mut cw = PhysicsWorld::new();
|
||||||
|
let cw_traj = cw
|
||||||
|
.throw(Team::Red, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, 1, 1.0)
|
||||||
|
.unwrap();
|
||||||
|
let right_x = cw_traj[0].path.last().map(|p| p.0).unwrap_or(f32::NAN);
|
||||||
|
|
||||||
|
let mut ccw = PhysicsWorld::new();
|
||||||
|
let ccw_traj = ccw
|
||||||
|
.throw(Team::Red, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, -1, 1.0)
|
||||||
|
.unwrap();
|
||||||
|
let left_x = ccw_traj[0].path.last().map(|p| p.0).unwrap_or(f32::NAN);
|
||||||
|
|
||||||
|
println!("clockwise final x={} counterclockwise final x={}", right_x, left_x);
|
||||||
|
assert!(
|
||||||
|
right_x > left_x + 0.05,
|
||||||
|
"clockwise curl should move right: right_x={} left_x={}",
|
||||||
|
right_x,
|
||||||
|
left_x
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn path_samples_include_nonzero_theta_when_spinning() {
|
||||||
|
let mut world = PhysicsWorld::new();
|
||||||
|
let traj = world
|
||||||
|
.throw(Team::Red, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, 1, 1.0)
|
||||||
|
.unwrap();
|
||||||
|
let path = &traj[0].path;
|
||||||
|
let max_abs_theta = path
|
||||||
|
.iter()
|
||||||
|
.map(|(_, _, theta)| theta.abs())
|
||||||
|
.fold(0.0_f32, f32::max);
|
||||||
|
assert!(
|
||||||
|
max_abs_theta > 0.05,
|
||||||
|
"spinning stone path should include nonzero theta, max|θ|={}",
|
||||||
|
max_abs_theta
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn stones_persist_after_multiple_throws() {
|
fn stones_persist_after_multiple_throws() {
|
||||||
let mut world = PhysicsWorld::new();
|
let mut world = PhysicsWorld::new();
|
||||||
@ -614,11 +697,14 @@ mod tests {
|
|||||||
second_path.len()
|
second_path.len()
|
||||||
);
|
);
|
||||||
|
|
||||||
// Both paths should share the same t=0 reference (the thrown stone's release).
|
// Paths are (x, y, theta); both stones must have a sample at release (index 0).
|
||||||
assert_eq!(first_path[0].2, 0.0, "first stone path should start at t=0");
|
assert!(
|
||||||
assert_eq!(
|
first_path[0].2.is_finite(),
|
||||||
second_path[0].2, 0.0,
|
"first stone path should include finite theta"
|
||||||
"thrown stone path should start at t=0"
|
);
|
||||||
|
assert!(
|
||||||
|
second_path[0].2.is_finite(),
|
||||||
|
"thrown stone path should include finite theta"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -109,6 +109,7 @@ pub enum Phase {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct StoneTrajectory {
|
pub struct StoneTrajectory {
|
||||||
pub stone_id: u32,
|
pub stone_id: u32,
|
||||||
|
/// Samples as (x, y, theta). Time is sample_index / SAMPLE_RATE_HZ on the client.
|
||||||
pub path: Vec<(f32, f32, f32)>,
|
pub path: Vec<(f32, f32, f32)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,33 +1,57 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import {
|
import {
|
||||||
|
hogTrimStartIndex,
|
||||||
|
sampleTime,
|
||||||
trimPathToStartAtHogLine,
|
trimPathToStartAtHogLine,
|
||||||
velocityToWeight,
|
velocityToWeight,
|
||||||
weightToVelocity,
|
weightToVelocity,
|
||||||
} from './game-helpers'
|
} from './game-helpers'
|
||||||
|
import { SAMPLE_RATE_HZ } from './protocol'
|
||||||
|
|
||||||
describe('trimPathToStartAtHogLine', () => {
|
describe('trimPathToStartAtHogLine', () => {
|
||||||
it('trims path at the first hog-line crossing and zeroes time', () => {
|
it('trims path at the first hog-line crossing and preserves theta', () => {
|
||||||
|
// (x, y, theta) — time is derived from index after trim
|
||||||
const path: [number, number, number][] = [
|
const path: [number, number, number][] = [
|
||||||
[0, 2, 0],
|
[0, 2, 0.1],
|
||||||
[0, 20, 1],
|
[0, 20, 0.2],
|
||||||
[0, 21.5, 2],
|
[0, 21.5, 0.3],
|
||||||
[0, 30, 3],
|
[0, 30, 0.4],
|
||||||
]
|
]
|
||||||
const trimmed = trimPathToStartAtHogLine(path)
|
const trimmed = trimPathToStartAtHogLine(path)
|
||||||
expect(trimmed[0][1]).toBe(20)
|
expect(trimmed[0][1]).toBe(20)
|
||||||
expect(trimmed[0][2]).toBe(0)
|
expect(trimmed[0][2]).toBe(0.2)
|
||||||
expect(trimmed[trimmed.length - 1][2]).toBe(2)
|
expect(trimmed[trimmed.length - 1][2]).toBe(0.4)
|
||||||
|
expect(trimmed).toHaveLength(3)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns full path when hog line is never reached', () => {
|
it('returns full path when hog line is never reached', () => {
|
||||||
const path: [number, number, number][] = [
|
const path: [number, number, number][] = [
|
||||||
[0, 2, 0],
|
[0, 2, 0],
|
||||||
[0, 10, 1],
|
[0, 10, 0.5],
|
||||||
]
|
]
|
||||||
expect(trimPathToStartAtHogLine(path)).toEqual(path)
|
expect(trimPathToStartAtHogLine(path)).toEqual(path)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('sampleTime', () => {
|
||||||
|
it('is index / SAMPLE_RATE_HZ', () => {
|
||||||
|
expect(sampleTime(0)).toBe(0)
|
||||||
|
expect(sampleTime(SAMPLE_RATE_HZ)).toBe(1)
|
||||||
|
expect(sampleTime(1)).toBeCloseTo(1 / SAMPLE_RATE_HZ)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('hogTrimStartIndex', () => {
|
||||||
|
it('returns index just before hog crossing', () => {
|
||||||
|
const path: [number, number, number][] = [
|
||||||
|
[0, 2, 0],
|
||||||
|
[0, 20, 0],
|
||||||
|
[0, 21.5, 0],
|
||||||
|
]
|
||||||
|
expect(hogTrimStartIndex(path)).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('velocity ↔ weight', () => {
|
describe('velocity ↔ weight', () => {
|
||||||
it('maps endpoints correctly', () => {
|
it('maps endpoints correctly', () => {
|
||||||
expect(velocityToWeight(3.0)).toBe(1)
|
expect(velocityToWeight(3.0)).toBe(1)
|
||||||
@ -43,4 +67,3 @@ describe('velocity ↔ weight', () => {
|
|||||||
expect(weightToVelocity(11)).toBe(6.45)
|
expect(weightToVelocity(11)).toBe(6.45)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,9 @@
|
|||||||
import { HOG_LINE_Y, MAX_SPEED, MIN_SPEED } from './protocol'
|
import { HOG_LINE_Y, MAX_SPEED, MIN_SPEED, SAMPLE_RATE_HZ } from './protocol'
|
||||||
|
|
||||||
|
/** Path samples are (x, y, theta). Time is sample index / SAMPLE_RATE_HZ. */
|
||||||
|
export function sampleTime(index: number): number {
|
||||||
|
return index / SAMPLE_RATE_HZ
|
||||||
|
}
|
||||||
|
|
||||||
export function trimPathToStartAtHogLine(
|
export function trimPathToStartAtHogLine(
|
||||||
path: [number, number, number][],
|
path: [number, number, number][],
|
||||||
@ -7,9 +12,17 @@ export function trimPathToStartAtHogLine(
|
|||||||
const idx = path.findIndex(([, y]) => y >= HOG_LINE_Y)
|
const idx = path.findIndex(([, y]) => y >= HOG_LINE_Y)
|
||||||
if (idx < 0) return path
|
if (idx < 0) return path
|
||||||
// Start just before the hog line crossing so the stone enters smoothly.
|
// Start just before the hog line crossing so the stone enters smoothly.
|
||||||
|
// Theta is preserved; time is rebased via sample index on the trimmed array.
|
||||||
const start = Math.max(0, idx - 1)
|
const start = Math.max(0, idx - 1)
|
||||||
const t0 = path[start][2]
|
return path.slice(start)
|
||||||
return path.slice(start).map(([x, y, t]) => [x, y, t - t0])
|
}
|
||||||
|
|
||||||
|
/** Index at which a path should start for hog-line sync (same as trim start). */
|
||||||
|
export function hogTrimStartIndex(path: [number, number, number][]): number {
|
||||||
|
if (path.length < 2) return 0
|
||||||
|
const idx = path.findIndex(([, y]) => y >= HOG_LINE_Y)
|
||||||
|
if (idx < 0) return 0
|
||||||
|
return Math.max(0, idx - 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function velocityToWeight(velocity: number): number {
|
export function velocityToWeight(velocity: number): number {
|
||||||
@ -23,4 +36,3 @@ export function weightToVelocity(weight: number): number {
|
|||||||
const t = (clamped - 1) / 9
|
const t = (clamped - 1) / 9
|
||||||
return MIN_SPEED + t * (MAX_SPEED - MIN_SPEED)
|
return MIN_SPEED + t * (MAX_SPEED - MIN_SPEED)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { GameModel } from './game-model'
|
import { GameModel } from './game-model'
|
||||||
import type { ServerStoneTrajectory, StoneState } from './protocol'
|
import type { ServerStoneTrajectory, StoneState } from './protocol'
|
||||||
|
import { SAMPLE_RATE_HZ } from './protocol'
|
||||||
|
|
||||||
function stone(partial: Partial<StoneState> & Pick<StoneState, 'id' | 'team'>): StoneState {
|
function stone(partial: Partial<StoneState> & Pick<StoneState, 'id' | 'team'>): StoneState {
|
||||||
return {
|
return {
|
||||||
@ -12,40 +13,42 @@ function stone(partial: Partial<StoneState> & Pick<StoneState, 'id' | 'team'>):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Build (x,y,theta) samples; time comes from index / SAMPLE_RATE_HZ. */
|
||||||
|
function pathSamples(
|
||||||
|
points: { x: number; y: number; theta?: number }[],
|
||||||
|
): [number, number, number][] {
|
||||||
|
return points.map((p) => [p.x, p.y, p.theta ?? 0])
|
||||||
|
}
|
||||||
|
|
||||||
describe('GameModel multi-path trajectory animation', () => {
|
describe('GameModel multi-path trajectory animation', () => {
|
||||||
it('returns two DrawableStones in parallel mid-trajectory', () => {
|
it('returns two DrawableStones in parallel mid-trajectory', () => {
|
||||||
const model = new GameModel()
|
const model = new GameModel()
|
||||||
model.state.stones = [stone({ id: 1, team: 'red' }), stone({ id: 2, team: 'yellow' })]
|
model.state.stones = [stone({ id: 1, team: 'red' }), stone({ id: 2, team: 'yellow' })]
|
||||||
model.state.turnTeam = 'red'
|
model.state.turnTeam = 'red'
|
||||||
|
|
||||||
|
// 21 samples → duration 20/40 = 0.5s; mid at 0.25s is sample 10 → y=11
|
||||||
|
const n = 21
|
||||||
|
const path1 = pathSamples(
|
||||||
|
Array.from({ length: n }, (_, i) => ({ x: 0, y: 10 + i * 0.1, theta: 0 })),
|
||||||
|
)
|
||||||
|
const path2 = pathSamples(
|
||||||
|
Array.from({ length: n }, (_, i) => ({ x: 1, y: 10 + i * 0.1, theta: 0 })),
|
||||||
|
)
|
||||||
|
|
||||||
const paths: ServerStoneTrajectory[] = [
|
const paths: ServerStoneTrajectory[] = [
|
||||||
{
|
{ stone_id: 1, path: path1 },
|
||||||
stone_id: 1,
|
{ stone_id: 2, path: path2 },
|
||||||
path: [
|
|
||||||
[0, 10, 0],
|
|
||||||
[0, 11, 0.5],
|
|
||||||
[0, 12, 1.0],
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
stone_id: 2,
|
|
||||||
path: [
|
|
||||||
[1, 10, 0],
|
|
||||||
[1, 11, 0.5],
|
|
||||||
[1, 12, 1.0],
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
model.startTrajectory(paths)
|
model.startTrajectory(paths)
|
||||||
expect(model.state.animating).toBe(true)
|
expect(model.state.animating).toBe(true)
|
||||||
|
|
||||||
const mid = performance.now() + 500
|
const mid = performance.now() + 250
|
||||||
const drawn = model.tick(mid)
|
const drawn = model.tick(mid)
|
||||||
|
|
||||||
expect(drawn).toHaveLength(2)
|
expect(drawn).toHaveLength(2)
|
||||||
expect(drawn.map((d) => d.team).sort()).toEqual(['red', 'yellow'])
|
expect(drawn.map((d) => d.team).sort()).toEqual(['red', 'yellow'])
|
||||||
// Mid-sample y ≈ 11 for both paths (y never reaches hog line → no trim shift)
|
// Mid-sample y ≈ 11 for both paths (y never reaches hog line → no trim)
|
||||||
for (const d of drawn) {
|
for (const d of drawn) {
|
||||||
expect(d.y).toBeCloseTo(11, 0)
|
expect(d.y).toBeCloseTo(11, 0)
|
||||||
}
|
}
|
||||||
@ -56,19 +59,20 @@ describe('GameModel multi-path trajectory animation', () => {
|
|||||||
model.state.stones = [stone({ id: 1, team: 'red' })]
|
model.state.stones = [stone({ id: 1, team: 'red' })]
|
||||||
model.state.turnTeam = 'red'
|
model.state.turnTeam = 'red'
|
||||||
|
|
||||||
|
// 3 samples → max t = 2/40 = 0.05s
|
||||||
model.startTrajectory([
|
model.startTrajectory([
|
||||||
{
|
{
|
||||||
stone_id: 1,
|
stone_id: 1,
|
||||||
path: [
|
path: pathSamples([
|
||||||
[0, 10, 0],
|
{ x: 0, y: 10 },
|
||||||
[0, 10.5, 0.2],
|
{ x: 0, y: 10.5 },
|
||||||
[0, 11, 0.4],
|
{ x: 0, y: 11 },
|
||||||
],
|
]),
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
expect(model.state.animating).toBe(true)
|
expect(model.state.animating).toBe(true)
|
||||||
|
|
||||||
const afterEnd = performance.now() + 500
|
const afterEnd = performance.now() + 200
|
||||||
const drawn = model.tick(afterEnd)
|
const drawn = model.tick(afterEnd)
|
||||||
|
|
||||||
expect(drawn).toEqual([])
|
expect(drawn).toEqual([])
|
||||||
@ -81,17 +85,17 @@ describe('GameModel multi-path trajectory animation', () => {
|
|||||||
model.state.stones = [stone({ id: 1, team: 'yellow', x: 0.5, y: 35 })]
|
model.state.stones = [stone({ id: 1, team: 'yellow', x: 0.5, y: 35 })]
|
||||||
model.state.turnTeam = 'red'
|
model.state.turnTeam = 'red'
|
||||||
|
|
||||||
const thrownPath: [number, number, number][] = [
|
const thrownPath = pathSamples([
|
||||||
[0, 2, 0],
|
{ x: 0, y: 2, theta: 0.1 },
|
||||||
[0, 20, 1],
|
{ x: 0, y: 20, theta: 0.2 },
|
||||||
[0, 21.5, 2],
|
{ x: 0, y: 21.5, theta: 0.3 },
|
||||||
[0, 30, 3],
|
{ x: 0, y: 30, theta: 0.4 },
|
||||||
]
|
])
|
||||||
|
|
||||||
model.startTrajectory([{ stone_id: 2, path: thrownPath }])
|
model.startTrajectory([{ stone_id: 2, path: thrownPath }])
|
||||||
expect(model.state.animating).toBe(true)
|
expect(model.state.animating).toBe(true)
|
||||||
|
|
||||||
// Immediately after start: hog-trimmed path begins at y=20 (sample before hog), t=0
|
// Immediately after start: hog-trimmed path begins at y=20 (sample before hog)
|
||||||
const atStart = performance.now()
|
const atStart = performance.now()
|
||||||
const drawn = model.tick(atStart)
|
const drawn = model.tick(atStart)
|
||||||
|
|
||||||
@ -100,5 +104,11 @@ describe('GameModel multi-path trajectory animation', () => {
|
|||||||
expect(drawn[0].y).toBeCloseTo(20, 0)
|
expect(drawn[0].y).toBeCloseTo(20, 0)
|
||||||
// Must not still be at the hack (y=2)
|
// Must not still be at the hack (y=2)
|
||||||
expect(drawn[0].y).toBeGreaterThan(15)
|
expect(drawn[0].y).toBeGreaterThan(15)
|
||||||
|
// Uses body theta from path (small elapsed may blend toward next sample)
|
||||||
|
expect(drawn[0].rotation).toBeCloseTo(0.2, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses sample index for time (SAMPLE_RATE_HZ)', () => {
|
||||||
|
expect(SAMPLE_RATE_HZ).toBe(40)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { HOG_LINE_Y, HOUSE_CENTER, type DrawableStone, type Phase, type ServerGameStateMessage, type ServerStoneTrajectory, type StoneState, type Team } from './protocol'
|
import { HOUSE_CENTER, type DrawableStone, type Phase, type ServerGameStateMessage, type ServerStoneTrajectory, type StoneState, type Team } from './protocol'
|
||||||
import { trimPathToStartAtHogLine } from './game-helpers'
|
import { hogTrimStartIndex, sampleTime, trimPathToStartAtHogLine } from './game-helpers'
|
||||||
|
|
||||||
export interface GameModelState {
|
export interface GameModelState {
|
||||||
end: number
|
end: number
|
||||||
@ -77,16 +77,12 @@ export class GameModel {
|
|||||||
thrownId = paths[0].stone_id
|
thrownId = paths[0].stone_id
|
||||||
}
|
}
|
||||||
|
|
||||||
let tRef = 0
|
// Shared sample-index trim so multi-stone paths stay on one clock.
|
||||||
|
// Path entries are (x, y, theta); t = index / SAMPLE_RATE_HZ after trim.
|
||||||
|
let startIdx = 0
|
||||||
if (thrownId !== null) {
|
if (thrownId !== null) {
|
||||||
const thrownPath = paths.find((p) => p.stone_id === thrownId)?.path ?? []
|
const thrownPath = paths.find((p) => p.stone_id === thrownId)?.path ?? []
|
||||||
if (thrownPath.length >= 2) {
|
startIdx = hogTrimStartIndex(thrownPath)
|
||||||
const idx = thrownPath.findIndex(([, y]) => y >= HOG_LINE_Y)
|
|
||||||
if (idx >= 0) {
|
|
||||||
const start = Math.max(0, idx - 1)
|
|
||||||
tRef = thrownPath[start][2]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const pathMap = new Map<number, [number, number, number][]>()
|
const pathMap = new Map<number, [number, number, number][]>()
|
||||||
@ -94,10 +90,7 @@ export class GameModel {
|
|||||||
if (stone_id === thrownId) {
|
if (stone_id === thrownId) {
|
||||||
pathMap.set(stone_id, trimPathToStartAtHogLine(path))
|
pathMap.set(stone_id, trimPathToStartAtHogLine(path))
|
||||||
} else {
|
} else {
|
||||||
const shifted = path
|
pathMap.set(stone_id, path.slice(startIdx))
|
||||||
.map(([x, y, t]) => [x, y, t - tRef] as [number, number, number])
|
|
||||||
.filter(([, , t]) => t >= 0)
|
|
||||||
pathMap.set(stone_id, shifted)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -115,7 +108,9 @@ export class GameModel {
|
|||||||
const elapsed = (now - this.animationStartTime) / 1000
|
const elapsed = (now - this.animationStartTime) / 1000
|
||||||
const maxTotal = Math.max(
|
const maxTotal = Math.max(
|
||||||
0,
|
0,
|
||||||
...Array.from(this.activePaths.values()).map((p) => (p.length > 0 ? p[p.length - 1][2] : 0)),
|
...Array.from(this.activePaths.values()).map((p) =>
|
||||||
|
p.length > 0 ? sampleTime(p.length - 1) : 0,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
if (elapsed >= maxTotal) {
|
if (elapsed >= maxTotal) {
|
||||||
@ -143,31 +138,33 @@ export class GameModel {
|
|||||||
): { x: number; y: number; rotation: number } | null {
|
): { x: number; y: number; rotation: number } | null {
|
||||||
if (path.length === 0) return null
|
if (path.length === 0) return null
|
||||||
if (path.length === 1) {
|
if (path.length === 1) {
|
||||||
const [x, y] = path[0]
|
const [x, y, theta] = path[0]
|
||||||
return { x, y, rotation: 0 }
|
return { x, y, rotation: theta }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (elapsed >= path[path.length - 1][2]) {
|
const lastT = sampleTime(path.length - 1)
|
||||||
|
if (elapsed >= lastT) {
|
||||||
const last = path[path.length - 1]
|
const last = path[path.length - 1]
|
||||||
const prev = path[path.length - 2]
|
return { x: last[0], y: last[1], rotation: last[2] }
|
||||||
const dx = last[0] - prev[0]
|
|
||||||
const dy = last[1] - prev[1]
|
|
||||||
return { x: last[0], y: last[1], rotation: Math.atan2(dy, dx) * 2 }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Find segment where sampleTime(i) <= elapsed < sampleTime(i+1)
|
||||||
let i = 0
|
let i = 0
|
||||||
while (i + 1 < path.length && path[i + 1][2] < elapsed) i++
|
while (i + 1 < path.length && sampleTime(i + 1) < elapsed) i++
|
||||||
const p0 = path[i]
|
const p0 = path[i]
|
||||||
const p1 = path[i + 1] ?? p0
|
const p1 = path[i + 1] ?? p0
|
||||||
const t0 = path[Math.max(i - 1, 0)]
|
const t0 = sampleTime(i)
|
||||||
const t2 = path[Math.min(i + 2, path.length - 1)]
|
const t1 = sampleTime(i + 1)
|
||||||
const dt = p1[2] - p0[2]
|
const dt = t1 - t0
|
||||||
const t = dt > 0 ? (elapsed - p0[2]) / dt : 0
|
const t = dt > 0 ? (elapsed - t0) / dt : 0
|
||||||
const x = p0[0] + (p1[0] - p0[0]) * t
|
const x = p0[0] + (p1[0] - p0[0]) * t
|
||||||
const y = p0[1] + (p1[1] - p0[1]) * t
|
const y = p0[1] + (p1[1] - p0[1]) * t
|
||||||
const dx = t2[0] - t0[0]
|
// Interpolate body rotation (theta) from path samples
|
||||||
const dy = t2[1] - t0[1]
|
let dTheta = p1[2] - p0[2]
|
||||||
const rotation = Math.atan2(dy, dx) * 2
|
// Unwrap shortest path across ±π
|
||||||
|
if (dTheta > Math.PI) dTheta -= 2 * Math.PI
|
||||||
|
if (dTheta < -Math.PI) dTheta += 2 * Math.PI
|
||||||
|
const rotation = p0[2] + dTheta * t
|
||||||
return { x, y, rotation }
|
return { x, y, rotation }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -64,6 +64,7 @@ export interface ServerGameStateMessage {
|
|||||||
phase: Phase
|
phase: Phase
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Path samples are (x, y, theta). Time is sample_index / SAMPLE_RATE_HZ. */
|
||||||
export interface ServerStoneTrajectory {
|
export interface ServerStoneTrajectory {
|
||||||
stone_id: number
|
stone_id: number
|
||||||
path: [number, number, number][]
|
path: [number, number, number][]
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user