Init
This commit is contained in:
435
src/App.vue
Normal file
435
src/App.vue
Normal file
@@ -0,0 +1,435 @@
|
||||
<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="games.length === 0" class="empty-state">
|
||||
<p>No games found yet.</p>
|
||||
<p>
|
||||
Add folders in <code>src/Data/<GameName>/<Category></code>
|
||||
with <code>Songs</code> and <code>Answers</code> subfolders.
|
||||
</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">
|
||||
<audio
|
||||
ref="player"
|
||||
v-if="currentClipUrl"
|
||||
:src="currentClipUrl"
|
||||
controls
|
||||
></audio>
|
||||
<div v-else class="player-empty">
|
||||
Pick a tile to start the round.
|
||||
</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 { gameData, 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: gameData as Game[],
|
||||
selectedGame: null as Game | null,
|
||||
tiles: {} as TileMap,
|
||||
currentTileKey: null,
|
||||
currentClipUrl: '',
|
||||
currentSelectorId: null,
|
||||
lastAwardedTeamId: null
|
||||
}
|
||||
},
|
||||
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(', ')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getPlayer() {
|
||||
return this.$refs.player as HTMLAudioElement | undefined
|
||||
},
|
||||
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
|
||||
},
|
||||
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)
|
||||
await nextTick()
|
||||
const player = this.getPlayer()
|
||||
if (player) {
|
||||
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)
|
||||
await nextTick()
|
||||
const player = this.getPlayer()
|
||||
if (player) {
|
||||
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 = ''
|
||||
const player = this.getPlayer()
|
||||
player?.pause()
|
||||
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'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user