Replace REQUIREMENTS.md as source of truth with the pm-board yaml. Drop README friction-scalar lies. Include pending HUD frontend work.
235 lines
6.8 KiB
TypeScript
235 lines
6.8 KiB
TypeScript
import { connect, sendThrow, type NetCallbacks } from './net'
|
|
import { createRenderer } from './renderer'
|
|
import { createHud, createVelocitySelector, createCurlSelector } from './hud'
|
|
import { type Team } from './protocol'
|
|
import { GameModel } from './game-model'
|
|
|
|
export function startGame(): void {
|
|
const app = document.querySelector<HTMLDivElement>('#app')!
|
|
app.innerHTML = '<canvas id="game-canvas"></canvas>'
|
|
|
|
const canvas = app.querySelector<HTMLCanvasElement>('#game-canvas')!
|
|
const renderer = createRenderer(canvas)
|
|
const hud = createHud()
|
|
app.appendChild(hud.root)
|
|
|
|
if (import.meta.env.DEV) {
|
|
;(window as unknown as { __renderer: typeof renderer }).__renderer = renderer
|
|
}
|
|
|
|
const velocityContainer = hud.velocityControl
|
|
const curlContainer = hud.curlSelector
|
|
const throwBtn = hud.throwButton
|
|
const velocity = createVelocitySelector(velocityContainer, () => {})
|
|
const curls = createCurlSelector(curlContainer, () => {})
|
|
|
|
const params = new URLSearchParams(window.location.search)
|
|
let room = params.get('room')
|
|
if (!room) {
|
|
room = generateRoomCode()
|
|
window.history.replaceState({}, '', `/?room=${room}`)
|
|
}
|
|
|
|
const shareLink = `${window.location.origin}/?room=${room}`
|
|
hud.setShareLink(shareLink)
|
|
|
|
const model = new GameModel()
|
|
let lastScoreboardLen = model.state.scoreboard.length
|
|
type EndModalPayload = {
|
|
end: number
|
|
team1: number
|
|
team2: number
|
|
nextHammer: Team
|
|
scoreboard: typeof model.state.scoreboard
|
|
}
|
|
let deferredEndModal: EndModalPayload | null = null
|
|
/** True after trajectories for the current throw until animation ends. */
|
|
let throwAnimPending = false
|
|
|
|
const stored = localStorage.getItem('curltastic-team')
|
|
const initialTeam: Team = stored === 'team2' || stored === 'yellow' ? 'team2' : 'team1'
|
|
model.setMyTeam(initialTeam)
|
|
hud.setTeam(initialTeam)
|
|
|
|
// connect() is invoked after callbacks is defined below.
|
|
|
|
const updateControls = () => {
|
|
const activeTeam = hud.teamSelect.value as Team
|
|
model.setMyTeam(activeTeam)
|
|
const myTurn = model.isMyTurn
|
|
velocity.setEnabled(myTurn)
|
|
curls.setEnabled(myTurn)
|
|
throwBtn.disabled = !myTurn
|
|
hud.update(model.state)
|
|
}
|
|
|
|
/** Queue end modal when scoreboard grows; only show after throw animation. */
|
|
const queueEndModalIfNeeded = () => {
|
|
const board = model.state.scoreboard
|
|
if (board.length <= lastScoreboardLen) return
|
|
const last = board[board.length - 1]
|
|
lastScoreboardLen = board.length
|
|
if (!last) return
|
|
deferredEndModal = {
|
|
end: last.end,
|
|
team1: last.team1,
|
|
team2: last.team2,
|
|
nextHammer: model.state.hammer,
|
|
scoreboard: board,
|
|
}
|
|
}
|
|
|
|
const flushEndModal = () => {
|
|
if (!deferredEndModal || model.state.animating || throwAnimPending) return
|
|
hud.showEndModal(deferredEndModal)
|
|
deferredEndModal = null
|
|
}
|
|
|
|
hud.teamSelect.addEventListener('change', () => {
|
|
localStorage.setItem('curltastic-team', hud.teamSelect.value)
|
|
updateControls()
|
|
})
|
|
|
|
const render = () => {
|
|
const wasAnimating = model.state.animating
|
|
const activeStonePos = model.tick(performance.now())
|
|
if (wasAnimating && !model.state.animating) {
|
|
throwAnimPending = false
|
|
flushEndModal()
|
|
updateControls()
|
|
}
|
|
|
|
renderer.draw({
|
|
stones: model.state.stones,
|
|
broom:
|
|
model.state.phase === 'playing' &&
|
|
!model.state.animating &&
|
|
model.state.myTeam === model.state.turnTeam
|
|
? model.broom
|
|
: null,
|
|
animating: model.state.animating,
|
|
activeStonePos,
|
|
})
|
|
requestAnimationFrame(render)
|
|
}
|
|
|
|
const callbacks: NetCallbacks = {
|
|
onJoined: () => {
|
|
updateControls()
|
|
},
|
|
onWaiting: () => {
|
|
model.setWaiting()
|
|
updateControls()
|
|
},
|
|
onGameState: (msg) => {
|
|
model.updateGameState(msg)
|
|
queueEndModalIfNeeded()
|
|
// Only flush if this throw is not mid-animation (or never animated).
|
|
flushEndModal()
|
|
updateControls()
|
|
},
|
|
onTrajectories: (stones) => {
|
|
model.startTrajectory(stones)
|
|
// Hold end modal until playback finishes (even if sim paths were short).
|
|
throwAnimPending = model.state.animating
|
|
if (!throwAnimPending) {
|
|
flushEndModal()
|
|
}
|
|
updateControls()
|
|
},
|
|
onGameOver: (scores, winner) => {
|
|
const msg = winner ? `${winner.toUpperCase()} wins!` : 'Tie game!'
|
|
hud.showToast(`Game over: ${msg} (${scores[0]}-${scores[1]})`)
|
|
},
|
|
onError: (message) => {
|
|
hud.showToast(message)
|
|
},
|
|
onClose: () => {
|
|
// Disconnect => do nothing; keep UI as-is.
|
|
},
|
|
}
|
|
|
|
const getPos = (e: TouchEvent | MouseEvent) => {
|
|
if ('touches' in e) {
|
|
const t = e.touches[0] ?? e.changedTouches[0]
|
|
return { x: t.clientX, y: t.clientY }
|
|
}
|
|
return { x: e.clientX, y: e.clientY }
|
|
}
|
|
|
|
let lastPanScreenY = 0
|
|
|
|
const handleStart = (e: Event) => {
|
|
const pos = getPos(e as TouchEvent)
|
|
const world = renderer.screenToWorld(pos.x, pos.y)
|
|
|
|
if (model.isMyTurn) {
|
|
model.isDragging = true
|
|
model.broom = world
|
|
} else {
|
|
model.isPanning = true
|
|
lastPanScreenY = pos.y
|
|
}
|
|
}
|
|
|
|
const handleMove = (e: Event) => {
|
|
if (model.isDragging && model.isMyTurn) {
|
|
e.preventDefault()
|
|
const pos = getPos(e as TouchEvent)
|
|
model.broom = renderer.screenToWorld(pos.x, pos.y)
|
|
return
|
|
}
|
|
|
|
if (model.isPanning) {
|
|
e.preventDefault()
|
|
const pos = getPos(e as TouchEvent)
|
|
const prevWorld = renderer.screenToWorld(pos.x, lastPanScreenY)
|
|
const nowWorld = renderer.screenToWorld(pos.x, pos.y)
|
|
// Direct-manipulation pan: dragging the sheet down reveals the hog line above.
|
|
const deltaY = nowWorld.y - prevWorld.y
|
|
const sensitivity = 1.5
|
|
renderer.setViewYOffset(renderer.clampViewYOffset() + deltaY * sensitivity)
|
|
lastPanScreenY = pos.y
|
|
}
|
|
}
|
|
|
|
const handleEnd = () => {
|
|
model.isDragging = false
|
|
model.isPanning = false
|
|
}
|
|
|
|
canvas.addEventListener('touchstart', handleStart, { passive: false })
|
|
canvas.addEventListener('touchmove', handleMove, { passive: false })
|
|
canvas.addEventListener('touchend', handleEnd)
|
|
canvas.addEventListener('mousedown', handleStart)
|
|
canvas.addEventListener('mousemove', handleMove)
|
|
canvas.addEventListener('mouseup', handleEnd)
|
|
canvas.addEventListener('mouseleave', handleEnd)
|
|
|
|
throwBtn.addEventListener('click', () => {
|
|
if (!model.isMyTurn) return
|
|
// Hold end-of-end modal until trajectories + playback complete.
|
|
throwAnimPending = true
|
|
sendThrow(
|
|
hud.teamSelect.value as Team,
|
|
model.broom.x,
|
|
model.broom.y,
|
|
velocity.getVelocity(),
|
|
curls.getSelected(),
|
|
)
|
|
})
|
|
|
|
connect(room, callbacks)
|
|
updateControls()
|
|
render()
|
|
}
|
|
|
|
function generateRoomCode(): string {
|
|
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
|
|
let s = ''
|
|
for (let i = 0; i < 6; i++) {
|
|
s += chars.charAt(Math.floor(Math.random() * chars.length))
|
|
}
|
|
return s
|
|
}
|