forked from eros/curltastic
feat(frontend): end-of-end modal with scoreboard
Show modal for all clients when scoreboard grows: end points, next hammer, full table. Auto-dismiss 5s; dismiss button and backdrop. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
parent
e209ad3e1f
commit
95d04f8d9e
@ -36,6 +36,7 @@ export function startGame(): void {
|
|||||||
hud.setShareLink(shareLink)
|
hud.setShareLink(shareLink)
|
||||||
|
|
||||||
const model = new GameModel()
|
const model = new GameModel()
|
||||||
|
let lastScoreboardLen = model.state.scoreboard.length
|
||||||
|
|
||||||
const stored = localStorage.getItem('curltastic-team')
|
const stored = localStorage.getItem('curltastic-team')
|
||||||
const initialTeam: Team = stored === 'team2' || stored === 'yellow' ? 'team2' : 'team1'
|
const initialTeam: Team = stored === 'team2' || stored === 'yellow' ? 'team2' : 'team1'
|
||||||
@ -55,6 +56,24 @@ export function startGame(): void {
|
|||||||
hud.update(model.state)
|
hud.update(model.state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const maybeShowEndModal = () => {
|
||||||
|
const board = model.state.scoreboard
|
||||||
|
if (board.length <= lastScoreboardLen) {
|
||||||
|
lastScoreboardLen = board.length
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const last = board[board.length - 1]
|
||||||
|
lastScoreboardLen = board.length
|
||||||
|
if (!last) return
|
||||||
|
hud.showEndModal({
|
||||||
|
end: last.end,
|
||||||
|
team1: last.team1,
|
||||||
|
team2: last.team2,
|
||||||
|
nextHammer: model.state.hammer,
|
||||||
|
scoreboard: board,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
hud.teamSelect.addEventListener('change', () => {
|
hud.teamSelect.addEventListener('change', () => {
|
||||||
localStorage.setItem('curltastic-team', hud.teamSelect.value)
|
localStorage.setItem('curltastic-team', hud.teamSelect.value)
|
||||||
updateControls()
|
updateControls()
|
||||||
@ -91,6 +110,7 @@ export function startGame(): void {
|
|||||||
},
|
},
|
||||||
onGameState: (msg) => {
|
onGameState: (msg) => {
|
||||||
model.updateGameState(msg)
|
model.updateGameState(msg)
|
||||||
|
maybeShowEndModal()
|
||||||
updateControls()
|
updateControls()
|
||||||
},
|
},
|
||||||
onTrajectories: (stones) => {
|
onTrajectories: (stones) => {
|
||||||
|
|||||||
@ -26,6 +26,13 @@ export interface Hud {
|
|||||||
stonesRemaining: number[]
|
stonesRemaining: number[]
|
||||||
scoreboard: EndScore[]
|
scoreboard: EndScore[]
|
||||||
}) => void
|
}) => void
|
||||||
|
showEndModal: (payload: {
|
||||||
|
end: number
|
||||||
|
team1: number
|
||||||
|
team2: number
|
||||||
|
nextHammer: Team
|
||||||
|
scoreboard: EndScore[]
|
||||||
|
}) => void
|
||||||
showToast: (message: string) => void
|
showToast: (message: string) => void
|
||||||
setShareLink: (link: string) => void
|
setShareLink: (link: string) => void
|
||||||
}
|
}
|
||||||
@ -66,6 +73,21 @@ function buildStoneChipsHtml(team: Team): string {
|
|||||||
return `<div class="stones-row stones-row--${team}" data-team="${team}" role="img" aria-label="${TEAM_LABELS[team]} stones remaining">${chips}</div>`
|
return `<div class="stones-row stones-row--${team}" data-team="${team}" role="img" aria-label="${TEAM_LABELS[team]} stones remaining">${chips}</div>`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderScoreboardTable(scoreboard: EndScore[]): string {
|
||||||
|
if (scoreboard.length === 0) {
|
||||||
|
return '<p class="end-modal-empty">No ends scored yet</p>'
|
||||||
|
}
|
||||||
|
const rows = scoreboard
|
||||||
|
.map(
|
||||||
|
(e) =>
|
||||||
|
`<tr><td>${e.end}</td><td>${e.team1}</td><td>${e.team2}</td><td>${TEAM_LABELS[e.hammer]}</td></tr>`,
|
||||||
|
)
|
||||||
|
.join('')
|
||||||
|
return `<table class="scoreboard-table" aria-label="Scoreboard">
|
||||||
|
<thead><tr><th>End</th><th>Team 1</th><th>Team 2</th><th>Hammer</th></tr></thead>
|
||||||
|
<tbody>${rows}</tbody>
|
||||||
|
</table>`
|
||||||
|
}
|
||||||
|
|
||||||
export function createHud(): Hud {
|
export function createHud(): Hud {
|
||||||
const root = document.createElement('div')
|
const root = document.createElement('div')
|
||||||
@ -113,6 +135,20 @@ export function createHud(): Hud {
|
|||||||
const stonesHud = root.querySelector<HTMLDivElement>('#stones-hud')!
|
const stonesHud = root.querySelector<HTMLDivElement>('#stones-hud')!
|
||||||
const scoreboardStrip = root.querySelector<HTMLDivElement>('#scoreboard-strip')!
|
const scoreboardStrip = root.querySelector<HTMLDivElement>('#scoreboard-strip')!
|
||||||
|
|
||||||
|
let endModalEl: HTMLDivElement | null = null
|
||||||
|
let endModalTimer = 0
|
||||||
|
|
||||||
|
const dismissEndModal = () => {
|
||||||
|
if (endModalTimer) {
|
||||||
|
window.clearTimeout(endModalTimer)
|
||||||
|
endModalTimer = 0
|
||||||
|
}
|
||||||
|
if (endModalEl) {
|
||||||
|
endModalEl.remove()
|
||||||
|
endModalEl = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const updateStonesRemaining = (remaining: number[]) => {
|
const updateStonesRemaining = (remaining: number[]) => {
|
||||||
const teams: Team[] = ['team1', 'team2']
|
const teams: Team[] = ['team1', 'team2']
|
||||||
for (let t = 0; t < teams.length; t++) {
|
for (let t = 0; t < teams.length; t++) {
|
||||||
@ -169,6 +205,35 @@ export function createHud(): Hud {
|
|||||||
updateStonesRemaining(state.stonesRemaining)
|
updateStonesRemaining(state.stonesRemaining)
|
||||||
updateScoreboardStrip(state.scoreboard, state.scores)
|
updateScoreboardStrip(state.scoreboard, state.scores)
|
||||||
},
|
},
|
||||||
|
showEndModal: (payload) => {
|
||||||
|
dismissEndModal()
|
||||||
|
const modal = document.createElement('div')
|
||||||
|
modal.id = 'end-modal'
|
||||||
|
modal.setAttribute('role', 'dialog')
|
||||||
|
modal.setAttribute('aria-modal', 'true')
|
||||||
|
modal.setAttribute('aria-labelledby', 'end-modal-title')
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="end-modal-backdrop" data-dismiss="1"></div>
|
||||||
|
<div class="end-modal-card">
|
||||||
|
<h2 id="end-modal-title">End ${payload.end} complete</h2>
|
||||||
|
<p class="end-modal-points">
|
||||||
|
<span class="end-modal-team end-modal-team--team1">Team 1 <strong>${payload.team1}</strong></span>
|
||||||
|
<span class="end-modal-vs">vs</span>
|
||||||
|
<span class="end-modal-team end-modal-team--team2">Team 2 <strong>${payload.team2}</strong></span>
|
||||||
|
</p>
|
||||||
|
<p class="end-modal-hammer">Next hammer: <strong>${TEAM_LABELS[payload.nextHammer]}</strong></p>
|
||||||
|
<div class="end-modal-board">${renderScoreboardTable(payload.scoreboard)}</div>
|
||||||
|
<button type="button" class="end-modal-dismiss" data-dismiss="1">Dismiss</button>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
modal.addEventListener('click', (e) => {
|
||||||
|
const t = e.target as HTMLElement
|
||||||
|
if (t.closest('[data-dismiss]')) dismissEndModal()
|
||||||
|
})
|
||||||
|
document.body.appendChild(modal)
|
||||||
|
endModalEl = modal
|
||||||
|
endModalTimer = window.setTimeout(dismissEndModal, 5000)
|
||||||
|
},
|
||||||
showToast: (message: string) => {
|
showToast: (message: string) => {
|
||||||
const toast = document.createElement('div')
|
const toast = document.createElement('div')
|
||||||
toast.id = 'toast'
|
toast.id = 'toast'
|
||||||
|
|||||||
@ -167,6 +167,130 @@ html, body {
|
|||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* —— End-of-end modal —— */
|
||||||
|
#end-modal {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 50;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.end-modal-backdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(5, 14, 28, 0.72);
|
||||||
|
backdrop-filter: blur(3px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.end-modal-card {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: min(360px, calc(100vw - 32px));
|
||||||
|
max-height: min(80vh, 520px);
|
||||||
|
overflow: auto;
|
||||||
|
padding: 20px 18px 16px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: linear-gradient(160deg, rgba(28, 74, 122, 0.96), rgba(11, 31, 58, 0.98));
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||||
|
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.12);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.end-modal-card h2 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.end-modal-points {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.end-modal-team strong {
|
||||||
|
font-size: 22px;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.end-modal-team--team1 strong {
|
||||||
|
color: #ff6b5c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.end-modal-team--team2 strong {
|
||||||
|
color: #ffd666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.end-modal-vs {
|
||||||
|
opacity: 0.55;
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.end-modal-hammer {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
opacity: 0.95;
|
||||||
|
}
|
||||||
|
|
||||||
|
.end-modal-board {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.end-modal-empty {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scoreboard-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scoreboard-table th,
|
||||||
|
.scoreboard-table td {
|
||||||
|
padding: 6px 4px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scoreboard-table th {
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
font-size: 10px;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.end-modal-dismiss {
|
||||||
|
appearance: none;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.35);
|
||||||
|
background: rgba(255, 255, 255, 0.14);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 8px 18px;
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.end-modal-dismiss:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.end-modal-dismiss:focus-visible {
|
||||||
|
outline: 2px solid #00aaff;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
#share {
|
#share {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -351,6 +475,13 @@ html, body {
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.end-modal-card {
|
||||||
|
padding: 16px 14px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.end-modal-card h2 {
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
#velocity-control {
|
#velocity-control {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user