forked from eros/curltastic
200 lines
5.8 KiB
TypeScript
200 lines
5.8 KiB
TypeScript
import { connect, sendThrow, type NetCallbacks } from './net'
|
|
import { createRenderer } from './renderer'
|
|
import { createHud, createVelocitySelector, createCurlSelector, createFrictionSlider } 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 frictionContainer = hud.frictionControl
|
|
const throwBtn = hud.throwButton
|
|
const velocity = createVelocitySelector(velocityContainer, () => {})
|
|
const curls = createCurlSelector(curlContainer, () => {})
|
|
const friction = createFrictionSlider(frictionContainer)
|
|
|
|
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()
|
|
|
|
const initialTeam: Team = localStorage.getItem('curltastic-team') === 'yellow' ? 'yellow' : 'red'
|
|
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)
|
|
friction.setEnabled(myTurn)
|
|
throwBtn.disabled = !myTurn
|
|
hud.update(model.state)
|
|
}
|
|
|
|
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) {
|
|
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)
|
|
updateControls()
|
|
},
|
|
onTrajectory: (paths) => {
|
|
model.startTrajectory(paths)
|
|
updateControls()
|
|
},
|
|
onEndScored: (end, points, scoringTeam) => {
|
|
if (points > 0 && scoringTeam) {
|
|
hud.showToast(`${scoringTeam.toUpperCase()} scores ${points} in end ${end}`)
|
|
} else {
|
|
hud.showToast(`End ${end} scored: blank end`)
|
|
}
|
|
},
|
|
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
|
|
sendThrow(
|
|
hud.teamSelect.value as Team,
|
|
model.broom.x,
|
|
model.broom.y,
|
|
velocity.getWeight(),
|
|
curls.getSelected(),
|
|
friction.getFriction(),
|
|
)
|
|
})
|
|
|
|
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
|
|
}
|