1202 lines
39 KiB
Vue
1202 lines
39 KiB
Vue
<template>
|
|
<div class="app" :class="{ suspended: viewerSuspended }">
|
|
<header class="app-header">
|
|
<div>
|
|
<p class="eyebrow">Music Jeopardy</p>
|
|
<h1>Track the Beat, Claim the Points</h1>
|
|
</div>
|
|
<div v-if="step === 'game'" class="selector-pill">
|
|
<span class="label">Selecting</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>
|
|
|
|
<main class="app-main">
|
|
<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">
|
|
<div class="team-card-header">
|
|
<input
|
|
v-model="team.name"
|
|
class="input"
|
|
placeholder="Team name"
|
|
:disabled="!canControlGame"
|
|
/>
|
|
<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" :disabled="!canControlGame" />
|
|
</label>
|
|
<label>
|
|
Color B
|
|
<input v-model="team.colorB" type="color" :disabled="!canControlGame" />
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<button class="add-card" @click="addTeam" :disabled="!canControlGame">
|
|
<span>+ Add Team</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="actions">
|
|
<button class="primary" :disabled="!canProceedToSelect" @click="goToSelect">
|
|
Next: Choose Game
|
|
</button>
|
|
</div>
|
|
</section>
|
|
|
|
<section v-if="step === 'select'" class="panel">
|
|
<h2>Select a Game</h2>
|
|
<p class="hint">
|
|
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>
|
|
</div>
|
|
|
|
<div v-else-if="loadError" class="empty-state">
|
|
<p>Could not load games.</p>
|
|
<p>{{ loadError }}</p>
|
|
</div>
|
|
|
|
<div v-else-if="games.length === 0" class="empty-state">
|
|
<p>No games found yet.</p>
|
|
<p>
|
|
Add folders in <code>public/Data/<GameName>/<Category></code>
|
|
with <code>Songs</code> and <code>Answers</code> subfolders, then
|
|
run <code>npm run generate:data</code>.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="games-grid" v-else>
|
|
<button
|
|
v-for="game in games"
|
|
:key="game.name"
|
|
class="game-card"
|
|
@click="startGame(game)"
|
|
:disabled="!canControlGame"
|
|
>
|
|
<h3>{{ game.name }}</h3>
|
|
<p>{{ game.categories.length }} categories</p>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="actions">
|
|
<button class="ghost" @click="step = 'setup'" :disabled="!canControlGame">Back</button>
|
|
</div>
|
|
</section>
|
|
|
|
<section v-if="step === 'game'" class="game-layout">
|
|
<aside class="scoreboard">
|
|
<h2>Teams</h2>
|
|
<div v-if="!canControlGame" class="viewer-team-select">
|
|
<label for="viewer-team">Playing For</label>
|
|
<select id="viewer-team" v-model="viewerTeamId" class="input">
|
|
<option value="">Select team</option>
|
|
<option v-for="team in teams" :key="`viewer-${team.id}`" :value="team.id">
|
|
{{ team.name }}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
<div class="team-list">
|
|
<button
|
|
v-for="team in teams"
|
|
:key="team.id"
|
|
class="team-score-card"
|
|
:class="{
|
|
active: team.id === currentSelectorId,
|
|
viewerTeam: !canControlGame && !!viewerTeamId && team.id === viewerTeamId
|
|
}"
|
|
:style="teamGradient(team)"
|
|
@click="awardPoints(team.id)"
|
|
:disabled="!canAward || !canControlGame"
|
|
>
|
|
<div class="team-name">{{ team.name }}</div>
|
|
<div class="team-score">{{ team.score }}</div>
|
|
<div v-if="team.id === lastAwardedTeamId" class="award-tag">
|
|
Last Award
|
|
</div>
|
|
</button>
|
|
</div>
|
|
<div class="controls">
|
|
<button class="ghost" @click="resetGame" :disabled="!canControlGame">Reset Game</button>
|
|
</div>
|
|
</aside>
|
|
|
|
<section class="board">
|
|
<div v-if="isGuessSuspended && !!guessingTeamId" class="board-guessing-banner">
|
|
{{ guessingTeamLabel }} is guessing
|
|
</div>
|
|
<div class="board-grid">
|
|
<div
|
|
v-for="(category, cIndex) in selectedGame?.categories || []"
|
|
:key="category.name"
|
|
class="category-column"
|
|
>
|
|
<div class="category-title">{{ category.name }}</div>
|
|
<button
|
|
v-for="(clue, qIndex) in category.clues"
|
|
:key="clue.points"
|
|
class="tile"
|
|
:class="tileClass(cIndex, qIndex)"
|
|
:style="tileStyle(cIndex, qIndex)"
|
|
:disabled="tileDisabled(cIndex, qIndex) || !canControlGame"
|
|
@click="handleTileClick(cIndex, qIndex)"
|
|
@contextmenu.prevent="handleTileRightClick(cIndex, qIndex)"
|
|
>
|
|
<span v-if="tileStatus(cIndex, qIndex) === 'available'">
|
|
{{ clue.points }}
|
|
</span>
|
|
<span v-else-if="tileStatus(cIndex, qIndex) === 'playing'">
|
|
Playing
|
|
</span>
|
|
<span v-else-if="tileStatus(cIndex, qIndex) === 'paused'">
|
|
Paused
|
|
</span>
|
|
<span v-else-if="tileStatus(cIndex, qIndex) === 'guessed'">
|
|
Guessed
|
|
</span>
|
|
<span v-else-if="tileStatus(cIndex, qIndex) === 'won'">
|
|
Won
|
|
</span>
|
|
<span v-else>Skipped</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="player-panel">
|
|
<div class="player-header">
|
|
<h3>Now Playing</h3>
|
|
<p>
|
|
Left click the active tile to pause/guess. Right click to resume
|
|
or skip.
|
|
</p>
|
|
</div>
|
|
<div
|
|
class="player-body"
|
|
:class="{
|
|
playing: isPlaying && !isAnswerClip,
|
|
answer: isPlaying && isAnswerClip,
|
|
idle: !currentClipUrl
|
|
}"
|
|
>
|
|
<div
|
|
class="pulse-orb"
|
|
:class="{
|
|
active: isPlaying,
|
|
playing: isPlaying && !isAnswerClip,
|
|
answer: isPlaying && isAnswerClip,
|
|
idle: !currentClipUrl
|
|
}"
|
|
:style="{
|
|
'--pulse': pulseLevel.toFixed(3),
|
|
'--ray': rayLevel.toFixed(3),
|
|
'--ray-hue': rayHue.toFixed(0)
|
|
}"
|
|
></div>
|
|
<div
|
|
class="pulse-tentacles"
|
|
:class="{
|
|
playing: isPlaying && !isAnswerClip,
|
|
answer: isPlaying && isAnswerClip,
|
|
idle: !currentClipUrl
|
|
}"
|
|
:style="{ '--pulse': pulseLevel.toFixed(3) }"
|
|
>
|
|
<span
|
|
v-for="(level, index) in tentacleLevels"
|
|
:key="index"
|
|
class="tentacle"
|
|
:style="{
|
|
'--level': level.toFixed(3),
|
|
'--index': index
|
|
}"
|
|
></span>
|
|
</div>
|
|
<audio
|
|
ref="player"
|
|
v-if="currentClipUrl"
|
|
:src="currentClipUrl"
|
|
class="hidden-audio"
|
|
preload="auto"
|
|
@play="handlePlayerPlay"
|
|
@pause="handlePlayerPause"
|
|
@ended="handlePlayerPause"
|
|
></audio>
|
|
<div v-if="viewerGuessVisible || showEnableAudio" class="viewer-actions">
|
|
<button v-if="showEnableAudio" class="primary enable-audio" @click="enableViewerAudio">
|
|
Tap To Enable Audio
|
|
</button>
|
|
<button class="primary viewer-guess" :disabled="!canViewerGuess" @click="requestGuessStop">
|
|
Stop Song And Guess
|
|
</button>
|
|
</div>
|
|
<button
|
|
v-if="canControlGame && getCurrentTileStatus() === 'paused' && !!guessingTeamId"
|
|
class="primary host-reveal"
|
|
@click="revealPausedGuess"
|
|
>
|
|
Reveal Answer
|
|
</button>
|
|
<div v-if="!currentClipUrl" class="player-empty"></div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</section>
|
|
|
|
<section v-if="step === 'end'" class="panel end-panel">
|
|
<h2>Final Scores</h2>
|
|
<div class="winner-banner">
|
|
<span>Winner</span>
|
|
<strong>{{ winnerNames }}</strong>
|
|
</div>
|
|
<div class="teams-grid">
|
|
<div
|
|
v-for="team in teams"
|
|
:key="team.id"
|
|
class="team-card"
|
|
:class="{ winner: winnerIds.includes(team.id) }"
|
|
>
|
|
<div class="gradient-preview" :style="teamGradient(team)"></div>
|
|
<h3>{{ team.name }}</h3>
|
|
<p class="score">{{ team.score }} pts</p>
|
|
</div>
|
|
</div>
|
|
<div class="actions">
|
|
<button class="primary" @click="step = 'setup'">
|
|
Start New Game
|
|
</button>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
<div v-if="viewerSuspended" class="guess-overlay">
|
|
<p>{{ guessingTeamLabel }} is guessing</p>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script lang="ts">
|
|
import { nextTick } from 'vue'
|
|
import { loadGameData, type Game } from './dataLoader'
|
|
|
|
type Step = 'setup' | 'select' | 'game' | 'end'
|
|
|
|
type Team = {
|
|
id: string
|
|
name: string
|
|
colorA: string
|
|
colorB: string
|
|
score: number
|
|
}
|
|
|
|
type TileEntry = {
|
|
status: 'available' | 'playing' | 'paused' | 'guessed' | 'won' | 'void'
|
|
lastTeamId: string | null
|
|
}
|
|
|
|
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
|
|
guessingTeamId: string | null
|
|
}
|
|
|
|
type RealtimeMessage = {
|
|
type: 'session:init' | 'state:update' | 'guess:request'
|
|
state?: RealtimeState | null
|
|
teamId?: string
|
|
}
|
|
|
|
const REALTIME_BASE = '/realtime'
|
|
|
|
const makeTeam = (index: number): Team => ({
|
|
id: `team-${Date.now()}-${index}`,
|
|
name: `Team ${index + 1}`,
|
|
colorA: '#3dd6ff',
|
|
colorB: '#fbd72b',
|
|
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' 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 as string | null,
|
|
currentClipUrl: '',
|
|
currentSelectorId: null as string | null,
|
|
lastAwardedTeamId: null as string | null,
|
|
isPlaying: false,
|
|
pulseLevel: 0,
|
|
audioContext: null as AudioContext | null,
|
|
analyser: null as AnalyserNode | null,
|
|
analyserData: null as Uint8Array | null,
|
|
frequencyData: null as Uint8Array | null,
|
|
rafId: 0,
|
|
mediaSource: null as MediaElementAudioSourceNode | null,
|
|
mediaElement: null as HTMLAudioElement | null,
|
|
rayLevel: 0,
|
|
rayHue: 200,
|
|
isAnswerClip: false,
|
|
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,
|
|
audioUnlocked: true,
|
|
latestRemoteState: null as RealtimeState | null,
|
|
viewerTeamId: '',
|
|
guessingTeamId: null as string | null,
|
|
pauseTransitionLockUntil: 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 {
|
|
this.loadingGames = false
|
|
}
|
|
},
|
|
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
|
|
},
|
|
showEnableAudio() {
|
|
return !this.canControlGame && !!this.currentClipUrl && !this.audioUnlocked
|
|
},
|
|
canViewerGuess() {
|
|
return (
|
|
!this.canControlGame &&
|
|
!!this.viewerTeamId &&
|
|
this.getCurrentTileStatus() === 'playing'
|
|
)
|
|
},
|
|
viewerGuessVisible() {
|
|
return !this.canControlGame && this.getCurrentTileStatus() === 'playing'
|
|
},
|
|
canControlGame() {
|
|
return !this.gameId || this.isHost
|
|
},
|
|
isGuessSuspended() {
|
|
return this.step === 'game' && this.getCurrentTileStatus() === 'paused' && !!this.guessingTeamId
|
|
},
|
|
viewerSuspended() {
|
|
return this.isGuessSuspended && !this.canControlGame
|
|
},
|
|
guessingTeamLabel() {
|
|
if (this.guessingTeamId) {
|
|
const team = this.teams.find((candidate) => candidate.id === this.guessingTeamId)
|
|
if (team?.name?.trim()) return team.name.trim()
|
|
}
|
|
return 'A team'
|
|
},
|
|
currentSelector() {
|
|
return this.teams.find((team) => team.id === this.currentSelectorId) || null
|
|
},
|
|
canAward() {
|
|
return !!this.currentTileKey && this.tiles[this.currentTileKey]?.status === 'guessed'
|
|
},
|
|
winnerIds() {
|
|
const maxScore = Math.max(...this.teams.map((team) => team.score))
|
|
return this.teams.filter((team) => team.score === maxScore).map((team) => team.id)
|
|
},
|
|
winnerNames() {
|
|
return this.teams
|
|
.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: {
|
|
currentClipUrl(newValue: string) {
|
|
if (!newValue) {
|
|
this.teardownAudio()
|
|
return
|
|
}
|
|
nextTick(() => this.setupAudioGraph())
|
|
}
|
|
},
|
|
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.audioUnlocked = 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')
|
|
const sessionInfo = (await existsResponse.json()) as { hasState?: boolean }
|
|
if (!sessionInfo.hasState) {
|
|
throw new Error('Game is not open yet. Host must press Next: Choose Game first.')
|
|
}
|
|
this.gameId = normalized
|
|
this.gameIdInput = normalized
|
|
this.isHost = false
|
|
this.audioUnlocked = false
|
|
this.viewerTeamId = ''
|
|
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.audioUnlocked = true
|
|
this.latestRemoteState = null
|
|
this.viewerTeamId = ''
|
|
this.guessingTeamId = null
|
|
this.pauseTransitionLockUntil = 0
|
|
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
|
|
|
|
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)
|
|
return
|
|
}
|
|
if (message.type === 'guess:request') {
|
|
await this.handleRemoteGuessRequest(message.teamId || '')
|
|
}
|
|
}
|
|
|
|
socket.onclose = () => {
|
|
if (this.socket === socket) {
|
|
this.socketConnected = false
|
|
}
|
|
}
|
|
|
|
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(() => {})
|
|
},
|
|
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,
|
|
guessingTeamId: this.guessingTeamId
|
|
}
|
|
},
|
|
publishState() {
|
|
if (!this.isHost || !this.socketConnected || !this.socket) return
|
|
if (this.isApplyingRemote) return
|
|
if (this.step === 'setup') 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.latestRemoteState = state
|
|
const previousClipUrl = this.currentClipUrl
|
|
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
|
|
this.guessingTeamId = state.guessingTeamId || null
|
|
if (this.viewerTeamId && !this.teams.some((team) => team.id === this.viewerTeamId)) {
|
|
this.viewerTeamId = ''
|
|
}
|
|
} finally {
|
|
this.isApplyingRemote = false
|
|
}
|
|
await nextTick()
|
|
this.syncRemotePlayback(previousClipUrl !== this.currentClipUrl)
|
|
},
|
|
async enableViewerAudio() {
|
|
if (this.canControlGame) return
|
|
this.audioUnlocked = true
|
|
this.ensureAudioContext()
|
|
await nextTick()
|
|
this.syncRemotePlayback(false)
|
|
},
|
|
requestGuessStop() {
|
|
if (this.canControlGame) return
|
|
if (!this.socketConnected || !this.socket) return
|
|
if (!this.viewerTeamId) return
|
|
if (this.getCurrentTileStatus() !== 'playing') return
|
|
this.socket.send(
|
|
JSON.stringify({
|
|
type: 'guess:request',
|
|
teamId: this.viewerTeamId
|
|
})
|
|
)
|
|
},
|
|
async handleRemoteGuessRequest(teamId: string) {
|
|
if (!this.canControlGame || !this.isHost) return
|
|
if (!teamId) return
|
|
if (!this.currentTileKey || !this.selectedGame) return
|
|
const status = this.getCurrentTileStatus()
|
|
if (status !== 'playing') return
|
|
const key = this.currentTileKey
|
|
this.tiles[key].status = 'paused'
|
|
this.lastAwardedTeamId = null
|
|
this.guessingTeamId = teamId
|
|
this.pauseTransitionLockUntil = Date.now() + 1200
|
|
this.isAnswerClip = false
|
|
const player = this.getPlayer()
|
|
player?.pause()
|
|
this.queueStateSync()
|
|
},
|
|
getCurrentTileStatus() {
|
|
if (!this.currentTileKey) return 'available'
|
|
return this.tiles[this.currentTileKey]?.status || 'available'
|
|
},
|
|
syncRemotePlayback(forceReload: boolean) {
|
|
if (this.canControlGame) return
|
|
const player = this.getPlayer()
|
|
if (!player) return
|
|
const tileStatus = this.getCurrentTileStatus()
|
|
|
|
if (!this.currentClipUrl || tileStatus === 'won' || tileStatus === 'void') {
|
|
player.pause()
|
|
return
|
|
}
|
|
|
|
if (forceReload) {
|
|
player.currentTime = 0
|
|
player.load()
|
|
}
|
|
|
|
if (tileStatus === 'paused') {
|
|
player.pause()
|
|
return
|
|
}
|
|
|
|
if (tileStatus === 'playing' || tileStatus === 'guessed') {
|
|
if (!this.audioUnlocked) return
|
|
this.ensureAudioContext()
|
|
player.play().catch(() => {})
|
|
}
|
|
},
|
|
getPlayer() {
|
|
return this.$refs.player as HTMLAudioElement | undefined
|
|
},
|
|
ensureAudioContext() {
|
|
if (!this.audioContext) {
|
|
this.audioContext = new AudioContext()
|
|
}
|
|
if (this.audioContext.state === 'suspended') {
|
|
this.audioContext.resume().catch(() => {})
|
|
}
|
|
},
|
|
setupAudioGraph() {
|
|
const player = this.getPlayer()
|
|
if (!player) return
|
|
try {
|
|
this.ensureAudioContext()
|
|
} catch {
|
|
return
|
|
}
|
|
|
|
if (this.analyser) {
|
|
this.analyser.disconnect()
|
|
this.analyser = null
|
|
}
|
|
|
|
const context = this.audioContext
|
|
if (!context) return
|
|
|
|
if (this.mediaElement !== player) {
|
|
if (this.mediaSource) {
|
|
this.mediaSource.disconnect()
|
|
}
|
|
try {
|
|
this.mediaSource = context.createMediaElementSource(player)
|
|
} catch {
|
|
return
|
|
}
|
|
this.mediaElement = player
|
|
}
|
|
|
|
const analyser = context.createAnalyser()
|
|
analyser.fftSize = 1024
|
|
this.mediaSource?.connect(analyser)
|
|
analyser.connect(context.destination)
|
|
|
|
this.analyser = analyser
|
|
this.analyserData = new Uint8Array(analyser.fftSize)
|
|
this.frequencyData = new Uint8Array(analyser.frequencyBinCount)
|
|
this.pulseLevel = 0
|
|
this.rayLevel = 0
|
|
this.tentacleLevels = this.tentacleLevels.map(() => 0)
|
|
},
|
|
teardownAudio() {
|
|
if (this.rafId) {
|
|
cancelAnimationFrame(this.rafId)
|
|
this.rafId = 0
|
|
}
|
|
if (this.analyser) {
|
|
this.analyser.disconnect()
|
|
this.analyser = null
|
|
}
|
|
if (this.mediaSource) {
|
|
this.mediaSource.disconnect()
|
|
this.mediaSource = null
|
|
}
|
|
this.mediaElement = null
|
|
this.analyserData = null
|
|
this.frequencyData = null
|
|
this.isPlaying = false
|
|
this.pulseLevel = 0
|
|
this.rayLevel = 0
|
|
this.tentacleLevels = this.tentacleLevels.map(() => 0)
|
|
},
|
|
startPulse() {
|
|
if (!this.analyser || !this.analyserData || !this.frequencyData) return
|
|
const analyser = this.analyser
|
|
const data = this.analyserData
|
|
const freq = this.frequencyData
|
|
|
|
const tick = () => {
|
|
if (!this.isPlaying || !this.analyser || !this.analyserData || !this.frequencyData) {
|
|
this.rafId = 0
|
|
return
|
|
}
|
|
analyser.getByteTimeDomainData(data)
|
|
analyser.getByteFrequencyData(freq)
|
|
let sumSquares = 0
|
|
for (let i = 0; i < data.length; i += 1) {
|
|
const centered = (data[i] - 128) / 128
|
|
sumSquares += centered * centered
|
|
}
|
|
const rms = Math.sqrt(sumSquares / data.length)
|
|
const target = Math.min(1, rms * 3.6)
|
|
this.pulseLevel = this.pulseLevel * 0.65 + target * 0.35
|
|
|
|
const lowBandEnd = Math.floor(freq.length * 0.2)
|
|
const highBandStart = Math.floor(freq.length * 0.55)
|
|
let lowSum = 0
|
|
for (let i = 0; i < lowBandEnd; i += 1) lowSum += freq[i]
|
|
const lowAvg = lowSum / Math.max(1, lowBandEnd) / 255
|
|
|
|
let highSum = 0
|
|
for (let i = highBandStart; i < freq.length; i += 1) highSum += freq[i]
|
|
const highAvg = highSum / Math.max(1, freq.length - highBandStart) / 255
|
|
|
|
const weightedEnergy = lowAvg * 0.4 + highAvg * 0.7
|
|
const compressed = Math.pow(Math.min(1, weightedEnergy * 1.1), 1.8)
|
|
const rayTarget = Math.min(1, compressed)
|
|
this.rayLevel = this.rayLevel * 0.78 + rayTarget * 0.22
|
|
const hueTarget = 180 + highAvg * 120
|
|
this.rayHue = this.rayHue * 0.8 + hueTarget * 0.2
|
|
|
|
const bandCount = this.tentacleLevels.length
|
|
const binSize = Math.max(1, Math.floor(freq.length / bandCount))
|
|
for (let i = 0; i < bandCount; i += 1) {
|
|
const start = i * binSize
|
|
const end = i === bandCount - 1 ? freq.length : start + binSize
|
|
let bandSum = 0
|
|
for (let j = start; j < end; j += 1) bandSum += freq[j]
|
|
const avg = bandSum / Math.max(1, end - start) / 255
|
|
const amplified = Math.min(1, avg * 1.25)
|
|
const shaped = Math.log1p(amplified * 9) / Math.log(10)
|
|
this.tentacleLevels[i] =
|
|
this.tentacleLevels[i] * 0.6 + shaped * 0.4
|
|
}
|
|
this.rafId = requestAnimationFrame(tick)
|
|
}
|
|
|
|
if (this.rafId) cancelAnimationFrame(this.rafId)
|
|
this.rafId = requestAnimationFrame(tick)
|
|
},
|
|
handlePlayerPlay() {
|
|
this.isPlaying = true
|
|
this.startPulse()
|
|
if (this.isHost) this.queueStateSync()
|
|
},
|
|
handlePlayerPause() {
|
|
this.isPlaying = false
|
|
if (this.rafId) {
|
|
cancelAnimationFrame(this.rafId)
|
|
this.rafId = 0
|
|
}
|
|
this.pulseLevel = 0
|
|
if (this.isHost) this.queueStateSync()
|
|
},
|
|
togglePlayback() {
|
|
const player = this.getPlayer()
|
|
if (!player) return
|
|
this.ensureAudioContext()
|
|
if (player.paused) {
|
|
player.play().catch(() => {})
|
|
} else {
|
|
player.pause()
|
|
}
|
|
},
|
|
teamGradient(team: Team) {
|
|
return {
|
|
background: `linear-gradient(135deg, ${team.colorA}, ${team.colorB})`
|
|
}
|
|
},
|
|
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()
|
|
},
|
|
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
|
|
this.currentClipUrl = ''
|
|
this.lastAwardedTeamId = null
|
|
this.guessingTeamId = null
|
|
this.pauseTransitionLockUntil = 0
|
|
this.teams = this.teams.map((team) => ({ ...team, score: 0 }))
|
|
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 = {}
|
|
this.currentTileKey = null
|
|
this.currentClipUrl = ''
|
|
this.lastAwardedTeamId = null
|
|
this.isAnswerClip = false
|
|
this.guessingTeamId = null
|
|
this.pauseTransitionLockUntil = 0
|
|
this.teardownAudio()
|
|
this.queueStateSync()
|
|
},
|
|
tileKey(cIndex: number, qIndex: number) {
|
|
return `${cIndex}-${qIndex}`
|
|
},
|
|
tileStatus(cIndex: number, qIndex: number) {
|
|
const key = this.tileKey(cIndex, qIndex)
|
|
return this.tiles[key]?.status || 'available'
|
|
},
|
|
tileDisabled(cIndex: number, qIndex: number) {
|
|
const status = this.tileStatus(cIndex, qIndex)
|
|
if (status === 'won' || status === 'void') return true
|
|
if (!this.currentTileKey) return false
|
|
return this.currentTileKey !== this.tileKey(cIndex, qIndex)
|
|
},
|
|
tileClass(cIndex: number, qIndex: number) {
|
|
const status = this.tileStatus(cIndex, qIndex)
|
|
return {
|
|
playing: status === 'playing',
|
|
paused: status === 'paused',
|
|
guessed: status === 'guessed',
|
|
won: status === 'won',
|
|
void: status === 'void'
|
|
}
|
|
},
|
|
tileStyle(cIndex: number, qIndex: number) {
|
|
const key = this.tileKey(cIndex, qIndex)
|
|
const entry = this.tiles[key]
|
|
if (entry?.status !== 'won' || !entry.lastTeamId) return {}
|
|
const team = this.teams.find((t) => t.id === entry.lastTeamId)
|
|
if (!team) return {}
|
|
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]
|
|
|
|
if (!clue || !clue.song) return
|
|
|
|
if (status === 'available') {
|
|
this.tiles[key] = { status: 'playing', lastTeamId: null }
|
|
this.currentTileKey = key
|
|
this.currentClipUrl = encodeURI(clue.song)
|
|
this.isAnswerClip = false
|
|
this.guessingTeamId = null
|
|
this.pauseTransitionLockUntil = 0
|
|
await nextTick()
|
|
const player = this.getPlayer()
|
|
if (player) {
|
|
this.ensureAudioContext()
|
|
player.currentTime = 0
|
|
player.load()
|
|
player.play().catch(() => {})
|
|
}
|
|
this.queueStateSync()
|
|
return
|
|
}
|
|
|
|
if (status === 'playing') {
|
|
this.tiles[key].status = 'paused'
|
|
this.guessingTeamId = null
|
|
this.pauseTransitionLockUntil = 0
|
|
const player = this.getPlayer()
|
|
player?.pause()
|
|
this.queueStateSync()
|
|
return
|
|
}
|
|
|
|
if (status === 'paused') {
|
|
if (this.guessingTeamId) return
|
|
if (Date.now() < this.pauseTransitionLockUntil) return
|
|
this.tiles[key].status = 'guessed'
|
|
this.guessingTeamId = null
|
|
this.pauseTransitionLockUntil = 0
|
|
this.lastAwardedTeamId = null
|
|
if (clue.answer) {
|
|
this.currentClipUrl = encodeURI(clue.answer)
|
|
this.isAnswerClip = true
|
|
await nextTick()
|
|
const player = this.getPlayer()
|
|
if (player) {
|
|
this.ensureAudioContext()
|
|
player.currentTime = 0
|
|
player.load()
|
|
player.play().catch(() => {})
|
|
}
|
|
}
|
|
this.queueStateSync()
|
|
return
|
|
}
|
|
|
|
if (status === 'guessed') {
|
|
if (!this.lastAwardedTeamId) return
|
|
this.tiles[key].status = 'won'
|
|
this.tiles[key].lastTeamId = this.lastAwardedTeamId
|
|
this.currentSelectorId = this.lastAwardedTeamId
|
|
this.currentTileKey = null
|
|
this.currentClipUrl = ''
|
|
this.lastAwardedTeamId = null
|
|
this.guessingTeamId = null
|
|
this.pauseTransitionLockUntil = 0
|
|
this.checkEnd()
|
|
this.queueStateSync()
|
|
}
|
|
},
|
|
async revealPausedGuess() {
|
|
if (!this.canControlGame) return
|
|
if (!this.currentTileKey || !this.selectedGame) return
|
|
if (this.getCurrentTileStatus() !== 'paused') return
|
|
const [cIndex, qIndex] = this.currentTileKey.split('-').map(Number)
|
|
const clue = this.selectedGame.categories[cIndex].clues[qIndex]
|
|
this.tiles[this.currentTileKey].status = 'guessed'
|
|
this.guessingTeamId = null
|
|
this.pauseTransitionLockUntil = 0
|
|
this.lastAwardedTeamId = null
|
|
if (clue.answer) {
|
|
this.currentClipUrl = encodeURI(clue.answer)
|
|
this.isAnswerClip = true
|
|
await nextTick()
|
|
const player = this.getPlayer()
|
|
if (player) {
|
|
this.ensureAudioContext()
|
|
player.currentTime = 0
|
|
player.load()
|
|
player.play().catch(() => {})
|
|
}
|
|
}
|
|
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
|
|
|
|
if (status === 'paused') {
|
|
this.tiles[key].status = 'playing'
|
|
this.pauseTransitionLockUntil = 0
|
|
this.guessingTeamId = null
|
|
const player = this.getPlayer()
|
|
player?.play().catch(() => {})
|
|
this.queueStateSync()
|
|
return
|
|
}
|
|
|
|
if (status === 'playing') {
|
|
this.tiles[key].status = 'void'
|
|
this.currentTileKey = null
|
|
this.currentClipUrl = ''
|
|
this.isAnswerClip = false
|
|
this.guessingTeamId = null
|
|
this.pauseTransitionLockUntil = 0
|
|
const player = this.getPlayer()
|
|
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
|
|
const [cIndex, qIndex] = key.split('-').map(Number)
|
|
const clue = this.selectedGame.categories[cIndex].clues[qIndex]
|
|
this.teams = this.teams.map((team) =>
|
|
team.id === teamId ? { ...team, score: team.score + clue.points } : team
|
|
)
|
|
this.lastAwardedTeamId = teamId
|
|
if (this.tiles[key]) {
|
|
this.tiles[key].lastTeamId = teamId
|
|
}
|
|
this.queueStateSync()
|
|
},
|
|
checkEnd() {
|
|
if (!this.selectedGame) return
|
|
const allTiles: TileEntry['status'][] = []
|
|
this.selectedGame.categories.forEach((category, cIndex) => {
|
|
category.clues.forEach((_, qIndex) => {
|
|
allTiles.push(this.tileStatus(cIndex, qIndex))
|
|
})
|
|
})
|
|
const finished = allTiles.every((status) => status === 'won' || status === 'void')
|
|
if (finished) {
|
|
this.step = 'end'
|
|
this.guessingTeamId = null
|
|
}
|
|
}
|
|
},
|
|
unmounted() {
|
|
this.teardownAudio()
|
|
this.closeSocket()
|
|
if (this.syncTimer) window.clearTimeout(this.syncTimer)
|
|
}
|
|
}
|
|
</script>
|
|
|