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 <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Jason Dekarske 2026-07-10 23:16:06 -07:00
parent c28bf32eb8
commit 56a7d5abba
2 changed files with 89 additions and 49 deletions

View File

@ -13,6 +13,10 @@ export function startGame(): void {
const hud = createHud() const hud = createHud()
app.appendChild(hud.root) app.appendChild(hud.root)
if (import.meta.env.DEV) {
;(window as unknown as { __renderer: typeof renderer }).__renderer = renderer
}
const velocityContainer = hud.velocityControl const velocityContainer = hud.velocityControl
const curlContainer = hud.curlSelector const curlContainer = hud.curlSelector
const frictionContainer = hud.frictionControl const frictionContainer = hud.frictionControl
@ -31,30 +35,17 @@ export function startGame(): void {
const shareLink = `${window.location.origin}/?room=${room}` const shareLink = `${window.location.origin}/?room=${room}`
hud.setShareLink(shareLink) hud.setShareLink(shareLink)
const picker = document.createElement('div')
picker.id = 'team-picker'
picker.innerHTML = `
<div class="team-picker-box">
<h2>Choose your team</h2>
<div class="team-picker-buttons">
<button class="team-btn team-red" data-team="red">Play as Red</button>
<button class="team-btn team-yellow" data-team="yellow">Play as Yellow</button>
</div>
</div>
`
app.appendChild(picker)
const model = new GameModel() const model = new GameModel()
picker.querySelectorAll<HTMLButtonElement>('.team-btn').forEach((btn) => { const initialTeam: Team = localStorage.getItem('curltastic-team') === 'yellow' ? 'yellow' : 'red'
btn.addEventListener('click', () => { model.setMyTeam(initialTeam)
const team = btn.dataset.team as Team hud.setTeam(initialTeam)
picker.remove()
connect(room, team, callbacks) // connect() is invoked after callbacks is defined below.
})
})
const updateControls = () => { const updateControls = () => {
const activeTeam = hud.teamSelect.value as Team
model.setMyTeam(activeTeam)
const myTurn = model.isMyTurn const myTurn = model.isMyTurn
velocity.setEnabled(myTurn) velocity.setEnabled(myTurn)
curls.setEnabled(myTurn) curls.setEnabled(myTurn)
@ -63,6 +54,11 @@ export function startGame(): void {
hud.update(model.state) hud.update(model.state)
} }
hud.teamSelect.addEventListener('change', () => {
localStorage.setItem('curltastic-team', hud.teamSelect.value)
updateControls()
})
const render = () => { const render = () => {
const wasAnimating = model.state.animating const wasAnimating = model.state.animating
const activeStonePos = model.tick(performance.now()) const activeStonePos = model.tick(performance.now())
@ -85,8 +81,7 @@ export function startGame(): void {
} }
const callbacks: NetCallbacks = { const callbacks: NetCallbacks = {
onJoined: (_roomId, team) => { onJoined: () => {
model.setMyTeam(team)
updateControls() updateControls()
}, },
onWaiting: () => { onWaiting: () => {
@ -97,8 +92,8 @@ export function startGame(): void {
model.updateGameState(msg) model.updateGameState(msg)
updateControls() updateControls()
}, },
onTrajectory: (path) => { onTrajectory: (paths) => {
model.startTrajectory(path, model.state.turnTeam) model.startTrajectory(paths)
updateControls() updateControls()
}, },
onEndScored: (end, points, scoringTeam) => { onEndScored: (end, points, scoringTeam) => {
@ -142,22 +137,51 @@ export function startGame(): void {
return world 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) => { const handleStart = (e: Event) => {
if (!model.isMyTurn) return
model.isDragging = true
const pos = getPos(e as TouchEvent) 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) => { const handleMove = (e: Event) => {
if (!model.isDragging || !model.isMyTurn) return if (model.isDragging && model.isMyTurn) {
e.preventDefault() e.preventDefault()
const pos = getPos(e as TouchEvent) const pos = getPos(e as TouchEvent)
model.broom = constrainBroom(renderer.screenToWorld(pos.x, pos.y)) 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 = () => { const handleEnd = () => {
model.isDragging = false model.isDragging = false
model.isPanning = false
} }
canvas.addEventListener('touchstart', handleStart, { passive: false }) canvas.addEventListener('touchstart', handleStart, { passive: false })
@ -170,9 +194,17 @@ export function startGame(): void {
throwBtn.addEventListener('click', () => { throwBtn.addEventListener('click', () => {
if (!model.isMyTurn) return 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() updateControls()
render() render()
} }

View File

@ -3,17 +3,18 @@ import { velocityToWeight, weightToVelocity } from './game-helpers'
export interface Hud { export interface Hud {
root: HTMLDivElement root: HTMLDivElement
teamSelect: HTMLSelectElement
velocityControl: HTMLDivElement velocityControl: HTMLDivElement
curlSelector: HTMLDivElement curlSelector: HTMLDivElement
frictionControl: HTMLDivElement frictionControl: HTMLDivElement
throwButton: HTMLButtonElement throwButton: HTMLButtonElement
setTeam: (team: Team) => void
update: (state: { update: (state: {
phase: Phase phase: Phase
end: number end: number
scores: number[] scores: number[]
hammer: Team hammer: Team
turnTeam: Team turnTeam: Team
myTeam: Team | null
animating: boolean animating: boolean
}) => void }) => void
showToast: (message: string) => void showToast: (message: string) => void
@ -48,11 +49,19 @@ export function createHud(): Hud {
const root = document.createElement('div') const root = document.createElement('div')
root.id = 'hud' root.id = 'hud'
root.innerHTML = ` root.innerHTML = `
<div class="hud-row"> <div id="hud-top-group">
<div id="score">Red 0 - Yellow 0</div> <div class="hud-row" id="share-row">
<div id="end-info">End 1 · Waiting</div> <div id="share"><button>Copy share link</button></div>
<div id="team">You: -</div> </div>
<div id="hammer">Hammer: -</div> <div class="hud-row">
<div id="score">Red 0 - Yellow 0</div>
<div id="end-info">End 1 · Waiting</div>
<select id="team-select" aria-label="Team">
<option value="red">Red</option>
<option value="yellow">Yellow</option>
</select>
<div id="hammer">Hammer: -</div>
</div>
</div> </div>
<div class="hud-row" style="align-items:flex-end;"> <div class="hud-row" style="align-items:flex-end;">
<div id="velocity-control"></div> <div id="velocity-control"></div>
@ -66,34 +75,32 @@ export function createHud(): Hud {
<button id="throw-btn" disabled>THROW</button> <button id="throw-btn" disabled>THROW</button>
</div> </div>
</div> </div>
<div id="waiting">Waiting for other player</div> <div id="waiting">Waiting</div>
<div id="share"><button>Copy share link</button></div>
` `
const scoreEl = root.querySelector<HTMLDivElement>('#score')! const scoreEl = root.querySelector<HTMLDivElement>('#score')!
const endInfoEl = root.querySelector<HTMLDivElement>('#end-info')! const endInfoEl = root.querySelector<HTMLDivElement>('#end-info')!
const teamEl = root.querySelector<HTMLDivElement>('#team')! const teamSelect = root.querySelector<HTMLSelectElement>('#team-select')!
const hammerEl = root.querySelector<HTMLDivElement>('#hammer')! const hammerEl = root.querySelector<HTMLDivElement>('#hammer')!
const waitingEl = root.querySelector<HTMLDivElement>('#waiting')! const waitingEl = root.querySelector<HTMLDivElement>('#waiting')!
return { return {
root, root,
teamSelect,
velocityControl: root.querySelector<HTMLDivElement>('#velocity-control')!, velocityControl: root.querySelector<HTMLDivElement>('#velocity-control')!,
curlSelector: root.querySelector<HTMLDivElement>('#curl-selector')!, curlSelector: root.querySelector<HTMLDivElement>('#curl-selector')!,
frictionControl: root.querySelector<HTMLDivElement>('#friction-control')!, frictionControl: root.querySelector<HTMLDivElement>('#friction-control')!,
throwButton: root.querySelector<HTMLButtonElement>('#throw-btn')!, throwButton: root.querySelector<HTMLButtonElement>('#throw-btn')!,
setTeam: (team) => {
teamSelect.value = team
},
update: (state) => { update: (state) => {
scoreEl.textContent = `Red ${state.scores[0] ?? 0} - Yellow ${state.scores[1] ?? 0}` scoreEl.textContent = `Red ${state.scores[0] ?? 0} - Yellow ${state.scores[1] ?? 0}`
const teamNames: Record<Team, string> = { red: 'Red', yellow: 'Yellow' } const teamNames: Record<Team, string> = { red: 'Red', yellow: 'Yellow' }
const phaseText = state.phase === 'playing' ? `${teamNames[state.turnTeam]}'s turn` : state.phase.replace(/_/g, ' ') const phaseText = state.phase === 'playing' ? `${teamNames[state.turnTeam]}'s turn` : state.phase.replace(/_/g, ' ')
endInfoEl.textContent = `End ${state.end} · ${phaseText}` endInfoEl.textContent = `End ${state.end} · ${phaseText}`
teamEl.textContent = state.myTeam ? `You: ${teamNames[state.myTeam]}` : 'You: -'
hammerEl.textContent = `Hammer: ${teamNames[state.hammer]}` hammerEl.textContent = `Hammer: ${teamNames[state.hammer]}`
const waiting = waitingEl.classList.toggle('visible', state.phase !== 'game_complete' && state.animating)
state.phase === 'waiting' ||
(state.phase === 'playing' && state.myTeam !== null && state.turnTeam !== state.myTeam) ||
state.animating
waitingEl.classList.toggle('visible', state.phase !== 'game_complete' && waiting)
}, },
showToast: (message: string) => { showToast: (message: string) => {
const toast = document.createElement('div') const toast = document.createElement('div')
@ -179,14 +186,15 @@ export function createCurlSelector(
): { getSelected: () => number; setEnabled: (enabled: boolean) => void } { ): { getSelected: () => number; setEnabled: (enabled: boolean) => void } {
const state = { selected: 1, enabled: true } const state = { selected: 1, enabled: true }
const options = [ const options = [
{ value: -1, label: '↶ Left' }, { value: -1, label: '↷', ariaLabel: 'Left curl' },
{ value: 1, label: 'Right ↷' }, { value: 1, label: '↶', ariaLabel: 'Right curl' },
] ]
container.innerHTML = '' container.innerHTML = ''
for (const opt of options) { for (const opt of options) {
const btn = document.createElement('button') const btn = document.createElement('button')
btn.className = 'curl-btn' btn.className = 'curl-btn'
btn.textContent = opt.label btn.textContent = opt.label
btn.ariaLabel = opt.ariaLabel
btn.dataset.curl = String(opt.value) btn.dataset.curl = String(opt.value)
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
if (!state.enabled) return if (!state.enabled) return