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