Initial commit

This commit is contained in:
2026-05-12 03:10:52 +00:00
commit b88f47b72d
9 changed files with 2210 additions and 0 deletions
+442
View File
@@ -0,0 +1,442 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NI Quiz · Host</title>
<!-- QR Code library -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&family=DM+Sans:wght@400;500&display=swap');
*{margin:0;padding:0;box-sizing:border-box;}
:root{
--bg:#000510;--surface:#0d1117;--surface2:#161b22;
--accent:#00c8ff;--green:#00ff88;--red:#ff3355;--amber:#ffcc00;--purple:#cc44ff;
--text:#e8edf5;--muted:#6b7a99;--border:rgba(255,255,255,0.08);
--pixel:'Press Start 2P',monospace;
}
html,body{width:100%;height:100vh;background:var(--bg);color:var(--text);font-family:'DM Sans',sans-serif;overflow:hidden;}
body::after{content:'';position:fixed;inset:0;background:repeating-linear-gradient(0deg,transparent,transparent 2px,rgba(0,0,0,0.1) 2px,rgba(0,0,0,0.1) 4px);pointer-events:none;z-index:9999;}
.grid-bg{position:fixed;inset:0;background-image:linear-gradient(rgba(0,200,255,0.03) 1px,transparent 1px),linear-gradient(90deg,rgba(0,200,255,0.03) 1px,transparent 1px);background-size:40px 40px;pointer-events:none;}
.screen{display:none;height:100vh;padding:1.5rem 2rem;flex-direction:column;}
.screen.active{display:flex;}
/* ── LOBBY ── */
#lobby-screen{align-items:center;justify-content:center;text-align:center;}
.logo{font-family:var(--pixel);font-size:1.6rem;color:var(--accent);text-shadow:0 0 30px var(--accent);margin-bottom:.3rem;}
.tagline{font-size:.8rem;color:var(--muted);margin-bottom:2rem;}
.lobby-main{display:flex;gap:2.5rem;align-items:flex-start;justify-content:center;width:100%;max-width:900px;}
/* QR */
.qr-box{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:1.2rem;text-align:center;min-width:200px;}
.qr-label{font-family:var(--pixel);font-size:.4rem;color:var(--muted);letter-spacing:.1em;margin-bottom:.8rem;}
#qrcode{display:flex;justify-content:center;margin-bottom:.8rem;}
#qrcode canvas,#qrcode img{border-radius:8px;}
.qr-url{font-size:.75rem;color:var(--accent);word-break:break-all;}
/* Player list */
.players-box{flex:1;background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:1.2rem;}
.players-box-title{font-family:var(--pixel);font-size:.45rem;color:var(--muted);letter-spacing:.1em;margin-bottom:.8rem;}
.player-count-big{font-family:var(--pixel);font-size:2.5rem;color:var(--accent);line-height:1;margin-bottom:.3rem;}
.player-count-sub{font-size:.75rem;color:var(--muted);margin-bottom:.8rem;}
.p-chips{display:flex;flex-wrap:wrap;gap:.4rem;max-height:120px;overflow-y:auto;}
.p-chip{padding:.25rem .6rem;border-radius:100px;background:var(--surface2);border:1px solid var(--border);font-size:.72rem;}
.start-row{display:flex;gap:.8rem;margin-top:2rem;}
.hbtn{padding:.8rem 1.8rem;border-radius:6px;font-family:var(--pixel);font-size:.5rem;letter-spacing:.08em;cursor:pointer;border:2px solid;transition:all .15s;}
.hbtn:active{transform:scale(.96);}
.hbtn-primary{border-color:var(--accent);color:#000;background:var(--accent);}
.hbtn-primary:disabled{opacity:.35;cursor:not-allowed;}
.hbtn-secondary{border-color:var(--border);color:var(--muted);background:transparent;}
.hbtn-secondary:hover{border-color:var(--muted);color:var(--text);}
.hbtn-danger{border-color:var(--red);color:var(--red);background:transparent;}
.hbtn-danger:hover{background:rgba(255,51,85,.1);}
/* ── QUESTION ── */
#question-screen{justify-content:space-between;}
.q-top{display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;}
.q-meta{font-family:var(--pixel);font-size:.5rem;color:var(--muted);}
.timer-big{font-family:var(--pixel);font-size:2.5rem;color:var(--amber);}
.timer-big.urgent{color:var(--red);animation:blink .5s step-end infinite;}
.q-body{display:flex;gap:1.5rem;flex:1;}
.q-left{flex:1;}
.q-text{font-family:var(--pixel);font-size:clamp(.55rem,1.2vw,.75rem);line-height:2;margin-bottom:1.2rem;color:var(--text);}
.opts-grid{display:grid;grid-template-columns:1fr 1fr;gap:.6rem;}
.opt-box{background:var(--surface);border:1.5px solid var(--border);border-radius:8px;padding:.8rem 1rem;font-size:.82rem;display:flex;align-items:center;gap:.6rem;}
.opt-box.correct{border-color:var(--green);background:rgba(0,255,136,.07);}
.opt-letter{font-family:var(--pixel);font-size:.55rem;min-width:24px;color:var(--muted);}
.q-right{width:240px;display:flex;flex-direction:column;gap:.8rem;}
.ans-box,.lb-box{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:1rem;}
.box-title{font-family:var(--pixel);font-size:.38rem;color:var(--muted);letter-spacing:.1em;margin-bottom:.6rem;}
.ans-count{font-family:var(--pixel);font-size:2rem;color:var(--accent);}
.ans-sub{font-size:.72rem;color:var(--muted);}
.ans-bar-wrap{margin-top:.6rem;height:4px;background:var(--surface2);border-radius:2px;}
.ans-bar{height:100%;background:var(--accent);border-radius:2px;transition:width .3s;}
.lb-box{flex:1;overflow:hidden;}
.lb-item{display:flex;align-items:center;gap:.5rem;margin-bottom:.45rem;font-size:.78rem;}
.lb-rank{font-family:var(--pixel);font-size:.38rem;color:var(--muted);min-width:18px;}
.lb-name{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:.78rem;}
.lb-score{font-family:var(--pixel);font-size:.38rem;color:var(--accent);}
.host-controls{display:flex;gap:.8rem;margin-top:.8rem;}
/* ── RESULTS ── */
#results-screen{justify-content:center;align-items:center;}
.res-inner{width:100%;display:flex;gap:2.5rem;align-items:flex-start;justify-content:center;}
.res-left{flex:1;max-width:480px;}
.res-q-num{font-family:var(--pixel);font-size:.4rem;color:var(--muted);letter-spacing:.1em;margin-bottom:.8rem;}
.correct-box{background:rgba(0,255,136,.06);border:1px solid rgba(0,255,136,.25);border-radius:10px;padding:1rem;margin-bottom:.8rem;}
.correct-box-lbl{font-family:var(--pixel);font-size:.38rem;color:var(--green);letter-spacing:.1em;margin-bottom:.4rem;}
.correct-box-text{font-size:.95rem;font-weight:500;}
.fun-fact-box{background:var(--surface);border:1px solid var(--border);border-left:3px solid var(--accent);border-radius:0 10px 10px 0;padding:.9rem 1.1rem;font-size:.85rem;line-height:1.65;}
.res-right{width:260px;}
.full-lb{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:1rem;}
.full-lb-title{font-family:var(--pixel);font-size:.5rem;margin-bottom:.8rem;}
.flb-item{display:flex;align-items:center;gap:.6rem;padding:.45rem 0;border-bottom:1px solid var(--border);font-size:.82rem;}
.flb-item:last-child{border:none;}
.flb-rank{font-size:.9rem;min-width:24px;}
.flb-name{flex:1;}
.flb-score{font-family:var(--pixel);font-size:.38rem;color:var(--accent);}
.flb-dept{font-size:.68rem;color:var(--muted);}
/* ── PODIUM ── */
#podium-screen{align-items:center;justify-content:center;text-align:center;}
#podium-screen h1{font-family:var(--pixel);font-size:1.2rem;color:var(--amber);text-shadow:0 0 20px var(--amber);margin-bottom:.3rem;}
.podium-stage{display:flex;align-items:flex-end;gap:1rem;margin:1.5rem 0;}
.p-block{display:flex;flex-direction:column;align-items:center;width:160px;}
.p-avatar{font-size:1.8rem;margin-bottom:.4rem;}
.p-pname{font-family:var(--pixel);font-size:.45rem;margin-bottom:.2rem;line-height:1.5;}
.p-pdept{font-size:.7rem;color:var(--muted);margin-bottom:.4rem;}
.p-pscore{font-size:.65rem;color:var(--muted);margin-bottom:.4rem;}
.p-bar{width:100%;display:flex;align-items:center;justify-content:center;border-radius:8px 8px 0 0;font-family:var(--pixel);font-size:1.5rem;font-weight:800;}
.pb1{background:rgba(255,204,0,.15);border:1px solid rgba(255,204,0,.4);height:120px;color:var(--amber);}
.pb2{background:rgba(200,200,200,.08);border:1px solid rgba(200,200,200,.3);height:85px;color:#9ca3af;}
.pb3{background:rgba(180,120,60,.08);border:1px solid rgba(180,120,60,.3);height:60px;color:#b4783c;}
.rest-list{display:flex;flex-direction:column;gap:.4rem;max-width:380px;}
.rest-item{display:flex;align-items:center;gap:.7rem;background:var(--surface);border:1px solid var(--border);border-radius:6px;padding:.5rem .8rem;font-size:.8rem;}
.rest-rank{font-family:var(--pixel);font-size:.38rem;color:var(--muted);min-width:22px;}
.rest-name{flex:1;}
.rest-score{font-family:var(--pixel);font-size:.38rem;color:var(--accent);}
@keyframes blink{0%,100%{opacity:1}50%{opacity:0}}
@keyframes confetti{from{transform:translateY(-20px) rotate(0);opacity:1}to{transform:translateY(100vh) rotate(720deg);opacity:0}}
.cfp{position:fixed;width:8px;height:8px;border-radius:2px;animation:confetti linear forwards;pointer-events:none;}
/* ── AUTH GATE ── */
#auth-gate{
position:fixed;inset:0;z-index:9000;
background:var(--bg);
display:flex;align-items:center;justify-content:center;
}
#auth-box{
display:flex;flex-direction:column;align-items:center;gap:1rem;
background:var(--surface);border:1px solid var(--border);
border-radius:16px;padding:2.5rem 2rem;width:100%;max-width:340px;
}
#auth-logo{font-family:var(--pixel);font-size:1rem;color:var(--accent);text-shadow:0 0 20px var(--accent);}
#auth-sub{font-size:.75rem;color:var(--muted);margin-bottom:.5rem;}
#auth-input{
width:100%;padding:.85rem 1rem;
background:var(--surface2);border:1px solid var(--border);border-radius:6px;
color:var(--text);font-size:1rem;font-family:'DM Sans',sans-serif;
outline:none;text-align:center;letter-spacing:.15em;
}
#auth-input:focus{border-color:var(--accent);}
#auth-btn{
width:100%;padding:.85rem;
background:var(--accent);border:none;border-radius:6px;
color:#000;font-family:var(--pixel);font-size:.55rem;letter-spacing:.1em;
cursor:pointer;transition:opacity .15s;
}
#auth-btn:hover{opacity:.85;}
#auth-err{font-family:var(--pixel);font-size:.4rem;color:var(--red);min-height:1rem;}
</style>
</head>
<body>
<div class="grid-bg"></div>
<!-- LOBBY -->
<div class="screen active" id="lobby-screen">
<div class="logo">🌐 NI QUIZ</div>
<div class="tagline">Network Intelligence · LCPR — Host Panel</div>
<div class="lobby-main">
<div class="qr-box">
<div class="qr-label">SCAN TO PLAY</div>
<div id="qrcode"></div>
<div class="qr-url" id="join-url"></div>
</div>
<div class="players-box">
<div class="players-box-title">PLAYERS CONNECTED</div>
<div class="player-count-big" id="player-count">0</div>
<div class="player-count-sub">participants ready</div>
<div class="p-chips" id="p-chips"></div>
</div>
</div>
<div class="start-row">
<button class="hbtn hbtn-primary" id="start-btn" disabled onclick="startGame()">▶ START GAME</button>
<button class="hbtn hbtn-danger" onclick="clearScores()">🗑 CLEAR SCORES</button>
<button class="hbtn hbtn-secondary" onclick="location.href='/scores'">📊 HISTORY</button>
</div>
</div>
<!-- QUESTION -->
<div class="screen" id="question-screen">
<div class="q-top">
<span class="q-meta" id="q-meta">Q 1 / 5</span>
<div class="timer-big" id="host-timer">15</div>
<button class="hbtn hbtn-secondary" onclick="showResults()">RESULTS →</button>
</div>
<div class="q-body">
<div class="q-left">
<div class="q-text" id="host-q-text"></div>
<div class="opts-grid" id="host-opts"></div>
</div>
<div class="q-right">
<div class="ans-box">
<div class="box-title">ANSWERED</div>
<div class="ans-count" id="ans-count">0</div>
<div class="ans-sub" id="ans-sub">of 0 players</div>
<div class="ans-bar-wrap"><div class="ans-bar" id="ans-bar" style="width:0%"></div></div>
</div>
<div class="lb-box">
<div class="box-title">LIVE RANKING</div>
<div id="live-lb"></div>
</div>
</div>
</div>
</div>
<!-- RESULTS -->
<div class="screen" id="results-screen">
<div class="res-inner">
<div class="res-left">
<div class="res-q-num" id="res-q-num">QUESTION 1</div>
<div class="correct-box">
<div class="correct-box-lbl">✓ CORRECT ANSWER</div>
<div class="correct-box-text" id="correct-ans-text"></div>
</div>
<div class="fun-fact-box" id="host-fun-fact"></div>
<div style="display:flex;gap:.8rem;margin-top:1.2rem;">
<button class="hbtn hbtn-primary" id="next-btn" onclick="nextQuestion()">NEXT →</button>
<button class="hbtn hbtn-secondary" onclick="resetGame()">RESET</button>
</div>
</div>
<div class="res-right">
<div class="full-lb">
<div class="full-lb-title">🏆 TOP 10</div>
<div id="full-lb-list"></div>
</div>
</div>
</div>
</div>
<!-- PODIUM -->
<div class="screen" id="podium-screen">
<h1>🏆 GAME OVER!</h1>
<div class="podium-stage" id="podium-stage"></div>
<div class="rest-list" id="rest-list"></div>
<div style="display:flex;gap:.8rem;margin-top:1.5rem;">
<button class="hbtn hbtn-primary" onclick="resetGame()">▶ PLAY AGAIN</button>
<button class="hbtn hbtn-secondary" onclick="location.href='/scores'">📊 FULL HISTORY</button>
</div>
</div>
<script>
let ws, currentTotal = 0, currentQ = 0, timerInterval = null;
// ── QR CODE ──────────────────────────────────────────────────────────────────
const baseUrl = `${location.protocol}//${location.hostname}${location.port?':'+location.port:''}`;
document.getElementById('join-url').textContent = baseUrl;
new QRCode(document.getElementById('qrcode'), {
text: baseUrl,
width: 160, height: 160,
colorDark: '#00c8ff', colorLight: '#0d1117',
correctLevel: QRCode.CorrectLevel.M
});
// ── WS ────────────────────────────────────────────────────────────────────────
function connectWS() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
ws = new WebSocket(`${proto}://${location.host}/ws/host`);
ws.onmessage = e => handleMessage(JSON.parse(e.data));
ws.onclose = () => setTimeout(connectWS, 2000);
}
function show(id) {
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
document.getElementById(id).classList.add('active');
}
function handleMessage(msg) {
if (msg.type === 'lobby_update') {
document.getElementById('player-count').textContent = msg.count;
document.getElementById('start-btn').disabled = msg.count < 1;
currentTotal = msg.count;
const chips = document.getElementById('p-chips');
chips.innerHTML = msg.players.map(p =>
`<span class="p-chip">${p.display || p.name}</span>`).join('');
}
else if (msg.type === 'question') {
currentQ = msg.number;
document.getElementById('q-meta').textContent = `Q ${msg.number} / ${msg.total}`;
document.getElementById('host-q-text').textContent = msg.q;
const letters = ['A','B','C','D'];
document.getElementById('host-opts').innerHTML = msg.options.map((o,i) =>
`<div class="opt-box" id="hopt-${i}"><span class="opt-letter">${letters[i]}</span>${o}</div>`
).join('');
document.getElementById('ans-count').textContent = '0';
document.getElementById('ans-sub').textContent = `of ${currentTotal} players`;
document.getElementById('ans-bar').style.width = '0%';
document.getElementById('live-lb').innerHTML = '';
show('question-screen');
startTimer(msg.time_limit);
}
else if (msg.type === 'answer_update') {
const pct = currentTotal > 0 ? (msg.answered / currentTotal * 100) : 0;
document.getElementById('ans-count').textContent = msg.answered;
document.getElementById('ans-sub').textContent = `of ${msg.total} players`;
document.getElementById('ans-bar').style.width = pct + '%';
// Live ranking from answer details
const lb = document.getElementById('live-lb');
lb.innerHTML = (msg.details || []).slice(0,10).map((d,i) =>
`<div class="lb-item">
<span class="lb-rank">${i+1}</span>
<span class="lb-name">${d.name}</span>
<span class="lb-score" style="color:${d.correct?'var(--green)':'var(--red)'}">${d.correct?d.elapsed+'s ✓':'✗'}</span>
</div>`).join('');
}
else if (msg.type === 'results') {
clearInterval(timerInterval);
document.getElementById('res-q-num').textContent = `QUESTION ${currentQ} / 5`;
document.getElementById('host-fun-fact').textContent = msg.fun_fact;
const opts = document.querySelectorAll('#host-opts .opt-box');
opts.forEach((o,i) => { if(i === msg.correct_idx) o.classList.add('correct'); });
document.getElementById('correct-ans-text').textContent =
opts[msg.correct_idx]?.textContent.trim().slice(1) || '';
document.getElementById('next-btn').textContent = currentQ >= 5 ? 'PODIUM →' : 'NEXT →';
// Top 10 leaderboard
const medals = ['🥇','🥈','🥉'];
document.getElementById('full-lb-list').innerHTML = msg.leaderboard.slice(0,10).map((p,i) =>
`<div class="flb-item">
<span class="flb-rank">${i<3?medals[i]:i+1}</span>
<span class="flb-name">${p.name}<br><span class="flb-dept">${p.dept||''}</span></span>
<span class="flb-score">${p.score.toLocaleString()}</span>
</div>`).join('');
show('results-screen');
}
else if (msg.type === 'podium') {
clearInterval(timerInterval);
buildPodium(msg.leaderboard);
}
}
function startTimer(secs) {
clearInterval(timerInterval);
let rem = secs;
const el = document.getElementById('host-timer');
function tick() {
el.textContent = rem;
el.classList.toggle('urgent', rem <= 5);
if(rem <= 0) clearInterval(timerInterval);
rem--;
}
tick();
timerInterval = setInterval(tick, 1000);
}
function startGame() { ws.send(JSON.stringify({type:'start_game'})); nextQuestion(); }
function nextQuestion() { ws.send(JSON.stringify({type:'next_question'})); }
function showResults() { clearInterval(timerInterval); ws.send(JSON.stringify({type:'show_results'})); }
function resetGame() { clearInterval(timerInterval); ws.send(JSON.stringify({type:'reset'})); show('lobby-screen'); }
async function clearScores() {
if (!confirm('¿Borrar todos los scores guardados? Esta acción no se puede deshacer.')) return;
const r = await fetch('/api/scores/clear', {method:'DELETE'});
if (r.ok) alert('Scores borrados.');
else alert('Error al borrar scores.');
}
function buildPodium(lb) {
const medals = ['🥇','🥈','🥉'];
const pbClass = ['pb1','pb2','pb3'];
const order = [1,0,2];
const stage = document.getElementById('podium-stage');
stage.innerHTML = order.map(i => {
const p = lb[i]; if(!p) return '';
return `<div class="p-block">
<div class="p-avatar">${medals[i]}</div>
<div class="p-pname">${p.name}</div>
<div class="p-pdept">${p.dept||''}</div>
<div class="p-pscore">${p.score.toLocaleString()} pts</div>
<div class="p-bar ${pbClass[i]}">${i+1}</div>
</div>`;
}).join('');
document.getElementById('rest-list').innerHTML = lb.slice(3).map((p,i) =>
`<div class="rest-item">
<span class="rest-rank">${i+4}</span>
<span class="rest-name">${p.name} <span style="color:var(--muted);font-size:.72rem;">${p.dept||''}</span></span>
<span class="rest-score">${p.score.toLocaleString()}</span>
</div>`).join('');
show('podium-screen');
confetti();
}
function confetti() {
const colors = ['#00c8ff','#cc44ff','#00ff88','#ffcc00','#fff'];
for(let i=0;i<80;i++) {
setTimeout(()=>{
const el = document.createElement('div');
el.className = 'cfp';
el.style.cssText = `left:${Math.random()*100}vw;background:${colors[Math.floor(Math.random()*colors.length)]};animation-duration:${2+Math.random()*2}s;top:-10px;`;
document.body.appendChild(el);
setTimeout(()=>el.remove(), 4000);
}, i*40);
}
}
// ── AUTH ─────────────────────────────────────────────────────────────────────
const HOST_PASSWORD = 'ni2026'; // ← cambia esto
function checkAuth() {
const saved = sessionStorage.getItem('host_auth');
if (saved === HOST_PASSWORD) { connectWS(); return; }
// Build gate overlay
const gate = document.createElement('div');
gate.id = 'auth-gate';
gate.innerHTML = `
<div id="auth-box">
<div id="auth-logo">🌐 NI QUIZ</div>
<div id="auth-sub">Host Panel · Acceso restringido</div>
<input id="auth-input" type="password" placeholder="contraseña" autocomplete="off" />
<button id="auth-btn">ENTRAR →</button>
<div id="auth-err"></div>
</div>`;
document.body.appendChild(gate);
const input = document.getElementById('auth-input');
const btn = document.getElementById('auth-btn');
const err = document.getElementById('auth-err');
function attempt() {
if (input.value === HOST_PASSWORD) {
sessionStorage.setItem('host_auth', HOST_PASSWORD);
gate.remove();
connectWS();
} else {
err.textContent = 'contraseña incorrecta';
input.value = '';
input.focus();
setTimeout(() => err.textContent = '', 2000);
}
}
btn.addEventListener('click', attempt);
input.addEventListener('keydown', e => { if (e.key === 'Enter') attempt(); });
input.focus();
}
checkAuth();
</script>
</body>
</html>
+763
View File
@@ -0,0 +1,763 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>NI Quiz · Arcade</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&family=DM+Sans:wght@400;500&display=swap');
*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent;}
:root{
--bg:#000510;--surface:#0a0e1a;--surface2:#111827;
--accent:#00c8ff;--green:#00ff88;--red:#ff3355;--amber:#ffcc00;--purple:#cc44ff;
--text:#e8edf5;--muted:#4a5568;--border:rgba(255,255,255,0.08);
--pixel:'Press Start 2P',monospace;
}
html,body{width:100%;height:100%;background:var(--bg);color:var(--text);font-family:'DM Sans',sans-serif;overflow:hidden;touch-action:none;}
/* SCANLINES */
body::after{content:'';position:fixed;inset:0;background:repeating-linear-gradient(0deg,transparent,transparent 2px,rgba(0,0,0,0.15) 2px,rgba(0,0,0,0.15) 4px);pointer-events:none;z-index:9999;}
.screen{display:none;flex-direction:column;align-items:center;justify-content:center;height:100vh;padding:1.5rem;text-align:center;}
.screen.active{display:flex;}
#lobby-screen{height:auto;min-height:100vh;overflow-y:auto;padding:2rem 1.5rem;justify-content:center;}
/* ── LANGUAGE SELECT ── */
#lang-screen .title{font-family:var(--pixel);font-size:1.2rem;color:var(--accent);margin-bottom:.3rem;text-shadow:0 0 20px var(--accent);line-height:1.8;}
#lang-screen .subtitle{font-family:var(--pixel);font-size:.45rem;color:var(--muted);margin-bottom:2.5rem;letter-spacing:.1em;}
.lang-btn{
display:block;width:100%;max-width:280px;margin:.6rem auto;
padding:1rem;border-radius:4px;
font-family:var(--pixel);font-size:.65rem;letter-spacing:.1em;
cursor:pointer;border:2px solid;transition:all .15s;
}
.lang-btn.en{border-color:var(--accent);color:var(--accent);background:rgba(0,200,255,.05);}
.lang-btn.es{border-color:var(--amber);color:var(--amber);background:rgba(255,204,0,.05);}
.lang-btn:active{transform:scale(.96);}
.lang-btn:hover{filter:brightness(1.3);}
.pixel-stars{position:fixed;inset:0;pointer-events:none;}
/* ── JOIN ── */
#join-screen .logo{font-family:var(--pixel);font-size:1rem;color:var(--accent);margin-bottom:.3rem;text-shadow:0 0 20px var(--accent);line-height:1.8;}
#join-screen .sub{font-size:.75rem;color:var(--muted);margin-bottom:2rem;}
.field-wrap{width:100%;max-width:320px;margin-bottom:.8rem;text-align:left;}
.field-wrap label{font-family:var(--pixel);font-size:.45rem;color:var(--muted);letter-spacing:.1em;display:block;margin-bottom:.4rem;}
.field-wrap input, .field-wrap select{
width:100%;padding:.85rem 1rem;
background:var(--surface2);border:1px solid var(--border);border-radius:4px;
color:var(--text);font-size:.9rem;font-family:'DM Sans',sans-serif;
outline:none;
}
.field-wrap input:focus,.field-wrap select:focus{border-color:var(--accent);}
.field-wrap input::placeholder{color:var(--muted);}
.field-wrap select option{background:var(--surface2);}
#join-btn{
width:100%;max-width:320px;padding:1rem;margin-top:.5rem;
background:var(--accent);border:none;border-radius:4px;
color:#000;font-family:var(--pixel);font-size:.6rem;letter-spacing:.1em;
cursor:pointer;
}
#join-btn:active{opacity:.85;transform:scale(.97);}
/* ── LOBBY ── */
#lobby-screen{
justify-content:center;
min-height:100vh;
height:auto;
padding:2rem 1.5rem;
overflow-y:auto;
}
#lobby-screen .big{font-size:3.5rem;margin-bottom:1rem;animation:float 2s ease-in-out infinite;}
#lobby-screen .pname{font-family:var(--pixel);font-size:.7rem;color:var(--accent);margin:.5rem 0;}
#lobby-screen .wait{font-family:var(--pixel);font-size:.4rem;color:var(--muted);animation:blink 1s step-end infinite;margin-top:1rem;}
.exit-btn{
margin-top:1.5rem;
background:transparent;border:1px solid var(--muted);
color:var(--muted);font-family:var(--pixel);font-size:.4rem;
letter-spacing:.08em;padding:.6rem 1.4rem;border-radius:4px;
cursor:pointer;transition:all .2s;
}
.exit-btn:hover,.exit-btn:active{border-color:var(--red);color:var(--red);}
/* ── GAME CANVAS ── */
#game-screen{padding:0;position:relative;background:var(--bg);}
#game-canvas{display:block;width:100%;height:100vh;}
/* ── HUD + BANNER — stacked, no overlap ── */
#game-overlay{
position:absolute;top:0;left:0;right:0;
pointer-events:none;
display:flex;flex-direction:column;
padding:8px 12px 0;
gap:6px;
}
#score-hud{
display:flex;justify-content:space-between;align-items:center;
}
#score-hud .hud-score{font-family:var(--pixel);font-size:.5rem;color:var(--accent);}
#score-hud .hud-timer{font-family:var(--pixel);font-size:.75rem;color:var(--amber);}
#score-hud .hud-q{font-family:var(--pixel);font-size:.4rem;color:var(--muted);}
#question-banner{
font-family:'DM Sans',sans-serif;font-size:.88rem;font-weight:500;
color:var(--text);line-height:1.5;text-align:center;
background:rgba(0,5,16,.9);border:1px solid var(--border);
border-radius:6px;padding:.55rem .9rem;
/* clamp so it never grows past ~25% of viewport */
max-height:28vh;overflow:hidden;
}
/* ── RESULT FLASH ── */
#result-flash{
position:absolute;inset:0;display:none;
flex-direction:column;align-items:center;justify-content:center;
background:rgba(0,5,16,.92);z-index:50;
}
#result-flash.active{display:flex;}
#flash-icon{font-size:4rem;margin-bottom:.5rem;}
#flash-title{font-family:var(--pixel);font-size:.9rem;margin-bottom:.4rem;}
#flash-pts{font-family:var(--pixel);font-size:1.4rem;color:var(--accent);}
#flash-total{font-family:var(--pixel);font-size:.45rem;color:var(--muted);margin-top:.3rem;}
/* ── RESULTS / FUNFACT ── */
#results-screen{background:var(--bg);}
#results-screen .r-label{font-family:var(--pixel);font-size:.45rem;color:var(--green);letter-spacing:.1em;margin-bottom:1rem;}
#results-screen .r-fact{
background:var(--surface2);border:1px solid var(--border);border-left:3px solid var(--accent);
border-radius:0 8px 8px 0;padding:1rem 1.2rem;
font-size:.88rem;line-height:1.65;text-align:left;max-width:360px;margin:0 auto 1rem;
}
#results-screen .r-score{font-family:var(--pixel);font-size:.65rem;color:var(--accent);margin-bottom:.5rem;}
#results-screen .r-wait{font-family:var(--pixel);font-size:.38rem;color:var(--muted);animation:blink 1s step-end infinite;}
/* ── PODIUM ── */
#podium-screen{background:var(--bg);height:auto;min-height:100vh;overflow-y:auto;padding:2rem 1.5rem;justify-content:center;}
#podium-screen h2{font-family:var(--pixel);font-size:.85rem;color:var(--amber);margin-bottom:.3rem;text-shadow:0 0 20px var(--amber);}
#podium-screen .sub{font-size:.75rem;color:var(--muted);margin-bottom:1.5rem;}
.podium-list{width:100%;max-width:360px;}
.podium-item{
display:flex;align-items:center;gap:.8rem;
background:var(--surface2);border:1px solid var(--border);
border-radius:4px;padding:.7rem 1rem;margin-bottom:.5rem;
}
.podium-item.r1{border-color:var(--amber);background:rgba(255,204,0,.06);}
.podium-item.r2{border-color:rgba(200,200,200,.4);}
.podium-item.r3{border-color:rgba(180,120,60,.4);}
.p-rank{font-family:var(--pixel);font-size:.7rem;min-width:28px;}
.p-name{flex:1;font-size:.85rem;}
.p-score{font-family:var(--pixel);font-size:.6rem;color:var(--accent);}
@keyframes float{0%,100%{transform:translateY(0)}50%{transform:translateY(-8px)}}
@keyframes blink{0%,100%{opacity:1}50%{opacity:0}}
@keyframes popIn{from{transform:scale(.7);opacity:0}to{transform:scale(1);opacity:1}}
.pop{animation:popIn .3s cubic-bezier(.34,1.56,.64,1) both;}
</style>
</head>
<body>
<!-- STARS BG -->
<canvas class="pixel-stars" id="stars-canvas"></canvas>
<!-- LANGUAGE SELECT -->
<div class="screen active" id="lang-screen">
<div class="title">🌐 NI QUIZ</div>
<div class="subtitle">NETWORK INTELLIGENCE · LCPR</div>
<button class="lang-btn en" onclick="setLang('en')">🇺🇸 &nbsp; ENGLISH</button>
<button class="lang-btn es" onclick="setLang('es')">🇵🇷 &nbsp; ESPAÑOL</button>
</div>
<!-- JOIN -->
<div class="screen" id="join-screen">
<div class="logo">🚀 NI QUIZ</div>
<div class="sub" id="join-sub">Network Intelligence · LCPR</div>
<div class="field-wrap">
<label id="lbl-fname">FIRST NAME</label>
<input id="inp-fname" type="text" maxlength="20" autocomplete="off" autocorrect="off" autocapitalize="words">
</div>
<div class="field-wrap">
<label id="lbl-lname">LAST NAME</label>
<input id="inp-lname" type="text" maxlength="20" autocomplete="off" autocorrect="off" autocapitalize="words">
</div>
<div class="field-wrap">
<label id="lbl-dept">DEPARTMENT</label>
<select id="inp-dept">
<option value="">-- Select --</option>
<option>Network Intelligence</option>
<option>NOC</option>
<option>Network Engineering</option>
<option>Field Operations</option>
<option>IT</option>
<option>Planning</option>
<option>Other / Otro</option>
</select>
</div>
<button id="join-btn">▶ ENTER GAME</button>
</div>
<!-- LOBBY -->
<div class="screen" id="lobby-screen">
<div class="big">🛸</div>
<div class="pname" id="lobby-name"></div>
<div style="font-family:var(--pixel);font-size:.4rem;color:var(--muted);margin-bottom:.5rem;" id="lobby-dept"></div>
<div class="wait" id="lobby-wait">WAITING FOR HOST...</div>
<button class="exit-btn" id="exit-btn" onclick="exitGame()">✕ salir</button>
</div>
<!-- GAME -->
<div class="screen" id="game-screen">
<canvas id="game-canvas"></canvas>
<div id="game-overlay">
<div id="score-hud">
<span class="hud-score" id="hud-score">000000</span>
<span class="hud-timer" id="hud-timer">15</span>
<span class="hud-q" id="hud-q">Q1/5</span>
</div>
<div id="question-banner"></div>
</div>
<div id="result-flash">
<div id="flash-icon"></div>
<div id="flash-title"></div>
<div id="flash-pts"></div>
<div id="flash-total"></div>
</div>
</div>
<!-- RESULTS -->
<div class="screen" id="results-screen">
<div class="r-label" id="r-label">✓ CORRECT ANSWER</div>
<div class="r-fact" id="r-fact"></div>
<div class="r-score" id="r-score"></div>
<div class="r-wait" id="r-wait">WAITING FOR HOST...</div>
</div>
<!-- PODIUM -->
<div class="screen" id="podium-screen">
<h2 id="pod-title">🏆 GAME OVER</h2>
<p class="sub" id="pod-sub">Network Intelligence · LCPR</p>
<div class="podium-list" id="podium-list"></div>
<button class="exit-btn" onclick="exitGame()">✕ salir</button>
</div>
<script>
// ── STARS ────────────────────────────────────────────────────────────────────
(function() {
const c = document.getElementById('stars-canvas');
const ctx = c.getContext('2d');
let stars = [];
function resize() {
c.width = window.innerWidth; c.height = window.innerHeight;
stars = Array.from({length:80}, () => ({
x: Math.random()*c.width, y: Math.random()*c.height,
s: Math.random()*1.5+.5, b: Math.random()
}));
}
function draw() {
ctx.clearRect(0,0,c.width,c.height);
stars.forEach(s => {
s.b += .005; if(s.b>1) s.b=0;
ctx.fillStyle = `rgba(255,255,255,${Math.abs(Math.sin(s.b*Math.PI))*.6})`;
ctx.fillRect(s.x, s.y, s.s, s.s);
});
requestAnimationFrame(draw);
}
window.addEventListener('resize', resize);
resize(); draw();
})();
// ── I18N ─────────────────────────────────────────────────────────────────────
const T = {
en: {
fname:'FIRST NAME', lname:'LAST NAME', dept:'DEPARTMENT',
enter:'▶ ENTER GAME', waiting:'WAITING FOR HOST...',
correct:'✓ CORRECT!', wrong:'✗ WRONG!', pts:'+{n} PTS', zero:'+0 PTS',
total:'TOTAL: {n} PTS', waithost:'WAITING FOR HOST...',
correct_ans:'✓ CORRECT ANSWER', your_score:'YOUR SCORE: {n} PTS',
gameover:'🏆 GAME OVER', dept_select:'-- Select --',
q_prefix:'Q', of:'/'
},
es: {
fname:'NOMBRE', lname:'APELLIDO', dept:'DEPARTAMENTO',
enter:'▶ ENTRAR AL JUEGO', waiting:'ESPERANDO AL HOST...',
correct:'✓ ¡CORRECTO!', wrong:'✗ ¡INCORRECTO!', pts:'+{n} PTS', zero:'+0 PTS',
total:'TOTAL: {n} PTS', waithost:'ESPERANDO AL HOST...',
correct_ans:'✓ RESPUESTA CORRECTA', your_score:'TU PUNTAJE: {n} PTS',
gameover:'🏆 ¡JUEGO TERMINADO!', dept_select:'-- Selecciona --',
q_prefix:'P', of:'/'
}
};
let lang = 'es';
function t(key, vars={}) {
let s = T[lang][key] || key;
Object.entries(vars).forEach(([k,v]) => s = s.replace(`{${k}}`, v));
return s;
}
function setLang(l) {
lang = l;
// Update join labels
document.getElementById('lbl-fname').textContent = t('fname');
document.getElementById('lbl-lname').textContent = t('lname');
document.getElementById('lbl-dept').textContent = t('dept');
document.getElementById('join-btn').textContent = t('enter');
document.getElementById('inp-dept').options[0].text = t('dept_select');
show('join-screen');
}
// ── SCREENS ──────────────────────────────────────────────────────────────────
function show(id) {
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
document.getElementById(id).classList.add('active');
}
// ── WS ───────────────────────────────────────────────────────────────────────
const WS_ID = crypto.randomUUID();
let ws, myName = '', myScore = 0, myDept = '';
function connectWS() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
ws = new WebSocket(`${proto}://${location.host}/ws/player/${WS_ID}`);
ws.onmessage = e => handleMessage(JSON.parse(e.data));
ws.onclose = () => setTimeout(connectWS, 2000);
}
function handleMessage(msg) {
if (msg.type === 'joined') {
myName = msg.name;
document.getElementById('lobby-name').textContent = msg.display;
document.getElementById('lobby-dept').textContent = msg.dept;
document.getElementById('lobby-wait').textContent = t('waiting');
show('lobby-screen');
}
else if (msg.type === 'phase') {
if (msg.phase === 'lobby' && myName) show('lobby-screen');
}
else if (msg.type === 'question') {
startGame(msg);
}
else if (msg.type === 'answer_result') {
showFlash(msg);
}
else if (msg.type === 'results') {
stopGame();
myScore = msg.leaderboard.find(p => p.name === myName)?.score ?? myScore;
document.getElementById('r-label').textContent = t('correct_ans');
document.getElementById('r-fact').textContent = msg.fun_fact;
document.getElementById('r-score').textContent = t('your_score', {n: myScore.toLocaleString()});
document.getElementById('r-wait').textContent = t('waithost');
show('results-screen');
}
else if (msg.type === 'podium') {
stopGame();
showPodium(msg.leaderboard);
}
}
// ── JOIN ─────────────────────────────────────────────────────────────────────
document.getElementById('join-btn').addEventListener('click', () => {
const fn = document.getElementById('inp-fname').value.trim();
const ln = document.getElementById('inp-lname').value.trim();
const dept = document.getElementById('inp-dept').value;
if (!fn || !ln || !dept) {
// Shake empty fields
[fn ? null : 'inp-fname', ln ? null : 'inp-lname', dept ? null : 'inp-dept']
.filter(Boolean).forEach(id => {
const el = document.getElementById(id);
el.style.borderColor = 'var(--red)';
setTimeout(() => el.style.borderColor = '', 1000);
});
return;
}
myDept = dept;
const displayName = `${fn} ${ln}`;
ws.send(JSON.stringify({type: 'join', name: `${fn} ${ln}`, dept, display: displayName}));
});
// ── GAME ENGINE ──────────────────────────────────────────────────────────────
const canvas = document.getElementById('game-canvas');
const ctx = canvas.getContext('2d');
let gameLoop = null;
let gameState = {
ship: {x: 0, targetX: 0, y: 0, w: 36, h: 28},
bullets: [],
invaders: [], // {x, y, w, h, text, idx, hit, hitTimer, correct}
particles: [],
answered: false,
correctIdx: -1,
timeLeft: 15,
timerStart: 0,
};
function resizeCanvas() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
window.addEventListener('resize', resizeCanvas);
resizeCanvas();
const COL_COLORS = ['#00c8ff','#cc44ff','#00ff88','#ffcc00'];
function startGame(msg) {
resizeCanvas();
const W = canvas.width, H = canvas.height;
const colW = W / 4;
// Invader block sizing — responsive to screen width
// On 375px wide: colW=93, invW≈77, invH≈90 (enough for 4-5 lines at 13px)
const invW = colW - 12;
const invPadX = 20; // space for letter badge on left
const textAreaW = invW - invPadX - 10;
const fontSize = Math.max(12, Math.min(14, W * 0.034)); // 1214px
const lineH = fontSize * 1.4;
// Pre-measure how many lines each option needs, then set uniform height
const measCtx = canvas.getContext('2d');
measCtx.font = `${fontSize}px 'DM Sans', sans-serif`;
function countLines(text, maxW) {
const words = text.split(' ');
let line = '', count = 1;
words.forEach(w => {
const test = line ? line + ' ' + w : w;
if (measCtx.measureText(test).width > maxW && line) { count++; line = w; }
else line = test;
});
return count;
}
const maxLines = Math.max(...msg.options.map(o => countLines(o, textAreaW)));
const badgeH = 20; // letter badge row
const invH = badgeH + maxLines * lineH + 20; // 20px vertical padding
gameState = {
ship: {x: W/2, targetX: W/2, y: H - 120, w: 36, h: 28},
bullets: [],
invaders: msg.options.map((text, idx) => ({
x: colW * idx + colW/2,
y: 120,
targetY: 120,
w: invW,
h: invH,
text,
idx,
hit: false,
hitTimer: 0,
correct: idx === msg.correct_idx_hint,
fontSize,
lineH,
textAreaW,
})),
particles: [],
answered: false,
correctIdx: msg.correct_idx_hint ?? -1,
timeLeft: msg.time_limit,
timerStart: performance.now(),
options: msg.options,
correctAnswer: msg.correct_idx_hint,
W, H, colW,
totalTime: msg.time_limit,
qNum: msg.number,
qTotal: msg.total,
};
document.getElementById('question-banner').textContent = msg.q;
document.getElementById('hud-q').textContent = `${t('q_prefix')}${msg.number}${t('of')}${msg.total}`;
document.getElementById('result-flash').classList.remove('active');
// Measure overlay height so invaders don't spawn behind it
const overlay = document.getElementById('game-overlay');
const overlayH = overlay ? overlay.getBoundingClientRect().height : 100;
const spawnY = overlayH + invH / 2 + 20; // first visible position just below overlay
// Invaders start high and slowly descend
gameState.invaders.forEach((inv, i) => {
inv.y = -invH - i * 25;
inv.targetY = spawnY;
});
show('game-screen');
if (gameLoop) cancelAnimationFrame(gameLoop);
gameLoop = requestAnimationFrame(gameFrame);
// Touch controls — tap column
canvas.ontouchstart = (e) => {
e.preventDefault();
if (gameState.answered) return;
const touch = e.touches[0];
const col = Math.floor(touch.clientX / gameState.colW);
moveAndShoot(col);
};
canvas.onmousedown = (e) => {
if (gameState.answered) return;
const col = Math.floor(e.clientX / gameState.colW);
moveAndShoot(col);
};
}
function moveAndShoot(col) {
const gs = gameState;
if (gs.answered) return;
const targetX = gs.colW * col + gs.colW / 2;
gs.ship.targetX = targetX;
// Fire bullet from current position toward that column
gs.bullets.push({
x: gs.ship.x, y: gs.ship.y - 20,
targetCol: col,
vx: (targetX - gs.ship.x) * 0.04,
vy: -18, w: 4, h: 16,
col
});
playBeep(880, 0.08, 0.05);
}
function stopGame() {
if (gameLoop) { cancelAnimationFrame(gameLoop); gameLoop = null; }
canvas.ontouchstart = null;
canvas.onmousedown = null;
}
function gameFrame(ts) {
const gs = gameState;
const W = gs.W, H = gs.H;
ctx.clearRect(0, 0, W, H);
// ── BACKGROUND ──
ctx.fillStyle = '#000510';
ctx.fillRect(0, 0, W, H);
// Column dividers
for (let i = 1; i < 4; i++) {
ctx.strokeStyle = 'rgba(255,255,255,0.04)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(gs.colW * i, 0);
ctx.lineTo(gs.colW * i, H);
ctx.stroke();
}
// Timer update
const elapsed = (performance.now() - gs.timerStart) / 1000;
const timeLeft = Math.max(0, gs.totalTime - elapsed);
document.getElementById('hud-timer').textContent = Math.ceil(timeLeft);
document.getElementById('hud-timer').style.color = timeLeft < 5 ? 'var(--red)' : 'var(--amber)';
// ── INVADERS ──
gs.invaders.forEach((inv, i) => {
if (inv.hit) {
inv.hitTimer--;
return;
}
// Slowly descend after appearing
if (!gs.answered) {
inv.y += (inv.targetY - inv.y) * 0.08;
inv.targetY += 0.25; // drift down slowly
}
const color = COL_COLORS[i];
// Box
ctx.fillStyle = `rgba(${hexToRgb(color)},0.10)`;
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
roundRect(ctx, inv.x - inv.w/2, inv.y - inv.h/2, inv.w, inv.h, 8);
ctx.fill(); ctx.stroke();
// Letter badge — top-left, full row
const letters = ['A','B','C','D'];
const badgeSize = 13;
ctx.fillStyle = color;
ctx.font = `bold ${badgeSize}px 'Press Start 2P', monospace`;
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
ctx.fillText(letters[i], inv.x - inv.w/2 + 8, inv.y - inv.h/2 + 7);
// Answer text — wrap inside block, below badge row
const fz = inv.fontSize || 12;
const lh = inv.lineH || 17;
const maxW = inv.textAreaW || (inv.w - 28);
ctx.fillStyle = '#e8edf5';
ctx.font = `${fz}px 'DM Sans', sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// Text block starts after badge (20px) + padding (4px)
const textBlockTop = inv.y - inv.h/2 + 24;
const textBlockH = inv.h - 28;
wrapText(ctx, inv.text, inv.x, textBlockTop + textBlockH/2, maxW, lh);
});
// ── SHIP ──
gs.ship.x += (gs.ship.targetX - gs.ship.x) * 0.18;
const sx = gs.ship.x, sy = gs.ship.y;
// Ship body
ctx.fillStyle = '#00c8ff';
ctx.beginPath();
ctx.moveTo(sx, sy - 16);
ctx.lineTo(sx - 14, sy + 12);
ctx.lineTo(sx + 14, sy + 12);
ctx.closePath();
ctx.fill();
// Engine glow
ctx.fillStyle = `rgba(0,200,255,${0.3 + Math.sin(ts*0.01)*0.2})`;
ctx.beginPath();
ctx.arc(sx, sy + 14, 8, 0, Math.PI*2);
ctx.fill();
// Cockpit
ctx.fillStyle = '#fff';
ctx.beginPath();
ctx.arc(sx, sy - 2, 4, 0, Math.PI*2);
ctx.fill();
// ── BULLETS ──
gs.bullets = gs.bullets.filter(b => b.y > -20);
gs.bullets.forEach(b => {
b.x += b.vx;
b.y += b.vy;
ctx.fillStyle = '#ffcc00';
ctx.shadowColor = '#ffcc00';
ctx.shadowBlur = 8;
ctx.fillRect(b.x - 2, b.y, 4, 16);
ctx.shadowBlur = 0;
// Collision check
if (!gs.answered) {
gs.invaders.forEach((inv, i) => {
if (inv.hit) return;
if (b.x > inv.x - inv.w/2 && b.x < inv.x + inv.w/2 &&
b.y > inv.y - inv.h/2 && b.y < inv.y + inv.h/2) {
b.y = -999; // destroy bullet
if (!gs.answered) {
gs.answered = true;
ws.send(JSON.stringify({type: 'answer', choice: i}));
spawnParticles(inv.x, inv.y, COL_COLORS[i], 20);
inv.hit = true;
playBeep(i === gs.correctAnswer ? 1046 : 220, 0.3, 0.3);
}
}
});
}
});
// ── PARTICLES ──
gs.particles = gs.particles.filter(p => p.life > 0);
gs.particles.forEach(p => {
p.x += p.vx; p.y += p.vy; p.vy += 0.3; p.life--;
const a = p.life / p.maxLife;
ctx.fillStyle = p.color + Math.floor(a*255).toString(16).padStart(2,'0');
ctx.fillRect(p.x, p.y, p.s, p.s);
});
// ── GROUND LINE ──
ctx.strokeStyle = 'rgba(0,200,255,0.2)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, H - 90);
ctx.lineTo(W, H - 90);
ctx.stroke();
gameLoop = requestAnimationFrame(gameFrame);
}
function spawnParticles(x, y, color, count) {
for (let i = 0; i < count; i++) {
gameState.particles.push({
x, y,
vx: (Math.random()-0.5)*8,
vy: (Math.random()-0.5)*8,
s: Math.random()*4+2,
life: 30+Math.random()*20,
maxLife: 50,
color
});
}
}
function showFlash(msg) {
stopGame();
const flash = document.getElementById('result-flash');
document.getElementById('flash-icon').textContent = msg.correct ? '🎯' : '💥';
document.getElementById('flash-title').textContent = msg.correct ? t('correct') : t('wrong');
document.getElementById('flash-pts').textContent = msg.correct ? t('pts',{n:msg.points}) : t('zero');
document.getElementById('flash-total').textContent = t('total',{n:msg.total.toLocaleString()});
flash.classList.add('active');
flash.classList.add('pop');
show('game-screen');
}
function showPodium(leaderboard) {
const medals = ['🥇','🥈','🥉'];
const rc = ['r1','r2','r3'];
document.getElementById('pod-title').textContent = t('gameover');
const list = document.getElementById('podium-list');
list.innerHTML = '';
leaderboard.slice(0,10).forEach((p,i) => {
const div = document.createElement('div');
div.className = `podium-item ${rc[i]||''}`;
const isMe = p.name === myName;
div.innerHTML = `
<span class="p-rank">${i<3?medals[i]:i+1}</span>
<span class="p-name">${p.name}${isMe?' 👈':''}</span>
<span class="p-score">${p.score.toLocaleString()}</span>`;
list.appendChild(div);
});
show('podium-screen');
}
// ── AUDIO ────────────────────────────────────────────────────────────────────
let audioCtx;
function playBeep(freq, vol, dur) {
try {
if (!audioCtx) audioCtx = new (window.AudioContext||window.webkitAudioContext)();
const o = audioCtx.createOscillator();
const g = audioCtx.createGain();
o.connect(g); g.connect(audioCtx.destination);
o.type = 'square';
o.frequency.value = freq;
g.gain.setValueAtTime(vol, audioCtx.currentTime);
g.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + dur);
o.start(); o.stop(audioCtx.currentTime + dur);
} catch(e) {}
}
// ── UTILS ────────────────────────────────────────────────────────────────────
function wrapText(ctx, text, x, centerY, maxW, lineH) {
const words = text.split(' ');
let line = '';
const lines = [];
words.forEach(w => {
const test = line ? line + ' ' + w : w;
if (ctx.measureText(test).width > maxW && line) {
lines.push(line); line = w;
} else line = test;
});
lines.push(line);
const totalH = lines.length * lineH;
const startY = centerY - totalH / 2 + lineH / 2;
lines.forEach((l, i) => ctx.fillText(l, x, startY + i * lineH));
}
function roundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x+r, y);
ctx.lineTo(x+w-r, y);
ctx.quadraticCurveTo(x+w, y, x+w, y+r);
ctx.lineTo(x+w, y+h-r);
ctx.quadraticCurveTo(x+w, y+h, x+w-r, y+h);
ctx.lineTo(x+r, y+h);
ctx.quadraticCurveTo(x, y+h, x, y+h-r);
ctx.lineTo(x, y+r);
ctx.quadraticCurveTo(x, y, x+r, y);
ctx.closePath();
}
function hexToRgb(hex) {
const r = parseInt(hex.slice(1,3),16);
const g = parseInt(hex.slice(3,5),16);
const b = parseInt(hex.slice(5,7),16);
return `${r},${g},${b}`;
}
connectWS();
function exitGame() {
if (ws) { ws.onclose = null; ws.close(); }
myName = ''; myScore = 0; myDept = '';
show('lang-screen');
connectWS();
}
</script>
</body>
</html>
+325
View File
@@ -0,0 +1,325 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NI Quiz · Historial de Scores</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Syne:wght@700;800&family=DM+Sans:wght@400;500&display=swap');
*{margin:0;padding:0;box-sizing:border-box;}
:root{
--bg:#0a0e1a;--surface:#111827;--surface2:#1a2235;
--accent:#00c8ff;--green:#10b981;--amber:#f59e0b;--purple:#7c3aed;
--text:#e8edf5;--muted:#6b7a99;--border:rgba(255,255,255,0.08);
}
html,body{min-height:100vh;background:var(--bg);color:var(--text);font-family:'DM Sans',sans-serif;}
.grid-bg{position:fixed;inset:0;background-image:linear-gradient(rgba(0,200,255,0.03) 1px,transparent 1px),linear-gradient(90deg,rgba(0,200,255,0.03) 1px,transparent 1px);background-size:40px 40px;pointer-events:none;}
header{padding:2rem 2rem 1rem;border-bottom:1px solid var(--border);position:relative;z-index:1;}
header h1{font-family:'Syne',sans-serif;font-size:1.8rem;color:var(--accent);}
header p{font-size:.85rem;color:var(--muted);margin-top:.2rem;}
.nav-links{margin-top:.8rem;display:flex;gap:.8rem;}
.nav-links a{font-size:.8rem;color:var(--muted);text-decoration:none;padding:.25rem .7rem;border:1px solid var(--border);border-radius:6px;}
.nav-links a:hover{color:var(--text);}
main{padding:2rem;max-width:900px;margin:0 auto;position:relative;z-index:1;}
#loading{text-align:center;color:var(--muted);padding:3rem;font-size:.9rem;}
#empty{display:none;text-align:center;color:var(--muted);padding:3rem;}
#empty .icon{font-size:3rem;margin-bottom:1rem;}
/* ALL-TIME TOP 5 */
.all-time{margin-bottom:2.5rem;}
.section-title{font-family:'Syne',sans-serif;font-size:1rem;margin-bottom:1rem;display:flex;align-items:center;gap:.6rem;}
.section-title span{font-size:.7rem;letter-spacing:.12em;text-transform:uppercase;color:var(--muted);}
.top-row{display:flex;gap:.75rem;flex-wrap:wrap;}
.top-card{
background:var(--surface);border:1px solid var(--border);border-radius:12px;
padding:.9rem 1.1rem;display:flex;align-items:center;gap:.8rem;
min-width:160px;flex:1;
}
.top-card.gold{border-color:rgba(245,158,11,.5);background:rgba(245,158,11,.05);}
.top-card.silver{border-color:rgba(200,200,200,.3);}
.top-card.bronze{border-color:rgba(180,120,60,.3);}
.top-medal{font-size:1.4rem;}
.top-info{flex:1;}
.top-name{font-weight:500;font-size:.9rem;}
.top-score{font-family:'Syne',sans-serif;font-size:1.1rem;color:var(--accent);font-weight:700;}
.top-sessions{font-size:.7rem;color:var(--muted);}
/* SESSIONS */
.session-card{
background:var(--surface);border:1px solid var(--border);border-radius:14px;
margin-bottom:1rem;overflow:hidden;
}
.session-header{
padding:1rem 1.2rem;display:flex;align-items:center;justify-content:space-between;
cursor:pointer;
}
.session-header:hover{background:var(--surface2);}
.session-meta{display:flex;align-items:center;gap:1rem;}
.session-id{font-size:.7rem;letter-spacing:.1em;color:var(--muted);font-family:'Syne',sans-serif;}
.session-date{font-size:.85rem;font-weight:500;}
.session-badge{
padding:.2rem .6rem;border-radius:100px;font-size:.7rem;
background:rgba(0,200,255,.08);border:1px solid rgba(0,200,255,.2);color:var(--accent);
}
.chevron{color:var(--muted);transition:transform .2s;font-size:1rem;}
.session-body{display:none;border-top:1px solid var(--border);}
.session-body.open{display:block;}
.scores-table{width:100%;border-collapse:collapse;}
.scores-table th{
text-align:left;font-size:.7rem;letter-spacing:.12em;text-transform:uppercase;
color:var(--muted);padding:.7rem 1.2rem;border-bottom:1px solid var(--border);
}
.scores-table td{padding:.65rem 1.2rem;font-size:.85rem;border-bottom:1px solid rgba(255,255,255,.03);}
.scores-table tr:last-child td{border:none;}
.rank-cell{font-family:'Syne',sans-serif;font-weight:700;color:var(--muted);}
.score-cell{font-family:'Syne',sans-serif;color:var(--accent);font-weight:700;}
.medal-cell{font-size:1.1rem;}
/* TABS */
.tabs{display:flex;gap:.5rem;margin-bottom:1.5rem;flex-wrap:wrap;}
.tab-btn{
padding:.4rem 1rem;border-radius:100px;font-size:.78rem;font-weight:500;
border:1px solid var(--border);background:transparent;color:var(--muted);
cursor:pointer;transition:all .15s;
}
.tab-btn.active{background:var(--accent);border-color:var(--accent);color:#000;font-weight:700;}
.tab-btn:hover:not(.active){border-color:var(--accent);color:var(--accent);}
.tab-panel{display:none;}.tab-panel.active{display:block;}
/* DEPT SECTION */
.dept-block{margin-bottom:1.5rem;}
.dept-header{
display:flex;align-items:center;gap:.7rem;
padding:.6rem .9rem;border-radius:10px 10px 0 0;
background:var(--surface2);border:1px solid var(--border);border-bottom:none;
}
.dept-dot{width:10px;height:10px;border-radius:50%;}
.dept-name{font-family:'Syne',sans-serif;font-size:.95rem;font-weight:700;}
.dept-count{font-size:.75rem;color:var(--muted);}
.dept-table{width:100%;border-collapse:collapse;background:var(--surface);border:1px solid var(--border);border-radius:0 0 10px 10px;overflow:hidden;}
.dept-table td{padding:.6rem 1rem;font-size:.85rem;border-bottom:1px solid rgba(255,255,255,.03);}
.dept-table tr:last-child td{border:none;}
.dept-rank{font-family:'Syne',sans-serif;font-weight:700;color:var(--muted);width:36px;}
.dept-score{font-family:'Syne',sans-serif;color:var(--accent);font-weight:700;text-align:right;}
</style>
</head>
<body>
<div class="grid-bg"></div>
<header>
<h1>🌐 NI Quiz · Historial</h1>
<p>Network Intelligence · LCPR — Scores guardados por sesión</p>
<div class="nav-links">
<a href="/host">← Host Panel</a>
<a href="/">Jugar</a>
</div>
</header>
<main>
<div id="loading">Cargando historial...</div>
<div id="empty"><div class="icon">📭</div><p>Aún no hay sesiones guardadas.<br>Los scores se guardan al terminar el juego.</p></div>
<div id="content" style="display:none;">
<div class="all-time">
<div class="section-title">🏆 <span>Top jugadores — todas las sesiones</span></div>
<div class="top-row" id="top-row"></div>
</div>
<!-- TABS -->
<div class="tabs">
<button class="tab-btn active" onclick="switchTab('global')">🌐 Global</button>
<button class="tab-btn" onclick="switchTab('dept')">🏢 Por departamento</button>
<button class="tab-btn" onclick="switchTab('sessions')">📋 Sesiones</button>
</div>
<!-- GLOBAL TAB -->
<div class="tab-panel active" id="tab-global">
<div class="section-title">🥇 <span>Ranking global — mejor score histórico</span></div>
<div id="global-list"></div>
</div>
<!-- DEPT TAB -->
<div class="tab-panel" id="tab-dept">
<div class="section-title">🏢 <span>Ranking por departamento</span></div>
<div id="dept-list"></div>
</div>
<!-- SESSIONS TAB -->
<div class="tab-panel" id="tab-sessions">
<div class="section-title">📋 <span>Sesiones recientes</span></div>
<div id="sessions-list"></div>
</div>
</div>
</main>
<script>
const medals = ['🥇','🥈','🥉'];
const rankClasses = ['gold','silver','bronze'];
const DEPT_COLORS = [
'#00c8ff','#cc44ff','#00ff88','#ffcc00','#ff6b35','#5b9cf6','#f472b6','#34d399'
];
function switchTab(name) {
document.querySelectorAll('.tab-btn').forEach((b,i) => {
b.classList.toggle('active', ['global','dept','sessions'][i] === name);
});
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
document.getElementById('tab-' + name).classList.add('active');
}
async function load() {
const res = await fetch('/api/scores');
const data = await res.json();
document.getElementById('loading').style.display = 'none';
if (!data.length) {
document.getElementById('empty').style.display = 'block';
return;
}
document.getElementById('content').style.display = 'block';
// ── BUILD PLAYER MAP ──
// allScores[name] = { best, sessions, dept }
const allScores = {};
data.forEach(s => {
s.players.forEach(p => {
const key = p.player;
if (!allScores[key]) {
allScores[key] = { best: p.score, sessions: 1, dept: p.dept || 'Sin departamento' };
} else {
if (p.score > allScores[key].best) allScores[key].best = p.score;
allScores[key].sessions++;
if (p.dept) allScores[key].dept = p.dept; // keep most recent dept
}
});
});
const sorted = Object.entries(allScores).sort((a,b) => b[1].best - a[1].best);
// ── TOP 5 CARDS ──
const topRow = document.getElementById('top-row');
sorted.slice(0, 5).forEach(([name, info], i) => {
const card = document.createElement('div');
card.className = `top-card ${rankClasses[i] || ''}`;
card.innerHTML = `
<div class="top-medal">${i < 3 ? medals[i] : `#${i+1}`}</div>
<div class="top-info">
<div class="top-name">${name}</div>
<div class="top-score">${info.best.toLocaleString()} pts</div>
<div class="top-sessions">${info.dept || '—'} · ${info.sessions} sesión${info.sessions > 1 ? 'es' : ''}</div>
</div>`;
topRow.appendChild(card);
});
// ── GLOBAL RANKING TABLE ──
const globalList = document.getElementById('global-list');
const globalTable = document.createElement('table');
globalTable.className = 'scores-table';
globalTable.style.cssText = 'background:var(--surface);border:1px solid var(--border);border-radius:10px;overflow:hidden;';
globalTable.innerHTML = `
<thead><tr>
<th></th><th>Jugador</th><th>Departamento</th><th>Mejor score</th><th>Sesiones</th>
</tr></thead>
<tbody>${sorted.map(([ name, info], i) => `
<tr>
<td class="medal-cell">${i < 3 ? medals[i] : `<span style="font-family:'Syne',sans-serif;color:var(--muted)">#${i+1}</span>`}</td>
<td style="font-weight:500">${name}</td>
<td style="font-size:.78rem;color:var(--muted)">${info.dept || '—'}</td>
<td class="score-cell">${info.best.toLocaleString()}</td>
<td style="font-size:.78rem;color:var(--muted)">${info.sessions}</td>
</tr>`).join('')}
</tbody>`;
globalList.appendChild(globalTable);
// ── DEPT RANKING ──
// Group players by dept, pick best score per player, sum for dept total
const depts = {};
sorted.forEach(([name, info]) => {
const d = info.dept || 'Sin departamento';
if (!depts[d]) depts[d] = [];
depts[d].push({ name, best: info.best, sessions: info.sessions });
});
// Sort depts by their top player's score
const deptsSorted = Object.entries(depts).sort((a, b) => b[1][0].best - a[1][0].best);
const deptList = document.getElementById('dept-list');
deptsSorted.forEach(([dept, players], di) => {
const color = DEPT_COLORS[di % DEPT_COLORS.length];
const block = document.createElement('div');
block.className = 'dept-block';
block.innerHTML = `
<div class="dept-header">
<div class="dept-dot" style="background:${color}"></div>
<div class="dept-name">${dept}</div>
<div class="dept-count">${players.length} jugador${players.length > 1 ? 'es' : ''}</div>
</div>
<table class="dept-table">
<tbody>${players.map((p, i) => `
<tr>
<td class="dept-rank">${i < 3 ? medals[i] : '#' + (i+1)}</td>
<td style="font-weight:500">${p.name}</td>
<td class="dept-score">${p.best.toLocaleString()} pts</td>
</tr>`).join('')}
</tbody>
</table>`;
deptList.appendChild(block);
});
// ── SESSIONS ──
const list = document.getElementById('sessions-list');
data.forEach(session => {
const date = new Date(session.started_at + 'Z').toLocaleString('es-PR', {
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
});
const winner = session.players[0];
const card = document.createElement('div');
card.className = 'session-card';
card.innerHTML = `
<div class="session-header" onclick="toggle(this)">
<div class="session-meta">
<span class="session-id">SESIÓN #${session.id}</span>
<span class="session-date">${date}</span>
${winner ? `<span class="session-badge">🥇 ${winner.player} · ${winner.score.toLocaleString()} pts</span>` : ''}
</div>
<div style="display:flex;align-items:center;gap:.8rem;">
<span style="font-size:.75rem;color:var(--muted);">${session.total_players} jugadores</span>
<span class="chevron">▼</span>
</div>
</div>
<div class="session-body">
<table class="scores-table">
<thead><tr>
<th></th><th>Jugador</th><th>Departamento</th><th>Puntaje</th>
</tr></thead>
<tbody>${session.players.map((p,i) => `
<tr>
<td class="medal-cell">${i < 3 ? medals[i] : ''}</td>
<td>${p.player}</td>
<td style="font-size:.78rem;color:var(--muted)">${p.dept || '—'}</td>
<td class="score-cell">${p.score.toLocaleString()}</td>
</tr>`).join('')}
</tbody>
</table>
</div>`;
list.appendChild(card);
});
}
function toggle(header) {
const body = header.nextElementSibling;
const chevron = header.querySelector('.chevron');
body.classList.toggle('open');
chevron.style.transform = body.classList.contains('open') ? 'rotate(180deg)' : '';
}
load();
</script>
</body>
</html>