actual fix

This commit is contained in:
Jason Dekarske 2026-07-11 13:17:41 -07:00
parent 5eefcd23bf
commit 99a4f59a39
11 changed files with 797 additions and 230 deletions

View File

@ -1,16 +1,20 @@
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 { SAMPLE_RATE_HZ } from './protocol' import { DRAW_VELOCITY, SAMPLE_RATE_HZ, SPEED_TABLE } 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],
@ -53,22 +57,25 @@ describe('hogTrimStartIndex', () => {
}) })
describe('velocity ↔ weight', () => { describe('velocity ↔ weight', () => {
it('maps endpoints correctly', () => { it('maps endpoints and tee-line weight 7', () => {
expect(velocityToWeight(1.9)).toBe(1) expect(weightToVelocity(7)).toBe(DRAW_VELOCITY)
expect(velocityToWeight(3.0)).toBe(10) expect(velocityToWeight(DRAW_VELOCITY)).toBe(7)
expect(weightToVelocity(1)).toBe(1.9)
expect(weightToVelocity(10)).toBe(3.0)
}) })
it('mid weight is near draw (tee-line) velocity', () => { it('includes takeout labels on the weight continuum', () => {
// weight 5 → 1.9 + 4/9 * 1.1 ≈ 2.389 — calibrated DRAW_VELOCITY expect(SPEED_LABEL_ORDER).toEqual([
expect(weightToVelocity(5)).toBeCloseTo(2.389, 2) 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 'hack', 'board', 'control', 'normal', 'peel',
}) ])
expect(formatSpeedLabel('board')).toBe('board')
it('clamps out-of-range inputs', () => { expect(formatSpeedLabel(7)).toBe('7')
expect(velocityToWeight(1.5)).toBe(1) expect(labelAtIndex(indexOfLabel('hack'))).toBe('hack')
expect(velocityToWeight(4.0)).toBe(10) expect(labelAtIndex(indexOfLabel('peel'))).toBe('peel')
expect(weightToVelocity(0)).toBe(1.9) expect(velocityForLabel('board')).toBe(SPEED_TABLE.board)
expect(weightToVelocity(11)).toBe(3.0) 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. */ /** 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 {
@ -11,8 +16,6 @@ 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)
} }
@ -25,14 +28,47 @@ export function hogTrimStartIndex(path: [number, number, number][]): number {
return Math.max(0, idx - 1) return Math.max(0, idx - 1)
} }
export function velocityToWeight(velocity: number): number { /** Full weight order for the UI slider: draws 110 then takeouts. */
const t = (velocity - MIN_SPEED) / (MAX_SPEED - MIN_SPEED) export const SPEED_LABEL_ORDER: readonly SpeedLabel[] = [
const weight = 1 + Math.round(t * 9) 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 'hack', 'board', 'control', 'normal', 'peel',
return Math.max(1, Math.min(10, weight)) ] 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 { export function weightToVelocity(weight: number): number {
const clamped = Math.max(1, Math.min(10, weight)) // Legacy: numeric 110 only
const t = (clamped - 1) / 9 const w = Math.max(1, Math.min(10, Math.round(weight)))
return MIN_SPEED + t * (MAX_SPEED - MIN_SPEED) 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)', () => { 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,5 +1,10 @@
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,
@ -11,7 +16,7 @@ import {
type StoneState, type StoneState,
type Team, type Team,
} from './protocol' } from './protocol'
import { hogTrimStartIndex, sampleTime, trimPathToStartAtHogLine } from './game-helpers' import { hogTrimStartIndex, sampleTime } from './game-helpers'
export interface GameModelState { export interface GameModelState {
end: number end: number
@ -26,6 +31,24 @@ 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,
@ -45,8 +68,15 @@ export class GameModel {
isDragging = false isDragging = false
isPanning = 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 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
@ -61,12 +91,19 @@ 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 = {
@ -82,6 +119,9 @@ 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
@ -92,30 +132,36 @@ 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
} }
// Shared sample-index trim so multi-stone paths stay on one clock. this.pathClockOffset = 0
// Path entries are (x, y, theta); t = index / SAMPLE_RATE_HZ after trim.
let startIdx = 0
if (thrownId !== null) { if (thrownId !== null) {
const thrownPath = stones.find((p) => stoneIdsEqual(p.stone_id, thrownId!))?.trajectory ?? [] const thrownPath =
startIdx = hogTrimStartIndex(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) { for (const { stone_id, team, trajectory } of stones) {
const path = pathMap.set(stoneIdKey(stone_id), {
thrownId !== null && stoneIdsEqual(stone_id, thrownId) id: stone_id,
? trimPathToStartAtHogLine(trajectory) team,
: trajectory.slice(startIdx) path: trajectory,
pathMap.set(stoneIdKey(stone_id), { id: stone_id, team, path }) })
} }
this.activePaths = pathMap 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() 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[] {
@ -124,63 +170,99 @@ export class GameModel {
} }
const elapsed = (now - this.animationStartTime) / 1000 const elapsed = (now - this.animationStartTime) / 1000
const maxTotal = Math.max( const absT = this.pathClockOffset / SAMPLE_RATE_HZ + elapsed
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 (elapsed >= maxTotal) { if (absT >= maxAbsT) {
this.state.animating = false this.finishAnimation()
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.interpolatePath(path, elapsed) const pos = this.interpolatePathAtAbsTime(path, absT)
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 ?? team ?? this.state.turnTeam this.state.stones.find((s) => stoneIdsEqual(s.id, id))?.team ??
team ??
this.state.turnTeam
result.push({ ...pos, team: resolvedTeam }) result.push({ ...pos, team: resolvedTeam })
} }
return result 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][], path: [number, number, number][],
elapsed: number, absT: 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 (elapsed >= lastT) { if (absT <= 0) {
const last = path[path.length - 1] 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] } return { x: last[0], y: last[1], rotation: last[2] }
} }
// Find segment where sampleTime(i) <= elapsed < sampleTime(i+1) let i = Math.min(path.length - 2, Math.max(0, Math.floor(absT * SAMPLE_RATE_HZ)))
let i = 0 while (i + 1 < path.length && sampleTime(i + 1) < absT) i++
while (i + 1 < path.length && sampleTime(i + 1) < elapsed) i++ while (i > 0 && sampleTime(i) > absT) 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 ? (elapsed - t0) / dt : 0 const t = dt > 0 ? (absT - 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, createFrictionSlider } from './hud' import { createHud, createVelocitySelector, createCurlSelector } from './hud'
import { type Team } from './protocol' import { type Team } from './protocol'
import { GameModel } from './game-model' import { GameModel } from './game-model'
@ -19,11 +19,9 @@ 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')
@ -37,6 +35,14 @@ 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'
@ -51,27 +57,30 @@ 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)
} }
const maybeShowEndModal = () => { /** Queue end modal when scoreboard grows; only show after throw animation. */
const queueEndModalIfNeeded = () => {
const board = model.state.scoreboard const board = model.state.scoreboard
if (board.length <= lastScoreboardLen) { if (board.length <= lastScoreboardLen) return
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
hud.showEndModal({ deferredEndModal = {
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', () => {
@ -83,6 +92,7 @@ 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()
} }
@ -110,11 +120,15 @@ export function startGame(): void {
}, },
onGameState: (msg) => { onGameState: (msg) => {
model.updateGameState(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() 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) => {
@ -194,7 +208,6 @@ export function startGame(): void {
model.broom.y, model.broom.y,
velocity.getVelocity(), velocity.getVelocity(),
curls.getSelected(), curls.getSelected(),
friction.getFriction(),
) )
}) })

View File

@ -1,19 +1,24 @@
import { import {
MAX_SPEED, ENDS,
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 { velocityToWeight, weightToVelocity } from './game-helpers' import {
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: {
@ -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>` return `<div class="stones-row stones-row--${team}" data-team="${team}" role="img" aria-label="${TEAM_LABELS[team]} stones remaining"><span class="hammer-badge" title="Hammer" aria-hidden="true">🔨</span>${chips}</div>`
} }
function renderScoreboardTable(scoreboard: EndScore[]): string { function scoreboardTotals(scoreboard: readonly EndScore[]): [number, number] {
if (scoreboard.length === 0) { let t1 = 0
return '<p class="end-modal-empty">No ends scored yet</p>' let t2 = 0
for (const e of scoreboard) {
t1 += e.team1
t2 += e.team2
} }
const rows = scoreboard return [t1, t2]
.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 {
@ -102,24 +101,18 @@ 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>
@ -127,12 +120,11 @@ 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 scoreboardStrip = root.querySelector<HTMLDivElement>('#scoreboard-strip')! const scoreboardEl = root.querySelector<HTMLDivElement>('#scoreboard-baseball')!
const updateHammerBadge = (hammer: Team) => { const updateHammerBadge = (hammer: Team) => {
for (const team of ['team1', 'team2'] as const) { for (const team of ['team1', 'team2'] as const) {
@ -153,13 +145,8 @@ 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
@ -189,33 +176,33 @@ export function createHud(): Hud {
} }
} }
const updateScoreboardStrip = (scoreboard: EndScore[], totals: number[]) => { const updateScoreboard = (
if (scoreboard.length === 0) { scoreboard: EndScore[],
scoreboardStrip.textContent = '' totals: number[],
scoreboardStrip.hidden = true options?: { hammer?: Team; currentEnd?: number },
return ) => {
} const t1 = totals[0] ?? 0
scoreboardStrip.hidden = false const t2 = totals[1] ?? 0
const cells = scoreboard scoreboardEl.innerHTML = renderBaseballScoreboard(scoreboard, [t1, t2], {
.map((e) => `<span class="scoreboard-end" title="End ${e.end}">${e.team1}-${e.team2}</span>`) ends: ENDS,
.join('') hammer: options?.hammer,
scoreboardStrip.innerHTML = `${cells}<span class="scoreboard-total">Σ ${totals[0] ?? 0}-${totals[1] ?? 0}</span>` currentEnd: options?.currentEnd,
})
} }
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`
@ -225,7 +212,10 @@ 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)
updateScoreboardStrip(state.scoreboard, state.scores) updateScoreboard(state.scoreboard, state.scores, {
hammer: state.hammer,
currentEnd: state.end,
})
}, },
showEndModal: (payload) => { showEndModal: (payload) => {
dismissEndModal() dismissEndModal()
@ -234,6 +224,15 @@ 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">
@ -244,7 +243,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">${renderScoreboardTable(payload.scoreboard)}</div> <div class="end-modal-board">${boardHtml}</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>
` `
@ -254,7 +253,6 @@ 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')
@ -281,44 +279,63 @@ 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 = { weight: 5 } const state = { index: indexOfLabel(7) }
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 label = document.createElement('label') const title = document.createElement('label')
label.textContent = 'Velocity' title.textContent = 'Weight'
wrap.appendChild(label) wrap.appendChild(title)
const slider = document.createElement('input') const slider = document.createElement('input')
slider.type = 'range' slider.type = 'range'
slider.min = String(MIN_SPEED) slider.min = '0'
slider.max = String(MAX_SPEED) slider.max = String(14) // 1..10 + hack/board/control/normal/peel
slider.step = '0.05' slider.step = '1'
slider.value = String(weightToVelocity(state.weight)) slider.value = String(state.index)
slider.className = 'velocity-slider' slider.className = 'velocity-slider'
slider.setAttribute('list', 'weight-marks')
slider.ariaLabel = 'Throw weight'
const datalist = document.createElement('datalist') const marks = document.createElement('datalist')
datalist.id = 'velocity-marks' marks.id = 'weight-marks'
for (let w = 1; w <= 10; w++) { const tickLabels = [1, 5, 7, 10, 'hack', 'board', 'control', 'normal', 'peel'] as const
for (const tick of tickLabels) {
const opt = document.createElement('option') const opt = document.createElement('option')
opt.value = String(weightToVelocity(w)) opt.value = String(indexOfLabel(tick))
opt.label = String(w) opt.label = String(tick)
datalist.appendChild(opt) marks.appendChild(opt)
} }
slider.setAttribute('list', 'velocity-marks')
wrap.appendChild(slider) const ticks = document.createElement('div')
wrap.appendChild(datalist) 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') 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 = () => {
const v = Number(slider.value) state.index = clampSpeedIndex(Number(slider.value))
state.weight = velocityToWeight(v) const label = labelAtIndex(state.index)
readout.textContent = `${v.toFixed(2)} m/s · Weight ${state.weight}` 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() onSelect()
} }
slider.addEventListener('input', update) slider.addEventListener('input', update)
@ -327,7 +344,7 @@ export function createVelocitySelector(
container.appendChild(wrap) container.appendChild(wrap)
return { return {
getVelocity: () => Number(slider.value), getVelocity: () => velocityForLabel(labelAtIndex(clampSpeedIndex(Number(slider.value)))),
setEnabled: (enabled) => { setEnabled: (enabled) => {
slider.disabled = !enabled slider.disabled = !enabled
}, },
@ -338,7 +355,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 } {
// 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. // 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 = [
@ -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, 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(
@ -89,7 +88,6 @@ export function sendThrow(
broom_y: broomY, broom_y: broomY,
velocity, velocity,
curl, 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 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
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 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. */
export const MIN_SPEED = 1.9 /** Feet relative to tee for weights 1..10 (front is negative). */
/** Heavy (weight 10). Span keeps weight 5 ≈ DRAW_VELOCITY. */ export const WEIGHT_STOP_OFFSET_FT = [-11, -9, -7, -5, -3, -1, 0, 1, 3, 5] as const
export const MAX_SPEED = 3.0 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 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'
@ -57,7 +99,6 @@ 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

@ -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); border-color: rgba(255, 255, 255, 0.25);
} }
.scoreboard-strip { /* —— Baseball-style scoreboard matrix —— */
.scoreboard-baseball-wrap {
display: flex; display: flex;
flex-wrap: wrap;
justify-content: center; justify-content: center;
gap: 4px; width: 100%;
font-size: 11px; 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; font-weight: 600;
opacity: 0.9; line-height: 1.25;
} letter-spacing: 0.02em;
color: #fff;
.scoreboard-strip[hidden] { background: rgba(0, 0, 0, 0.55);
display: none; border: 1px solid rgba(255, 255, 255, 0.28);
}
.scoreboard-end {
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px; 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 { .scoreboard-baseball th,
background: rgba(0, 170, 102, 0.25); .scoreboard-baseball td {
border: 1px solid rgba(0, 170, 102, 0.45); min-width: 1.35em;
border-radius: 8px; padding: 3px 4px;
padding: 2px 6px; 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 —— */ /* —— End-of-end modal —— */
@ -430,32 +520,9 @@ 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;
@ -526,23 +593,32 @@ 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;
}