pr2-features #2

Merged
eros merged 9 commits from pr2-features into main 2026-07-11 13:15:51 -07:00
7 changed files with 168 additions and 96 deletions
Showing only changes of commit 162d6a7e59 - Show all commits

View File

@ -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<StoneState> & Pick<StoneState, 'id' | 'team'>): 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)
})

View File

@ -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<number, [number, number, number][]>()
private activePaths = new Map<string, { id: StoneId; team: Team; path: [number, number, number][] }>()
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<number, [number, number, number][]>()
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<string, { id: StoneId; team: Team; path: [number, number, number][] }>()
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
}

View File

@ -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(),
)

View File

@ -45,6 +45,11 @@ function copyText(text: string): Promise<void> {
})
}
const TEAM_LABELS: Record<Team, string> = {
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 {
<div id="share"><button>Copy share link</button></div>
</div>
<div class="hud-row">
<div id="score">Red 0 - Yellow 0</div>
<div id="score">Team 1 0 - Team 2 0</div>
<div id="end-info">End 1 · Waiting</div>
<select id="team-select" aria-label="Team">
<option value="red">Red</option>
<option value="yellow">Yellow</option>
<option value="team1">Team 1</option>
<option value="team2">Team 2</option>
</select>
<div id="hammer">Hammer: -</div>
</div>
@ -68,7 +73,7 @@ export function createHud(): Hud {
<div id="curl-selector"></div>
<div id="friction-control">
<label for="friction-slider">Friction</label>
<input id="friction-slider" type="range" min="0.5" max="2.0" step="0.1" value="1.0" />
<input id="friction-slider" type="range" min="0.5" max="1.5" step="0.1" value="1.0" />
<span id="friction-value">1.0</span>
</div>
<div>
@ -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<Team, string> = { 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
},

View File

@ -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,
}),

View File

@ -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
}

View File

@ -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