initial vibe code
This commit is contained in:
240
backend/main.py
Normal file
240
backend/main.py
Normal file
@@ -0,0 +1,240 @@
|
||||
import asyncio
|
||||
import uuid
|
||||
import time
|
||||
from typing import final
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
# Enable CORS for local dev
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
class Question(BaseModel):
|
||||
text: str
|
||||
choices: list[str]
|
||||
answer: str
|
||||
seconds: int = 20
|
||||
|
||||
|
||||
class QuizConfig(BaseModel):
|
||||
title: str = "Trivia"
|
||||
questions: list[Question]
|
||||
|
||||
|
||||
@final
|
||||
class Participant:
|
||||
def __init__(self, name: str, ws: WebSocket):
|
||||
self.name = name
|
||||
self.ws = ws
|
||||
self.score = 0
|
||||
self.current_answer: str | None = None
|
||||
|
||||
|
||||
@final
|
||||
class GameSession:
|
||||
def __init__(self, host_ws: WebSocket, questions: list[Question]):
|
||||
self.id = str(uuid.uuid4())[:4].upper() # Simple 4-char join code
|
||||
self.host_ws = host_ws
|
||||
self.questions = questions
|
||||
self.participants: dict[int, Participant] = {} # Keyed by websocket
|
||||
self.current_q_index = -1
|
||||
self.state = "LOBBY" # LOBBY, QUESTION, ANSWER, END
|
||||
self.timer_task = None
|
||||
self.end_time = 0.0
|
||||
|
||||
|
||||
sessions: dict[str, GameSession] = {}
|
||||
|
||||
|
||||
async def broadcast_state(session_id: str):
|
||||
session = sessions.get(session_id)
|
||||
if not session:
|
||||
return
|
||||
|
||||
# Build Participant List (Public info)
|
||||
player_list = [
|
||||
{"name": p.name, "score": p.score, "answered": p.current_answer is not None}
|
||||
for p in session.participants.values()
|
||||
]
|
||||
|
||||
# Sort for leaderboard if ended
|
||||
if session.state == "END":
|
||||
player_list.sort(key=lambda x: x["score"], reverse=True)
|
||||
|
||||
# Current Question Info
|
||||
q_data = None
|
||||
if 0 <= session.current_q_index < len(session.questions):
|
||||
q = session.questions[session.current_q_index]
|
||||
q_data = {
|
||||
"text": q.text,
|
||||
"choices": q.choices,
|
||||
"seconds": q.seconds,
|
||||
# Only reveal answer in ANSWER state
|
||||
"correct_answer": q.answer if session.state == "ANSWER" else None,
|
||||
"answer_stats": {}, # Calculate stats for host view
|
||||
}
|
||||
|
||||
if session.state == "ANSWER":
|
||||
counts = {c: 0 for c in q.choices}
|
||||
for p in session.participants.values():
|
||||
if p.current_answer in counts:
|
||||
counts[p.current_answer] += 1
|
||||
q_data["answer_stats"] = counts
|
||||
|
||||
state_msg = {
|
||||
"type": "STATE_UPDATE",
|
||||
"state": session.state,
|
||||
"players": player_list,
|
||||
"question": q_data,
|
||||
"end_time": session.end_time, # Sync timers
|
||||
}
|
||||
|
||||
# Broadcast to Host
|
||||
try:
|
||||
await session.host_ws.send_json(state_msg)
|
||||
except:
|
||||
pass # Host disconnected handling omitted for brevity
|
||||
|
||||
# Broadcast to Participants
|
||||
for p in session.participants.values():
|
||||
try:
|
||||
await p.ws.send_json(state_msg)
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
async def end_question_timer(session_id: str, q_index: int):
|
||||
"""Wait for timer, then force transition if still on same question"""
|
||||
session = sessions.get(session_id)
|
||||
if not session:
|
||||
return
|
||||
|
||||
seconds = session.questions[q_index].seconds
|
||||
await asyncio.sleep(seconds)
|
||||
|
||||
# Refresh session state
|
||||
if session.state == "QUESTION" and session.current_q_index == q_index:
|
||||
await transition_to_answer(session_id)
|
||||
|
||||
|
||||
async def transition_to_answer(session_id: str):
|
||||
session = sessions.get(session_id)
|
||||
assert session is not None
|
||||
session.state = "ANSWER"
|
||||
|
||||
# Calculate scores
|
||||
current_q = session.questions[session.current_q_index]
|
||||
for p in session.participants.values():
|
||||
if p.current_answer == current_q.answer:
|
||||
p.score += 100 # Simple scoring
|
||||
|
||||
await broadcast_state(session_id)
|
||||
|
||||
|
||||
# --- Routes ---
|
||||
@app.websocket("/ws/host")
|
||||
async def websocket_host(websocket: WebSocket):
|
||||
await websocket.accept()
|
||||
session = None
|
||||
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_json()
|
||||
action = data.get("action")
|
||||
|
||||
if action == "CREATE_SESSION":
|
||||
quiz_data = data.get("quiz") # Parsed YAML
|
||||
questions = [Question(**q) for q in quiz_data["questions"]]
|
||||
session = GameSession(websocket, questions)
|
||||
sessions[session.id] = session
|
||||
|
||||
# Send code back to host
|
||||
await websocket.send_json(
|
||||
{"type": "SESSION_CREATED", "code": session.id}
|
||||
)
|
||||
await broadcast_state(session.id)
|
||||
|
||||
elif action == "START_GAME":
|
||||
if session:
|
||||
session.current_q_index = 0
|
||||
session.state = "QUESTION"
|
||||
session.end_time = time.time() + session.questions[0].seconds
|
||||
# Reset player answers
|
||||
for p in session.participants.values():
|
||||
p.current_answer = None
|
||||
|
||||
await broadcast_state(session.id)
|
||||
# Start background timer
|
||||
asyncio.create_task(end_question_timer(session.id, 0))
|
||||
|
||||
elif action == "NEXT_QUESTION":
|
||||
if session:
|
||||
if session.current_q_index + 1 < len(session.questions):
|
||||
session.current_q_index += 1
|
||||
session.state = "QUESTION"
|
||||
session.end_time = (
|
||||
time.time()
|
||||
+ session.questions[session.current_q_index].seconds
|
||||
)
|
||||
for p in session.participants.values():
|
||||
p.current_answer = None
|
||||
await broadcast_state(session.id)
|
||||
_ = asyncio.create_task(
|
||||
end_question_timer(session.id, session.current_q_index)
|
||||
)
|
||||
else:
|
||||
session.state = "END"
|
||||
await broadcast_state(session.id)
|
||||
|
||||
except WebSocketDisconnect:
|
||||
if session and session.id in sessions:
|
||||
del sessions[session.id]
|
||||
|
||||
|
||||
@app.websocket("/ws/play/{code}/{name}")
|
||||
async def websocket_player(websocket: WebSocket, code: str, name: str):
|
||||
code = code.upper()
|
||||
if code not in sessions:
|
||||
await websocket.close(code=4000)
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
session = sessions[code]
|
||||
|
||||
# Add participant
|
||||
player = Participant(name, websocket)
|
||||
session.participants[id(websocket)] = player
|
||||
|
||||
await broadcast_state(code)
|
||||
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_json()
|
||||
action = data.get("action")
|
||||
|
||||
if action == "SUBMIT_ANSWER":
|
||||
if session.state == "QUESTION":
|
||||
player.current_answer = data.get("choice")
|
||||
await broadcast_state(code) # Update "Answered" status on host
|
||||
|
||||
# Check if everyone answered
|
||||
all_answered = all(
|
||||
p.current_answer is not None
|
||||
for p in session.participants.values()
|
||||
)
|
||||
if all_answered:
|
||||
await transition_to_answer(code)
|
||||
|
||||
except WebSocketDisconnect:
|
||||
if id(websocket) in session.participants:
|
||||
del session.participants[id(websocket)]
|
||||
await broadcast_state(code)
|
||||
3
backend/requirements.txt
Normal file
3
backend/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
pydantic
|
||||
8
frontend/.editorconfig
Normal file
8
frontend/.editorconfig
Normal file
@@ -0,0 +1,8 @@
|
||||
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
|
||||
charset = utf-8
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
end_of_line = lf
|
||||
max_line_length = 100
|
||||
1
frontend/.gitattributes
vendored
Normal file
1
frontend/.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
39
frontend/.gitignore
vendored
Normal file
39
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
||||
|
||||
.eslintcache
|
||||
|
||||
# Cypress
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Vitest
|
||||
__screenshots__/
|
||||
|
||||
# Vite
|
||||
*.timestamp-*-*.mjs
|
||||
10
frontend/.oxlintrc.json
Normal file
10
frontend/.oxlintrc.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["eslint", "unicorn", "oxc", "vue"],
|
||||
"env": {
|
||||
"browser": true
|
||||
},
|
||||
"categories": {
|
||||
"correctness": "error"
|
||||
}
|
||||
}
|
||||
6
frontend/.prettierrc.json
Normal file
6
frontend/.prettierrc.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/prettierrc",
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100
|
||||
}
|
||||
9
frontend/.vscode/extensions.json
vendored
Normal file
9
frontend/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"Vue.volar",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"EditorConfig.EditorConfig",
|
||||
"oxc.oxc-vscode",
|
||||
"esbenp.prettier-vscode"
|
||||
]
|
||||
}
|
||||
44
frontend/README.md
Normal file
44
frontend/README.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# frontend
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
||||
|
||||
## Recommended Browser Setup
|
||||
|
||||
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
|
||||
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
|
||||
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
|
||||
- Firefox:
|
||||
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
|
||||
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
|
||||
|
||||
## Customize configuration
|
||||
|
||||
See [Vite Configuration Reference](https://vite.dev/config/).
|
||||
|
||||
## Project Setup
|
||||
|
||||
```sh
|
||||
npm install
|
||||
```
|
||||
|
||||
### Compile and Hot-Reload for Development
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Compile and Minify for Production
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Lint with [ESLint](https://eslint.org/)
|
||||
|
||||
```sh
|
||||
npm run lint
|
||||
```
|
||||
30
frontend/eslint.config.js
Normal file
30
frontend/eslint.config.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
import globals from 'globals'
|
||||
import js from '@eslint/js'
|
||||
import pluginVue from 'eslint-plugin-vue'
|
||||
import pluginOxlint from 'eslint-plugin-oxlint'
|
||||
import skipFormatting from 'eslint-config-prettier/flat'
|
||||
|
||||
export default defineConfig([
|
||||
{
|
||||
name: 'app/files-to-lint',
|
||||
files: ['**/*.{vue,js,mjs,jsx}'],
|
||||
},
|
||||
|
||||
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
|
||||
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
js.configs.recommended,
|
||||
...pluginVue.configs['flat/essential'],
|
||||
|
||||
...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'),
|
||||
|
||||
skipFormatting,
|
||||
])
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Vite App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
8
frontend/jsconfig.json
Normal file
8
frontend/jsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
4559
frontend/package-lock.json
generated
Normal file
4559
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
40
frontend/package.json
Normal file
40
frontend/package.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "run-s lint:*",
|
||||
"lint:oxlint": "oxlint . --fix",
|
||||
"lint:eslint": "eslint . --fix --cache",
|
||||
"format": "prettier --write --experimental-cli src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"js-yaml": "^4.1.1",
|
||||
"mdi": "^2.2.43",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.27",
|
||||
"vue-router": "^5.0.1",
|
||||
"vuetify": "^3.11.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@vitejs/plugin-vue": "^6.0.3",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-oxlint": "~1.42.0",
|
||||
"eslint-plugin-vue": "~10.7.0",
|
||||
"globals": "^17.3.0",
|
||||
"npm-run-all2": "^8.0.4",
|
||||
"oxlint": "~1.42.0",
|
||||
"prettier": "3.8.1",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-vue-devtools": "^8.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
}
|
||||
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
7
frontend/src/App.vue
Normal file
7
frontend/src/App.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<script setup></script>
|
||||
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
19
frontend/src/main.js
Normal file
19
frontend/src/main.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
// Vuetify
|
||||
import 'vuetify/styles'
|
||||
import { createVuetify } from 'vuetify'
|
||||
import * as components from 'vuetify/components'
|
||||
import * as directives from 'vuetify/directives'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const vuetify = createVuetify({ components, directives })
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(vuetify)
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
0
frontend/src/plugins/vuetify.js
Normal file
0
frontend/src/plugins/vuetify.js
Normal file
19
frontend/src/router/index.js
Normal file
19
frontend/src/router/index.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
name: 'PlayView',
|
||||
component: import('@/views/PlayView.vue'),
|
||||
path: '/play',
|
||||
},
|
||||
{
|
||||
name: 'HostView',
|
||||
component: import('@/views/HostView.vue'),
|
||||
path: '/host',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
export default router
|
||||
12
frontend/src/stores/counter.js
Normal file
12
frontend/src/stores/counter.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useCounterStore = defineStore('counter', () => {
|
||||
const count = ref(0)
|
||||
const doubleCount = computed(() => count.value * 2)
|
||||
function increment() {
|
||||
count.value++
|
||||
}
|
||||
|
||||
return { count, doubleCount, increment }
|
||||
})
|
||||
56
frontend/src/stores/game.js
Normal file
56
frontend/src/stores/game.js
Normal file
@@ -0,0 +1,56 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const useGameStore = defineStore('game', () => {
|
||||
const ws = ref(null)
|
||||
const isHost = ref(false)
|
||||
const gameCode = ref('')
|
||||
const gameState = ref('INIT') // INIT, LOBBY, QUESTION, ANSWER, END
|
||||
const players = ref([])
|
||||
const currentQuestion = ref(null)
|
||||
const myAnswer = ref(null)
|
||||
const endTime = ref(0)
|
||||
|
||||
// Connection Logic
|
||||
function connect(url) {
|
||||
ws.value = new WebSocket(url)
|
||||
|
||||
ws.value.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data)
|
||||
|
||||
if (data.type === 'SESSION_CREATED') {
|
||||
gameCode.value = data.code
|
||||
gameState.value = 'LOBBY'
|
||||
}
|
||||
|
||||
if (data.type === 'STATE_UPDATE') {
|
||||
gameState.value = data.state
|
||||
players.value = data.players
|
||||
currentQuestion.value = data.question
|
||||
if (data.end_time) endTime.value = data.end_time
|
||||
|
||||
// Reset local answer state if we moved to a new question
|
||||
if (data.state === 'QUESTION' && myAnswer.value && !data.question.correct_answer) {
|
||||
// Logic to reset UI answer selection is handled in component
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function send(msg) {
|
||||
if (ws.value) ws.value.send(JSON.stringify(msg))
|
||||
}
|
||||
|
||||
return {
|
||||
ws,
|
||||
isHost,
|
||||
gameCode,
|
||||
gameState,
|
||||
players,
|
||||
currentQuestion,
|
||||
myAnswer,
|
||||
endTime,
|
||||
connect,
|
||||
send,
|
||||
}
|
||||
})
|
||||
139
frontend/src/views/HostView.vue
Normal file
139
frontend/src/views/HostView.vue
Normal file
@@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<v-container class="fill-height justify-center">
|
||||
<v-card v-if="store.gameState === 'INIT'" width="600" class="pa-5">
|
||||
<v-card-title>Host a Game</v-card-title>
|
||||
<v-textarea v-model="yamlInput" label="Paste YAML Quiz Here" rows="10"></v-textarea>
|
||||
<v-btn color="primary" block @click="createSession">Create Session</v-btn>
|
||||
</v-card>
|
||||
|
||||
<v-card v-if="store.gameState === 'LOBBY'" width="800" class="pa-5 text-center">
|
||||
<div class="text-h2 font-weight-bold mb-4">Join Code: {{ store.gameCode }}</div>
|
||||
<v-divider class="mb-4"></v-divider>
|
||||
<div class="text-h5 mb-2">Participants ({{ store.players.length }})</div>
|
||||
<v-chip-group class="justify-center">
|
||||
<v-chip v-for="p in store.players" :key="p.name" size="large">{{ p.name }}</v-chip>
|
||||
</v-chip-group>
|
||||
<v-btn color="success" size="x-large" class="mt-10" @click="startGame">Start Game</v-btn>
|
||||
</v-card>
|
||||
|
||||
<v-card
|
||||
v-if="store.gameState === 'QUESTION'"
|
||||
width="100%"
|
||||
height="100%"
|
||||
class="pa-5 d-flex flex-column"
|
||||
>
|
||||
<div class="d-flex justify-space-between align-center mb-5">
|
||||
<div class="text-h4">Question</div>
|
||||
<v-progress-circular :model-value="timerVal" :max="100" size="60" width="8" color="primary">
|
||||
{{ Math.ceil(timeLeft) }}
|
||||
</v-progress-circular>
|
||||
</div>
|
||||
|
||||
<div class="text-h3 text-center my-10">{{ store.currentQuestion.text }}</div>
|
||||
|
||||
<v-row>
|
||||
<v-col cols="6" v-for="choice in store.currentQuestion.choices" :key="choice">
|
||||
<v-card height="100" color="grey-lighten-3" class="d-flex align-center justify-center">
|
||||
<span class="text-h5">{{ choice }}</span>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-footer border>
|
||||
<div class="d-flex w-100 justify-space-between align-center">
|
||||
<span>{{ answeredCount }} / {{ store.players.length }} Answered</span>
|
||||
<v-btn disabled>Waiting for answers...</v-btn>
|
||||
</div>
|
||||
</v-footer>
|
||||
</v-card>
|
||||
|
||||
<v-card v-if="store.gameState === 'ANSWER'" width="100%" class="pa-5 text-center">
|
||||
<div class="text-h4 mb-5">Correct Answer:</div>
|
||||
<div class="text-h3 text-success font-weight-bold mb-10">
|
||||
{{ store.currentQuestion.correct_answer }}
|
||||
</div>
|
||||
|
||||
<v-sheet
|
||||
v-for="(count, choice) in store.currentQuestion.answer_stats"
|
||||
:key="choice"
|
||||
class="mb-2"
|
||||
>
|
||||
<div class="d-flex justify-space-between">
|
||||
<span>{{ choice }}</span>
|
||||
<span>{{ count }} votes</span>
|
||||
</div>
|
||||
<v-progress-linear
|
||||
:model-value="(count / store.players.length) * 100"
|
||||
height="20"
|
||||
color="info"
|
||||
></v-progress-linear>
|
||||
</v-sheet>
|
||||
|
||||
<v-btn color="primary" size="x-large" class="mt-10" @click="nextQuestion">Next</v-btn>
|
||||
</v-card>
|
||||
|
||||
<v-card v-if="store.gameState === 'END'" width="600" class="pa-5 text-center">
|
||||
<div class="text-h2 mb-5">Game Over</div>
|
||||
<v-list>
|
||||
<v-list-item v-for="(p, i) in store.players" :key="p.name">
|
||||
<template v-slot:prepend>
|
||||
<v-avatar color="primary" class="text-white">{{ i + 1 }}</v-avatar>
|
||||
</template>
|
||||
<v-list-item-title class="text-h5">{{ p.name }}</v-list-item-title>
|
||||
<template v-slot:append>
|
||||
<span class="text-h5 font-weight-bold">{{ p.score }} pts</span>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useGameStore } from '@/stores/game'
|
||||
import yaml from 'js-yaml'
|
||||
|
||||
const store = useGameStore()
|
||||
const yamlInput = ref(`title: Demo Quiz
|
||||
questions:
|
||||
- text: What is 2 + 2?
|
||||
choices: ["3", "4", "5", "6"]
|
||||
answer: "4"
|
||||
seconds: 15
|
||||
- text: Best Pizza?
|
||||
choices: ["Pineapple", "Pepperoni", "Cheese", "Mushroom"]
|
||||
answer: "Pepperoni"
|
||||
seconds: 15`)
|
||||
|
||||
const timeLeft = ref(0)
|
||||
const timerVal = computed(() => {
|
||||
if (!store.currentQuestion) return 0
|
||||
return (timeLeft.value / store.currentQuestion.seconds) * 100
|
||||
})
|
||||
|
||||
const answeredCount = computed(() => store.players.filter((p) => p.answered).length)
|
||||
|
||||
// Timer Logic
|
||||
setInterval(() => {
|
||||
if (store.gameState === 'QUESTION' && store.endTime) {
|
||||
const diff = store.endTime - Date.now() / 1000
|
||||
timeLeft.value = diff > 0 ? diff : 0
|
||||
}
|
||||
}, 100)
|
||||
|
||||
function createSession() {
|
||||
store.connect(`ws://${window.location.hostname}:8000/ws/host`)
|
||||
store.ws.onopen = () => {
|
||||
const quiz = yaml.load(yamlInput.value)
|
||||
store.send({ action: 'CREATE_SESSION', quiz })
|
||||
}
|
||||
}
|
||||
|
||||
function startGame() {
|
||||
store.send({ action: 'START_GAME' })
|
||||
}
|
||||
function nextQuestion() {
|
||||
store.send({ action: 'NEXT_QUESTION' })
|
||||
}
|
||||
</script>
|
||||
107
frontend/src/views/PlayView.vue
Normal file
107
frontend/src/views/PlayView.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<v-container class="fill-height justify-center bg-grey-lighten-4">
|
||||
<v-card v-if="!joined" width="400" class="pa-5">
|
||||
<v-card-title>Join Game</v-card-title>
|
||||
<v-text-field v-model="code" label="Game Code" variant="outlined"></v-text-field>
|
||||
<v-text-field v-model="name" label="Nickname" variant="outlined"></v-text-field>
|
||||
<v-btn block color="secondary" @click="joinGame" :disabled="!code || !name">Join</v-btn>
|
||||
</v-card>
|
||||
|
||||
<div v-else-if="store.gameState === 'LOBBY'" class="text-center">
|
||||
<div class="text-h4">You're in!</div>
|
||||
<div class="text-subtitle-1">See your name on the host screen?</div>
|
||||
<v-progress-circular indeterminate color="primary" class="mt-5"></v-progress-circular>
|
||||
</div>
|
||||
|
||||
<div v-else-if="store.gameState === 'QUESTION'" class="w-100 h-100 d-flex flex-column pa-2">
|
||||
<v-sheet class="pa-4 text-center text-h5 mb-4 rounded">{{
|
||||
store.currentQuestion.text
|
||||
}}</v-sheet>
|
||||
|
||||
<div class="flex-grow-1">
|
||||
<v-row class="h-100">
|
||||
<v-col
|
||||
cols="6"
|
||||
v-for="choice in store.currentQuestion.choices"
|
||||
:key="choice"
|
||||
class="h-50"
|
||||
>
|
||||
<v-btn
|
||||
block
|
||||
height="100%"
|
||||
:color="getMyColor(choice)"
|
||||
:disabled="hasAnswered"
|
||||
@click="submitAnswer(choice)"
|
||||
class="text-h6 text-wrap"
|
||||
>
|
||||
{{ choice }}
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="store.gameState === 'ANSWER'" class="text-center">
|
||||
<div v-if="lastAnswerCorrect" class="text-h2 text-success font-weight-bold">Correct!</div>
|
||||
<div v-else class="text-h2 text-error font-weight-bold">Wrong</div>
|
||||
<div class="text-h5 mt-5">Waiting for Host...</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="store.gameState === 'END'" class="text-center">
|
||||
<div class="text-h3">Game Over</div>
|
||||
<div class="text-h5 mt-2">Check the host screen for the podium!</div>
|
||||
<div class="text-h6 mt-5">Your Final Score: {{ myScore }}</div>
|
||||
</div>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useGameStore } from '@/stores/game'
|
||||
|
||||
const store = useGameStore()
|
||||
const code = ref('')
|
||||
const name = ref('')
|
||||
const joined = ref(false)
|
||||
|
||||
// Use computed to check against the store's persisted answer
|
||||
const lastAnswerCorrect = computed(() => {
|
||||
if (!store.currentQuestion?.correct_answer || !store.myAnswer) return false
|
||||
// specific fix: ensure both are strings to avoid "4" !== 4 errors
|
||||
return String(store.myAnswer) === String(store.currentQuestion.correct_answer)
|
||||
})
|
||||
|
||||
const myScore = computed(() => {
|
||||
const me = store.players.find((p) => p.name === name.value)
|
||||
return me ? me.score : 0
|
||||
})
|
||||
|
||||
function joinGame() {
|
||||
store.connect(`ws://${window.location.hostname}:8000/ws/play/${code.value}/${name.value}`)
|
||||
store.ws.onopen = () => {
|
||||
joined.value = true
|
||||
// If we join mid-game, this ensures we see the right state
|
||||
}
|
||||
}
|
||||
|
||||
function submitAnswer(choice) {
|
||||
store.myAnswer = choice // Save to Store
|
||||
store.send({ action: 'SUBMIT_ANSWER', choice })
|
||||
}
|
||||
|
||||
function getMyColor(choice) {
|
||||
if (!store.myAnswer) return 'white'
|
||||
return store.myAnswer === choice ? 'secondary' : 'grey'
|
||||
}
|
||||
|
||||
// Reset answer only when a NEW question starts
|
||||
watch(
|
||||
() => store.currentQuestion,
|
||||
(newQ, oldQ) => {
|
||||
// If we are in QUESTION mode and the text changed, it's a new question. Clear answer.
|
||||
if (store.gameState === 'QUESTION' && newQ?.text !== oldQ?.text) {
|
||||
store.myAnswer = null
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
18
frontend/vite.config.js
Normal file
18
frontend/vite.config.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
vueDevTools(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user