more changes

This commit is contained in:
Jason Dekarske 2026-07-11 10:13:48 -07:00
parent 0c5449cd1e
commit 94c6949426
8 changed files with 388 additions and 57 deletions

View File

@ -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).
- Stonestone 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`.

View File

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

View File

@ -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 stonestone 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")]

View File

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

View File

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

View File

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

View File

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

View File

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