Files
music-jeopardy/src/App.vue
Johnny322 52e2a47bc9
All checks were successful
On Push Deploy / deploy (push) Successful in 19s
Test logarithmic
2026-02-10 17:03:33 +01:00

675 lines
21 KiB
Vue

<template>
<div class="app">
<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>
</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="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"
/>
<button class="ghost" @click="removeTeam(team.id)">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" />
</label>
<label>
Color B
<input v-model="team.colorB" type="color" />
</label>
</div>
</div>
<button class="add-card" @click="addTeam">
<span>+ Add Team</span>
</button>
</div>
<div class="actions">
<button class="primary" :disabled="!canProceed" @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>src/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/&lt;GameName&gt;/&lt;Category&gt;</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)"
>
<h3>{{ game.name }}</h3>
<p>{{ game.categories.length }} categories</p>
</button>
</div>
<div class="actions">
<button class="ghost" @click="step = 'setup'">Back</button>
</div>
</section>
<section v-if="step === 'game'" class="game-layout">
<aside class="scoreboard">
<h2>Teams</h2>
<div class="team-list">
<button
v-for="team in teams"
:key="team.id"
class="team-score-card"
:class="{ active: team.id === currentSelectorId }"
:style="teamGradient(team)"
@click="awardPoints(team.id)"
:disabled="!canAward"
>
<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">Reset Game</button>
</div>
</aside>
<section class="board">
<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)"
@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="!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>
</template>
<script lang="ts">
import { nextTick } from 'vue'
import { loadGameData, type Game } from './dataLoader'
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>
const makeTeam = (index: number): Team => ({
id: `team-${Date.now()}-${index}`,
name: `Team ${index + 1}`,
colorA: '#3dd6ff',
colorB: '#fbd72b',
score: 0
})
export default {
data() {
return {
step: 'setup',
teams: [makeTeam(0), makeTeam(1)] as Team[],
games: [] as Game[],
loadingGames: true,
loadError: '',
selectedGame: null as Game | null,
tiles: {} as TileMap,
currentTileKey: null,
currentClipUrl: '',
currentSelectorId: null,
lastAwardedTeamId: 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)
}
},
async mounted() {
try {
this.loadingGames = true
this.games = await loadGameData()
} catch (error) {
this.loadError = error instanceof Error ? error.message : 'Failed to load games.'
} finally {
this.loadingGames = false
}
},
computed: {
canProceed() {
return this.teams.length > 0 && this.teams.every((team) => team.name.trim())
},
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(', ')
}
},
watch: {
currentClipUrl(newValue: string) {
if (!newValue) {
this.teardownAudio()
return
}
nextTick(() => this.setupAudioGraph())
}
},
methods: {
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
this.ensureAudioContext()
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()
}
this.mediaSource = context.createMediaElementSource(player)
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()
},
handlePlayerPause() {
this.isPlaying = false
if (this.rafId) {
cancelAnimationFrame(this.rafId)
this.rafId = 0
}
this.pulseLevel = 0
},
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() {
this.teams.push(makeTeam(this.teams.length))
},
removeTeam(id: string) {
if (this.teams.length === 1) return
this.teams = this.teams.filter((team) => team.id !== id)
},
goToSelect() {
this.step = 'select'
},
startGame(game: Game) {
this.selectedGame = game
this.tiles = {}
this.currentTileKey = null
this.currentClipUrl = ''
this.lastAwardedTeamId = null
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'
},
resetGame() {
this.step = 'select'
this.selectedGame = null
this.tiles = {}
this.currentTileKey = null
this.currentClipUrl = ''
this.lastAwardedTeamId = null
this.isAnswerClip = false
this.teardownAudio()
},
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) {
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
await nextTick()
const player = this.getPlayer()
if (player) {
this.ensureAudioContext()
player.currentTime = 0
player.load()
player.play().catch(() => {})
}
return
}
if (status === 'playing') {
this.tiles[key].status = 'paused'
const player = this.getPlayer()
player?.pause()
return
}
if (status === 'paused') {
this.tiles[key].status = 'guessed'
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(() => {})
}
}
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.checkEnd()
}
},
handleTileRightClick(cIndex: number, qIndex: number) {
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'
const player = this.getPlayer()
player?.play().catch(() => {})
return
}
if (status === 'playing') {
this.tiles[key].status = 'void'
this.currentTileKey = null
this.currentClipUrl = ''
this.isAnswerClip = false
const player = this.getPlayer()
player?.pause()
this.teardownAudio()
this.checkEnd()
}
},
awardPoints(teamId: string) {
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
}
},
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'
}
}
},
unmounted() {
this.teardownAudio()
}
}
</script>