From 94c6949426cc13e03e68cf5f0e7833cb9516ce4d Mon Sep 17 00:00:00 2001 From: Jason Dekarske Date: Sat, 11 Jul 2026 10:13:48 -0700 Subject: [PATCH] more changes --- README.md | 3 +- backend/src/physics.rs | 286 +++++++++++++++++++++++++++--- backend/src/protocol.rs | 18 +- frontend/src/game-helpers.test.ts | 21 ++- frontend/src/hud.ts | 39 +++- frontend/src/protocol.ts | 6 +- frontend/src/renderer.ts | 39 ++-- frontend/src/style.css | 33 +++- 8 files changed, 388 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index 9f8eb31..562b62e 100644 --- a/README.md +++ b/README.md @@ -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. - 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. - Stone ids: `{ team, n }` with n = 1…8 per team per end. - Foot-derived radii use `FEET_TO_METERS = 0.3048`. diff --git a/backend/src/physics.rs b/backend/src/physics.rs index bec77af..9020d6e 100644 --- a/backend/src/physics.rs +++ b/backend/src/physics.rs @@ -11,12 +11,29 @@ const G: f32 = 9.80665; /// 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; +/// Target spin duration (s) matching the 5-rev / 14 s design. +/// Client animation only starts at the hog (~9 s in); damping must leave +/// 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 /// 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; /// Ice friction coefficient µ as a function of speed (m/s). @@ -192,6 +209,7 @@ impl PhysicsWorld { .angvel(omega0) .linear_damping(0.0) .angular_damping(0.0) + .ccd_enabled(true) .can_sleep(false) .build(); @@ -207,6 +225,7 @@ impl PhysicsWorld { self.colliders .insert_with_parent(collider, handle, &mut self.bodies); + self.stone_handles .push((id, handle, team, curl_sign, friction_scalar)); @@ -251,12 +270,16 @@ impl PhysicsWorld { if let Some(body) = self.bodies.get(*handle) { let pos = body.translation(); 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]); } } } - if self.all_stones_at_rest() || time > MAX_SIM_TIME { + if self.all_stones_settled_or_out() || time > MAX_SIM_TIME { break; } } @@ -311,11 +334,14 @@ impl PhysicsWorld { } /// 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 + /// - Angular damping designed for ~SPIN_HOLD_S hold (not µmg/R, which killed + /// spin in ~4 s — before the client ever drew the stone past the hog) + /// - 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) { - const EPS: f32 = 1e-3; + const MIN_CURL_SPEED: f32 = 0.08; for (_, handle, _, curl_sign, friction_scalar) in &self.stone_handles { let body = match self.bodies.get_mut(*handle) { @@ -325,12 +351,15 @@ impl PhysicsWorld { let v = body.linvel(); 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(); - if omega.abs() > 1e-8 { - let domega = mu_eff * G / STONE_RADIUS * PHYSICS_DT; + if speed < REST_SPEED { + 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() { 0.0 } else { @@ -339,22 +368,25 @@ impl PhysicsWorld { body.set_angvel(new_omega, true); } - if *curl_sign == 0 || speed < 1e-4 { + if *curl_sign == 0 || speed < MIN_CURL_SPEED { continue; } - // Unit forward and right (CW 90° from forward): (ux,uy) → (uy, -ux). - // Moving +y → right = +x. + // Instantaneous velocity heading and body-right (CW 90°). 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); + // v_lat = k * µ(speed) * friction_scalar (same µ table as ice friction). + let v_lat = + (*curl_sign as f32) * CURL_LAT_K * mu(speed) * *friction_scalar; + // Continuous: a_n = v_lat / τ → integrates without per-tick geometric stack. + 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 { 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 speed = (v.x * v.x + v.y * v.y).sqrt(); 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] fn stones_persist_after_multiple_throws() { 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> = 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] fn stone_path_samples_are_xyz_arrays() { let mut world = PhysicsWorld::new(); @@ -826,4 +965,109 @@ mod tests { assert_eq!(sample.len(), 3); 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()); + } } + diff --git a/backend/src/protocol.rs b/backend/src/protocol.rs index 02c1336..0c89ef5 100644 --- a/backend/src/protocol.rs +++ b/backend/src/protocol.rs @@ -11,6 +11,8 @@ pub const ENDS: u8 = 10; // World coordinates in meters, y along sheet toward house. 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 HOUSE_CENTER: (f32, f32) = (0.0, 38.5); 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_MASS: f32 = 20.0; pub const STONE_FRICTION: f32 = 0.015; -pub const STONE_RESTITUTION: f32 = 0.05; -pub const MIN_SPEED: f32 = 3.0; -pub const MAX_SPEED: f32 = 6.45; +/// Newton restitution for stone–stone contacts (Rapier, Average combine). +/// Curling granite is nearly elastic on contact; low e makes takeouts feel like +/// 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)] #[serde(rename_all = "snake_case")] diff --git a/frontend/src/game-helpers.test.ts b/frontend/src/game-helpers.test.ts index 4970d23..335c2a9 100644 --- a/frontend/src/game-helpers.test.ts +++ b/frontend/src/game-helpers.test.ts @@ -54,16 +54,21 @@ describe('hogTrimStartIndex', () => { describe('velocity ↔ weight', () => { it('maps endpoints correctly', () => { - expect(velocityToWeight(3.0)).toBe(1) - expect(velocityToWeight(6.45)).toBe(10) - expect(weightToVelocity(1)).toBe(3.0) - expect(weightToVelocity(10)).toBe(6.45) + expect(velocityToWeight(1.9)).toBe(1) + expect(velocityToWeight(3.0)).toBe(10) + expect(weightToVelocity(1)).toBe(1.9) + 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', () => { - expect(velocityToWeight(2.5)).toBe(1) - expect(velocityToWeight(7.0)).toBe(10) - expect(weightToVelocity(0)).toBe(3.0) - expect(weightToVelocity(11)).toBe(6.45) + expect(velocityToWeight(1.5)).toBe(1) + expect(velocityToWeight(4.0)).toBe(10) + expect(weightToVelocity(0)).toBe(1.9) + expect(weightToVelocity(11)).toBe(3.0) }) }) diff --git a/frontend/src/hud.ts b/frontend/src/hud.ts index e85aa9b..45af5cc 100644 --- a/frontend/src/hud.ts +++ b/frontend/src/hud.ts @@ -70,7 +70,8 @@ function buildStoneChipsHtml(team: Team): string { const chips = Array.from({ length: STONES_PER_TEAM }, (_, i) => { return `` }).join('') - return `` + // Hammer glyph lives on the row of the team that has last-rock; toggled in update(). + return `` } function renderScoreboardTable(scoreboard: EndScore[]): string { @@ -108,7 +109,6 @@ export function createHud(): Hud { -
Hammer: -
@@ -130,11 +130,28 @@ export function createHud(): Hud { const scoreEl = root.querySelector('#score')! const endInfoEl = root.querySelector('#end-info')! const teamSelect = root.querySelector('#team-select')! - const hammerEl = root.querySelector('#hammer')! const waitingEl = root.querySelector('#waiting')! const stonesHud = root.querySelector('#stones-hud')! const scoreboardStrip = root.querySelector('#scoreboard-strip')! + const updateHammerBadge = (hammer: Team) => { + for (const team of ['team1', 'team2'] as const) { + const row = stonesHud.querySelector(`.stones-row--${team}`) + const badge = row?.querySelector('.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 endModalTimer = 0 @@ -158,7 +175,11 @@ export function createHud(): Hud { const firstRemaining = STONES_PER_TEAM - left const row = stonesHud.querySelector(`.stones-row--${team}`) 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('.stone-chip').forEach((chip) => { const index = Number(chip.dataset.index) const isRemaining = index >= firstRemaining @@ -200,8 +221,9 @@ export function createHud(): Hud { ? `${TEAM_LABELS[state.turnTeam]}'s turn` : state.phase.replace(/_/g, ' ') endInfoEl.textContent = `End ${state.end} · ${phaseText}` - hammerEl.textContent = `Hammer: ${TEAM_LABELS[state.hammer]}` 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) updateScoreboardStrip(state.scoreboard, state.scores) }, @@ -316,15 +338,18 @@ export function createCurlSelector( container: HTMLDivElement, onSelect: (curl: number) => 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 options = [ - { value: -1, label: '↷', ariaLabel: 'Left curl' }, - { value: 1, label: '↶', ariaLabel: 'Right curl' }, + { value: -1, label: '↺', ariaLabel: 'Counterclockwise curl' }, + { value: 1, label: '↻', ariaLabel: 'Clockwise curl' }, ] container.innerHTML = '' for (const opt of options) { const btn = document.createElement('button') btn.className = 'curl-btn' + btn.type = 'button' btn.textContent = opt.label btn.ariaLabel = opt.ariaLabel btn.dataset.curl = String(opt.value) diff --git a/frontend/src/protocol.ts b/frontend/src/protocol.ts index bc1ebd7..91aefb6 100644 --- a/frontend/src/protocol.ts +++ b/frontend/src/protocol.ts @@ -15,8 +15,10 @@ export const HOG_LINE_Y = 21.0 export const BACK_LINE_Y = 42.0 export const HACK_Y = 2.0 export const STONE_RADIUS = 0.15 -export const MIN_SPEED = 3.0 -export const MAX_SPEED = 6.45 +/** Soft guard (weight 1). Mid slider (weight 5) ≈ DRAW 2.38 m/s lands near tee. */ +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 Phase = 'waiting' | 'playing' | 'simulating' | 'scoring' | 'end_complete' | 'game_complete' diff --git a/frontend/src/renderer.ts b/frontend/src/renderer.ts index 3afef15..1924104 100644 --- a/frontend/src/renderer.ts +++ b/frontend/src/renderer.ts @@ -160,23 +160,42 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer { const c = worldToScreen(stone.x, stone.y) const r = STONE_RADIUS * scale() // team1 = red palette, team2 = yellow palette + const rim = stone.team === 'team1' ? '#8b1a12' : '#a66d00' const color = stone.team === 'team1' ? '#d93025' : '#f9ab00' - ctx.beginPath() - ctx.arc(c.x, c.y, r, 0, Math.PI * 2) - ctx.fillStyle = color - ctx.fill() - ctx.strokeStyle = '#fff' - ctx.lineWidth = Math.max(1, scale() * 0.02) - ctx.stroke() + const highlight = stone.team === 'team1' ? '#ff6b5c' : '#ffd666' + + // Paint but body fully in stone frame so θ from physics is obvious while spinning. + // Canvas +Y is down; negate so CCW body angle matches ice coordinates. ctx.save() ctx.translate(c.x, c.y) 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.moveTo(0, 0) - ctx.lineTo(r * 0.8, 0) - ctx.strokeStyle = 'rgba(0,0,0,0.5)' - ctx.lineWidth = Math.max(1, scale() * 0.03) + ctx.lineTo(r * 0.72, 0) ctx.stroke() + ctx.restore() } diff --git a/frontend/src/style.css b/frontend/src/style.css index 58c9034..aac68b7 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -106,9 +106,31 @@ html, body { } .stones-row { - display: flex; - gap: 5px; + display: grid; + /* 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; + 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 { @@ -383,14 +405,15 @@ html, body { .curl-btn { flex: 0 0 auto; - min-width: 64px; - height: 36px; + min-width: 52px; + height: 40px; border-radius: 18px; border: 2px solid rgba(255, 255, 255, 0.4); background: rgba(0, 0, 0, 0.4); color: white; font-weight: 700; - font-size: 13px; + font-size: 22px; + line-height: 1; display: flex; align-items: center; justify-content: center;