forked from eros/curltastic
actual fix
This commit is contained in:
parent
5eefcd23bf
commit
99a4f59a39
@ -1,16 +1,20 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import {
|
import {
|
||||||
|
formatSpeedLabel,
|
||||||
hogTrimStartIndex,
|
hogTrimStartIndex,
|
||||||
|
indexOfLabel,
|
||||||
|
labelAtIndex,
|
||||||
sampleTime,
|
sampleTime,
|
||||||
|
SPEED_LABEL_ORDER,
|
||||||
trimPathToStartAtHogLine,
|
trimPathToStartAtHogLine,
|
||||||
|
velocityForLabel,
|
||||||
velocityToWeight,
|
velocityToWeight,
|
||||||
weightToVelocity,
|
weightToVelocity,
|
||||||
} from './game-helpers'
|
} from './game-helpers'
|
||||||
import { SAMPLE_RATE_HZ } from './protocol'
|
import { DRAW_VELOCITY, SAMPLE_RATE_HZ, SPEED_TABLE } from './protocol'
|
||||||
|
|
||||||
describe('trimPathToStartAtHogLine', () => {
|
describe('trimPathToStartAtHogLine', () => {
|
||||||
it('trims path at the first hog-line crossing and preserves theta', () => {
|
it('trims path at the first hog-line crossing and preserves theta', () => {
|
||||||
// (x, y, theta) — time is derived from index after trim
|
|
||||||
const path: [number, number, number][] = [
|
const path: [number, number, number][] = [
|
||||||
[0, 2, 0.1],
|
[0, 2, 0.1],
|
||||||
[0, 20, 0.2],
|
[0, 20, 0.2],
|
||||||
@ -53,22 +57,25 @@ describe('hogTrimStartIndex', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('velocity ↔ weight', () => {
|
describe('velocity ↔ weight', () => {
|
||||||
it('maps endpoints correctly', () => {
|
it('maps endpoints and tee-line weight 7', () => {
|
||||||
expect(velocityToWeight(1.9)).toBe(1)
|
expect(weightToVelocity(7)).toBe(DRAW_VELOCITY)
|
||||||
expect(velocityToWeight(3.0)).toBe(10)
|
expect(velocityToWeight(DRAW_VELOCITY)).toBe(7)
|
||||||
expect(weightToVelocity(1)).toBe(1.9)
|
|
||||||
expect(weightToVelocity(10)).toBe(3.0)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('mid weight is near draw (tee-line) velocity', () => {
|
it('includes takeout labels on the weight continuum', () => {
|
||||||
// weight 5 → 1.9 + 4/9 * 1.1 ≈ 2.389 — calibrated DRAW_VELOCITY
|
expect(SPEED_LABEL_ORDER).toEqual([
|
||||||
expect(weightToVelocity(5)).toBeCloseTo(2.389, 2)
|
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 'hack', 'board', 'control', 'normal', 'peel',
|
||||||
})
|
])
|
||||||
|
expect(formatSpeedLabel('board')).toBe('board')
|
||||||
it('clamps out-of-range inputs', () => {
|
expect(formatSpeedLabel(7)).toBe('7')
|
||||||
expect(velocityToWeight(1.5)).toBe(1)
|
expect(labelAtIndex(indexOfLabel('hack'))).toBe('hack')
|
||||||
expect(velocityToWeight(4.0)).toBe(10)
|
expect(labelAtIndex(indexOfLabel('peel'))).toBe('peel')
|
||||||
expect(weightToVelocity(0)).toBe(1.9)
|
expect(velocityForLabel('board')).toBe(SPEED_TABLE.board)
|
||||||
expect(weightToVelocity(11)).toBe(3.0)
|
expect(velocityForLabel('control')).toBe(SPEED_TABLE.control)
|
||||||
|
expect(velocityForLabel('normal')).toBe(SPEED_TABLE.normal)
|
||||||
|
expect(velocityForLabel('peel')).toBe(SPEED_TABLE.peel)
|
||||||
|
expect(SPEED_TABLE.board).toBeLessThan(SPEED_TABLE.control)
|
||||||
|
expect(SPEED_TABLE.control).toBeLessThan(SPEED_TABLE.normal)
|
||||||
|
expect(SPEED_TABLE.normal).toBeLessThan(SPEED_TABLE.peel)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,4 +1,9 @@
|
|||||||
import { HOG_LINE_Y, MAX_SPEED, MIN_SPEED, SAMPLE_RATE_HZ } from './protocol'
|
import {
|
||||||
|
HOG_LINE_Y,
|
||||||
|
SAMPLE_RATE_HZ,
|
||||||
|
SPEED_TABLE,
|
||||||
|
type SpeedLabel,
|
||||||
|
} from './protocol'
|
||||||
|
|
||||||
/** Path samples are (x, y, theta). Time is sample index / SAMPLE_RATE_HZ. */
|
/** Path samples are (x, y, theta). Time is sample index / SAMPLE_RATE_HZ. */
|
||||||
export function sampleTime(index: number): number {
|
export function sampleTime(index: number): number {
|
||||||
@ -11,8 +16,6 @@ export function trimPathToStartAtHogLine(
|
|||||||
if (path.length < 2) return path
|
if (path.length < 2) return path
|
||||||
const idx = path.findIndex(([, y]) => y >= HOG_LINE_Y)
|
const idx = path.findIndex(([, y]) => y >= HOG_LINE_Y)
|
||||||
if (idx < 0) return path
|
if (idx < 0) return path
|
||||||
// Start just before the hog line crossing so the stone enters smoothly.
|
|
||||||
// Theta is preserved; time is rebased via sample index on the trimmed array.
|
|
||||||
const start = Math.max(0, idx - 1)
|
const start = Math.max(0, idx - 1)
|
||||||
return path.slice(start)
|
return path.slice(start)
|
||||||
}
|
}
|
||||||
@ -25,14 +28,47 @@ export function hogTrimStartIndex(path: [number, number, number][]): number {
|
|||||||
return Math.max(0, idx - 1)
|
return Math.max(0, idx - 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function velocityToWeight(velocity: number): number {
|
/** Full weight order for the UI slider: draws 1–10 then takeouts. */
|
||||||
const t = (velocity - MIN_SPEED) / (MAX_SPEED - MIN_SPEED)
|
export const SPEED_LABEL_ORDER: readonly SpeedLabel[] = [
|
||||||
const weight = 1 + Math.round(t * 9)
|
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 'hack', 'board', 'control', 'normal', 'peel',
|
||||||
return Math.max(1, Math.min(10, weight))
|
] as const
|
||||||
|
|
||||||
|
export function clampSpeedIndex(index: number): number {
|
||||||
|
return Math.max(0, Math.min(SPEED_LABEL_ORDER.length - 1, Math.round(index)))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function labelAtIndex(index: number): SpeedLabel {
|
||||||
|
return SPEED_LABEL_ORDER[clampSpeedIndex(index)]!
|
||||||
|
}
|
||||||
|
|
||||||
|
export function indexOfLabel(label: SpeedLabel): number {
|
||||||
|
const i = SPEED_LABEL_ORDER.indexOf(label)
|
||||||
|
return i < 0 ? 6 : i
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatSpeedLabel(label: SpeedLabel): string {
|
||||||
|
return typeof label === 'number' ? String(label) : label
|
||||||
|
}
|
||||||
|
|
||||||
|
export function velocityForLabel(label: SpeedLabel): number {
|
||||||
|
return SPEED_TABLE[label]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function weightToVelocity(weight: number): number {
|
export function weightToVelocity(weight: number): number {
|
||||||
const clamped = Math.max(1, Math.min(10, weight))
|
// Legacy: numeric 1–10 only
|
||||||
const t = (clamped - 1) / 9
|
const w = Math.max(1, Math.min(10, Math.round(weight)))
|
||||||
return MIN_SPEED + t * (MAX_SPEED - MIN_SPEED)
|
return SPEED_TABLE[w as SpeedLabel]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function velocityToWeight(velocity: number): number {
|
||||||
|
let best = 1
|
||||||
|
let bestDist = Infinity
|
||||||
|
for (let w = 1; w <= 10; w++) {
|
||||||
|
const d = Math.abs(SPEED_TABLE[w as SpeedLabel] - velocity)
|
||||||
|
if (d < bestDist) {
|
||||||
|
bestDist = d
|
||||||
|
best = w
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
}
|
}
|
||||||
|
|||||||
@ -150,4 +150,129 @@ describe('GameModel multi-path trajectory animation', () => {
|
|||||||
it('uses sample index for time (SAMPLE_RATE_HZ)', () => {
|
it('uses sample index for time (SAMPLE_RATE_HZ)', () => {
|
||||||
expect(SAMPLE_RATE_HZ).toBe(40)
|
expect(SAMPLE_RATE_HZ).toBe(40)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('does not snap a takeout target back to its pre-throw rest position', () => {
|
||||||
|
const model = new GameModel()
|
||||||
|
const rest = stone({ id: sid('team1', 1), team: 'team1', x: 0.2, y: 38.5 })
|
||||||
|
model.state.stones = [rest]
|
||||||
|
model.state.turnTeam = 'team2'
|
||||||
|
|
||||||
|
// Shared clock: thrown reaches hog ~ sample 2; target then exits past backline.
|
||||||
|
const thrownPath = pathSamples([
|
||||||
|
{ x: 0, y: 2 },
|
||||||
|
{ x: 0, y: 20 },
|
||||||
|
{ x: 0, y: 25 },
|
||||||
|
{ x: 0, y: 38.5 },
|
||||||
|
{ x: 0, y: 40 },
|
||||||
|
])
|
||||||
|
const targetPath = pathSamples([
|
||||||
|
{ x: 0.2, y: 38.5 },
|
||||||
|
{ x: 0.2, y: 38.5 },
|
||||||
|
{ x: 0.2, y: 38.5 },
|
||||||
|
{ x: 0.4, y: 39.5 },
|
||||||
|
{ x: 1.0, y: 42.0 }, // edge past backline → out
|
||||||
|
])
|
||||||
|
|
||||||
|
model.startTrajectory([
|
||||||
|
stonePath(sid('team2', 1), 'team2', thrownPath),
|
||||||
|
stonePath(sid('team1', 1), 'team1', targetPath),
|
||||||
|
])
|
||||||
|
// Final game_state: only shooter remains in play.
|
||||||
|
model.updateGameState({
|
||||||
|
type: 'game_state',
|
||||||
|
end: 1,
|
||||||
|
scores: [0, 0],
|
||||||
|
hammer: 'team1',
|
||||||
|
turn_team: 'team1',
|
||||||
|
scoreboard: [],
|
||||||
|
stones_remaining: [8, 7],
|
||||||
|
stones: [
|
||||||
|
stone({ id: sid('team2', 1), team: 'team2', x: 0, y: 39 }),
|
||||||
|
],
|
||||||
|
phase: 'playing',
|
||||||
|
})
|
||||||
|
|
||||||
|
const afterEnd = performance.now() + 5000
|
||||||
|
model.tick(afterEnd)
|
||||||
|
|
||||||
|
expect(model.state.animating).toBe(false)
|
||||||
|
expect(model.state.stones).toHaveLength(1)
|
||||||
|
expect(model.state.stones[0]?.id).toEqual(sid('team2', 1))
|
||||||
|
// Must not resurrect target at rest position.
|
||||||
|
expect(model.state.stones.some((s) => s.id.team === 'team1' && s.id.n === 1)).toBe(
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not resurrect a removed rock at the start of the next throw', () => {
|
||||||
|
const model = new GameModel()
|
||||||
|
model.state.stones = [stone({ id: sid('team1', 1), team: 'team1', x: 0.2, y: 38.5 })]
|
||||||
|
model.state.turnTeam = 'team2'
|
||||||
|
|
||||||
|
// First throw: hit team1/1 out. Server post-state has only shooter.
|
||||||
|
model.startTrajectory([
|
||||||
|
stonePath(
|
||||||
|
sid('team2', 1),
|
||||||
|
'team2',
|
||||||
|
pathSamples([
|
||||||
|
{ x: 0, y: 2 },
|
||||||
|
{ x: 0, y: 20 },
|
||||||
|
{ x: 0, y: 38 },
|
||||||
|
{ x: 0, y: 39 },
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
stonePath(
|
||||||
|
sid('team1', 1),
|
||||||
|
'team1',
|
||||||
|
pathSamples([
|
||||||
|
{ x: 0.2, y: 38.5 },
|
||||||
|
{ x: 0.2, y: 38.5 },
|
||||||
|
{ x: 0.5, y: 40 },
|
||||||
|
{ x: 1.2, y: 42 },
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
])
|
||||||
|
model.updateGameState({
|
||||||
|
type: 'game_state',
|
||||||
|
end: 1,
|
||||||
|
scores: [0, 0],
|
||||||
|
hammer: 'team1',
|
||||||
|
turn_team: 'team1',
|
||||||
|
scoreboard: [],
|
||||||
|
stones_remaining: [8, 7],
|
||||||
|
stones: [stone({ id: sid('team2', 1), team: 'team2', x: 0, y: 39 })],
|
||||||
|
phase: 'playing',
|
||||||
|
})
|
||||||
|
model.tick(performance.now() + 5000)
|
||||||
|
expect(model.state.stones.map((s) => s.id)).toEqual([sid('team2', 1)])
|
||||||
|
|
||||||
|
// Second throw: only shooter already on ice + new team1 rock.
|
||||||
|
model.state.turnTeam = 'team1'
|
||||||
|
model.startTrajectory([
|
||||||
|
stonePath(
|
||||||
|
sid('team2', 1),
|
||||||
|
'team2',
|
||||||
|
pathSamples([
|
||||||
|
{ x: 0, y: 39 },
|
||||||
|
{ x: 0, y: 39 },
|
||||||
|
{ x: 0, y: 39 },
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
stonePath(
|
||||||
|
sid('team1', 2),
|
||||||
|
'team1',
|
||||||
|
pathSamples([
|
||||||
|
{ x: 0, y: 2 },
|
||||||
|
{ x: 0, y: 20 },
|
||||||
|
{ x: 0, y: 30 },
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
])
|
||||||
|
// First frame of next throw must not draw removed team1/1 at old rest.
|
||||||
|
const drawn = model.tick(performance.now())
|
||||||
|
expect(drawn.every((d) => !(d.team === 'team1' && Math.abs(d.x - 0.2) < 0.01 && Math.abs(d.y - 38.5) < 0.01))).toBe(
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
expect(model.state.stones.some((s) => s.id.team === 'team1' && s.id.n === 1)).toBe(false)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,5 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
HOUSE_CENTER,
|
HOUSE_CENTER,
|
||||||
|
SAMPLE_RATE_HZ,
|
||||||
|
STONE_RADIUS,
|
||||||
|
BACK_LINE_Y,
|
||||||
|
HOG_LINE_Y,
|
||||||
|
SHEET_WIDTH,
|
||||||
stoneIdKey,
|
stoneIdKey,
|
||||||
stoneIdsEqual,
|
stoneIdsEqual,
|
||||||
type DrawableStone,
|
type DrawableStone,
|
||||||
@ -11,7 +16,7 @@ import {
|
|||||||
type StoneState,
|
type StoneState,
|
||||||
type Team,
|
type Team,
|
||||||
} from './protocol'
|
} from './protocol'
|
||||||
import { hogTrimStartIndex, sampleTime, trimPathToStartAtHogLine } from './game-helpers'
|
import { hogTrimStartIndex, sampleTime } from './game-helpers'
|
||||||
|
|
||||||
export interface GameModelState {
|
export interface GameModelState {
|
||||||
end: number
|
end: number
|
||||||
@ -26,6 +31,24 @@ export interface GameModelState {
|
|||||||
animating: boolean
|
animating: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Side / back edge-touch (same as backend edge_out_of_bounds) for mid-flight hide. */
|
||||||
|
function samplePastSideOrBack(x: number, y: number): boolean {
|
||||||
|
const half = SHEET_WIDTH / 2
|
||||||
|
return Math.abs(x) + STONE_RADIUS >= half || y + STONE_RADIUS >= BACK_LINE_Y
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Final rest OOB: sides/back or never cleared the hog. */
|
||||||
|
function pathEndedOutOfPlay(path: [number, number, number][]): boolean {
|
||||||
|
if (path.length === 0) return true
|
||||||
|
const [x, y] = path[path.length - 1]!
|
||||||
|
const half = SHEET_WIDTH / 2
|
||||||
|
return (
|
||||||
|
Math.abs(x) + STONE_RADIUS >= half ||
|
||||||
|
y + STONE_RADIUS >= BACK_LINE_Y ||
|
||||||
|
y - STONE_RADIUS <= HOG_LINE_Y
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export class GameModel {
|
export class GameModel {
|
||||||
state: GameModelState = {
|
state: GameModelState = {
|
||||||
end: 1,
|
end: 1,
|
||||||
@ -45,8 +68,15 @@ export class GameModel {
|
|||||||
isDragging = false
|
isDragging = false
|
||||||
isPanning = false
|
isPanning = false
|
||||||
|
|
||||||
private activePaths = new Map<string, { id: StoneId; team: Team; path: [number, number, number][] }>()
|
private activePaths = new Map<
|
||||||
|
string,
|
||||||
|
{ id: StoneId; team: Team; path: [number, number, number][] }
|
||||||
|
>()
|
||||||
private animationStartTime = 0
|
private animationStartTime = 0
|
||||||
|
/** Absolute sample index when the thrown stone reaches the hog (shared clock). */
|
||||||
|
private pathClockOffset = 0
|
||||||
|
/** True once a game_state snapshot arrived for the in-flight anim. */
|
||||||
|
private hasServerSnapshot = false
|
||||||
|
|
||||||
setMyTeam(team: Team): void {
|
setMyTeam(team: Team): void {
|
||||||
this.state.myTeam = team
|
this.state.myTeam = team
|
||||||
@ -61,12 +91,19 @@ export class GameModel {
|
|||||||
if (reset) {
|
if (reset) {
|
||||||
this.state.stones = []
|
this.state.stones = []
|
||||||
this.pendingStones = []
|
this.pendingStones = []
|
||||||
|
this.hasServerSnapshot = false
|
||||||
|
this.activePaths.clear()
|
||||||
|
this.state.animating = false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.state.animating) {
|
if (this.state.animating) {
|
||||||
|
// Authoritative post-throw board — apply even when empty (all stones removed).
|
||||||
this.pendingStones = msg.stones
|
this.pendingStones = msg.stones
|
||||||
|
this.hasServerSnapshot = true
|
||||||
} else {
|
} else {
|
||||||
this.state.stones = msg.stones
|
this.state.stones = msg.stones
|
||||||
|
this.pendingStones = []
|
||||||
|
this.hasServerSnapshot = false
|
||||||
}
|
}
|
||||||
|
|
||||||
this.state = {
|
this.state = {
|
||||||
@ -82,6 +119,9 @@ export class GameModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
startTrajectory(stones: StonePath[]): void {
|
startTrajectory(stones: StonePath[]): void {
|
||||||
|
// Commit any leftover server board before basing "existing" on it.
|
||||||
|
this.commitServerStones()
|
||||||
|
|
||||||
const existingKeys = new Set(this.state.stones.map((s) => stoneIdKey(s.id)))
|
const existingKeys = new Set(this.state.stones.map((s) => stoneIdKey(s.id)))
|
||||||
|
|
||||||
let thrownId: StoneId | null = null
|
let thrownId: StoneId | null = null
|
||||||
@ -92,30 +132,36 @@ export class GameModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (thrownId === null && stones.length > 0) {
|
if (thrownId === null && stones.length > 0) {
|
||||||
thrownId = stones[0].stone_id
|
thrownId = stones[0]!.stone_id
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shared sample-index trim so multi-stone paths stay on one clock.
|
this.pathClockOffset = 0
|
||||||
// Path entries are (x, y, theta); t = index / SAMPLE_RATE_HZ after trim.
|
|
||||||
let startIdx = 0
|
|
||||||
if (thrownId !== null) {
|
if (thrownId !== null) {
|
||||||
const thrownPath = stones.find((p) => stoneIdsEqual(p.stone_id, thrownId!))?.trajectory ?? []
|
const thrownPath =
|
||||||
startIdx = hogTrimStartIndex(thrownPath)
|
stones.find((p) => stoneIdsEqual(p.stone_id, thrownId!))?.trajectory ?? []
|
||||||
|
this.pathClockOffset = hogTrimStartIndex(thrownPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
const pathMap = new Map<string, { id: StoneId; team: Team; path: [number, number, number][] }>()
|
const pathMap = new Map<
|
||||||
|
string,
|
||||||
|
{ id: StoneId; team: Team; path: [number, number, number][] }
|
||||||
|
>()
|
||||||
for (const { stone_id, team, trajectory } of stones) {
|
for (const { stone_id, team, trajectory } of stones) {
|
||||||
const path =
|
pathMap.set(stoneIdKey(stone_id), {
|
||||||
thrownId !== null && stoneIdsEqual(stone_id, thrownId)
|
id: stone_id,
|
||||||
? trimPathToStartAtHogLine(trajectory)
|
team,
|
||||||
: trajectory.slice(startIdx)
|
path: trajectory,
|
||||||
pathMap.set(stoneIdKey(stone_id), { id: stone_id, team, path })
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
this.activePaths = pathMap
|
this.activePaths = pathMap
|
||||||
this.state.animating = Array.from(pathMap.values()).some((p) => p.path.length > 1)
|
this.state.animating = Array.from(pathMap.values()).some(
|
||||||
|
(p) => p.path.length > this.pathClockOffset + 1,
|
||||||
|
)
|
||||||
this.animationStartTime = performance.now()
|
this.animationStartTime = performance.now()
|
||||||
|
// Fresh throw: wait for the matching game_state.
|
||||||
this.pendingStones = []
|
this.pendingStones = []
|
||||||
|
this.hasServerSnapshot = false
|
||||||
}
|
}
|
||||||
|
|
||||||
tick(now: number): DrawableStone[] {
|
tick(now: number): DrawableStone[] {
|
||||||
@ -124,63 +170,99 @@ export class GameModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const elapsed = (now - this.animationStartTime) / 1000
|
const elapsed = (now - this.animationStartTime) / 1000
|
||||||
const maxTotal = Math.max(
|
const absT = this.pathClockOffset / SAMPLE_RATE_HZ + elapsed
|
||||||
|
const maxAbsT = Math.max(
|
||||||
0,
|
0,
|
||||||
...Array.from(this.activePaths.values()).map((p) =>
|
...Array.from(this.activePaths.values()).map((p) =>
|
||||||
p.path.length > 0 ? sampleTime(p.path.length - 1) : 0,
|
p.path.length > 0 ? sampleTime(p.path.length - 1) : 0,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
if (elapsed >= maxTotal) {
|
if (absT >= maxAbsT) {
|
||||||
this.state.animating = false
|
this.finishAnimation()
|
||||||
if (this.pendingStones.length > 0) {
|
|
||||||
this.state.stones = this.pendingStones
|
|
||||||
this.pendingStones = []
|
|
||||||
}
|
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
const result: DrawableStone[] = []
|
const result: DrawableStone[] = []
|
||||||
for (const { id, team, path } of this.activePaths.values()) {
|
for (const { id, team, path } of this.activePaths.values()) {
|
||||||
const pos = this.interpolatePath(path, elapsed)
|
const pos = this.interpolatePathAtAbsTime(path, absT)
|
||||||
if (!pos) continue
|
if (!pos) continue
|
||||||
|
if (samplePastSideOrBack(pos.x, pos.y)) continue
|
||||||
const resolvedTeam =
|
const resolvedTeam =
|
||||||
this.state.stones.find((s) => stoneIdsEqual(s.id, id))?.team ?? team ?? this.state.turnTeam
|
this.state.stones.find((s) => stoneIdsEqual(s.id, id))?.team ??
|
||||||
|
team ??
|
||||||
|
this.state.turnTeam
|
||||||
result.push({ ...pos, team: resolvedTeam })
|
result.push({ ...pos, team: resolvedTeam })
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
private interpolatePath(
|
private commitServerStones(): void {
|
||||||
|
if (!this.hasServerSnapshot) return
|
||||||
|
this.state.stones = this.pendingStones
|
||||||
|
this.pendingStones = []
|
||||||
|
this.hasServerSnapshot = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private finishAnimation(): void {
|
||||||
|
this.state.animating = false
|
||||||
|
if (this.hasServerSnapshot) {
|
||||||
|
// Apply even when pending is [] (full clear).
|
||||||
|
this.state.stones = this.pendingStones
|
||||||
|
this.pendingStones = []
|
||||||
|
this.hasServerSnapshot = false
|
||||||
|
} else {
|
||||||
|
const settled: StoneState[] = []
|
||||||
|
for (const { id, team, path } of this.activePaths.values()) {
|
||||||
|
if (path.length === 0 || pathEndedOutOfPlay(path)) continue
|
||||||
|
const last = path[path.length - 1]!
|
||||||
|
settled.push({
|
||||||
|
id,
|
||||||
|
team,
|
||||||
|
x: last[0],
|
||||||
|
y: last[1],
|
||||||
|
rotation: last[2],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
this.state.stones = settled
|
||||||
|
}
|
||||||
|
this.activePaths.clear()
|
||||||
|
this.pathClockOffset = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private interpolatePathAtAbsTime(
|
||||||
path: [number, number, number][],
|
path: [number, number, number][],
|
||||||
elapsed: number,
|
absT: number,
|
||||||
): { x: number; y: number; rotation: number } | null {
|
): { x: number; y: number; rotation: number } | null {
|
||||||
if (path.length === 0) return null
|
if (path.length === 0) return null
|
||||||
if (path.length === 1) {
|
if (path.length === 1) {
|
||||||
const [x, y, theta] = path[0]
|
const [x, y, theta] = path[0]!
|
||||||
return { x, y, rotation: theta }
|
return { x, y, rotation: theta }
|
||||||
}
|
}
|
||||||
|
|
||||||
const lastT = sampleTime(path.length - 1)
|
const lastT = sampleTime(path.length - 1)
|
||||||
if (elapsed >= lastT) {
|
if (absT <= 0) {
|
||||||
const last = path[path.length - 1]
|
const [x, y, theta] = path[0]!
|
||||||
|
return { x, y, rotation: theta }
|
||||||
|
}
|
||||||
|
if (absT >= lastT) {
|
||||||
|
const last = path[path.length - 1]!
|
||||||
return { x: last[0], y: last[1], rotation: last[2] }
|
return { x: last[0], y: last[1], rotation: last[2] }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find segment where sampleTime(i) <= elapsed < sampleTime(i+1)
|
let i = Math.min(path.length - 2, Math.max(0, Math.floor(absT * SAMPLE_RATE_HZ)))
|
||||||
let i = 0
|
while (i + 1 < path.length && sampleTime(i + 1) < absT) i++
|
||||||
while (i + 1 < path.length && sampleTime(i + 1) < elapsed) i++
|
while (i > 0 && sampleTime(i) > absT) i--
|
||||||
const p0 = path[i]
|
|
||||||
|
const p0 = path[i]!
|
||||||
const p1 = path[i + 1] ?? p0
|
const p1 = path[i + 1] ?? p0
|
||||||
const t0 = sampleTime(i)
|
const t0 = sampleTime(i)
|
||||||
const t1 = sampleTime(i + 1)
|
const t1 = sampleTime(i + 1)
|
||||||
const dt = t1 - t0
|
const dt = t1 - t0
|
||||||
const t = dt > 0 ? (elapsed - t0) / dt : 0
|
const t = dt > 0 ? (absT - t0) / dt : 0
|
||||||
const x = p0[0] + (p1[0] - p0[0]) * t
|
const x = p0[0] + (p1[0] - p0[0]) * t
|
||||||
const y = p0[1] + (p1[1] - p0[1]) * t
|
const y = p0[1] + (p1[1] - p0[1]) * t
|
||||||
// Interpolate body rotation (theta) from path samples
|
|
||||||
let dTheta = p1[2] - p0[2]
|
let dTheta = p1[2] - p0[2]
|
||||||
// Unwrap shortest path across ±π
|
|
||||||
if (dTheta > Math.PI) dTheta -= 2 * Math.PI
|
if (dTheta > Math.PI) dTheta -= 2 * Math.PI
|
||||||
if (dTheta < -Math.PI) dTheta += 2 * Math.PI
|
if (dTheta < -Math.PI) dTheta += 2 * Math.PI
|
||||||
const rotation = p0[2] + dTheta * t
|
const rotation = p0[2] + dTheta * t
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { connect, sendThrow, type NetCallbacks } from './net'
|
import { connect, sendThrow, type NetCallbacks } from './net'
|
||||||
import { createRenderer } from './renderer'
|
import { createRenderer } from './renderer'
|
||||||
import { createHud, createVelocitySelector, createCurlSelector, createFrictionSlider } from './hud'
|
import { createHud, createVelocitySelector, createCurlSelector } from './hud'
|
||||||
import { type Team } from './protocol'
|
import { type Team } from './protocol'
|
||||||
import { GameModel } from './game-model'
|
import { GameModel } from './game-model'
|
||||||
|
|
||||||
@ -19,11 +19,9 @@ export function startGame(): void {
|
|||||||
|
|
||||||
const velocityContainer = hud.velocityControl
|
const velocityContainer = hud.velocityControl
|
||||||
const curlContainer = hud.curlSelector
|
const curlContainer = hud.curlSelector
|
||||||
const frictionContainer = hud.frictionControl
|
|
||||||
const throwBtn = hud.throwButton
|
const throwBtn = hud.throwButton
|
||||||
const velocity = createVelocitySelector(velocityContainer, () => {})
|
const velocity = createVelocitySelector(velocityContainer, () => {})
|
||||||
const curls = createCurlSelector(curlContainer, () => {})
|
const curls = createCurlSelector(curlContainer, () => {})
|
||||||
const friction = createFrictionSlider(frictionContainer)
|
|
||||||
|
|
||||||
const params = new URLSearchParams(window.location.search)
|
const params = new URLSearchParams(window.location.search)
|
||||||
let room = params.get('room')
|
let room = params.get('room')
|
||||||
@ -37,6 +35,14 @@ export function startGame(): void {
|
|||||||
|
|
||||||
const model = new GameModel()
|
const model = new GameModel()
|
||||||
let lastScoreboardLen = model.state.scoreboard.length
|
let lastScoreboardLen = model.state.scoreboard.length
|
||||||
|
type EndModalPayload = {
|
||||||
|
end: number
|
||||||
|
team1: number
|
||||||
|
team2: number
|
||||||
|
nextHammer: Team
|
||||||
|
scoreboard: typeof model.state.scoreboard
|
||||||
|
}
|
||||||
|
let deferredEndModal: EndModalPayload | null = null
|
||||||
|
|
||||||
const stored = localStorage.getItem('curltastic-team')
|
const stored = localStorage.getItem('curltastic-team')
|
||||||
const initialTeam: Team = stored === 'team2' || stored === 'yellow' ? 'team2' : 'team1'
|
const initialTeam: Team = stored === 'team2' || stored === 'yellow' ? 'team2' : 'team1'
|
||||||
@ -51,27 +57,30 @@ export function startGame(): void {
|
|||||||
const myTurn = model.isMyTurn
|
const myTurn = model.isMyTurn
|
||||||
velocity.setEnabled(myTurn)
|
velocity.setEnabled(myTurn)
|
||||||
curls.setEnabled(myTurn)
|
curls.setEnabled(myTurn)
|
||||||
friction.setEnabled(myTurn)
|
|
||||||
throwBtn.disabled = !myTurn
|
throwBtn.disabled = !myTurn
|
||||||
hud.update(model.state)
|
hud.update(model.state)
|
||||||
}
|
}
|
||||||
|
|
||||||
const maybeShowEndModal = () => {
|
/** Queue end modal when scoreboard grows; only show after throw animation. */
|
||||||
|
const queueEndModalIfNeeded = () => {
|
||||||
const board = model.state.scoreboard
|
const board = model.state.scoreboard
|
||||||
if (board.length <= lastScoreboardLen) {
|
if (board.length <= lastScoreboardLen) return
|
||||||
lastScoreboardLen = board.length
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const last = board[board.length - 1]
|
const last = board[board.length - 1]
|
||||||
lastScoreboardLen = board.length
|
lastScoreboardLen = board.length
|
||||||
if (!last) return
|
if (!last) return
|
||||||
hud.showEndModal({
|
deferredEndModal = {
|
||||||
end: last.end,
|
end: last.end,
|
||||||
team1: last.team1,
|
team1: last.team1,
|
||||||
team2: last.team2,
|
team2: last.team2,
|
||||||
nextHammer: model.state.hammer,
|
nextHammer: model.state.hammer,
|
||||||
scoreboard: board,
|
scoreboard: board,
|
||||||
})
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const flushEndModal = () => {
|
||||||
|
if (!deferredEndModal || model.state.animating) return
|
||||||
|
hud.showEndModal(deferredEndModal)
|
||||||
|
deferredEndModal = null
|
||||||
}
|
}
|
||||||
|
|
||||||
hud.teamSelect.addEventListener('change', () => {
|
hud.teamSelect.addEventListener('change', () => {
|
||||||
@ -83,6 +92,7 @@ export function startGame(): void {
|
|||||||
const wasAnimating = model.state.animating
|
const wasAnimating = model.state.animating
|
||||||
const activeStonePos = model.tick(performance.now())
|
const activeStonePos = model.tick(performance.now())
|
||||||
if (wasAnimating && !model.state.animating) {
|
if (wasAnimating && !model.state.animating) {
|
||||||
|
flushEndModal()
|
||||||
updateControls()
|
updateControls()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -110,11 +120,15 @@ export function startGame(): void {
|
|||||||
},
|
},
|
||||||
onGameState: (msg) => {
|
onGameState: (msg) => {
|
||||||
model.updateGameState(msg)
|
model.updateGameState(msg)
|
||||||
maybeShowEndModal()
|
queueEndModalIfNeeded()
|
||||||
|
// Never flush here: game_state can arrive before/during throw anim.
|
||||||
|
// Modal is flushed when the trajectory animation ends (or traj is a no-op).
|
||||||
updateControls()
|
updateControls()
|
||||||
},
|
},
|
||||||
onTrajectories: (stones) => {
|
onTrajectories: (stones) => {
|
||||||
model.startTrajectory(stones)
|
model.startTrajectory(stones)
|
||||||
|
// Short / empty paths finish immediately — show end modal if already queued.
|
||||||
|
flushEndModal()
|
||||||
updateControls()
|
updateControls()
|
||||||
},
|
},
|
||||||
onGameOver: (scores, winner) => {
|
onGameOver: (scores, winner) => {
|
||||||
@ -194,7 +208,6 @@ export function startGame(): void {
|
|||||||
model.broom.y,
|
model.broom.y,
|
||||||
velocity.getVelocity(),
|
velocity.getVelocity(),
|
||||||
curls.getSelected(),
|
curls.getSelected(),
|
||||||
friction.getFriction(),
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -1,19 +1,24 @@
|
|||||||
import {
|
import {
|
||||||
MAX_SPEED,
|
ENDS,
|
||||||
MIN_SPEED,
|
|
||||||
STONES_PER_TEAM,
|
STONES_PER_TEAM,
|
||||||
type EndScore,
|
type EndScore,
|
||||||
type Phase,
|
type Phase,
|
||||||
type Team,
|
type Team,
|
||||||
} from './protocol'
|
} from './protocol'
|
||||||
import { velocityToWeight, weightToVelocity } from './game-helpers'
|
import {
|
||||||
|
clampSpeedIndex,
|
||||||
|
formatSpeedLabel,
|
||||||
|
indexOfLabel,
|
||||||
|
labelAtIndex,
|
||||||
|
velocityForLabel,
|
||||||
|
} from './game-helpers'
|
||||||
|
import { renderBaseballScoreboard } from './scoreboard'
|
||||||
|
|
||||||
export interface Hud {
|
export interface Hud {
|
||||||
root: HTMLDivElement
|
root: HTMLDivElement
|
||||||
teamSelect: HTMLSelectElement
|
teamSelect: HTMLSelectElement
|
||||||
velocityControl: HTMLDivElement
|
velocityControl: HTMLDivElement
|
||||||
curlSelector: HTMLDivElement
|
curlSelector: HTMLDivElement
|
||||||
frictionControl: HTMLDivElement
|
|
||||||
throwButton: HTMLButtonElement
|
throwButton: HTMLButtonElement
|
||||||
setTeam: (team: Team) => void
|
setTeam: (team: Team) => void
|
||||||
update: (state: {
|
update: (state: {
|
||||||
@ -74,20 +79,14 @@ function buildStoneChipsHtml(team: Team): string {
|
|||||||
return `<div class="stones-row stones-row--${team}" data-team="${team}" role="img" aria-label="${TEAM_LABELS[team]} stones remaining"><span class="hammer-badge" title="Hammer" aria-hidden="true">🔨</span>${chips}</div>`
|
return `<div class="stones-row stones-row--${team}" data-team="${team}" role="img" aria-label="${TEAM_LABELS[team]} stones remaining"><span class="hammer-badge" title="Hammer" aria-hidden="true">🔨</span>${chips}</div>`
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderScoreboardTable(scoreboard: EndScore[]): string {
|
function scoreboardTotals(scoreboard: readonly EndScore[]): [number, number] {
|
||||||
if (scoreboard.length === 0) {
|
let t1 = 0
|
||||||
return '<p class="end-modal-empty">No ends scored yet</p>'
|
let t2 = 0
|
||||||
|
for (const e of scoreboard) {
|
||||||
|
t1 += e.team1
|
||||||
|
t2 += e.team2
|
||||||
}
|
}
|
||||||
const rows = scoreboard
|
return [t1, t2]
|
||||||
.map(
|
|
||||||
(e) =>
|
|
||||||
`<tr><td>${e.end}</td><td>${e.team1}</td><td>${e.team2}</td><td>${TEAM_LABELS[e.hammer]}</td></tr>`,
|
|
||||||
)
|
|
||||||
.join('')
|
|
||||||
return `<table class="scoreboard-table" aria-label="Scoreboard">
|
|
||||||
<thead><tr><th>End</th><th>Team 1</th><th>Team 2</th><th>Hammer</th></tr></thead>
|
|
||||||
<tbody>${rows}</tbody>
|
|
||||||
</table>`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createHud(): Hud {
|
export function createHud(): Hud {
|
||||||
@ -102,24 +101,18 @@ export function createHud(): Hud {
|
|||||||
${buildStoneChipsHtml('team1')}
|
${buildStoneChipsHtml('team1')}
|
||||||
${buildStoneChipsHtml('team2')}
|
${buildStoneChipsHtml('team2')}
|
||||||
</div>
|
</div>
|
||||||
|
<div id="scoreboard-baseball" class="scoreboard-baseball-wrap" aria-live="polite"></div>
|
||||||
<div class="hud-row">
|
<div class="hud-row">
|
||||||
<div id="score">Team 1 0 - Team 2 0</div>
|
|
||||||
<div id="end-info">End 1 · Waiting</div>
|
<div id="end-info">End 1 · Waiting</div>
|
||||||
<select id="team-select" aria-label="Team">
|
<select id="team-select" aria-label="Team">
|
||||||
<option value="team1">Team 1</option>
|
<option value="team1">Team 1</option>
|
||||||
<option value="team2">Team 2</option>
|
<option value="team2">Team 2</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div id="scoreboard-strip" class="scoreboard-strip" aria-label="End scores"></div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="hud-row" style="align-items:flex-end;">
|
<div class="hud-row" style="align-items:flex-end;">
|
||||||
<div id="velocity-control"></div>
|
<div id="velocity-control"></div>
|
||||||
<div id="curl-selector"></div>
|
<div id="curl-selector"></div>
|
||||||
<div id="friction-control">
|
|
||||||
<label for="friction-slider">Friction</label>
|
|
||||||
<input id="friction-slider" type="range" min="0.5" max="1.5" step="0.1" value="1.0" />
|
|
||||||
<span id="friction-value">1.0</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<button id="throw-btn" type="button" disabled>THROW</button>
|
<button id="throw-btn" type="button" disabled>THROW</button>
|
||||||
</div>
|
</div>
|
||||||
@ -127,12 +120,11 @@ export function createHud(): Hud {
|
|||||||
<div id="waiting">Waiting</div>
|
<div id="waiting">Waiting</div>
|
||||||
`
|
`
|
||||||
|
|
||||||
const scoreEl = root.querySelector<HTMLDivElement>('#score')!
|
|
||||||
const endInfoEl = root.querySelector<HTMLDivElement>('#end-info')!
|
const endInfoEl = root.querySelector<HTMLDivElement>('#end-info')!
|
||||||
const teamSelect = root.querySelector<HTMLSelectElement>('#team-select')!
|
const teamSelect = root.querySelector<HTMLSelectElement>('#team-select')!
|
||||||
const waitingEl = root.querySelector<HTMLDivElement>('#waiting')!
|
const waitingEl = root.querySelector<HTMLDivElement>('#waiting')!
|
||||||
const stonesHud = root.querySelector<HTMLDivElement>('#stones-hud')!
|
const stonesHud = root.querySelector<HTMLDivElement>('#stones-hud')!
|
||||||
const scoreboardStrip = root.querySelector<HTMLDivElement>('#scoreboard-strip')!
|
const scoreboardEl = root.querySelector<HTMLDivElement>('#scoreboard-baseball')!
|
||||||
|
|
||||||
const updateHammerBadge = (hammer: Team) => {
|
const updateHammerBadge = (hammer: Team) => {
|
||||||
for (const team of ['team1', 'team2'] as const) {
|
for (const team of ['team1', 'team2'] as const) {
|
||||||
@ -153,13 +145,8 @@ export function createHud(): Hud {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let endModalEl: HTMLDivElement | null = null
|
let endModalEl: HTMLDivElement | null = null
|
||||||
let endModalTimer = 0
|
|
||||||
|
|
||||||
const dismissEndModal = () => {
|
const dismissEndModal = () => {
|
||||||
if (endModalTimer) {
|
|
||||||
window.clearTimeout(endModalTimer)
|
|
||||||
endModalTimer = 0
|
|
||||||
}
|
|
||||||
if (endModalEl) {
|
if (endModalEl) {
|
||||||
endModalEl.remove()
|
endModalEl.remove()
|
||||||
endModalEl = null
|
endModalEl = null
|
||||||
@ -189,33 +176,33 @@ export function createHud(): Hud {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateScoreboardStrip = (scoreboard: EndScore[], totals: number[]) => {
|
const updateScoreboard = (
|
||||||
if (scoreboard.length === 0) {
|
scoreboard: EndScore[],
|
||||||
scoreboardStrip.textContent = ''
|
totals: number[],
|
||||||
scoreboardStrip.hidden = true
|
options?: { hammer?: Team; currentEnd?: number },
|
||||||
return
|
) => {
|
||||||
}
|
const t1 = totals[0] ?? 0
|
||||||
scoreboardStrip.hidden = false
|
const t2 = totals[1] ?? 0
|
||||||
const cells = scoreboard
|
scoreboardEl.innerHTML = renderBaseballScoreboard(scoreboard, [t1, t2], {
|
||||||
.map((e) => `<span class="scoreboard-end" title="End ${e.end}">${e.team1}-${e.team2}</span>`)
|
ends: ENDS,
|
||||||
.join('')
|
hammer: options?.hammer,
|
||||||
scoreboardStrip.innerHTML = `${cells}<span class="scoreboard-total">Σ ${totals[0] ?? 0}-${totals[1] ?? 0}</span>`
|
currentEnd: options?.currentEnd,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
updateStonesRemaining([STONES_PER_TEAM, STONES_PER_TEAM])
|
updateStonesRemaining([STONES_PER_TEAM, STONES_PER_TEAM])
|
||||||
|
updateScoreboard([], [0, 0], { currentEnd: 1 })
|
||||||
|
|
||||||
return {
|
return {
|
||||||
root,
|
root,
|
||||||
teamSelect,
|
teamSelect,
|
||||||
velocityControl: root.querySelector<HTMLDivElement>('#velocity-control')!,
|
velocityControl: root.querySelector<HTMLDivElement>('#velocity-control')!,
|
||||||
curlSelector: root.querySelector<HTMLDivElement>('#curl-selector')!,
|
curlSelector: root.querySelector<HTMLDivElement>('#curl-selector')!,
|
||||||
frictionControl: root.querySelector<HTMLDivElement>('#friction-control')!,
|
|
||||||
throwButton: root.querySelector<HTMLButtonElement>('#throw-btn')!,
|
throwButton: root.querySelector<HTMLButtonElement>('#throw-btn')!,
|
||||||
setTeam: (team) => {
|
setTeam: (team) => {
|
||||||
teamSelect.value = team
|
teamSelect.value = team
|
||||||
},
|
},
|
||||||
update: (state) => {
|
update: (state) => {
|
||||||
scoreEl.textContent = `Team 1 ${state.scores[0] ?? 0} - Team 2 ${state.scores[1] ?? 0}`
|
|
||||||
const phaseText =
|
const phaseText =
|
||||||
state.phase === 'playing'
|
state.phase === 'playing'
|
||||||
? `${TEAM_LABELS[state.turnTeam]}'s turn`
|
? `${TEAM_LABELS[state.turnTeam]}'s turn`
|
||||||
@ -225,7 +212,10 @@ export function createHud(): Hud {
|
|||||||
// Hammer class first so stones-remaining aria can mention it.
|
// Hammer class first so stones-remaining aria can mention it.
|
||||||
updateHammerBadge(state.hammer)
|
updateHammerBadge(state.hammer)
|
||||||
updateStonesRemaining(state.stonesRemaining)
|
updateStonesRemaining(state.stonesRemaining)
|
||||||
updateScoreboardStrip(state.scoreboard, state.scores)
|
updateScoreboard(state.scoreboard, state.scores, {
|
||||||
|
hammer: state.hammer,
|
||||||
|
currentEnd: state.end,
|
||||||
|
})
|
||||||
},
|
},
|
||||||
showEndModal: (payload) => {
|
showEndModal: (payload) => {
|
||||||
dismissEndModal()
|
dismissEndModal()
|
||||||
@ -234,6 +224,15 @@ export function createHud(): Hud {
|
|||||||
modal.setAttribute('role', 'dialog')
|
modal.setAttribute('role', 'dialog')
|
||||||
modal.setAttribute('aria-modal', 'true')
|
modal.setAttribute('aria-modal', 'true')
|
||||||
modal.setAttribute('aria-labelledby', 'end-modal-title')
|
modal.setAttribute('aria-labelledby', 'end-modal-title')
|
||||||
|
const boardHtml = renderBaseballScoreboard(
|
||||||
|
payload.scoreboard,
|
||||||
|
scoreboardTotals(payload.scoreboard),
|
||||||
|
{
|
||||||
|
ends: ENDS,
|
||||||
|
hammer: payload.nextHammer,
|
||||||
|
currentEnd: payload.end,
|
||||||
|
},
|
||||||
|
)
|
||||||
modal.innerHTML = `
|
modal.innerHTML = `
|
||||||
<div class="end-modal-backdrop" data-dismiss="1"></div>
|
<div class="end-modal-backdrop" data-dismiss="1"></div>
|
||||||
<div class="end-modal-card">
|
<div class="end-modal-card">
|
||||||
@ -244,7 +243,7 @@ export function createHud(): Hud {
|
|||||||
<span class="end-modal-team end-modal-team--team2">Team 2 <strong>${payload.team2}</strong></span>
|
<span class="end-modal-team end-modal-team--team2">Team 2 <strong>${payload.team2}</strong></span>
|
||||||
</p>
|
</p>
|
||||||
<p class="end-modal-hammer">Next hammer: <strong>${TEAM_LABELS[payload.nextHammer]}</strong></p>
|
<p class="end-modal-hammer">Next hammer: <strong>${TEAM_LABELS[payload.nextHammer]}</strong></p>
|
||||||
<div class="end-modal-board">${renderScoreboardTable(payload.scoreboard)}</div>
|
<div class="end-modal-board">${boardHtml}</div>
|
||||||
<button type="button" class="end-modal-dismiss" data-dismiss="1">Dismiss</button>
|
<button type="button" class="end-modal-dismiss" data-dismiss="1">Dismiss</button>
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
@ -254,7 +253,6 @@ export function createHud(): Hud {
|
|||||||
})
|
})
|
||||||
document.body.appendChild(modal)
|
document.body.appendChild(modal)
|
||||||
endModalEl = modal
|
endModalEl = modal
|
||||||
endModalTimer = window.setTimeout(dismissEndModal, 5000)
|
|
||||||
},
|
},
|
||||||
showToast: (message: string) => {
|
showToast: (message: string) => {
|
||||||
const toast = document.createElement('div')
|
const toast = document.createElement('div')
|
||||||
@ -281,44 +279,63 @@ export function createVelocitySelector(
|
|||||||
container: HTMLDivElement,
|
container: HTMLDivElement,
|
||||||
onSelect: () => void,
|
onSelect: () => void,
|
||||||
): { getVelocity: () => number; setEnabled: (enabled: boolean) => void } {
|
): { getVelocity: () => number; setEnabled: (enabled: boolean) => void } {
|
||||||
const state = { weight: 5 }
|
const state = { index: indexOfLabel(7) }
|
||||||
container.innerHTML = ''
|
container.innerHTML = ''
|
||||||
|
|
||||||
const wrap = document.createElement('div')
|
const wrap = document.createElement('div')
|
||||||
wrap.className = 'velocity-control-inner'
|
wrap.className = 'velocity-control-inner'
|
||||||
|
|
||||||
const label = document.createElement('label')
|
const title = document.createElement('label')
|
||||||
label.textContent = 'Velocity'
|
title.textContent = 'Weight'
|
||||||
wrap.appendChild(label)
|
wrap.appendChild(title)
|
||||||
|
|
||||||
const slider = document.createElement('input')
|
const slider = document.createElement('input')
|
||||||
slider.type = 'range'
|
slider.type = 'range'
|
||||||
slider.min = String(MIN_SPEED)
|
slider.min = '0'
|
||||||
slider.max = String(MAX_SPEED)
|
slider.max = String(14) // 1..10 + hack/board/control/normal/peel
|
||||||
slider.step = '0.05'
|
slider.step = '1'
|
||||||
slider.value = String(weightToVelocity(state.weight))
|
slider.value = String(state.index)
|
||||||
slider.className = 'velocity-slider'
|
slider.className = 'velocity-slider'
|
||||||
|
slider.setAttribute('list', 'weight-marks')
|
||||||
|
slider.ariaLabel = 'Throw weight'
|
||||||
|
|
||||||
const datalist = document.createElement('datalist')
|
const marks = document.createElement('datalist')
|
||||||
datalist.id = 'velocity-marks'
|
marks.id = 'weight-marks'
|
||||||
for (let w = 1; w <= 10; w++) {
|
const tickLabels = [1, 5, 7, 10, 'hack', 'board', 'control', 'normal', 'peel'] as const
|
||||||
|
for (const tick of tickLabels) {
|
||||||
const opt = document.createElement('option')
|
const opt = document.createElement('option')
|
||||||
opt.value = String(weightToVelocity(w))
|
opt.value = String(indexOfLabel(tick))
|
||||||
opt.label = String(w)
|
opt.label = String(tick)
|
||||||
datalist.appendChild(opt)
|
marks.appendChild(opt)
|
||||||
}
|
}
|
||||||
slider.setAttribute('list', 'velocity-marks')
|
|
||||||
wrap.appendChild(slider)
|
const ticks = document.createElement('div')
|
||||||
wrap.appendChild(datalist)
|
ticks.className = 'velocity-ticks'
|
||||||
|
ticks.setAttribute('aria-hidden', 'true')
|
||||||
|
const named = ['hack', 'board', 'control', 'normal', 'peel'] as const
|
||||||
|
ticks.innerHTML = named
|
||||||
|
.map((name) => `<span class="velocity-tick" data-label="${name}">${name}</span>`)
|
||||||
|
.join('')
|
||||||
|
|
||||||
const readout = document.createElement('div')
|
const readout = document.createElement('div')
|
||||||
readout.className = 'velocity-readout'
|
readout.className = 'velocity-readout'
|
||||||
|
wrap.appendChild(slider)
|
||||||
|
wrap.appendChild(marks)
|
||||||
|
wrap.appendChild(ticks)
|
||||||
wrap.appendChild(readout)
|
wrap.appendChild(readout)
|
||||||
|
|
||||||
const update = () => {
|
const update = () => {
|
||||||
const v = Number(slider.value)
|
state.index = clampSpeedIndex(Number(slider.value))
|
||||||
state.weight = velocityToWeight(v)
|
const label = labelAtIndex(state.index)
|
||||||
readout.textContent = `${v.toFixed(2)} m/s · Weight ${state.weight}`
|
const v = velocityForLabel(label)
|
||||||
|
const name = formatSpeedLabel(label)
|
||||||
|
const isTakeout = typeof label === 'string'
|
||||||
|
readout.textContent = isTakeout
|
||||||
|
? `${name} · ${v.toFixed(3)} m/s`
|
||||||
|
: `${name} · ${v.toFixed(3)} m/s`
|
||||||
|
ticks.querySelectorAll<HTMLSpanElement>('.velocity-tick').forEach((el) => {
|
||||||
|
el.classList.toggle('velocity-tick--active', el.dataset.label === String(label))
|
||||||
|
})
|
||||||
onSelect()
|
onSelect()
|
||||||
}
|
}
|
||||||
slider.addEventListener('input', update)
|
slider.addEventListener('input', update)
|
||||||
@ -327,7 +344,7 @@ export function createVelocitySelector(
|
|||||||
container.appendChild(wrap)
|
container.appendChild(wrap)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getVelocity: () => Number(slider.value),
|
getVelocity: () => velocityForLabel(labelAtIndex(clampSpeedIndex(Number(slider.value)))),
|
||||||
setEnabled: (enabled) => {
|
setEnabled: (enabled) => {
|
||||||
slider.disabled = !enabled
|
slider.disabled = !enabled
|
||||||
},
|
},
|
||||||
@ -338,7 +355,7 @@ export function createCurlSelector(
|
|||||||
container: HTMLDivElement,
|
container: HTMLDivElement,
|
||||||
onSelect: (curl: number) => void,
|
onSelect: (curl: number) => void,
|
||||||
): { getSelected: () => number; setEnabled: (enabled: boolean) => void } {
|
): { getSelected: () => number; setEnabled: (enabled: boolean) => void } {
|
||||||
// Only full curl: backend curl>0 = clockwise (right), curl<0 = counter-clockwise (left).
|
// curl>0 = clockwise (drifts left when heading down-sheet); curl<0 = CCW (right).
|
||||||
// Layout L→R: CCW on the left, CW on the right. Default clockwise.
|
// Layout L→R: CCW on the left, CW on the right. Default clockwise.
|
||||||
const state = { selected: 1, enabled: true }
|
const state = { selected: 1, enabled: true }
|
||||||
const options = [
|
const options = [
|
||||||
@ -379,23 +396,3 @@ export function createCurlSelector(
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createFrictionSlider(
|
|
||||||
container: HTMLDivElement,
|
|
||||||
): { getFriction: () => number; setEnabled: (enabled: boolean) => void } {
|
|
||||||
const slider = container.querySelector<HTMLInputElement>('#friction-slider')!
|
|
||||||
const valueEl = container.querySelector<HTMLSpanElement>('#friction-value')!
|
|
||||||
|
|
||||||
const updateValue = () => {
|
|
||||||
valueEl.textContent = Number(slider.value).toFixed(1)
|
|
||||||
}
|
|
||||||
slider.addEventListener('input', updateValue)
|
|
||||||
updateValue()
|
|
||||||
|
|
||||||
return {
|
|
||||||
getFriction: () => Number(slider.value),
|
|
||||||
setEnabled: (enabled) => {
|
|
||||||
slider.disabled = !enabled
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -78,7 +78,6 @@ export function sendThrow(
|
|||||||
broomY: number,
|
broomY: number,
|
||||||
velocity: number,
|
velocity: number,
|
||||||
curl: number,
|
curl: number,
|
||||||
friction: number,
|
|
||||||
): void {
|
): void {
|
||||||
if (!socket || socket.readyState !== WebSocket.OPEN) return
|
if (!socket || socket.readyState !== WebSocket.OPEN) return
|
||||||
socket.send(
|
socket.send(
|
||||||
@ -89,7 +88,6 @@ export function sendThrow(
|
|||||||
broom_y: broomY,
|
broom_y: broomY,
|
||||||
velocity,
|
velocity,
|
||||||
curl,
|
curl,
|
||||||
friction,
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,13 +12,55 @@ export const FOUR_FT_RADIUS = 2 * FEET_TO_METERS
|
|||||||
export const EIGHT_FT_RADIUS = 4 * FEET_TO_METERS
|
export const EIGHT_FT_RADIUS = 4 * FEET_TO_METERS
|
||||||
export const TWELVE_FT_RADIUS = 6 * FEET_TO_METERS
|
export const TWELVE_FT_RADIUS = 6 * FEET_TO_METERS
|
||||||
export const HOG_LINE_Y = 21.0
|
export const HOG_LINE_Y = 21.0
|
||||||
export const BACK_LINE_Y = 42.0
|
/** Backline on outer house ring (tee + 6 ft). */
|
||||||
|
export const BACK_LINE_Y = HOUSE_CENTER.y + HOUSE_RADIUS
|
||||||
export const HACK_Y = 2.0
|
export const HACK_Y = 2.0
|
||||||
export const STONE_RADIUS = 0.15
|
export const STONE_RADIUS = 0.15
|
||||||
/** Soft guard (weight 1). Mid slider (weight 5) ≈ DRAW 2.38 m/s lands near tee. */
|
|
||||||
export const MIN_SPEED = 1.9
|
/** Feet relative to tee for weights 1..10 (front is negative). */
|
||||||
/** Heavy (weight 10). Span keeps weight 5 ≈ DRAW_VELOCITY. */
|
export const WEIGHT_STOP_OFFSET_FT = [-11, -9, -7, -5, -3, -1, 0, 1, 3, 5] as const
|
||||||
export const MAX_SPEED = 3.0
|
export const HACK_STOP_OFFSET_FT = 12
|
||||||
|
|
||||||
|
/** Calibrated m/s for weights 1..10 (open-ice stop vs tee offsets). */
|
||||||
|
export const WEIGHT_SPEEDS = [
|
||||||
|
2.2573, 2.2783, 2.2992, 2.3199, 2.3404, 2.3608, 2.3709, 2.3810, 2.4012, 2.4211,
|
||||||
|
] as const
|
||||||
|
export const HACK_SPEED = 2.4899
|
||||||
|
/** Takeouts: open-ice stop at hack+N feet (N = 6,12,18,24). */
|
||||||
|
export const BOARD_SPEED = 2.5492
|
||||||
|
export const CONTROL_SPEED = 2.6408
|
||||||
|
export const NORMAL_SPEED = 2.7448
|
||||||
|
export const PEEL_SPEED = 2.8499
|
||||||
|
export const BOARD_STOP_OFFSET_FT = HACK_STOP_OFFSET_FT + 6
|
||||||
|
export const CONTROL_STOP_OFFSET_FT = HACK_STOP_OFFSET_FT + 15
|
||||||
|
export const NORMAL_STOP_OFFSET_FT = HACK_STOP_OFFSET_FT + 25
|
||||||
|
export const PEEL_STOP_OFFSET_FT = HACK_STOP_OFFSET_FT + 35
|
||||||
|
/** Tee-line weight (category 7). */
|
||||||
|
export const DRAW_VELOCITY = WEIGHT_SPEEDS[6]
|
||||||
|
export const MIN_SPEED = WEIGHT_SPEEDS[0]
|
||||||
|
export const MAX_SPEED = WEIGHT_SPEEDS[9]
|
||||||
|
|
||||||
|
export type SpeedLabel =
|
||||||
|
| 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10
|
||||||
|
| 'hack' | 'board' | 'control' | 'normal' | 'peel'
|
||||||
|
|
||||||
|
export const SPEED_TABLE: Record<SpeedLabel, number> = {
|
||||||
|
1: WEIGHT_SPEEDS[0],
|
||||||
|
2: WEIGHT_SPEEDS[1],
|
||||||
|
3: WEIGHT_SPEEDS[2],
|
||||||
|
4: WEIGHT_SPEEDS[3],
|
||||||
|
5: WEIGHT_SPEEDS[4],
|
||||||
|
6: WEIGHT_SPEEDS[5],
|
||||||
|
7: WEIGHT_SPEEDS[6],
|
||||||
|
8: WEIGHT_SPEEDS[7],
|
||||||
|
9: WEIGHT_SPEEDS[8],
|
||||||
|
10: WEIGHT_SPEEDS[9],
|
||||||
|
hack: HACK_SPEED,
|
||||||
|
board: BOARD_SPEED,
|
||||||
|
control: CONTROL_SPEED,
|
||||||
|
normal: NORMAL_SPEED,
|
||||||
|
peel: PEEL_SPEED,
|
||||||
|
}
|
||||||
|
|
||||||
export type Team = 'team1' | 'team2'
|
export type Team = 'team1' | 'team2'
|
||||||
export type Phase = 'waiting' | 'playing' | 'simulating' | 'scoring' | 'end_complete' | 'game_complete'
|
export type Phase = 'waiting' | 'playing' | 'simulating' | 'scoring' | 'end_complete' | 'game_complete'
|
||||||
@ -57,7 +99,6 @@ export interface ClientThrowMessage {
|
|||||||
broom_y: number
|
broom_y: number
|
||||||
velocity: number
|
velocity: number
|
||||||
curl: number
|
curl: number
|
||||||
friction: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServerJoinedMessage {
|
export interface ServerJoinedMessage {
|
||||||
|
|||||||
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);
|
border-color: rgba(255, 255, 255, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.scoreboard-strip {
|
/* —— Baseball-style scoreboard matrix —— */
|
||||||
|
.scoreboard-baseball-wrap {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 4px;
|
width: 100%;
|
||||||
font-size: 11px;
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
padding: 4px 0 6px;
|
||||||
|
pointer-events: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scoreboard-baseball {
|
||||||
|
width: max-content;
|
||||||
|
max-width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
font-family: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
||||||
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
opacity: 0.9;
|
line-height: 1.25;
|
||||||
}
|
letter-spacing: 0.02em;
|
||||||
|
color: #fff;
|
||||||
.scoreboard-strip[hidden] {
|
background: rgba(0, 0, 0, 0.55);
|
||||||
display: none;
|
border: 1px solid rgba(255, 255, 255, 0.28);
|
||||||
}
|
|
||||||
|
|
||||||
.scoreboard-end {
|
|
||||||
background: rgba(255, 255, 255, 0.12);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 2px 6px;
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), 0 2px 10px rgba(0, 0, 0, 0.35);
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scoreboard-total {
|
.scoreboard-baseball th,
|
||||||
background: rgba(0, 170, 102, 0.25);
|
.scoreboard-baseball td {
|
||||||
border: 1px solid rgba(0, 170, 102, 0.45);
|
min-width: 1.35em;
|
||||||
border-radius: 8px;
|
padding: 3px 4px;
|
||||||
padding: 2px 6px;
|
text-align: center;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scoreboard-baseball thead th {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: rgba(255, 255, 255, 0.72);
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scoreboard-baseball__corner {
|
||||||
|
min-width: 3.6em;
|
||||||
|
border-left: none;
|
||||||
|
border-top: none;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scoreboard-baseball__label {
|
||||||
|
min-width: 3.6em;
|
||||||
|
text-align: left;
|
||||||
|
padding-left: 6px;
|
||||||
|
padding-right: 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: rgba(255, 255, 255, 0.92);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scoreboard-baseball__team--team1 .scoreboard-baseball__label {
|
||||||
|
color: #ff6b5c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scoreboard-baseball__team--team2 .scoreboard-baseball__label {
|
||||||
|
color: #ffd666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scoreboard-baseball__runs {
|
||||||
|
min-width: 1.6em;
|
||||||
|
font-weight: 800;
|
||||||
|
background: rgba(0, 170, 102, 0.18);
|
||||||
|
color: #7dffc0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scoreboard-baseball thead .scoreboard-baseball__runs {
|
||||||
|
color: #7dffc0;
|
||||||
|
background: rgba(0, 170, 102, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scoreboard-baseball__cell--empty {
|
||||||
|
color: rgba(255, 255, 255, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scoreboard-baseball__end--current,
|
||||||
|
.scoreboard-baseball__cell--current {
|
||||||
|
background: rgba(0, 170, 255, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scoreboard-baseball__hammer {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 3px;
|
||||||
|
padding: 0 3px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.3;
|
||||||
|
vertical-align: middle;
|
||||||
|
color: #0b1f3a;
|
||||||
|
background: #ffd666;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.end-modal-board .scoreboard-baseball {
|
||||||
|
width: 100%;
|
||||||
|
max-width: none;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.end-modal-board .scoreboard-baseball th,
|
||||||
|
.end-modal-board .scoreboard-baseball td {
|
||||||
|
padding: 5px 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* —— End-of-end modal —— */
|
/* —— End-of-end modal —— */
|
||||||
@ -430,32 +520,9 @@ html, body {
|
|||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
#friction-control {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 4px;
|
|
||||||
min-width: 100px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#friction-control label {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#friction-slider {
|
|
||||||
width: 100px;
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
#friction-value {
|
|
||||||
font-size: 12px;
|
|
||||||
min-width: 24px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
#throw-btn {
|
#throw-btn {
|
||||||
width: 80px;
|
width: 80px;
|
||||||
@ -526,23 +593,32 @@ html, body {
|
|||||||
background: rgba(0, 0, 0, 0.5);
|
background: rgba(0, 0, 0, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
#friction-control {
|
|
||||||
min-width: 0;
|
|
||||||
flex: 0 1 auto;
|
|
||||||
padding: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#friction-slider {
|
|
||||||
width: 60px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#friction-value {
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#throw-btn {
|
#throw-btn {
|
||||||
width: 56px;
|
width: 56px;
|
||||||
height: 56px;
|
height: 56px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.velocity-ticks {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 4px;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 2px;
|
||||||
|
padding-left: 42%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.velocity-tick {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
color: rgba(255, 255, 255, 0.45);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.velocity-tick--active {
|
||||||
|
color: #7dffc0;
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user