pr2-features #2

Merged
eros merged 9 commits from pr2-features into main 2026-07-11 13:15:51 -07:00
7 changed files with 249 additions and 119 deletions
Showing only changes of commit 7af2fc9f35 - Show all commits

View File

@ -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"
); );
} }
} }

View File

@ -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)>,
} }

View File

@ -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)
}) })
}) })

View File

@ -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)
} }

View File

@ -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)
}) })
}) })

View File

@ -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 }
} }

View File

@ -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][]