docs: move product spec to .pm/board.yaml #3

Merged
eros merged 3 commits from swengineer/curltastic:pm-board-spec into main 2026-09-07 17:59:46 -07:00
9 changed files with 262 additions and 169 deletions
Showing only changes of commit 78b8ebeb7d - Show all commits

122
.pm/board.yaml Normal file
View File

@ -0,0 +1,122 @@
requirements:
- id: R1
title: Mobile 2D overhead curling
body: Players play curling in a mobile browser from a 2D overhead view.
- id: R2
title: Portrait sheet
body: The sheet is shown in portrait.
- id: R3
title: Two teams, unlimited clients
body: A room has two teams and an unlimited number of clients.
- id: R4
title: Team pick per device
body: Each client picks Team 1 or Team 2 on that device.
- id: R5
title: Anyone on turn may throw
body: Anyone on the team whose turn it is may throw.
- id: R6
title: Shareable room link
body: Players join a room from a shareable link.
- id: R7
title: Create room from empty link
body: If the link has no room, the client creates one and writes that room into the URL.
- id: R8
title: Ten ends, eight stones
body: A match has 10 ends. Each team has 8 stones per end.
- id: R9
title: Extra ends until a lead
body: Extra ends are played while the score is tied after 10 ends. The match ends when one team leads after an end.
- id: R10
title: Random hammer
body: Hammer is random at the start. The team without hammer throws first in the end.
- id: R11
title: Hammer after score or blank
body: The team that scores gives hammer away. A blank end keeps hammer.
- id: R12
title: Alternate throws
body: After a throw, that team has one fewer stone and the other team throws.
- id: R13
title: Score when stones are gone
body: When all throws have completed, the end is scored.
- id: R14
title: House only scores
body: Only stones touching the house can score.
- id: R15
title: Count to opponent nearest
body: The team closest to the tee scores one point for each of its stones nearer than the opponent's nearest stone.
- id: R16
title: Blank end is 0-0
body: If no stone can score, the end is 0-0.
- id: R17
title: Shared live state
body: Every client sees the current end, both scores, hammer, whose turn it is, stones remaining, stone positions, trajectories from all shots, and whether the match is over.
- id: R18
title: Winner on match end
body: When the match ends, both scores are shown with the winning team.
- id: R19
title: Hack to broom
body: A thrown stone starts at the hack and travels toward the broom. The player chooses initial speed and curl.
- id: R20
title: Clockwise is thrower's right
body: Clockwise curl moves the stone to the thrower's right.
- id: R21
title: Stone bounce, no line bounce
body: Stones bounce off other stones. Stones do not bounce back from a sideline or the backline.
- id: R22
title: Out of play rules
body: A stone is out if it touches a sideline. A stone must fully cross the backline to be out. A stone is out if it never fully crosses the hog line.
- id: R23
title: Sheet markings
body: The ice is a curling sheet with a house, hog line, sidelines, and a backline on the 12-foot ring.
- id: R24
title: Default house framing
body: The default view frames the house, sidelines, and backline above the controls.
- id: R25
title: Pan to hog
body: The player can pan up and down the sheet to the hog line.
- id: R26
title: Broom anywhere then throw
body: On turn, the player drags to place the broom anywhere on the sheet, then throws.
- id: R27
title: Discrete throw weights
body: The player sets throw weight on a discrete scale of draw weights 1 through 10, then named takeouts hack, board, control, normal, and peel.
- id: R28
title: Curl direction
body: The player sets curl clockwise or counterclockwise. Clockwise is the default.
- id: R29
title: Throw only on turn while still
body: Throw controls work only on that team's turn and only while stones are not moving.
- id: R30
title: Stones, hammer, scoreboard HUD
body: The HUD shows eight remaining-stone markers per team, a hammer marker, and a scoreboard with each end and a total.
- id: R31
title: End overlay
body: After the stones stop and the end is scored, an end-result overlay appears. The player dismisses it.
- id: R32
title: Shared motion, hide out stones
body: All clients see the same stone motion. A stone that leaves play is hidden when it leaves the sheet.
- id: R33
title: Copyable errors
body: Errors and the final result appear on the page as copyable text, not as a blocking alert.
- id: R34
title: No pinch-zoom, remember team
body: Pinch-zoom is off. The client remembers the last team choice.
choices:
- id: C1
title: Stack
body: Backend is Rust, Axum, WebSocket, Rapier2D, server-authoritative at 120 Hz. Frontend is TypeScript, Vite, Canvas2D, portrait-first touch.
- id: C2
title: Team colours
body: Team 1 is red. Team 2 is yellow.
- id: C3
title: Wire protocol
body: WebSocket JSON with snake_case tags. game_state carries totals, hammer, turn_team, per-end scoreboard, stones_remaining, and stones. Trajectories sample at 40 Hz from the 120 Hz sim. No room-full limit. No end_scored message.
- id: C4
title: Physics how
body: Ice friction is a server µ(v) table shared by linear and angular damping. Curl uses initial |ω| = 5 rot / 14 s with clockwise to the thrower's right. Stone-stone restitution is 0.9. Sidelines and backline are not colliders; out-of-play is applied after sim. Stone ids are { team, n } with n 1 through 8. Lengths use FEET_TO_METERS = 0.3048.
- id: C5
title: Weight mapping
body: Discrete UI weights map to calibrated open-ice stop speeds in SPEED_TABLE. The server takes velocity and curl, not a local friction scalar.
- id: C6
title: Out of scope
body: No accounts, persistence, anti-cheat, replay log, turn timer, or sweeping.

1
.pm/outbox.md Normal file
View File

@ -0,0 +1 @@
dirty: board.yaml

View File

@ -2,6 +2,8 @@
A multiplayer 2D curling game for mobile browser. Any number of clients can join a room, pick **Team 1** or **Team 2** freely, and throw when it is that team's turn (solo pass-and-play or remote opponents). Default palette: Team 1 red, Team 2 yellow. A multiplayer 2D curling game for mobile browser. Any number of clients can join a room, pick **Team 1** or **Team 2** freely, and throw when it is that team's turn (solo pass-and-play or remote opponents). Default palette: Team 1 red, Team 2 yellow.
Product must-haves and decided hows live in `.pm/board.yaml`.
- **Backend** — Rust, Axum, WebSocket, Rapier2D physics (server-authoritative, 120 Hz). - **Backend** — Rust, Axum, WebSocket, Rapier2D physics (server-authoritative, 120 Hz).
- **Frontend** — TypeScript, Vite, Canvas2D, portrait-first touch UI. - **Frontend** — TypeScript, Vite, Canvas2D, portrait-first touch UI.
@ -32,15 +34,15 @@ A multiplayer 2D curling game for mobile browser. Any number of clients can join
- On your team's turn, drag on solid ice to place the broom (aim is **not** limited to the house). - On your team's turn, drag on solid ice to place the broom (aim is **not** limited to the house).
- Otherwise, drag to pan (default framing shows house + sidelines; pan up toward the hog line). - Otherwise, drag to pan (default framing shows house + sidelines; pan up toward the hog line).
- Team dropdown: free switch mid-game (solo: throw for Team 1, switch, throw for Team 2). - Team dropdown: free switch mid-game (solo: throw for Team 1, switch, throw for Team 2).
- Velocity slider (release speed, m/s), curl buttons, friction scalar **0.51.5** (local; multiplies ice µ(v) table). - Weight slider: draws 110 then takeouts hack, board, control, normal, peel. Curl buttons.
- Tap **THROW**. Anyone joined as the current turn team may throw. - Tap **THROW**. Anyone joined as the current turn team may throw.
- Parallel multi-stone trajectories; rotation θ comes from physics. - Parallel multi-stone trajectories; rotation θ comes from physics.
- Skeuomorphic 2×8 stones-left HUD; end-of-end modal with scoreboard (auto ~5s + manual dismiss). - Skeuomorphic 2×8 stones-left HUD; end-of-end overlay with scoreboard (player dismisses).
### Physics (high level) ### Physics (high level)
- Pure initial **velocity** (m/s), not discrete weight. - Discrete weight labels map to calibrated release speeds (m/s) on the server.
- Ice friction µ(v) table (interpolated) × local scalar; linear and angular damping share the table. - Ice friction µ(v) table (interpolated) on the server; linear and angular damping share the table.
- Curl: initial |ω| = 5 rot / 14 s; lateral continuous model (clockwise → right). - Curl: initial |ω| = 5 rot / 14 s; lateral continuous model (clockwise → right).
- Stonestone contacts are **nearly elastic** (`STONE_RESTITUTION = 0.9`) so takeouts launch both rocks along the impact line instead of plastic-sticking. - Stonestone contacts are **nearly elastic** (`STONE_RESTITUTION = 0.9`) so takeouts launch both rocks along the impact line instead of plastic-sticking.
- Back line and sidelines are **not** colliders — touch → out of play after sim. - Back line and sidelines are **not** colliders — touch → out of play after sim.
@ -72,13 +74,8 @@ node load_test.cjs # optional concurrent rooms
Unit tests: `cd backend && cargo test`, `cd frontend && npm test`. Unit tests: `cd backend && cargo test`, `cd frontend && npm test`.
### Branches (this work)
- **`jasonlooked`** — PR-A refactor: multi-client, multi-path animation, free broom, camera, FEET_TO_METERS.
- **`pr2-features`** — PR-B features: physics rewrite, team1/2 wire, scoreboard, HUD, modal (branched from PR-A tip).
### Limitations ### Limitations
- No accounts, persistence, anti-cheat, replay log, or turn timer. - No accounts, persistence, anti-cheat, replay log, or turn timer.
- Sweeping not implemented; friction/curl not shared-room state. - Sweeping not implemented.
- Stones past back/sideline/hog rules are removed from play after simulation. - Stones past back/sideline/hog rules are removed from play after simulation.

View File

@ -1,36 +1 @@
# Requirements Product requirements live in `.pm/board.yaml`.
1. Players play curling in a mobile browser from a 2D overhead view.
2. The sheet is shown in portrait.
3. A room has two teams and an unlimited number of clients.
4. Each client picks Team 1 or Team 2 on that device.
5. Anyone on the team whose turn it is may throw.
6. Players join a room from a shareable link.
7. If the link has no room, the client creates one and writes that room into the URL.
8. A match has 10 ends. Each team has 8 stones per end.
9. Extra ends are played while the score is tied after 10 ends.
10. Hammer is random at the start. The team without hammer throws first in the end.
11. The team that scores gives hammer away. A blank end keeps hammer.
12. After a throw, that team has one fewer stone and the other team throws.
13. When all throws have completed, the end is scored.
14. Only stones touching the house can score.
15. The team closest to the tee scores one point for each of its stones nearer than the opponent's nearest stone.
16. If no stone can score, the end is 0-0.
17. Every client sees the current end, both scores, hammer, whose turn it is, stones remaining, stone positions, trajectories from all shots, and whether the match is over.
18. When the match ends, both scores are shown with a winner, or with no winner if tied.
19. A thrown stone starts at the hack and travels toward the broom. The player chooses initial speed and curl.
20. Clockwise curl moves the stone to the thrower's right.
21. Stones bounce off other stones. Stones do not bounce back from a sideline or the backline.
22. A stone is out if it touches a sideline. A stone must fully cross the backline to be out. A stone is out if it never fully crosses the hog line.
23. The ice is a curling sheet with a house, hog line, sidelines, and a backline on the 12-foot ring.
24. The default view frames the house, sidelines, and backline above the controls.
25. The player can pan up and down the sheet to the hog line.
26. On turn, the player drags to place the broom anywhere on the sheet, then throws.
27. The player sets throw weight on a 1 to 10 scale, including named weights hack, board, control, normal, and peel.
28. The player sets curl clockwise or counterclockwise. Clockwise is the default.
29. Throw controls work only on that team's turn and only while stones are not moving.
30. The HUD shows eight remaining-stone markers per team, a hammer marker, and a scoreboard with each end and a total.
31. After the stones stop and the end is scored, an end-result overlay appears. The player dismisses it.
32. All clients see the same stone motion. A stone that leaves play is hidden when it leaves the sheet.
33. Errors and the final result appear on the page as copyable text, not as a blocking alert.
34. Pinch-zoom is off. The client remembers the last team choice.

View File

@ -139,7 +139,9 @@ export class GameModel {
if (thrownId !== null) { if (thrownId !== null) {
const thrownPath = const thrownPath =
stones.find((p) => stoneIdsEqual(p.stone_id, thrownId!))?.trajectory ?? [] stones.find((p) => stoneIdsEqual(p.stone_id, thrownId!))?.trajectory ?? []
this.pathClockOffset = hogTrimStartIndex(thrownPath) const hogIdx = hogTrimStartIndex(thrownPath)
// Leave at least one segment after offset so hog start still plays.
this.pathClockOffset = Math.min(hogIdx, Math.max(0, thrownPath.length - 2))
} }
const pathMap = new Map< const pathMap = new Map<
@ -155,9 +157,7 @@ export class GameModel {
} }
this.activePaths = pathMap this.activePaths = pathMap
this.state.animating = Array.from(pathMap.values()).some( this.state.animating = Array.from(pathMap.values()).some((p) => p.path.length > 1)
(p) => p.path.length > this.pathClockOffset + 1,
)
this.animationStartTime = performance.now() this.animationStartTime = performance.now()
// Fresh throw: wait for the matching game_state. // Fresh throw: wait for the matching game_state.
this.pendingStones = [] this.pendingStones = []

View File

@ -43,6 +43,8 @@ export function startGame(): void {
scoreboard: typeof model.state.scoreboard scoreboard: typeof model.state.scoreboard
} }
let deferredEndModal: EndModalPayload | null = null let deferredEndModal: EndModalPayload | null = null
/** True after trajectories for the current throw until animation ends. */
let throwAnimPending = false
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'
@ -78,7 +80,7 @@ export function startGame(): void {
} }
const flushEndModal = () => { const flushEndModal = () => {
if (!deferredEndModal || model.state.animating) return if (!deferredEndModal || model.state.animating || throwAnimPending) return
hud.showEndModal(deferredEndModal) hud.showEndModal(deferredEndModal)
deferredEndModal = null deferredEndModal = null
} }
@ -92,6 +94,7 @@ export function startGame(): void {
const wasAnimating = model.state.animating const wasAnimating = model.state.animating
const activeStonePos = model.tick(performance.now()) const activeStonePos = model.tick(performance.now())
if (wasAnimating && !model.state.animating) { if (wasAnimating && !model.state.animating) {
throwAnimPending = false
flushEndModal() flushEndModal()
updateControls() updateControls()
} }
@ -121,14 +124,17 @@ export function startGame(): void {
onGameState: (msg) => { onGameState: (msg) => {
model.updateGameState(msg) model.updateGameState(msg)
queueEndModalIfNeeded() queueEndModalIfNeeded()
// Never flush here: game_state can arrive before/during throw anim. // Only flush if this throw is not mid-animation (or never animated).
// Modal is flushed when the trajectory animation ends (or traj is a no-op). flushEndModal()
updateControls() updateControls()
}, },
onTrajectories: (stones) => { onTrajectories: (stones) => {
model.startTrajectory(stones) model.startTrajectory(stones)
// Short / empty paths finish immediately — show end modal if already queued. // Hold end modal until playback finishes (even if sim paths were short).
throwAnimPending = model.state.animating
if (!throwAnimPending) {
flushEndModal() flushEndModal()
}
updateControls() updateControls()
}, },
onGameOver: (scores, winner) => { onGameOver: (scores, winner) => {
@ -202,6 +208,8 @@ export function startGame(): void {
throwBtn.addEventListener('click', () => { throwBtn.addEventListener('click', () => {
if (!model.isMyTurn) return if (!model.isMyTurn) return
// Hold end-of-end modal until trajectories + playback complete.
throwAnimPending = true
sendThrow( sendThrow(
hud.teamSelect.value as Team, hud.teamSelect.value as Team,
model.broom.x, model.broom.x,

View File

@ -71,6 +71,25 @@ const TEAM_LABELS: Record<Team, string> = {
team2: 'Team 2', team2: 'Team 2',
} }
/** "Team 1 stole 1!" / "Team 2 scored 4!" from the completed end row. */
function endResultHeadline(payload: {
team1: number
team2: number
scoreboard: readonly EndScore[]
}): string {
const entry = payload.scoreboard[payload.scoreboard.length - 1]
const hammer = entry?.hammer
const t1 = payload.team1
const t2 = payload.team2
if (t1 === 0 && t2 === 0) return 'Blank end!'
if (t1 > 0) {
const stole = hammer === 'team2'
return stole ? `Team 1 stole ${t1}!` : `Team 1 scored ${t1}!`
}
const stole = hammer === 'team1'
return stole ? `Team 2 stole ${t2}!` : `Team 2 scored ${t2}!`
}
function buildStoneChipsHtml(team: Team): string { function buildStoneChipsHtml(team: Team): string {
const chips = Array.from({ length: STONES_PER_TEAM }, (_, i) => { const chips = Array.from({ length: STONES_PER_TEAM }, (_, i) => {
return `<span class="stone-chip stone-chip--${team}" data-index="${i}" aria-hidden="true"></span>` return `<span class="stone-chip stone-chip--${team}" data-index="${i}" aria-hidden="true"></span>`
@ -79,31 +98,22 @@ 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"><span class="hammer-badge" title="Hammer" aria-hidden="true">🔨</span>${chips}</div>` return `<div class="stones-row stones-row--${team}" data-team="${team}" role="img" aria-label="${TEAM_LABELS[team]} stones remaining"><span class="hammer-badge" title="Hammer" aria-hidden="true">🔨</span>${chips}</div>`
} }
function scoreboardTotals(scoreboard: readonly EndScore[]): [number, number] {
let t1 = 0
let t2 = 0
for (const e of scoreboard) {
t1 += e.team1
t2 += e.team2
}
return [t1, t2]
}
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 id="hud-status-row" aria-live="polite">
<div id="share"><button type="button">Copy share link</button></div> <div id="stones-hud">
</div>
<div id="stones-hud" aria-live="polite">
${buildStoneChipsHtml('team1')} ${buildStoneChipsHtml('team1')}
${buildStoneChipsHtml('team2')} ${buildStoneChipsHtml('team2')}
</div> </div>
<div id="scoreboard-baseball" class="scoreboard-baseball-wrap" aria-live="polite"></div> <div id="scoreboard-baseball" class="scoreboard-baseball-wrap"></div>
<div class="hud-row"> </div>
<div id="end-info">End 1 · Waiting</div> <div class="hud-row" id="hud-meta-row">
<div id="share"><button type="button">Copy share link</button></div>
<div id="turn-info">Waiting</div>
<select id="team-select" aria-label="Team"> <select id="team-select" aria-label="Team">
<option value="team1">Team 1</option> <option value="team1">Team 1</option>
<option value="team2">Team 2</option> <option value="team2">Team 2</option>
@ -120,7 +130,7 @@ export function createHud(): Hud {
<div id="waiting">Waiting</div> <div id="waiting">Waiting</div>
` `
const endInfoEl = root.querySelector<HTMLDivElement>('#end-info')! const turnInfoEl = root.querySelector<HTMLDivElement>('#turn-info')!
const teamSelect = root.querySelector<HTMLSelectElement>('#team-select')! const teamSelect = root.querySelector<HTMLSelectElement>('#team-select')!
const waitingEl = root.querySelector<HTMLDivElement>('#waiting')! const waitingEl = root.querySelector<HTMLDivElement>('#waiting')!
const stonesHud = root.querySelector<HTMLDivElement>('#stones-hud')! const stonesHud = root.querySelector<HTMLDivElement>('#stones-hud')!
@ -207,9 +217,8 @@ export function createHud(): Hud {
state.phase === 'playing' state.phase === 'playing'
? `${TEAM_LABELS[state.turnTeam]}'s turn` ? `${TEAM_LABELS[state.turnTeam]}'s turn`
: state.phase.replace(/_/g, ' ') : state.phase.replace(/_/g, ' ')
endInfoEl.textContent = `End ${state.end} · ${phaseText}` turnInfoEl.textContent = phaseText
waitingEl.classList.toggle('visible', state.phase !== 'game_complete' && state.animating) waitingEl.classList.toggle('visible', state.phase !== 'game_complete' && state.animating)
// Hammer class first so stones-remaining aria can mention it.
updateHammerBadge(state.hammer) updateHammerBadge(state.hammer)
updateStonesRemaining(state.stonesRemaining) updateStonesRemaining(state.stonesRemaining)
updateScoreboard(state.scoreboard, state.scores, { updateScoreboard(state.scoreboard, state.scores, {
@ -224,26 +233,11 @@ export function createHud(): Hud {
modal.setAttribute('role', 'dialog') modal.setAttribute('role', 'dialog')
modal.setAttribute('aria-modal', 'true') modal.setAttribute('aria-modal', 'true')
modal.setAttribute('aria-labelledby', 'end-modal-title') modal.setAttribute('aria-labelledby', 'end-modal-title')
const boardHtml = renderBaseballScoreboard( const headline = endResultHeadline(payload)
payload.scoreboard,
scoreboardTotals(payload.scoreboard),
{
ends: ENDS,
hammer: payload.nextHammer,
currentEnd: payload.end,
},
)
modal.innerHTML = ` modal.innerHTML = `
<div class="end-modal-backdrop" data-dismiss="1"></div> <div class="end-modal-backdrop" data-dismiss="1"></div>
<div class="end-modal-card"> <div class="end-modal-card end-modal-card--compact">
<h2 id="end-modal-title">End ${payload.end} complete</h2> <h2 id="end-modal-title">${headline}</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">${boardHtml}</div>
<button type="button" class="end-modal-dismiss" data-dismiss="1">Dismiss</button> <button type="button" class="end-modal-dismiss" data-dismiss="1">Dismiss</button>
</div> </div>
` `
@ -285,57 +279,24 @@ export function createVelocitySelector(
const wrap = document.createElement('div') const wrap = document.createElement('div')
wrap.className = 'velocity-control-inner' wrap.className = 'velocity-control-inner'
const title = document.createElement('label')
title.textContent = 'Weight'
wrap.appendChild(title)
const slider = document.createElement('input') const slider = document.createElement('input')
slider.type = 'range' slider.type = 'range'
slider.min = '0' slider.min = '0'
slider.max = String(14) // 1..10 + hack/board/control/normal/peel slider.max = '14'
slider.step = '1' slider.step = '1'
slider.value = String(state.index) slider.value = String(state.index)
slider.className = 'velocity-slider' slider.className = 'velocity-slider'
slider.setAttribute('list', 'weight-marks')
slider.ariaLabel = 'Throw weight' slider.ariaLabel = 'Throw weight'
const marks = document.createElement('datalist')
marks.id = 'weight-marks'
const tickLabels = [1, 5, 7, 10, 'hack', 'board', 'control', 'normal', 'peel'] as const
for (const tick of tickLabels) {
const opt = document.createElement('option')
opt.value = String(indexOfLabel(tick))
opt.label = String(tick)
marks.appendChild(opt)
}
const ticks = document.createElement('div')
ticks.className = 'velocity-ticks'
ticks.setAttribute('aria-hidden', 'true')
const named = ['hack', 'board', 'control', 'normal', 'peel'] as const
ticks.innerHTML = named
.map((name) => `<span class="velocity-tick" data-label="${name}">${name}</span>`)
.join('')
const readout = document.createElement('div') const readout = document.createElement('div')
readout.className = 'velocity-readout' readout.className = 'velocity-readout'
wrap.appendChild(slider) wrap.appendChild(slider)
wrap.appendChild(marks)
wrap.appendChild(ticks)
wrap.appendChild(readout) wrap.appendChild(readout)
const update = () => { const update = () => {
state.index = clampSpeedIndex(Number(slider.value)) state.index = clampSpeedIndex(Number(slider.value))
const label = labelAtIndex(state.index) const label = labelAtIndex(state.index)
const v = velocityForLabel(label) readout.textContent = formatSpeedLabel(label)
const name = formatSpeedLabel(label)
const isTakeout = typeof label === 'string'
readout.textContent = isTakeout
? `${name} · ${v.toFixed(3)} m/s`
: `${name} · ${v.toFixed(3)} m/s`
ticks.querySelectorAll<HTMLSpanElement>('.velocity-tick').forEach((el) => {
el.classList.toggle('velocity-tick--active', el.dataset.label === String(label))
})
onSelect() onSelect()
} }
slider.addEventListener('input', update) slider.addEventListener('input', update)

View File

@ -178,9 +178,6 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer {
ctx.arc(0, 0, r, 0, Math.PI * 2) ctx.arc(0, 0, r, 0, Math.PI * 2)
ctx.fillStyle = bodyGrad ctx.fillStyle = bodyGrad
ctx.fill() ctx.fill()
ctx.strokeStyle = 'rgba(255,255,255,0.9)'
ctx.lineWidth = Math.max(1.5, scale() * 0.02)
ctx.stroke()
// Asymmetric handle: bright bar + dark toe so spin reads clearly. // Asymmetric handle: bright bar + dark toe so spin reads clearly.
ctx.fillStyle = 'rgba(255,255,255,0.92)' ctx.fillStyle = 'rgba(255,255,255,0.92)'

View File

@ -87,29 +87,57 @@ html, body {
#hud-top-group { #hud-top-group {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 6px;
} }
/* —— Stones remaining (2×8 skeuomorphic chips) —— */ #hud-status-row {
display: flex;
flex-direction: row;
align-items: stretch;
justify-content: space-between;
gap: 8px;
width: 100%;
}
#hud-meta-row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
}
#turn-info {
flex: 1;
text-align: center;
font-size: 13px;
font-weight: 600;
opacity: 0.92;
}
/* —— Stones remaining (2×8 chips) —— */
#stones-hud { #stones-hud {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 6px; justify-content: center;
padding: 6px 10px; gap: 4px;
margin: 0 auto 2px; flex: 1 1 50%;
min-width: 0;
padding: 4px 6px;
margin: 0;
background: rgba(0, 0, 0, 0.35); background: rgba(0, 0, 0, 0.35);
border: 1px solid rgba(255, 255, 255, 0.18); border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 14px; border-radius: 10px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), 0 4px 12px rgba(0, 0, 0, 0.25); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), 0 2px 8px rgba(0, 0, 0, 0.25);
pointer-events: none; pointer-events: none;
} }
.stones-row { .stones-row {
display: grid; display: grid;
/* Fixed hammer column so both team chips align vertically under each other. */ grid-template-columns: 12px repeat(8, 12px);
grid-template-columns: 18px repeat(8, 18px); column-gap: 3px;
column-gap: 5px;
align-items: center; align-items: center;
justify-items: center; justify-items: center;
width: max-content; width: max-content;
@ -117,12 +145,12 @@ html, body {
.hammer-badge { .hammer-badge {
grid-column: 1; grid-column: 1;
width: 18px; width: 12px;
height: 18px; height: 12px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 14px; font-size: 10px;
line-height: 1; line-height: 1;
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.55)); filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.55));
/* Keep layout space when the other team has hammer (no collapse / shift). */ /* Keep layout space when the other team has hammer (no collapse / shift). */
@ -134,14 +162,14 @@ html, body {
} }
.stone-chip { .stone-chip {
width: 18px; width: 12px;
height: 18px; height: 12px;
border-radius: 50%; border-radius: 50%;
border: 1.5px solid rgba(255, 255, 255, 0.85); border: none;
box-shadow: box-shadow:
inset 0 2px 3px rgba(255, 255, 255, 0.45), inset 0 1px 2px rgba(255, 255, 255, 0.35),
inset 0 -2px 3px rgba(0, 0, 0, 0.35), inset 0 -1px 2px rgba(0, 0, 0, 0.35),
0 1px 2px rgba(0, 0, 0, 0.4); 0 1px 2px rgba(0, 0, 0, 0.35);
transition: opacity 0.2s ease, transform 0.2s ease, filter 0.2s ease; transition: opacity 0.2s ease, transform 0.2s ease, filter 0.2s ease;
} }
@ -158,19 +186,19 @@ html, body {
transform: scale(0.72); transform: scale(0.72);
filter: grayscale(0.6); filter: grayscale(0.6);
box-shadow: none; box-shadow: none;
border-color: rgba(255, 255, 255, 0.25);
} }
/* —— Baseball-style scoreboard matrix —— */ /* —— Baseball-style scoreboard matrix —— */
.scoreboard-baseball-wrap { .scoreboard-baseball-wrap {
display: flex; display: flex;
justify-content: center; justify-content: center;
width: 100%; align-items: center;
flex: 1 1 50%;
min-width: 0;
overflow-x: auto; overflow-x: auto;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
padding: 4px 0 6px; padding: 0;
pointer-events: none; pointer-events: none;
flex-shrink: 0;
} }
.scoreboard-baseball { .scoreboard-baseball {
@ -179,47 +207,49 @@ html, body {
border-collapse: collapse; border-collapse: collapse;
table-layout: fixed; table-layout: fixed;
font-family: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; font-family: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
font-size: 12px; font-size: 9px;
font-weight: 600; font-weight: 600;
line-height: 1.25; line-height: 1.15;
letter-spacing: 0.02em; letter-spacing: 0.02em;
color: #fff; color: #fff;
background: rgba(0, 0, 0, 0.55); background: rgba(0, 0, 0, 0.55);
border: 1px solid rgba(255, 255, 255, 0.28); border: 1px solid rgba(255, 255, 255, 0.28);
border-radius: 8px; border-radius: 6px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), 0 2px 10px rgba(0, 0, 0, 0.35); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), 0 2px 8px rgba(0, 0, 0, 0.3);
overflow: hidden; overflow: hidden;
transform: scale(0.92);
transform-origin: center center;
} }
.scoreboard-baseball th, .scoreboard-baseball th,
.scoreboard-baseball td { .scoreboard-baseball td {
min-width: 1.35em; min-width: 1.1em;
padding: 3px 4px; padding: 2px 3px;
text-align: center; text-align: center;
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.1);
white-space: nowrap; white-space: nowrap;
} }
.scoreboard-baseball thead th { .scoreboard-baseball thead th {
font-size: 10px; font-size: 8px;
font-weight: 700; font-weight: 700;
color: rgba(255, 255, 255, 0.72); color: rgba(255, 255, 255, 0.72);
background: rgba(255, 255, 255, 0.06); background: rgba(255, 255, 255, 0.06);
} }
.scoreboard-baseball__corner { .scoreboard-baseball__corner {
min-width: 3.6em; min-width: 2.6em;
border-left: none; border-left: none;
border-top: none; border-top: none;
background: transparent; background: transparent;
} }
.scoreboard-baseball__label { .scoreboard-baseball__label {
min-width: 3.6em; min-width: 2.6em;
text-align: left; text-align: left;
padding-left: 6px; padding-left: 4px;
padding-right: 6px; padding-right: 4px;
font-size: 10px; font-size: 8px;
font-weight: 700; font-weight: 700;
color: rgba(255, 255, 255, 0.92); color: rgba(255, 255, 255, 0.92);
background: rgba(255, 255, 255, 0.04); background: rgba(255, 255, 255, 0.04);
@ -410,7 +440,7 @@ html, body {
width: 100%; width: 100%;
} }
#share-row { #share-row, #hud-meta-row #share {
justify-content: center; justify-content: center;
margin-bottom: 4px; margin-bottom: 4px;
} }
@ -468,7 +498,7 @@ html, body {
width: 100%; width: 100%;
} }
#velocity-control label { #velocity-control label { display: none;
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
@ -622,3 +652,15 @@ html, body {
.velocity-tick--active { .velocity-tick--active {
color: #7dffc0; color: #7dffc0;
} }
.end-modal-card--compact {
max-width: min(360px, 92vw);
text-align: center;
padding: 20px 22px;
}
.end-modal-card--compact h2 {
margin: 0 0 16px;
font-size: 22px;
}