317 lines
10 KiB
HTML
317 lines
10 KiB
HTML
<!-- 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 || '');
|
||
}
|
||
|
||
// 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 || '');
|
||
}
|
||
|
||
// 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 %}
|