improve + test
This commit is contained in:
parent
881d190d61
commit
b917d3476d
116
app.py
116
app.py
@ -5,6 +5,7 @@ import base64
|
|||||||
import pandas as pd
|
import pandas as pd
|
||||||
import qrcode
|
import qrcode
|
||||||
import uuid
|
import uuid
|
||||||
|
import time, math
|
||||||
from flask import Flask, render_template, request, session, redirect, url_for, abort
|
from flask import Flask, render_template, request, session, redirect, url_for, abort
|
||||||
from flask_socketio import SocketIO, emit, join_room
|
from flask_socketio import SocketIO, emit, join_room
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
@ -30,12 +31,20 @@ game = {
|
|||||||
|
|
||||||
@app.route('/', methods=['GET'])
|
@app.route('/', methods=['GET'])
|
||||||
def host():
|
def host():
|
||||||
|
# If this browser is already a player, don't let it hit the host view.
|
||||||
|
if session.get('role') == 'player':
|
||||||
|
secret = session.get('secret')
|
||||||
|
if secret:
|
||||||
|
return redirect(url_for('player', secret=secret))
|
||||||
|
return redirect(url_for('player'))
|
||||||
|
|
||||||
session['role'] = 'host'
|
session['role'] = 'host'
|
||||||
# — manage per-game secret in session —
|
# — manage per-game secret in session —
|
||||||
secret = session.get('secret')
|
secret = session.get('secret')
|
||||||
if not secret:
|
if not secret:
|
||||||
secret = uuid.uuid4().hex[:8]
|
secret = uuid.uuid4().hex[:8]
|
||||||
session['secret'] = secret
|
session['secret'] = secret
|
||||||
|
|
||||||
# ensure game exists (with questions placeholder)
|
# ensure game exists (with questions placeholder)
|
||||||
if secret not in games:
|
if secret not in games:
|
||||||
games[secret] = {
|
games[secret] = {
|
||||||
@ -198,9 +207,13 @@ def player():
|
|||||||
|
|
||||||
@app.route('/player/join', methods=['POST'])
|
@app.route('/player/join', methods=['POST'])
|
||||||
def do_join():
|
def do_join():
|
||||||
name = request.form.get('name') or 'Anonymous'
|
name = (request.form.get('name') or 'Anonymous').strip()
|
||||||
session['role'] = 'player'
|
if not name:
|
||||||
session['joined'] = True
|
name = 'Anonymous'
|
||||||
|
|
||||||
|
session['role'] = 'player'
|
||||||
|
session['joined'] = True
|
||||||
|
|
||||||
# make sure the player is still talking to a live game
|
# make sure the player is still talking to a live game
|
||||||
secret = session.get('secret')
|
secret = session.get('secret')
|
||||||
if not secret or secret not in games:
|
if not secret or secret not in games:
|
||||||
@ -213,11 +226,25 @@ def do_join():
|
|||||||
error='Spiel nicht gefunden'
|
error='Spiel nicht gefunden'
|
||||||
), 400
|
), 400
|
||||||
|
|
||||||
|
# ─── enforce unique name per game (case-insensitive) ───
|
||||||
|
existing_lower = {v['name'].strip().lower() for v in games[secret]['players'].values()}
|
||||||
|
if name.lower() in existing_lower:
|
||||||
|
# do NOT mark session as joined; force them back to join view with error
|
||||||
|
session['joined'] = False
|
||||||
|
return render_template(
|
||||||
|
'player.html',
|
||||||
|
joined=False,
|
||||||
|
state='join',
|
||||||
|
options=[],
|
||||||
|
answered=False,
|
||||||
|
error='Dieser Name ist bereits vergeben. Bitte wähle einen anderen.'
|
||||||
|
), 400
|
||||||
|
|
||||||
|
# proceed with normal join
|
||||||
session['player_name'] = name
|
session['player_name'] = name
|
||||||
session['player_id'] = str(uuid.uuid4())
|
session['player_id'] = str(uuid.uuid4())
|
||||||
return redirect(
|
return redirect(url_for('player', secret=session.get('secret')))
|
||||||
url_for('player', secret=session.get('secret'))
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@socketio.on('connect')
|
@socketio.on('connect')
|
||||||
@ -290,7 +317,7 @@ def on_start_game():
|
|||||||
'finished': False
|
'finished': False
|
||||||
})
|
})
|
||||||
for p in game['players'].values():
|
for p in game['players'].values():
|
||||||
p['score'] = 0
|
p['score'] = 0.0
|
||||||
|
|
||||||
game['started'] = True
|
game['started'] = True
|
||||||
socketio.emit('game_started', room='player')
|
socketio.emit('game_started', room='player')
|
||||||
@ -308,7 +335,6 @@ def on_get_roster():
|
|||||||
emit('update_roster', {'players': roster})
|
emit('update_roster', {'players': roster})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@socketio.on('submit_answer')
|
@socketio.on('submit_answer')
|
||||||
def on_submit_answer(data):
|
def on_submit_answer(data):
|
||||||
secret = session.get('secret')
|
secret = session.get('secret')
|
||||||
@ -318,10 +344,16 @@ def on_submit_answer(data):
|
|||||||
game = games[secret]
|
game = games[secret]
|
||||||
if game.get('revealed'):
|
if game.get('revealed'):
|
||||||
return
|
return
|
||||||
if any(pid == p for p,_ in game['answered']):
|
if any(pid == t[0] for t in game['answered']):
|
||||||
return
|
return
|
||||||
|
|
||||||
game['answered'].append((pid, data.get('answer')))
|
# capture reaction time
|
||||||
|
now = time.perf_counter()
|
||||||
|
q_started = game.get('q_started', now)
|
||||||
|
rt = max(0.0, now - q_started)
|
||||||
|
|
||||||
|
# store (pid, answer, rt)
|
||||||
|
game['answered'].append((pid, data.get('answer'), rt))
|
||||||
_score_and_emit(secret)
|
_score_and_emit(secret)
|
||||||
|
|
||||||
|
|
||||||
@ -403,7 +435,7 @@ def _send_sync_state(pid, game):
|
|||||||
else:
|
else:
|
||||||
idx = game['current_q']
|
idx = game['current_q']
|
||||||
q = game['questions'][idx]
|
q = game['questions'][idx]
|
||||||
answered = any(pid == p for p,_ in game['answered'])
|
answered = any(pid == t[0] for t in game['answered'])
|
||||||
emit('sync_state', {
|
emit('sync_state', {
|
||||||
'state': 'question',
|
'state': 'question',
|
||||||
'question': q['question'],
|
'question': q['question'],
|
||||||
@ -416,62 +448,58 @@ def _send_question(secret):
|
|||||||
game = games[secret]
|
game = games[secret]
|
||||||
game['revealed'] = False
|
game['revealed'] = False
|
||||||
q = game['questions'][game['current_q']]
|
q = game['questions'][game['current_q']]
|
||||||
# clear previous answers
|
|
||||||
game['answered'].clear()
|
game['answered'].clear()
|
||||||
# broadcast
|
|
||||||
socketio.emit(
|
# NEW: start monotonic timer for this question
|
||||||
'new_question',
|
game['q_started'] = time.perf_counter()
|
||||||
{'question': q['question'], 'options': q['options']},
|
game['q_deadline_s'] = 20 # configurable per question if you want
|
||||||
room='player'
|
|
||||||
)
|
socketio.emit('new_question', {'question': q['question'], 'options': q['options']}, room='player')
|
||||||
socketio.emit(
|
socketio.emit('new_question', {'question': q['question']}, room='host')
|
||||||
'new_question',
|
|
||||||
{'question': q['question']},
|
|
||||||
room='host'
|
def _calc_points(rt_s: float, deadline_s: float, correct: bool) -> float:
|
||||||
)
|
if not correct or deadline_s <= 0:
|
||||||
|
return 0.0
|
||||||
|
if rt_s < 0:
|
||||||
|
rt_s = 0.0
|
||||||
|
if rt_s >= deadline_s:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
BASE = 10
|
||||||
|
MIN = 1
|
||||||
|
frac = 1.0 - (rt_s / float(deadline_s))
|
||||||
|
pts = MIN + (BASE - MIN) * frac
|
||||||
|
return round(pts, 1) # ← keep a decimal, not an int
|
||||||
|
|
||||||
|
|
||||||
def _score_and_emit(secret):
|
def _score_and_emit(secret):
|
||||||
game = games[secret]
|
game = games[secret]
|
||||||
# Punkteverteilung:
|
per_q = []
|
||||||
# 1. richtige Antwort → 5 Punkte
|
|
||||||
# 2. richtige Antwort → 4 Punkte
|
|
||||||
# 3. richtige Antwort → 3 Punkte
|
|
||||||
# alle weiteren richtigen Antworten → 1 Punkt
|
|
||||||
score_map = {1: 5, 2: 4, 3: 3}
|
|
||||||
per_q = []
|
|
||||||
|
|
||||||
# grab the current question’s correct answer
|
|
||||||
correct = game['questions'][game['current_q']]['correct']
|
correct = game['questions'][game['current_q']]['correct']
|
||||||
# take only the most‐recent submission
|
pid, ans, rt = game['answered'][-1] # NEW: includes rt
|
||||||
pid, ans = game['answered'][-1]
|
|
||||||
|
|
||||||
# count how many *earlier* answers were correct
|
# NEW: time-based points for correct answers
|
||||||
prev_correct = sum(1 for p, a in game['answered'][:-1] if a == correct)
|
pts = _calc_points(rt, game.get('q_deadline_s', 20), ans == correct)
|
||||||
rank = prev_correct + 1
|
if pts > 0:
|
||||||
|
|
||||||
# assign points only if korrekt
|
|
||||||
if ans == correct:
|
|
||||||
# für Platz 1–3 nach Mapping, ab Platz 4 immer 1 Punkt
|
|
||||||
pts = score_map.get(rank, 1)
|
|
||||||
game['players'][pid]['score'] += pts
|
game['players'][pid]['score'] += pts
|
||||||
per_q.append({
|
per_q.append({
|
||||||
'name': game['players'][pid]['name'],
|
'name': game['players'][pid]['name'],
|
||||||
'points': pts
|
'points': pts
|
||||||
})
|
})
|
||||||
|
|
||||||
# rebuild overall leaderboard
|
|
||||||
board = sorted(
|
board = sorted(
|
||||||
[{'name': v['name'], 'score': v['score']} for v in game['players'].values()],
|
[{'name': v['name'], 'score': v['score']} for v in game['players'].values()],
|
||||||
key=lambda x: -x['score']
|
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='host')
|
||||||
socketio.emit('question_leader', {'results': per_q}, room='player')
|
socketio.emit('question_leader', {'results': per_q}, room='player')
|
||||||
socketio.emit('overall_leader', {'board': board}, room='host')
|
socketio.emit('overall_leader', {'board': board}, room='host')
|
||||||
socketio.emit('overall_leader', {'board': board}, room='player')
|
socketio.emit('overall_leader', {'board': board}, room='player')
|
||||||
|
|
||||||
|
|
||||||
# if that was the last question, finish up
|
# if that was the last question, finish up
|
||||||
if game['current_q'] >= len(game['questions']):
|
if game['current_q'] >= len(game['questions']):
|
||||||
game['finished'] = True
|
game['finished'] = True
|
||||||
|
|||||||
@ -1,19 +1,91 @@
|
|||||||
<!-- host.html -->
|
<!-- host.html -->
|
||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
<style>
|
||||||
|
/* Right sidebar leaderboard (default during game) */
|
||||||
|
#final-results {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 380px;
|
||||||
|
height: 100vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: #fff;
|
||||||
|
border-left: 1px solid #dee2e6;
|
||||||
|
box-shadow: -2px 0 8px rgba(0,0,0,.06);
|
||||||
|
padding: 1rem;
|
||||||
|
z-index: 2000;
|
||||||
|
display: none; /* shown by JS */
|
||||||
|
transition: all .25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shift the page when the sidebar is visible */
|
||||||
|
body.with-sidebar { margin-right: 400px; }
|
||||||
|
|
||||||
|
/* NEW: centered leaderboard style for end of game */
|
||||||
|
#final-results.centerboard {
|
||||||
|
position: fixed; /* take it out of the flow */
|
||||||
|
top: 64px; /* nice breathing space from top */
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%); /* perfect horizontal centering */
|
||||||
|
right: auto; /* cancel sidebar positioning */
|
||||||
|
width: min(720px, calc(100vw - 32px));
|
||||||
|
max-height: calc(100vh - 128px);
|
||||||
|
overflow: auto;
|
||||||
|
margin: 0; /* override .mt-4 from markup */
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
box-shadow: 0 8px 24px rgba(0,0,0,.08);
|
||||||
|
z-index: 2050; /* above page content, below controls-bar (2100) */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Make large questions adapt responsively */
|
||||||
|
#qText {
|
||||||
|
font-size: 5rem;
|
||||||
|
line-height: 1.1;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
@media (max-width: 992px) { #qText { font-size: 3rem; } }
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
#final-results { width: 320px; }
|
||||||
|
body.with-sidebar { margin-right: 340px; }
|
||||||
|
#qText { font-size: 2.5rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sticky controls bar shown during the game */
|
||||||
|
.controls-bar {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: #fff;
|
||||||
|
border-top: 1px solid #dee2e6;
|
||||||
|
padding: .75rem 1rem;
|
||||||
|
z-index: 2100;
|
||||||
|
}
|
||||||
|
/* Leave room for the sidebar while in-game */
|
||||||
|
body.with-sidebar .controls-bar { right: 400px; }
|
||||||
|
|
||||||
|
/* Prevent content being covered by the controls bar */
|
||||||
|
#host-question { padding-bottom: 88px; } /* ~bar height + breathing room */
|
||||||
|
|
||||||
|
</style>
|
||||||
|
|
||||||
<h1 class="mb-4">Public Quiz</h1>
|
<h1 class="mb-4">Public Quiz</h1>
|
||||||
|
|
||||||
<div class="container" id="host-app">
|
<div class="container" id="host-app">
|
||||||
<!-- QR & Join Roster -->
|
<!-- QR & Join Roster -->
|
||||||
<div id="qrContainer" class="mb-4 text-center">
|
<div id="qrContainer" class="mb-4 text-center">
|
||||||
<img id="qrImg"
|
<img
|
||||||
src="data:image/png;base64,{{ qr_data }}"
|
id="qrImg"
|
||||||
class="img-fluid"
|
src="data:image/png;base64,{{ qr_data }}"
|
||||||
style="max-width:300px;"/>
|
class="img-fluid"
|
||||||
|
style="max-width:300px;"
|
||||||
|
/>
|
||||||
<p class="mt-2">Scanne den QR-Code, um dem Spiel beizutreten!</p>
|
<p class="mt-2">Scanne den QR-Code, um dem Spiel beizutreten!</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- new-game form -->
|
<!-- new-game form -->
|
||||||
<form action="{{ url_for('new_game') }}" method="post" class="d-inline">
|
<form id="newGameForm" action="{{ url_for('new_game') }}" method="post" class="d-inline">
|
||||||
<button type="submit" class="btn btn-secondary mb-3" id="newGameButton">
|
<button type="submit" class="btn btn-secondary mb-3" id="newGameButton">
|
||||||
Spiel neu starten
|
Spiel neu starten
|
||||||
</button>
|
</button>
|
||||||
@ -23,46 +95,117 @@
|
|||||||
<h3 id="rosterHeader">Spieler Beigetreten:</h3>
|
<h3 id="rosterHeader">Spieler Beigetreten:</h3>
|
||||||
<ul id="roster" class="list-group mb-4"></ul>
|
<ul id="roster" class="list-group mb-4"></ul>
|
||||||
|
|
||||||
<!-- Question & Current Leaderboard -->
|
<!-- Question & Answer Area -->
|
||||||
<div id="host-question" style="display:none;">
|
<div id="host-question" style="display:none;">
|
||||||
<h2 id="qText" style="font-size:5rem;"></h2>
|
<h2 id="qText"></h2>
|
||||||
<div id="host-results" style="display:none; margin:20px;">
|
<div id="host-results" style="display:none; margin:20px;">
|
||||||
<ul id="perQ" class="list-group mb-3 text-center"></ul>
|
<ul id="perQ" class="list-group mb-3 text-center"></ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3">
|
<div id="controlsBar" class="controls-bar" style="display:none;">
|
||||||
<button id="showAnswerBtn" class="btn btn-info">Antwort zeigen</button>
|
<div class="d-flex gap-2 justify-content-center">
|
||||||
<button id="nextBtn" class="btn btn-warning" disabled>Nächste Frage</button>
|
<button id="showAnswerBtn" class="btn btn-info">Antwort zeigen</button>
|
||||||
|
<button id="nextBtn" class="btn btn-warning" disabled>Nächste Frage</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Sidebar Leaderboard (used during game + final) -->
|
||||||
<!-- Final Top-10 Leaderboard -->
|
<aside id="final-results" class="mt-4">
|
||||||
<div id="final-results" style="display:none;" class="mt-4">
|
<h4 id="finalResultsTitle" class="mb-3">Rangliste</h4>
|
||||||
<h4>Rangliste</h4>
|
<table class="table table-striped mb-0">
|
||||||
<table class="table table-striped">
|
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>Rang</th><th>Name</th><th>Punkte</th></tr>
|
<tr><th>Rang</th><th>Name</th><th>Punkte</th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="finalResultsBody"></tbody>
|
<tbody id="finalResultsBody"></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</aside>
|
||||||
|
|
||||||
<!-- 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>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const socket = io();
|
const socket = io();
|
||||||
|
|
||||||
// Connect and fetch roster
|
/* ------------------ Persistence (no backend change) ------------------ */
|
||||||
|
const LS_KEY = 'leaderboardState'; // { title: string, board: array, ts: number }
|
||||||
|
let answerTimer = null;
|
||||||
|
|
||||||
|
function saveLeaderboardToStorage(title, board) {
|
||||||
|
try {
|
||||||
|
const payload = { title: title || 'Rangliste', board: board || [], ts: Date.now() };
|
||||||
|
localStorage.setItem(LS_KEY, JSON.stringify(payload));
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Could not save leaderboard:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadLeaderboardFromStorage() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(LS_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
const data = JSON.parse(raw);
|
||||||
|
if (!data || !Array.isArray(data.board)) return null;
|
||||||
|
return data;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Could not parse leaderboard:', e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearLeaderboardStorage() {
|
||||||
|
try { localStorage.removeItem(LS_KEY); } catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------ UI Helpers ------------------ */
|
||||||
|
function showSidebar(titleText = 'Rangliste') {
|
||||||
|
document.body.classList.add('with-sidebar');
|
||||||
|
const panel = document.getElementById('final-results');
|
||||||
|
panel.classList.remove('centerboard'); // NEW: ensure sidebar mode
|
||||||
|
$('#finalResultsTitle').text(titleText);
|
||||||
|
$('#final-results').show();
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideSidebar() {
|
||||||
|
document.body.classList.remove('with-sidebar');
|
||||||
|
$('#final-results').hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLeaderboard(board = [], limit = 10) {
|
||||||
|
const rows = (board || []).slice(0, limit);
|
||||||
|
const $tbody = $('#finalResultsBody');
|
||||||
|
$tbody.empty();
|
||||||
|
rows.forEach((p, i) => {
|
||||||
|
$tbody.append(
|
||||||
|
`<tr><td>${i + 1}</td><td>${p.name}</td><td>${Number(p.score).toFixed(1)}</td></tr>`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAndShow(title, board) {
|
||||||
|
renderLeaderboard(board, 10);
|
||||||
|
showSidebar(title);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------ Restore from storage on load ------------------ */
|
||||||
|
(function restoreFromStorageOnLoad() {
|
||||||
|
const stored = loadLeaderboardFromStorage();
|
||||||
|
if (stored && stored.board.length) {
|
||||||
|
if ((stored.title || '').toLowerCase().includes('final')) {
|
||||||
|
showCenteredBoard(stored.title, stored.board);
|
||||||
|
} else {
|
||||||
|
renderAndShow(stored.title || 'Rangliste', stored.board);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
hideSidebar();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
/* Clear storage when starting a totally new game (so an old board isn't shown) */
|
||||||
|
document.getElementById('newGameForm')?.addEventListener('submit', () => {
|
||||||
|
clearLeaderboardStorage();
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ------------------ Socket Wiring ------------------ */
|
||||||
|
|
||||||
|
// On connect, just fetch roster (no backend change required)
|
||||||
socket.on('connect', () => {
|
socket.on('connect', () => {
|
||||||
console.log('Connected as host');
|
console.log('Connected as host');
|
||||||
socket.emit('get_roster');
|
socket.emit('get_roster');
|
||||||
@ -72,17 +215,19 @@
|
|||||||
socket.on('update_roster', data => {
|
socket.on('update_roster', data => {
|
||||||
console.log('Roster update:', data.players);
|
console.log('Roster update:', data.players);
|
||||||
$('#roster').empty();
|
$('#roster').empty();
|
||||||
data.players.forEach(name => {
|
(data.players || []).forEach(name => {
|
||||||
$('#roster').append(`<li class="list-group-item">${name}</li>`);
|
$('#roster').append(
|
||||||
|
`<li class="list-group-item">${name}</li>`
|
||||||
|
);
|
||||||
});
|
});
|
||||||
$('#startBtn').prop('disabled', data.players.length === 0);
|
$('#startBtn').prop('disabled', (data.players || []).length === 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start game
|
// Start game
|
||||||
$('#startBtn').on('click', () => {
|
$('#startBtn').on('click', () => {
|
||||||
console.log('Start game clicked');
|
console.log('Start game clicked');
|
||||||
$('#newGameButton').hide();
|
$('#newGameButton').hide();
|
||||||
$('#overallFooter').show();
|
showSidebar('Rangliste');
|
||||||
socket.emit('start_game');
|
socket.emit('start_game');
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -91,68 +236,94 @@
|
|||||||
console.log('New question:', data.question);
|
console.log('New question:', data.question);
|
||||||
$('#qrContainer, #startBtn, #rosterHeader, #roster').hide();
|
$('#qrContainer, #startBtn, #rosterHeader, #roster').hide();
|
||||||
$('#host-question').show();
|
$('#host-question').show();
|
||||||
$('#qText').text(data.question);
|
$('#qText').text(data.question || '');
|
||||||
$('#host-results').hide();
|
$('#host-results').hide();
|
||||||
$('#showAnswerBtn').prop({ disabled: false }).show();
|
$('#showAnswerBtn').prop({ disabled: false }).show();
|
||||||
$('#nextBtn').prop('disabled', true);
|
$('#nextBtn').prop('disabled', true);
|
||||||
|
$('#controlsBar').show(); // show sticky buttons
|
||||||
|
|
||||||
|
// Keep the sidebar visible during the game
|
||||||
|
showSidebar('Rangliste');
|
||||||
|
|
||||||
|
/* NEW: (re)start the 21s auto-reveal timer */
|
||||||
|
if (answerTimer) clearTimeout(answerTimer);
|
||||||
|
answerTimer = setTimeout(() => {
|
||||||
|
if (!$('#showAnswerBtn').prop('disabled')) {
|
||||||
|
$('#showAnswerBtn').trigger('click');
|
||||||
|
}
|
||||||
|
}, 21000);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Overall leaderboard updates (max 5 rows)
|
// Live overall leaderboard updates -> fill the right sidebar (top 10)
|
||||||
socket.on('overall_leader', data => {
|
socket.on('overall_leader', data => {
|
||||||
console.log('Overall leader:', data.board);
|
console.log('Overall leader:', data.board);
|
||||||
window.lastOverall = data;
|
const board = (data && Array.isArray(data.board)) ? data.board : [];
|
||||||
$('#overallFooterBody').empty();
|
renderAndShow('Rangliste', board);
|
||||||
const top5 = data.board.slice(0, 5);
|
// Persist so a reload restores instantly
|
||||||
top5.forEach(p => {
|
saveLeaderboardToStorage('Rangliste', board);
|
||||||
$('#overallFooterBody').append(`<tr><td>${p.name}</td><td>${p.score}</td></tr>`);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Next question
|
// Next question
|
||||||
$('#nextBtn').off('click').on('click', () => {
|
$('#nextBtn').off('click').on('click', () => {
|
||||||
console.log('Next question clicked');
|
console.log('Next question clicked');
|
||||||
|
if (answerTimer) { clearTimeout(answerTimer); answerTimer = null; }
|
||||||
socket.emit('next_question');
|
socket.emit('next_question');
|
||||||
});
|
});
|
||||||
|
|
||||||
// when host clicks “Show Answer”
|
// when host clicks “Show Answer”
|
||||||
$('#showAnswerBtn').off('click').on('click', () => {
|
$('#showAnswerBtn').off('click').on('click', () => {
|
||||||
|
if (answerTimer) { clearTimeout(answerTimer); answerTimer = null; }
|
||||||
socket.emit('show_answer');
|
socket.emit('show_answer');
|
||||||
});
|
});
|
||||||
|
|
||||||
// once server confirms the answer is revealed
|
// once server confirms the answer is revealed
|
||||||
socket.on('answer_revealed', data => {
|
socket.on('answer_revealed', data => {
|
||||||
// display the correct answer on host screen
|
$('#perQ').empty().append(
|
||||||
$('#perQ').empty();
|
`<li class="list-group-item list-group-item-success" style="font-size:2.5rem;">${data.correct}</li>`
|
||||||
$('#perQ').append(
|
|
||||||
`<li class="list-group-item list-group-item-success" style="font-size:5rem;">${data.correct}</li>`
|
|
||||||
);
|
);
|
||||||
$('#host-results').show();
|
$('#host-results').show();
|
||||||
|
|
||||||
// disable Show Answer button, enable Next
|
|
||||||
$('#showAnswerBtn').prop('disabled', true);
|
$('#showAnswerBtn').prop('disabled', true);
|
||||||
$('#nextBtn').prop('disabled', false);
|
$('#nextBtn').prop('disabled', false);
|
||||||
|
|
||||||
|
if (answerTimer) { clearTimeout(answerTimer); answerTimer = null; }
|
||||||
});
|
});
|
||||||
|
|
||||||
// Show final top-10 when game_over arrives
|
// Final results (use the same sidebar, change the title)
|
||||||
socket.on('game_over', data => {
|
socket.on('game_over', data => {
|
||||||
if (data.board) {
|
$('#controlsBar').hide();
|
||||||
// hide all the old UI
|
/* NEW: clear any lingering timer */
|
||||||
$('#qrContainer, #startBtn, #rosterHeader, #roster, #host-question, #host-results, #overallFooter').hide();
|
if (answerTimer) { clearTimeout(answerTimer); answerTimer = null; }
|
||||||
|
|
||||||
|
if (data && Array.isArray(data.board)) {
|
||||||
|
console.log('Game over');
|
||||||
|
$('#qrContainer, #startBtn, #rosterHeader, #roster, #host-question, #host-results').hide();
|
||||||
$('#newGameButton').show();
|
$('#newGameButton').show();
|
||||||
|
|
||||||
// fill & show your existing <div id="final-results">…
|
// NEW: switch to centered leaderboard
|
||||||
const top10 = data.board.slice(0, 10);
|
showCenteredBoard('Finale Rangliste', data.board);
|
||||||
$('#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);
|
$('#startBtn').prop('disabled', true);
|
||||||
|
|
||||||
|
// Persist final board for reload
|
||||||
|
saveLeaderboardToStorage('Finale Rangliste', data.board);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function showCenteredBoard(title, board) {
|
||||||
|
renderLeaderboard(board, 10);
|
||||||
|
$('#finalResultsTitle').text(title);
|
||||||
|
|
||||||
|
// remove sidebar layout
|
||||||
|
document.body.classList.remove('with-sidebar');
|
||||||
|
|
||||||
|
// switch the panel into centered mode + make sure it's visible
|
||||||
|
const panel = document.getElementById('final-results');
|
||||||
|
panel.classList.add('centerboard');
|
||||||
|
panel.style.display = 'block'; // ← force visible
|
||||||
|
$('#final-results').show(); // ← double-sure in jQuery land
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------ Initial UI state for lobby ------------------ */
|
||||||
|
$('#host-question').hide();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -2,38 +2,61 @@
|
|||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<style>
|
<style>
|
||||||
|
/* Larger overall text */
|
||||||
|
body {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
h1, h2 {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Option buttons: bigger, comfy hit area */
|
||||||
|
.option {
|
||||||
|
font-size: 1.6rem; /* larger text on options */
|
||||||
|
padding: 1rem 1.25rem; /* bigger click target */
|
||||||
|
text-align: left;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
.option.selected {
|
.option.selected {
|
||||||
background-color:rgb(110, 168, 255); /* bootstrap “primary” bg */
|
background-color: rgb(110, 168, 255); /* primary bg */
|
||||||
color: black;
|
color: black;
|
||||||
}
|
}
|
||||||
.option.disabled {
|
.option.disabled {
|
||||||
background-color:rgb(148, 148, 148); /* bootstrap “secondary” bg */
|
background-color: rgb(148, 148, 148); /* secondary bg */
|
||||||
color: black;
|
color: black;
|
||||||
}
|
}
|
||||||
.option.correct {
|
.option.correct {
|
||||||
border: 5px solid #198754 !important; /* bootstrap “success” green */
|
border: 5px solid #198754 !important; /* success green */
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div class="container" id="player-app">
|
<div class="container" id="player-app">
|
||||||
<!-- Join View -->
|
<!-- Join View -->
|
||||||
<div id="view-join" {% if state == 'join' %} {% else %}style="display:none;"{% endif %}>
|
<div id="view-join" {% if state == 'join' %} {% else %}style="display:none;"{% endif %}>
|
||||||
<h1 class="mb-4">Dem Quiz Beitreten</h1>
|
<h1 class="mb-4">Dem Quiz Beitreten</h1>
|
||||||
{% if not error %}
|
|
||||||
<form id="joinForm" action="{{ url_for('do_join') }}" method="post">
|
{% if error %}
|
||||||
<div class="input-group mb-3">
|
|
||||||
<input name="name" id="nameInput" type="text"
|
|
||||||
class="form-control" placeholder="Deinen 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 id="joinStatus" class="mt-2">
|
||||||
<div class="alert alert-danger" role="alert">{{ error }}</div>
|
<div class="alert alert-danger mb-0" role="alert">{{ error }}</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{# Show the form unless the error is exactly "Spiel nicht gefunden" #}
|
||||||
|
{% if not (error and error == 'Spiel nicht gefunden') %}
|
||||||
|
<form id="joinForm" action="{{ url_for('do_join') }}" method="post" class="mt-3">
|
||||||
|
<div class="input-group mb-3">
|
||||||
|
<input name="name" id="nameInput" type="text"
|
||||||
|
class="form-control form-control-lg" placeholder="Name" required />
|
||||||
|
<button type="submit" class="btn btn-success btn-lg">Beitreten</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Wait View -->
|
<!-- Wait View -->
|
||||||
<div id="view-wait" {% if state == 'waiting' %} {% else %}style="display:none;"{% endif %}>
|
<div id="view-wait" {% if state == 'waiting' %} {% else %}style="display:none;"{% endif %}>
|
||||||
<div class="alert alert-info mt-4" role="alert">
|
<div class="alert alert-info mt-4" role="alert">
|
||||||
@ -53,12 +76,112 @@
|
|||||||
<p>Du hast <strong><span id="playerScore">0</span> Punkte</strong> erzielt und Platz <strong><span id="playerRank">–</span></strong> belegt.</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
// Key for storing a stable per-question order in this browser
|
||||||
|
function orderKeyForQuestion(qText) {
|
||||||
|
return 'order:' + hashStr(qText || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fisher–Yates shuffle using crypto when available
|
||||||
|
function shuffleArray(arr) {
|
||||||
|
const a = arr.slice();
|
||||||
|
for (let i = a.length - 1; i > 0; i--) {
|
||||||
|
let j;
|
||||||
|
if (window.crypto && window.crypto.getRandomValues) {
|
||||||
|
const r = new Uint32Array(1);
|
||||||
|
window.crypto.getRandomValues(r);
|
||||||
|
j = r[0] % (i + 1);
|
||||||
|
} else {
|
||||||
|
j = Math.floor(Math.random() * (i + 1));
|
||||||
|
}
|
||||||
|
[a[i], a[j]] = [a[j], a[i]];
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get a stable shuffled order for this question; persist in sessionStorage
|
||||||
|
function getShuffledOptions(options, questionText) {
|
||||||
|
const key = orderKeyForQuestion(questionText || '');
|
||||||
|
try {
|
||||||
|
const cached = sessionStorage.getItem(key);
|
||||||
|
if (cached) {
|
||||||
|
const parsed = JSON.parse(cached);
|
||||||
|
// Defensive: ensure it still contains exactly the same options (by set equality)
|
||||||
|
const same =
|
||||||
|
parsed.length === options.length &&
|
||||||
|
parsed.every(x => options.includes(x));
|
||||||
|
if (same) return parsed;
|
||||||
|
}
|
||||||
|
// No valid cache → shuffle and store
|
||||||
|
const shuffled = shuffleArray(options);
|
||||||
|
sessionStorage.setItem(key, JSON.stringify(shuffled));
|
||||||
|
return shuffled;
|
||||||
|
} catch (_) {
|
||||||
|
// If storage fails, just return a fresh shuffle (non-persistent)
|
||||||
|
return shuffleArray(options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const socket = io();
|
const socket = io();
|
||||||
|
|
||||||
|
/* =======================
|
||||||
|
Small utilities
|
||||||
|
======================= */
|
||||||
|
// Hash a string to build a stable storage key for each question text
|
||||||
|
function hashStr(str) {
|
||||||
|
let h = 5381;
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
h = ((h << 5) + h) ^ str.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return (h >>> 0).toString(36);
|
||||||
|
}
|
||||||
|
function selKeyForQuestion(qText) {
|
||||||
|
return 'sel:' + hashStr(qText || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the current question text so clicks can be stored against it
|
||||||
|
let currentQuestionText = '';
|
||||||
|
|
||||||
|
// Web Audio beep for new questions (autoplay-safe after first user interaction)
|
||||||
|
let audioCtx = null;
|
||||||
|
function playBeep() {
|
||||||
|
try {
|
||||||
|
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
if (audioCtx.state === 'suspended') audioCtx.resume();
|
||||||
|
|
||||||
|
const o = audioCtx.createOscillator();
|
||||||
|
const g = audioCtx.createGain();
|
||||||
|
|
||||||
|
o.type = 'sine';
|
||||||
|
o.frequency.value = 880; // A5
|
||||||
|
g.gain.setValueAtTime(0.0001, audioCtx.currentTime);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.18, audioCtx.currentTime + 0.01);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.0001, audioCtx.currentTime + 0.22);
|
||||||
|
|
||||||
|
o.connect(g); g.connect(audioCtx.destination);
|
||||||
|
o.start();
|
||||||
|
o.stop(audioCtx.currentTime + 0.24);
|
||||||
|
} catch (e) {
|
||||||
|
// ignore if blocked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// After the first click/touch, audio context can play safely
|
||||||
|
window.addEventListener('click', () => {
|
||||||
|
if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume();
|
||||||
|
}, { once: true });
|
||||||
|
|
||||||
|
window.addEventListener('pointerdown', () => {
|
||||||
|
if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume();
|
||||||
|
}, { once: true });
|
||||||
|
|
||||||
|
/* =======================
|
||||||
|
Socket wiring
|
||||||
|
======================= */
|
||||||
// Restore state on load
|
// Restore state on load
|
||||||
socket.on('connect', () => {
|
socket.on('connect', () => {
|
||||||
socket.emit('rejoin_game');
|
socket.emit('rejoin_game');
|
||||||
@ -73,12 +196,13 @@
|
|||||||
else if (data.state === 'question') {
|
else if (data.state === 'question') {
|
||||||
$('#view-join, #view-wait').hide();
|
$('#view-join, #view-wait').hide();
|
||||||
$('#view-question').show();
|
$('#view-question').show();
|
||||||
renderOptions(data.options, data.answered);
|
currentQuestionText = data.question || '';
|
||||||
|
renderOptions(data.options, data.answered, currentQuestionText);
|
||||||
}
|
}
|
||||||
else if (data.state === 'end') {
|
else if (data.state === 'end') {
|
||||||
$('#view-join, #view-wait, #view-question').hide();
|
$('#view-join, #view-wait, #view-question').hide();
|
||||||
$('#view-end').show();
|
$('#view-end').show();
|
||||||
$('#playerScore').text(data.score);
|
$('#playerScore').text(Number(data.score).toFixed(1));
|
||||||
$('#playerRank').text(data.placement);
|
$('#playerRank').text(data.placement);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -93,34 +217,28 @@
|
|||||||
socket.on('new_question', data => {
|
socket.on('new_question', data => {
|
||||||
$('#view-join, #view-wait').hide();
|
$('#view-join, #view-wait').hide();
|
||||||
$('#view-question').show();
|
$('#view-question').show();
|
||||||
renderOptions(data.options, false);
|
currentQuestionText = data.question || '';
|
||||||
|
renderOptions(data.options, false, currentQuestionText);
|
||||||
|
playBeep();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
// when the right answer is revealed
|
// when the right answer is revealed
|
||||||
socket.on('answer_revealed', data => {
|
socket.on('answer_revealed', data => {
|
||||||
$('.option').each(function() {
|
$('.option').each(function() {
|
||||||
const $opt = $(this);
|
const $opt = $(this);
|
||||||
if ($opt.text() === data.correct) {
|
if ($opt.text() === data.correct) {
|
||||||
// highlight as correct with green outline
|
$opt.addClass('correct').removeClass('btn-outline-primary disabled').prop('disabled', true);
|
||||||
$opt
|
|
||||||
.addClass('correct')
|
|
||||||
.removeClass('btn-outline-primary disabled')
|
|
||||||
.prop('disabled', true);
|
|
||||||
} else {
|
} else {
|
||||||
// any un-clicked options become permanently disabled
|
|
||||||
if (!$opt.hasClass('selected')) {
|
if (!$opt.hasClass('selected')) {
|
||||||
$opt
|
$opt.addClass('disabled').removeClass('btn-outline-primary btn-primary').prop('disabled', true);
|
||||||
.addClass('disabled')
|
|
||||||
.removeClass('btn-outline-primary btn-primary')
|
|
||||||
.prop('disabled', true);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* =======================
|
||||||
// Handle answer clicks
|
Click handling
|
||||||
|
======================= */
|
||||||
$(document).on('click', '.option', function() {
|
$(document).on('click', '.option', function() {
|
||||||
const $all = $('.option');
|
const $all = $('.option');
|
||||||
const $me = $(this);
|
const $me = $(this);
|
||||||
@ -129,44 +247,69 @@
|
|||||||
// emit once
|
// emit once
|
||||||
socket.emit('submit_answer', { answer });
|
socket.emit('submit_answer', { answer });
|
||||||
|
|
||||||
|
// Persist selection for this question (so it's there after reloads)
|
||||||
|
try {
|
||||||
|
const key = selKeyForQuestion(currentQuestionText);
|
||||||
|
sessionStorage.setItem(key, answer);
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
// style the clicked one as selected
|
// style the clicked one as selected
|
||||||
$me
|
$me.addClass('selected').removeClass('btn-outline-primary').prop('disabled', true);
|
||||||
.addClass('selected')
|
|
||||||
.removeClass('btn-outline-primary')
|
|
||||||
.prop('disabled', true);
|
|
||||||
|
|
||||||
// style all the others as disabled
|
// style all the others as disabled
|
||||||
$all.not($me)
|
$all.not($me).addClass('disabled').removeClass('btn-outline-primary btn-primary').prop('disabled', true);
|
||||||
.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) {
|
Rendering
|
||||||
|
======================= */
|
||||||
|
// Render options with optional restoration of a previously selected answer.
|
||||||
|
function renderOptions(options, alreadyAnswered, questionText) {
|
||||||
const $opts = $('#options').empty();
|
const $opts = $('#options').empty();
|
||||||
options.forEach(opt => {
|
|
||||||
|
// 1) Determine (and cache) a stable shuffled order for THIS player & question
|
||||||
|
const ordered = getShuffledOptions(options, questionText);
|
||||||
|
|
||||||
|
// 2) Check if the player already picked something for this question
|
||||||
|
const storedKey = selKeyForQuestion(questionText || '');
|
||||||
|
let storedAnswer = null;
|
||||||
|
try { storedAnswer = sessionStorage.getItem(storedKey); } catch (_) {}
|
||||||
|
|
||||||
|
// 3) Render buttons in the shuffled order
|
||||||
|
ordered.forEach(opt => {
|
||||||
const btn = $(`<button class="btn option btn-outline-primary btn-lg mb-2">${opt}</button>`);
|
const btn = $(`<button class="btn option btn-outline-primary btn-lg mb-2">${opt}</button>`);
|
||||||
if (alreadyAnswered) {
|
|
||||||
btn.addClass('disabled')
|
if (storedAnswer && storedAnswer === opt) {
|
||||||
.removeClass('btn-outline-primary')
|
// Player picked this earlier on this device
|
||||||
.prop('disabled', true);
|
btn.addClass('selected').removeClass('btn-outline-primary').prop('disabled', true);
|
||||||
|
} else if (alreadyAnswered) {
|
||||||
|
// Answered (maybe from another device) -> disable all, but don't select
|
||||||
|
btn.addClass('disabled').removeClass('btn-outline-primary btn-primary').prop('disabled', true);
|
||||||
}
|
}
|
||||||
|
|
||||||
$opts.append(btn);
|
$opts.append(btn);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 4) If we have a stored selection, disable all other buttons
|
||||||
|
if (storedAnswer) {
|
||||||
|
$('.option').each(function() {
|
||||||
|
const $o = $(this);
|
||||||
|
if (!$o.hasClass('selected')) {
|
||||||
|
$o.addClass('disabled').removeClass('btn-outline-primary btn-primary').prop('disabled', true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Handle end-of-game for players
|
// Handle end-of-game for players
|
||||||
socket.on('game_over', data => {
|
socket.on('game_over', data => {
|
||||||
if (data.placement !== undefined) {
|
if (data.placement !== undefined) {
|
||||||
// hide everything else
|
|
||||||
$('#view-join, #view-wait, #view-question').hide();
|
$('#view-join, #view-wait, #view-question').hide();
|
||||||
// show final screen
|
|
||||||
$('#view-end').show();
|
$('#view-end').show();
|
||||||
// fill in score & rank
|
$('#playerScore').text(Number(data.score).toFixed(1));
|
||||||
$('#playerScore').text(data.score);
|
|
||||||
$('#playerRank').text(data.placement);
|
$('#playerRank').text(data.placement);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -4,41 +4,38 @@
|
|||||||
|
|
||||||
<!-- Einführung -->
|
<!-- Einführung -->
|
||||||
<p class="lead">
|
<p class="lead">
|
||||||
Willkommen zum interaktiven Gruppenquiz! Lade deinen eigenen Fragebogen hoch und starte ein spannendes Live-Quiz.
|
Willkommen zum interaktiven Gruppenquiz! Lade deinen Fragebogen hoch und starte ein spannendes Live-Quiz.
|
||||||
Deine Teilnehmer beantworten die Fragen schnell und wettbewerbsorientiert direkt über ihre Smartphones.
|
Die Teilnehmenden antworten direkt über ihre Smartphones – schnell, fair und mit Live-Rangliste.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Punktevergabe -->
|
<!-- Punktevergabe (entspricht der aktuellen Backend-Logik) -->
|
||||||
|
<h2 class="mb-3">Wie werden Punkte vergeben?</h2>
|
||||||
<p>
|
<p>
|
||||||
Schnelle und richtige Antworten werden mit mehr Punkten belohnt. Jeder Spieler hat pro Frage nur einen Versuch.
|
Pro Frage zählt <strong>genau ein</strong> Antwortversuch pro Spieler. Punkte gibt es ausschließlich für
|
||||||
Ein guter Mix aus Schnelligkeit und Genauigkeit zahlt sich aus!
|
<strong>richtige</strong> Antworten und sie hängen davon ab, wie schnell geantwortet wird.
|
||||||
</p>
|
Das Zeitlimit pro Frage beträgt <strong>20 Sekunden</strong>.
|
||||||
<p class="text-muted">
|
|
||||||
<strong>Hinweis:</strong> Die ersten drei richtigen Antworten erhalten höhere Punktwerte, alle weiteren richtigen Antworten bringen jeweils 1 Punkt.
|
|
||||||
</p>
|
</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>erste richtige Antwort → 5 Punkte</li>
|
<li><strong>Sofort richtige Antwort:</strong> bis zu <strong>10 Punkte</strong></li>
|
||||||
<li>zweite richtige Antwort → 4 Punkte</li>
|
<li><strong>Knapp vor Ablauf:</strong> mindestens <strong>1 Punkt</strong></li>
|
||||||
<li>dritte richtige Antwort → 3 Punkte</li>
|
<li><strong>Falsch oder zu spät:</strong> <strong>0 Punkte</strong></li>
|
||||||
<li>jeder weitere richtige Antwort → 1 Punkt</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
|
<p>
|
||||||
<p>Sobald die richtige Antwort für alle anzeigt wird, werden keine weiteren Punkte verteilt.</p>
|
Die Punkte werden zwischen 10 (schnellste richtige Antwort) und 1 (direkt vor Fristende) berechnet
|
||||||
|
und auf <strong>eine Dezimalstelle</strong> gerundet (z. B. 7,3 Punkte).
|
||||||
|
</p>
|
||||||
|
<p class="text-muted">
|
||||||
|
Sobald die richtige Lösung eingeblendet wurde, werden <strong>keine weiteren Antworten</strong> mehr gewertet.
|
||||||
|
Die Rangliste aktualisiert sich nach jeder gewerteten Antwort und zeigt am Ende die finale Platzierung.
|
||||||
|
</p>
|
||||||
|
|
||||||
<!-- Dateivorlage -->
|
<!-- Dateivorlage -->
|
||||||
<h2 class="mb-4">Fragebogen erstellen</h2>
|
<h2 class="mb-4">Fragebogen erstellen</h2>
|
||||||
<p>
|
<p>
|
||||||
Nutze die <strong>Beispieldatei</strong> als Vorlage und trage deine Fragen sowie die Antwortoptionen ein.
|
Nutze die <strong>Beispieldatei</strong> als Vorlage und trage deine Fragen sowie die Antwortoptionen ein.
|
||||||
Deine Excel-Datei sollte folgende Spalten enthalten:
|
|
||||||
</p>
|
</p>
|
||||||
<ul>
|
|
||||||
<li><code>question</code>: Text der Frage (z. B. „Wer baute die Arche?“)</li>
|
|
||||||
<li><code>answer1</code> bis <code>answer4</code>: Vier Antwortmöglichkeiten</li>
|
|
||||||
<li><code>correct</code>: Spaltenname der richtigen Antwort (<code>answer1</code>-<code>answer4</code>)</li>
|
|
||||||
</ul>
|
|
||||||
<p class="text-muted">
|
<p class="text-muted">
|
||||||
Unterstützte Formate: <code>.xlsx</code>, <code>.xls</code>
|
Unterstützte Formate: <code>.xlsx</code>, <code>.xls</code> — maximal <strong>100</strong> Fragen.
|
||||||
Maximal 100 Fragen!
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Download-Link zur Beispiel-Datei -->
|
<!-- Download-Link zur Beispiel-Datei -->
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user