pr2-features #2
@ -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'
|
import { velocityToWeight, weightToVelocity } from './game-helpers'
|
||||||
|
|
||||||
export interface Hud {
|
export interface Hud {
|
||||||
@ -16,6 +23,8 @@ export interface Hud {
|
|||||||
hammer: Team
|
hammer: Team
|
||||||
turnTeam: Team
|
turnTeam: Team
|
||||||
animating: boolean
|
animating: boolean
|
||||||
|
stonesRemaining: number[]
|
||||||
|
scoreboard: EndScore[]
|
||||||
}) => void
|
}) => void
|
||||||
showToast: (message: string) => void
|
showToast: (message: string) => void
|
||||||
setShareLink: (link: string) => void
|
setShareLink: (link: string) => void
|
||||||
@ -50,13 +59,25 @@ const TEAM_LABELS: Record<Team, string> = {
|
|||||||
team2: 'Team 2',
|
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 {
|
export function createHud(): Hud {
|
||||||
const root = document.createElement('div')
|
const root = document.createElement('div')
|
||||||
root.id = 'hud'
|
root.id = 'hud'
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
<div id="hud-top-group">
|
<div id="hud-top-group">
|
||||||
<div class="hud-row" id="share-row">
|
<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>
|
||||||
<div class="hud-row">
|
<div class="hud-row">
|
||||||
<div id="score">Team 1 0 - Team 2 0</div>
|
<div id="score">Team 1 0 - Team 2 0</div>
|
||||||
@ -67,6 +88,7 @@ export function createHud(): Hud {
|
|||||||
</select>
|
</select>
|
||||||
<div id="hammer">Hammer: -</div>
|
<div id="hammer">Hammer: -</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="scoreboard-strip" class="scoreboard-strip" aria-label="End scores"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="hud-row" style="align-items:flex-end;">
|
<div class="hud-row" style="align-items:flex-end;">
|
||||||
<div id="velocity-control"></div>
|
<div id="velocity-control"></div>
|
||||||
@ -77,7 +99,7 @@ export function createHud(): Hud {
|
|||||||
<span id="friction-value">1.0</span>
|
<span id="friction-value">1.0</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<button id="throw-btn" disabled>THROW</button>
|
<button id="throw-btn" type="button" disabled>THROW</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="waiting">Waiting</div>
|
<div id="waiting">Waiting</div>
|
||||||
@ -88,6 +110,42 @@ export function createHud(): Hud {
|
|||||||
const teamSelect = root.querySelector<HTMLSelectElement>('#team-select')!
|
const teamSelect = root.querySelector<HTMLSelectElement>('#team-select')!
|
||||||
const hammerEl = root.querySelector<HTMLDivElement>('#hammer')!
|
const hammerEl = root.querySelector<HTMLDivElement>('#hammer')!
|
||||||
const waitingEl = root.querySelector<HTMLDivElement>('#waiting')!
|
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 {
|
return {
|
||||||
root,
|
root,
|
||||||
@ -108,6 +166,8 @@ export function createHud(): Hud {
|
|||||||
endInfoEl.textContent = `End ${state.end} · ${phaseText}`
|
endInfoEl.textContent = `End ${state.end} · ${phaseText}`
|
||||||
hammerEl.textContent = `Hammer: ${TEAM_LABELS[state.hammer]}`
|
hammerEl.textContent = `Hammer: ${TEAM_LABELS[state.hammer]}`
|
||||||
waitingEl.classList.toggle('visible', state.phase !== 'game_complete' && state.animating)
|
waitingEl.classList.toggle('visible', state.phase !== 'game_complete' && state.animating)
|
||||||
|
updateStonesRemaining(state.stonesRemaining)
|
||||||
|
updateScoreboardStrip(state.scoreboard, state.scores)
|
||||||
},
|
},
|
||||||
showToast: (message: string) => {
|
showToast: (message: string) => {
|
||||||
const toast = document.createElement('div')
|
const toast = document.createElement('div')
|
||||||
|
|||||||
@ -90,6 +90,84 @@ html, body {
|
|||||||
gap: 4px;
|
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 {
|
#share {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@ -259,6 +337,21 @@ html, body {
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stone-chip {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stones-row {
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#stones-hud {
|
||||||
|
padding: 4px 8px;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
#velocity-control {
|
#velocity-control {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user