improve + test

This commit is contained in:
lelo 2025-10-22 20:52:38 +00:00
parent 881d190d61
commit b917d3476d
4 changed files with 518 additions and 179 deletions

116
app.py
View File

@ -5,6 +5,7 @@ import base64
import pandas as pd
import qrcode
import uuid
import time, math
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
@ -30,12 +31,20 @@ game = {
@app.route('/', methods=['GET'])
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'
# — manage per-game secret in session —
secret = session.get('secret')
if not secret:
secret = uuid.uuid4().hex[:8]
session['secret'] = secret
# ensure game exists (with questions placeholder)
if secret not in games:
games[secret] = {
@ -198,9 +207,13 @@ def player():
@app.route('/player/join', methods=['POST'])
def do_join():
name = request.form.get('name') or 'Anonymous'
session['role'] = 'player'
session['joined'] = True
name = (request.form.get('name') or 'Anonymous').strip()
if not name:
name = '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:
@ -213,11 +226,25 @@ def do_join():
error='Spiel nicht gefunden'
), 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_id'] = str(uuid.uuid4())
return redirect(
url_for('player', secret=session.get('secret'))
)
return redirect(url_for('player', secret=session.get('secret')))
@socketio.on('connect')
@ -290,7 +317,7 @@ def on_start_game():
'finished': False
})
for p in game['players'].values():
p['score'] = 0
p['score'] = 0.0
game['started'] = True
socketio.emit('game_started', room='player')
@ -308,7 +335,6 @@ def on_get_roster():
emit('update_roster', {'players': roster})
@socketio.on('submit_answer')
def on_submit_answer(data):
secret = session.get('secret')
@ -318,10 +344,16 @@ def on_submit_answer(data):
game = games[secret]
if game.get('revealed'):
return
if any(pid == p for p,_ in game['answered']):
if any(pid == t[0] for t in game['answered']):
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)
@ -403,7 +435,7 @@ def _send_sync_state(pid, game):
else:
idx = game['current_q']
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', {
'state': 'question',
'question': q['question'],
@ -416,62 +448,58 @@ def _send_question(secret):
game = games[secret]
game['revealed'] = False
q = game['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'
)
# NEW: start monotonic timer for this question
game['q_started'] = time.perf_counter()
game['q_deadline_s'] = 20 # configurable per question if you want
socketio.emit('new_question', {'question': q['question'], 'options': q['options']}, room='player')
socketio.emit('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):
game = games[secret]
# Punkteverteilung:
# 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 = []
game = games[secret]
per_q = []
# grab the current questions correct answer
correct = game['questions'][game['current_q']]['correct']
# take only the mostrecent submission
pid, ans = game['answered'][-1]
pid, ans, rt = game['answered'][-1] # NEW: includes rt
# 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 korrekt
if ans == correct:
# für Platz 13 nach Mapping, ab Platz 4 immer 1 Punkt
pts = score_map.get(rank, 1)
# NEW: time-based points for correct answers
pts = _calc_points(rt, game.get('q_deadline_s', 20), ans == correct)
if pts > 0:
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 newanswer 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(game['questions']):
game['finished'] = True

View File

@ -1,19 +1,91 @@
<!-- host.html -->
{% extends 'base.html' %}
{% 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>
<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;"/>
<img
id="qrImg"
src="data:image/png;base64,{{ qr_data }}"
class="img-fluid"
style="max-width:300px;"
/>
<p class="mt-2">Scanne den QR-Code, um dem Spiel beizutreten!</p>
</div>
<!-- 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">
Spiel neu starten
</button>
@ -23,46 +95,117 @@
<h3 id="rosterHeader">Spieler Beigetreten:</h3>
<ul id="roster" class="list-group mb-4"></ul>
<!-- Question & Current Leaderboard -->
<!-- Question & Answer Area -->
<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;">
<ul id="perQ" class="list-group mb-3 text-center"></ul>
</div>
<div class="mt-3">
<button id="showAnswerBtn" class="btn btn-info">Antwort zeigen</button>
<button id="nextBtn" class="btn btn-warning" disabled>Nächste Frage</button>
<div id="controlsBar" class="controls-bar" style="display:none;">
<div class="d-flex gap-2 justify-content-center">
<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>
<!-- Final Top-10 Leaderboard -->
<div id="final-results" style="display:none;" class="mt-4">
<h4>Rangliste</h4>
<table class="table table-striped">
<!-- Right Sidebar Leaderboard (used during game + final) -->
<aside id="final-results" class="mt-4">
<h4 id="finalResultsTitle" class="mb-3">Rangliste</h4>
<table class="table table-striped mb-0">
<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>
</aside>
</div>
<script>
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', () => {
console.log('Connected as host');
socket.emit('get_roster');
@ -72,17 +215,19 @@
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>`);
(data.players || []).forEach(name => {
$('#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
$('#startBtn').on('click', () => {
console.log('Start game clicked');
$('#newGameButton').hide();
$('#overallFooter').show();
showSidebar('Rangliste');
socket.emit('start_game');
});
@ -91,68 +236,94 @@
console.log('New question:', data.question);
$('#qrContainer, #startBtn, #rosterHeader, #roster').hide();
$('#host-question').show();
$('#qText').text(data.question);
$('#qText').text(data.question || '');
$('#host-results').hide();
$('#showAnswerBtn').prop({ disabled: false }).show();
$('#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 => {
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>`);
});
const board = (data && Array.isArray(data.board)) ? data.board : [];
renderAndShow('Rangliste', board);
// Persist so a reload restores instantly
saveLeaderboardToStorage('Rangliste', board);
});
// Next question
$('#nextBtn').off('click').on('click', () => {
console.log('Next question clicked');
if (answerTimer) { clearTimeout(answerTimer); answerTimer = null; }
socket.emit('next_question');
});
// when host clicks “Show Answer”
$('#showAnswerBtn').off('click').on('click', () => {
if (answerTimer) { clearTimeout(answerTimer); answerTimer = null; }
socket.emit('show_answer');
});
// once server confirms the answer is revealed
socket.on('answer_revealed', data => {
// display the correct answer on host screen
$('#perQ').empty();
$('#perQ').append(
`<li class="list-group-item list-group-item-success" style="font-size:5rem;">${data.correct}</li>`
$('#perQ').empty().append(
`<li class="list-group-item list-group-item-success" style="font-size:2.5rem;">${data.correct}</li>`
);
$('#host-results').show();
// disable Show Answer button, enable Next
$('#showAnswerBtn').prop('disabled', true);
$('#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 => {
if (data.board) {
// hide all the old UI
$('#qrContainer, #startBtn, #rosterHeader, #roster, #host-question, #host-results, #overallFooter').hide();
$('#controlsBar').hide();
/* NEW: clear any lingering timer */
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();
// 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();
// NEW: switch to centered leaderboard
showCenteredBoard('Finale Rangliste', data.board);
// make absolutely sure the “Start Game” button stays disabled
$('#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>
{% endblock %}

View File

@ -2,38 +2,61 @@
{% extends 'base.html' %}
{% block content %}
<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 {
background-color:rgb(110, 168, 255); /* bootstrap “primary” bg */
background-color: rgb(110, 168, 255); /* primary bg */
color: black;
}
.option.disabled {
background-color:rgb(148, 148, 148); /* bootstrap “secondary” bg */
background-color: rgb(148, 148, 148); /* secondary bg */
color: black;
}
.option.correct {
border: 5px solid #198754 !important; /* bootstrap “success” green */
border: 5px solid #198754 !important; /* success green */
}
</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="Deinen Name eingeben..." />
<button type="submit" class="btn btn-success">Beitreten</button>
</div>
</form>
{% else %}
<!-- show backend-passed error if present -->
{% if error %}
<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>
{% 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>
<!-- Wait View -->
<div id="view-wait" {% if state == 'waiting' %} {% else %}style="display:none;"{% endif %}>
<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>
</div>
</div>
</div>
<script>
// Key for storing a stable per-question order in this browser
function orderKeyForQuestion(qText) {
return 'order:' + hashStr(qText || '');
}
// FisherYates 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();
/* =======================
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
socket.on('connect', () => {
socket.emit('rejoin_game');
@ -73,12 +196,13 @@
else if (data.state === 'question') {
$('#view-join, #view-wait').hide();
$('#view-question').show();
renderOptions(data.options, data.answered);
currentQuestionText = data.question || '';
renderOptions(data.options, data.answered, currentQuestionText);
}
else if (data.state === 'end') {
$('#view-join, #view-wait, #view-question').hide();
$('#view-end').show();
$('#playerScore').text(data.score);
$('#playerScore').text(Number(data.score).toFixed(1));
$('#playerRank').text(data.placement);
}
});
@ -93,34 +217,28 @@
socket.on('new_question', data => {
$('#view-join, #view-wait').hide();
$('#view-question').show();
renderOptions(data.options, false);
currentQuestionText = data.question || '';
renderOptions(data.options, false, currentQuestionText);
playBeep();
});
// when the right answer is revealed
socket.on('answer_revealed', data => {
$('.option').each(function() {
const $opt = $(this);
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 {
// any un-clicked options become permanently disabled
if (!$opt.hasClass('selected')) {
$opt
.addClass('disabled')
.removeClass('btn-outline-primary btn-primary')
.prop('disabled', true);
$opt.addClass('disabled').removeClass('btn-outline-primary btn-primary').prop('disabled', true);
}
}
});
});
// Handle answer clicks
/* =======================
Click handling
======================= */
$(document).on('click', '.option', function() {
const $all = $('.option');
const $me = $(this);
@ -129,44 +247,69 @@
// emit once
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
$me
.addClass('selected')
.removeClass('btn-outline-primary')
.prop('disabled', true);
$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);
$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) {
/* =======================
Rendering
======================= */
// Render options with optional restoration of a previously selected answer.
function renderOptions(options, alreadyAnswered, questionText) {
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>`);
if (alreadyAnswered) {
btn.addClass('disabled')
.removeClass('btn-outline-primary')
.prop('disabled', true);
if (storedAnswer && storedAnswer === opt) {
// Player picked this earlier on this device
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);
});
// 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
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);
$('#playerScore').text(Number(data.score).toFixed(1));
$('#playerRank').text(data.placement);
}
})
});
</script>
{% endblock %}
{% endblock %}

View File

@ -4,41 +4,38 @@
<!-- Einführung -->
<p class="lead">
Willkommen zum interaktiven Gruppenquiz! Lade deinen eigenen Fragebogen hoch und starte ein spannendes Live-Quiz.
Deine Teilnehmer beantworten die Fragen schnell und wettbewerbsorientiert direkt über ihre Smartphones.
Willkommen zum interaktiven Gruppenquiz! Lade deinen Fragebogen hoch und starte ein spannendes Live-Quiz.
Die Teilnehmenden antworten direkt über ihre Smartphones schnell, fair und mit Live-Rangliste.
</p>
<!-- Punktevergabe -->
<!-- Punktevergabe (entspricht der aktuellen Backend-Logik) -->
<h2 class="mb-3">Wie werden Punkte vergeben?</h2>
<p>
Schnelle und richtige Antworten werden mit mehr Punkten belohnt. Jeder Spieler hat pro Frage nur einen Versuch.
Ein guter Mix aus Schnelligkeit und Genauigkeit zahlt sich aus!
</p>
<p class="text-muted">
<strong>Hinweis:</strong> Die ersten drei richtigen Antworten erhalten höhere Punktwerte, alle weiteren richtigen Antworten bringen jeweils 1 Punkt.
Pro Frage zählt <strong>genau ein</strong> Antwortversuch pro Spieler. Punkte gibt es ausschließlich für
<strong>richtige</strong> Antworten und sie hängen davon ab, wie schnell geantwortet wird.
Das Zeitlimit pro Frage beträgt <strong>20&nbsp;Sekunden</strong>.
</p>
<ul>
<li>erste richtige Antwort → 5 Punkte</li>
<li>zweite richtige Antwort → 4 Punkte</li>
<li>dritte richtige Antwort → 3 Punkte</li>
<li>jeder weitere richtige Antwort → 1 Punkt</li>
<li><strong>Sofort richtige Antwort:</strong> bis zu <strong>10 Punkte</strong></li>
<li><strong>Knapp vor Ablauf:</strong> mindestens <strong>1 Punkt</strong></li>
<li><strong>Falsch oder zu spät:</strong> <strong>0 Punkte</strong></li>
</ul>
<p>Sobald die richtige Antwort für alle anzeigt wird, werden keine weiteren Punkte verteilt.</p>
<p>
Die Punkte werden zwischen 10 (schnellste richtige Antwort) und 1 (direkt vor Fristende) berechnet
und auf <strong>eine Dezimalstelle</strong> gerundet (z.&nbsp;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 -->
<h2 class="mb-4">Fragebogen erstellen</h2>
<p>
Nutze die <strong>Beispieldatei</strong> als Vorlage und trage deine Fragen sowie die Antwortoptionen ein.
Deine Excel-Datei sollte folgende Spalten enthalten:
Nutze die <strong>Beispieldatei</strong> als Vorlage und trage deine Fragen sowie die Antwortoptionen ein.
</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">
Unterstützte Formate: <code>.xlsx</code>, <code>.xls</code>
Maximal 100 Fragen!
Unterstützte Formate: <code>.xlsx</code>, <code>.xls</code> — maximal <strong>100</strong> Fragen.
</p>
<!-- Download-Link zur Beispiel-Datei -->