feat(physics): spin curl model and theta trajectories

Replace constant-rate velocity rotate with ω0 spin + v_lat curl.
Path samples are (x, y, theta); client time is index/SAMPLE_RATE_HZ.

Ultraworked with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <sisyphus@ohmyopencode>
This commit is contained in:
Jason Dekarske 2026-07-10 23:34:39 -07:00
parent 72ffa15489
commit 7af2fc9f35
7 changed files with 249 additions and 119 deletions

View File

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

View File

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

View File

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

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

View File

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

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

View File

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