feat(frontend): 2x8 stones-remaining HUD

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>
This commit is contained in:
Jason Dekarske 2026-07-10 23:42:19 -07:00
parent 162d6a7e59
commit e209ad3e1f
2 changed files with 156 additions and 3 deletions

View File

@ -1,4 +1,11 @@
import { MAX_SPEED, MIN_SPEED, type Phase, type Team } from './protocol'
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 {
@ -16,6 +23,8 @@ export interface Hud {
hammer: Team
turnTeam: Team
animating: boolean
stonesRemaining: number[]
scoreboard: EndScore[]
}) => void
showToast: (message: string) => void
setShareLink: (link: string) => void
@ -50,13 +59,25 @@ const TEAM_LABELS: Record<Team, string> = {
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>Copy share link</button></div>
<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>
@ -67,6 +88,7 @@ export function createHud(): Hud {
</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>
@ -77,7 +99,7 @@ export function createHud(): Hud {
<span id="friction-value">1.0</span>
</div>
<div>
<button id="throw-btn" disabled>THROW</button>
<button id="throw-btn" type="button" disabled>THROW</button>
</div>
</div>
<div id="waiting">Waiting</div>
@ -88,6 +110,42 @@ export function createHud(): Hud {
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,
@ -108,6 +166,8 @@ export function createHud(): Hud {
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')

View File

@ -90,6 +90,84 @@ html, body {
gap: 4px;
}
/* —— Stones remaining (2×8 skeuomorphic chips) —— */
#stones-hud {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 6px 10px;
margin: 0 auto 2px;
background: rgba(0, 0, 0, 0.35);
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 14px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), 0 4px 12px rgba(0, 0, 0, 0.25);
pointer-events: none;
}
.stones-row {
display: flex;
gap: 5px;
align-items: center;
}
.stone-chip {
width: 18px;
height: 18px;
border-radius: 50%;
border: 1.5px solid rgba(255, 255, 255, 0.85);
box-shadow:
inset 0 2px 3px rgba(255, 255, 255, 0.45),
inset 0 -2px 3px rgba(0, 0, 0, 0.35),
0 1px 2px rgba(0, 0, 0, 0.4);
transition: opacity 0.2s ease, transform 0.2s ease, filter 0.2s ease;
}
.stone-chip--team1 {
background: radial-gradient(circle at 35% 30%, #ff6b5c 0%, #d93025 55%, #8b1a12 100%);
}
.stone-chip--team2 {
background: radial-gradient(circle at 35% 30%, #ffd666 0%, #f9ab00 55%, #a66d00 100%);
}
.stone-chip--gone {
opacity: 0.18;
transform: scale(0.72);
filter: grayscale(0.6);
box-shadow: none;
border-color: rgba(255, 255, 255, 0.25);
}
.scoreboard-strip {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 4px;
font-size: 11px;
font-weight: 600;
opacity: 0.9;
}
.scoreboard-strip[hidden] {
display: none;
}
.scoreboard-end {
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
padding: 2px 6px;
}
.scoreboard-total {
background: rgba(0, 170, 102, 0.25);
border: 1px solid rgba(0, 170, 102, 0.45);
border-radius: 8px;
padding: 2px 6px;
}
#share {
display: flex;
justify-content: center;
@ -259,6 +337,21 @@ html, body {
gap: 6px;
}
.stone-chip {
width: 14px;
height: 14px;
}
.stones-row {
gap: 3px;
}
#stones-hud {
padding: 4px 8px;
gap: 4px;
}
#velocity-control {
min-width: 0;
flex: 1 1 auto;