jasonlooked #1
114
frontend/src/game-model.ts
Normal file
114
frontend/src/game-model.ts
Normal file
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,19 +1,8 @@
|
|||||||
import { connect, sendThrow, type NetCallbacks } from './net'
|
import { connect, sendThrow, type NetCallbacks } from './net'
|
||||||
import { createRenderer } from './renderer'
|
import { createRenderer } from './renderer'
|
||||||
import { createHud, createVelocitySelector, createCurlSelector, createFrictionSlider } from './hud'
|
import { createHud, createVelocitySelector, createCurlSelector, createFrictionSlider } from './hud'
|
||||||
import { HOUSE_CENTER, HOUSE_RADIUS, type Phase, type StoneState, type Team } from './protocol'
|
import { HOUSE_CENTER, HOUSE_RADIUS, type Team } from './protocol'
|
||||||
import { trimPathToStartAtHogLine } from './game-helpers'
|
import { GameModel } from './game-model'
|
||||||
|
|
||||||
interface GameState {
|
|
||||||
end: number
|
|
||||||
scores: number[]
|
|
||||||
hammer: Team
|
|
||||||
turnTeam: Team
|
|
||||||
myTeam: Team | null
|
|
||||||
phase: Phase
|
|
||||||
stones: StoneState[]
|
|
||||||
animating: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function startGame(): void {
|
export function startGame(): void {
|
||||||
const app = document.querySelector<HTMLDivElement>('#app')!
|
const app = document.querySelector<HTMLDivElement>('#app')!
|
||||||
|
|
|||||||
@ -55,6 +44,8 @@ export function startGame(): void {
|
|||||||
`
|
`
|
||||||
app.appendChild(picker)
|
app.appendChild(picker)
|
||||||
|
|
||||||
|
const model = new GameModel()
|
||||||
|
|
||||||
picker.querySelectorAll<HTMLButtonElement>('.team-btn').forEach((btn) => {
|
picker.querySelectorAll<HTMLButtonElement>('.team-btn').forEach((btn) => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
const team = btn.dataset.team as Team
|
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 updateControls = () => {
|
||||||
const myTurn = Boolean(
|
const myTurn = model.isMyTurn
|
||||||
gameState.myTeam && gameState.turnTeam === gameState.myTeam && gameState.phase === 'playing',
|
velocity.setEnabled(myTurn)
|
||||||
)
|
curls.setEnabled(myTurn)
|
||||||
velocity.setEnabled(myTurn && !gameState.animating)
|
friction.setEnabled(myTurn)
|
||||||
curls.setEnabled(myTurn && !gameState.animating)
|
throwBtn.disabled = !myTurn
|
||||||
friction.setEnabled(myTurn && !gameState.animating)
|
hud.update(model.state)
|
||||||
throwBtn.disabled = !myTurn || gameState.animating
|
|
||||||
if (gameState.phase === 'simulating' || gameState.animating) {
|
|
||||||
velocity.setEnabled(false)
|
|
||||||
curls.setEnabled(false)
|
|
||||||
friction.setEnabled(false)
|
|
||||||
}
|
|
||||||
hud.update(gameState)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const render = () => {
|
const render = () => {
|
||||||
let activeStonePos: { x: number; y: number; rotation: number; team: Team } | null = null
|
const wasAnimating = model.state.animating
|
||||||
if (gameState.animating && activePath.length > 1) {
|
const activeStonePos = model.tick(performance.now())
|
||||||
const elapsed = (performance.now() - animationStartTime) / 1000
|
if (wasAnimating && !model.state.animating) {
|
||||||
const total = activePath[activePath.length - 1][2]
|
|
||||||
if (elapsed >= total) {
|
|
||||||
gameState.animating = false
|
|
||||||
if (pendingStones.length > 0) {
|
|
||||||
gameState.stones = pendingStones
|
|
||||||
pendingStones = []
|
|
||||||
}
|
|
||||||
updateControls()
|
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 }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
renderer.draw({
|
renderer.draw({
|
||||||
stones: gameState.stones,
|
stones: model.state.stones,
|
||||||
broom:
|
broom:
|
||||||
gameState.phase === 'playing' && !gameState.animating && gameState.myTeam === gameState.turnTeam
|
model.state.phase === 'playing' &&
|
||||||
? broom
|
!model.state.animating &&
|
||||||
|
model.state.myTeam === model.state.turnTeam
|
||||||
|
? model.broom
|
||||||
: null,
|
: null,
|
||||||
animating: gameState.animating,
|
animating: model.state.animating,
|
||||||
activeStonePos,
|
activeStonePos,
|
||||||
})
|
})
|
||||||
requestAnimationFrame(render)
|
requestAnimationFrame(render)
|
||||||
@ -141,42 +86,19 @@ export function startGame(): void {
|
|||||||
|
|
||||||
const callbacks: NetCallbacks = {
|
const callbacks: NetCallbacks = {
|
||||||
onJoined: (_roomId, team) => {
|
onJoined: (_roomId, team) => {
|
||||||
gameState.myTeam = team
|
model.setMyTeam(team)
|
||||||
updateControls()
|
updateControls()
|
||||||
},
|
},
|
||||||
onWaiting: () => {
|
onWaiting: () => {
|
||||||
gameState.phase = 'waiting'
|
model.setWaiting()
|
||||||
updateControls()
|
updateControls()
|
||||||
},
|
},
|
||||||
onGameState: (msg) => {
|
onGameState: (msg) => {
|
||||||
const reset = msg.phase === 'waiting' || msg.end !== gameState.end
|
model.updateGameState(msg)
|
||||||
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,
|
|
||||||
}
|
|
||||||
updateControls()
|
updateControls()
|
||||||
},
|
},
|
||||||
onTrajectory: (path) => {
|
onTrajectory: (path) => {
|
||||||
activePath = trimPathToStartAtHogLine(path)
|
model.startTrajectory(path, model.state.turnTeam)
|
||||||
gameState.animating = activePath.length > 1
|
|
||||||
animationStartTime = performance.now()
|
|
||||||
animationTeam = gameState.turnTeam
|
|
||||||
pendingStones = []
|
|
||||||
updateControls()
|
updateControls()
|
||||||
},
|
},
|
||||||
onEndScored: (end, points, scoringTeam) => {
|
onEndScored: (end, points, scoringTeam) => {
|
||||||
@ -221,30 +143,21 @@ export function startGame(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleStart = (e: Event) => {
|
const handleStart = (e: Event) => {
|
||||||
if (!isMyDragTurn()) return
|
if (!model.isMyTurn) return
|
||||||
isDragging = true
|
model.isDragging = true
|
||||||
const pos = getPos(e as TouchEvent)
|
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) => {
|
const handleMove = (e: Event) => {
|
||||||
if (!isDragging || !isMyDragTurn()) return
|
if (!model.isDragging || !model.isMyTurn) return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const pos = getPos(e as TouchEvent)
|
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 = () => {
|
const handleEnd = () => {
|
||||||
isDragging = false
|
model.isDragging = false
|
||||||
}
|
|
||||||
|
|
||||||
const isMyDragTurn = () => {
|
|
||||||
return (
|
|
||||||
gameState.myTeam !== null &&
|
|
||||||
gameState.turnTeam === gameState.myTeam &&
|
|
||||||
gameState.phase === 'playing' &&
|
|
||||||
!gameState.animating
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
canvas.addEventListener('touchstart', handleStart, { passive: false })
|
canvas.addEventListener('touchstart', handleStart, { passive: false })
|
||||||
@ -256,8 +169,8 @@ export function startGame(): void {
|
|||||||
canvas.addEventListener('mouseleave', handleEnd)
|
canvas.addEventListener('mouseleave', handleEnd)
|
||||||
|
|
||||||
throwBtn.addEventListener('click', () => {
|
throwBtn.addEventListener('click', () => {
|
||||||
if (gameState.myTeam !== gameState.turnTeam || gameState.animating || gameState.phase !== 'playing') return
|
if (!model.isMyTurn) return
|
||||||
sendThrow(broom.x, broom.y, velocity.getWeight(), curls.getSelected(), friction.getFriction())
|
sendThrow(model.broom.x, model.broom.y, velocity.getWeight(), curls.getSelected(), friction.getFriction())
|
||||||
})
|
})
|
||||||
|
|
||||||
updateControls()
|
updateControls()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user
move physics parameters (friction, curl) to a dropdown menu