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:
parent
f0437bbbbe
commit
162d6a7e59
@ -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)
|
||||
})
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
)
|
||||
|
||||
@ -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
|
||||
},
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user