import { MAX_SPEED, MIN_SPEED, STONES_PER_TEAM, type EndScore, type Phase, type Team, } from './protocol' import { velocityToWeight, weightToVelocity } from './game-helpers' export interface Hud { root: HTMLDivElement teamSelect: HTMLSelectElement velocityControl: HTMLDivElement curlSelector: HTMLDivElement frictionControl: HTMLDivElement throwButton: HTMLButtonElement setTeam: (team: Team) => void update: (state: { phase: Phase end: number scores: number[] hammer: Team turnTeam: Team animating: boolean stonesRemaining: number[] scoreboard: EndScore[] }) => void showToast: (message: string) => void setShareLink: (link: string) => void } function copyText(text: string): Promise { if (navigator.clipboard && window.isSecureContext) { return navigator.clipboard.writeText(text) } return new Promise((resolve, reject) => { const ta = document.createElement('textarea') ta.value = text ta.style.position = 'fixed' ta.style.opacity = '0' document.body.appendChild(ta) ta.focus() ta.select() try { const ok = document.execCommand('copy') document.body.removeChild(ta) if (ok) resolve() else reject(new Error('execCommand copy failed')) } catch (e) { document.body.removeChild(ta) reject(e) } }) } const TEAM_LABELS: Record = { team1: 'Team 1', team2: 'Team 2', } function buildStoneChipsHtml(team: Team): string { const chips = Array.from({ length: STONES_PER_TEAM }, (_, i) => { return `` }).join('') return `` } export function createHud(): Hud { const root = document.createElement('div') root.id = 'hud' root.innerHTML = `
${buildStoneChipsHtml('team1')} ${buildStoneChipsHtml('team2')}
Team 1 0 - Team 2 0
End 1 · Waiting
Hammer: -
1.0
Waiting
` const scoreEl = root.querySelector('#score')! const endInfoEl = root.querySelector('#end-info')! const teamSelect = root.querySelector('#team-select')! const hammerEl = root.querySelector('#hammer')! const waitingEl = root.querySelector('#waiting')! const stonesHud = root.querySelector('#stones-hud')! const scoreboardStrip = root.querySelector('#scoreboard-strip')! const updateStonesRemaining = (remaining: number[]) => { const teams: Team[] = ['team1', 'team2'] for (let t = 0; t < teams.length; t++) { const team = teams[t] const left = Math.max(0, Math.min(STONES_PER_TEAM, remaining[t] ?? STONES_PER_TEAM)) // Thrown stones remove leftmost chips: chip i is remaining when i >= (8 - left). const firstRemaining = STONES_PER_TEAM - left const row = stonesHud.querySelector(`.stones-row--${team}`) if (!row) continue row.setAttribute('aria-label', `${TEAM_LABELS[team]} ${left} stones remaining`) row.querySelectorAll('.stone-chip').forEach((chip) => { const index = Number(chip.dataset.index) const isRemaining = index >= firstRemaining chip.classList.toggle('stone-chip--gone', !isRemaining) chip.classList.toggle('stone-chip--remaining', isRemaining) }) } } const updateScoreboardStrip = (scoreboard: EndScore[], totals: number[]) => { if (scoreboard.length === 0) { scoreboardStrip.textContent = '' scoreboardStrip.hidden = true return } scoreboardStrip.hidden = false const cells = scoreboard .map((e) => `${e.team1}-${e.team2}`) .join('') scoreboardStrip.innerHTML = `${cells}Σ ${totals[0] ?? 0}-${totals[1] ?? 0}` } updateStonesRemaining([STONES_PER_TEAM, STONES_PER_TEAM]) return { root, teamSelect, velocityControl: root.querySelector('#velocity-control')!, curlSelector: root.querySelector('#curl-selector')!, frictionControl: root.querySelector('#friction-control')!, throwButton: root.querySelector('#throw-btn')!, setTeam: (team) => { teamSelect.value = team }, update: (state) => { scoreEl.textContent = `Team 1 ${state.scores[0] ?? 0} - Team 2 ${state.scores[1] ?? 0}` const phaseText = state.phase === 'playing' ? `${TEAM_LABELS[state.turnTeam]}'s turn` : state.phase.replace(/_/g, ' ') endInfoEl.textContent = `End ${state.end} · ${phaseText}` hammerEl.textContent = `Hammer: ${TEAM_LABELS[state.hammer]}` waitingEl.classList.toggle('visible', state.phase !== 'game_complete' && state.animating) updateStonesRemaining(state.stonesRemaining) updateScoreboardStrip(state.scoreboard, state.scores) }, showToast: (message: string) => { const toast = document.createElement('div') toast.id = 'toast' toast.textContent = message document.body.appendChild(toast) toast.style.display = 'block' window.setTimeout(() => toast.remove(), 3000) }, setShareLink: (link: string) => { const btn = root.querySelector('#share button')! btn.onclick = () => { copyText(link) .then(() => (btn.textContent = 'Copied!')) .catch(() => (btn.textContent = 'Copy failed')) window.setTimeout(() => (btn.textContent = 'Copy share link'), 2500) } btn.textContent = 'Copy share link' }, } } export function createVelocitySelector( container: HTMLDivElement, onSelect: () => void, ): { getVelocity: () => number; setEnabled: (enabled: boolean) => void } { const state = { weight: 5 } container.innerHTML = '' const wrap = document.createElement('div') wrap.className = 'velocity-control-inner' const label = document.createElement('label') label.textContent = 'Velocity' wrap.appendChild(label) 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.className = 'velocity-slider' const datalist = document.createElement('datalist') datalist.id = 'velocity-marks' for (let w = 1; w <= 10; w++) { const opt = document.createElement('option') opt.value = String(weightToVelocity(w)) opt.label = String(w) datalist.appendChild(opt) } slider.setAttribute('list', 'velocity-marks') wrap.appendChild(slider) wrap.appendChild(datalist) const readout = document.createElement('div') readout.className = 'velocity-readout' wrap.appendChild(readout) const update = () => { const v = Number(slider.value) state.weight = velocityToWeight(v) readout.textContent = `${v.toFixed(2)} m/s · Weight ${state.weight}` onSelect() } slider.addEventListener('input', update) update() container.appendChild(wrap) return { getVelocity: () => Number(slider.value), setEnabled: (enabled) => { slider.disabled = !enabled }, } } export function createCurlSelector( container: HTMLDivElement, onSelect: (curl: number) => void, ): { getSelected: () => number; setEnabled: (enabled: boolean) => void } { const state = { selected: 1, enabled: true } const options = [ { value: -1, label: '↷', ariaLabel: 'Left curl' }, { value: 1, label: '↶', ariaLabel: 'Right curl' }, ] container.innerHTML = '' for (const opt of options) { const btn = document.createElement('button') btn.className = 'curl-btn' btn.textContent = opt.label btn.ariaLabel = opt.ariaLabel btn.dataset.curl = String(opt.value) btn.addEventListener('click', () => { if (!state.enabled) return state.selected = opt.value updateSelection() onSelect(opt.value) }) container.appendChild(btn) } const updateSelection = () => { container.querySelectorAll('.curl-btn').forEach((btn) => { btn.classList.toggle('selected', Number(btn.dataset.curl) === state.selected) }) } updateSelection() return { getSelected: () => state.selected, setEnabled: (enabled) => { state.enabled = enabled container.querySelectorAll('.curl-btn').forEach((btn) => { btn.disabled = !enabled }) }, } } export function createFrictionSlider( container: HTMLDivElement, ): { getFriction: () => number; setEnabled: (enabled: boolean) => void } { const slider = container.querySelector('#friction-slider')! const valueEl = container.querySelector('#friction-value')! const updateValue = () => { valueEl.textContent = Number(slider.value).toFixed(1) } slider.addEventListener('input', updateValue) updateValue() return { getFriction: () => Number(slider.value), setEnabled: (enabled) => { slider.disabled = !enabled }, } }