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('#app')! app.innerHTML = '' const canvas = app.querySelector('#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 }