Files
ni-quiz/templates/player.html
T
2026-05-12 03:10:52 +00:00

763 lines
28 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.
<!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>