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>
252 lines
7.7 KiB
TypeScript
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
|
|
},
|
|
}
|
|
}
|