pr2-features #2
@ -5,11 +5,16 @@ use crate::protocol::*;
|
||||
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;
|
||||
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
|
||||
/// with friction_scalar = 1.0, curl = 0, broom aimed at HOUSE_CENTER.
|
||||
pub const DRAW_VELOCITY: f32 = 2.38;
|
||||
@ -160,10 +165,12 @@ impl PhysicsWorld {
|
||||
let id = self.next_stone_id;
|
||||
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()
|
||||
.translation(Vector::new(x, y))
|
||||
.linvel(Vector::new(vx, vy))
|
||||
.angvel(0.0)
|
||||
.angvel(omega0)
|
||||
.linear_damping(0.0)
|
||||
.angular_damping(0.0)
|
||||
.can_sleep(false)
|
||||
@ -187,11 +194,9 @@ impl PhysicsWorld {
|
||||
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.
|
||||
fn simulate_until_rest(&mut self, _thrown_id: u32) -> Result<Vec<StoneTrajectory>, String> {
|
||||
// Path samples are (x, y, theta). Client time is sample_index / SAMPLE_RATE_HZ.
|
||||
// All stones share the same sample clock from the thrown stone's release.
|
||||
let sample_step = 1.0 / SAMPLE_RATE_HZ as f32;
|
||||
let mut sample_accum: f32 = 0.0;
|
||||
let mut time: f32 = 0.0;
|
||||
@ -203,11 +208,12 @@ impl PhysicsWorld {
|
||||
.map(|(id, handle, _, _, _)| (*id, *handle, Vec::new()))
|
||||
.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 {
|
||||
if let Some(body) = self.bodies.get(*handle) {
|
||||
let pos = body.translation();
|
||||
path.push((pos.x, pos.y, time));
|
||||
let theta = body.rotation().angle();
|
||||
path.push((pos.x, pos.y, theta));
|
||||
} else {
|
||||
// Body missing for an tracked stone; this should not happen.
|
||||
return Err(format!("stone {} has no rigid body", id));
|
||||
@ -226,7 +232,8 @@ impl PhysicsWorld {
|
||||
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));
|
||||
let theta = body.rotation().angle();
|
||||
path.push((pos.x, pos.y, theta));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -238,26 +245,9 @@ impl PhysicsWorld {
|
||||
|
||||
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 }
|
||||
})
|
||||
.map(|(id, _, path)| StoneTrajectory { stone_id: id, path })
|
||||
.collect())
|
||||
}
|
||||
|
||||
@ -289,26 +279,50 @@ impl PhysicsWorld {
|
||||
}
|
||||
}
|
||||
|
||||
// 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).
|
||||
/// Spin-curl model after drag:
|
||||
/// - 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) {
|
||||
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) {
|
||||
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 {
|
||||
let speed = (v.x * v.x + v.y * v.y).sqrt();
|
||||
let mu_eff = mu(speed) * *friction_scalar;
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
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);
|
||||
// Unit forward and right (CW 90° from forward): (ux,uy) → (uy, -ux).
|
||||
// Moving +y → right = +x.
|
||||
let ux = v.x / speed;
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -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]
|
||||
fn stones_persist_after_multiple_throws() {
|
||||
let mut world = PhysicsWorld::new();
|
||||
@ -614,11 +697,14 @@ mod tests {
|
||||
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"
|
||||
// Paths are (x, y, theta); both stones must have a sample at release (index 0).
|
||||
assert!(
|
||||
first_path[0].2.is_finite(),
|
||||
"first stone path should include finite theta"
|
||||
);
|
||||
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)]
|
||||
pub struct StoneTrajectory {
|
||||
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)>,
|
||||
}
|
||||
|
||||
|
||||
@ -1,33 +1,57 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
hogTrimStartIndex,
|
||||
sampleTime,
|
||||
trimPathToStartAtHogLine,
|
||||
velocityToWeight,
|
||||
weightToVelocity,
|
||||
} from './game-helpers'
|
||||
import { SAMPLE_RATE_HZ } from './protocol'
|
||||
|
||||
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][] = [
|
||||
[0, 2, 0],
|
||||
[0, 20, 1],
|
||||
[0, 21.5, 2],
|
||||
[0, 30, 3],
|
||||
[0, 2, 0.1],
|
||||
[0, 20, 0.2],
|
||||
[0, 21.5, 0.3],
|
||||
[0, 30, 0.4],
|
||||
]
|
||||
const trimmed = trimPathToStartAtHogLine(path)
|
||||
expect(trimmed[0][1]).toBe(20)
|
||||
expect(trimmed[0][2]).toBe(0)
|
||||
expect(trimmed[trimmed.length - 1][2]).toBe(2)
|
||||
expect(trimmed[0][2]).toBe(0.2)
|
||||
expect(trimmed[trimmed.length - 1][2]).toBe(0.4)
|
||||
expect(trimmed).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('returns full path when hog line is never reached', () => {
|
||||
const path: [number, number, number][] = [
|
||||
[0, 2, 0],
|
||||
[0, 10, 1],
|
||||
[0, 10, 0.5],
|
||||
]
|
||||
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', () => {
|
||||
it('maps endpoints correctly', () => {
|
||||
expect(velocityToWeight(3.0)).toBe(1)
|
||||
@ -43,4 +67,3 @@ describe('velocity ↔ weight', () => {
|
||||
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(
|
||||
path: [number, number, number][],
|
||||
@ -7,9 +12,17 @@ export function trimPathToStartAtHogLine(
|
||||
const idx = path.findIndex(([, y]) => y >= HOG_LINE_Y)
|
||||
if (idx < 0) return path
|
||||
// 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 t0 = path[start][2]
|
||||
return path.slice(start).map(([x, y, t]) => [x, y, t - t0])
|
||||
return path.slice(start)
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
@ -23,4 +36,3 @@ export function weightToVelocity(weight: number): number {
|
||||
const t = (clamped - 1) / 9
|
||||
return MIN_SPEED + t * (MAX_SPEED - MIN_SPEED)
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { GameModel } from './game-model'
|
||||
import type { ServerStoneTrajectory, StoneState } from './protocol'
|
||||
import { SAMPLE_RATE_HZ } from './protocol'
|
||||
|
||||
function stone(partial: Partial<StoneState> & Pick<StoneState, 'id' | 'team'>): StoneState {
|
||||
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', () => {
|
||||
it('returns two DrawableStones in parallel mid-trajectory', () => {
|
||||
const model = new GameModel()
|
||||
model.state.stones = [stone({ id: 1, team: 'red' }), stone({ id: 2, team: 'yellow' })]
|
||||
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[] = [
|
||||
{
|
||||
stone_id: 1,
|
||||
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],
|
||||
],
|
||||
},
|
||||
{ stone_id: 1, path: path1 },
|
||||
{ stone_id: 2, path: path2 },
|
||||
]
|
||||
|
||||
model.startTrajectory(paths)
|
||||
expect(model.state.animating).toBe(true)
|
||||
|
||||
const mid = performance.now() + 500
|
||||
const mid = performance.now() + 250
|
||||
const drawn = model.tick(mid)
|
||||
|
||||
expect(drawn).toHaveLength(2)
|
||||
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) {
|
||||
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.turnTeam = 'red'
|
||||
|
||||
// 3 samples → max t = 2/40 = 0.05s
|
||||
model.startTrajectory([
|
||||
{
|
||||
stone_id: 1,
|
||||
path: [
|
||||
[0, 10, 0],
|
||||
[0, 10.5, 0.2],
|
||||
[0, 11, 0.4],
|
||||
],
|
||||
path: pathSamples([
|
||||
{ x: 0, y: 10 },
|
||||
{ x: 0, y: 10.5 },
|
||||
{ x: 0, y: 11 },
|
||||
]),
|
||||
},
|
||||
])
|
||||
expect(model.state.animating).toBe(true)
|
||||
|
||||
const afterEnd = performance.now() + 500
|
||||
const afterEnd = performance.now() + 200
|
||||
const drawn = model.tick(afterEnd)
|
||||
|
||||
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.turnTeam = 'red'
|
||||
|
||||
const thrownPath: [number, number, number][] = [
|
||||
[0, 2, 0],
|
||||
[0, 20, 1],
|
||||
[0, 21.5, 2],
|
||||
[0, 30, 3],
|
||||
]
|
||||
const thrownPath = pathSamples([
|
||||
{ x: 0, y: 2, theta: 0.1 },
|
||||
{ x: 0, y: 20, theta: 0.2 },
|
||||
{ x: 0, y: 21.5, theta: 0.3 },
|
||||
{ x: 0, y: 30, theta: 0.4 },
|
||||
])
|
||||
|
||||
model.startTrajectory([{ stone_id: 2, path: thrownPath }])
|
||||
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 drawn = model.tick(atStart)
|
||||
|
||||
@ -100,5 +104,11 @@ describe('GameModel multi-path trajectory animation', () => {
|
||||
expect(drawn[0].y).toBeCloseTo(20, 0)
|
||||
// Must not still be at the hack (y=2)
|
||||
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 { trimPathToStartAtHogLine } from './game-helpers'
|
||||
import { HOUSE_CENTER, type DrawableStone, type Phase, type ServerGameStateMessage, type ServerStoneTrajectory, type StoneState, type Team } from './protocol'
|
||||
import { hogTrimStartIndex, sampleTime, trimPathToStartAtHogLine } from './game-helpers'
|
||||
|
||||
export interface GameModelState {
|
||||
end: number
|
||||
@ -77,16 +77,12 @@ export class GameModel {
|
||||
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) {
|
||||
const thrownPath = paths.find((p) => p.stone_id === thrownId)?.path ?? []
|
||||
if (thrownPath.length >= 2) {
|
||||
const idx = thrownPath.findIndex(([, y]) => y >= HOG_LINE_Y)
|
||||
if (idx >= 0) {
|
||||
const start = Math.max(0, idx - 1)
|
||||
tRef = thrownPath[start][2]
|
||||
}
|
||||
}
|
||||
startIdx = hogTrimStartIndex(thrownPath)
|
||||
}
|
||||
|
||||
const pathMap = new Map<number, [number, number, number][]>()
|
||||
@ -94,10 +90,7 @@ export class GameModel {
|
||||
if (stone_id === thrownId) {
|
||||
pathMap.set(stone_id, trimPathToStartAtHogLine(path))
|
||||
} else {
|
||||
const shifted = path
|
||||
.map(([x, y, t]) => [x, y, t - tRef] as [number, number, number])
|
||||
.filter(([, , t]) => t >= 0)
|
||||
pathMap.set(stone_id, shifted)
|
||||
pathMap.set(stone_id, path.slice(startIdx))
|
||||
}
|
||||
}
|
||||
|
||||
@ -115,7 +108,9 @@ export class GameModel {
|
||||
const elapsed = (now - this.animationStartTime) / 1000
|
||||
const maxTotal = Math.max(
|
||||
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) {
|
||||
@ -143,31 +138,33 @@ export class GameModel {
|
||||
): { x: number; y: number; rotation: number } | null {
|
||||
if (path.length === 0) return null
|
||||
if (path.length === 1) {
|
||||
const [x, y] = path[0]
|
||||
return { x, y, rotation: 0 }
|
||||
const [x, y, theta] = path[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 prev = path[path.length - 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 }
|
||||
return { x: last[0], y: last[1], rotation: last[2] }
|
||||
}
|
||||
|
||||
// Find segment where sampleTime(i) <= elapsed < sampleTime(i+1)
|
||||
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 p1 = path[i + 1] ?? p0
|
||||
const t0 = path[Math.max(i - 1, 0)]
|
||||
const t2 = path[Math.min(i + 2, path.length - 1)]
|
||||
const dt = p1[2] - p0[2]
|
||||
const t = dt > 0 ? (elapsed - p0[2]) / dt : 0
|
||||
const t0 = sampleTime(i)
|
||||
const t1 = sampleTime(i + 1)
|
||||
const dt = t1 - t0
|
||||
const t = dt > 0 ? (elapsed - t0) / dt : 0
|
||||
const x = p0[0] + (p1[0] - p0[0]) * t
|
||||
const y = p0[1] + (p1[1] - p0[1]) * t
|
||||
const dx = t2[0] - t0[0]
|
||||
const dy = t2[1] - t0[1]
|
||||
const rotation = Math.atan2(dy, dx) * 2
|
||||
// Interpolate body rotation (theta) from path samples
|
||||
let dTheta = p1[2] - p0[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 }
|
||||
}
|
||||
|
||||
|
||||
@ -64,6 +64,7 @@ export interface ServerGameStateMessage {
|
||||
phase: Phase
|
||||
}
|
||||
|
||||
/** Path samples are (x, y, theta). Time is sample_index / SAMPLE_RATE_HZ. */
|
||||
export interface ServerStoneTrajectory {
|
||||
stone_id: number
|
||||
path: [number, number, number][]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user