2026-09-08 02:16:58 +00:00

235 lines
6.9 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({}, '', `${window.location.pathname}?room=${room}`)
}
const shareLink = `${window.location.origin}${window.location.pathname}?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
}