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 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 question’s correct answer
|
||||
correct = game['questions'][game['current_q']]['correct']
|
||||
# take only the most‐recent 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 1–3 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 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(game['questions']):
|
||||
game['finished'] = True
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -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 || '');
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
||||
/* =======================
|
||||
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 %}
|
||||
|
||||
@ -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 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. 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 -->
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user