import { HOUSE_CENTER, SAMPLE_RATE_HZ, STONE_RADIUS, BACK_LINE_Y, HOG_LINE_Y, SHEET_WIDTH, stoneIdKey, stoneIdsEqual, type DrawableStone, type EndScore, type Phase, type ServerGameStateMessage, type StoneId, type StonePath, type StoneState, type Team, } from './protocol' import { hogTrimStartIndex, sampleTime } from './game-helpers' export interface GameModelState { end: number scores: number[] hammer: Team turnTeam: Team myTeam: Team | null phase: Phase stones: StoneState[] scoreboard: EndScore[] stonesRemaining: number[] animating: boolean } /** Side / back edge-touch (same as backend edge_out_of_bounds) for mid-flight hide. */ function samplePastSideOrBack(x: number, y: number): boolean { const half = SHEET_WIDTH / 2 return Math.abs(x) + STONE_RADIUS >= half || y + STONE_RADIUS >= BACK_LINE_Y } /** Final rest OOB: sides/back or never cleared the hog. */ function pathEndedOutOfPlay(path: [number, number, number][]): boolean { if (path.length === 0) return true const [x, y] = path[path.length - 1]! const half = SHEET_WIDTH / 2 return ( Math.abs(x) + STONE_RADIUS >= half || y + STONE_RADIUS >= BACK_LINE_Y || y - STONE_RADIUS <= HOG_LINE_Y ) } export class GameModel { state: GameModelState = { end: 1, scores: [0, 0], hammer: 'team1', turnTeam: 'team1', myTeam: null, phase: 'waiting', stones: [], scoreboard: [], stonesRemaining: [8, 8], animating: false, } pendingStones: StoneState[] = [] broom: { x: number; y: number } = { x: 0, y: HOUSE_CENTER.y } isDragging = false isPanning = false private activePaths = new Map< string, { id: StoneId; team: Team; path: [number, number, number][] } >() private animationStartTime = 0 /** Absolute sample index when the thrown stone reaches the hog (shared clock). */ private pathClockOffset = 0 /** True once a game_state snapshot arrived for the in-flight anim. */ private hasServerSnapshot = false setMyTeam(team: Team): void { this.state.myTeam = team } setWaiting(): void { this.state.phase = 'waiting' } updateGameState(msg: ServerGameStateMessage): void { const reset = msg.phase === 'waiting' || msg.end !== this.state.end if (reset) { this.state.stones = [] this.pendingStones = [] this.hasServerSnapshot = false this.activePaths.clear() this.state.animating = false } if (this.state.animating) { // Authoritative post-throw board — apply even when empty (all stones removed). this.pendingStones = msg.stones this.hasServerSnapshot = true } else { this.state.stones = msg.stones this.pendingStones = [] this.hasServerSnapshot = false } this.state = { ...this.state, end: msg.end, scores: msg.scores, hammer: msg.hammer, turnTeam: msg.turn_team, phase: msg.phase, scoreboard: msg.scoreboard ?? [], stonesRemaining: msg.stones_remaining ?? [8, 8], } } startTrajectory(stones: StonePath[]): void { // Commit any leftover server board before basing "existing" on it. this.commitServerStones() const existingKeys = new Set(this.state.stones.map((s) => stoneIdKey(s.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 && stones.length > 0) { thrownId = stones[0]!.stone_id } this.pathClockOffset = 0 if (thrownId !== null) { const thrownPath = stones.find((p) => stoneIdsEqual(p.stone_id, thrownId!))?.trajectory ?? [] 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< string, { id: StoneId; team: Team; path: [number, number, number][] } >() for (const { stone_id, team, trajectory } of stones) { pathMap.set(stoneIdKey(stone_id), { id: stone_id, team, path: trajectory, }) } this.activePaths = pathMap 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 = [] this.hasServerSnapshot = false } tick(now: number): DrawableStone[] { if (!this.state.animating) { return [] } const elapsed = (now - this.animationStartTime) / 1000 const absT = this.pathClockOffset / SAMPLE_RATE_HZ + elapsed const maxAbsT = Math.max( 0, ...Array.from(this.activePaths.values()).map((p) => p.path.length > 0 ? sampleTime(p.path.length - 1) : 0, ), ) if (absT >= maxAbsT) { this.finishAnimation() return [] } const result: DrawableStone[] = [] for (const { id, team, path } of this.activePaths.values()) { const pos = this.interpolatePathAtAbsTime(path, absT) if (!pos) continue if (samplePastSideOrBack(pos.x, pos.y)) continue const resolvedTeam = this.state.stones.find((s) => stoneIdsEqual(s.id, id))?.team ?? team ?? this.state.turnTeam result.push({ ...pos, team: resolvedTeam }) } return result } private commitServerStones(): void { if (!this.hasServerSnapshot) return this.state.stones = this.pendingStones this.pendingStones = [] this.hasServerSnapshot = false } private finishAnimation(): void { this.state.animating = false if (this.hasServerSnapshot) { // Apply even when pending is [] (full clear). this.state.stones = this.pendingStones this.pendingStones = [] this.hasServerSnapshot = false } else { const settled: StoneState[] = [] for (const { id, team, path } of this.activePaths.values()) { if (path.length === 0 || pathEndedOutOfPlay(path)) continue const last = path[path.length - 1]! settled.push({ id, team, x: last[0], y: last[1], rotation: last[2], }) } this.state.stones = settled } this.activePaths.clear() this.pathClockOffset = 0 } private interpolatePathAtAbsTime( path: [number, number, number][], absT: number, ): { x: number; y: number; rotation: number } | null { if (path.length === 0) return null if (path.length === 1) { const [x, y, theta] = path[0]! return { x, y, rotation: theta } } const lastT = sampleTime(path.length - 1) if (absT <= 0) { const [x, y, theta] = path[0]! return { x, y, rotation: theta } } if (absT >= lastT) { const last = path[path.length - 1]! return { x: last[0], y: last[1], rotation: last[2] } } let i = Math.min(path.length - 2, Math.max(0, Math.floor(absT * SAMPLE_RATE_HZ))) while (i + 1 < path.length && sampleTime(i + 1) < absT) i++ while (i > 0 && sampleTime(i) > absT) i-- const p0 = path[i]! const p1 = path[i + 1] ?? p0 const t0 = sampleTime(i) const t1 = sampleTime(i + 1) const dt = t1 - t0 const t = dt > 0 ? (absT - t0) / dt : 0 const x = p0[0] + (p1[0] - p0[0]) * t const y = p0[1] + (p1[1] - p0[1]) * t let dTheta = p1[2] - p0[2] if (dTheta > Math.PI) dTheta -= 2 * Math.PI if (dTheta < -Math.PI) dTheta += 2 * Math.PI const rotation = p0[2] + dTheta * t return { x, y, rotation } } get isMyTurn(): boolean { return Boolean( this.state.myTeam && this.state.turnTeam === this.state.myTeam && this.state.phase === 'playing' && !this.state.animating, ) } }