diff --git a/backend/src/physics.rs b/backend/src/physics.rs index 2c4da92..9f6f4fd 100644 --- a/backend/src/physics.rs +++ b/backend/src/physics.rs @@ -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, 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, 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" ); } } diff --git a/backend/src/protocol.rs b/backend/src/protocol.rs index 79e3964..2d1fb2b 100644 --- a/backend/src/protocol.rs +++ b/backend/src/protocol.rs @@ -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)>, } diff --git a/frontend/src/game-helpers.test.ts b/frontend/src/game-helpers.test.ts index 17d5fe3..4970d23 100644 --- a/frontend/src/game-helpers.test.ts +++ b/frontend/src/game-helpers.test.ts @@ -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) }) }) - diff --git a/frontend/src/game-helpers.ts b/frontend/src/game-helpers.ts index 22435db..1137e10 100644 --- a/frontend/src/game-helpers.ts +++ b/frontend/src/game-helpers.ts @@ -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) } - diff --git a/frontend/src/game-model.test.ts b/frontend/src/game-model.test.ts index 14ad192..f290b05 100644 --- a/frontend/src/game-model.test.ts +++ b/frontend/src/game-model.test.ts @@ -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 & Pick): StoneState { return { @@ -12,40 +13,42 @@ function stone(partial: Partial & Pick): } } +/** 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) }) }) diff --git a/frontend/src/game-model.ts b/frontend/src/game-model.ts index 86ffb3a..dfbf0b5 100644 --- a/frontend/src/game-model.ts +++ b/frontend/src/game-model.ts @@ -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() @@ -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 } } diff --git a/frontend/src/protocol.ts b/frontend/src/protocol.ts index 2c9ba22..c4dc3f3 100644 --- a/frontend/src/protocol.ts +++ b/frontend/src/protocol.ts @@ -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][]