forked from eros/curltastic
Replace REQUIREMENTS.md as source of truth with the pm-board yaml. Drop README friction-scalar lies. Include pending HUD frontend work.
281 lines
7.7 KiB
TypeScript
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,
|
|
)
|
|
}
|
|
}
|