diff --git a/.pm/board.yaml b/.pm/board.yaml new file mode 100644 index 0000000..60a89be --- /dev/null +++ b/.pm/board.yaml @@ -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. diff --git a/.pm/outbox.md b/.pm/outbox.md new file mode 100644 index 0000000..8ca40f6 --- /dev/null +++ b/.pm/outbox.md @@ -0,0 +1 @@ +dirty: board.yaml diff --git a/README.md b/README.md index 562b62e..30d2b26 100644 --- a/README.md +++ b/README.md @@ -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. +Product must-haves and decided hows live in `.pm/board.yaml`. + - **Backend** — Rust, Axum, WebSocket, Rapier2D physics (server-authoritative, 120 Hz). - **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). - 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). -- Velocity slider (release speed, m/s), curl buttons, friction scalar **0.5–1.5** (local; multiplies ice µ(v) table). +- Weight slider: draws 1–10 then takeouts hack, board, control, normal, peel. Curl buttons. - Tap **THROW**. Anyone joined as the current turn team may throw. - 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) -- Pure initial **velocity** (m/s), not discrete weight. -- Ice friction µ(v) table (interpolated) × local scalar; linear and angular damping share the table. +- Discrete weight labels map to calibrated release speeds (m/s) on the server. +- 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). - Stone–stone 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. @@ -72,13 +74,8 @@ node load_test.cjs # optional concurrent rooms 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 - 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. diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 873ac28..75fe000 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -1,36 +1 @@ -# Requirements - -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. +Product requirements live in `.pm/board.yaml`. diff --git a/frontend/src/game-model.ts b/frontend/src/game-model.ts index b31f300..6117023 100644 --- a/frontend/src/game-model.ts +++ b/frontend/src/game-model.ts @@ -139,7 +139,9 @@ export class GameModel { if (thrownId !== null) { const thrownPath = 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< @@ -155,9 +157,7 @@ export class GameModel { } this.activePaths = pathMap - this.state.animating = Array.from(pathMap.values()).some( - (p) => p.path.length > this.pathClockOffset + 1, - ) + this.state.animating = Array.from(pathMap.values()).some((p) => p.path.length > 1) this.animationStartTime = performance.now() // Fresh throw: wait for the matching game_state. this.pendingStones = [] diff --git a/frontend/src/game.ts b/frontend/src/game.ts index 1eeed2c..1cd3d77 100644 --- a/frontend/src/game.ts +++ b/frontend/src/game.ts @@ -43,6 +43,8 @@ export function startGame(): void { scoreboard: typeof model.state.scoreboard } 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 initialTeam: Team = stored === 'team2' || stored === 'yellow' ? 'team2' : 'team1' @@ -78,7 +80,7 @@ export function startGame(): void { } const flushEndModal = () => { - if (!deferredEndModal || model.state.animating) return + if (!deferredEndModal || model.state.animating || throwAnimPending) return hud.showEndModal(deferredEndModal) deferredEndModal = null } @@ -92,6 +94,7 @@ export function startGame(): void { const wasAnimating = model.state.animating const activeStonePos = model.tick(performance.now()) if (wasAnimating && !model.state.animating) { + throwAnimPending = false flushEndModal() updateControls() } @@ -121,14 +124,17 @@ export function startGame(): void { onGameState: (msg) => { model.updateGameState(msg) queueEndModalIfNeeded() - // Never flush here: game_state can arrive before/during throw anim. - // Modal is flushed when the trajectory animation ends (or traj is a no-op). + // Only flush if this throw is not mid-animation (or never animated). + flushEndModal() updateControls() }, onTrajectories: (stones) => { model.startTrajectory(stones) - // Short / empty paths finish immediately — show end modal if already queued. - flushEndModal() + // Hold end modal until playback finishes (even if sim paths were short). + throwAnimPending = model.state.animating + if (!throwAnimPending) { + flushEndModal() + } updateControls() }, onGameOver: (scores, winner) => { @@ -202,6 +208,8 @@ export function startGame(): void { throwBtn.addEventListener('click', () => { if (!model.isMyTurn) return + // Hold end-of-end modal until trajectories + playback complete. + throwAnimPending = true sendThrow( hud.teamSelect.value as Team, model.broom.x, diff --git a/frontend/src/hud.ts b/frontend/src/hud.ts index 0518c37..15b2d67 100644 --- a/frontend/src/hud.ts +++ b/frontend/src/hud.ts @@ -71,6 +71,25 @@ const TEAM_LABELS: Record = { 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 { const chips = Array.from({ length: STONES_PER_TEAM }, (_, i) => { return `` @@ -79,31 +98,22 @@ function buildStoneChipsHtml(team: Team): string { return `` } -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 { const root = document.createElement('div') root.id = 'hud' root.innerHTML = `
-
+
+
+ ${buildStoneChipsHtml('team1')} + ${buildStoneChipsHtml('team2')} +
+
+
+
-
-
- ${buildStoneChipsHtml('team1')} - ${buildStoneChipsHtml('team2')} -
-
-
-
End 1 · Waiting
+
Waiting