diff --git a/frontend/src/game-model.ts b/frontend/src/game-model.ts index afd74d0..86ffb3a 100644 --- a/frontend/src/game-model.ts +++ b/frontend/src/game-model.ts @@ -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() 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() + 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 {