Compare commits

...

2 Commits

Author SHA1 Message Date
Jason Dekarske
99a4f59a39 actual fix 2026-07-11 13:17:41 -07:00
Jason Dekarske
5eefcd23bf fix 2026-07-11 13:16:27 -07:00
15 changed files with 1099 additions and 943 deletions

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -17,26 +17,45 @@ 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
pub const HOG_LINE_Y: f32 = 21.0;
pub const BACK_LINE_Y: f32 = 42.0;
/// Backline touches the outer house ring (tee + 6 ft).
pub const BACK_LINE_Y: f32 = HOUSE_CENTER.1 + HOUSE_RADIUS;
pub const HACK_Y: f32 = 2.0;
// Stone physical properties
pub const STONE_RADIUS: f32 = 0.15;
pub const STONE_MASS: f32 = 20.0;
pub const STONE_FRICTION: f32 = 0.015;
/// 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.
/// Newton restitution for stonestone contacts (Rapier, Max/Average combiners).
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).
/// Stop offsets (feet relative to tee) for weights 1..=10 and hack.
/// Positive = past tee toward backline (behind the tee).
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)]
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).
pub const MIN_SPEED: f32 = WEIGHT_SPEEDS[0];
/// UI heavy end of draw weights (weight 10). Peel/hack sit above this.
#[allow(dead_code)]
pub const MAX_SPEED: f32 = 3.0;
pub const MAX_SPEED: f32 = WEIGHT_SPEEDS[9];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash, Default)]
#[serde(rename_all = "snake_case")]
@ -89,17 +108,12 @@ pub enum ClientMessage {
velocity: f32,
#[serde(default = "default_curl")]
curl: i8,
#[serde(default = "default_friction")]
friction: f32,
},
}
fn default_curl() -> i8 {
1
}
fn default_friction() -> f32 {
1.0
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EndScore {
@ -185,7 +199,7 @@ mod tests {
#[test]
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,"friction":1.0}"#;
let json = r#"{"type":"throw","team":"team1","broom_x":0.5,"broom_y":38.5,"velocity":4.2,"curl":1}"#;
let msg: ClientMessage = serde_json::from_str(json).unwrap();
match msg {
ClientMessage::Throw {

View File

@ -1,16 +1,20 @@
import { describe, expect, it } from 'vitest'
import {
formatSpeedLabel,
hogTrimStartIndex,
indexOfLabel,
labelAtIndex,
sampleTime,
SPEED_LABEL_ORDER,
trimPathToStartAtHogLine,
velocityForLabel,
velocityToWeight,
weightToVelocity,
} from './game-helpers'
import { SAMPLE_RATE_HZ } from './protocol'
import { DRAW_VELOCITY, SAMPLE_RATE_HZ, SPEED_TABLE } from './protocol'
describe('trimPathToStartAtHogLine', () => {
it('trims path at the first hog-line crossing and preserves theta', () => {
// (x, y, theta) — time is derived from index after trim
const path: [number, number, number][] = [
[0, 2, 0.1],
[0, 20, 0.2],
@ -53,22 +57,25 @@ describe('hogTrimStartIndex', () => {
})
describe('velocity ↔ weight', () => {
it('maps endpoints correctly', () => {
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('maps endpoints and tee-line weight 7', () => {
expect(weightToVelocity(7)).toBe(DRAW_VELOCITY)
expect(velocityToWeight(DRAW_VELOCITY)).toBe(7)
})
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(1.5)).toBe(1)
expect(velocityToWeight(4.0)).toBe(10)
expect(weightToVelocity(0)).toBe(1.9)
expect(weightToVelocity(11)).toBe(3.0)
it('includes takeout labels on the weight continuum', () => {
expect(SPEED_LABEL_ORDER).toEqual([
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 'hack', 'board', 'control', 'normal', 'peel',
])
expect(formatSpeedLabel('board')).toBe('board')
expect(formatSpeedLabel(7)).toBe('7')
expect(labelAtIndex(indexOfLabel('hack'))).toBe('hack')
expect(labelAtIndex(indexOfLabel('peel'))).toBe('peel')
expect(velocityForLabel('board')).toBe(SPEED_TABLE.board)
expect(velocityForLabel('control')).toBe(SPEED_TABLE.control)
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,4 +1,9 @@
import { HOG_LINE_Y, MAX_SPEED, MIN_SPEED, SAMPLE_RATE_HZ } from './protocol'
import {
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. */
export function sampleTime(index: number): number {
@ -11,8 +16,6 @@ export function trimPathToStartAtHogLine(
if (path.length < 2) return path
const idx = path.findIndex(([, y]) => y >= HOG_LINE_Y)
if (idx < 0) return path
// Start just before the hog line crossing so the stone enters smoothly.
// Theta is preserved; time is rebased via sample index on the trimmed array.
const start = Math.max(0, idx - 1)
return path.slice(start)
}
@ -25,14 +28,47 @@ export function hogTrimStartIndex(path: [number, number, number][]): number {
return Math.max(0, idx - 1)
}
export function velocityToWeight(velocity: number): number {
const t = (velocity - MIN_SPEED) / (MAX_SPEED - MIN_SPEED)
const weight = 1 + Math.round(t * 9)
return Math.max(1, Math.min(10, weight))
/** Full weight order for the UI slider: draws 110 then takeouts. */
export const SPEED_LABEL_ORDER: readonly SpeedLabel[] = [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 'hack', 'board', 'control', 'normal', 'peel',
] as const
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 {
const clamped = Math.max(1, Math.min(10, weight))
const t = (clamped - 1) / 9
return MIN_SPEED + t * (MAX_SPEED - MIN_SPEED)
// Legacy: numeric 110 only
const w = Math.max(1, Math.min(10, Math.round(weight)))
return SPEED_TABLE[w as SpeedLabel]
}
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,4 +150,129 @@ describe('GameModel multi-path trajectory animation', () => {
it('uses sample index for time (SAMPLE_RATE_HZ)', () => {
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,5 +1,10 @@
import {
HOUSE_CENTER,
SAMPLE_RATE_HZ,
STONE_RADIUS,
BACK_LINE_Y,
HOG_LINE_Y,
SHEET_WIDTH,
stoneIdKey,
stoneIdsEqual,
type DrawableStone,
@ -11,7 +16,7 @@ import {
type StoneState,
type Team,
} from './protocol'
import { hogTrimStartIndex, sampleTime, trimPathToStartAtHogLine } from './game-helpers'
import { hogTrimStartIndex, sampleTime } from './game-helpers'
export interface GameModelState {
end: number
@ -26,6 +31,24 @@ export interface GameModelState {
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 {
state: GameModelState = {
end: 1,
@ -45,8 +68,15 @@ export class GameModel {
isDragging = false
isPanning = false
private activePaths = new Map<string, { id: StoneId; team: Team; path: [number, number, number][] }>()
private activePaths = new Map<
string,
{ id: StoneId; team: Team; path: [number, number, number][] }
>()
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 {
this.state.myTeam = team
@ -61,12 +91,19 @@ export class GameModel {
if (reset) {
this.state.stones = []
this.pendingStones = []
this.hasServerSnapshot = false
this.activePaths.clear()
this.state.animating = false
}
if (this.state.animating) {
// Authoritative post-throw board — apply even when empty (all stones removed).
this.pendingStones = msg.stones
this.hasServerSnapshot = true
} else {
this.state.stones = msg.stones
this.pendingStones = []
this.hasServerSnapshot = false
}
this.state = {
@ -82,6 +119,9 @@ export class GameModel {
}
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)))
let thrownId: StoneId | null = null
@ -92,30 +132,36 @@ export class GameModel {
}
}
if (thrownId === null && stones.length > 0) {
thrownId = stones[0].stone_id
thrownId = stones[0]!.stone_id
}
// 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
this.pathClockOffset = 0
if (thrownId !== null) {
const thrownPath = stones.find((p) => stoneIdsEqual(p.stone_id, thrownId!))?.trajectory ?? []
startIdx = hogTrimStartIndex(thrownPath)
const thrownPath =
stones.find((p) => stoneIdsEqual(p.stone_id, thrownId!))?.trajectory ?? []
this.pathClockOffset = hogTrimStartIndex(thrownPath)
}
const pathMap = new Map<string, { id: StoneId; team: Team; path: [number, number, number][] }>()
const pathMap = new Map<
string,
{ id: StoneId; team: Team; path: [number, number, number][] }
>()
for (const { stone_id, team, trajectory } of stones) {
const path =
thrownId !== null && stoneIdsEqual(stone_id, thrownId)
? trimPathToStartAtHogLine(trajectory)
: trajectory.slice(startIdx)
pathMap.set(stoneIdKey(stone_id), { id: stone_id, team, path })
pathMap.set(stoneIdKey(stone_id), {
id: stone_id,
team,
path: trajectory,
})
}
this.activePaths = pathMap
this.state.animating = Array.from(pathMap.values()).some((p) => p.path.length > 1)
this.state.animating = Array.from(pathMap.values()).some(
(p) => p.path.length > this.pathClockOffset + 1,
)
this.animationStartTime = performance.now()
// Fresh throw: wait for the matching game_state.
this.pendingStones = []
this.hasServerSnapshot = false
}
tick(now: number): DrawableStone[] {
@ -124,63 +170,99 @@ export class GameModel {
}
const elapsed = (now - this.animationStartTime) / 1000
const maxTotal = Math.max(
const absT = this.pathClockOffset / SAMPLE_RATE_HZ + elapsed
const maxAbsT = Math.max(
0,
...Array.from(this.activePaths.values()).map((p) =>
p.path.length > 0 ? sampleTime(p.path.length - 1) : 0,
),
)
if (elapsed >= maxTotal) {
this.state.animating = false
if (this.pendingStones.length > 0) {
this.state.stones = this.pendingStones
this.pendingStones = []
}
if (absT >= maxAbsT) {
this.finishAnimation()
return []
}
const result: DrawableStone[] = []
for (const { id, team, path } of this.activePaths.values()) {
const pos = this.interpolatePath(path, elapsed)
const pos = this.interpolatePathAtAbsTime(path, absT)
if (!pos) continue
if (samplePastSideOrBack(pos.x, pos.y)) continue
const resolvedTeam =
this.state.stones.find((s) => stoneIdsEqual(s.id, id))?.team ?? team ?? this.state.turnTeam
this.state.stones.find((s) => stoneIdsEqual(s.id, id))?.team ??
team ??
this.state.turnTeam
result.push({ ...pos, team: resolvedTeam })
}
return result
}
private interpolatePath(
private commitServerStones(): void {
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][],
elapsed: number,
absT: number,
): { x: number; y: number; rotation: number } | null {
if (path.length === 0) return null
if (path.length === 1) {
const [x, y, theta] = path[0]
const [x, y, theta] = path[0]!
return { x, y, rotation: theta }
}
const lastT = sampleTime(path.length - 1)
if (elapsed >= lastT) {
const last = path[path.length - 1]
if (absT <= 0) {
const [x, y, theta] = path[0]!
return { x, y, rotation: theta }
}
if (absT >= lastT) {
const last = path[path.length - 1]!
return { x: last[0], y: last[1], rotation: last[2] }
}
// Find segment where sampleTime(i) <= elapsed < sampleTime(i+1)
let i = 0
while (i + 1 < path.length && sampleTime(i + 1) < elapsed) i++
const p0 = path[i]
let i = Math.min(path.length - 2, Math.max(0, Math.floor(absT * SAMPLE_RATE_HZ)))
while (i + 1 < path.length && sampleTime(i + 1) < absT) i++
while (i > 0 && sampleTime(i) > absT) i--
const p0 = path[i]!
const p1 = path[i + 1] ?? p0
const t0 = sampleTime(i)
const t1 = sampleTime(i + 1)
const dt = t1 - t0
const t = dt > 0 ? (elapsed - t0) / dt : 0
const t = dt > 0 ? (absT - t0) / dt : 0
const x = p0[0] + (p1[0] - p0[0]) * t
const y = p0[1] + (p1[1] - p0[1]) * t
// Interpolate body rotation (theta) from path samples
let dTheta = p1[2] - p0[2]
// Unwrap shortest path across ±π
if (dTheta > Math.PI) dTheta -= 2 * Math.PI
if (dTheta < -Math.PI) dTheta += 2 * Math.PI
const rotation = p0[2] + dTheta * t

View File

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

View File

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

View File

@ -12,13 +12,55 @@ export const FOUR_FT_RADIUS = 2 * FEET_TO_METERS
export const EIGHT_FT_RADIUS = 4 * FEET_TO_METERS
export const TWELVE_FT_RADIUS = 6 * FEET_TO_METERS
export const HOG_LINE_Y = 21.0
export const BACK_LINE_Y = 42.0
/** Backline on outer house ring (tee + 6 ft). */
export const BACK_LINE_Y = HOUSE_CENTER.y + HOUSE_RADIUS
export const HACK_Y = 2.0
export const STONE_RADIUS = 0.15
/** 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
/** Feet relative to tee for weights 1..10 (front is negative). */
export const WEIGHT_STOP_OFFSET_FT = [-11, -9, -7, -5, -3, -1, 0, 1, 3, 5] as const
export const HACK_STOP_OFFSET_FT = 12
/** 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 Phase = 'waiting' | 'playing' | 'simulating' | 'scoring' | 'end_complete' | 'game_complete'
@ -57,7 +99,6 @@ export interface ClientThrowMessage {
broom_y: number
velocity: number
curl: number
friction: number
}
export interface ServerJoinedMessage {

View File

@ -0,0 +1,76 @@
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</)
})
})

116
frontend/src/scoreboard.ts Normal file
View File

@ -0,0 +1,116 @@
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,32 +161,122 @@ html, body {
border-color: rgba(255, 255, 255, 0.25);
}
.scoreboard-strip {
/* —— Baseball-style scoreboard matrix —— */
.scoreboard-baseball-wrap {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 4px;
font-size: 11px;
width: 100%;
overflow-x: auto;
-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;
opacity: 0.9;
}
.scoreboard-strip[hidden] {
display: none;
}
.scoreboard-end {
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.2);
line-height: 1.25;
letter-spacing: 0.02em;
color: #fff;
background: rgba(0, 0, 0, 0.55);
border: 1px solid rgba(255, 255, 255, 0.28);
border-radius: 8px;
padding: 2px 6px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), 0 2px 10px rgba(0, 0, 0, 0.35);
overflow: hidden;
}
.scoreboard-total {
background: rgba(0, 170, 102, 0.25);
border: 1px solid rgba(0, 170, 102, 0.45);
border-radius: 8px;
padding: 2px 6px;
.scoreboard-baseball th,
.scoreboard-baseball td {
min-width: 1.35em;
padding: 3px 4px;
text-align: center;
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 —— */
@ -430,32 +520,9 @@ html, body {
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 {
width: 80px;
@ -526,23 +593,32 @@ html, body {
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 {
width: 56px;
height: 56px;
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;
}