initial commit
This commit is contained in:
commit
985a70abf3
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
venv
|
||||||
|
__pycache__
|
||||||
|
.env
|
||||||
406
app.py
Normal file
406
app.py
Normal file
@ -0,0 +1,406 @@
|
|||||||
|
# --- app.py ---
|
||||||
|
import os
|
||||||
|
import io
|
||||||
|
import base64
|
||||||
|
import pandas as pd
|
||||||
|
import qrcode
|
||||||
|
import uuid
|
||||||
|
from flask import Flask, render_template, request, session, redirect, url_for, abort
|
||||||
|
from flask_socketio import SocketIO, emit, join_room
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# global store of all games, keyed by secret
|
||||||
|
games = {}
|
||||||
|
|
||||||
|
# Load environment vars
|
||||||
|
load_dotenv()
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'dev')
|
||||||
|
# manage_session ensures Flask session is available in SocketIO handlers
|
||||||
|
socketio = SocketIO(app, manage_session=True)
|
||||||
|
|
||||||
|
# Expect columns: question, answer1..answer4, correct (column name)
|
||||||
|
df = pd.read_excel('quizzes/questions.xlsx')
|
||||||
|
questions = []
|
||||||
|
for _, row in df.iterrows():
|
||||||
|
correct_col = row['correct'] # e.g. 'answer3'
|
||||||
|
correct_text = row[correct_col]
|
||||||
|
questions.append({
|
||||||
|
'question': row['question'],
|
||||||
|
'options': [row[f'answer{i}'] for i in range(1,5)], # only four options
|
||||||
|
'correct': correct_text
|
||||||
|
})
|
||||||
|
|
||||||
|
# In-memory game state
|
||||||
|
game = {
|
||||||
|
'players': {},
|
||||||
|
'order': [],
|
||||||
|
'current_q': 0,
|
||||||
|
'answered': [],
|
||||||
|
'started': False
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
|
def host():
|
||||||
|
session['role'] = 'host'
|
||||||
|
# — manage per-game secret in session —
|
||||||
|
secret = session.get('secret')
|
||||||
|
if not secret:
|
||||||
|
secret = uuid.uuid4().hex[:8]
|
||||||
|
session['secret'] = secret
|
||||||
|
# ensure game exists
|
||||||
|
if secret not in games:
|
||||||
|
games[secret] = {
|
||||||
|
'players': {}, 'order': [],
|
||||||
|
'current_q': 0, 'answered': [],
|
||||||
|
'started': False, 'finished': False
|
||||||
|
}
|
||||||
|
|
||||||
|
# build join URL + QR
|
||||||
|
join_url = url_for('player', secret=secret, _external=True)
|
||||||
|
img = qrcode.make(join_url)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
img.save(buf, format='PNG')
|
||||||
|
qr_data = base64.b64encode(buf.getvalue()).decode('ascii')
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'host.html',
|
||||||
|
qr_data=qr_data,
|
||||||
|
join_url=join_url,
|
||||||
|
secret=secret
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/new_game', methods=['POST'])
|
||||||
|
def new_game():
|
||||||
|
if session.get('role') != 'host':
|
||||||
|
abort(403)
|
||||||
|
# remove the old game entirely (so its secret is no longer valid)
|
||||||
|
old_secret = session.pop('secret', None)
|
||||||
|
if old_secret and old_secret in games:
|
||||||
|
del games[old_secret]
|
||||||
|
return redirect(url_for('host'))
|
||||||
|
|
||||||
|
@app.route('/player')
|
||||||
|
def player():
|
||||||
|
session['role'] = 'player'
|
||||||
|
|
||||||
|
# 1) Grab any secret from the URL
|
||||||
|
new_secret = request.args.get('secret')
|
||||||
|
old_secret = session.get('secret')
|
||||||
|
|
||||||
|
# 2) If they provided a new secret, it *must* already exist or we reject immediately
|
||||||
|
if new_secret:
|
||||||
|
if new_secret not in games:
|
||||||
|
return render_template(
|
||||||
|
'player.html',
|
||||||
|
joined=False,
|
||||||
|
state='join',
|
||||||
|
options=[],
|
||||||
|
answered=False,
|
||||||
|
error='Spiel nicht gefunden'
|
||||||
|
), 400
|
||||||
|
# only clear join info if it really *is* a different, valid game
|
||||||
|
if new_secret != old_secret:
|
||||||
|
for k in ('joined','player_id','player_name'):
|
||||||
|
session.pop(k, None)
|
||||||
|
secret = new_secret
|
||||||
|
else:
|
||||||
|
secret = old_secret
|
||||||
|
|
||||||
|
# 3) We need *some* secret in session — and it must still be in our games dict
|
||||||
|
if not secret or secret not in games:
|
||||||
|
return render_template(
|
||||||
|
'player.html',
|
||||||
|
joined=False,
|
||||||
|
state='join',
|
||||||
|
options=[],
|
||||||
|
answered=False,
|
||||||
|
error='Spiel nicht gefunden'
|
||||||
|
), 400
|
||||||
|
|
||||||
|
# 4) Save it for future Socket.IO events
|
||||||
|
session['secret'] = secret
|
||||||
|
|
||||||
|
# At this point, we’ve wiped only *this user’s* join info if they scanned a new code.
|
||||||
|
# We did *not* delete games[old_secret], so old games remain intact.
|
||||||
|
|
||||||
|
joined = session.get('joined', False)
|
||||||
|
# We don’t emit() here; the socket handlers will PUSH the right state.
|
||||||
|
return render_template(
|
||||||
|
'player.html',
|
||||||
|
joined=joined,
|
||||||
|
state='join', # actual view switched via sync_state over Socket.IO
|
||||||
|
options=[],
|
||||||
|
answered=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/player/join', methods=['POST'])
|
||||||
|
def do_join():
|
||||||
|
name = request.form.get('name') or 'Anonymous'
|
||||||
|
session['role'] = 'player'
|
||||||
|
session['joined'] = True
|
||||||
|
# make sure the player is still talking to a live game
|
||||||
|
secret = session.get('secret')
|
||||||
|
if not secret or secret not in games:
|
||||||
|
return render_template(
|
||||||
|
'player.html',
|
||||||
|
joined=False,
|
||||||
|
state='join',
|
||||||
|
options=[],
|
||||||
|
answered=False,
|
||||||
|
error='Spiel nicht gefunden'
|
||||||
|
), 400
|
||||||
|
|
||||||
|
session['player_name'] = name
|
||||||
|
session['player_id'] = str(uuid.uuid4())
|
||||||
|
return redirect(
|
||||||
|
url_for('player', secret=session.get('secret'))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@socketio.on('connect')
|
||||||
|
def on_connect():
|
||||||
|
secret = session.get('secret')
|
||||||
|
if not secret or secret not in games:
|
||||||
|
return
|
||||||
|
game = games[secret]
|
||||||
|
role = session.get('role')
|
||||||
|
|
||||||
|
if role == 'host':
|
||||||
|
join_room('host')
|
||||||
|
|
||||||
|
# ─── if the game is already finished, just re-emit the final board ───
|
||||||
|
if game.get('finished'):
|
||||||
|
board = sorted(
|
||||||
|
[{'name': v['name'], 'score': v['score']} for v in game['players'].values()],
|
||||||
|
key=lambda x: -x['score']
|
||||||
|
)
|
||||||
|
emit('game_over', {'board': board}, room='host')
|
||||||
|
return
|
||||||
|
|
||||||
|
# otherwise fall back to normal roster / question logic
|
||||||
|
players = [game['players'][pid]['name'] for pid in game['order']]
|
||||||
|
emit('update_roster', {'players': players})
|
||||||
|
|
||||||
|
if game.get('started') and game['current_q'] < len(questions):
|
||||||
|
q = questions[game['current_q']]
|
||||||
|
emit('new_question', {'question': q['question']}, room='host')
|
||||||
|
return
|
||||||
|
|
||||||
|
# player logic
|
||||||
|
if role != 'player' or not session.get('joined'):
|
||||||
|
return
|
||||||
|
|
||||||
|
pid = session['player_id']
|
||||||
|
join_room('player')
|
||||||
|
join_room(pid)
|
||||||
|
|
||||||
|
if pid not in game['players']:
|
||||||
|
game['players'][pid] = {
|
||||||
|
'name': session.get('player_name'),
|
||||||
|
'score': 0
|
||||||
|
}
|
||||||
|
game['order'].append(pid)
|
||||||
|
socketio.emit(
|
||||||
|
'update_roster',
|
||||||
|
{'players': [game['players'][p]['name'] for p in game['order']]},
|
||||||
|
room='host'
|
||||||
|
)
|
||||||
|
|
||||||
|
# delegate to rejoin logic
|
||||||
|
_send_sync_state(pid, game)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@socketio.on('start_game')
|
||||||
|
def on_start_game():
|
||||||
|
secret = session.get('secret')
|
||||||
|
if session.get('role') != 'host' or not secret or secret not in games:
|
||||||
|
return
|
||||||
|
game = games[secret]
|
||||||
|
|
||||||
|
# if already through, reset
|
||||||
|
if game.get('started') or game['current_q'] >= len(questions):
|
||||||
|
game.update({
|
||||||
|
'current_q': 0,
|
||||||
|
'answered': [],
|
||||||
|
'started': False,
|
||||||
|
'finished': False
|
||||||
|
})
|
||||||
|
for p in game['players'].values():
|
||||||
|
p['score'] = 0
|
||||||
|
|
||||||
|
game['started'] = True
|
||||||
|
socketio.emit('game_started', room='player')
|
||||||
|
_send_question(secret)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@socketio.on('get_roster')
|
||||||
|
def on_get_roster():
|
||||||
|
secret = session.get('secret')
|
||||||
|
if not secret or secret not in games:
|
||||||
|
return
|
||||||
|
game = games[secret]
|
||||||
|
roster = [game['players'][s]['name'] for s in game['order']]
|
||||||
|
emit('update_roster', {'players': roster})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@socketio.on('submit_answer')
|
||||||
|
def on_submit_answer(data):
|
||||||
|
secret = session.get('secret')
|
||||||
|
pid = session.get('player_id')
|
||||||
|
if not secret or secret not in games or pid not in games[secret]['players']:
|
||||||
|
return
|
||||||
|
game = games[secret]
|
||||||
|
if any(pid == p for p,_ in game['answered']):
|
||||||
|
return
|
||||||
|
|
||||||
|
game['answered'].append((pid, data.get('answer')))
|
||||||
|
_score_and_emit(secret)
|
||||||
|
|
||||||
|
|
||||||
|
@socketio.on('next_question')
|
||||||
|
def on_next_question():
|
||||||
|
secret = session.get('secret')
|
||||||
|
if session.get('role') != 'host' or not secret or secret not in games:
|
||||||
|
return
|
||||||
|
game = games[secret]
|
||||||
|
game['current_q'] += 1
|
||||||
|
|
||||||
|
if game['current_q'] < len(questions):
|
||||||
|
_send_question(secret)
|
||||||
|
else:
|
||||||
|
# mark finished & emit final boards
|
||||||
|
game['finished'] = True
|
||||||
|
board = sorted(
|
||||||
|
[{'name':v['name'],'score':v['score']} for v in game['players'].values()],
|
||||||
|
key=lambda x: -x['score']
|
||||||
|
)
|
||||||
|
socketio.emit('game_over', {'board': board}, room='host')
|
||||||
|
for psid,pdata in game['players'].items():
|
||||||
|
placement = next(i+1 for i,p in enumerate(board) if p['name']==pdata['name'])
|
||||||
|
socketio.emit(
|
||||||
|
'game_over',
|
||||||
|
{'placement': placement, 'score': pdata['score']},
|
||||||
|
room=psid
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@socketio.on('rejoin_game')
|
||||||
|
def on_rejoin_game():
|
||||||
|
secret = session.get('secret')
|
||||||
|
pid = session.get('player_id')
|
||||||
|
if not secret or secret not in games or not pid:
|
||||||
|
return
|
||||||
|
game = games[secret]
|
||||||
|
join_room('player')
|
||||||
|
join_room(pid)
|
||||||
|
_send_sync_state(pid, game)
|
||||||
|
|
||||||
|
|
||||||
|
# helpers
|
||||||
|
|
||||||
|
def _send_sync_state(pid, game):
|
||||||
|
# finished?
|
||||||
|
if game.get('finished'):
|
||||||
|
board = sorted(
|
||||||
|
[{'name': v['name'], 'score': v['score']} for v in game['players'].values()],
|
||||||
|
key=lambda x: -x['score']
|
||||||
|
)
|
||||||
|
me = game['players'][pid]
|
||||||
|
placement = next(i+1 for i,p in enumerate(board) if p['name']==me['name'])
|
||||||
|
emit('sync_state', {
|
||||||
|
'state': 'end',
|
||||||
|
'score': me['score'],
|
||||||
|
'placement': placement
|
||||||
|
}, room=pid)
|
||||||
|
# waiting?
|
||||||
|
elif not game['started']:
|
||||||
|
emit('sync_state', {'state':'waiting'}, room=pid)
|
||||||
|
# question?
|
||||||
|
else:
|
||||||
|
idx = game['current_q']
|
||||||
|
q = questions[idx]
|
||||||
|
answered = any(pid == p for p,_ in game['answered'])
|
||||||
|
emit('sync_state', {
|
||||||
|
'state': 'question',
|
||||||
|
'question': q['question'],
|
||||||
|
'options': q['options'],
|
||||||
|
'answered': answered
|
||||||
|
}, room=pid)
|
||||||
|
|
||||||
|
|
||||||
|
def _send_question(secret):
|
||||||
|
game = games[secret]
|
||||||
|
q = questions[game['current_q']]
|
||||||
|
# clear previous answers
|
||||||
|
game['answered'].clear()
|
||||||
|
# broadcast
|
||||||
|
socketio.emit(
|
||||||
|
'new_question',
|
||||||
|
{'question': q['question'], 'options': q['options']},
|
||||||
|
room='player'
|
||||||
|
)
|
||||||
|
socketio.emit(
|
||||||
|
'new_question',
|
||||||
|
{'question': q['question']},
|
||||||
|
room='host'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _score_and_emit(secret):
|
||||||
|
game = games[secret]
|
||||||
|
score_map = {1: 4, 2: 3, 3: 2, 4: 1}
|
||||||
|
per_q = []
|
||||||
|
|
||||||
|
# grab the current question’s correct answer
|
||||||
|
correct = questions[game['current_q']]['correct']
|
||||||
|
# take only the most‐recent submission
|
||||||
|
pid, ans = game['answered'][-1]
|
||||||
|
|
||||||
|
# count how many *earlier* answers were correct
|
||||||
|
prev_correct = sum(1 for p, a in game['answered'][:-1] if a == correct)
|
||||||
|
rank = prev_correct + 1
|
||||||
|
|
||||||
|
# assign points only if this one is correct and within top‐4
|
||||||
|
if ans == correct and rank <= 4:
|
||||||
|
pts = score_map[rank]
|
||||||
|
game['players'][pid]['score'] += pts
|
||||||
|
per_q.append({
|
||||||
|
'name': game['players'][pid]['name'],
|
||||||
|
'points': pts
|
||||||
|
})
|
||||||
|
|
||||||
|
# rebuild overall leaderboard
|
||||||
|
board = sorted(
|
||||||
|
[{'name': v['name'], 'score': v['score']} for v in game['players'].values()],
|
||||||
|
key=lambda x: -x['score']
|
||||||
|
)
|
||||||
|
|
||||||
|
# emit just the new‐answer results, plus the overall board
|
||||||
|
socketio.emit('question_leader', {'results': per_q}, room='host')
|
||||||
|
socketio.emit('question_leader', {'results': per_q}, room='player')
|
||||||
|
socketio.emit('overall_leader', {'board': board}, room='host')
|
||||||
|
socketio.emit('overall_leader', {'board': board}, room='player')
|
||||||
|
|
||||||
|
# if that was the last question, finish up
|
||||||
|
if game['current_q'] >= len(questions):
|
||||||
|
game['finished'] = True
|
||||||
|
socketio.emit('game_over', {'board': board}, room='host')
|
||||||
|
for psid, pdata in game['players'].items():
|
||||||
|
placement = next(i+1 for i, p in enumerate(board) if p['name'] == pdata['name'])
|
||||||
|
socketio.emit(
|
||||||
|
'game_over',
|
||||||
|
{'placement': placement, 'score': pdata['score']},
|
||||||
|
room=psid
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
socketio.run(app, host='0.0.0.0', port=5000)
|
||||||
47
docker-compose.yml
Executable file
47
docker-compose.yml
Executable file
@ -0,0 +1,47 @@
|
|||||||
|
services:
|
||||||
|
flask-app:
|
||||||
|
image: python:3.11-slim
|
||||||
|
container_name: "${CONTAINER_NAME}"
|
||||||
|
restart: always
|
||||||
|
working_dir: /app
|
||||||
|
volumes:
|
||||||
|
- ./:/app
|
||||||
|
- ./templates:/app/templates
|
||||||
|
- type: bind
|
||||||
|
source: /mnt
|
||||||
|
target: /mnt
|
||||||
|
bind:
|
||||||
|
propagation: rshared
|
||||||
|
environment:
|
||||||
|
- FLASK_APP=app.py
|
||||||
|
- FLASK_ENV=production
|
||||||
|
- TITLE_SHORT=${TITLE_SHORT}
|
||||||
|
- TITLE_LONG=${TITLE_LONG}
|
||||||
|
networks:
|
||||||
|
- traefik
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
|
||||||
|
# HTTP router (port 80), redirecting to HTTPS
|
||||||
|
- "traefik.http.routers.${CONTAINER_NAME}.rule=${HOST_RULE}"
|
||||||
|
- "traefik.http.routers.${CONTAINER_NAME}.entrypoints=web"
|
||||||
|
- "traefik.http.routers.${CONTAINER_NAME}.middlewares=redirect-to-https"
|
||||||
|
- "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https"
|
||||||
|
|
||||||
|
# HTTPS router (TLS via Let's Encrypt)
|
||||||
|
- "traefik.http.routers.${CONTAINER_NAME}-secure.rule=${HOST_RULE}"
|
||||||
|
- "traefik.http.routers.${CONTAINER_NAME}-secure.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.${CONTAINER_NAME}-secure.tls=true"
|
||||||
|
- "traefik.http.routers.${CONTAINER_NAME}-secure.tls.certresolver=myresolver"
|
||||||
|
|
||||||
|
# Internal port
|
||||||
|
- "traefik.http.services.${CONTAINER_NAME}.loadbalancer.server.port=5000"
|
||||||
|
|
||||||
|
# Production-ready Gunicorn command with eventlet
|
||||||
|
command: >
|
||||||
|
sh -c "pip install -r requirements.txt &&
|
||||||
|
gunicorn --worker-class eventlet -w 1 -b 0.0.0.0:5000 app:app"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
traefik:
|
||||||
|
external: true
|
||||||
BIN
quizzes/questions.xlsx
Normal file
BIN
quizzes/questions.xlsx
Normal file
Binary file not shown.
9
requirements.txt
Normal file
9
requirements.txt
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
flask
|
||||||
|
flask_socketio
|
||||||
|
pandas
|
||||||
|
qrcode
|
||||||
|
dotenv
|
||||||
|
gunicorn
|
||||||
|
eventlet
|
||||||
|
pillow
|
||||||
|
openpyxl
|
||||||
18
templates/base.html
Normal file
18
templates/base.html
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<!-- base.html -->
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<script src="https://cdn.socket.io/4.5.4/socket.io.min.js"></script>
|
||||||
|
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||||
|
<title>Public Quiz</title>
|
||||||
|
</head>
|
||||||
|
<body class="p-4">
|
||||||
|
<div class="container">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
143
templates/host.html
Normal file
143
templates/host.html
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
<!-- host.html -->
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block content %}
|
||||||
|
<h1 class="mb-4">Bibelquiz</h1>
|
||||||
|
<div class="container" id="host-app">
|
||||||
|
<!-- QR & Join Roster -->
|
||||||
|
<div id="qrContainer" class="mb-4 text-center">
|
||||||
|
<img id="qrImg"
|
||||||
|
src="data:image/png;base64,{{ qr_data }}"
|
||||||
|
class="img-fluid"
|
||||||
|
style="max-width:300px;"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- new-game form -->
|
||||||
|
<form action="{{ url_for('new_game') }}" method="post" class="d-inline">
|
||||||
|
<button type="submit" class="btn btn-secondary mb-3" id="newGameButton">
|
||||||
|
Spiel neu starten
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<button id="startBtn" class="btn btn-primary mb-3" disabled>Spiel beginnen</button>
|
||||||
|
<h3 id="rosterHeader">Spieler Beigetreten:</h3>
|
||||||
|
<ul id="roster" class="list-group mb-4"></ul>
|
||||||
|
|
||||||
|
<!-- Question & Current Leaderboard -->
|
||||||
|
<div id="host-question" style="display:none;">
|
||||||
|
<h2 id="qText" style="font-size:5rem;"></h2>
|
||||||
|
<button id="nextBtn" class="btn btn-warning mt-3">Nächste Frage</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Per-Question and Overall Results -->
|
||||||
|
<div id="host-results" style="display:none;">
|
||||||
|
<h4>Question Results</h4>
|
||||||
|
<ul id="perQ" class="list-group mb-3"></ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Final Top-10 Leaderboard -->
|
||||||
|
<div id="final-results" style="display:none;" class="mt-4">
|
||||||
|
<h4>Rangliste</h4>
|
||||||
|
<table class="table table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Rang</th><th>Name</th><th>Punkte</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="finalResultsBody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Persistent Overall Leaderboard Footer -->
|
||||||
|
<footer id="overallFooter" class="mt-4 bg-light border-top" style="position:fixed; bottom:0; left:0; width:100%; z-index:1000; display:none;">
|
||||||
|
<div class="container py-2">
|
||||||
|
<h4 class="mb-2">Rangliste</h4>
|
||||||
|
<table class="table table-striped mb-0">
|
||||||
|
<thead><tr><th>Name</th><th>Punkte</th></tr></thead>
|
||||||
|
<tbody id="overallFooterBody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const socket = io();
|
||||||
|
|
||||||
|
// Connect and fetch roster
|
||||||
|
socket.on('connect', () => {
|
||||||
|
console.log('Connected as host');
|
||||||
|
socket.emit('get_roster');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update roster when players join
|
||||||
|
socket.on('update_roster', data => {
|
||||||
|
console.log('Roster update:', data.players);
|
||||||
|
$('#roster').empty();
|
||||||
|
data.players.forEach(name => {
|
||||||
|
$('#roster').append(`<li class="list-group-item">${name}</li>`);
|
||||||
|
});
|
||||||
|
$('#startBtn').prop('disabled', data.players.length === 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start game
|
||||||
|
$('#startBtn').on('click', () => {
|
||||||
|
console.log('Start game clicked');
|
||||||
|
$('#newGameButton').hide();
|
||||||
|
$('#overallFooter').show();
|
||||||
|
socket.emit('start_game');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle new question
|
||||||
|
socket.on('new_question', data => {
|
||||||
|
console.log('New question:', data.question);
|
||||||
|
$('#qrContainer, #startBtn, #rosterHeader, #roster').hide();
|
||||||
|
$('#host-question').show();
|
||||||
|
$('#qText').text(data.question);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Overall leaderboard updates (max 5 rows)
|
||||||
|
socket.on('overall_leader', data => {
|
||||||
|
console.log('Overall leader:', data.board);
|
||||||
|
window.lastOverall = data;
|
||||||
|
$('#overallFooterBody').empty();
|
||||||
|
const top5 = data.board.slice(0, 5);
|
||||||
|
top5.forEach(p => {
|
||||||
|
$('#overallFooterBody').append(`<tr><td>${p.name}</td><td>${p.score}</td></tr>`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Per-question results
|
||||||
|
socket.on('question_leader', data => {
|
||||||
|
console.log('Question leader:', data.results);
|
||||||
|
$('#perQ').empty();
|
||||||
|
data.results.forEach(r => {
|
||||||
|
$('#perQ').append(`<li class="list-group-item">${r.name}: +${r.points}</li>`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Next question
|
||||||
|
$('#nextBtn').off('click').on('click', () => {
|
||||||
|
console.log('Next question clicked');
|
||||||
|
socket.emit('next_question');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show final top-10 when game_over arrives
|
||||||
|
socket.on('game_over', data => {
|
||||||
|
if (data.board) {
|
||||||
|
// hide all the old UI
|
||||||
|
$('#qrContainer, #startBtn, #rosterHeader, #roster, #host-question, #host-results, #overallFooter').hide();
|
||||||
|
$('#newGameButton').show();
|
||||||
|
|
||||||
|
// fill & show your existing <div id="final-results">…
|
||||||
|
const top10 = data.board.slice(0, 10);
|
||||||
|
$('#finalResultsBody').empty();
|
||||||
|
top10.forEach((p, i) => {
|
||||||
|
$('#finalResultsBody').append(
|
||||||
|
`<tr><td>${i+1}</td><td>${p.name}</td><td>${p.score}</td></tr>`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
$('#final-results').show();
|
||||||
|
|
||||||
|
// make absolutely sure the “Start Game” button stays disabled
|
||||||
|
$('#startBtn').prop('disabled', true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
145
templates/player.html
Normal file
145
templates/player.html
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
<!-- player.html -->
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block content %}
|
||||||
|
<style>
|
||||||
|
.option.selected {
|
||||||
|
background-color: #0d6efd; /* bootstrap “primary” bg */
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.option.disabled {
|
||||||
|
background-color: #6c757d; /* bootstrap “secondary” bg */
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div class="container" id="player-app">
|
||||||
|
<!-- Join View -->
|
||||||
|
<div id="view-join" {% if state == 'join' %} {% else %}style="display:none;"{% endif %}>
|
||||||
|
<h1 class="mb-4">Dem Quiz Beitreten</h1>
|
||||||
|
{% if not error %}
|
||||||
|
<form id="joinForm" action="{{ url_for('do_join') }}" method="post">
|
||||||
|
<div class="input-group mb-3">
|
||||||
|
<input name="name" id="nameInput" type="text"
|
||||||
|
class="form-control" placeholder="Name eingeben" />
|
||||||
|
<button type="submit" class="btn btn-success">Beitreten</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<!-- show backend-passed error if present -->
|
||||||
|
<div id="joinStatus" class="mt-2">
|
||||||
|
<div class="alert alert-danger" role="alert">{{ error }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Wait View -->
|
||||||
|
<div id="view-wait" {% if state == 'waiting' %} {% else %}style="display:none;"{% endif %}>
|
||||||
|
<div class="alert alert-info mt-4" role="alert">
|
||||||
|
Bitte warten bis das Spiel beginnt.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Options View -->
|
||||||
|
<div id="view-question" {% if state == 'question' %} {% else %}style="display:none;"{% endif %}>
|
||||||
|
<div id="options" class="d-grid gap-2"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- End View -->
|
||||||
|
<div id="view-end" style="display:none;">
|
||||||
|
<div class="alert alert-success mt-4 text-center">
|
||||||
|
<h2>Spiel beendet!</h2>
|
||||||
|
<p>Du hast <strong><span id="playerScore">0</span> Punkte</strong> erzielt und Platz <strong><span id="playerRank">–</span></strong> belegt.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const socket = io();
|
||||||
|
|
||||||
|
// Restore state on load
|
||||||
|
socket.on('connect', () => {
|
||||||
|
socket.emit('rejoin_game');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync initial or reconnect state
|
||||||
|
socket.on('sync_state', data => {
|
||||||
|
if (data.state === 'waiting') {
|
||||||
|
$('#view-join, #view-question').hide();
|
||||||
|
$('#view-wait').show();
|
||||||
|
}
|
||||||
|
else if (data.state === 'question') {
|
||||||
|
$('#view-join, #view-wait').hide();
|
||||||
|
$('#view-question').show();
|
||||||
|
renderOptions(data.options, data.answered);
|
||||||
|
}
|
||||||
|
else if (data.state === 'end') {
|
||||||
|
$('#view-join, #view-wait, #view-question').hide();
|
||||||
|
$('#view-end').show();
|
||||||
|
$('#playerScore').text(data.score);
|
||||||
|
$('#playerRank').text(data.placement);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Host clicked “Start Game”
|
||||||
|
socket.on('game_started', () => {
|
||||||
|
$('#view-join, #view-wait').hide();
|
||||||
|
$('#view-question').show();
|
||||||
|
});
|
||||||
|
|
||||||
|
// A new question just dropped
|
||||||
|
socket.on('new_question', data => {
|
||||||
|
$('#view-join, #view-wait').hide();
|
||||||
|
$('#view-question').show();
|
||||||
|
renderOptions(data.options, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle answer clicks
|
||||||
|
$(document).on('click', '.option', function() {
|
||||||
|
const $all = $('.option');
|
||||||
|
const $me = $(this);
|
||||||
|
const answer = $me.text();
|
||||||
|
|
||||||
|
// emit once
|
||||||
|
socket.emit('submit_answer', { answer });
|
||||||
|
|
||||||
|
// style the clicked one as selected
|
||||||
|
$me
|
||||||
|
.addClass('selected')
|
||||||
|
.removeClass('btn-outline-primary')
|
||||||
|
.prop('disabled', true);
|
||||||
|
|
||||||
|
// style all the others as disabled
|
||||||
|
$all.not($me)
|
||||||
|
.addClass('disabled')
|
||||||
|
.removeClass('btn-outline-primary btn-primary')
|
||||||
|
.prop('disabled', true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// When re-rendering options (e.g. on sync or new_question), clear any classes:
|
||||||
|
function renderOptions(options, alreadyAnswered) {
|
||||||
|
const $opts = $('#options').empty();
|
||||||
|
options.forEach(opt => {
|
||||||
|
const btn = $(`<button class="btn option btn-outline-primary btn-lg mb-2">${opt}</button>`);
|
||||||
|
if (alreadyAnswered) {
|
||||||
|
btn.addClass('disabled')
|
||||||
|
.removeClass('btn-outline-primary')
|
||||||
|
.prop('disabled', true);
|
||||||
|
}
|
||||||
|
$opts.append(btn);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle end-of-game for players
|
||||||
|
socket.on('game_over', data => {
|
||||||
|
if (data.placement !== undefined) {
|
||||||
|
// hide everything else
|
||||||
|
$('#view-join, #view-wait, #view-question').hide();
|
||||||
|
// show final screen
|
||||||
|
$('#view-end').show();
|
||||||
|
// fill in score & rank
|
||||||
|
$('#playerScore').text(data.score);
|
||||||
|
$('#playerRank').text(data.placement);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
Loading…
x
Reference in New Issue
Block a user