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 { 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<HTMLDivElement>('#app')!
|
||||
|
|
||||
@ -55,6 +44,8 @@ export function startGame(): void {
|
||||
`
|
||||
app.appendChild(picker)
|
||||
|
||||
const model = new GameModel()
|
||||
|
||||
picker.querySelectorAll<HTMLButtonElement>('.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()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user
move physics parameters (friction, curl) to a dropdown menu