From 56a7d5abbac7e057bc0a81906c6a3326892e1299 Mon Sep 17 00:00:00 2001 From: Jason Dekarske Date: Fri, 10 Jul 2026 23:16:06 -0700 Subject: [PATCH] feat(frontend): free team switch and sheet pan for multi-client play Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- frontend/src/game.ts | 96 +++++++++++++++++++++++++++++--------------- frontend/src/hud.ts | 42 +++++++++++-------- 2 files changed, 89 insertions(+), 49 deletions(-) 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