Jason Dekarske 162d6a7e59 feat(frontend): consume trajectories+scoreboard protocol
Mirror team1/team2 StoneId wire, velocity throws, trajectories animation,
scoreboard/stones_remaining state, HUD labels, and team1 red / team2 yellow palette.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-10 23:39:19 -07:00

252 lines
7.7 KiB
TypeScript

import { MAX_SPEED, MIN_SPEED, 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
}) => void
showToast: (message: string) => void
setShareLink: (link: string) => void
}
function copyText(text: string): Promise<void> {
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<Team, string> = {
team1: 'Team 1',
team2: 'Team 2',
}
export function createHud(): Hud {
const root = document.createElement('div')
root.id = 'hud'
root.innerHTML = `
<div id="hud-top-group">
<div class="hud-row" id="share-row">
<div id="share"><button>Copy share link</button></div>
</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 id="hammer">Hammer: -</div>
</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" disabled>THROW</button>
</div>
</div>
<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 hammerEl = root.querySelector<HTMLDivElement>('#hammer')!
const waitingEl = root.querySelector<HTMLDivElement>('#waiting')!
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`
: 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)
},
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<HTMLButtonElement>('#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<HTMLButtonElement>('.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<HTMLButtonElement>('.curl-btn').forEach((btn) => {
btn.disabled = !enabled
})
},
}
}
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
},
}
}