Skeuomorphic team1/team2 stone chips near top of HUD; deplete left-to-right from stones_remaining. Score strip shows end totals. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
312 lines
10 KiB
TypeScript
312 lines
10 KiB
TypeScript
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<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',
|
|
}
|
|
|
|
function buildStoneChipsHtml(team: Team): string {
|
|
const chips = Array.from({ length: STONES_PER_TEAM }, (_, i) => {
|
|
return `<span class="stone-chip stone-chip--${team}" data-index="${i}" aria-hidden="true"></span>`
|
|
}).join('')
|
|
return `<div class="stones-row stones-row--${team}" data-team="${team}" role="img" aria-label="${TEAM_LABELS[team]} stones remaining">${chips}</div>`
|
|
}
|
|
|
|
|
|
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 type="button">Copy share link</button></div>
|
|
</div>
|
|
<div id="stones-hud" aria-live="polite">
|
|
${buildStoneChipsHtml('team1')}
|
|
${buildStoneChipsHtml('team2')}
|
|
</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 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>
|
|
</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')!
|
|
const stonesHud = root.querySelector<HTMLDivElement>('#stones-hud')!
|
|
const scoreboardStrip = root.querySelector<HTMLDivElement>('#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<HTMLDivElement>(`.stones-row--${team}`)
|
|
if (!row) continue
|
|
row.setAttribute('aria-label', `${TEAM_LABELS[team]} ${left} stones remaining`)
|
|
row.querySelectorAll<HTMLSpanElement>('.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) => `<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>`
|
|
}
|
|
|
|
updateStonesRemaining([STONES_PER_TEAM, STONES_PER_TEAM])
|
|
|
|
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)
|
|
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<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
|
|
},
|
|
}
|
|
}
|