From 0ca0f0e3ffbeef5f3c616986afe0cb80fee36f0c Mon Sep 17 00:00:00 2001 From: eros Date: Wed, 24 Jun 2026 10:55:45 -0700 Subject: [PATCH] refactor(frontend): centralize game state in GameModel Extract mutable state from game.ts into a dedicated GameModel class to clarify data flow and state transitions. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- frontend/src/game-model.ts | 114 ++++++++++++++++++++++++++++ frontend/src/game.ts | 151 ++++++++----------------------------- 2 files changed, 146 insertions(+), 119 deletions(-) create mode 100644 frontend/src/game-model.ts diff --git a/frontend/src/game-model.ts b/frontend/src/game-model.ts new file mode 100644 index 0000000..afd74d0 --- /dev/null +++ b/frontend/src/game-model.ts @@ -0,0 +1,114 @@ +import { HOUSE_CENTER, type DrawableStone, type Phase, type ServerGameStateMessage, type StoneState, type Team } from './protocol' +import { trimPathToStartAtHogLine } from './game-helpers' + +export interface GameModelState { + end: number + scores: number[] + hammer: Team + turnTeam: Team + myTeam: Team | null + phase: Phase + stones: StoneState[] + animating: boolean +} + +export class GameModel { + state: GameModelState = { + end: 1, + scores: [0, 0], + hammer: 'red', + turnTeam: 'red', + myTeam: null, + phase: 'waiting', + stones: [], + animating: false, + } + + pendingStones: StoneState[] = [] + broom: { x: number; y: number } = { x: 0, y: HOUSE_CENTER.y } + isDragging = false + + private activePath: [number, number, number][] = [] + private animationStartTime = 0 + private animationTeam: Team = 'red' + + 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 = [] + } + + if (this.state.animating) { + this.pendingStones = msg.stones + } else { + this.state.stones = msg.stones + } + + this.state = { + ...this.state, + end: msg.end, + scores: msg.scores, + hammer: msg.hammer, + turnTeam: msg.turn_team, + phase: msg.phase, + } + } + + startTrajectory(path: [number, number, number][], team: Team): void { + this.activePath = trimPathToStartAtHogLine(path) + this.state.animating = this.activePath.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 + } + + const elapsed = (now - this.animationStartTime) / 1000 + const total = this.activePath[this.activePath.length - 1][2] + if (elapsed >= total) { + this.state.animating = false + if (this.pendingStones.length > 0) { + this.state.stones = this.pendingStones + this.pendingStones = [] + } + return null + } + + 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)] + const dt = p1[2] - p0[2] + const t = dt > 0 ? (elapsed - p0[2]) / dt : 0 + const x = p0[0] + (p1[0] - p0[0]) * t + const y = p0[1] + (p1[1] - p0[1]) * t + 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 } + } + + get isMyTurn(): boolean { + return Boolean( + this.state.myTeam && + this.state.turnTeam === this.state.myTeam && + this.state.phase === 'playing' && + !this.state.animating, + ) + } +} diff --git a/frontend/src/game.ts b/frontend/src/game.ts index 630c60a..3586d4e 100644 --- a/frontend/src/game.ts +++ b/frontend/src/game.ts @@ -1,19 +1,8 @@ import { connect, sendThrow, type NetCallbacks } from './net' import { createRenderer } from './renderer' import { createHud, createVelocitySelector, createCurlSelector, createFrictionSlider } from './hud' -import { HOUSE_CENTER, HOUSE_RADIUS, type Phase, type StoneState, type Team } from './protocol' -import { trimPathToStartAtHogLine } from './game-helpers' - -interface GameState { - end: number - scores: number[] - hammer: Team - turnTeam: Team - myTeam: Team | null - phase: Phase - stones: StoneState[] - animating: boolean -} +import { HOUSE_CENTER, HOUSE_RADIUS, type Team } from './protocol' +import { GameModel } from './game-model' export function startGame(): void { const app = document.querySelector('#app')! @@ -55,6 +44,8 @@ export function startGame(): void { ` app.appendChild(picker) + const model = new GameModel() + picker.querySelectorAll('.team-btn').forEach((btn) => { btn.addEventListener('click', () => { const team = btn.dataset.team as Team @@ -63,77 +54,31 @@ export function startGame(): void { }) }) - let gameState: GameState = { - end: 1, - scores: [0, 0], - hammer: 'red', - turnTeam: 'red', - myTeam: null, - phase: 'waiting', - stones: [], - animating: false, - } - let pendingStones: StoneState[] = [] - - let broom: { x: number; y: number } = { x: 0, y: HOUSE_CENTER.y } - let isDragging = false - let activePath: [number, number, number][] = [] - let animationStartTime = 0 - let animationTeam: Team = 'red' - const updateControls = () => { - const myTurn = Boolean( - gameState.myTeam && gameState.turnTeam === gameState.myTeam && gameState.phase === 'playing', - ) - velocity.setEnabled(myTurn && !gameState.animating) - curls.setEnabled(myTurn && !gameState.animating) - friction.setEnabled(myTurn && !gameState.animating) - throwBtn.disabled = !myTurn || gameState.animating - if (gameState.phase === 'simulating' || gameState.animating) { - velocity.setEnabled(false) - curls.setEnabled(false) - friction.setEnabled(false) - } - hud.update(gameState) + const myTurn = model.isMyTurn + velocity.setEnabled(myTurn) + curls.setEnabled(myTurn) + friction.setEnabled(myTurn) + throwBtn.disabled = !myTurn + hud.update(model.state) } const render = () => { - let activeStonePos: { x: number; y: number; rotation: number; team: Team } | null = null - if (gameState.animating && activePath.length > 1) { - const elapsed = (performance.now() - animationStartTime) / 1000 - const total = activePath[activePath.length - 1][2] - if (elapsed >= total) { - gameState.animating = false - if (pendingStones.length > 0) { - gameState.stones = pendingStones - pendingStones = [] - } - updateControls() - } else { - let i = 0 - while (i + 1 < activePath.length && activePath[i + 1][2] < elapsed) i++ - const p0 = activePath[i] - const p1 = activePath[i + 1] ?? p0 - const t0 = activePath[Math.max(i - 1, 0)] - const t2 = activePath[Math.min(i + 2, activePath.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 - const y = p0[1] + (p1[1] - p0[1]) * t - const dx = t2[0] - t0[0] - const dy = t2[1] - t0[1] - const rotation = Math.atan2(dy, dx) * 2 - activeStonePos = { x, y, rotation, team: animationTeam } - } + const wasAnimating = model.state.animating + const activeStonePos = model.tick(performance.now()) + if (wasAnimating && !model.state.animating) { + updateControls() } renderer.draw({ - stones: gameState.stones, + stones: model.state.stones, broom: - gameState.phase === 'playing' && !gameState.animating && gameState.myTeam === gameState.turnTeam - ? broom + model.state.phase === 'playing' && + !model.state.animating && + model.state.myTeam === model.state.turnTeam + ? model.broom : null, - animating: gameState.animating, + animating: model.state.animating, activeStonePos, }) requestAnimationFrame(render) @@ -141,42 +86,19 @@ export function startGame(): void { const callbacks: NetCallbacks = { onJoined: (_roomId, team) => { - gameState.myTeam = team + model.setMyTeam(team) updateControls() }, onWaiting: () => { - gameState.phase = 'waiting' + model.setWaiting() updateControls() }, onGameState: (msg) => { - const reset = msg.phase === 'waiting' || msg.end !== gameState.end - if (reset) { - gameState.stones = [] - pendingStones = [] - } - - if (gameState.animating) { - pendingStones = msg.stones - } else { - gameState.stones = msg.stones - } - - gameState = { - ...gameState, - end: msg.end, - scores: msg.scores, - hammer: msg.hammer, - turnTeam: msg.turn_team, - phase: msg.phase, - } + model.updateGameState(msg) updateControls() }, onTrajectory: (path) => { - activePath = trimPathToStartAtHogLine(path) - gameState.animating = activePath.length > 1 - animationStartTime = performance.now() - animationTeam = gameState.turnTeam - pendingStones = [] + model.startTrajectory(path, model.state.turnTeam) updateControls() }, onEndScored: (end, points, scoringTeam) => { @@ -221,30 +143,21 @@ export function startGame(): void { } const handleStart = (e: Event) => { - if (!isMyDragTurn()) return - isDragging = true + if (!model.isMyTurn) return + model.isDragging = true const pos = getPos(e as TouchEvent) - broom = constrainBroom(renderer.screenToWorld(pos.x, pos.y)) + model.broom = constrainBroom(renderer.screenToWorld(pos.x, pos.y)) } const handleMove = (e: Event) => { - if (!isDragging || !isMyDragTurn()) return + if (!model.isDragging || !model.isMyTurn) return e.preventDefault() const pos = getPos(e as TouchEvent) - broom = constrainBroom(renderer.screenToWorld(pos.x, pos.y)) + model.broom = constrainBroom(renderer.screenToWorld(pos.x, pos.y)) } const handleEnd = () => { - isDragging = false - } - - const isMyDragTurn = () => { - return ( - gameState.myTeam !== null && - gameState.turnTeam === gameState.myTeam && - gameState.phase === 'playing' && - !gameState.animating - ) + model.isDragging = false } canvas.addEventListener('touchstart', handleStart, { passive: false }) @@ -256,8 +169,8 @@ export function startGame(): void { canvas.addEventListener('mouseleave', handleEnd) throwBtn.addEventListener('click', () => { - if (gameState.myTeam !== gameState.turnTeam || gameState.animating || gameState.phase !== 'playing') return - sendThrow(broom.x, broom.y, velocity.getWeight(), curls.getSelected(), friction.getFriction()) + if (!model.isMyTurn) return + sendThrow(model.broom.x, model.broom.y, velocity.getWeight(), curls.getSelected(), friction.getFriction()) }) updateControls()