more changes
This commit is contained in:
parent
0c5449cd1e
commit
94c6949426
@ -41,7 +41,8 @@ A multiplayer 2D curling game for mobile browser. Any number of clients can join
|
|||||||
|
|
||||||
- Pure initial **velocity** (m/s), not discrete weight.
|
- Pure initial **velocity** (m/s), not discrete weight.
|
||||||
- Ice friction µ(v) table (interpolated) × local scalar; linear and angular damping share the table.
|
- Ice friction µ(v) table (interpolated) × local scalar; linear and angular damping share the table.
|
||||||
- Curl: initial |ω| = 5 rot / 14 s; lateral speed ≈ k / v_forward (clockwise → right).
|
- Curl: initial |ω| = 5 rot / 14 s; lateral continuous model (clockwise → right).
|
||||||
|
- Stone–stone contacts are **nearly elastic** (`STONE_RESTITUTION = 0.9`) so takeouts launch both rocks along the impact line instead of plastic-sticking.
|
||||||
- Back line and sidelines are **not** colliders — touch → out of play after sim.
|
- Back line and sidelines are **not** colliders — touch → out of play after sim.
|
||||||
- Stone ids: `{ team, n }` with n = 1…8 per team per end.
|
- Stone ids: `{ team, n }` with n = 1…8 per team per end.
|
||||||
- Foot-derived radii use `FEET_TO_METERS = 0.3048`.
|
- Foot-derived radii use `FEET_TO_METERS = 0.3048`.
|
||||||
|
|||||||
@ -11,12 +11,29 @@ const G: f32 = 9.80665;
|
|||||||
/// Sign follows curl_sign; clockwise (curl>0) uses +ω0 in spawn (see apply_curl).
|
/// 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;
|
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, ε).
|
/// Target spin duration (s) matching the 5-rev / 14 s design.
|
||||||
/// Clockwise curl → right (+x when moving +y). Calibrated for clockwise_curl_moves_right.
|
/// Client animation only starts at the hog (~9 s in); damping must leave
|
||||||
pub const CURL_LAT_K: f32 = 0.06;
|
/// tangible |ω| past that, or stones look frozen on screen.
|
||||||
|
pub const SPIN_HOLD_S: f32 = 14.0;
|
||||||
|
|
||||||
|
/// Scale for lateral speed: `v_lat = CURL_LAT_K * µ(speed) * friction_scalar` (m/s).
|
||||||
|
/// Applied ⊥ **instantaneous velocity** heading as continuous normal dynamics
|
||||||
|
/// (`a_n = v_lat / CURL_LAT_TAU`, integrated each substep) so we do not stack
|
||||||
|
/// a fixed geometric rotation of atan(v_lat/v) per 1/120 s tick.
|
||||||
|
/// Calibrated so a full-curl DRAW_VELOCITY throw to the tee drifts ≈ 4 feet.
|
||||||
|
/// Clockwise curl_sign > 0 → right of velocity (+x when moving +y).
|
||||||
|
pub const CURL_LAT_K: f32 = 0.683;
|
||||||
|
|
||||||
|
/// Time constant (s) mapping target v_lat → normal acceleration: a_n = v_lat / TAU.
|
||||||
|
pub const CURL_LAT_TAU: f32 = 1.0;
|
||||||
|
|
||||||
|
/// Target lateral displacement (m) for a full-curl draw to the tee line.
|
||||||
|
#[allow(dead_code)] // used by unit tests + docs; keeps calibration goal explicit
|
||||||
|
pub const CURL_DRAW_LATERAL_M: f32 = 4.0 * FEET_TO_METERS;
|
||||||
|
|
||||||
/// 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.
|
||||||
|
#[allow(dead_code)] // used by unit tests / clients; sim accepts arbitrary velocity
|
||||||
pub const DRAW_VELOCITY: f32 = 2.38;
|
pub const DRAW_VELOCITY: f32 = 2.38;
|
||||||
|
|
||||||
/// Ice friction coefficient µ as a function of speed (m/s).
|
/// Ice friction coefficient µ as a function of speed (m/s).
|
||||||
@ -192,6 +209,7 @@ impl PhysicsWorld {
|
|||||||
.angvel(omega0)
|
.angvel(omega0)
|
||||||
.linear_damping(0.0)
|
.linear_damping(0.0)
|
||||||
.angular_damping(0.0)
|
.angular_damping(0.0)
|
||||||
|
.ccd_enabled(true)
|
||||||
.can_sleep(false)
|
.can_sleep(false)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
@ -207,6 +225,7 @@ impl PhysicsWorld {
|
|||||||
|
|
||||||
self.colliders
|
self.colliders
|
||||||
.insert_with_parent(collider, handle, &mut self.bodies);
|
.insert_with_parent(collider, handle, &mut self.bodies);
|
||||||
|
|
||||||
self.stone_handles
|
self.stone_handles
|
||||||
.push((id, handle, team, curl_sign, friction_scalar));
|
.push((id, handle, team, curl_sign, friction_scalar));
|
||||||
|
|
||||||
@ -251,12 +270,16 @@ impl PhysicsWorld {
|
|||||||
if let Some(body) = self.bodies.get(*handle) {
|
if let Some(body) = self.bodies.get(*handle) {
|
||||||
let pos = body.translation();
|
let pos = body.translation();
|
||||||
let theta = body.rotation().angle();
|
let theta = body.rotation().angle();
|
||||||
|
// Don't grow client animation paths once the stone is clearly off-sheet.
|
||||||
|
if Self::position_clearly_out_of_play(pos.x, pos.y) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
path.push([pos.x, pos.y, theta]);
|
path.push([pos.x, pos.y, theta]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.all_stones_at_rest() || time > MAX_SIM_TIME {
|
if self.all_stones_settled_or_out() || time > MAX_SIM_TIME {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -311,11 +334,14 @@ impl PhysicsWorld {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Spin-curl model after drag:
|
/// Spin-curl model after drag:
|
||||||
/// - Angular damping from µ(v)*friction_scalar (slower → larger µ → more damp)
|
/// - Angular damping designed for ~SPIN_HOLD_S hold (not µmg/R, which killed
|
||||||
/// - Lateral speed target v_lat = curl_sign * CURL_LAT_K / max(v_forward, ε)
|
/// spin in ~4 s — before the client ever drew the stone past the hog)
|
||||||
/// - Clockwise (curl_sign > 0) → right: +x when moving +y
|
/// - Instantaneous velocity heading; right = CW perp (uy, -ux)
|
||||||
|
/// - v_lat = curl_sign * CURL_LAT_K * µ(speed) * friction_scalar
|
||||||
|
/// - Continuous normal dynamics: a_n = v_lat / CURL_LAT_TAU, v += a_n * right * dt
|
||||||
|
/// - Clockwise curl_sign > 0 → right of velocity (+x when moving +y)
|
||||||
fn apply_curl(&mut self) {
|
fn apply_curl(&mut self) {
|
||||||
const EPS: f32 = 1e-3;
|
const MIN_CURL_SPEED: f32 = 0.08;
|
||||||
|
|
||||||
for (_, handle, _, curl_sign, friction_scalar) in &self.stone_handles {
|
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) {
|
||||||
@ -325,12 +351,15 @@ impl PhysicsWorld {
|
|||||||
|
|
||||||
let v = body.linvel();
|
let v = body.linvel();
|
||||||
let speed = (v.x * v.x + v.y * v.y).sqrt();
|
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.
|
// Decay |ω| so it lasts ~SPIN_HOLD_S at friction_scalar=1; scale by scalar.
|
||||||
|
// Old α = µ g / R wiped spin pre-hog so FE never showed rotation.
|
||||||
let omega = body.angvel();
|
let omega = body.angvel();
|
||||||
if omega.abs() > 1e-8 {
|
if speed < REST_SPEED {
|
||||||
let domega = mu_eff * G / STONE_RADIUS * PHYSICS_DT;
|
body.set_angvel(0.0, true);
|
||||||
|
} else if omega.abs() > 1e-8 {
|
||||||
|
let alpha = (INITIAL_OMEGA / SPIN_HOLD_S) * *friction_scalar;
|
||||||
|
let domega = alpha * PHYSICS_DT;
|
||||||
let new_omega = if domega >= omega.abs() {
|
let new_omega = if domega >= omega.abs() {
|
||||||
0.0
|
0.0
|
||||||
} else {
|
} else {
|
||||||
@ -339,22 +368,25 @@ impl PhysicsWorld {
|
|||||||
body.set_angvel(new_omega, true);
|
body.set_angvel(new_omega, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
if *curl_sign == 0 || speed < 1e-4 {
|
if *curl_sign == 0 || speed < MIN_CURL_SPEED {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unit forward and right (CW 90° from forward): (ux,uy) → (uy, -ux).
|
// Instantaneous velocity heading and body-right (CW 90°).
|
||||||
// Moving +y → right = +x.
|
|
||||||
let ux = v.x / speed;
|
let ux = v.x / speed;
|
||||||
let uy = v.y / speed;
|
let uy = v.y / speed;
|
||||||
let rx = uy;
|
let rx = uy;
|
||||||
let ry = -ux;
|
let ry = -ux;
|
||||||
|
|
||||||
let v_forward = speed;
|
// v_lat = k * µ(speed) * friction_scalar (same µ table as ice friction).
|
||||||
let v_lat_target = (*curl_sign as f32) * CURL_LAT_K / v_forward.max(EPS);
|
let v_lat =
|
||||||
let v_fwd = v.x * ux + v.y * uy;
|
(*curl_sign as f32) * CURL_LAT_K * mu(speed) * *friction_scalar;
|
||||||
let new_v = Vector::new(v_fwd * ux + v_lat_target * rx, v_fwd * uy + v_lat_target * ry);
|
// Continuous: a_n = v_lat / τ → integrates without per-tick geometric stack.
|
||||||
body.set_linvel(new_v, true);
|
let a_n = v_lat / CURL_LAT_TAU;
|
||||||
|
body.set_linvel(
|
||||||
|
Vector::new(v.x + rx * a_n * PHYSICS_DT, v.y + ry * a_n * PHYSICS_DT),
|
||||||
|
true,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -400,9 +432,19 @@ impl PhysicsWorld {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn all_stones_at_rest(&self) -> bool {
|
fn position_clearly_out_of_play(x: f32, y: f32) -> bool {
|
||||||
|
y > BACK_LINE_Y || x.abs() > SHEET_WIDTH / 2.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// End sim when every stone is at rest or already past back/sidelines.
|
||||||
|
/// Avoids MAX_SIM_TIME client animations for long overthrows.
|
||||||
|
fn all_stones_settled_or_out(&self) -> bool {
|
||||||
for (_, handle, _, _, _) in &self.stone_handles {
|
for (_, handle, _, _, _) in &self.stone_handles {
|
||||||
if let Some(body) = self.bodies.get(*handle) {
|
if let Some(body) = self.bodies.get(*handle) {
|
||||||
|
let pos = body.translation();
|
||||||
|
if Self::position_clearly_out_of_play(pos.x, pos.y) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let v = body.linvel();
|
let v = body.linvel();
|
||||||
let speed = (v.x * v.x + v.y * v.y).sqrt();
|
let speed = (v.x * v.x + v.y * v.y).sqrt();
|
||||||
if speed > REST_SPEED || body.angvel().abs() > REST_ANGULAR_SPEED {
|
if speed > REST_SPEED || body.angvel().abs() > REST_ANGULAR_SPEED {
|
||||||
@ -675,6 +717,45 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// FE trims trajectories to the hog; spin must still change θ after that.
|
||||||
|
#[test]
|
||||||
|
fn theta_keeps_changing_after_hog_when_curling() {
|
||||||
|
let mut world = PhysicsWorld::new();
|
||||||
|
let traj = world
|
||||||
|
.throw(Team::Team1, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, 1, 1.0)
|
||||||
|
.unwrap();
|
||||||
|
let path = &traj[0].trajectory;
|
||||||
|
let post_hog: Vec<[f32; 3]> = path
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|s| s[1] >= HOG_LINE_Y)
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
post_hog.len() > 10,
|
||||||
|
"need a post-hog path to animate, got {}",
|
||||||
|
post_hog.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Unwrap sample-to-sample Δθ (Rapier angle is in [-π, π]).
|
||||||
|
let mut travel = 0.0_f32;
|
||||||
|
let mut prev = post_hog[0][2];
|
||||||
|
for s in post_hog.iter().skip(1) {
|
||||||
|
let mut d = s[2] - prev;
|
||||||
|
if d > std::f32::consts::PI {
|
||||||
|
d -= 2.0 * std::f32::consts::PI;
|
||||||
|
}
|
||||||
|
if d < -std::f32::consts::PI {
|
||||||
|
d += 2.0 * std::f32::consts::PI;
|
||||||
|
}
|
||||||
|
travel += d.abs();
|
||||||
|
prev = s[2];
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
travel > 0.75,
|
||||||
|
"stone should rotate past the hog (client-visible), |Δθ|sum={travel} rad"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn stones_persist_after_multiple_throws() {
|
fn stones_persist_after_multiple_throws() {
|
||||||
let mut world = PhysicsWorld::new();
|
let mut world = PhysicsWorld::new();
|
||||||
@ -813,6 +894,64 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Head-on takeout with nearly elastic restitution must launch the sitters
|
||||||
|
/// and keep both moving along the impact (down-sheet) direction — not a
|
||||||
|
/// plastic "stick and dump" limp.
|
||||||
|
#[test]
|
||||||
|
fn near_elastic_takeout_launches_both_downsheet() {
|
||||||
|
let takeout_v = DRAW_VELOCITY * 1.6;
|
||||||
|
|
||||||
|
let mut world = PhysicsWorld::new();
|
||||||
|
world
|
||||||
|
.throw(Team::Team1, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, 0, 1.0)
|
||||||
|
.unwrap();
|
||||||
|
let first_id = last_thrown_id(&world, Team::Team1);
|
||||||
|
let rest_x = final_x(&world, first_id);
|
||||||
|
let rest_y = final_y(&world, first_id);
|
||||||
|
|
||||||
|
let trajectories = world
|
||||||
|
.throw(Team::Team2, rest_x, rest_y, takeout_v, 0, 1.0)
|
||||||
|
.unwrap();
|
||||||
|
let second_id = last_thrown_id(&world, Team::Team2);
|
||||||
|
|
||||||
|
let by_id: std::collections::HashMap<StoneId, Vec<[f32; 3]>> = trajectories
|
||||||
|
.into_iter()
|
||||||
|
.map(|st| (st.stone_id, st.trajectory))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let first_path = by_id.get(&first_id).expect("struck stone path");
|
||||||
|
let second_path = by_id.get(&second_id).expect("shooter path");
|
||||||
|
|
||||||
|
let first_start_y = first_path[0][1];
|
||||||
|
let first_max_y = first_path.iter().map(|s| s[1]).fold(f32::NEG_INFINITY, f32::max);
|
||||||
|
let first_launch = first_max_y - first_start_y;
|
||||||
|
|
||||||
|
// Inelastic e≈0.05 only nudges the sitters; nearly elastic takes them meters.
|
||||||
|
assert!(
|
||||||
|
first_launch > 1.5,
|
||||||
|
"struck stone should be launched down-sheet, launch={first_launch} rest_y={rest_y}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Both should still be moving +y at some point after contact (sample peak
|
||||||
|
// leftmost/rightmost velocity proxy: later samples farther down than early).
|
||||||
|
let second_start_y = second_path[0][1];
|
||||||
|
let second_max_y = second_path.iter().map(|s| s[1]).fold(f32::NEG_INFINITY, f32::max);
|
||||||
|
assert!(
|
||||||
|
second_max_y > second_start_y + 10.0,
|
||||||
|
"shooter must travel down-sheet, Δy={}",
|
||||||
|
second_max_y - second_start_y
|
||||||
|
);
|
||||||
|
|
||||||
|
// Impact direction is primarily +y; struck stone's net lateral drift after
|
||||||
|
// a head-on should stay small compared to longitudinal launch.
|
||||||
|
let first_end = first_path.last().expect("non-empty struck path");
|
||||||
|
let lateral = (first_end[0] - rest_x).abs();
|
||||||
|
assert!(
|
||||||
|
lateral < first_launch * 0.5,
|
||||||
|
"head-on should keep both mostly along impact axis: lateral={lateral} launch={first_launch}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn stone_path_samples_are_xyz_arrays() {
|
fn stone_path_samples_are_xyz_arrays() {
|
||||||
let mut world = PhysicsWorld::new();
|
let mut world = PhysicsWorld::new();
|
||||||
@ -826,4 +965,109 @@ mod tests {
|
|||||||
assert_eq!(sample.len(), 3);
|
assert_eq!(sample.len(), 3);
|
||||||
assert!(sample[0].is_finite() && sample[1].is_finite() && sample[2].is_finite());
|
assert!(sample[0].is_finite() && sample[1].is_finite() && sample[2].is_finite());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn weight5_ui_velocity_leaves_stone_in_play() {
|
||||||
|
let v = crate::protocol::MIN_SPEED
|
||||||
|
+ 4.0 / 9.0 * (crate::protocol::MAX_SPEED - crate::protocol::MIN_SPEED);
|
||||||
|
assert!(
|
||||||
|
(v - DRAW_VELOCITY).abs() < 0.02,
|
||||||
|
"weight-5 velocity {v} should ≈ DRAW_VELOCITY {DRAW_VELOCITY}"
|
||||||
|
);
|
||||||
|
let mut world = PhysicsWorld::new();
|
||||||
|
world
|
||||||
|
.throw(Team::Team1, 0.0, HOUSE_CENTER.1, v, 0, 1.0)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(world.current_stones().len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn min_ui_speed_reaches_past_hog() {
|
||||||
|
let mut world = PhysicsWorld::new();
|
||||||
|
world
|
||||||
|
.throw(
|
||||||
|
Team::Team1,
|
||||||
|
0.0,
|
||||||
|
HOUSE_CENTER.1,
|
||||||
|
crate::protocol::MIN_SPEED,
|
||||||
|
0,
|
||||||
|
1.0,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let stones = world.current_stones();
|
||||||
|
assert_eq!(stones.len(), 1);
|
||||||
|
assert!(stones[0].y >= HOG_LINE_Y);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn full_curl_draw_stays_on_sheet() {
|
||||||
|
// UI default was curl=±1; old k/v model pruned every curled throw.
|
||||||
|
let v = crate::protocol::MIN_SPEED
|
||||||
|
+ 4.0 / 9.0 * (crate::protocol::MAX_SPEED - crate::protocol::MIN_SPEED);
|
||||||
|
for curl in [1i8, -1] {
|
||||||
|
let mut world = PhysicsWorld::new();
|
||||||
|
world
|
||||||
|
.throw(Team::Team1, 0.0, HOUSE_CENTER.1, v, curl, 1.0)
|
||||||
|
.unwrap();
|
||||||
|
let stones = world.current_stones();
|
||||||
|
assert_eq!(
|
||||||
|
stones.len(),
|
||||||
|
1,
|
||||||
|
"curl={curl} must leave a stone in play, got {}",
|
||||||
|
stones.len()
|
||||||
|
);
|
||||||
|
assert!(stones[0].y >= HOG_LINE_Y && stones[0].y <= BACK_LINE_Y);
|
||||||
|
assert!(stones[0].x.abs() <= SHEET_WIDTH / 2.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn draw_to_tee_full_curl_drifts_four_feet() {
|
||||||
|
// v_lat = CURL_LAT_K * µ(v); k calibrated so |x| ≈ 4 ft on a tee-line draw.
|
||||||
|
let mut world = PhysicsWorld::new();
|
||||||
|
world
|
||||||
|
.throw(Team::Team1, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, 1, 1.0)
|
||||||
|
.unwrap();
|
||||||
|
let stones = world.current_stones();
|
||||||
|
assert_eq!(stones.len(), 1);
|
||||||
|
let s = &stones[0];
|
||||||
|
assert!(
|
||||||
|
(s.y - HOUSE_CENTER.1).abs() < 1.0,
|
||||||
|
"should stop near tee line, y={}",
|
||||||
|
s.y
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(s.x - CURL_DRAW_LATERAL_M).abs() < 0.25,
|
||||||
|
"full curl should drift ~4 ft ({} m), got x={} m ({:.2} ft)",
|
||||||
|
CURL_DRAW_LATERAL_M,
|
||||||
|
s.x,
|
||||||
|
s.x / FEET_TO_METERS
|
||||||
|
);
|
||||||
|
// Opposite curl is mirror-image.
|
||||||
|
let mut world2 = PhysicsWorld::new();
|
||||||
|
world2
|
||||||
|
.throw(Team::Team1, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, -1, 1.0)
|
||||||
|
.unwrap();
|
||||||
|
let s2 = &world2.current_stones()[0];
|
||||||
|
assert!(
|
||||||
|
(s2.x + CURL_DRAW_LATERAL_M).abs() < 0.25,
|
||||||
|
"ccw curl should drift ~-4 ft, got x={}",
|
||||||
|
s2.x
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fast_overshoot_does_not_run_full_max_sim_path() {
|
||||||
|
let mut world = PhysicsWorld::new();
|
||||||
|
let paths = world
|
||||||
|
.throw(Team::Team1, 0.0, HOUSE_CENTER.1, 4.0, 0, 1.0)
|
||||||
|
.unwrap();
|
||||||
|
let n = paths[0].trajectory.len();
|
||||||
|
assert!(
|
||||||
|
n < 900,
|
||||||
|
"overshoot path should end when past back line, got {n} samples"
|
||||||
|
);
|
||||||
|
assert!(world.current_stones().is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,8 @@ pub const ENDS: u8 = 10;
|
|||||||
|
|
||||||
// World coordinates in meters, y along sheet toward house.
|
// World coordinates in meters, y along sheet toward house.
|
||||||
pub const SHEET_WIDTH: f32 = 5.0;
|
pub const SHEET_WIDTH: f32 = 5.0;
|
||||||
|
/// Full sheet length (m). Kept for protocol/docs/layout parity with the frontend.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub const SHEET_LENGTH: f32 = 45.0;
|
pub const SHEET_LENGTH: f32 = 45.0;
|
||||||
pub const HOUSE_CENTER: (f32, f32) = (0.0, 38.5);
|
pub const HOUSE_CENTER: (f32, f32) = (0.0, 38.5);
|
||||||
pub const HOUSE_RADIUS: f32 = 6.0 * FEET_TO_METERS; // 12 ft diameter → 6 ft radius
|
pub const HOUSE_RADIUS: f32 = 6.0 * FEET_TO_METERS; // 12 ft diameter → 6 ft radius
|
||||||
@ -22,9 +24,19 @@ pub const HACK_Y: f32 = 2.0;
|
|||||||
pub const STONE_RADIUS: f32 = 0.15;
|
pub const STONE_RADIUS: f32 = 0.15;
|
||||||
pub const STONE_MASS: f32 = 20.0;
|
pub const STONE_MASS: f32 = 20.0;
|
||||||
pub const STONE_FRICTION: f32 = 0.015;
|
pub const STONE_FRICTION: f32 = 0.015;
|
||||||
pub const STONE_RESTITUTION: f32 = 0.05;
|
/// Newton restitution for stone–stone contacts (Rapier, Average combine).
|
||||||
pub const MIN_SPEED: f32 = 3.0;
|
/// Curling granite is nearly elastic on contact; low e makes takeouts feel like
|
||||||
pub const MAX_SPEED: f32 = 6.45;
|
/// putty (both limp together). ~0.9 → both keep going along impact direction.
|
||||||
|
pub const STONE_RESTITUTION: f32 = 0.9;
|
||||||
|
/// Soft guard end of the throw slider. Weight 1 → MIN_SPEED.
|
||||||
|
/// Calibrated with DRAW_VELOCITY so mid-slider (weight 5) lands near the tee.
|
||||||
|
/// Shared with the frontend; binary sim does not clamp on it (clients send free velocity).
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub const MIN_SPEED: f32 = 1.9;
|
||||||
|
/// Heavy end of the throw slider. Weight 10 → MAX_SPEED.
|
||||||
|
/// Keep span so weight 5 ≈ DRAW_VELOCITY (2.38).
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub const MAX_SPEED: f32 = 3.0;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash, Default)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
|
|||||||
@ -54,16 +54,21 @@ describe('hogTrimStartIndex', () => {
|
|||||||
|
|
||||||
describe('velocity ↔ weight', () => {
|
describe('velocity ↔ weight', () => {
|
||||||
it('maps endpoints correctly', () => {
|
it('maps endpoints correctly', () => {
|
||||||
expect(velocityToWeight(3.0)).toBe(1)
|
expect(velocityToWeight(1.9)).toBe(1)
|
||||||
expect(velocityToWeight(6.45)).toBe(10)
|
expect(velocityToWeight(3.0)).toBe(10)
|
||||||
expect(weightToVelocity(1)).toBe(3.0)
|
expect(weightToVelocity(1)).toBe(1.9)
|
||||||
expect(weightToVelocity(10)).toBe(6.45)
|
expect(weightToVelocity(10)).toBe(3.0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mid weight is near draw (tee-line) velocity', () => {
|
||||||
|
// weight 5 → 1.9 + 4/9 * 1.1 ≈ 2.389 — calibrated DRAW_VELOCITY
|
||||||
|
expect(weightToVelocity(5)).toBeCloseTo(2.389, 2)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('clamps out-of-range inputs', () => {
|
it('clamps out-of-range inputs', () => {
|
||||||
expect(velocityToWeight(2.5)).toBe(1)
|
expect(velocityToWeight(1.5)).toBe(1)
|
||||||
expect(velocityToWeight(7.0)).toBe(10)
|
expect(velocityToWeight(4.0)).toBe(10)
|
||||||
expect(weightToVelocity(0)).toBe(3.0)
|
expect(weightToVelocity(0)).toBe(1.9)
|
||||||
expect(weightToVelocity(11)).toBe(6.45)
|
expect(weightToVelocity(11)).toBe(3.0)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -70,7 +70,8 @@ function buildStoneChipsHtml(team: Team): string {
|
|||||||
const chips = Array.from({ length: STONES_PER_TEAM }, (_, i) => {
|
const chips = Array.from({ length: STONES_PER_TEAM }, (_, i) => {
|
||||||
return `<span class="stone-chip stone-chip--${team}" data-index="${i}" aria-hidden="true"></span>`
|
return `<span class="stone-chip stone-chip--${team}" data-index="${i}" aria-hidden="true"></span>`
|
||||||
}).join('')
|
}).join('')
|
||||||
return `<div class="stones-row stones-row--${team}" data-team="${team}" role="img" aria-label="${TEAM_LABELS[team]} stones remaining">${chips}</div>`
|
// Hammer glyph lives on the row of the team that has last-rock; toggled in update().
|
||||||
|
return `<div class="stones-row stones-row--${team}" data-team="${team}" role="img" aria-label="${TEAM_LABELS[team]} stones remaining"><span class="hammer-badge" title="Hammer" aria-hidden="true">🔨</span>${chips}</div>`
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderScoreboardTable(scoreboard: EndScore[]): string {
|
function renderScoreboardTable(scoreboard: EndScore[]): string {
|
||||||
@ -108,7 +109,6 @@ export function createHud(): Hud {
|
|||||||
<option value="team1">Team 1</option>
|
<option value="team1">Team 1</option>
|
||||||
<option value="team2">Team 2</option>
|
<option value="team2">Team 2</option>
|
||||||
</select>
|
</select>
|
||||||
<div id="hammer">Hammer: -</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div id="scoreboard-strip" class="scoreboard-strip" aria-label="End scores"></div>
|
<div id="scoreboard-strip" class="scoreboard-strip" aria-label="End scores"></div>
|
||||||
</div>
|
</div>
|
||||||
@ -130,11 +130,28 @@ export function createHud(): Hud {
|
|||||||
const scoreEl = root.querySelector<HTMLDivElement>('#score')!
|
const scoreEl = root.querySelector<HTMLDivElement>('#score')!
|
||||||
const endInfoEl = root.querySelector<HTMLDivElement>('#end-info')!
|
const endInfoEl = root.querySelector<HTMLDivElement>('#end-info')!
|
||||||
const teamSelect = root.querySelector<HTMLSelectElement>('#team-select')!
|
const teamSelect = root.querySelector<HTMLSelectElement>('#team-select')!
|
||||||
const hammerEl = root.querySelector<HTMLDivElement>('#hammer')!
|
|
||||||
const waitingEl = root.querySelector<HTMLDivElement>('#waiting')!
|
const waitingEl = root.querySelector<HTMLDivElement>('#waiting')!
|
||||||
const stonesHud = root.querySelector<HTMLDivElement>('#stones-hud')!
|
const stonesHud = root.querySelector<HTMLDivElement>('#stones-hud')!
|
||||||
const scoreboardStrip = root.querySelector<HTMLDivElement>('#scoreboard-strip')!
|
const scoreboardStrip = root.querySelector<HTMLDivElement>('#scoreboard-strip')!
|
||||||
|
|
||||||
|
const updateHammerBadge = (hammer: Team) => {
|
||||||
|
for (const team of ['team1', 'team2'] as const) {
|
||||||
|
const row = stonesHud.querySelector<HTMLDivElement>(`.stones-row--${team}`)
|
||||||
|
const badge = row?.querySelector<HTMLSpanElement>('.hammer-badge')
|
||||||
|
if (!badge || !row) continue
|
||||||
|
const hasHammer = team === hammer
|
||||||
|
// Class only — keep the 18px hammer column on both rows so chips align.
|
||||||
|
row.classList.toggle('stones-row--hammer', hasHammer)
|
||||||
|
if (hasHammer) {
|
||||||
|
badge.removeAttribute('aria-hidden')
|
||||||
|
badge.setAttribute('aria-label', 'Hammer')
|
||||||
|
} else {
|
||||||
|
badge.setAttribute('aria-hidden', 'true')
|
||||||
|
badge.removeAttribute('aria-label')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let endModalEl: HTMLDivElement | null = null
|
let endModalEl: HTMLDivElement | null = null
|
||||||
let endModalTimer = 0
|
let endModalTimer = 0
|
||||||
|
|
||||||
@ -158,7 +175,11 @@ export function createHud(): Hud {
|
|||||||
const firstRemaining = STONES_PER_TEAM - left
|
const firstRemaining = STONES_PER_TEAM - left
|
||||||
const row = stonesHud.querySelector<HTMLDivElement>(`.stones-row--${team}`)
|
const row = stonesHud.querySelector<HTMLDivElement>(`.stones-row--${team}`)
|
||||||
if (!row) continue
|
if (!row) continue
|
||||||
row.setAttribute('aria-label', `${TEAM_LABELS[team]} ${left} stones remaining`)
|
const hammer = row.classList.contains('stones-row--hammer')
|
||||||
|
row.setAttribute(
|
||||||
|
'aria-label',
|
||||||
|
`${TEAM_LABELS[team]} ${left} stones remaining${hammer ? ' (hammer)' : ''}`,
|
||||||
|
)
|
||||||
row.querySelectorAll<HTMLSpanElement>('.stone-chip').forEach((chip) => {
|
row.querySelectorAll<HTMLSpanElement>('.stone-chip').forEach((chip) => {
|
||||||
const index = Number(chip.dataset.index)
|
const index = Number(chip.dataset.index)
|
||||||
const isRemaining = index >= firstRemaining
|
const isRemaining = index >= firstRemaining
|
||||||
@ -200,8 +221,9 @@ export function createHud(): Hud {
|
|||||||
? `${TEAM_LABELS[state.turnTeam]}'s turn`
|
? `${TEAM_LABELS[state.turnTeam]}'s turn`
|
||||||
: state.phase.replace(/_/g, ' ')
|
: state.phase.replace(/_/g, ' ')
|
||||||
endInfoEl.textContent = `End ${state.end} · ${phaseText}`
|
endInfoEl.textContent = `End ${state.end} · ${phaseText}`
|
||||||
hammerEl.textContent = `Hammer: ${TEAM_LABELS[state.hammer]}`
|
|
||||||
waitingEl.classList.toggle('visible', state.phase !== 'game_complete' && state.animating)
|
waitingEl.classList.toggle('visible', state.phase !== 'game_complete' && state.animating)
|
||||||
|
// Hammer class first so stones-remaining aria can mention it.
|
||||||
|
updateHammerBadge(state.hammer)
|
||||||
updateStonesRemaining(state.stonesRemaining)
|
updateStonesRemaining(state.stonesRemaining)
|
||||||
updateScoreboardStrip(state.scoreboard, state.scores)
|
updateScoreboardStrip(state.scoreboard, state.scores)
|
||||||
},
|
},
|
||||||
@ -316,15 +338,18 @@ export function createCurlSelector(
|
|||||||
container: HTMLDivElement,
|
container: HTMLDivElement,
|
||||||
onSelect: (curl: number) => void,
|
onSelect: (curl: number) => void,
|
||||||
): { getSelected: () => number; setEnabled: (enabled: boolean) => void } {
|
): { getSelected: () => number; setEnabled: (enabled: boolean) => void } {
|
||||||
|
// Only full curl: backend curl>0 = clockwise (right), curl<0 = counter-clockwise (left).
|
||||||
|
// Layout L→R: CCW on the left, CW on the right. Default clockwise.
|
||||||
const state = { selected: 1, enabled: true }
|
const state = { selected: 1, enabled: true }
|
||||||
const options = [
|
const options = [
|
||||||
{ value: -1, label: '↷', ariaLabel: 'Left curl' },
|
{ value: -1, label: '↺', ariaLabel: 'Counterclockwise curl' },
|
||||||
{ value: 1, label: '↶', ariaLabel: 'Right curl' },
|
{ value: 1, label: '↻', ariaLabel: 'Clockwise curl' },
|
||||||
]
|
]
|
||||||
container.innerHTML = ''
|
container.innerHTML = ''
|
||||||
for (const opt of options) {
|
for (const opt of options) {
|
||||||
const btn = document.createElement('button')
|
const btn = document.createElement('button')
|
||||||
btn.className = 'curl-btn'
|
btn.className = 'curl-btn'
|
||||||
|
btn.type = 'button'
|
||||||
btn.textContent = opt.label
|
btn.textContent = opt.label
|
||||||
btn.ariaLabel = opt.ariaLabel
|
btn.ariaLabel = opt.ariaLabel
|
||||||
btn.dataset.curl = String(opt.value)
|
btn.dataset.curl = String(opt.value)
|
||||||
|
|||||||
@ -15,8 +15,10 @@ export const HOG_LINE_Y = 21.0
|
|||||||
export const BACK_LINE_Y = 42.0
|
export const BACK_LINE_Y = 42.0
|
||||||
export const HACK_Y = 2.0
|
export const HACK_Y = 2.0
|
||||||
export const STONE_RADIUS = 0.15
|
export const STONE_RADIUS = 0.15
|
||||||
export const MIN_SPEED = 3.0
|
/** Soft guard (weight 1). Mid slider (weight 5) ≈ DRAW 2.38 m/s lands near tee. */
|
||||||
export const MAX_SPEED = 6.45
|
export const MIN_SPEED = 1.9
|
||||||
|
/** Heavy (weight 10). Span keeps weight 5 ≈ DRAW_VELOCITY. */
|
||||||
|
export const MAX_SPEED = 3.0
|
||||||
|
|
||||||
export type Team = 'team1' | 'team2'
|
export type Team = 'team1' | 'team2'
|
||||||
export type Phase = 'waiting' | 'playing' | 'simulating' | 'scoring' | 'end_complete' | 'game_complete'
|
export type Phase = 'waiting' | 'playing' | 'simulating' | 'scoring' | 'end_complete' | 'game_complete'
|
||||||
|
|||||||
@ -160,23 +160,42 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer {
|
|||||||
const c = worldToScreen(stone.x, stone.y)
|
const c = worldToScreen(stone.x, stone.y)
|
||||||
const r = STONE_RADIUS * scale()
|
const r = STONE_RADIUS * scale()
|
||||||
// team1 = red palette, team2 = yellow palette
|
// team1 = red palette, team2 = yellow palette
|
||||||
|
const rim = stone.team === 'team1' ? '#8b1a12' : '#a66d00'
|
||||||
const color = stone.team === 'team1' ? '#d93025' : '#f9ab00'
|
const color = stone.team === 'team1' ? '#d93025' : '#f9ab00'
|
||||||
ctx.beginPath()
|
const highlight = stone.team === 'team1' ? '#ff6b5c' : '#ffd666'
|
||||||
ctx.arc(c.x, c.y, r, 0, Math.PI * 2)
|
|
||||||
ctx.fillStyle = color
|
// Paint but body fully in stone frame so θ from physics is obvious while spinning.
|
||||||
ctx.fill()
|
// Canvas +Y is down; negate so CCW body angle matches ice coordinates.
|
||||||
ctx.strokeStyle = '#fff'
|
|
||||||
ctx.lineWidth = Math.max(1, scale() * 0.02)
|
|
||||||
ctx.stroke()
|
|
||||||
ctx.save()
|
ctx.save()
|
||||||
ctx.translate(c.x, c.y)
|
ctx.translate(c.x, c.y)
|
||||||
ctx.rotate(-stone.rotation)
|
ctx.rotate(-stone.rotation)
|
||||||
|
|
||||||
|
const bodyGrad = ctx.createRadialGradient(-r * 0.3, -r * 0.35, r * 0.1, 0, 0, r)
|
||||||
|
bodyGrad.addColorStop(0, highlight)
|
||||||
|
bodyGrad.addColorStop(0.55, color)
|
||||||
|
bodyGrad.addColorStop(1, rim)
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(0, 0, r, 0, Math.PI * 2)
|
||||||
|
ctx.fillStyle = bodyGrad
|
||||||
|
ctx.fill()
|
||||||
|
ctx.strokeStyle = 'rgba(255,255,255,0.9)'
|
||||||
|
ctx.lineWidth = Math.max(1.5, scale() * 0.02)
|
||||||
|
ctx.stroke()
|
||||||
|
|
||||||
|
// Asymmetric handle: bright bar + dark toe so spin reads clearly.
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.92)'
|
||||||
|
ctx.fillRect(-r * 0.12, -r * 0.18, r * 0.9, r * 0.36)
|
||||||
|
ctx.fillStyle = 'rgba(20,20,20,0.55)'
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(-r * 0.4, 0, r * 0.22, 0, Math.PI * 2)
|
||||||
|
ctx.fill()
|
||||||
|
ctx.strokeStyle = 'rgba(0,0,0,0.35)'
|
||||||
|
ctx.lineWidth = Math.max(1, scale() * 0.015)
|
||||||
ctx.beginPath()
|
ctx.beginPath()
|
||||||
ctx.moveTo(0, 0)
|
ctx.moveTo(0, 0)
|
||||||
ctx.lineTo(r * 0.8, 0)
|
ctx.lineTo(r * 0.72, 0)
|
||||||
ctx.strokeStyle = 'rgba(0,0,0,0.5)'
|
|
||||||
ctx.lineWidth = Math.max(1, scale() * 0.03)
|
|
||||||
ctx.stroke()
|
ctx.stroke()
|
||||||
|
|
||||||
ctx.restore()
|
ctx.restore()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -106,9 +106,31 @@ html, body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.stones-row {
|
.stones-row {
|
||||||
display: flex;
|
display: grid;
|
||||||
gap: 5px;
|
/* Fixed hammer column so both team chips align vertically under each other. */
|
||||||
|
grid-template-columns: 18px repeat(8, 18px);
|
||||||
|
column-gap: 5px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-items: center;
|
||||||
|
width: max-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hammer-badge {
|
||||||
|
grid-column: 1;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.55));
|
||||||
|
/* Keep layout space when the other team has hammer (no collapse / shift). */
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stones-row--hammer .hammer-badge {
|
||||||
|
visibility: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stone-chip {
|
.stone-chip {
|
||||||
@ -383,14 +405,15 @@ html, body {
|
|||||||
|
|
||||||
.curl-btn {
|
.curl-btn {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
min-width: 64px;
|
min-width: 52px;
|
||||||
height: 36px;
|
height: 40px;
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
border: 2px solid rgba(255, 255, 255, 0.4);
|
border: 2px solid rgba(255, 255, 255, 0.4);
|
||||||
background: rgba(0, 0, 0, 0.4);
|
background: rgba(0, 0, 0, 0.4);
|
||||||
color: white;
|
color: white;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 13px;
|
font-size: 22px;
|
||||||
|
line-height: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user