Compare commits

..

2 Commits

15 changed files with 944 additions and 1100 deletions

View File

@ -71,6 +71,7 @@ impl Game {
broom_y: f32, broom_y: f32,
velocity: f32, velocity: f32,
curl: i8, curl: i8,
friction: f32,
) -> Result<Vec<StonePath>, String> { ) -> Result<Vec<StonePath>, String> {
if self.turn_team != team { if self.turn_team != team {
return Err("Not your turn".to_string()); return Err("Not your turn".to_string());
@ -80,9 +81,9 @@ impl Game {
} }
self.active_stones.clear(); self.active_stones.clear();
let trajectory = self let trajectory =
.physics self.physics
.throw(self.turn_team, broom_x, broom_y, velocity, curl)?; .throw(self.turn_team, broom_x, broom_y, velocity, curl, friction)?;
self.active_stones = self.physics.current_stones(); self.active_stones = self.physics.current_stones();
self.phase = GamePhase::Simulating; self.phase = GamePhase::Simulating;
@ -101,8 +102,9 @@ impl Game {
broom_y: f32, broom_y: f32,
velocity: f32, velocity: f32,
curl: i8, curl: i8,
friction: f32,
) -> Result<ThrowOutcome, String> { ) -> Result<ThrowOutcome, String> {
let trajectories = self.handle_throw(team, broom_x, broom_y, velocity, curl)?; let trajectories = self.handle_throw(team, broom_x, broom_y, velocity, curl, friction)?;
self.finish_simulation(); self.finish_simulation();
let state_message = self.game_state_message(); let state_message = self.game_state_message();
@ -257,7 +259,7 @@ impl Room {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::protocol::DRAW_VELOCITY; use crate::physics::DRAW_VELOCITY;
#[test] #[test]
fn starts_in_waiting_phase() { fn starts_in_waiting_phase() {
@ -283,7 +285,7 @@ mod tests {
game.start(); game.start();
let turn = game.turn_team; let turn = game.turn_team;
let wrong = turn.other(); let wrong = turn.other();
let result = game.handle_throw(wrong, 0.5, 38.7, DRAW_VELOCITY, 1); let result = game.handle_throw(wrong, 0.5, 38.7, DRAW_VELOCITY, 1, 1.0);
assert!(result.is_err()); assert!(result.is_err());
} }
@ -292,7 +294,7 @@ mod tests {
let mut game = Game::new(); let mut game = Game::new();
game.start(); game.start();
let turn = game.turn_team; let turn = game.turn_team;
let result = game.handle_throw(turn, 0.5, 38.7, DRAW_VELOCITY, 1); let result = game.handle_throw(turn, 0.5, 38.7, DRAW_VELOCITY, 1, 1.0);
assert!(result.is_ok()); assert!(result.is_ok());
let paths = result.unwrap(); let paths = result.unwrap();
assert!(!paths.is_empty()); assert!(!paths.is_empty());
@ -306,7 +308,7 @@ mod tests {
game.start(); game.start();
let turn = game.turn_team; let turn = game.turn_team;
// (0.0, 30.0) is well outside HOUSE_RADIUS of HOUSE_CENTER // (0.0, 30.0) is well outside HOUSE_RADIUS of HOUSE_CENTER
let result = game.handle_throw(turn, 0.0, 30.0, DRAW_VELOCITY, 1); let result = game.handle_throw(turn, 0.0, 30.0, DRAW_VELOCITY, 1, 1.0);
assert!( assert!(
result.is_ok(), result.is_ok(),
"broom outside house should be allowed: {:?}", "broom outside house should be allowed: {:?}",
@ -320,7 +322,7 @@ mod tests {
game.start(); game.start();
let turn = game.turn_team; let turn = game.turn_team;
let outcome = game let outcome = game
.process_throw(turn, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, 0) .process_throw(turn, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, 0, 1.0)
.unwrap(); .unwrap();
// ThrowOutcome must not carry end_scored // ThrowOutcome must not carry end_scored
assert!(!outcome.trajectories.is_empty()); assert!(!outcome.trajectories.is_empty());
@ -364,7 +366,7 @@ mod tests {
let mut game = Game::new(); let mut game = Game::new();
game.start(); game.start();
let turn = game.turn_team; let turn = game.turn_team;
game.handle_throw(turn, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, 0) game.handle_throw(turn, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, 0, 1.0)
.unwrap(); .unwrap();
match turn { match turn {
Team::Team1 => assert_eq!(game.stones_team1, STONES_PER_TEAM - 1), Team::Team1 => assert_eq!(game.stones_team1, STONES_PER_TEAM - 1),

View File

@ -180,11 +180,12 @@ fn spawn_message_handler(
broom_y, broom_y,
velocity, velocity,
curl, curl,
friction,
}) => { }) => {
let mut room_guard = room.lock().await; let mut room_guard = room.lock().await;
match room_guard match room_guard
.game .game
.process_throw(team, broom_x, broom_y, velocity, curl) .process_throw(team, broom_x, broom_y, velocity, curl, friction)
{ {
Ok(ThrowOutcome { Ok(ThrowOutcome {
trajectories, trajectories,

File diff suppressed because it is too large Load Diff

View File

@ -17,45 +17,26 @@ 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
pub const HOG_LINE_Y: f32 = 21.0; pub const HOG_LINE_Y: f32 = 21.0;
/// Backline touches the outer house ring (tee + 6 ft). pub const BACK_LINE_Y: f32 = 42.0;
pub const BACK_LINE_Y: f32 = HOUSE_CENTER.1 + HOUSE_RADIUS;
pub const HACK_Y: f32 = 2.0; pub const HACK_Y: f32 = 2.0;
// Stone physical properties // Stone physical properties
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;
/// Newton restitution for stonestone contacts (Rapier, Max/Average combiners). /// Newton restitution for stonestone 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; pub const STONE_RESTITUTION: f32 = 0.9;
/// Soft guard end of the throw slider. Weight 1 → MIN_SPEED.
/// Stop offsets (feet relative to tee) for weights 1..=10 and hack. /// Calibrated with DRAW_VELOCITY so mid-slider (weight 5) lands near the tee.
/// Positive = past tee toward backline (behind the tee). /// Shared with the frontend; binary sim does not clamp on it (clients send free velocity).
pub const WEIGHT_STOP_OFFSET_FT: [f32; 10] =
[-11.0, -9.0, -7.0, -5.0, -3.0, -1.0, 0.0, 1.0, 3.0, 5.0];
pub const HACK_STOP_OFFSET_FT: f32 = 12.0;
/// m/s for weights 1..=10 (calibrated via open-ice stop distance).
pub const WEIGHT_SPEEDS: [f32; 10] = [
2.2573, 2.2783, 2.2992, 2.3199, 2.3404, 2.3608, 2.3709, 2.3810, 2.4012, 2.4211,
];
pub const HACK_SPEED: f32 = 2.4899;
/// Takeouts: open-ice stop at hack+N feet (N = 6,12,18,24).
pub const BOARD_SPEED: f32 = 2.5492;
pub const CONTROL_SPEED: f32 = 2.6408;
pub const NORMAL_SPEED: f32 = 2.7448;
pub const PEEL_SPEED: f32 = 2.8499;
pub const BOARD_STOP_OFFSET_FT: f32 = HACK_STOP_OFFSET_FT + 6.0;
pub const CONTROL_STOP_OFFSET_FT: f32 = HACK_STOP_OFFSET_FT + 15.0;
pub const NORMAL_STOP_OFFSET_FT: f32 = HACK_STOP_OFFSET_FT + 25.0;
pub const PEEL_STOP_OFFSET_FT: f32 = HACK_STOP_OFFSET_FT + 35.0;
/// Tee-line weight (category 7).
pub const DRAW_VELOCITY: f32 = WEIGHT_SPEEDS[6];
/// UI soft end (weight 1).
#[allow(dead_code)] #[allow(dead_code)]
pub const MIN_SPEED: f32 = WEIGHT_SPEEDS[0]; pub const MIN_SPEED: f32 = 1.9;
/// UI heavy end of draw weights (weight 10). Peel/hack sit above this. /// Heavy end of the throw slider. Weight 10 → MAX_SPEED.
/// Keep span so weight 5 ≈ DRAW_VELOCITY (2.38).
#[allow(dead_code)] #[allow(dead_code)]
pub const MAX_SPEED: f32 = WEIGHT_SPEEDS[9]; 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")]
@ -108,12 +89,17 @@ pub enum ClientMessage {
velocity: f32, velocity: f32,
#[serde(default = "default_curl")] #[serde(default = "default_curl")]
curl: i8, curl: i8,
#[serde(default = "default_friction")]
friction: f32,
}, },
} }
fn default_curl() -> i8 { fn default_curl() -> i8 {
1 1
} }
fn default_friction() -> f32 {
1.0
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EndScore { pub struct EndScore {
@ -199,7 +185,7 @@ mod tests {
#[test] #[test]
fn throw_message_uses_velocity_not_weight() { fn throw_message_uses_velocity_not_weight() {
let json = r#"{"type":"throw","team":"team1","broom_x":0.5,"broom_y":38.5,"velocity":4.2,"curl":1}"#; let json = r#"{"type":"throw","team":"team1","broom_x":0.5,"broom_y":38.5,"velocity":4.2,"curl":1,"friction":1.0}"#;
let msg: ClientMessage = serde_json::from_str(json).unwrap(); let msg: ClientMessage = serde_json::from_str(json).unwrap();
match msg { match msg {
ClientMessage::Throw { ClientMessage::Throw {

View File

@ -1,20 +1,16 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { import {
formatSpeedLabel,
hogTrimStartIndex, hogTrimStartIndex,
indexOfLabel,
labelAtIndex,
sampleTime, sampleTime,
SPEED_LABEL_ORDER,
trimPathToStartAtHogLine, trimPathToStartAtHogLine,
velocityForLabel,
velocityToWeight, velocityToWeight,
weightToVelocity, weightToVelocity,
} from './game-helpers' } from './game-helpers'
import { DRAW_VELOCITY, SAMPLE_RATE_HZ, SPEED_TABLE } from './protocol' import { SAMPLE_RATE_HZ } from './protocol'
describe('trimPathToStartAtHogLine', () => { describe('trimPathToStartAtHogLine', () => {
it('trims path at the first hog-line crossing and preserves theta', () => { 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][] = [ const path: [number, number, number][] = [
[0, 2, 0.1], [0, 2, 0.1],
[0, 20, 0.2], [0, 20, 0.2],
@ -57,25 +53,22 @@ describe('hogTrimStartIndex', () => {
}) })
describe('velocity ↔ weight', () => { describe('velocity ↔ weight', () => {
it('maps endpoints and tee-line weight 7', () => { it('maps endpoints correctly', () => {
expect(weightToVelocity(7)).toBe(DRAW_VELOCITY) expect(velocityToWeight(1.9)).toBe(1)
expect(velocityToWeight(DRAW_VELOCITY)).toBe(7) expect(velocityToWeight(3.0)).toBe(10)
expect(weightToVelocity(1)).toBe(1.9)
expect(weightToVelocity(10)).toBe(3.0)
}) })
it('includes takeout labels on the weight continuum', () => { it('mid weight is near draw (tee-line) velocity', () => {
expect(SPEED_LABEL_ORDER).toEqual([ // weight 5 → 1.9 + 4/9 * 1.1 ≈ 2.389 — calibrated DRAW_VELOCITY
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 'hack', 'board', 'control', 'normal', 'peel', expect(weightToVelocity(5)).toBeCloseTo(2.389, 2)
]) })
expect(formatSpeedLabel('board')).toBe('board')
expect(formatSpeedLabel(7)).toBe('7') it('clamps out-of-range inputs', () => {
expect(labelAtIndex(indexOfLabel('hack'))).toBe('hack') expect(velocityToWeight(1.5)).toBe(1)
expect(labelAtIndex(indexOfLabel('peel'))).toBe('peel') expect(velocityToWeight(4.0)).toBe(10)
expect(velocityForLabel('board')).toBe(SPEED_TABLE.board) expect(weightToVelocity(0)).toBe(1.9)
expect(velocityForLabel('control')).toBe(SPEED_TABLE.control) expect(weightToVelocity(11)).toBe(3.0)
expect(velocityForLabel('normal')).toBe(SPEED_TABLE.normal)
expect(velocityForLabel('peel')).toBe(SPEED_TABLE.peel)
expect(SPEED_TABLE.board).toBeLessThan(SPEED_TABLE.control)
expect(SPEED_TABLE.control).toBeLessThan(SPEED_TABLE.normal)
expect(SPEED_TABLE.normal).toBeLessThan(SPEED_TABLE.peel)
}) })
}) })

View File

@ -1,9 +1,4 @@
import { import { HOG_LINE_Y, MAX_SPEED, MIN_SPEED, SAMPLE_RATE_HZ } from './protocol'
HOG_LINE_Y,
SAMPLE_RATE_HZ,
SPEED_TABLE,
type SpeedLabel,
} from './protocol'
/** Path samples are (x, y, theta). Time is sample index / SAMPLE_RATE_HZ. */ /** Path samples are (x, y, theta). Time is sample index / SAMPLE_RATE_HZ. */
export function sampleTime(index: number): number { export function sampleTime(index: number): number {
@ -16,6 +11,8 @@ export function trimPathToStartAtHogLine(
if (path.length < 2) return path if (path.length < 2) return path
const idx = path.findIndex(([, y]) => y >= HOG_LINE_Y) const idx = path.findIndex(([, y]) => y >= HOG_LINE_Y)
if (idx < 0) return path 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 start = Math.max(0, idx - 1)
return path.slice(start) return path.slice(start)
} }
@ -28,47 +25,14 @@ export function hogTrimStartIndex(path: [number, number, number][]): number {
return Math.max(0, idx - 1) return Math.max(0, idx - 1)
} }
/** Full weight order for the UI slider: draws 110 then takeouts. */ export function velocityToWeight(velocity: number): number {
export const SPEED_LABEL_ORDER: readonly SpeedLabel[] = [ const t = (velocity - MIN_SPEED) / (MAX_SPEED - MIN_SPEED)
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 'hack', 'board', 'control', 'normal', 'peel', const weight = 1 + Math.round(t * 9)
] as const return Math.max(1, Math.min(10, weight))
export function clampSpeedIndex(index: number): number {
return Math.max(0, Math.min(SPEED_LABEL_ORDER.length - 1, Math.round(index)))
}
export function labelAtIndex(index: number): SpeedLabel {
return SPEED_LABEL_ORDER[clampSpeedIndex(index)]!
}
export function indexOfLabel(label: SpeedLabel): number {
const i = SPEED_LABEL_ORDER.indexOf(label)
return i < 0 ? 6 : i
}
export function formatSpeedLabel(label: SpeedLabel): string {
return typeof label === 'number' ? String(label) : label
}
export function velocityForLabel(label: SpeedLabel): number {
return SPEED_TABLE[label]
} }
export function weightToVelocity(weight: number): number { export function weightToVelocity(weight: number): number {
// Legacy: numeric 110 only const clamped = Math.max(1, Math.min(10, weight))
const w = Math.max(1, Math.min(10, Math.round(weight))) const t = (clamped - 1) / 9
return SPEED_TABLE[w as SpeedLabel] return MIN_SPEED + t * (MAX_SPEED - MIN_SPEED)
}
export function velocityToWeight(velocity: number): number {
let best = 1
let bestDist = Infinity
for (let w = 1; w <= 10; w++) {
const d = Math.abs(SPEED_TABLE[w as SpeedLabel] - velocity)
if (d < bestDist) {
bestDist = d
best = w
}
}
return best
} }

View File

@ -150,129 +150,4 @@ describe('GameModel multi-path trajectory animation', () => {
it('uses sample index for time (SAMPLE_RATE_HZ)', () => { it('uses sample index for time (SAMPLE_RATE_HZ)', () => {
expect(SAMPLE_RATE_HZ).toBe(40) expect(SAMPLE_RATE_HZ).toBe(40)
}) })
it('does not snap a takeout target back to its pre-throw rest position', () => {
const model = new GameModel()
const rest = stone({ id: sid('team1', 1), team: 'team1', x: 0.2, y: 38.5 })
model.state.stones = [rest]
model.state.turnTeam = 'team2'
// Shared clock: thrown reaches hog ~ sample 2; target then exits past backline.
const thrownPath = pathSamples([
{ x: 0, y: 2 },
{ x: 0, y: 20 },
{ x: 0, y: 25 },
{ x: 0, y: 38.5 },
{ x: 0, y: 40 },
])
const targetPath = pathSamples([
{ x: 0.2, y: 38.5 },
{ x: 0.2, y: 38.5 },
{ x: 0.2, y: 38.5 },
{ x: 0.4, y: 39.5 },
{ x: 1.0, y: 42.0 }, // edge past backline → out
])
model.startTrajectory([
stonePath(sid('team2', 1), 'team2', thrownPath),
stonePath(sid('team1', 1), 'team1', targetPath),
])
// Final game_state: only shooter remains in play.
model.updateGameState({
type: 'game_state',
end: 1,
scores: [0, 0],
hammer: 'team1',
turn_team: 'team1',
scoreboard: [],
stones_remaining: [8, 7],
stones: [
stone({ id: sid('team2', 1), team: 'team2', x: 0, y: 39 }),
],
phase: 'playing',
})
const afterEnd = performance.now() + 5000
model.tick(afterEnd)
expect(model.state.animating).toBe(false)
expect(model.state.stones).toHaveLength(1)
expect(model.state.stones[0]?.id).toEqual(sid('team2', 1))
// Must not resurrect target at rest position.
expect(model.state.stones.some((s) => s.id.team === 'team1' && s.id.n === 1)).toBe(
false,
)
})
it('does not resurrect a removed rock at the start of the next throw', () => {
const model = new GameModel()
model.state.stones = [stone({ id: sid('team1', 1), team: 'team1', x: 0.2, y: 38.5 })]
model.state.turnTeam = 'team2'
// First throw: hit team1/1 out. Server post-state has only shooter.
model.startTrajectory([
stonePath(
sid('team2', 1),
'team2',
pathSamples([
{ x: 0, y: 2 },
{ x: 0, y: 20 },
{ x: 0, y: 38 },
{ x: 0, y: 39 },
]),
),
stonePath(
sid('team1', 1),
'team1',
pathSamples([
{ x: 0.2, y: 38.5 },
{ x: 0.2, y: 38.5 },
{ x: 0.5, y: 40 },
{ x: 1.2, y: 42 },
]),
),
])
model.updateGameState({
type: 'game_state',
end: 1,
scores: [0, 0],
hammer: 'team1',
turn_team: 'team1',
scoreboard: [],
stones_remaining: [8, 7],
stones: [stone({ id: sid('team2', 1), team: 'team2', x: 0, y: 39 })],
phase: 'playing',
})
model.tick(performance.now() + 5000)
expect(model.state.stones.map((s) => s.id)).toEqual([sid('team2', 1)])
// Second throw: only shooter already on ice + new team1 rock.
model.state.turnTeam = 'team1'
model.startTrajectory([
stonePath(
sid('team2', 1),
'team2',
pathSamples([
{ x: 0, y: 39 },
{ x: 0, y: 39 },
{ x: 0, y: 39 },
]),
),
stonePath(
sid('team1', 2),
'team1',
pathSamples([
{ x: 0, y: 2 },
{ x: 0, y: 20 },
{ x: 0, y: 30 },
]),
),
])
// First frame of next throw must not draw removed team1/1 at old rest.
const drawn = model.tick(performance.now())
expect(drawn.every((d) => !(d.team === 'team1' && Math.abs(d.x - 0.2) < 0.01 && Math.abs(d.y - 38.5) < 0.01))).toBe(
true,
)
expect(model.state.stones.some((s) => s.id.team === 'team1' && s.id.n === 1)).toBe(false)
})
}) })

View File

@ -1,10 +1,5 @@
import { import {
HOUSE_CENTER, HOUSE_CENTER,
SAMPLE_RATE_HZ,
STONE_RADIUS,
BACK_LINE_Y,
HOG_LINE_Y,
SHEET_WIDTH,
stoneIdKey, stoneIdKey,
stoneIdsEqual, stoneIdsEqual,
type DrawableStone, type DrawableStone,
@ -16,7 +11,7 @@ import {
type StoneState, type StoneState,
type Team, type Team,
} from './protocol' } from './protocol'
import { hogTrimStartIndex, sampleTime } from './game-helpers' import { hogTrimStartIndex, sampleTime, trimPathToStartAtHogLine } from './game-helpers'
export interface GameModelState { export interface GameModelState {
end: number end: number
@ -31,24 +26,6 @@ export interface GameModelState {
animating: boolean animating: boolean
} }
/** Side / back edge-touch (same as backend edge_out_of_bounds) for mid-flight hide. */
function samplePastSideOrBack(x: number, y: number): boolean {
const half = SHEET_WIDTH / 2
return Math.abs(x) + STONE_RADIUS >= half || y + STONE_RADIUS >= BACK_LINE_Y
}
/** Final rest OOB: sides/back or never cleared the hog. */
function pathEndedOutOfPlay(path: [number, number, number][]): boolean {
if (path.length === 0) return true
const [x, y] = path[path.length - 1]!
const half = SHEET_WIDTH / 2
return (
Math.abs(x) + STONE_RADIUS >= half ||
y + STONE_RADIUS >= BACK_LINE_Y ||
y - STONE_RADIUS <= HOG_LINE_Y
)
}
export class GameModel { export class GameModel {
state: GameModelState = { state: GameModelState = {
end: 1, end: 1,
@ -68,15 +45,8 @@ export class GameModel {
isDragging = false isDragging = false
isPanning = false isPanning = false
private activePaths = new Map< private activePaths = new Map<string, { id: StoneId; team: Team; path: [number, number, number][] }>()
string,
{ id: StoneId; team: Team; path: [number, number, number][] }
>()
private animationStartTime = 0 private animationStartTime = 0
/** Absolute sample index when the thrown stone reaches the hog (shared clock). */
private pathClockOffset = 0
/** True once a game_state snapshot arrived for the in-flight anim. */
private hasServerSnapshot = false
setMyTeam(team: Team): void { setMyTeam(team: Team): void {
this.state.myTeam = team this.state.myTeam = team
@ -91,19 +61,12 @@ export class GameModel {
if (reset) { if (reset) {
this.state.stones = [] this.state.stones = []
this.pendingStones = [] this.pendingStones = []
this.hasServerSnapshot = false
this.activePaths.clear()
this.state.animating = false
} }
if (this.state.animating) { if (this.state.animating) {
// Authoritative post-throw board — apply even when empty (all stones removed).
this.pendingStones = msg.stones this.pendingStones = msg.stones
this.hasServerSnapshot = true
} else { } else {
this.state.stones = msg.stones this.state.stones = msg.stones
this.pendingStones = []
this.hasServerSnapshot = false
} }
this.state = { this.state = {
@ -119,9 +82,6 @@ export class GameModel {
} }
startTrajectory(stones: StonePath[]): void { startTrajectory(stones: StonePath[]): void {
// Commit any leftover server board before basing "existing" on it.
this.commitServerStones()
const existingKeys = new Set(this.state.stones.map((s) => stoneIdKey(s.id))) const existingKeys = new Set(this.state.stones.map((s) => stoneIdKey(s.id)))
let thrownId: StoneId | null = null let thrownId: StoneId | null = null
@ -132,36 +92,30 @@ export class GameModel {
} }
} }
if (thrownId === null && stones.length > 0) { if (thrownId === null && stones.length > 0) {
thrownId = stones[0]!.stone_id thrownId = stones[0].stone_id
} }
this.pathClockOffset = 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) { if (thrownId !== null) {
const thrownPath = const thrownPath = stones.find((p) => stoneIdsEqual(p.stone_id, thrownId!))?.trajectory ?? []
stones.find((p) => stoneIdsEqual(p.stone_id, thrownId!))?.trajectory ?? [] startIdx = hogTrimStartIndex(thrownPath)
this.pathClockOffset = hogTrimStartIndex(thrownPath)
} }
const pathMap = new Map< const pathMap = new Map<string, { id: StoneId; team: Team; path: [number, number, number][] }>()
string,
{ id: StoneId; team: Team; path: [number, number, number][] }
>()
for (const { stone_id, team, trajectory } of stones) { for (const { stone_id, team, trajectory } of stones) {
pathMap.set(stoneIdKey(stone_id), { const path =
id: stone_id, thrownId !== null && stoneIdsEqual(stone_id, thrownId)
team, ? trimPathToStartAtHogLine(trajectory)
path: trajectory, : trajectory.slice(startIdx)
}) pathMap.set(stoneIdKey(stone_id), { id: stone_id, team, path })
} }
this.activePaths = pathMap this.activePaths = pathMap
this.state.animating = Array.from(pathMap.values()).some( this.state.animating = Array.from(pathMap.values()).some((p) => p.path.length > 1)
(p) => p.path.length > this.pathClockOffset + 1,
)
this.animationStartTime = performance.now() this.animationStartTime = performance.now()
// Fresh throw: wait for the matching game_state.
this.pendingStones = [] this.pendingStones = []
this.hasServerSnapshot = false
} }
tick(now: number): DrawableStone[] { tick(now: number): DrawableStone[] {
@ -170,99 +124,63 @@ export class GameModel {
} }
const elapsed = (now - this.animationStartTime) / 1000 const elapsed = (now - this.animationStartTime) / 1000
const absT = this.pathClockOffset / SAMPLE_RATE_HZ + elapsed const maxTotal = Math.max(
const maxAbsT = Math.max(
0, 0,
...Array.from(this.activePaths.values()).map((p) => ...Array.from(this.activePaths.values()).map((p) =>
p.path.length > 0 ? sampleTime(p.path.length - 1) : 0, p.path.length > 0 ? sampleTime(p.path.length - 1) : 0,
), ),
) )
if (absT >= maxAbsT) { if (elapsed >= maxTotal) {
this.finishAnimation() this.state.animating = false
if (this.pendingStones.length > 0) {
this.state.stones = this.pendingStones
this.pendingStones = []
}
return [] return []
} }
const result: DrawableStone[] = [] const result: DrawableStone[] = []
for (const { id, team, path } of this.activePaths.values()) { for (const { id, team, path } of this.activePaths.values()) {
const pos = this.interpolatePathAtAbsTime(path, absT) const pos = this.interpolatePath(path, elapsed)
if (!pos) continue if (!pos) continue
if (samplePastSideOrBack(pos.x, pos.y)) continue
const resolvedTeam = const resolvedTeam =
this.state.stones.find((s) => stoneIdsEqual(s.id, id))?.team ?? this.state.stones.find((s) => stoneIdsEqual(s.id, id))?.team ?? team ?? this.state.turnTeam
team ??
this.state.turnTeam
result.push({ ...pos, team: resolvedTeam }) result.push({ ...pos, team: resolvedTeam })
} }
return result return result
} }
private commitServerStones(): void { private interpolatePath(
if (!this.hasServerSnapshot) return
this.state.stones = this.pendingStones
this.pendingStones = []
this.hasServerSnapshot = false
}
private finishAnimation(): void {
this.state.animating = false
if (this.hasServerSnapshot) {
// Apply even when pending is [] (full clear).
this.state.stones = this.pendingStones
this.pendingStones = []
this.hasServerSnapshot = false
} else {
const settled: StoneState[] = []
for (const { id, team, path } of this.activePaths.values()) {
if (path.length === 0 || pathEndedOutOfPlay(path)) continue
const last = path[path.length - 1]!
settled.push({
id,
team,
x: last[0],
y: last[1],
rotation: last[2],
})
}
this.state.stones = settled
}
this.activePaths.clear()
this.pathClockOffset = 0
}
private interpolatePathAtAbsTime(
path: [number, number, number][], path: [number, number, number][],
absT: number, elapsed: number,
): { x: number; y: number; rotation: number } | null { ): { x: number; y: number; rotation: number } | null {
if (path.length === 0) return null if (path.length === 0) return null
if (path.length === 1) { if (path.length === 1) {
const [x, y, theta] = path[0]! const [x, y, theta] = path[0]
return { x, y, rotation: theta } return { x, y, rotation: theta }
} }
const lastT = sampleTime(path.length - 1) const lastT = sampleTime(path.length - 1)
if (absT <= 0) { if (elapsed >= lastT) {
const [x, y, theta] = path[0]! const last = path[path.length - 1]
return { x, y, rotation: theta }
}
if (absT >= lastT) {
const last = path[path.length - 1]!
return { x: last[0], y: last[1], rotation: last[2] } return { x: last[0], y: last[1], rotation: last[2] }
} }
let i = Math.min(path.length - 2, Math.max(0, Math.floor(absT * SAMPLE_RATE_HZ))) // Find segment where sampleTime(i) <= elapsed < sampleTime(i+1)
while (i + 1 < path.length && sampleTime(i + 1) < absT) i++ let i = 0
while (i > 0 && sampleTime(i) > absT) i-- while (i + 1 < path.length && sampleTime(i + 1) < elapsed) i++
const p0 = path[i]
const p0 = path[i]!
const p1 = path[i + 1] ?? p0 const p1 = path[i + 1] ?? p0
const t0 = sampleTime(i) const t0 = sampleTime(i)
const t1 = sampleTime(i + 1) const t1 = sampleTime(i + 1)
const dt = t1 - t0 const dt = t1 - t0
const t = dt > 0 ? (absT - t0) / dt : 0 const t = dt > 0 ? (elapsed - t0) / dt : 0
const x = p0[0] + (p1[0] - p0[0]) * t const x = p0[0] + (p1[0] - p0[0]) * t
const y = p0[1] + (p1[1] - p0[1]) * t const y = p0[1] + (p1[1] - p0[1]) * t
// Interpolate body rotation (theta) from path samples
let dTheta = p1[2] - p0[2] 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
if (dTheta < -Math.PI) dTheta += 2 * Math.PI if (dTheta < -Math.PI) dTheta += 2 * Math.PI
const rotation = p0[2] + dTheta * t const rotation = p0[2] + dTheta * t

View File

@ -1,6 +1,6 @@
import { connect, sendThrow, type NetCallbacks } from './net' import { connect, sendThrow, type NetCallbacks } from './net'
import { createRenderer } from './renderer' import { createRenderer } from './renderer'
import { createHud, createVelocitySelector, createCurlSelector } from './hud' import { createHud, createVelocitySelector, createCurlSelector, createFrictionSlider } from './hud'
import { type Team } from './protocol' import { type Team } from './protocol'
import { GameModel } from './game-model' import { GameModel } from './game-model'
@ -19,9 +19,11 @@ export function startGame(): void {
const velocityContainer = hud.velocityControl const velocityContainer = hud.velocityControl
const curlContainer = hud.curlSelector const curlContainer = hud.curlSelector
const frictionContainer = hud.frictionControl
const throwBtn = hud.throwButton const throwBtn = hud.throwButton
const velocity = createVelocitySelector(velocityContainer, () => {}) const velocity = createVelocitySelector(velocityContainer, () => {})
const curls = createCurlSelector(curlContainer, () => {}) const curls = createCurlSelector(curlContainer, () => {})
const friction = createFrictionSlider(frictionContainer)
const params = new URLSearchParams(window.location.search) const params = new URLSearchParams(window.location.search)
let room = params.get('room') let room = params.get('room')
@ -35,14 +37,6 @@ export function startGame(): void {
const model = new GameModel() const model = new GameModel()
let lastScoreboardLen = model.state.scoreboard.length let lastScoreboardLen = model.state.scoreboard.length
type EndModalPayload = {
end: number
team1: number
team2: number
nextHammer: Team
scoreboard: typeof model.state.scoreboard
}
let deferredEndModal: EndModalPayload | null = null
const stored = localStorage.getItem('curltastic-team') const stored = localStorage.getItem('curltastic-team')
const initialTeam: Team = stored === 'team2' || stored === 'yellow' ? 'team2' : 'team1' const initialTeam: Team = stored === 'team2' || stored === 'yellow' ? 'team2' : 'team1'
@ -57,30 +51,27 @@ export function startGame(): void {
const myTurn = model.isMyTurn const myTurn = model.isMyTurn
velocity.setEnabled(myTurn) velocity.setEnabled(myTurn)
curls.setEnabled(myTurn) curls.setEnabled(myTurn)
friction.setEnabled(myTurn)
throwBtn.disabled = !myTurn throwBtn.disabled = !myTurn
hud.update(model.state) hud.update(model.state)
} }
/** Queue end modal when scoreboard grows; only show after throw animation. */ const maybeShowEndModal = () => {
const queueEndModalIfNeeded = () => {
const board = model.state.scoreboard const board = model.state.scoreboard
if (board.length <= lastScoreboardLen) return if (board.length <= lastScoreboardLen) {
lastScoreboardLen = board.length
return
}
const last = board[board.length - 1] const last = board[board.length - 1]
lastScoreboardLen = board.length lastScoreboardLen = board.length
if (!last) return if (!last) return
deferredEndModal = { hud.showEndModal({
end: last.end, end: last.end,
team1: last.team1, team1: last.team1,
team2: last.team2, team2: last.team2,
nextHammer: model.state.hammer, nextHammer: model.state.hammer,
scoreboard: board, scoreboard: board,
} })
}
const flushEndModal = () => {
if (!deferredEndModal || model.state.animating) return
hud.showEndModal(deferredEndModal)
deferredEndModal = null
} }
hud.teamSelect.addEventListener('change', () => { hud.teamSelect.addEventListener('change', () => {
@ -92,7 +83,6 @@ export function startGame(): void {
const wasAnimating = model.state.animating const wasAnimating = model.state.animating
const activeStonePos = model.tick(performance.now()) const activeStonePos = model.tick(performance.now())
if (wasAnimating && !model.state.animating) { if (wasAnimating && !model.state.animating) {
flushEndModal()
updateControls() updateControls()
} }
@ -120,15 +110,11 @@ export function startGame(): void {
}, },
onGameState: (msg) => { onGameState: (msg) => {
model.updateGameState(msg) model.updateGameState(msg)
queueEndModalIfNeeded() maybeShowEndModal()
// Never flush here: game_state can arrive before/during throw anim.
// Modal is flushed when the trajectory animation ends (or traj is a no-op).
updateControls() updateControls()
}, },
onTrajectories: (stones) => { onTrajectories: (stones) => {
model.startTrajectory(stones) model.startTrajectory(stones)
// Short / empty paths finish immediately — show end modal if already queued.
flushEndModal()
updateControls() updateControls()
}, },
onGameOver: (scores, winner) => { onGameOver: (scores, winner) => {
@ -208,6 +194,7 @@ export function startGame(): void {
model.broom.y, model.broom.y,
velocity.getVelocity(), velocity.getVelocity(),
curls.getSelected(), curls.getSelected(),
friction.getFriction(),
) )
}) })

View File

@ -1,24 +1,19 @@
import { import {
ENDS, MAX_SPEED,
MIN_SPEED,
STONES_PER_TEAM, STONES_PER_TEAM,
type EndScore, type EndScore,
type Phase, type Phase,
type Team, type Team,
} from './protocol' } from './protocol'
import { import { velocityToWeight, weightToVelocity } from './game-helpers'
clampSpeedIndex,
formatSpeedLabel,
indexOfLabel,
labelAtIndex,
velocityForLabel,
} from './game-helpers'
import { renderBaseballScoreboard } from './scoreboard'
export interface Hud { export interface Hud {
root: HTMLDivElement root: HTMLDivElement
teamSelect: HTMLSelectElement teamSelect: HTMLSelectElement
velocityControl: HTMLDivElement velocityControl: HTMLDivElement
curlSelector: HTMLDivElement curlSelector: HTMLDivElement
frictionControl: HTMLDivElement
throwButton: HTMLButtonElement throwButton: HTMLButtonElement
setTeam: (team: Team) => void setTeam: (team: Team) => void
update: (state: { update: (state: {
@ -79,14 +74,20 @@ function buildStoneChipsHtml(team: Team): string {
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>` 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 scoreboardTotals(scoreboard: readonly EndScore[]): [number, number] { function renderScoreboardTable(scoreboard: EndScore[]): string {
let t1 = 0 if (scoreboard.length === 0) {
let t2 = 0 return '<p class="end-modal-empty">No ends scored yet</p>'
for (const e of scoreboard) {
t1 += e.team1
t2 += e.team2
} }
return [t1, t2] const rows = scoreboard
.map(
(e) =>
`<tr><td>${e.end}</td><td>${e.team1}</td><td>${e.team2}</td><td>${TEAM_LABELS[e.hammer]}</td></tr>`,
)
.join('')
return `<table class="scoreboard-table" aria-label="Scoreboard">
<thead><tr><th>End</th><th>Team 1</th><th>Team 2</th><th>Hammer</th></tr></thead>
<tbody>${rows}</tbody>
</table>`
} }
export function createHud(): Hud { export function createHud(): Hud {
@ -101,18 +102,24 @@ export function createHud(): Hud {
${buildStoneChipsHtml('team1')} ${buildStoneChipsHtml('team1')}
${buildStoneChipsHtml('team2')} ${buildStoneChipsHtml('team2')}
</div> </div>
<div id="scoreboard-baseball" class="scoreboard-baseball-wrap" aria-live="polite"></div>
<div class="hud-row"> <div class="hud-row">
<div id="score">Team 1 0 - Team 2 0</div>
<div id="end-info">End 1 · Waiting</div> <div id="end-info">End 1 · Waiting</div>
<select id="team-select" aria-label="Team"> <select id="team-select" aria-label="Team">
<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> </div>
<div id="scoreboard-strip" class="scoreboard-strip" aria-label="End scores"></div>
</div> </div>
<div class="hud-row" style="align-items:flex-end;"> <div class="hud-row" style="align-items:flex-end;">
<div id="velocity-control"></div> <div id="velocity-control"></div>
<div id="curl-selector"></div> <div id="curl-selector"></div>
<div id="friction-control">
<label for="friction-slider">Friction</label>
<input id="friction-slider" type="range" min="0.5" max="1.5" step="0.1" value="1.0" />
<span id="friction-value">1.0</span>
</div>
<div> <div>
<button id="throw-btn" type="button" disabled>THROW</button> <button id="throw-btn" type="button" disabled>THROW</button>
</div> </div>
@ -120,11 +127,12 @@ export function createHud(): Hud {
<div id="waiting">Waiting</div> <div id="waiting">Waiting</div>
` `
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 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 scoreboardEl = root.querySelector<HTMLDivElement>('#scoreboard-baseball')! const scoreboardStrip = root.querySelector<HTMLDivElement>('#scoreboard-strip')!
const updateHammerBadge = (hammer: Team) => { const updateHammerBadge = (hammer: Team) => {
for (const team of ['team1', 'team2'] as const) { for (const team of ['team1', 'team2'] as const) {
@ -145,8 +153,13 @@ export function createHud(): Hud {
} }
let endModalEl: HTMLDivElement | null = null let endModalEl: HTMLDivElement | null = null
let endModalTimer = 0
const dismissEndModal = () => { const dismissEndModal = () => {
if (endModalTimer) {
window.clearTimeout(endModalTimer)
endModalTimer = 0
}
if (endModalEl) { if (endModalEl) {
endModalEl.remove() endModalEl.remove()
endModalEl = null endModalEl = null
@ -176,33 +189,33 @@ export function createHud(): Hud {
} }
} }
const updateScoreboard = ( const updateScoreboardStrip = (scoreboard: EndScore[], totals: number[]) => {
scoreboard: EndScore[], if (scoreboard.length === 0) {
totals: number[], scoreboardStrip.textContent = ''
options?: { hammer?: Team; currentEnd?: number }, scoreboardStrip.hidden = true
) => { return
const t1 = totals[0] ?? 0 }
const t2 = totals[1] ?? 0 scoreboardStrip.hidden = false
scoreboardEl.innerHTML = renderBaseballScoreboard(scoreboard, [t1, t2], { const cells = scoreboard
ends: ENDS, .map((e) => `<span class="scoreboard-end" title="End ${e.end}">${e.team1}-${e.team2}</span>`)
hammer: options?.hammer, .join('')
currentEnd: options?.currentEnd, scoreboardStrip.innerHTML = `${cells}<span class="scoreboard-total">Σ ${totals[0] ?? 0}-${totals[1] ?? 0}</span>`
})
} }
updateStonesRemaining([STONES_PER_TEAM, STONES_PER_TEAM]) updateStonesRemaining([STONES_PER_TEAM, STONES_PER_TEAM])
updateScoreboard([], [0, 0], { currentEnd: 1 })
return { return {
root, root,
teamSelect, teamSelect,
velocityControl: root.querySelector<HTMLDivElement>('#velocity-control')!, velocityControl: root.querySelector<HTMLDivElement>('#velocity-control')!,
curlSelector: root.querySelector<HTMLDivElement>('#curl-selector')!, curlSelector: root.querySelector<HTMLDivElement>('#curl-selector')!,
frictionControl: root.querySelector<HTMLDivElement>('#friction-control')!,
throwButton: root.querySelector<HTMLButtonElement>('#throw-btn')!, throwButton: root.querySelector<HTMLButtonElement>('#throw-btn')!,
setTeam: (team) => { setTeam: (team) => {
teamSelect.value = team teamSelect.value = team
}, },
update: (state) => { update: (state) => {
scoreEl.textContent = `Team 1 ${state.scores[0] ?? 0} - Team 2 ${state.scores[1] ?? 0}`
const phaseText = const phaseText =
state.phase === 'playing' state.phase === 'playing'
? `${TEAM_LABELS[state.turnTeam]}'s turn` ? `${TEAM_LABELS[state.turnTeam]}'s turn`
@ -212,10 +225,7 @@ export function createHud(): Hud {
// Hammer class first so stones-remaining aria can mention it. // Hammer class first so stones-remaining aria can mention it.
updateHammerBadge(state.hammer) updateHammerBadge(state.hammer)
updateStonesRemaining(state.stonesRemaining) updateStonesRemaining(state.stonesRemaining)
updateScoreboard(state.scoreboard, state.scores, { updateScoreboardStrip(state.scoreboard, state.scores)
hammer: state.hammer,
currentEnd: state.end,
})
}, },
showEndModal: (payload) => { showEndModal: (payload) => {
dismissEndModal() dismissEndModal()
@ -224,15 +234,6 @@ export function createHud(): Hud {
modal.setAttribute('role', 'dialog') modal.setAttribute('role', 'dialog')
modal.setAttribute('aria-modal', 'true') modal.setAttribute('aria-modal', 'true')
modal.setAttribute('aria-labelledby', 'end-modal-title') modal.setAttribute('aria-labelledby', 'end-modal-title')
const boardHtml = renderBaseballScoreboard(
payload.scoreboard,
scoreboardTotals(payload.scoreboard),
{
ends: ENDS,
hammer: payload.nextHammer,
currentEnd: payload.end,
},
)
modal.innerHTML = ` modal.innerHTML = `
<div class="end-modal-backdrop" data-dismiss="1"></div> <div class="end-modal-backdrop" data-dismiss="1"></div>
<div class="end-modal-card"> <div class="end-modal-card">
@ -243,7 +244,7 @@ export function createHud(): Hud {
<span class="end-modal-team end-modal-team--team2">Team 2 <strong>${payload.team2}</strong></span> <span class="end-modal-team end-modal-team--team2">Team 2 <strong>${payload.team2}</strong></span>
</p> </p>
<p class="end-modal-hammer">Next hammer: <strong>${TEAM_LABELS[payload.nextHammer]}</strong></p> <p class="end-modal-hammer">Next hammer: <strong>${TEAM_LABELS[payload.nextHammer]}</strong></p>
<div class="end-modal-board">${boardHtml}</div> <div class="end-modal-board">${renderScoreboardTable(payload.scoreboard)}</div>
<button type="button" class="end-modal-dismiss" data-dismiss="1">Dismiss</button> <button type="button" class="end-modal-dismiss" data-dismiss="1">Dismiss</button>
</div> </div>
` `
@ -253,6 +254,7 @@ export function createHud(): Hud {
}) })
document.body.appendChild(modal) document.body.appendChild(modal)
endModalEl = modal endModalEl = modal
endModalTimer = window.setTimeout(dismissEndModal, 5000)
}, },
showToast: (message: string) => { showToast: (message: string) => {
const toast = document.createElement('div') const toast = document.createElement('div')
@ -279,63 +281,44 @@ export function createVelocitySelector(
container: HTMLDivElement, container: HTMLDivElement,
onSelect: () => void, onSelect: () => void,
): { getVelocity: () => number; setEnabled: (enabled: boolean) => void } { ): { getVelocity: () => number; setEnabled: (enabled: boolean) => void } {
const state = { index: indexOfLabel(7) } const state = { weight: 5 }
container.innerHTML = '' container.innerHTML = ''
const wrap = document.createElement('div') const wrap = document.createElement('div')
wrap.className = 'velocity-control-inner' wrap.className = 'velocity-control-inner'
const title = document.createElement('label') const label = document.createElement('label')
title.textContent = 'Weight' label.textContent = 'Velocity'
wrap.appendChild(title) wrap.appendChild(label)
const slider = document.createElement('input') const slider = document.createElement('input')
slider.type = 'range' slider.type = 'range'
slider.min = '0' slider.min = String(MIN_SPEED)
slider.max = String(14) // 1..10 + hack/board/control/normal/peel slider.max = String(MAX_SPEED)
slider.step = '1' slider.step = '0.05'
slider.value = String(state.index) slider.value = String(weightToVelocity(state.weight))
slider.className = 'velocity-slider' slider.className = 'velocity-slider'
slider.setAttribute('list', 'weight-marks')
slider.ariaLabel = 'Throw weight'
const marks = document.createElement('datalist') const datalist = document.createElement('datalist')
marks.id = 'weight-marks' datalist.id = 'velocity-marks'
const tickLabels = [1, 5, 7, 10, 'hack', 'board', 'control', 'normal', 'peel'] as const for (let w = 1; w <= 10; w++) {
for (const tick of tickLabels) {
const opt = document.createElement('option') const opt = document.createElement('option')
opt.value = String(indexOfLabel(tick)) opt.value = String(weightToVelocity(w))
opt.label = String(tick) opt.label = String(w)
marks.appendChild(opt) datalist.appendChild(opt)
} }
slider.setAttribute('list', 'velocity-marks')
const ticks = document.createElement('div') wrap.appendChild(slider)
ticks.className = 'velocity-ticks' wrap.appendChild(datalist)
ticks.setAttribute('aria-hidden', 'true')
const named = ['hack', 'board', 'control', 'normal', 'peel'] as const
ticks.innerHTML = named
.map((name) => `<span class="velocity-tick" data-label="${name}">${name}</span>`)
.join('')
const readout = document.createElement('div') const readout = document.createElement('div')
readout.className = 'velocity-readout' readout.className = 'velocity-readout'
wrap.appendChild(slider)
wrap.appendChild(marks)
wrap.appendChild(ticks)
wrap.appendChild(readout) wrap.appendChild(readout)
const update = () => { const update = () => {
state.index = clampSpeedIndex(Number(slider.value)) const v = Number(slider.value)
const label = labelAtIndex(state.index) state.weight = velocityToWeight(v)
const v = velocityForLabel(label) readout.textContent = `${v.toFixed(2)} m/s · Weight ${state.weight}`
const name = formatSpeedLabel(label)
const isTakeout = typeof label === 'string'
readout.textContent = isTakeout
? `${name} · ${v.toFixed(3)} m/s`
: `${name} · ${v.toFixed(3)} m/s`
ticks.querySelectorAll<HTMLSpanElement>('.velocity-tick').forEach((el) => {
el.classList.toggle('velocity-tick--active', el.dataset.label === String(label))
})
onSelect() onSelect()
} }
slider.addEventListener('input', update) slider.addEventListener('input', update)
@ -344,7 +327,7 @@ export function createVelocitySelector(
container.appendChild(wrap) container.appendChild(wrap)
return { return {
getVelocity: () => velocityForLabel(labelAtIndex(clampSpeedIndex(Number(slider.value)))), getVelocity: () => Number(slider.value),
setEnabled: (enabled) => { setEnabled: (enabled) => {
slider.disabled = !enabled slider.disabled = !enabled
}, },
@ -355,7 +338,7 @@ 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 } {
// curl>0 = clockwise (drifts left when heading down-sheet); curl<0 = CCW (right). // 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. // 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 = [
@ -396,3 +379,23 @@ export function createCurlSelector(
}, },
} }
} }
export function createFrictionSlider(
container: HTMLDivElement,
): { getFriction: () => number; setEnabled: (enabled: boolean) => void } {
const slider = container.querySelector<HTMLInputElement>('#friction-slider')!
const valueEl = container.querySelector<HTMLSpanElement>('#friction-value')!
const updateValue = () => {
valueEl.textContent = Number(slider.value).toFixed(1)
}
slider.addEventListener('input', updateValue)
updateValue()
return {
getFriction: () => Number(slider.value),
setEnabled: (enabled) => {
slider.disabled = !enabled
},
}
}

View File

@ -78,6 +78,7 @@ export function sendThrow(
broomY: number, broomY: number,
velocity: number, velocity: number,
curl: number, curl: number,
friction: number,
): void { ): void {
if (!socket || socket.readyState !== WebSocket.OPEN) return if (!socket || socket.readyState !== WebSocket.OPEN) return
socket.send( socket.send(
@ -88,6 +89,7 @@ export function sendThrow(
broom_y: broomY, broom_y: broomY,
velocity, velocity,
curl, curl,
friction,
}), }),
) )
} }

View File

@ -12,55 +12,13 @@ export const FOUR_FT_RADIUS = 2 * FEET_TO_METERS
export const EIGHT_FT_RADIUS = 4 * FEET_TO_METERS export const EIGHT_FT_RADIUS = 4 * FEET_TO_METERS
export const TWELVE_FT_RADIUS = 6 * FEET_TO_METERS export const TWELVE_FT_RADIUS = 6 * FEET_TO_METERS
export const HOG_LINE_Y = 21.0 export const HOG_LINE_Y = 21.0
/** Backline on outer house ring (tee + 6 ft). */ export const BACK_LINE_Y = 42.0
export const BACK_LINE_Y = HOUSE_CENTER.y + HOUSE_RADIUS
export const HACK_Y = 2.0 export const HACK_Y = 2.0
export const STONE_RADIUS = 0.15 export const STONE_RADIUS = 0.15
/** Soft guard (weight 1). Mid slider (weight 5) ≈ DRAW 2.38 m/s lands near tee. */
/** Feet relative to tee for weights 1..10 (front is negative). */ export const MIN_SPEED = 1.9
export const WEIGHT_STOP_OFFSET_FT = [-11, -9, -7, -5, -3, -1, 0, 1, 3, 5] as const /** Heavy (weight 10). Span keeps weight 5 ≈ DRAW_VELOCITY. */
export const HACK_STOP_OFFSET_FT = 12 export const MAX_SPEED = 3.0
/** Calibrated m/s for weights 1..10 (open-ice stop vs tee offsets). */
export const WEIGHT_SPEEDS = [
2.2573, 2.2783, 2.2992, 2.3199, 2.3404, 2.3608, 2.3709, 2.3810, 2.4012, 2.4211,
] as const
export const HACK_SPEED = 2.4899
/** Takeouts: open-ice stop at hack+N feet (N = 6,12,18,24). */
export const BOARD_SPEED = 2.5492
export const CONTROL_SPEED = 2.6408
export const NORMAL_SPEED = 2.7448
export const PEEL_SPEED = 2.8499
export const BOARD_STOP_OFFSET_FT = HACK_STOP_OFFSET_FT + 6
export const CONTROL_STOP_OFFSET_FT = HACK_STOP_OFFSET_FT + 15
export const NORMAL_STOP_OFFSET_FT = HACK_STOP_OFFSET_FT + 25
export const PEEL_STOP_OFFSET_FT = HACK_STOP_OFFSET_FT + 35
/** Tee-line weight (category 7). */
export const DRAW_VELOCITY = WEIGHT_SPEEDS[6]
export const MIN_SPEED = WEIGHT_SPEEDS[0]
export const MAX_SPEED = WEIGHT_SPEEDS[9]
export type SpeedLabel =
| 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10
| 'hack' | 'board' | 'control' | 'normal' | 'peel'
export const SPEED_TABLE: Record<SpeedLabel, number> = {
1: WEIGHT_SPEEDS[0],
2: WEIGHT_SPEEDS[1],
3: WEIGHT_SPEEDS[2],
4: WEIGHT_SPEEDS[3],
5: WEIGHT_SPEEDS[4],
6: WEIGHT_SPEEDS[5],
7: WEIGHT_SPEEDS[6],
8: WEIGHT_SPEEDS[7],
9: WEIGHT_SPEEDS[8],
10: WEIGHT_SPEEDS[9],
hack: HACK_SPEED,
board: BOARD_SPEED,
control: CONTROL_SPEED,
normal: NORMAL_SPEED,
peel: PEEL_SPEED,
}
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'
@ -99,6 +57,7 @@ export interface ClientThrowMessage {
broom_y: number broom_y: number
velocity: number velocity: number
curl: number curl: number
friction: number
} }
export interface ServerJoinedMessage { export interface ServerJoinedMessage {

View File

@ -1,76 +0,0 @@
import { describe, expect, it } from 'vitest'
import { baseballEndCount, renderBaseballScoreboard } from './scoreboard'
import type { EndScore } from './protocol'
import { ENDS } from './protocol'
describe('renderBaseballScoreboard', () => {
const sample: EndScore[] = [
{ end: 1, hammer: 'team1', team1: 2, team2: 0 },
{ end: 2, hammer: 'team2', team1: 0, team2: 1 },
]
it('renders Team 1, Team 2, end columns, and R totals', () => {
// Given scored ends and totals
// When rendering baseball scoreboard
const html = renderBaseballScoreboard(sample, [2, 1], {
ends: ENDS,
hammer: 'team1',
currentEnd: 3,
})
// Then structure includes teams, ends, and run totals
expect(html).toContain('Team 1')
expect(html).toContain('Team 2')
expect(html).toContain('scoreboard-baseball')
expect(html).toContain('>R<')
expect(html).toMatch(/class="scoreboard-baseball__runs">2</)
expect(html).toMatch(/class="scoreboard-baseball__runs">1</)
for (let end = 1; end <= ENDS; end++) {
expect(html).toContain(`>${end}<`)
}
expect(html).toContain('>2</td>') // team1 end 1
expect(html).toContain('>0</td>')
expect(html).toContain('>1</td>') // team2 end 2
})
it('shows blank cells for unscored future ends', () => {
const html = renderBaseballScoreboard(
[{ end: 1, hammer: 'team1', team1: 1, team2: 0 }],
[1, 0],
{ ends: 3 },
)
// Three end columns; ends 2 and 3 empty
const emptyCells = html.match(/scoreboard-baseball__cell--empty/g) ?? []
// 2 teams × 2 empty ends
expect(emptyCells.length).toBe(4)
})
it('marks hammer team with H badge', () => {
const html = renderBaseballScoreboard(sample, [2, 1], { hammer: 'team2' })
expect(html).toContain('scoreboard-baseball__team--team2 scoreboard-baseball__team--hammer')
expect(html).toContain('scoreboard-baseball__hammer')
expect(html).not.toContain(
'scoreboard-baseball__team--team1 scoreboard-baseball__team--hammer',
)
})
it('expands past ENDS when scoreboard is longer', () => {
const long: EndScore[] = Array.from({ length: 12 }, (_, i) => ({
end: i + 1,
hammer: 'team1' as const,
team1: 1,
team2: 0,
}))
expect(baseballEndCount(long)).toBe(12)
const html = renderBaseballScoreboard(long, [12, 0])
expect(html).toContain('>12<')
})
it('renders empty matrix when no ends scored yet', () => {
const html = renderBaseballScoreboard([], [0, 0], { ends: ENDS })
expect(html).toContain('Team 1')
expect(html).toContain('Team 2')
expect(html).toContain('>R<')
expect(html).toMatch(/class="scoreboard-baseball__runs">0</)
})
})

View File

@ -1,116 +0,0 @@
import { ENDS, type EndScore, type Team } from './protocol'
export type BaseballScoreboardOptions = {
readonly ends?: number
readonly hammer?: Team
readonly currentEnd?: number
}
const TEAM_LABELS: Record<Team, string> = {
team1: 'Team 1',
team2: 'Team 2',
}
const TEAMS = ['team1', 'team2'] as const
function lastScoredEnd(scoreboard: readonly EndScore[]): number {
let max = 0
for (const entry of scoreboard) {
if (entry.end > max) max = entry.end
}
return max
}
/** Column count: prefer 1..max(ENDS, scoreboard length, currentEnd) with blanks for unscored. */
export function baseballEndCount(
scoreboard: readonly EndScore[],
options?: BaseballScoreboardOptions,
): number {
const preferred = options?.ends ?? ENDS
const current = options?.currentEnd ?? 0
return Math.max(preferred, scoreboard.length, lastScoredEnd(scoreboard), current, 1)
}
function scoreByEnd(scoreboard: readonly EndScore[]): Map<number, EndScore> {
const map = new Map<number, EndScore>()
for (const entry of scoreboard) {
map.set(entry.end, entry)
}
return map
}
function cellForTeam(entry: EndScore | undefined, team: Team): string {
if (!entry) return ''
return String(team === 'team1' ? entry.team1 : entry.team2)
}
/**
* Pure baseball-style scoreboard HTML table.
*
* ```
* | 1 | 2 | ... | N | R
* Team1| ..| ..| | | total
* Team2| ..| ..| | | total
* ```
*/
export function renderBaseballScoreboard(
scoreboard: readonly EndScore[],
totals: readonly [number, number],
options?: BaseballScoreboardOptions,
): string {
const endCount = baseballEndCount(scoreboard, options)
const byEnd = scoreByEnd(scoreboard)
const hammer = options?.hammer
const currentEnd = options?.currentEnd
const headerEnds = Array.from({ length: endCount }, (_, i) => {
const end = i + 1
const currentClass =
currentEnd === end ? ' scoreboard-baseball__end--current' : ''
return `<th scope="col" class="scoreboard-baseball__end${currentClass}">${end}</th>`
}).join('')
const teamRows = TEAMS.map((team, teamIndex) => {
const hasHammer = hammer === team
const hammerMark = hasHammer
? '<span class="scoreboard-baseball__hammer" title="Hammer" aria-label="Hammer">H</span>'
: ''
const rowClass = [
'scoreboard-baseball__team',
`scoreboard-baseball__team--${team}`,
hasHammer ? 'scoreboard-baseball__team--hammer' : '',
]
.filter(Boolean)
.join(' ')
const endCells = Array.from({ length: endCount }, (_, i) => {
const end = i + 1
const entry = byEnd.get(end)
const value = cellForTeam(entry, team)
const emptyClass = value === '' ? ' scoreboard-baseball__cell--empty' : ''
const currentClass =
currentEnd === end ? ' scoreboard-baseball__cell--current' : ''
return `<td class="scoreboard-baseball__cell${emptyClass}${currentClass}">${value}</td>`
}).join('')
const total = totals[teamIndex] ?? 0
return `<tr class="${rowClass}">
<th scope="row" class="scoreboard-baseball__label">${TEAM_LABELS[team]}${hammerMark}</th>
${endCells}
<td class="scoreboard-baseball__runs">${total}</td>
</tr>`
}).join('')
return `<table class="scoreboard-baseball" aria-label="Scoreboard">
<thead>
<tr>
<th scope="col" class="scoreboard-baseball__corner"></th>
${headerEnds}
<th scope="col" class="scoreboard-baseball__runs">R</th>
</tr>
</thead>
<tbody>
${teamRows}
</tbody>
</table>`
}

View File

@ -161,122 +161,32 @@ html, body {
border-color: rgba(255, 255, 255, 0.25); border-color: rgba(255, 255, 255, 0.25);
} }
/* —— Baseball-style scoreboard matrix —— */ .scoreboard-strip {
.scoreboard-baseball-wrap {
display: flex; display: flex;
flex-wrap: wrap;
justify-content: center; justify-content: center;
width: 100%; gap: 4px;
overflow-x: auto; font-size: 11px;
-webkit-overflow-scrolling: touch;
padding: 4px 0 6px;
pointer-events: none;
flex-shrink: 0;
}
.scoreboard-baseball {
width: max-content;
max-width: 100%;
border-collapse: collapse;
table-layout: fixed;
font-family: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
font-size: 12px;
font-weight: 600; font-weight: 600;
line-height: 1.25; opacity: 0.9;
letter-spacing: 0.02em; }
color: #fff;
background: rgba(0, 0, 0, 0.55); .scoreboard-strip[hidden] {
border: 1px solid rgba(255, 255, 255, 0.28); display: none;
}
.scoreboard-end {
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px; border-radius: 8px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), 0 2px 10px rgba(0, 0, 0, 0.35); padding: 2px 6px;
overflow: hidden;
} }
.scoreboard-baseball th, .scoreboard-total {
.scoreboard-baseball td { background: rgba(0, 170, 102, 0.25);
min-width: 1.35em; border: 1px solid rgba(0, 170, 102, 0.45);
padding: 3px 4px; border-radius: 8px;
text-align: center; padding: 2px 6px;
border: 1px solid rgba(255, 255, 255, 0.1);
white-space: nowrap;
}
.scoreboard-baseball thead th {
font-size: 10px;
font-weight: 700;
color: rgba(255, 255, 255, 0.72);
background: rgba(255, 255, 255, 0.06);
}
.scoreboard-baseball__corner {
min-width: 3.6em;
border-left: none;
border-top: none;
background: transparent;
}
.scoreboard-baseball__label {
min-width: 3.6em;
text-align: left;
padding-left: 6px;
padding-right: 6px;
font-size: 10px;
font-weight: 700;
color: rgba(255, 255, 255, 0.92);
background: rgba(255, 255, 255, 0.04);
}
.scoreboard-baseball__team--team1 .scoreboard-baseball__label {
color: #ff6b5c;
}
.scoreboard-baseball__team--team2 .scoreboard-baseball__label {
color: #ffd666;
}
.scoreboard-baseball__runs {
min-width: 1.6em;
font-weight: 800;
background: rgba(0, 170, 102, 0.18);
color: #7dffc0;
}
.scoreboard-baseball thead .scoreboard-baseball__runs {
color: #7dffc0;
background: rgba(0, 170, 102, 0.22);
}
.scoreboard-baseball__cell--empty {
color: rgba(255, 255, 255, 0.22);
}
.scoreboard-baseball__end--current,
.scoreboard-baseball__cell--current {
background: rgba(0, 170, 255, 0.14);
}
.scoreboard-baseball__hammer {
display: inline-block;
margin-left: 3px;
padding: 0 3px;
border-radius: 3px;
font-size: 9px;
font-weight: 800;
line-height: 1.3;
vertical-align: middle;
color: #0b1f3a;
background: #ffd666;
letter-spacing: 0;
}
.end-modal-board .scoreboard-baseball {
width: 100%;
max-width: none;
font-size: 12px;
}
.end-modal-board .scoreboard-baseball th,
.end-modal-board .scoreboard-baseball td {
padding: 5px 4px;
} }
/* —— End-of-end modal —— */ /* —— End-of-end modal —— */
@ -520,9 +430,32 @@ html, body {
opacity: 0.5; opacity: 0.5;
} }
#friction-control {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 4px;
min-width: 100px;
}
#friction-control label {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
#friction-slider {
width: 100px;
pointer-events: auto;
}
#friction-value {
font-size: 12px;
min-width: 24px;
text-align: center;
}
#throw-btn { #throw-btn {
width: 80px; width: 80px;
@ -593,32 +526,23 @@ html, body {
background: rgba(0, 0, 0, 0.5); background: rgba(0, 0, 0, 0.5);
} }
#friction-control {
min-width: 0;
flex: 0 1 auto;
padding: 2px;
}
#friction-slider {
width: 60px;
}
#friction-value {
font-size: 10px;
}
#throw-btn { #throw-btn {
width: 56px; width: 56px;
height: 56px; height: 56px;
font-size: 12px; font-size: 12px;
} }
} }
.velocity-ticks {
display: flex;
justify-content: space-between;
gap: 4px;
width: 100%;
margin-top: 2px;
padding-left: 42%;
box-sizing: border-box;
}
.velocity-tick {
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
color: rgba(255, 255, 255, 0.45);
line-height: 1;
}
.velocity-tick--active {
color: #7dffc0;
}