jasonlooked #1

Merged
eros merged 21 commits from jasonlooked into main 2026-07-11 10:13:09 -07:00
2 changed files with 146 additions and 119 deletions
Showing only changes of commit 0ca0f0e3ff - Show all commits

114
frontend/src/game-model.ts Normal file
View 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,
)
}
}

View File

@ -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')!
Review

move physics parameters (friction, curl) to a dropdown menu

move physics parameters (friction, curl) to a dropdown menu
@ -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 = []
}
const wasAnimating = model.state.animating
const activeStonePos = model.tick(performance.now())
if (wasAnimating && !model.state.animating) {
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({
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()