feat(frontend): consume trajectories+scoreboard protocol

Mirror team1/team2 StoneId wire, velocity throws, trajectories animation,
scoreboard/stones_remaining state, HUD labels, and team1 red / team2 yellow palette.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Jason Dekarske 2026-07-10 23:39:19 -07:00
parent f0437bbbbe
commit 162d6a7e59
7 changed files with 168 additions and 96 deletions

View File

@ -1,14 +1,17 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { GameModel } from './game-model' 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' 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 { function stone(partial: Partial<StoneState> & Pick<StoneState, 'id' | 'team'>): StoneState {
return { return {
x: 0, x: 0,
y: 30, y: 30,
rotation: 0, rotation: 0,
active: true,
...partial, ...partial,
} }
} }
@ -20,11 +23,27 @@ function pathSamples(
return points.map((p) => [p.x, p.y, p.theta ?? 0]) 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', () => { describe('GameModel multi-path trajectory animation', () => {
it('returns two DrawableStones in parallel mid-trajectory', () => { it('returns two DrawableStones in parallel mid-trajectory', () => {
const model = new GameModel() const model = new GameModel()
model.state.stones = [stone({ id: 1, team: 'red' }), stone({ id: 2, team: 'yellow' })] model.state.stones = [
model.state.turnTeam = 'red' 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 // 21 samples → duration 20/40 = 0.5s; mid at 0.25s is sample 10 → y=11
const n = 21 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 })), Array.from({ length: n }, (_, i) => ({ x: 1, y: 10 + i * 0.1, theta: 0 })),
) )
const paths: ServerStoneTrajectory[] = [ const paths: StonePath[] = [
{ stone_id: 1, path: path1 }, stonePath(sid('team1', 1), 'team1', path1),
{ stone_id: 2, path: path2 }, stonePath(sid('team2', 1), 'team2', path2),
] ]
model.startTrajectory(paths) model.startTrajectory(paths)
@ -47,7 +66,7 @@ describe('GameModel multi-path trajectory animation', () => {
const drawn = model.tick(mid) const drawn = model.tick(mid)
expect(drawn).toHaveLength(2) 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) // Mid-sample y ≈ 11 for both paths (y never reaches hog line → no trim)
for (const d of drawn) { for (const d of drawn) {
expect(d.y).toBeCloseTo(11, 0) 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', () => { it('clears animating when elapsed reaches maxTotal on a short path', () => {
const model = new GameModel() const model = new GameModel()
model.state.stones = [stone({ id: 1, team: 'red' })] model.state.stones = [stone({ id: sid('team1', 1), team: 'team1' })]
model.state.turnTeam = 'red' model.state.turnTeam = 'team1'
// 3 samples → max t = 2/40 = 0.05s // 3 samples → max t = 2/40 = 0.05s
model.startTrajectory([ model.startTrajectory([
{ stonePath(
stone_id: 1, sid('team1', 1),
path: pathSamples([ 'team1',
pathSamples([
{ x: 0, y: 10 }, { x: 0, y: 10 },
{ x: 0, y: 10.5 }, { x: 0, y: 10.5 },
{ x: 0, y: 11 }, { x: 0, y: 11 },
]), ]),
}, ),
]) ])
expect(model.state.animating).toBe(true) 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', () => { it('trims thrown stone path to hog line when id is not in existing stones', () => {
const model = new GameModel() const model = new GameModel()
// Only stone 1 is already on the sheet; stone 2 is the newly thrown rock. // Only stone team2/1 is already on the sheet; team1/1 is the newly thrown rock.
model.state.stones = [stone({ id: 1, team: 'yellow', x: 0.5, y: 35 })] model.state.stones = [stone({ id: sid('team2', 1), team: 'team2', x: 0.5, y: 35 })]
model.state.turnTeam = 'red' model.state.turnTeam = 'team1'
const thrownPath = pathSamples([ const thrownPath = pathSamples([
{ x: 0, y: 2, theta: 0.1 }, { x: 0, y: 2, theta: 0.1 },
@ -92,7 +112,7 @@ describe('GameModel multi-path trajectory animation', () => {
{ x: 0, y: 30, theta: 0.4 }, { 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) expect(model.state.animating).toBe(true)
// Immediately after start: hog-trimmed path begins at y=20 (sample before hog) // 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) const drawn = model.tick(atStart)
expect(drawn).toHaveLength(1) 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) expect(drawn[0].y).toBeCloseTo(20, 0)
// Must not still be at the hack (y=2) // Must not still be at the hack (y=2)
expect(drawn[0].y).toBeGreaterThan(15) expect(drawn[0].y).toBeGreaterThan(15)
@ -108,6 +128,25 @@ describe('GameModel multi-path trajectory animation', () => {
expect(drawn[0].rotation).toBeCloseTo(0.2, 2) 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)', () => { it('uses sample index for time (SAMPLE_RATE_HZ)', () => {
expect(SAMPLE_RATE_HZ).toBe(40) 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' import { hogTrimStartIndex, sampleTime, trimPathToStartAtHogLine } from './game-helpers'
export interface GameModelState { export interface GameModelState {
@ -9,6 +21,8 @@ export interface GameModelState {
myTeam: Team | null myTeam: Team | null
phase: Phase phase: Phase
stones: StoneState[] stones: StoneState[]
scoreboard: EndScore[]
stonesRemaining: number[]
animating: boolean animating: boolean
} }
@ -16,11 +30,13 @@ export class GameModel {
state: GameModelState = { state: GameModelState = {
end: 1, end: 1,
scores: [0, 0], scores: [0, 0],
hammer: 'red', hammer: 'team1',
turnTeam: 'red', turnTeam: 'team1',
myTeam: null, myTeam: null,
phase: 'waiting', phase: 'waiting',
stones: [], stones: [],
scoreboard: [],
stonesRemaining: [8, 8],
animating: false, animating: false,
} }
@ -29,7 +45,7 @@ export class GameModel {
isDragging = false isDragging = false
isPanning = 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 private animationStartTime = 0
setMyTeam(team: Team): void { setMyTeam(team: Team): void {
@ -60,42 +76,44 @@ export class GameModel {
hammer: msg.hammer, hammer: msg.hammer,
turnTeam: msg.turn_team, turnTeam: msg.turn_team,
phase: msg.phase, phase: msg.phase,
scoreboard: msg.scoreboard ?? [],
stonesRemaining: msg.stones_remaining ?? [8, 8],
} }
} }
startTrajectory(paths: ServerStoneTrajectory[]): void { startTrajectory(stones: StonePath[]): void {
const existingIds = new Set(this.state.stones.map((s) => s.id)) const existingKeys = new Set(this.state.stones.map((s) => stoneIdKey(s.id)))
let thrownId: number | null = null let thrownId: StoneId | null = null
for (const { stone_id } of paths) { for (const { stone_id } of stones) {
if (!existingIds.has(stone_id)) { if (!existingKeys.has(stoneIdKey(stone_id))) {
thrownId = stone_id thrownId = stone_id
break break
} }
} }
if (thrownId === null && paths.length > 0) { if (thrownId === null && stones.length > 0) {
thrownId = paths[0].stone_id thrownId = stones[0].stone_id
} }
// Shared sample-index trim so multi-stone paths stay on one clock. // 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. // Path entries are (x, y, theta); t = index / SAMPLE_RATE_HZ after trim.
let startIdx = 0 let startIdx = 0
if (thrownId !== null) { 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) startIdx = hogTrimStartIndex(thrownPath)
} }
const pathMap = new Map<number, [number, number, number][]>() const pathMap = new Map<string, { id: StoneId; team: Team; path: [number, number, number][] }>()
for (const { stone_id, path } of paths) { for (const { stone_id, team, trajectory } of stones) {
if (stone_id === thrownId) { const path =
pathMap.set(stone_id, trimPathToStartAtHogLine(path)) thrownId !== null && stoneIdsEqual(stone_id, thrownId)
} else { ? trimPathToStartAtHogLine(trajectory)
pathMap.set(stone_id, path.slice(startIdx)) : trajectory.slice(startIdx)
} pathMap.set(stoneIdKey(stone_id), { id: stone_id, team, path })
} }
this.activePaths = pathMap 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.animationStartTime = performance.now()
this.pendingStones = [] this.pendingStones = []
} }
@ -109,7 +127,7 @@ export class GameModel {
const maxTotal = Math.max( const maxTotal = Math.max(
0, 0,
...Array.from(this.activePaths.values()).map((p) => ...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[] = [] 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) const pos = this.interpolatePath(path, elapsed)
if (!pos) continue if (!pos) continue
const team = this.state.stones.find((s) => s.id === stoneId)?.team ?? this.state.turnTeam const resolvedTeam =
result.push({ ...pos, team }) this.state.stones.find((s) => stoneIdsEqual(s.id, id))?.team ?? team ?? this.state.turnTeam
result.push({ ...pos, team: resolvedTeam })
} }
return result return result
} }

View File

@ -37,7 +37,8 @@ export function startGame(): void {
const model = new GameModel() 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) model.setMyTeam(initialTeam)
hud.setTeam(initialTeam) hud.setTeam(initialTeam)
@ -92,17 +93,10 @@ export function startGame(): void {
model.updateGameState(msg) model.updateGameState(msg)
updateControls() updateControls()
}, },
onTrajectory: (paths) => { onTrajectories: (stones) => {
model.startTrajectory(paths) model.startTrajectory(stones)
updateControls() 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) => { onGameOver: (scores, winner) => {
const msg = winner ? `${winner.toUpperCase()} wins!` : 'Tie game!' const msg = winner ? `${winner.toUpperCase()} wins!` : 'Tie game!'
hud.showToast(`Game over: ${msg} (${scores[0]}-${scores[1]})`) hud.showToast(`Game over: ${msg} (${scores[0]}-${scores[1]})`)
@ -178,7 +172,7 @@ export function startGame(): void {
hud.teamSelect.value as Team, hud.teamSelect.value as Team,
model.broom.x, model.broom.x,
model.broom.y, model.broom.y,
velocity.getWeight(), velocity.getVelocity(),
curls.getSelected(), curls.getSelected(),
friction.getFriction(), 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 { export function createHud(): Hud {
const root = document.createElement('div') const root = document.createElement('div')
root.id = 'hud' root.id = 'hud'
@ -54,11 +59,11 @@ export function createHud(): Hud {
<div id="share"><button>Copy share link</button></div> <div id="share"><button>Copy share link</button></div>
</div> </div>
<div class="hud-row"> <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> <div id="end-info">End 1 · Waiting</div>
<select id="team-select" aria-label="Team"> <select id="team-select" aria-label="Team">
<option value="red">Red</option> <option value="team1">Team 1</option>
<option value="yellow">Yellow</option> <option value="team2">Team 2</option>
</select> </select>
<div id="hammer">Hammer: -</div> <div id="hammer">Hammer: -</div>
</div> </div>
@ -68,7 +73,7 @@ export function createHud(): Hud {
<div id="curl-selector"></div> <div id="curl-selector"></div>
<div id="friction-control"> <div id="friction-control">
<label for="friction-slider">Friction</label> <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> <span id="friction-value">1.0</span>
</div> </div>
<div> <div>
@ -95,11 +100,13 @@ export function createHud(): Hud {
teamSelect.value = team teamSelect.value = team
}, },
update: (state) => { update: (state) => {
scoreEl.textContent = `Red ${state.scores[0] ?? 0} - Yellow ${state.scores[1] ?? 0}` scoreEl.textContent = `Team 1 ${state.scores[0] ?? 0} - Team 2 ${state.scores[1] ?? 0}`
const teamNames: Record<Team, string> = { red: 'Red', yellow: 'Yellow' } const phaseText =
const phaseText = state.phase === 'playing' ? `${teamNames[state.turnTeam]}'s turn` : state.phase.replace(/_/g, ' ') state.phase === 'playing'
? `${TEAM_LABELS[state.turnTeam]}'s turn`
: state.phase.replace(/_/g, ' ')
endInfoEl.textContent = `End ${state.end} · ${phaseText}` 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) waitingEl.classList.toggle('visible', state.phase !== 'game_complete' && state.animating)
}, },
showToast: (message: string) => { showToast: (message: string) => {
@ -126,7 +133,7 @@ export function createHud(): Hud {
export function createVelocitySelector( export function createVelocitySelector(
container: HTMLDivElement, container: HTMLDivElement,
onSelect: () => void, onSelect: () => void,
): { getWeight: () => number; setEnabled: (enabled: boolean) => void } { ): { getVelocity: () => number; setEnabled: (enabled: boolean) => void } {
const state = { weight: 5 } const state = { weight: 5 }
container.innerHTML = '' container.innerHTML = ''
@ -173,7 +180,7 @@ export function createVelocitySelector(
container.appendChild(wrap) container.appendChild(wrap)
return { return {
getWeight: () => state.weight, getVelocity: () => Number(slider.value),
setEnabled: (enabled) => { setEnabled: (enabled) => {
slider.disabled = !enabled slider.disabled = !enabled
}, },

View File

@ -1,6 +1,6 @@
import type { import type {
ServerGameStateMessage, ServerGameStateMessage,
ServerStoneTrajectory, StonePath,
ServerMessageTyped as ServerMessage, ServerMessageTyped as ServerMessage,
Team, Team,
} from './protocol' } from './protocol'
@ -17,8 +17,7 @@ export interface NetCallbacks {
onJoined: (room: string) => void onJoined: (room: string) => void
onWaiting: (message: string) => void onWaiting: (message: string) => void
onGameState: (msg: ServerGameStateMessage) => void onGameState: (msg: ServerGameStateMessage) => void
onTrajectory: (paths: ServerStoneTrajectory[]) => void onTrajectories: (stones: StonePath[]) => void
onEndScored: (end: number, points: number, scoringTeam: Team | null) => void
onGameOver: (scores: number[], winner: Team | null) => void onGameOver: (scores: number[], winner: Team | null) => void
onError: (message: string) => void onError: (message: string) => void
onClose: () => void onClose: () => void
@ -50,11 +49,8 @@ export function connect(room: string, callbacks: NetCallbacks): void {
case 'game_state': case 'game_state':
callbacks.onGameState(msg) callbacks.onGameState(msg)
break break
case 'trajectory': case 'trajectories':
callbacks.onTrajectory(msg.paths) callbacks.onTrajectories(msg.stones)
break
case 'end_scored':
callbacks.onEndScored(msg.end, msg.points, msg.scoring_team ?? null)
break break
case 'game_over': case 'game_over':
callbacks.onGameOver(msg.scores, msg.winner) callbacks.onGameOver(msg.scores, msg.winner)
@ -80,7 +76,7 @@ export function sendThrow(
team: Team, team: Team,
broomX: number, broomX: number,
broomY: number, broomY: number,
weight: number, velocity: number,
curl: number, curl: number,
friction: number, friction: number,
): void { ): void {
@ -91,7 +87,7 @@ export function sendThrow(
team, team,
broom_x: broomX, broom_x: broomX,
broom_y: broomY, broom_y: broomY,
weight, velocity,
curl, curl,
friction, friction,
}), }),

View File

@ -18,13 +18,20 @@ export const STONE_RADIUS = 0.15
export const MIN_SPEED = 3.0 export const MIN_SPEED = 3.0
export const MAX_SPEED = 6.45 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 { export interface StoneState {
id: number id: StoneId
team: Team team: Team
x: number x: number
y: number y: number
rotation: number rotation: number
active: boolean
} }
export interface DrawableStone { export interface DrawableStone {
@ -34,12 +41,19 @@ export interface DrawableStone {
team: Team team: Team
} }
export interface EndScore {
end: number
hammer: Team
team1: number
team2: number
}
export interface ClientThrowMessage { export interface ClientThrowMessage {
type: 'throw' type: 'throw'
team: Team team: Team
broom_x: number broom_x: number
broom_y: number broom_y: number
weight: number velocity: number
curl: number curl: number
friction: number friction: number
} }
@ -60,26 +74,23 @@ export interface ServerGameStateMessage {
scores: number[] scores: number[]
hammer: Team hammer: Team
turn_team: Team turn_team: Team
scoreboard: EndScore[]
stones_remaining: number[]
stones: StoneState[] stones: StoneState[]
phase: Phase phase: Phase
} }
/** Path samples are (x, y, theta). Time is sample_index / SAMPLE_RATE_HZ. */ /** Path samples are (x, y, theta). Time is sample_index / SAMPLE_RATE_HZ. */
export interface ServerStoneTrajectory { export interface StonePath {
stone_id: number stone_id: StoneId
path: [number, number, number][] rotation: number
team: Team
trajectory: [number, number, number][]
} }
export interface ServerTrajectoryMessage { export interface ServerTrajectoriesMessage {
type: 'trajectory' type: 'trajectories'
paths: ServerStoneTrajectory[] stones: StonePath[]
}
export interface ServerEndScoredMessage {
type: 'end_scored'
end: number
points: number
scoring_team?: Team
} }
export interface ServerGameOverMessage { export interface ServerGameOverMessage {
@ -97,15 +108,20 @@ export type ServerMessageTyped =
| ServerJoinedMessage | ServerJoinedMessage
| ServerWaitingMessage | ServerWaitingMessage
| ServerGameStateMessage | ServerGameStateMessage
| ServerTrajectoryMessage | ServerTrajectoriesMessage
| ServerEndScoredMessage
| ServerGameOverMessage | ServerGameOverMessage
| ServerErrorMessage | ServerErrorMessage
export type Team = 'red' | 'yellow'
export type Phase = 'waiting' | 'playing' | 'simulating' | 'scoring' | 'end_complete' | 'game_complete'
export type ServerMessage = ServerMessageTyped export type ServerMessage = ServerMessageTyped
export function isTeam(value: unknown): value is Team { 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 drawStone = (stone: DrawableStone) => {
const c = worldToScreen(stone.x, stone.y) const c = worldToScreen(stone.x, stone.y)
const r = STONE_RADIUS * scale() 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.beginPath()
ctx.arc(c.x, c.y, r, 0, Math.PI * 2) ctx.arc(c.x, c.y, r, 0, Math.PI * 2)
ctx.fillStyle = color ctx.fillStyle = color