feat(frontend): animate all stone trajectories on one clock

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:16:06 -07:00
parent 722e6090d1
commit c28bf32eb8

View File

@ -1,4 +1,4 @@
import { HOUSE_CENTER, type DrawableStone, type Phase, type ServerGameStateMessage, type StoneState, type Team } from './protocol'
import { HOG_LINE_Y, HOUSE_CENTER, type DrawableStone, type Phase, type ServerGameStateMessage, type ServerStoneTrajectory, type StoneState, type Team } from './protocol'
import { trimPathToStartAtHogLine } from './game-helpers'
export interface GameModelState {
@ -27,10 +27,10 @@ export class GameModel {
pendingStones: StoneState[] = []
broom: { x: number; y: number } = { x: 0, y: HOUSE_CENTER.y }
isDragging = false
isPanning = false
private activePath: [number, number, number][] = []
private activePaths = new Map<number, [number, number, number][]>()
private animationStartTime = 0
private animationTeam: Team = 'red'
setMyTeam(team: Team): void {
this.state.myTeam = team
@ -63,36 +63,104 @@ export class GameModel {
}
}
startTrajectory(path: [number, number, number][], team: Team): void {
this.activePath = trimPathToStartAtHogLine(path)
this.state.animating = this.activePath.length > 1
startTrajectory(paths: ServerStoneTrajectory[]): void {
const existingIds = new Set(this.state.stones.map((s) => s.id))
let thrownId: number | null = null
for (const { stone_id } of paths) {
if (!existingIds.has(stone_id)) {
thrownId = stone_id
break
}
}
if (thrownId === null && paths.length > 0) {
thrownId = paths[0].stone_id
}
let tRef = 0
if (thrownId !== null) {
const thrownPath = paths.find((p) => p.stone_id === thrownId)?.path ?? []
if (thrownPath.length >= 2) {
const idx = thrownPath.findIndex(([, y]) => y >= HOG_LINE_Y)
if (idx >= 0) {
const start = Math.max(0, idx - 1)
tRef = thrownPath[start][2]
}
}
}
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 {
const shifted = path
.map(([x, y, t]) => [x, y, t - tRef] as [number, number, number])
.filter(([, , t]) => t >= 0)
pathMap.set(stone_id, shifted)
}
}
this.activePaths = pathMap
this.state.animating = Array.from(pathMap.values()).some((p) => p.length > 1)
this.animationStartTime = performance.now()
this.animationTeam = team
this.pendingStones = []
}
tick(now: number): DrawableStone | null {
if (!this.state.animating || this.activePath.length <= 1) {
return null
tick(now: number): DrawableStone[] {
if (!this.state.animating) {
return []
}
const elapsed = (now - this.animationStartTime) / 1000
const total = this.activePath[this.activePath.length - 1][2]
if (elapsed >= total) {
const maxTotal = Math.max(
0,
...Array.from(this.activePaths.values()).map((p) => (p.length > 0 ? p[p.length - 1][2] : 0)),
)
if (elapsed >= maxTotal) {
this.state.animating = false
if (this.pendingStones.length > 0) {
this.state.stones = this.pendingStones
this.pendingStones = []
}
return null
return []
}
const result: DrawableStone[] = []
for (const [stoneId, path] of this.activePaths) {
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 })
}
return result
}
private interpolatePath(
path: [number, number, number][],
elapsed: number,
): { x: number; y: number; rotation: number } | null {
if (path.length === 0) return null
if (path.length === 1) {
const [x, y] = path[0]
return { x, y, rotation: 0 }
}
if (elapsed >= path[path.length - 1][2]) {
const last = path[path.length - 1]
const prev = path[path.length - 2]
const dx = last[0] - prev[0]
const dy = last[1] - prev[1]
return { x: last[0], y: last[1], rotation: Math.atan2(dy, dx) * 2 }
}
let i = 0
while (i + 1 < this.activePath.length && this.activePath[i + 1][2] < elapsed) i++
const p0 = this.activePath[i]
const p1 = this.activePath[i + 1] ?? p0
const t0 = this.activePath[Math.max(i - 1, 0)]
const t2 = this.activePath[Math.min(i + 2, this.activePath.length - 1)]
while (i + 1 < path.length && path[i + 1][2] < elapsed) i++
const p0 = path[i]
const p1 = path[i + 1] ?? p0
const t0 = path[Math.max(i - 1, 0)]
const t2 = path[Math.min(i + 2, path.length - 1)]
const dt = p1[2] - p0[2]
const t = dt > 0 ? (elapsed - p0[2]) / dt : 0
const x = p0[0] + (p1[0] - p0[0]) * t
@ -100,7 +168,7 @@ export class GameModel {
const dx = t2[0] - t0[0]
const dy = t2[1] - t0[1]
const rotation = Math.atan2(dy, dx) * 2
return { x, y, rotation, team: this.animationTeam }
return { x, y, rotation }
}
get isMyTurn(): boolean {