publicquiz/templates/player.html
2025-10-25 20:58:54 +00:00

317 lines
10 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!-- player.html -->
{% 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); /* primary bg */
color: black;
}
.option.disabled {
background-color: rgb(148, 148, 148); /* secondary bg */
color: black;
}
.option.correct {
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 error %}
<div id="joinStatus" class="mt-2">
<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">
Bitte warten bis das Spiel beginnt.
</div>
</div>
<!-- Options View -->
<div id="view-question" {% if state == 'question' %} {% else %}style="display:none;"{% endif %}>
<div id="options" class="d-grid gap-2"></div>
</div>
<!-- End View -->
<div id="view-end" style="display:none;">
<div class="alert alert-success mt-4 text-center">
<h2>Spiel beendet!</h2>
<p>Du hast <strong><span id="playerScore">0</span> Punkte</strong> erzielt und Platz <strong><span id="playerRank"></span></strong> belegt.</p>
</div>
</div>
</div>
<script>
let currentQuestionText = '';
let currentQid = null;
// 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 || '');
}
// 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');
});
// Sync initial or reconnect state
socket.on('sync_state', data => {
if (data.state === 'waiting') {
$('#view-join, #view-question').hide();
$('#view-wait').show();
}
else if (data.state === 'question') {
$('#view-join, #view-wait').hide();
$('#view-question').show();
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(Number(data.score).toFixed(1));
$('#playerRank').text(data.placement);
}
});
// Host clicked “Start Game”
socket.on('game_started', () => {
$('#view-join, #view-wait').hide();
$('#view-question').show();
});
// A new question just dropped
socket.on('new_question', data => {
$('#view-join, #view-wait').hide();
$('#view-question').show();
currentQid = data.qid;
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) {
$opt.addClass('correct').removeClass('btn-outline-primary disabled').prop('disabled', true);
} else {
if (!$opt.hasClass('selected')) {
$opt.addClass('disabled').removeClass('btn-outline-primary btn-primary').prop('disabled', true);
}
}
});
});
/* =======================
Click handling
======================= */
$(document).on('click', '.option', function() {
const $all = $('.option');
const $me = $(this);
const answer = $me.text();
// emit once
socket.emit('submit_answer', { answer, qid: currentQid });
// 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);
// style all the others as disabled
$all.not($me).addClass('disabled').removeClass('btn-outline-primary btn-primary').prop('disabled', true);
});
/* =======================
Rendering
======================= */
// Render options with optional restoration of a previously selected answer.
function renderOptions(options, alreadyAnswered, questionText) {
const $opts = $('#options').empty();
// 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 (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) {
$('#view-join, #view-wait, #view-question').hide();
$('#view-end').show();
$('#playerScore').text(Number(data.score).toFixed(1));
$('#playerRank').text(data.placement);
}
});
</script>
{% endblock %}