1const AUTH_KEY = 'quizstorm_token'; 2 3function getToken() { 4 return localStorage.getItem(AUTH_KEY); 5} 6 7async function apiFetch(url, options = {}) { 8 const token = getToken(); 9 const headers = { 'Content-Type': 'application/json', ...options.headers }; 10 if (token) headers.Authorization = 'Bearer ' + token; 11 return fetch(url, { ...options, headers }); 12} 13 14const socket = io({ auth: { token: getToken() } }); 15 16const state = { 17 pin: '', 18 player: null, 19 timer: null, 20 score: 0 21}; 22 23const screens = { 24 join: document.querySelector('#join-screen'), 25 wait: document.querySelector('#wait-screen'), 26 answer: document.querySelector('#answer-screen'), 27 advert: document.querySelector('#advert-screen'), 28 slot: document.querySelector('#slot-screen'), 29 submitted: document.querySelector('#submitted-screen'), 30 result: document.querySelector('#result-screen'), 31 final: document.querySelector('#final-screen') 32}; 33 34const joinMessage = document.querySelector('#join-message'); 35const playerName = document.querySelector('#player-name'); 36const questionNumber = document.querySelector('#question-number'); 37const playerScore = document.querySelector('#player-score'); 38const timerBar = document.querySelector('#timer-bar'); 39const timerSeconds = document.querySelector('#timer-seconds'); 40const advertTimerBar = document.querySelector('#advert-timer-bar'); 41const advertSeconds = document.querySelector('#advert-seconds'); 42const advertNumber = document.querySelector('#advert-number'); 43const advertText = document.querySelector('#advert-text'); 44const advertMedia = document.querySelector('#advert-media'); 45const slotTimerBar = document.querySelector('#slot-timer-bar'); 46const slotSeconds = document.querySelector('#slot-seconds'); 47const slotNumber = document.querySelector('#slot-number'); 48const slotText = document.querySelector('#slot-text'); 49const slotLeverImage = document.querySelector('#slot-lever-image'); 50const slotReels = document.querySelector('#slot-reels'); 51const slotWinners = document.querySelector('#slot-winners'); 52const answerGrid = document.querySelector('#answer-grid'); 53const colorOptions = document.querySelector('#color-options'); 54const questionText = document.querySelector('#question-text'); 55const questionMedia = document.querySelector('#question-media'); 56const playerCorrectAnswer = document.querySelector('#player-correct-answer'); 57const resultEffect = document.querySelector('#result-effect'); 58const rankStatus = document.querySelector('#rank-status'); 59const finalRank = document.querySelector('#final-rank'); 60const finalLeaderboard = document.querySelector('#final-leaderboard'); 61const playerAuthStatus = document.querySelector('#player-auth-status'); 62 63const initialPin = new URLSearchParams(window.location.search).get('pin'); 64if (/^\d{6}$/.test(initialPin || '')) { 65 document.querySelector('#pin-input').value = initialPin; 66} 67 68async function loadPlayerAccount() { 69 const token = getToken(); 70 if (!token) { 71 playerAuthStatus.innerHTML = '<a href="/#register">Sign in</a>'; 72 return; 73 } 74 75 try { 76 const response = await apiFetch('/api/auth/me'); 77 const data = await response.json(); 78 if (!response.ok) throw new Error('Invalid login.'); 79 playerAuthStatus.textContent = 'Signed in as ' + data.user.username; 80 if (!document.querySelector('#name-input').value) { 81 document.querySelector('#name-input').value = data.user.username; 82 } 83 } catch (error) { 84 localStorage.removeItem(AUTH_KEY); 85 playerAuthStatus.innerHTML = '<a href="/#register">Sign in</a>'; 86 } 87} 88 89// ââ Countdown DOM refs ââââââââââââââââââââââââââââââââââââââââ 90const countdownOverlay = document.querySelector('#countdown-overlay'); 91const countdownNumber = document.querySelector('#countdown-number'); 92let countdownTimer = null; 93 94function showCountdown(countdownMs) { 95 clearInterval(countdownTimer); 96 Sound.countdown(); 97 countdownOverlay.classList.add('active'); 98 99 const steps = ['3', '2', '1', 'GO!']; 100 const stepMs = countdownMs / steps.length; 101 let step = 0; 102 103 function tick() { 104 if (step >= steps.length) { 105 stopCountdown(); 106 return; 107 } 108 countdownNumber.textContent = steps[step];
109 countdownNumber.style.animation = 'none'; 110 void countdownNumber.offsetWidth; 111 countdownNumber.style.animation = 'countdown-pop ' + (stepMs / 1000) + 's ease-out forwards'; 112 step++; 113 } 114 115 tick(); 116 countdownTimer = setInterval(tick, stepMs); 117} 118 119function stopCountdown() { 120 clearInterval(countdownTimer); 121 countdownTimer = null; 122 countdownOverlay.classList.remove('active'); 123 countdownNumber.textContent = ''; 124} 125 126function showScreen(name) { 127 Object.values(screens).forEach((screen) => screen.classList.remove('active')); 128 screens[name].classList.add('active'); 129} 130 131function escapeHtml(value) { 132 return String(value) 133 .replaceAll('&', '&') 134 .replaceAll('<', '<') 135 .replaceAll('>', '>') 136 .replaceAll('"', '"') 137 .replaceAll("'", '''); 138} 139 140function startTimer(startedAt, endsAt) { 141 clearInterval(state.timer); 142 const total = endsAt - startedAt; 143 144 function tick() { 145 const remaining = Math.max(endsAt - Date.now(), 0); 146 const percentage = total > 0 ? (remaining / total) * 100 : 0; 147 const secs = Math.ceil(remaining / 1000); 148 timerBar.style.width = `${percentage}%`; 149 timerSeconds.textContent = secs; 150 151 if (percentage > 50) { 152 timerBar.style.background = '#22c55e'; 153 } else if (percentage > 25) { 154 timerBar.style.background = '#eab308'; 155 } else { 156 timerBar.style.background = '#ef4444'; 157 } 158 } 159 160 tick(); 161 state.timer = setInterval(tick, 100); 162} 163 164function startAdvertTimer(startedAt, endsAt) { 165 clearInterval(state.timer); 166 const total = endsAt - startedAt; 167 168 function tick() { 169 const remaining = Math.max(endsAt - Date.now(), 0); 170 const percentage = total > 0 ? (remaining / total) * 100 : 0; 171 advertTimerBar.style.width = `${percentage}%`; 172 advertSeconds.textContent = Math.ceil(remaining / 1000); 173 174 if (percentage > 50) { 175 advertTimerBar.style.background = '#22c55e'; 176 } else if (percentage > 25) { 177 advertTimerBar.style.background = '#eab308'; 178 } else { 179 advertTimerBar.style.background = '#ef4444'; 180 } 181 } 182 183 tick(); 184 state.timer = setInterval(tick, 100); 185} 186 187function startSlotTimer(startedAt, endsAt) { 188 clearInterval(state.timer); 189 const total = endsAt - startedAt; 190 191 function tick() { 192 const remaining = Math.max(endsAt - Date.now(), 0); 193 const percentage = total > 0 ? (remaining / total) * 100 : 0; 194 slotTimerBar.style.width = `${percentage}%`; 195 slotSeconds.textContent = Math.ceil(remaining / 1000); 196 197 if (percentage > 50) { 198 slotTimerBar.style.background = '#22c55e'; 199 } else if (percentage > 25) { 200 slotTimerBar.style.background = '#eab308'; 201 } else { 202 slotTimerBar.style.background = '#ef4444'; 203 } 204 } 205 206 tick(); 207 state.timer = setInterval(tick, 100); 208} 209 210function renderAdvertMedia(advert) { 211 advertMedia.innerHTML = ''; 212 if (!advert || !advert.advert_media_url) return; 213 214 if (advert.advert_media_type === 'image') { 215 advertMedia.innerHTML = `<img src="${escapeHtml(advert.advert_media_url)}" alt="">`; 216 return; 217 } 218 219 if (advert.advert_media_type === 'video') { 220 advertMedia.innerHTML = ` 221 <video src="${escapeHtml(advert.advert_media_url)}" autoplay muted playsinline controls></video> 222 `; 223 const video = advertMedia.querySelector('video'); 224 if (video) video.play().catch(() => {}); 225 } 226} 227 228function renderQuestionMedia(question) { 229 questionMedia.innerHTML = ''; 230 if (!question || !question.question_media_url) return; 231 if (question.question_media_type === 'image') { 232 questionMedia.innerHTML = `<img src="${escapeHtml(question.question_media_url)}" alt="">`; 233 } 234} 235 236function renderLeaderboard(players) { 237 finalLeaderboard.innerHTML = players 238 .map((player) => `<li><span>${escapeHtml(player.name)}</span><span>${player.score}</span></li>`) 239 .join(''); 240} 241
242function renderSlotReels(players, winners) { 243 const names = (players.length ? players : winners).map((player) => player.name || 'Player'); 244 const winnerNames = winners.map((player) => player.name || 'Winner'); 245 slotWinners.innerHTML = ''; 246 slotReels.innerHTML = [0, 1, 2].map((index) => { 247 const winner = winnerNames[index] || winnerNames[winnerNames.length - 1] || 'Winner'; 248 const pool = names.length ? names : [winner]; 249 const displayNames = []; 250 for (let i = 0; i < 18; i += 1) displayNames.push(pool[i % pool.length]); 251 displayNames.push(winner); 252 displayNames.push(...pool.slice(0, 4)); 253 const stopY = -((displayNames.lastIndexOf(winner) - 1) * 58); 254 return ` 255 <div class="slot-reel"> 256 <div class="slot-window"></div> 257 <div class="slot-track" style="--slot-stop:${stopY}px; animation-duration:7s"> 258 ${displayNames.map((name) => `<span>${escapeHtml(name)}</span>`).join('')} 259 </div> 260 </div> 261 `; 262 }).join(''); 263} 264 265function renderSlotReady(players) { 266 const names = players.map((player) => player.name || 'Player'); 267 slotReels.innerHTML = ` 268 <div class="slot-ready-state"> 269 <strong>${names.length} player${names.length === 1 ? '' : 's'} loaded</strong> 270 <span>Waiting for host to pull the lever...</span> 271 </div> 272 `; 273 slotWinners.innerHTML = ''; 274} 275
276function renderSlotWinners(winners) { 277 slotWinners.innerHTML = winners 278 .map((winner, index) => `<li><span>#${index + 1}</span><strong>${escapeHtml(winner.name || 'Winner')}</strong></li>`) 279 .join(''); 280} 281 282function clearResultEffect() { 283 resultEffect.className = 'result-effect'; 284 resultEffect.innerHTML = ''; 285} 286 287function showCorrectEffect() { 288 const colors = ['#f8c537', '#22c55e', '#38bdf8', '#f472b6', '#f97316', '#a78bfa']; 289 clearResultEffect(); 290 resultEffect.classList.add('celebrate'); 291 resultEffect.innerHTML = 292 '<div class="result-face happy-face"><span class="face-eye left"></span><span class="face-eye right"></span><span class="face-mouth smile"></span></div>' + 293 Array.from({ length: 34 }, (_, index) => { 294 const color = colors[index % colors.length]; 295 const left = 4 + Math.random() * 92; 296 const delay = Math.random() * 0.45; 297 const duration = 1.8 + Math.random() * 1.2; 298 const rotate = -80 + Math.random() * 160; 299 return `<span class="confetti-piece" style="--x:${left}%; --delay:${delay}s; --duration:${duration}s; --rotate:${rotate}deg; --color:${color};"></span>`; 300 }).join(''); 301} 302 303function showWrongEffect() { 304 clearResultEffect(); 305 resultEffect.classList.add('missed'); 306 resultEffect.innerHTML = 307 '<div class="storm-cloud cloud-left"></div><div class="storm-cloud cloud-right"></div><div class="lightning bolt-left"></div><div class="lightning bolt-right"></div>' + 308 '<div class="result-face sad-face"><span class="face-eye left"></span><span class="face-eye right"></span><span class="face-mouth frown"></span></div>' + 309 Array.from({ length: 12 }, (_, index) => { 310 const left = 10 + Math.random() * 80; 311 const delay = index * 0.08; 312 const duration = 1.2 + Math.random() * 0.8; 313 return `<span class="tear-drop" style="--x:${left}%; --delay:${delay}s; --duration:${duration}s;"></span>`; 314 }).join(''); 315} 316 317document.querySelector('#join-form').addEventListener('submit', (event) => { 318 event.preventDefault(); 319 joinMessage.textContent = ''; 320 const pin = document.querySelector('#pin-input').value.trim(); 321 const name = document.querySelector('#name-input').value.trim(); 322 socket.emit('player:join', { pin, name }); 323}); 324 325socket.on('player:joined', ({ pin, player }) => { 326 Sound.join(); 327 state.pin = pin; 328 state.player = player; 329 state.score = player.score; 330 playerName.textContent = player.name; 331 playerScore.textContent = `${state.score} pts`; 332 showScreen('wait'); 333}); 334 335socket.on('player:reconnected', ({ pin, player, gameStatus }) => { 336 state.pin = pin; 337 state.player = player; 338 state.score = player.score; 339 playerName.textContent = player.name; 340 playerScore.textContent = `${state.score} pts`; 341 // Show wait screen â they'll catch the next question:started from host 342 showScreen('wait'); 343 joinMessage.textContent = ''; 344}); 345 346socket.on('game:started', () => { 347 Sound.gameStart(); 348 showScreen('wait'); 349}); 350 351socket.on('question:countdown', ({ countdownMs }) => { 352 advertMedia.innerHTML = ''; 353 questionMedia.innerHTML = ''; 354 showScreen('answer'); 355 answerGrid.innerHTML = ''; 356 colorOptions.innerHTML = ''; 357 colorOptions.classList.remove('active'); 358 showCountdown(countdownMs); 359}); 360 361socket.on('question:started', (payload) => { 362 stopCountdown(); 363 questionNumber.textContent = `Question ${payload.questionIndex + 1} of ${payload.totalQuestions}`; 364 playerScore.textContent = `${state.score} pts`; 365 questionText.textContent = payload.question.question_text; 366 renderQuestionMedia(payload.question); 367 368 const questionType = payload.question.question_type || 'multiple_choice'; 369 const isTrueFalse = questionType === 'true_false'; 370 const isColorChoice = questionType === 'color_choice'; 371 const isImageChoice = questionType === 'image_choice'; 372 373 if (isColorChoice) { 374 const colorNames = ['Red', 'Blue', 'Yellow', 'Green']; 375 const shapeNames = ['Square', 'Circle', 'Triangle', 'Star']; 376 const shapeClasses = ['shape-square', 'shape-circle', 'shape-triangle', 'shape-star']; 377 const colorClasses = ['color-red', 'color-blue', 'color-yellow', 'color-green']; 378 colorOptions.className = 'color-options active'; 379 colorOptions.innerHTML = payload.question.answers 380 .map((answer, index) => ` 381 <div class="color-option ${colorClasses[index] || ''}">
382 <span class="color-option-name">${colorNames[index] || 'Color'}</span> 383 <span class="color-option-text">${escapeHtml(answer.answer_text)}</span> 384 </div> 385 `) 386 .join(''); 387 answerGrid.className = 'answer-grid answer-grid-shapes'; 388 389 answerGrid.innerHTML = payload.question.answers 390 .map((answer, index) => ` 391 <button class="answer answer-shape" data-answer-id="${answer.id}" aria-label="${colorNames[index] || 'Color'} ${shapeNames[index] || 'shape'}"> 392 <span class="shape ${shapeClasses[index] || ''}"></span> 393 <span class="shape-label">${colorNames[index] || ''}</span> 394 </button> 395 `) 396 .join(''); 397 } else if (isImageChoice) { 398 colorOptions.innerHTML = ''; 399 colorOptions.classList.remove('active'); 400 const letters = ['A', 'B', 'C', 'D']; 401 answerGrid.className = 'answer-grid answer-grid-images'; 402 403 answerGrid.innerHTML = payload.question.answers 404 .map((answer, index) => { 405 const label = answer.answer_text ? `<span class="image-answer-label">${escapeHtml(answer.answer_text)}</span>` : ''; 406 const image = answer.answer_media_url ? `<img src="${escapeHtml(answer.answer_media_url)}" alt="">` : ''; 407 return ` 408 <button class="answer answer-image" data-answer-id="${answer.id}"> 409 <span class="answer-image-letter">${letters[index] || '?'}</span> 410 ${image} 411 ${label} 412 </button> 413 `; 414 }) 415 .join(''); 416 } else { 417 colorOptions.innerHTML = ''; 418 colorOptions.classList.remove('active'); 419 const letters = isTrueFalse ? ['T', 'F'] : ['A', 'B', 'C', 'D']; 420 const classes = isTrueFalse 421 ? ['answer answer-true', 'answer answer-false'] 422 : ['answer answer-a', 'answer answer-b', 'answer answer-c', 'answer answer-d']; 423 answerGrid.className = 'answer-grid'; 424 425 answerGrid.innerHTML = payload.question.answers 426 .map((answer, index) => { 427 const letter = letters[index] || '?'; 428 const className = classes[index] || 'answer-a'; 429 return ` 430 <button class="${className}" data-answer-id="${answer.id}"> 431 <span>${letter}</span>${escapeHtml(answer.answer_text)} 432 </button> 433 `; 434 }) 435 .join(''); 436 } 437 438 answerGrid.querySelectorAll('.answer').forEach((button) => { 439 button.addEventListener('click', () => { 440 answerGrid.querySelectorAll('.answer').forEach((item) => { 441 item.disabled = true; 442 item.classList.remove('selected'); 443 }); 444 button.classList.add('selected'); 445 socket.emit('player:answer', { 446 pin: state.pin, 447 questionIndex: payload.questionIndex, 448 answerId: Number(button.dataset.answerId) 449 }); 450 showScreen('submitted'); 451 }); 452 }); 453 454 startTimer(payload.startedAt, payload.endsAt); 455 showScreen('answer'); 456}); 457 458socket.on('advert:started', (payload) => { 459 stopCountdown(); 460 clearInterval(state.timer); 461 answerGrid.innerHTML = ''; 462 colorOptions.innerHTML = ''; 463 colorOptions.classList.remove('active'); 464 advertNumber.textContent = `Advert ${payload.itemIndex + 1} of ${payload.totalItems}`; 465 advertText.textContent = payload.advert.advert_text || 'Advert'; 466 renderAdvertMedia(payload.advert); 467 startAdvertTimer(payload.startedAt, payload.endsAt); 468 showScreen('advert'); 469}); 470 471socket.on('advert:ended', () => { 472 clearInterval(state.timer); 473 advertMedia.innerHTML = ''; 474 showScreen('wait'); 475}); 476 477socket.on('slot:ready', (payload) => { 478 stopCountdown(); 479 clearInterval(state.timer); 480 answerGrid.innerHTML = ''; 481 colorOptions.innerHTML = ''; 482 colorOptions.classList.remove('active'); 483 advertMedia.innerHTML = ''; 484 questionMedia.innerHTML = ''; 485 slotTimerBar.style.width = '100%'; 486 slotSeconds.textContent = '7'; 487 slotNumber.textContent = `Slot Spin ${payload.itemIndex + 1} of ${payload.totalItems}`; 488 slotText.textContent = payload.slot.slot_text || 'Slot Spin'; 489 slotLeverImage.src = '/assets/slot-ready.png'; 490 renderSlotReady(payload.players || []); 491 showScreen('slot'); 492}); 493 494socket.on('slot:started', (payload) => { 495 stopCountdown(); 496 clearInterval(state.timer); 497 answerGrid.innerHTML = ''; 498 colorOptions.innerHTML = ''; 499 colorOptions.classList.remove('active'); 500 advertMedia.innerHTML = ''; 501 questionMedia.innerHTML = ''; 502 slotNumber.textContent = `Slot Spin ${payload.itemIndex + 1} of ${payload.totalItems}`; 503 slotText.textContent = payload.slot.slot_text || 'Slot Spin'; 504 slotLeverImage.src = '/assets/slot-go.png'; 505 renderSlotReels(payload.players || [], payload.winners || []); 506 startSlotTimer(payload.startedAt, payload.endsAt); 507 showScreen('slot'); 508}); 509 510socket.on('slot:ended', ({ winners }) => { 511 clearInterval(state.timer); 512 renderSlotWinners(winners || []); 513}); 514 515socket.on('answer:accepted', () => { 516 showScreen('submitted'); 517}); 518 519socket.on('question:ended', ({ correctAnswer, leaderboard }) => { 520 clearInterval(state.timer); 521 const prevScore = state.score; 522 const me = leaderboard.find((player) => player.id === state.player.id); 523 const rank = leaderboard.findIndex((player) => player.id === state.player.id) + 1; 524 if (me) { 525 state.score = me.score; 526 playerScore.textContent = `${state.score} pts`; 527 } 528 529 const isCorrect = state.score > prevScore; 530 const resultScreen = screens.result; 531 532 // Flash background â green for correct, red for wrong 533 resultScreen.classList.remove('correct', 'wrong', 'result-flash'); 534 void resultScreen.offsetWidth; 535 resultScreen.classList.add('result-flash', isCorrect ? 'correct' : 'wrong'); 536 if (isCorrect) { 537 showCorrectEffect(); 538 Sound.correct(); 539 } else {
540 showWrongEffect(); 541 Sound.wrong(); 542 } 543 544 // Reveal animations 545 playerCorrectAnswer.textContent = correctAnswer.answer_text; 546 playerCorrectAnswer.classList.remove('reveal'); 547 void playerCorrectAnswer.offsetWidth; 548 playerCorrectAnswer.classList.add('reveal'); 549 550 rankStatus.textContent = rank > 0 ? `You are #${rank} with ${state.score} points` : `${state.score} points`; 551 rankStatus.classList.remove('reveal'); 552 void rankStatus.offsetWidth; 553 rankStatus.classList.add('reveal'); 554 555 showScreen('result'); 556}); 557 558socket.on('game:finished', ({ leaderboard }) => { 559 Sound.gameOver(); 560 const rank = leaderboard.findIndex((player) => player.id === state.player.id) + 1; 561 finalRank.textContent = rank > 0 ? `You finished #${rank}` : 'Game over'; 562 renderLeaderboard(leaderboard); 563 showScreen('final'); 564}); 565 566socket.on('error', ({ message }) => { 567 joinMessage.textContent = message; 568 if (screens.submitted.classList.contains('active')) { 569 showScreen('answer'); 570 } 571}); 572 573socket.on('player:kicked', () => { 574 Sound.kick(); 575 state.pin = ''; 576 state.player = null; 577 showScreen('join'); 578 joinMessage.textContent = 'You were removed from the game by the host.'; 579}); 580 581loadPlayerAccount();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.