curltastic/frontend/src/game-model.ts
Jason Dekarske 78b8ebeb7d docs: move product spec to .pm/board.yaml
Replace REQUIREMENTS.md as source of truth with the pm-board yaml.
Drop README friction-scalar lies. Include pending HUD frontend work.
2026-09-07 23:06:31 +00:00

281 lines
7.7 KiB
TypeScript

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