From e209ad3e1ff672db486292e8aac7d96a90a3e073 Mon Sep 17 00:00:00 2001 From: Jason Dekarske Date: Fri, 10 Jul 2026 23:42:19 -0700 Subject: [PATCH] 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 --- frontend/src/hud.ts | 66 ++++++++++++++++++++++++++++-- frontend/src/style.css | 93 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+), 3 deletions(-) diff --git a/frontend/src/hud.ts b/frontend/src/hud.ts index 86a0288..ae52990 100644 --- a/frontend/src/hud.ts +++ b/frontend/src/hud.ts @@ -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 = { 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
@@ -67,6 +88,7 @@ export function createHud(): Hud {
Hammer: -
+
@@ -77,7 +99,7 @@ export function createHud(): Hud { 1.0
- +
Waiting
@@ -88,6 +110,42 @@ export function createHud(): Hud { 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, @@ -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') diff --git a/frontend/src/style.css b/frontend/src/style.css index 66ceb15..4b5033c 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -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;