diff --git a/frontend/src/game-model.test.ts b/frontend/src/game-model.test.ts index f290b05..95ca8dd 100644 --- a/frontend/src/game-model.test.ts +++ b/frontend/src/game-model.test.ts @@ -1,14 +1,17 @@ import { describe, expect, it } from 'vitest' import { GameModel } from './game-model' -import type { ServerStoneTrajectory, StoneState } from './protocol' +import type { StoneId, StonePath, StoneState, Team } from './protocol' import { SAMPLE_RATE_HZ } from './protocol' +function sid(team: Team, n: number): StoneId { + return { team, n } +} + function stone(partial: Partial & Pick): StoneState { return { x: 0, y: 30, rotation: 0, - active: true, ...partial, } } @@ -20,11 +23,27 @@ function pathSamples( return points.map((p) => [p.x, p.y, p.theta ?? 0]) } +function stonePath( + stone_id: StoneId, + team: Team, + trajectory: [number, number, number][], +): StonePath { + return { + stone_id, + rotation: trajectory[trajectory.length - 1]?.[2] ?? 0, + team, + trajectory, + } +} + describe('GameModel multi-path trajectory animation', () => { it('returns two DrawableStones in parallel mid-trajectory', () => { const model = new GameModel() - model.state.stones = [stone({ id: 1, team: 'red' }), stone({ id: 2, team: 'yellow' })] - model.state.turnTeam = 'red' + model.state.stones = [ + stone({ id: sid('team1', 1), team: 'team1' }), + stone({ id: sid('team2', 1), team: 'team2' }), + ] + model.state.turnTeam = 'team1' // 21 samples → duration 20/40 = 0.5s; mid at 0.25s is sample 10 → y=11 const n = 21 @@ -35,9 +54,9 @@ describe('GameModel multi-path trajectory animation', () => { Array.from({ length: n }, (_, i) => ({ x: 1, y: 10 + i * 0.1, theta: 0 })), ) - const paths: ServerStoneTrajectory[] = [ - { stone_id: 1, path: path1 }, - { stone_id: 2, path: path2 }, + const paths: StonePath[] = [ + stonePath(sid('team1', 1), 'team1', path1), + stonePath(sid('team2', 1), 'team2', path2), ] model.startTrajectory(paths) @@ -47,7 +66,7 @@ describe('GameModel multi-path trajectory animation', () => { const drawn = model.tick(mid) expect(drawn).toHaveLength(2) - expect(drawn.map((d) => d.team).sort()).toEqual(['red', 'yellow']) + expect(drawn.map((d) => d.team).sort()).toEqual(['team1', 'team2']) // Mid-sample y ≈ 11 for both paths (y never reaches hog line → no trim) for (const d of drawn) { expect(d.y).toBeCloseTo(11, 0) @@ -56,19 +75,20 @@ describe('GameModel multi-path trajectory animation', () => { it('clears animating when elapsed reaches maxTotal on a short path', () => { const model = new GameModel() - model.state.stones = [stone({ id: 1, team: 'red' })] - model.state.turnTeam = 'red' + model.state.stones = [stone({ id: sid('team1', 1), team: 'team1' })] + model.state.turnTeam = 'team1' // 3 samples → max t = 2/40 = 0.05s model.startTrajectory([ - { - stone_id: 1, - path: pathSamples([ + stonePath( + sid('team1', 1), + 'team1', + pathSamples([ { x: 0, y: 10 }, { x: 0, y: 10.5 }, { x: 0, y: 11 }, ]), - }, + ), ]) expect(model.state.animating).toBe(true) @@ -81,9 +101,9 @@ describe('GameModel multi-path trajectory animation', () => { it('trims thrown stone path to hog line when id is not in existing stones', () => { const model = new GameModel() - // Only stone 1 is already on the sheet; stone 2 is the newly thrown rock. - model.state.stones = [stone({ id: 1, team: 'yellow', x: 0.5, y: 35 })] - model.state.turnTeam = 'red' + // Only stone team2/1 is already on the sheet; team1/1 is the newly thrown rock. + model.state.stones = [stone({ id: sid('team2', 1), team: 'team2', x: 0.5, y: 35 })] + model.state.turnTeam = 'team1' const thrownPath = pathSamples([ { x: 0, y: 2, theta: 0.1 }, @@ -92,7 +112,7 @@ describe('GameModel multi-path trajectory animation', () => { { x: 0, y: 30, theta: 0.4 }, ]) - model.startTrajectory([{ stone_id: 2, path: thrownPath }]) + model.startTrajectory([stonePath(sid('team1', 1), 'team1', thrownPath)]) expect(model.state.animating).toBe(true) // Immediately after start: hog-trimmed path begins at y=20 (sample before hog) @@ -100,7 +120,7 @@ describe('GameModel multi-path trajectory animation', () => { const drawn = model.tick(atStart) expect(drawn).toHaveLength(1) - expect(drawn[0].team).toBe('red') // turnTeam fallback for unknown id + expect(drawn[0].team).toBe('team1') expect(drawn[0].y).toBeCloseTo(20, 0) // Must not still be at the hack (y=2) expect(drawn[0].y).toBeGreaterThan(15) @@ -108,6 +128,25 @@ describe('GameModel multi-path trajectory animation', () => { expect(drawn[0].rotation).toBeCloseTo(0.2, 2) }) + it('stores scoreboard and stones_remaining from game_state', () => { + const model = new GameModel() + model.updateGameState({ + type: 'game_state', + end: 2, + scores: [1, 0], + hammer: 'team2', + turn_team: 'team1', + scoreboard: [{ end: 1, hammer: 'team1', team1: 1, team2: 0 }], + stones_remaining: [7, 8], + stones: [], + phase: 'playing', + }) + expect(model.state.scoreboard).toHaveLength(1) + expect(model.state.scoreboard[0].team1).toBe(1) + expect(model.state.stonesRemaining).toEqual([7, 8]) + expect(model.state.hammer).toBe('team2') + }) + it('uses sample index for time (SAMPLE_RATE_HZ)', () => { expect(SAMPLE_RATE_HZ).toBe(40) }) diff --git a/frontend/src/game-model.ts b/frontend/src/game-model.ts index dfbf0b5..56cb9b5 100644 --- a/frontend/src/game-model.ts +++ b/frontend/src/game-model.ts @@ -1,4 +1,16 @@ -import { HOUSE_CENTER, type DrawableStone, type Phase, type ServerGameStateMessage, type ServerStoneTrajectory, type StoneState, type Team } from './protocol' +import { + HOUSE_CENTER, + stoneIdKey, + stoneIdsEqual, + type DrawableStone, + type EndScore, + type Phase, + type ServerGameStateMessage, + type StoneId, + type StonePath, + type StoneState, + type Team, +} from './protocol' import { hogTrimStartIndex, sampleTime, trimPathToStartAtHogLine } from './game-helpers' export interface GameModelState { @@ -9,6 +21,8 @@ export interface GameModelState { myTeam: Team | null phase: Phase stones: StoneState[] + scoreboard: EndScore[] + stonesRemaining: number[] animating: boolean } @@ -16,11 +30,13 @@ export class GameModel { state: GameModelState = { end: 1, scores: [0, 0], - hammer: 'red', - turnTeam: 'red', + hammer: 'team1', + turnTeam: 'team1', myTeam: null, phase: 'waiting', stones: [], + scoreboard: [], + stonesRemaining: [8, 8], animating: false, } @@ -29,7 +45,7 @@ export class GameModel { isDragging = false isPanning = false - private activePaths = new Map() + private activePaths = new Map() private animationStartTime = 0 setMyTeam(team: Team): void { @@ -60,42 +76,44 @@ export class GameModel { hammer: msg.hammer, turnTeam: msg.turn_team, phase: msg.phase, + scoreboard: msg.scoreboard ?? [], + stonesRemaining: msg.stones_remaining ?? [8, 8], } } - startTrajectory(paths: ServerStoneTrajectory[]): void { - const existingIds = new Set(this.state.stones.map((s) => s.id)) + startTrajectory(stones: StonePath[]): void { + const existingKeys = new Set(this.state.stones.map((s) => stoneIdKey(s.id))) - let thrownId: number | null = null - for (const { stone_id } of paths) { - if (!existingIds.has(stone_id)) { + let thrownId: StoneId | null = null + for (const { stone_id } of stones) { + if (!existingKeys.has(stoneIdKey(stone_id))) { thrownId = stone_id break } } - if (thrownId === null && paths.length > 0) { - thrownId = paths[0].stone_id + if (thrownId === null && stones.length > 0) { + thrownId = stones[0].stone_id } // Shared sample-index trim so multi-stone paths stay on one clock. // Path entries are (x, y, theta); t = index / SAMPLE_RATE_HZ after trim. let startIdx = 0 if (thrownId !== null) { - const thrownPath = paths.find((p) => p.stone_id === thrownId)?.path ?? [] + const thrownPath = stones.find((p) => stoneIdsEqual(p.stone_id, thrownId!))?.trajectory ?? [] startIdx = hogTrimStartIndex(thrownPath) } - const pathMap = new Map() - for (const { stone_id, path } of paths) { - if (stone_id === thrownId) { - pathMap.set(stone_id, trimPathToStartAtHogLine(path)) - } else { - pathMap.set(stone_id, path.slice(startIdx)) - } + const pathMap = new Map() + for (const { stone_id, team, trajectory } of stones) { + const path = + thrownId !== null && stoneIdsEqual(stone_id, thrownId) + ? trimPathToStartAtHogLine(trajectory) + : trajectory.slice(startIdx) + pathMap.set(stoneIdKey(stone_id), { id: stone_id, team, path }) } this.activePaths = pathMap - this.state.animating = Array.from(pathMap.values()).some((p) => p.length > 1) + this.state.animating = Array.from(pathMap.values()).some((p) => p.path.length > 1) this.animationStartTime = performance.now() this.pendingStones = [] } @@ -109,7 +127,7 @@ export class GameModel { const maxTotal = Math.max( 0, ...Array.from(this.activePaths.values()).map((p) => - p.length > 0 ? sampleTime(p.length - 1) : 0, + p.path.length > 0 ? sampleTime(p.path.length - 1) : 0, ), ) @@ -123,11 +141,12 @@ export class GameModel { } const result: DrawableStone[] = [] - for (const [stoneId, path] of this.activePaths) { + for (const { id, team, path } of this.activePaths.values()) { const pos = this.interpolatePath(path, elapsed) if (!pos) continue - const team = this.state.stones.find((s) => s.id === stoneId)?.team ?? this.state.turnTeam - result.push({ ...pos, team }) + const resolvedTeam = + this.state.stones.find((s) => stoneIdsEqual(s.id, id))?.team ?? team ?? this.state.turnTeam + result.push({ ...pos, team: resolvedTeam }) } return result } diff --git a/frontend/src/game.ts b/frontend/src/game.ts index c7f3260..831a8cf 100644 --- a/frontend/src/game.ts +++ b/frontend/src/game.ts @@ -37,7 +37,8 @@ export function startGame(): void { const model = new GameModel() - const initialTeam: Team = localStorage.getItem('curltastic-team') === 'yellow' ? 'yellow' : 'red' + const stored = localStorage.getItem('curltastic-team') + const initialTeam: Team = stored === 'team2' || stored === 'yellow' ? 'team2' : 'team1' model.setMyTeam(initialTeam) hud.setTeam(initialTeam) @@ -92,17 +93,10 @@ export function startGame(): void { model.updateGameState(msg) updateControls() }, - onTrajectory: (paths) => { - model.startTrajectory(paths) + onTrajectories: (stones) => { + model.startTrajectory(stones) updateControls() }, - onEndScored: (end, points, scoringTeam) => { - if (points > 0 && scoringTeam) { - hud.showToast(`${scoringTeam.toUpperCase()} scores ${points} in end ${end}`) - } else { - hud.showToast(`End ${end} scored: blank end`) - } - }, onGameOver: (scores, winner) => { const msg = winner ? `${winner.toUpperCase()} wins!` : 'Tie game!' hud.showToast(`Game over: ${msg} (${scores[0]}-${scores[1]})`) @@ -178,7 +172,7 @@ export function startGame(): void { hud.teamSelect.value as Team, model.broom.x, model.broom.y, - velocity.getWeight(), + velocity.getVelocity(), curls.getSelected(), friction.getFriction(), ) diff --git a/frontend/src/hud.ts b/frontend/src/hud.ts index 65734f2..86a0288 100644 --- a/frontend/src/hud.ts +++ b/frontend/src/hud.ts @@ -45,6 +45,11 @@ function copyText(text: string): Promise { }) } +const TEAM_LABELS: Record = { + team1: 'Team 1', + team2: 'Team 2', +} + export function createHud(): Hud { const root = document.createElement('div') root.id = 'hud' @@ -54,11 +59,11 @@ export function createHud(): Hud {
-
Red 0 - Yellow 0
+
Team 1 0 - Team 2 0
End 1 · Waiting
Hammer: -
@@ -68,7 +73,7 @@ export function createHud(): Hud {
- + 1.0
@@ -95,11 +100,13 @@ export function createHud(): Hud { teamSelect.value = team }, update: (state) => { - scoreEl.textContent = `Red ${state.scores[0] ?? 0} - Yellow ${state.scores[1] ?? 0}` - const teamNames: Record = { red: 'Red', yellow: 'Yellow' } - const phaseText = state.phase === 'playing' ? `${teamNames[state.turnTeam]}'s turn` : state.phase.replace(/_/g, ' ') + scoreEl.textContent = `Team 1 ${state.scores[0] ?? 0} - Team 2 ${state.scores[1] ?? 0}` + const phaseText = + state.phase === 'playing' + ? `${TEAM_LABELS[state.turnTeam]}'s turn` + : state.phase.replace(/_/g, ' ') endInfoEl.textContent = `End ${state.end} · ${phaseText}` - hammerEl.textContent = `Hammer: ${teamNames[state.hammer]}` + hammerEl.textContent = `Hammer: ${TEAM_LABELS[state.hammer]}` waitingEl.classList.toggle('visible', state.phase !== 'game_complete' && state.animating) }, showToast: (message: string) => { @@ -126,7 +133,7 @@ export function createHud(): Hud { export function createVelocitySelector( container: HTMLDivElement, onSelect: () => void, -): { getWeight: () => number; setEnabled: (enabled: boolean) => void } { +): { getVelocity: () => number; setEnabled: (enabled: boolean) => void } { const state = { weight: 5 } container.innerHTML = '' @@ -173,7 +180,7 @@ export function createVelocitySelector( container.appendChild(wrap) return { - getWeight: () => state.weight, + getVelocity: () => Number(slider.value), setEnabled: (enabled) => { slider.disabled = !enabled }, diff --git a/frontend/src/net.ts b/frontend/src/net.ts index 6374acf..5b55924 100644 --- a/frontend/src/net.ts +++ b/frontend/src/net.ts @@ -1,6 +1,6 @@ import type { ServerGameStateMessage, - ServerStoneTrajectory, + StonePath, ServerMessageTyped as ServerMessage, Team, } from './protocol' @@ -17,8 +17,7 @@ export interface NetCallbacks { onJoined: (room: string) => void onWaiting: (message: string) => void onGameState: (msg: ServerGameStateMessage) => void - onTrajectory: (paths: ServerStoneTrajectory[]) => void - onEndScored: (end: number, points: number, scoringTeam: Team | null) => void + onTrajectories: (stones: StonePath[]) => void onGameOver: (scores: number[], winner: Team | null) => void onError: (message: string) => void onClose: () => void @@ -50,11 +49,8 @@ export function connect(room: string, callbacks: NetCallbacks): void { case 'game_state': callbacks.onGameState(msg) break - case 'trajectory': - callbacks.onTrajectory(msg.paths) - break - case 'end_scored': - callbacks.onEndScored(msg.end, msg.points, msg.scoring_team ?? null) + case 'trajectories': + callbacks.onTrajectories(msg.stones) break case 'game_over': callbacks.onGameOver(msg.scores, msg.winner) @@ -80,7 +76,7 @@ export function sendThrow( team: Team, broomX: number, broomY: number, - weight: number, + velocity: number, curl: number, friction: number, ): void { @@ -91,7 +87,7 @@ export function sendThrow( team, broom_x: broomX, broom_y: broomY, - weight, + velocity, curl, friction, }), diff --git a/frontend/src/protocol.ts b/frontend/src/protocol.ts index c4dc3f3..bc1ebd7 100644 --- a/frontend/src/protocol.ts +++ b/frontend/src/protocol.ts @@ -18,13 +18,20 @@ export const STONE_RADIUS = 0.15 export const MIN_SPEED = 3.0 export const MAX_SPEED = 6.45 +export type Team = 'team1' | 'team2' +export type Phase = 'waiting' | 'playing' | 'simulating' | 'scoring' | 'end_complete' | 'game_complete' + +export interface StoneId { + team: Team + n: number +} + export interface StoneState { - id: number + id: StoneId team: Team x: number y: number rotation: number - active: boolean } export interface DrawableStone { @@ -34,12 +41,19 @@ export interface DrawableStone { team: Team } +export interface EndScore { + end: number + hammer: Team + team1: number + team2: number +} + export interface ClientThrowMessage { type: 'throw' team: Team broom_x: number broom_y: number - weight: number + velocity: number curl: number friction: number } @@ -60,26 +74,23 @@ export interface ServerGameStateMessage { scores: number[] hammer: Team turn_team: Team + scoreboard: EndScore[] + stones_remaining: number[] stones: StoneState[] phase: Phase } /** Path samples are (x, y, theta). Time is sample_index / SAMPLE_RATE_HZ. */ -export interface ServerStoneTrajectory { - stone_id: number - path: [number, number, number][] +export interface StonePath { + stone_id: StoneId + rotation: number + team: Team + trajectory: [number, number, number][] } -export interface ServerTrajectoryMessage { - type: 'trajectory' - paths: ServerStoneTrajectory[] -} - -export interface ServerEndScoredMessage { - type: 'end_scored' - end: number - points: number - scoring_team?: Team +export interface ServerTrajectoriesMessage { + type: 'trajectories' + stones: StonePath[] } export interface ServerGameOverMessage { @@ -97,15 +108,20 @@ export type ServerMessageTyped = | ServerJoinedMessage | ServerWaitingMessage | ServerGameStateMessage - | ServerTrajectoryMessage - | ServerEndScoredMessage + | ServerTrajectoriesMessage | ServerGameOverMessage | ServerErrorMessage -export type Team = 'red' | 'yellow' -export type Phase = 'waiting' | 'playing' | 'simulating' | 'scoring' | 'end_complete' | 'game_complete' export type ServerMessage = ServerMessageTyped export function isTeam(value: unknown): value is Team { - return value === 'red' || value === 'yellow' + return value === 'team1' || value === 'team2' +} + +export function stoneIdKey(id: StoneId): string { + return `${id.team}:${id.n}` +} + +export function stoneIdsEqual(a: StoneId, b: StoneId): boolean { + return a.team === b.team && a.n === b.n } diff --git a/frontend/src/renderer.ts b/frontend/src/renderer.ts index b107dde..3afef15 100644 --- a/frontend/src/renderer.ts +++ b/frontend/src/renderer.ts @@ -159,7 +159,8 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer { const drawStone = (stone: DrawableStone) => { const c = worldToScreen(stone.x, stone.y) const r = STONE_RADIUS * scale() - const color = stone.team === 'red' ? '#d93025' : '#f9ab00' + // team1 = red palette, team2 = yellow palette + const color = stone.team === 'team1' ? '#d93025' : '#f9ab00' ctx.beginPath() ctx.arc(c.x, c.y, r, 0, Math.PI * 2) ctx.fillStyle = color