Init multiplayer
All checks were successful
Deploy Feature / deploy-feature (push) Successful in 28s

This commit is contained in:
Johnny322
2026-02-24 20:54:14 +01:00
parent 6dde7eedb6
commit 9945f8163e
14 changed files with 2027 additions and 53 deletions

View File

@@ -7,7 +7,11 @@
</div>
<div v-if="step === 'game'" class="selector-pill">
<span class="label">Selecting</span>
<span class="value">{{ currentSelector?.name || '' }}</span>
<span class="value">{{ currentSelector?.name || '-' }}</span>
</div>
<div v-if="gameId" class="selector-pill session-pill">
<span class="label">Game ID</span>
<span class="value">{{ gameId }}</span>
</div>
</header>
@@ -15,6 +19,37 @@
<section v-if="step === 'setup'" class="panel">
<h2>Team Setup</h2>
<p class="hint">Add teams, name them, and create their color gradients.</p>
<div class="session-box">
<h3>Live Session</h3>
<p class="hint">
Create a game ID as host, or join an existing game ID as viewer.
</p>
<div class="session-row">
<input
v-model.trim="gameIdInput"
class="input"
placeholder="Game ID"
:disabled="sessionBusy || !!gameId"
/>
<button class="ghost" @click="createSession()" :disabled="sessionBusy || !!gameId || !normalizedGameIdInput">
Create
</button>
<button
class="ghost"
@click="joinSession"
:disabled="sessionBusy || !!gameId || !normalizedGameIdInput"
>
Join
</button>
<button class="ghost" v-if="gameId" @click="leaveSession" :disabled="sessionBusy">
Leave
</button>
</div>
<p class="session-meta">
<span>Status: {{ connectionLabel }}</span>
<span v-if="syncError">Error: {{ syncError }}</span>
</p>
</div>
<div class="teams-grid">
<div v-for="team in teams" :key="team.id" class="team-card">
@@ -23,28 +58,29 @@
v-model="team.name"
class="input"
placeholder="Team name"
:disabled="!canControlGame"
/>
<button class="ghost" @click="removeTeam(team.id)">Remove</button>
<button class="ghost" @click="removeTeam(team.id)" :disabled="!canControlGame">Remove</button>
</div>
<div class="gradient-preview" :style="teamGradient(team)"></div>
<div class="color-row">
<label>
Color A
<input v-model="team.colorA" type="color" />
<input v-model="team.colorA" type="color" :disabled="!canControlGame" />
</label>
<label>
Color B
<input v-model="team.colorB" type="color" />
<input v-model="team.colorB" type="color" :disabled="!canControlGame" />
</label>
</div>
</div>
<button class="add-card" @click="addTeam">
<button class="add-card" @click="addTeam" :disabled="!canControlGame">
<span>+ Add Team</span>
</button>
</div>
<div class="actions">
<button class="primary" :disabled="!canProceed" @click="goToSelect">
<button class="primary" :disabled="!canProceedToSelect" @click="goToSelect">
Next: Choose Game
</button>
</div>
@@ -53,12 +89,12 @@
<section v-if="step === 'select'" class="panel">
<h2>Select a Game</h2>
<p class="hint">
Games are loaded from <code>src/Data</code>. Each subfolder becomes a
Games are loaded from <code>public/Data</code>. Each subfolder becomes a
playable game.
</p>
<div v-if="loadingGames" class="empty-state">
<p>Loading games</p>
<p>Loading games...</p>
</div>
<div v-else-if="loadError" class="empty-state">
@@ -81,6 +117,7 @@
:key="game.name"
class="game-card"
@click="startGame(game)"
:disabled="!canControlGame"
>
<h3>{{ game.name }}</h3>
<p>{{ game.categories.length }} categories</p>
@@ -88,7 +125,7 @@
</div>
<div class="actions">
<button class="ghost" @click="step = 'setup'">Back</button>
<button class="ghost" @click="step = 'setup'" :disabled="!canControlGame">Back</button>
</div>
</section>
@@ -103,7 +140,7 @@
:class="{ active: team.id === currentSelectorId }"
:style="teamGradient(team)"
@click="awardPoints(team.id)"
:disabled="!canAward"
:disabled="!canAward || !canControlGame"
>
<div class="team-name">{{ team.name }}</div>
<div class="team-score">{{ team.score }}</div>
@@ -113,7 +150,7 @@
</button>
</div>
<div class="controls">
<button class="ghost" @click="resetGame">Reset Game</button>
<button class="ghost" @click="resetGame" :disabled="!canControlGame">Reset Game</button>
</div>
</aside>
@@ -131,7 +168,7 @@
class="tile"
:class="tileClass(cIndex, qIndex)"
:style="tileStyle(cIndex, qIndex)"
:disabled="tileDisabled(cIndex, qIndex)"
:disabled="tileDisabled(cIndex, qIndex) || !canControlGame"
@click="handleTileClick(cIndex, qIndex)"
@contextmenu.prevent="handleTileRightClick(cIndex, qIndex)"
>
@@ -252,6 +289,8 @@
import { nextTick } from 'vue'
import { loadGameData, type Game } from './dataLoader'
type Step = 'setup' | 'select' | 'game' | 'end'
type Team = {
id: string
name: string
@@ -267,6 +306,25 @@ type TileEntry = {
type TileMap = Record<string, TileEntry>
type RealtimeState = {
step: Step
teams: Team[]
selectedGameName: string | null
tiles: TileMap
currentTileKey: string | null
currentClipUrl: string
currentSelectorId: string | null
lastAwardedTeamId: string | null
isAnswerClip: boolean
}
type RealtimeMessage = {
type: 'session:init' | 'state:update'
state: RealtimeState | null
}
const REALTIME_BASE = '/realtime'
const makeTeam = (index: number): Team => ({
id: `team-${Date.now()}-${index}`,
name: `Team ${index + 1}`,
@@ -275,20 +333,27 @@ const makeTeam = (index: number): Team => ({
score: 0
})
const makeClientId = () => {
const segment = Math.random().toString(36).slice(2, 10)
return `client-${Date.now()}-${segment}`
}
const cloneTiles = (tiles: TileMap): TileMap => JSON.parse(JSON.stringify(tiles)) as TileMap
export default {
data() {
return {
step: 'setup',
step: 'setup' as Step,
teams: [makeTeam(0), makeTeam(1)] as Team[],
games: [] as Game[],
loadingGames: true,
loadError: '',
selectedGame: null as Game | null,
tiles: {} as TileMap,
currentTileKey: null,
currentTileKey: null as string | null,
currentClipUrl: '',
currentSelectorId: null,
lastAwardedTeamId: null,
currentSelectorId: null as string | null,
lastAwardedTeamId: null as string | null,
isPlaying: false,
pulseLevel: 0,
audioContext: null as AudioContext | null,
@@ -301,13 +366,33 @@ export default {
rayLevel: 0,
rayHue: 200,
isAnswerClip: false,
tentacleLevels: Array.from({ length: 12 }, () => 0)
tentacleLevels: Array.from({ length: 12 }, () => 0),
gameId: '',
gameIdInput: '',
sessionBusy: false,
socket: null as WebSocket | null,
socketConnected: false,
isHost: false,
clientId: makeClientId(),
syncError: '',
isApplyingRemote: false,
queuedRemoteState: null as RealtimeState | null,
syncTimer: 0
}
},
async mounted() {
try {
this.loadingGames = true
this.games = await loadGameData()
if (this.queuedRemoteState) {
await this.applyRemoteState(this.queuedRemoteState)
this.queuedRemoteState = null
}
const fromUrl = new URL(window.location.href).searchParams.get('game')
if (fromUrl) {
this.gameIdInput = fromUrl.toUpperCase()
await this.joinSession()
}
} catch (error) {
this.loadError = error instanceof Error ? error.message : 'Failed to load games.'
} finally {
@@ -315,9 +400,18 @@ export default {
}
},
computed: {
normalizedGameIdInput() {
return this.normalizeGameId(this.gameIdInput)
},
canProceed() {
return this.teams.length > 0 && this.teams.every((team) => team.name.trim())
},
canProceedToSelect() {
return this.canProceed && this.canControlGame && !!this.normalizedGameIdInput && !this.sessionBusy
},
canControlGame() {
return !this.gameId || this.isHost
},
currentSelector() {
return this.teams.find((team) => team.id === this.currentSelectorId) || null
},
@@ -333,6 +427,12 @@ export default {
.filter((team) => this.winnerIds.includes(team.id))
.map((team) => team.name)
.join(', ')
},
connectionLabel() {
if (!this.gameId) return 'Local mode'
if (this.sessionBusy) return 'Connecting'
if (!this.socketConnected) return this.isHost ? 'Host disconnected' : 'Viewer disconnected'
return this.isHost ? 'Connected as host' : 'Connected as viewer'
}
},
watch: {
@@ -345,6 +445,190 @@ export default {
}
},
methods: {
normalizeGameId(value: string) {
return value.toUpperCase().replace(/[^A-Z0-9]/g, '')
},
setGameInUrl(gameId: string) {
const url = new URL(window.location.href)
if (gameId) {
url.searchParams.set('game', gameId)
} else {
url.searchParams.delete('game')
}
window.history.replaceState({}, '', url.toString())
},
closeSocket() {
if (this.socket) {
this.socket.close()
this.socket = null
}
this.socketConnected = false
},
async createSession(preferredId?: string) {
if (this.sessionBusy || this.gameId) return
const requestedId = this.normalizeGameId(preferredId || this.gameIdInput)
if (!requestedId) {
this.syncError = 'Game ID is required'
return
}
this.sessionBusy = true
this.syncError = ''
try {
const response = await fetch(`${REALTIME_BASE}/sessions`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ gameId: requestedId })
})
if (!response.ok) {
let errorMessage = 'Failed to create session'
try {
const payload = (await response.json()) as { error?: string }
if (payload?.error) errorMessage = payload.error
} catch {
// ignore parse errors and keep default message
}
throw new Error(errorMessage)
}
const payload = (await response.json()) as { gameId: string }
const gameId = this.normalizeGameId(payload.gameId)
this.gameId = gameId
this.gameIdInput = gameId
this.isHost = true
this.setGameInUrl(gameId)
await this.connectSession()
} catch (error) {
this.syncError = error instanceof Error ? error.message : 'Failed to create session'
} finally {
this.sessionBusy = false
}
},
async joinSession() {
if (this.sessionBusy || this.gameId) return
const normalized = this.normalizeGameId(this.gameIdInput)
if (!normalized) return
this.sessionBusy = true
this.syncError = ''
try {
const existsResponse = await fetch(`${REALTIME_BASE}/sessions/${normalized}`)
if (!existsResponse.ok) throw new Error('Session not found')
this.gameId = normalized
this.gameIdInput = normalized
this.isHost = false
this.setGameInUrl(normalized)
await this.connectSession()
} catch (error) {
this.syncError = error instanceof Error ? error.message : 'Failed to join session'
} finally {
this.sessionBusy = false
}
},
leaveSession() {
this.closeSocket()
this.gameId = ''
this.gameIdInput = ''
this.isHost = false
this.syncError = ''
this.setGameInUrl('')
},
async connectSession() {
this.closeSocket()
if (!this.gameId) return
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const wsUrl = `${protocol}//${window.location.host}${REALTIME_BASE}/ws?gameId=${encodeURIComponent(
this.gameId
)}&clientId=${encodeURIComponent(this.clientId)}`
const socket = new WebSocket(wsUrl)
this.socket = socket
await new Promise<void>((resolve, reject) => {
socket.onopen = () => {
if (this.socket !== socket) return
this.socketConnected = true
this.syncError = ''
if (this.isHost) this.publishState()
resolve()
}
socket.onerror = () => {
if (this.socket !== socket) return
this.socketConnected = false
this.syncError = 'Realtime connection failed'
reject(new Error('Realtime connection failed'))
}
}).catch(() => {})
socket.onmessage = async (event) => {
let message: RealtimeMessage | null = null
try {
message = JSON.parse(event.data as string) as RealtimeMessage
} catch {
return
}
if (!message) return
if (message.type === 'session:init' || message.type === 'state:update') {
if (message.state) await this.applyRemoteState(message.state)
}
}
socket.onclose = () => {
if (this.socket === socket) {
this.socketConnected = false
}
}
},
buildRealtimeState(): RealtimeState {
return {
step: this.step,
teams: this.teams.map((team) => ({ ...team })),
selectedGameName: this.selectedGame?.name || null,
tiles: cloneTiles(this.tiles),
currentTileKey: this.currentTileKey,
currentClipUrl: this.currentClipUrl,
currentSelectorId: this.currentSelectorId,
lastAwardedTeamId: this.lastAwardedTeamId,
isAnswerClip: this.isAnswerClip
}
},
publishState() {
if (!this.isHost || !this.socketConnected || !this.socket) return
if (this.isApplyingRemote) return
const payload = {
type: 'state:update',
state: this.buildRealtimeState()
}
this.socket.send(JSON.stringify(payload))
},
queueStateSync() {
if (!this.isHost || !this.socketConnected || !this.socket) return
if (this.isApplyingRemote) return
if (this.syncTimer) window.clearTimeout(this.syncTimer)
this.syncTimer = window.setTimeout(() => {
this.publishState()
}, 80)
},
async applyRemoteState(state: RealtimeState) {
if (this.isHost) return
if (this.loadingGames) {
this.queuedRemoteState = state
return
}
this.isApplyingRemote = true
try {
const selected = state.selectedGameName
? this.games.find((game) => game.name === state.selectedGameName) || null
: null
this.step = state.step
this.teams = state.teams.map((team) => ({ ...team }))
this.selectedGame = selected
this.tiles = cloneTiles(state.tiles)
this.currentTileKey = state.currentTileKey
this.currentClipUrl = state.currentClipUrl
this.currentSelectorId = state.currentSelectorId
this.lastAwardedTeamId = state.lastAwardedTeamId
this.isAnswerClip = state.isAnswerClip
} finally {
this.isApplyingRemote = false
}
},
getPlayer() {
return this.$refs.player as HTMLAudioElement | undefined
},
@@ -496,16 +780,32 @@ export default {
}
},
addTeam() {
if (!this.canControlGame) return
this.teams.push(makeTeam(this.teams.length))
this.queueStateSync()
},
removeTeam(id: string) {
if (!this.canControlGame) return
if (this.teams.length === 1) return
this.teams = this.teams.filter((team) => team.id !== id)
this.queueStateSync()
},
goToSelect() {
async goToSelect() {
if (!this.canControlGame) return
const requestedId = this.normalizeGameId(this.gameIdInput)
if (!requestedId) {
this.syncError = 'Game ID is required'
return
}
if (!this.gameId) {
await this.createSession(requestedId)
if (!this.gameId) return
}
this.step = 'select'
this.queueStateSync()
},
startGame(game: Game) {
if (!this.canControlGame) return
this.selectedGame = game
this.tiles = {}
this.currentTileKey = null
@@ -515,8 +815,10 @@ export default {
const randomTeam = this.teams[Math.floor(Math.random() * this.teams.length)]
this.currentSelectorId = randomTeam?.id || null
this.step = 'game'
this.queueStateSync()
},
resetGame() {
if (!this.canControlGame) return
this.step = 'select'
this.selectedGame = null
this.tiles = {}
@@ -525,6 +827,7 @@ export default {
this.lastAwardedTeamId = null
this.isAnswerClip = false
this.teardownAudio()
this.queueStateSync()
},
tileKey(cIndex: number, qIndex: number) {
return `${cIndex}-${qIndex}`
@@ -558,6 +861,7 @@ export default {
return this.teamGradient(team)
},
async handleTileClick(cIndex: number, qIndex: number) {
if (!this.canControlGame) return
const key = this.tileKey(cIndex, qIndex)
const status = this.tileStatus(cIndex, qIndex)
const clue = this.selectedGame?.categories[cIndex].clues[qIndex]
@@ -577,6 +881,7 @@ export default {
player.load()
player.play().catch(() => {})
}
this.queueStateSync()
return
}
@@ -584,6 +889,7 @@ export default {
this.tiles[key].status = 'paused'
const player = this.getPlayer()
player?.pause()
this.queueStateSync()
return
}
@@ -602,6 +908,7 @@ export default {
player.play().catch(() => {})
}
}
this.queueStateSync()
return
}
@@ -614,9 +921,11 @@ export default {
this.currentClipUrl = ''
this.lastAwardedTeamId = null
this.checkEnd()
this.queueStateSync()
}
},
handleTileRightClick(cIndex: number, qIndex: number) {
if (!this.canControlGame) return
const key = this.tileKey(cIndex, qIndex)
const status = this.tileStatus(cIndex, qIndex)
if (key !== this.currentTileKey) return
@@ -625,6 +934,7 @@ export default {
this.tiles[key].status = 'playing'
const player = this.getPlayer()
player?.play().catch(() => {})
this.queueStateSync()
return
}
@@ -637,9 +947,11 @@ export default {
player?.pause()
this.teardownAudio()
this.checkEnd()
this.queueStateSync()
}
},
awardPoints(teamId: string) {
if (!this.canControlGame) return
if (!this.canAward) return
const key = this.currentTileKey
if (!key || !this.selectedGame) return
@@ -652,6 +964,7 @@ export default {
if (this.tiles[key]) {
this.tiles[key].lastTeamId = teamId
}
this.queueStateSync()
},
checkEnd() {
if (!this.selectedGame) return
@@ -669,6 +982,9 @@ export default {
},
unmounted() {
this.teardownAudio()
this.closeSocket()
if (this.syncTimer) window.clearTimeout(this.syncTimer)
}
}
</script>

View File

@@ -72,6 +72,10 @@ code {
font-weight: 600;
}
.session-pill {
border-color: rgba(61, 214, 255, 0.5);
}
.app-main {
flex: 1;
}
@@ -96,6 +100,38 @@ code {
color: rgba(255, 255, 255, 0.7);
}
.session-box {
margin-top: 18px;
margin-bottom: 20px;
padding: 14px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.15);
background: rgba(255, 255, 255, 0.04);
}
.session-box h3 {
margin: 0 0 6px;
}
.session-row {
display: flex;
gap: 10px;
align-items: center;
}
.session-row .input {
max-width: 220px;
}
.session-meta {
margin: 12px 0 0;
display: flex;
gap: 14px;
flex-wrap: wrap;
color: rgba(255, 255, 255, 0.75);
font-size: 0.9rem;
}
.teams-grid,
.games-grid {
display: grid;
@@ -171,6 +207,11 @@ input[type='color'] {
background: linear-gradient(135deg, rgba(61, 214, 255, 0.12), rgba(251, 215, 43, 0.12));
}
.add-card:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.game-card {
cursor: pointer;
transition: transform 0.2s ease;
@@ -534,4 +575,8 @@ audio.hidden-audio {
flex-direction: column;
align-items: flex-start;
}
.session-row {
flex-wrap: wrap;
}
}