actual fix
This commit is contained in:
parent
5eefcd23bf
commit
99a4f59a39
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
import {
|
||||
HOUSE_CENTER,
|
||||
SAMPLE_RATE_HZ,
|
||||
STONE_RADIUS,
|
||||
BACK_LINE_Y,
|
||||
HOG_LINE_Y,
|
||||
SHEET_WIDTH,
|
||||
stoneIdKey,
|
||||
stoneIdsEqual,
|
||||
type DrawableStone,
|
||||
@ -11,7 +16,7 @@ import {
|
||||
type StoneState,
|
||||
type Team,
|
||||
} from './protocol'
|
||||
import { hogTrimStartIndex, sampleTime, trimPathToStartAtHogLine } from './game-helpers'
|
||||
import { hogTrimStartIndex, sampleTime } from './game-helpers'
|
||||
|
||||
export interface GameModelState {
|
||||
end: number
|
||||
@ -26,6 +31,24 @@ export interface GameModelState {
|
||||
animating: boolean
|
||||
}
|
||||
|
||||
/** Side / back edge-touch (same as backend edge_out_of_bounds) for mid-flight hide. */
|
||||
function samplePastSideOrBack(x: number, y: number): boolean {
|
||||
const half = SHEET_WIDTH / 2
|
||||
return Math.abs(x) + STONE_RADIUS >= half || y + STONE_RADIUS >= BACK_LINE_Y
|
||||
}
|
||||
|
||||
/** Final rest OOB: sides/back or never cleared the hog. */
|
||||
function pathEndedOutOfPlay(path: [number, number, number][]): boolean {
|
||||
if (path.length === 0) return true
|
||||
const [x, y] = path[path.length - 1]!
|
||||
const half = SHEET_WIDTH / 2
|
||||
return (
|
||||
Math.abs(x) + STONE_RADIUS >= half ||
|
||||
y + STONE_RADIUS >= BACK_LINE_Y ||
|
||||
y - STONE_RADIUS <= HOG_LINE_Y
|
||||
)
|
||||
}
|
||||
|
||||
export class GameModel {
|
||||
state: GameModelState = {
|
||||
end: 1,
|
||||
@ -45,8 +68,15 @@ export class GameModel {
|
||||
isDragging = false
|
||||
isPanning = false
|
||||
|
||||
private activePaths = new Map<string, { id: StoneId; team: Team; path: [number, number, number][] }>()
|
||||
private activePaths = new Map<
|
||||
string,
|
||||
{ id: StoneId; team: Team; path: [number, number, number][] }
|
||||
>()
|
||||
private animationStartTime = 0
|
||||
/** Absolute sample index when the thrown stone reaches the hog (shared clock). */
|
||||
private pathClockOffset = 0
|
||||
/** True once a game_state snapshot arrived for the in-flight anim. */
|
||||
private hasServerSnapshot = false
|
||||
|
||||
setMyTeam(team: Team): void {
|
||||
this.state.myTeam = team
|
||||
@ -61,12 +91,19 @@ export class GameModel {
|
||||
if (reset) {
|
||||
this.state.stones = []
|
||||
this.pendingStones = []
|
||||
this.hasServerSnapshot = false
|
||||
this.activePaths.clear()
|
||||
this.state.animating = false
|
||||
}
|
||||
|
||||
if (this.state.animating) {
|
||||
// Authoritative post-throw board — apply even when empty (all stones removed).
|
||||
this.pendingStones = msg.stones
|
||||
this.hasServerSnapshot = true
|
||||
} else {
|
||||
this.state.stones = msg.stones
|
||||
this.pendingStones = []
|
||||
this.hasServerSnapshot = false
|
||||
}
|
||||
|
||||
this.state = {
|
||||
@ -82,6 +119,9 @@ export class GameModel {
|
||||
}
|
||||
|
||||
startTrajectory(stones: StonePath[]): void {
|
||||
// Commit any leftover server board before basing "existing" on it.
|
||||
this.commitServerStones()
|
||||
|
||||
const existingKeys = new Set(this.state.stones.map((s) => stoneIdKey(s.id)))
|
||||
|
||||
let thrownId: StoneId | null = null
|
||||
@ -92,30 +132,36 @@ export class GameModel {
|
||||
}
|
||||
}
|
||||
if (thrownId === null && stones.length > 0) {
|
||||
thrownId = stones[0].stone_id
|
||||
thrownId = stones[0]!.stone_id
|
||||
}
|
||||
|
||||
// Shared sample-index trim so multi-stone paths stay on one clock.
|
||||
// Path entries are (x, y, theta); t = index / SAMPLE_RATE_HZ after trim.
|
||||
let startIdx = 0
|
||||
this.pathClockOffset = 0
|
||||
if (thrownId !== null) {
|
||||
const thrownPath = stones.find((p) => stoneIdsEqual(p.stone_id, thrownId!))?.trajectory ?? []
|
||||
startIdx = hogTrimStartIndex(thrownPath)
|
||||
const thrownPath =
|
||||
stones.find((p) => stoneIdsEqual(p.stone_id, thrownId!))?.trajectory ?? []
|
||||
this.pathClockOffset = hogTrimStartIndex(thrownPath)
|
||||
}
|
||||
|
||||
const pathMap = new Map<string, { id: StoneId; team: Team; path: [number, number, number][] }>()
|
||||
const pathMap = new Map<
|
||||
string,
|
||||
{ id: StoneId; team: Team; path: [number, number, number][] }
|
||||
>()
|
||||
for (const { stone_id, team, trajectory } of stones) {
|
||||
const path =
|
||||
thrownId !== null && stoneIdsEqual(stone_id, thrownId)
|
||||
? trimPathToStartAtHogLine(trajectory)
|
||||
: trajectory.slice(startIdx)
|
||||
pathMap.set(stoneIdKey(stone_id), { id: stone_id, team, path })
|
||||
pathMap.set(stoneIdKey(stone_id), {
|
||||
id: stone_id,
|
||||
team,
|
||||
path: trajectory,
|
||||
})
|
||||
}
|
||||
|
||||
this.activePaths = pathMap
|
||||
this.state.animating = Array.from(pathMap.values()).some((p) => p.path.length > 1)
|
||||
this.state.animating = Array.from(pathMap.values()).some(
|
||||
(p) => p.path.length > this.pathClockOffset + 1,
|
||||
)
|
||||
this.animationStartTime = performance.now()
|
||||
// Fresh throw: wait for the matching game_state.
|
||||
this.pendingStones = []
|
||||
this.hasServerSnapshot = false
|
||||
}
|
||||
|
||||
tick(now: number): DrawableStone[] {
|
||||
@ -124,63 +170,99 @@ export class GameModel {
|
||||
}
|
||||
|
||||
const elapsed = (now - this.animationStartTime) / 1000
|
||||
const maxTotal = Math.max(
|
||||
const absT = this.pathClockOffset / SAMPLE_RATE_HZ + elapsed
|
||||
const maxAbsT = Math.max(
|
||||
0,
|
||||
...Array.from(this.activePaths.values()).map((p) =>
|
||||
p.path.length > 0 ? sampleTime(p.path.length - 1) : 0,
|
||||
),
|
||||
)
|
||||
|
||||
if (elapsed >= maxTotal) {
|
||||
this.state.animating = false
|
||||
if (this.pendingStones.length > 0) {
|
||||
this.state.stones = this.pendingStones
|
||||
this.pendingStones = []
|
||||
}
|
||||
if (absT >= maxAbsT) {
|
||||
this.finishAnimation()
|
||||
return []
|
||||
}
|
||||
|
||||
const result: DrawableStone[] = []
|
||||
for (const { id, team, path } of this.activePaths.values()) {
|
||||
const pos = this.interpolatePath(path, elapsed)
|
||||
const pos = this.interpolatePathAtAbsTime(path, absT)
|
||||
if (!pos) continue
|
||||
if (samplePastSideOrBack(pos.x, pos.y)) continue
|
||||
const resolvedTeam =
|
||||
this.state.stones.find((s) => stoneIdsEqual(s.id, id))?.team ?? team ?? this.state.turnTeam
|
||||
this.state.stones.find((s) => stoneIdsEqual(s.id, id))?.team ??
|
||||
team ??
|
||||
this.state.turnTeam
|
||||
result.push({ ...pos, team: resolvedTeam })
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private interpolatePath(
|
||||
private commitServerStones(): void {
|
||||
if (!this.hasServerSnapshot) return
|
||||
this.state.stones = this.pendingStones
|
||||
this.pendingStones = []
|
||||
this.hasServerSnapshot = false
|
||||
}
|
||||
|
||||
private finishAnimation(): void {
|
||||
this.state.animating = false
|
||||
if (this.hasServerSnapshot) {
|
||||
// Apply even when pending is [] (full clear).
|
||||
this.state.stones = this.pendingStones
|
||||
this.pendingStones = []
|
||||
this.hasServerSnapshot = false
|
||||
} else {
|
||||
const settled: StoneState[] = []
|
||||
for (const { id, team, path } of this.activePaths.values()) {
|
||||
if (path.length === 0 || pathEndedOutOfPlay(path)) continue
|
||||
const last = path[path.length - 1]!
|
||||
settled.push({
|
||||
id,
|
||||
team,
|
||||
x: last[0],
|
||||
y: last[1],
|
||||
rotation: last[2],
|
||||
})
|
||||
}
|
||||
this.state.stones = settled
|
||||
}
|
||||
this.activePaths.clear()
|
||||
this.pathClockOffset = 0
|
||||
}
|
||||
|
||||
private interpolatePathAtAbsTime(
|
||||
path: [number, number, number][],
|
||||
elapsed: number,
|
||||
absT: number,
|
||||
): { x: number; y: number; rotation: number } | null {
|
||||
if (path.length === 0) return null
|
||||
if (path.length === 1) {
|
||||
const [x, y, theta] = path[0]
|
||||
const [x, y, theta] = path[0]!
|
||||
return { x, y, rotation: theta }
|
||||
}
|
||||
|
||||
const lastT = sampleTime(path.length - 1)
|
||||
if (elapsed >= lastT) {
|
||||
const last = path[path.length - 1]
|
||||
if (absT <= 0) {
|
||||
const [x, y, theta] = path[0]!
|
||||
return { x, y, rotation: theta }
|
||||
}
|
||||
if (absT >= lastT) {
|
||||
const last = path[path.length - 1]!
|
||||
return { x: last[0], y: last[1], rotation: last[2] }
|
||||
}
|
||||
|
||||
// Find segment where sampleTime(i) <= elapsed < sampleTime(i+1)
|
||||
let i = 0
|
||||
while (i + 1 < path.length && sampleTime(i + 1) < elapsed) i++
|
||||
const p0 = path[i]
|
||||
let i = Math.min(path.length - 2, Math.max(0, Math.floor(absT * SAMPLE_RATE_HZ)))
|
||||
while (i + 1 < path.length && sampleTime(i + 1) < absT) i++
|
||||
while (i > 0 && sampleTime(i) > absT) i--
|
||||
|
||||
const p0 = path[i]!
|
||||
const p1 = path[i + 1] ?? p0
|
||||
const t0 = sampleTime(i)
|
||||
const t1 = sampleTime(i + 1)
|
||||
const dt = t1 - t0
|
||||
const t = dt > 0 ? (elapsed - t0) / dt : 0
|
||||
const t = dt > 0 ? (absT - t0) / dt : 0
|
||||
const x = p0[0] + (p1[0] - p0[0]) * t
|
||||
const y = p0[1] + (p1[1] - p0[1]) * t
|
||||
// Interpolate body rotation (theta) from path samples
|
||||
let dTheta = p1[2] - p0[2]
|
||||
// Unwrap shortest path across ±π
|
||||
if (dTheta > Math.PI) dTheta -= 2 * Math.PI
|
||||
if (dTheta < -Math.PI) dTheta += 2 * Math.PI
|
||||
const rotation = p0[2] + dTheta * t
|
||||
|
||||
@ -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(),
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@ -1,19 +1,24 @@
|
||||
import {
|
||||
MAX_SPEED,
|
||||
MIN_SPEED,
|
||||
ENDS,
|
||||
STONES_PER_TEAM,
|
||||
type EndScore,
|
||||
type Phase,
|
||||
type Team,
|
||||
} from './protocol'
|
||||
import { velocityToWeight, weightToVelocity } from './game-helpers'
|
||||
import {
|
||||
clampSpeedIndex,
|
||||
formatSpeedLabel,
|
||||
indexOfLabel,
|
||||
labelAtIndex,
|
||||
velocityForLabel,
|
||||
} from './game-helpers'
|
||||
import { renderBaseballScoreboard } from './scoreboard'
|
||||
|
||||
export interface Hud {
|
||||
root: HTMLDivElement
|
||||
teamSelect: HTMLSelectElement
|
||||
velocityControl: HTMLDivElement
|
||||
curlSelector: HTMLDivElement
|
||||
frictionControl: HTMLDivElement
|
||||
throwButton: HTMLButtonElement
|
||||
setTeam: (team: Team) => void
|
||||
update: (state: {
|
||||
@ -74,20 +79,14 @@ function buildStoneChipsHtml(team: Team): string {
|
||||
return `<div class="stones-row stones-row--${team}" data-team="${team}" role="img" aria-label="${TEAM_LABELS[team]} stones remaining"><span class="hammer-badge" title="Hammer" aria-hidden="true">🔨</span>${chips}</div>`
|
||||
}
|
||||
|
||||
function renderScoreboardTable(scoreboard: EndScore[]): string {
|
||||
if (scoreboard.length === 0) {
|
||||
return '<p class="end-modal-empty">No ends scored yet</p>'
|
||||
function scoreboardTotals(scoreboard: readonly EndScore[]): [number, number] {
|
||||
let t1 = 0
|
||||
let t2 = 0
|
||||
for (const e of scoreboard) {
|
||||
t1 += e.team1
|
||||
t2 += e.team2
|
||||
}
|
||||
const rows = scoreboard
|
||||
.map(
|
||||
(e) =>
|
||||
`<tr><td>${e.end}</td><td>${e.team1}</td><td>${e.team2}</td><td>${TEAM_LABELS[e.hammer]}</td></tr>`,
|
||||
)
|
||||
.join('')
|
||||
return `<table class="scoreboard-table" aria-label="Scoreboard">
|
||||
<thead><tr><th>End</th><th>Team 1</th><th>Team 2</th><th>Hammer</th></tr></thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>`
|
||||
return [t1, t2]
|
||||
}
|
||||
|
||||
export function createHud(): Hud {
|
||||
@ -102,24 +101,18 @@ export function createHud(): Hud {
|
||||
${buildStoneChipsHtml('team1')}
|
||||
${buildStoneChipsHtml('team2')}
|
||||
</div>
|
||||
<div id="scoreboard-baseball" class="scoreboard-baseball-wrap" aria-live="polite"></div>
|
||||
<div class="hud-row">
|
||||
<div id="score">Team 1 0 - Team 2 0</div>
|
||||
<div id="end-info">End 1 · Waiting</div>
|
||||
<select id="team-select" aria-label="Team">
|
||||
<option value="team1">Team 1</option>
|
||||
<option value="team2">Team 2</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="scoreboard-strip" class="scoreboard-strip" aria-label="End scores"></div>
|
||||
</div>
|
||||
<div class="hud-row" style="align-items:flex-end;">
|
||||
<div id="velocity-control"></div>
|
||||
<div id="curl-selector"></div>
|
||||
<div id="friction-control">
|
||||
<label for="friction-slider">Friction</label>
|
||||
<input id="friction-slider" type="range" min="0.5" max="1.5" step="0.1" value="1.0" />
|
||||
<span id="friction-value">1.0</span>
|
||||
</div>
|
||||
<div>
|
||||
<button id="throw-btn" type="button" disabled>THROW</button>
|
||||
</div>
|
||||
@ -127,12 +120,11 @@ export function createHud(): Hud {
|
||||
<div id="waiting">Waiting</div>
|
||||
`
|
||||
|
||||
const scoreEl = root.querySelector<HTMLDivElement>('#score')!
|
||||
const endInfoEl = root.querySelector<HTMLDivElement>('#end-info')!
|
||||
const teamSelect = root.querySelector<HTMLSelectElement>('#team-select')!
|
||||
const waitingEl = root.querySelector<HTMLDivElement>('#waiting')!
|
||||
const stonesHud = root.querySelector<HTMLDivElement>('#stones-hud')!
|
||||
const scoreboardStrip = root.querySelector<HTMLDivElement>('#scoreboard-strip')!
|
||||
const scoreboardEl = root.querySelector<HTMLDivElement>('#scoreboard-baseball')!
|
||||
|
||||
const updateHammerBadge = (hammer: Team) => {
|
||||
for (const team of ['team1', 'team2'] as const) {
|
||||
@ -153,13 +145,8 @@ export function createHud(): Hud {
|
||||
}
|
||||
|
||||
let endModalEl: HTMLDivElement | null = null
|
||||
let endModalTimer = 0
|
||||
|
||||
const dismissEndModal = () => {
|
||||
if (endModalTimer) {
|
||||
window.clearTimeout(endModalTimer)
|
||||
endModalTimer = 0
|
||||
}
|
||||
if (endModalEl) {
|
||||
endModalEl.remove()
|
||||
endModalEl = null
|
||||
@ -189,33 +176,33 @@ export function createHud(): Hud {
|
||||
}
|
||||
}
|
||||
|
||||
const updateScoreboardStrip = (scoreboard: EndScore[], totals: number[]) => {
|
||||
if (scoreboard.length === 0) {
|
||||
scoreboardStrip.textContent = ''
|
||||
scoreboardStrip.hidden = true
|
||||
return
|
||||
}
|
||||
scoreboardStrip.hidden = false
|
||||
const cells = scoreboard
|
||||
.map((e) => `<span class="scoreboard-end" title="End ${e.end}">${e.team1}-${e.team2}</span>`)
|
||||
.join('')
|
||||
scoreboardStrip.innerHTML = `${cells}<span class="scoreboard-total">Σ ${totals[0] ?? 0}-${totals[1] ?? 0}</span>`
|
||||
const updateScoreboard = (
|
||||
scoreboard: EndScore[],
|
||||
totals: number[],
|
||||
options?: { hammer?: Team; currentEnd?: number },
|
||||
) => {
|
||||
const t1 = totals[0] ?? 0
|
||||
const t2 = totals[1] ?? 0
|
||||
scoreboardEl.innerHTML = renderBaseballScoreboard(scoreboard, [t1, t2], {
|
||||
ends: ENDS,
|
||||
hammer: options?.hammer,
|
||||
currentEnd: options?.currentEnd,
|
||||
})
|
||||
}
|
||||
|
||||
updateStonesRemaining([STONES_PER_TEAM, STONES_PER_TEAM])
|
||||
updateScoreboard([], [0, 0], { currentEnd: 1 })
|
||||
|
||||
return {
|
||||
root,
|
||||
teamSelect,
|
||||
velocityControl: root.querySelector<HTMLDivElement>('#velocity-control')!,
|
||||
curlSelector: root.querySelector<HTMLDivElement>('#curl-selector')!,
|
||||
frictionControl: root.querySelector<HTMLDivElement>('#friction-control')!,
|
||||
throwButton: root.querySelector<HTMLButtonElement>('#throw-btn')!,
|
||||
setTeam: (team) => {
|
||||
teamSelect.value = team
|
||||
},
|
||||
update: (state) => {
|
||||
scoreEl.textContent = `Team 1 ${state.scores[0] ?? 0} - Team 2 ${state.scores[1] ?? 0}`
|
||||
const phaseText =
|
||||
state.phase === 'playing'
|
||||
? `${TEAM_LABELS[state.turnTeam]}'s turn`
|
||||
@ -225,7 +212,10 @@ export function createHud(): Hud {
|
||||
// Hammer class first so stones-remaining aria can mention it.
|
||||
updateHammerBadge(state.hammer)
|
||||
updateStonesRemaining(state.stonesRemaining)
|
||||
updateScoreboardStrip(state.scoreboard, state.scores)
|
||||
updateScoreboard(state.scoreboard, state.scores, {
|
||||
hammer: state.hammer,
|
||||
currentEnd: state.end,
|
||||
})
|
||||
},
|
||||
showEndModal: (payload) => {
|
||||
dismissEndModal()
|
||||
@ -234,6 +224,15 @@ export function createHud(): Hud {
|
||||
modal.setAttribute('role', 'dialog')
|
||||
modal.setAttribute('aria-modal', 'true')
|
||||
modal.setAttribute('aria-labelledby', 'end-modal-title')
|
||||
const boardHtml = renderBaseballScoreboard(
|
||||
payload.scoreboard,
|
||||
scoreboardTotals(payload.scoreboard),
|
||||
{
|
||||
ends: ENDS,
|
||||
hammer: payload.nextHammer,
|
||||
currentEnd: payload.end,
|
||||
},
|
||||
)
|
||||
modal.innerHTML = `
|
||||
<div class="end-modal-backdrop" data-dismiss="1"></div>
|
||||
<div class="end-modal-card">
|
||||
@ -244,7 +243,7 @@ export function createHud(): Hud {
|
||||
<span class="end-modal-team end-modal-team--team2">Team 2 <strong>${payload.team2}</strong></span>
|
||||
</p>
|
||||
<p class="end-modal-hammer">Next hammer: <strong>${TEAM_LABELS[payload.nextHammer]}</strong></p>
|
||||
<div class="end-modal-board">${renderScoreboardTable(payload.scoreboard)}</div>
|
||||
<div class="end-modal-board">${boardHtml}</div>
|
||||
<button type="button" class="end-modal-dismiss" data-dismiss="1">Dismiss</button>
|
||||
</div>
|
||||
`
|
||||
@ -254,7 +253,6 @@ export function createHud(): Hud {
|
||||
})
|
||||
document.body.appendChild(modal)
|
||||
endModalEl = modal
|
||||
endModalTimer = window.setTimeout(dismissEndModal, 5000)
|
||||
},
|
||||
showToast: (message: string) => {
|
||||
const toast = document.createElement('div')
|
||||
@ -281,44 +279,63 @@ export function createVelocitySelector(
|
||||
container: HTMLDivElement,
|
||||
onSelect: () => void,
|
||||
): { getVelocity: () => number; setEnabled: (enabled: boolean) => void } {
|
||||
const state = { weight: 5 }
|
||||
const state = { index: indexOfLabel(7) }
|
||||
container.innerHTML = ''
|
||||
|
||||
const wrap = document.createElement('div')
|
||||
wrap.className = 'velocity-control-inner'
|
||||
|
||||
const label = document.createElement('label')
|
||||
label.textContent = 'Velocity'
|
||||
wrap.appendChild(label)
|
||||
const title = document.createElement('label')
|
||||
title.textContent = 'Weight'
|
||||
wrap.appendChild(title)
|
||||
|
||||
const slider = document.createElement('input')
|
||||
slider.type = 'range'
|
||||
slider.min = String(MIN_SPEED)
|
||||
slider.max = String(MAX_SPEED)
|
||||
slider.step = '0.05'
|
||||
slider.value = String(weightToVelocity(state.weight))
|
||||
slider.min = '0'
|
||||
slider.max = String(14) // 1..10 + hack/board/control/normal/peel
|
||||
slider.step = '1'
|
||||
slider.value = String(state.index)
|
||||
slider.className = 'velocity-slider'
|
||||
slider.setAttribute('list', 'weight-marks')
|
||||
slider.ariaLabel = 'Throw weight'
|
||||
|
||||
const datalist = document.createElement('datalist')
|
||||
datalist.id = 'velocity-marks'
|
||||
for (let w = 1; w <= 10; w++) {
|
||||
const marks = document.createElement('datalist')
|
||||
marks.id = 'weight-marks'
|
||||
const tickLabels = [1, 5, 7, 10, 'hack', 'board', 'control', 'normal', 'peel'] as const
|
||||
for (const tick of tickLabels) {
|
||||
const opt = document.createElement('option')
|
||||
opt.value = String(weightToVelocity(w))
|
||||
opt.label = String(w)
|
||||
datalist.appendChild(opt)
|
||||
opt.value = String(indexOfLabel(tick))
|
||||
opt.label = String(tick)
|
||||
marks.appendChild(opt)
|
||||
}
|
||||
slider.setAttribute('list', 'velocity-marks')
|
||||
wrap.appendChild(slider)
|
||||
wrap.appendChild(datalist)
|
||||
|
||||
const ticks = document.createElement('div')
|
||||
ticks.className = 'velocity-ticks'
|
||||
ticks.setAttribute('aria-hidden', 'true')
|
||||
const named = ['hack', 'board', 'control', 'normal', 'peel'] as const
|
||||
ticks.innerHTML = named
|
||||
.map((name) => `<span class="velocity-tick" data-label="${name}">${name}</span>`)
|
||||
.join('')
|
||||
|
||||
const readout = document.createElement('div')
|
||||
readout.className = 'velocity-readout'
|
||||
wrap.appendChild(slider)
|
||||
wrap.appendChild(marks)
|
||||
wrap.appendChild(ticks)
|
||||
wrap.appendChild(readout)
|
||||
|
||||
const update = () => {
|
||||
const v = Number(slider.value)
|
||||
state.weight = velocityToWeight(v)
|
||||
readout.textContent = `${v.toFixed(2)} m/s · Weight ${state.weight}`
|
||||
state.index = clampSpeedIndex(Number(slider.value))
|
||||
const label = labelAtIndex(state.index)
|
||||
const v = velocityForLabel(label)
|
||||
const name = formatSpeedLabel(label)
|
||||
const isTakeout = typeof label === 'string'
|
||||
readout.textContent = isTakeout
|
||||
? `${name} · ${v.toFixed(3)} m/s`
|
||||
: `${name} · ${v.toFixed(3)} m/s`
|
||||
ticks.querySelectorAll<HTMLSpanElement>('.velocity-tick').forEach((el) => {
|
||||
el.classList.toggle('velocity-tick--active', el.dataset.label === String(label))
|
||||
})
|
||||
onSelect()
|
||||
}
|
||||
slider.addEventListener('input', update)
|
||||
@ -327,7 +344,7 @@ export function createVelocitySelector(
|
||||
container.appendChild(wrap)
|
||||
|
||||
return {
|
||||
getVelocity: () => Number(slider.value),
|
||||
getVelocity: () => velocityForLabel(labelAtIndex(clampSpeedIndex(Number(slider.value)))),
|
||||
setEnabled: (enabled) => {
|
||||
slider.disabled = !enabled
|
||||
},
|
||||
@ -338,7 +355,7 @@ export function createCurlSelector(
|
||||
container: HTMLDivElement,
|
||||
onSelect: (curl: number) => void,
|
||||
): { getSelected: () => number; setEnabled: (enabled: boolean) => void } {
|
||||
// Only full curl: backend curl>0 = clockwise (right), curl<0 = counter-clockwise (left).
|
||||
// curl>0 = clockwise (drifts left when heading down-sheet); curl<0 = CCW (right).
|
||||
// Layout L→R: CCW on the left, CW on the right. Default clockwise.
|
||||
const state = { selected: 1, enabled: true }
|
||||
const options = [
|
||||
@ -379,23 +396,3 @@ export function createCurlSelector(
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createFrictionSlider(
|
||||
container: HTMLDivElement,
|
||||
): { getFriction: () => number; setEnabled: (enabled: boolean) => void } {
|
||||
const slider = container.querySelector<HTMLInputElement>('#friction-slider')!
|
||||
const valueEl = container.querySelector<HTMLSpanElement>('#friction-value')!
|
||||
|
||||
const updateValue = () => {
|
||||
valueEl.textContent = Number(slider.value).toFixed(1)
|
||||
}
|
||||
slider.addEventListener('input', updateValue)
|
||||
updateValue()
|
||||
|
||||
return {
|
||||
getFriction: () => Number(slider.value),
|
||||
setEnabled: (enabled) => {
|
||||
slider.disabled = !enabled
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@ -12,13 +12,55 @@ export const FOUR_FT_RADIUS = 2 * FEET_TO_METERS
|
||||
export const EIGHT_FT_RADIUS = 4 * FEET_TO_METERS
|
||||
export const TWELVE_FT_RADIUS = 6 * FEET_TO_METERS
|
||||
export const HOG_LINE_Y = 21.0
|
||||
export const BACK_LINE_Y = 42.0
|
||||
/** Backline on outer house ring (tee + 6 ft). */
|
||||
export const BACK_LINE_Y = HOUSE_CENTER.y + HOUSE_RADIUS
|
||||
export const HACK_Y = 2.0
|
||||
export const STONE_RADIUS = 0.15
|
||||
/** Soft guard (weight 1). Mid slider (weight 5) ≈ DRAW 2.38 m/s lands near tee. */
|
||||
export const MIN_SPEED = 1.9
|
||||
/** Heavy (weight 10). Span keeps weight 5 ≈ DRAW_VELOCITY. */
|
||||
export const MAX_SPEED = 3.0
|
||||
|
||||
/** Feet relative to tee for weights 1..10 (front is negative). */
|
||||
export const WEIGHT_STOP_OFFSET_FT = [-11, -9, -7, -5, -3, -1, 0, 1, 3, 5] as const
|
||||
export const HACK_STOP_OFFSET_FT = 12
|
||||
|
||||
/** Calibrated m/s for weights 1..10 (open-ice stop vs tee offsets). */
|
||||
export const WEIGHT_SPEEDS = [
|
||||
2.2573, 2.2783, 2.2992, 2.3199, 2.3404, 2.3608, 2.3709, 2.3810, 2.4012, 2.4211,
|
||||
] as const
|
||||
export const HACK_SPEED = 2.4899
|
||||
/** Takeouts: open-ice stop at hack+N feet (N = 6,12,18,24). */
|
||||
export const BOARD_SPEED = 2.5492
|
||||
export const CONTROL_SPEED = 2.6408
|
||||
export const NORMAL_SPEED = 2.7448
|
||||
export const PEEL_SPEED = 2.8499
|
||||
export const BOARD_STOP_OFFSET_FT = HACK_STOP_OFFSET_FT + 6
|
||||
export const CONTROL_STOP_OFFSET_FT = HACK_STOP_OFFSET_FT + 15
|
||||
export const NORMAL_STOP_OFFSET_FT = HACK_STOP_OFFSET_FT + 25
|
||||
export const PEEL_STOP_OFFSET_FT = HACK_STOP_OFFSET_FT + 35
|
||||
/** Tee-line weight (category 7). */
|
||||
export const DRAW_VELOCITY = WEIGHT_SPEEDS[6]
|
||||
export const MIN_SPEED = WEIGHT_SPEEDS[0]
|
||||
export const MAX_SPEED = WEIGHT_SPEEDS[9]
|
||||
|
||||
export type SpeedLabel =
|
||||
| 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10
|
||||
| 'hack' | 'board' | 'control' | 'normal' | 'peel'
|
||||
|
||||
export const SPEED_TABLE: Record<SpeedLabel, number> = {
|
||||
1: WEIGHT_SPEEDS[0],
|
||||
2: WEIGHT_SPEEDS[1],
|
||||
3: WEIGHT_SPEEDS[2],
|
||||
4: WEIGHT_SPEEDS[3],
|
||||
5: WEIGHT_SPEEDS[4],
|
||||
6: WEIGHT_SPEEDS[5],
|
||||
7: WEIGHT_SPEEDS[6],
|
||||
8: WEIGHT_SPEEDS[7],
|
||||
9: WEIGHT_SPEEDS[8],
|
||||
10: WEIGHT_SPEEDS[9],
|
||||
hack: HACK_SPEED,
|
||||
board: BOARD_SPEED,
|
||||
control: CONTROL_SPEED,
|
||||
normal: NORMAL_SPEED,
|
||||
peel: PEEL_SPEED,
|
||||
}
|
||||
|
||||
export type Team = 'team1' | 'team2'
|
||||
export type Phase = 'waiting' | 'playing' | 'simulating' | 'scoring' | 'end_complete' | 'game_complete'
|
||||
@ -57,7 +99,6 @@ export interface ClientThrowMessage {
|
||||
broom_y: number
|
||||
velocity: number
|
||||
curl: number
|
||||
friction: number
|
||||
}
|
||||
|
||||
export interface ServerJoinedMessage {
|
||||
|
||||
76
frontend/src/scoreboard.test.ts
Normal file
76
frontend/src/scoreboard.test.ts
Normal 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
116
frontend/src/scoreboard.ts
Normal 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>`
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user