diff --git a/frontend/src/game.ts b/frontend/src/game.ts
index 3586d4e..66ae665 100644
--- a/frontend/src/game.ts
+++ b/frontend/src/game.ts
@@ -13,6 +13,10 @@ export function startGame(): void {
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
@@ -31,30 +35,17 @@ export function startGame(): void {
const shareLink = `${window.location.origin}/?room=${room}`
hud.setShareLink(shareLink)
- const picker = document.createElement('div')
- picker.id = 'team-picker'
- picker.innerHTML = `
-
-
Choose your team
-
-
-
-
-
- `
- app.appendChild(picker)
-
const model = new GameModel()
- picker.querySelectorAll('.team-btn').forEach((btn) => {
- btn.addEventListener('click', () => {
- const team = btn.dataset.team as Team
- picker.remove()
- connect(room, team, callbacks)
- })
- })
+ 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)
@@ -63,6 +54,11 @@ export function startGame(): void {
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())
@@ -85,8 +81,7 @@ export function startGame(): void {
}
const callbacks: NetCallbacks = {
- onJoined: (_roomId, team) => {
- model.setMyTeam(team)
+ onJoined: () => {
updateControls()
},
onWaiting: () => {
@@ -97,8 +92,8 @@ export function startGame(): void {
model.updateGameState(msg)
updateControls()
},
- onTrajectory: (path) => {
- model.startTrajectory(path, model.state.turnTeam)
+ onTrajectory: (paths) => {
+ model.startTrajectory(paths)
updateControls()
},
onEndScored: (end, points, scoringTeam) => {
@@ -142,22 +137,51 @@ export function startGame(): void {
return world
}
+ const isInHouse = (world: { x: number; y: number }) => {
+ const dx = world.x - HOUSE_CENTER.x
+ const dy = world.y - HOUSE_CENTER.y
+ return Math.sqrt(dx * dx + dy * dy) <= HOUSE_RADIUS
+ }
+
+ let lastPanScreenY = 0
+
const handleStart = (e: Event) => {
- if (!model.isMyTurn) return
- model.isDragging = true
const pos = getPos(e as TouchEvent)
- model.broom = constrainBroom(renderer.screenToWorld(pos.x, pos.y))
+ const world = renderer.screenToWorld(pos.x, pos.y)
+
+ if (model.isMyTurn && isInHouse(world)) {
+ model.isDragging = true
+ model.broom = constrainBroom(world)
+ } else {
+ model.isPanning = true
+ lastPanScreenY = pos.y
+ }
}
const handleMove = (e: Event) => {
- if (!model.isDragging || !model.isMyTurn) return
- e.preventDefault()
- const pos = getPos(e as TouchEvent)
- model.broom = constrainBroom(renderer.screenToWorld(pos.x, pos.y))
+ if (model.isDragging && model.isMyTurn) {
+ e.preventDefault()
+ const pos = getPos(e as TouchEvent)
+ model.broom = constrainBroom(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 })
@@ -170,9 +194,17 @@ export function startGame(): void {
throwBtn.addEventListener('click', () => {
if (!model.isMyTurn) return
- sendThrow(model.broom.x, model.broom.y, velocity.getWeight(), curls.getSelected(), friction.getFriction())
+ sendThrow(
+ hud.teamSelect.value as Team,
+ model.broom.x,
+ model.broom.y,
+ velocity.getWeight(),
+ curls.getSelected(),
+ friction.getFriction(),
+ )
})
+ connect(room, callbacks)
updateControls()
render()
}
diff --git a/frontend/src/hud.ts b/frontend/src/hud.ts
index 0d0dd17..65734f2 100644
--- a/frontend/src/hud.ts
+++ b/frontend/src/hud.ts
@@ -3,17 +3,18 @@ import { velocityToWeight, weightToVelocity } from './game-helpers'
export interface Hud {
root: HTMLDivElement
+ teamSelect: HTMLSelectElement
velocityControl: HTMLDivElement
curlSelector: HTMLDivElement
frictionControl: HTMLDivElement
throwButton: HTMLButtonElement
+ setTeam: (team: Team) => void
update: (state: {
phase: Phase
end: number
scores: number[]
hammer: Team
turnTeam: Team
- myTeam: Team | null
animating: boolean
}) => void
showToast: (message: string) => void
@@ -48,11 +49,19 @@ export function createHud(): Hud {
const root = document.createElement('div')
root.id = 'hud'
root.innerHTML = `
-
-
Red 0 - Yellow 0
-
End 1 · Waiting
-
You: -
-
Hammer: -
+
+
+
+
Red 0 - Yellow 0
+
End 1 · Waiting
+
+
Hammer: -
+
@@ -66,34 +75,32 @@ export function createHud(): Hud {
- Waiting for other player
-
+ Waiting
`
const scoreEl = root.querySelector('#score')!
const endInfoEl = root.querySelector('#end-info')!
- const teamEl = root.querySelector('#team')!
+ const teamSelect = root.querySelector('#team-select')!
const hammerEl = root.querySelector('#hammer')!
const waitingEl = root.querySelector('#waiting')!
return {
root,
+ teamSelect,
velocityControl: root.querySelector('#velocity-control')!,
curlSelector: root.querySelector('#curl-selector')!,
frictionControl: root.querySelector('#friction-control')!,
throwButton: root.querySelector('#throw-btn')!,
+ setTeam: (team) => {
+ teamSelect.value = team
+ },
update: (state) => {
scoreEl.textContent = `Red ${state.scores[0] ?? 0} - Yellow ${state.scores[1] ?? 0}`
const teamNames: Record = { red: 'Red', yellow: 'Yellow' }
const phaseText = state.phase === 'playing' ? `${teamNames[state.turnTeam]}'s turn` : state.phase.replace(/_/g, ' ')
endInfoEl.textContent = `End ${state.end} · ${phaseText}`
- teamEl.textContent = state.myTeam ? `You: ${teamNames[state.myTeam]}` : 'You: -'
hammerEl.textContent = `Hammer: ${teamNames[state.hammer]}`
- const waiting =
- state.phase === 'waiting' ||
- (state.phase === 'playing' && state.myTeam !== null && state.turnTeam !== state.myTeam) ||
- state.animating
- waitingEl.classList.toggle('visible', state.phase !== 'game_complete' && waiting)
+ waitingEl.classList.toggle('visible', state.phase !== 'game_complete' && state.animating)
},
showToast: (message: string) => {
const toast = document.createElement('div')
@@ -179,14 +186,15 @@ export function createCurlSelector(
): { getSelected: () => number; setEnabled: (enabled: boolean) => void } {
const state = { selected: 1, enabled: true }
const options = [
- { value: -1, label: '↶ Left' },
- { value: 1, label: 'Right ↷' },
+ { value: -1, label: '↷', ariaLabel: 'Left curl' },
+ { value: 1, label: '↶', ariaLabel: 'Right curl' },
]
container.innerHTML = ''
for (const opt of options) {
const btn = document.createElement('button')
btn.className = 'curl-btn'
btn.textContent = opt.label
+ btn.ariaLabel = opt.ariaLabel
btn.dataset.curl = String(opt.value)
btn.addEventListener('click', () => {
if (!state.enabled) return