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:
parent
722e6090d1
commit
c28bf32eb8
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user