From 99a4f59a396b7d990249ceae717bd9546d02baf3 Mon Sep 17 00:00:00 2001 From: Jason Dekarske Date: Sat, 11 Jul 2026 13:17:41 -0700 Subject: [PATCH] actual fix --- frontend/src/game-helpers.test.ts | 41 ++++--- frontend/src/game-helpers.ts | 56 +++++++-- frontend/src/game-model.test.ts | 125 ++++++++++++++++++++ frontend/src/game-model.ts | 154 ++++++++++++++++++------ frontend/src/game.ts | 39 ++++-- frontend/src/hud.ts | 175 ++++++++++++++------------- frontend/src/net.ts | 2 - frontend/src/protocol.ts | 53 ++++++++- frontend/src/scoreboard.test.ts | 76 ++++++++++++ frontend/src/scoreboard.ts | 116 ++++++++++++++++++ frontend/src/style.css | 190 +++++++++++++++++++++--------- 11 files changed, 797 insertions(+), 230 deletions(-) create mode 100644 frontend/src/scoreboard.test.ts create mode 100644 frontend/src/scoreboard.ts diff --git a/frontend/src/game-helpers.test.ts b/frontend/src/game-helpers.test.ts index 335c2a9..3f7f8cf 100644 --- a/frontend/src/game-helpers.test.ts +++ b/frontend/src/game-helpers.test.ts @@ -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) }) }) diff --git a/frontend/src/game-helpers.ts b/frontend/src/game-helpers.ts index 1137e10..4ea77d7 100644 --- a/frontend/src/game-helpers.ts +++ b/frontend/src/game-helpers.ts @@ -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 1–10 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 1–10 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 } diff --git a/frontend/src/game-model.test.ts b/frontend/src/game-model.test.ts index 95ca8dd..50d7c6f 100644 --- a/frontend/src/game-model.test.ts +++ b/frontend/src/game-model.test.ts @@ -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) + }) }) diff --git a/frontend/src/game-model.ts b/frontend/src/game-model.ts index 56cb9b5..b31f300 100644 --- a/frontend/src/game-model.ts +++ b/frontend/src/game-model.ts @@ -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() + 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() + 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 diff --git a/frontend/src/game.ts b/frontend/src/game.ts index ed3044f..1eeed2c 100644 --- a/frontend/src/game.ts +++ b/frontend/src/game.ts @@ -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(), ) }) diff --git a/frontend/src/hud.ts b/frontend/src/hud.ts index 45af5cc..0518c37 100644 --- a/frontend/src/hud.ts +++ b/frontend/src/hud.ts @@ -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 `` } -function renderScoreboardTable(scoreboard: EndScore[]): string { - if (scoreboard.length === 0) { - return '

No ends scored yet

' +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) => - `${e.end}${e.team1}${e.team2}${TEAM_LABELS[e.hammer]}`, - ) - .join('') - return ` - - ${rows} -
EndTeam 1Team 2Hammer
` + return [t1, t2] } export function createHud(): Hud { @@ -102,24 +101,18 @@ export function createHud(): Hud { ${buildStoneChipsHtml('team1')} ${buildStoneChipsHtml('team2')} +
-
Team 1 0 - Team 2 0
End 1 · Waiting
-
-
- - - 1.0 -
@@ -127,12 +120,11 @@ export function createHud(): Hud {
Waiting
` - const scoreEl = root.querySelector('#score')! const endInfoEl = root.querySelector('#end-info')! const teamSelect = root.querySelector('#team-select')! const waitingEl = root.querySelector('#waiting')! const stonesHud = root.querySelector('#stones-hud')! - const scoreboardStrip = root.querySelector('#scoreboard-strip')! + const scoreboardEl = root.querySelector('#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) => `${e.team1}-${e.team2}`) - .join('') - scoreboardStrip.innerHTML = `${cells}Σ ${totals[0] ?? 0}-${totals[1] ?? 0}` + 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('#velocity-control')!, curlSelector: root.querySelector('#curl-selector')!, - frictionControl: root.querySelector('#friction-control')!, throwButton: root.querySelector('#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 = `
@@ -244,7 +243,7 @@ export function createHud(): Hud { Team 2 ${payload.team2}

Next hammer: ${TEAM_LABELS[payload.nextHammer]}

-
${renderScoreboardTable(payload.scoreboard)}
+
${boardHtml}
` @@ -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) => `${name}`) + .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('.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('#friction-slider')! - const valueEl = container.querySelector('#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 - }, - } -} diff --git a/frontend/src/net.ts b/frontend/src/net.ts index 5b55924..d352733 100644 --- a/frontend/src/net.ts +++ b/frontend/src/net.ts @@ -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, }), ) } diff --git a/frontend/src/protocol.ts b/frontend/src/protocol.ts index 91aefb6..02e183f 100644 --- a/frontend/src/protocol.ts +++ b/frontend/src/protocol.ts @@ -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 = { + 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 { diff --git a/frontend/src/scoreboard.test.ts b/frontend/src/scoreboard.test.ts new file mode 100644 index 0000000..3293262 --- /dev/null +++ b/frontend/src/scoreboard.test.ts @@ -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">21${end}<`) + } + expect(html).toContain('>2') // team1 end 1 + expect(html).toContain('>0') + expect(html).toContain('>1') // 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 = { + 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 { + const map = new Map() + 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 `${end}` + }).join('') + + const teamRows = TEAMS.map((team, teamIndex) => { + const hasHammer = hammer === team + const hammerMark = hasHammer + ? 'H' + : '' + 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 `${value}` + }).join('') + + const total = totals[teamIndex] ?? 0 + return ` + ${TEAM_LABELS[team]}${hammerMark} + ${endCells} + ${total} + ` + }).join('') + + return ` + + + + ${headerEnds} + + + + + ${teamRows} + +
R
` +} diff --git a/frontend/src/style.css b/frontend/src/style.css index aac68b7..af26bea 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -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; +}