Files
ni-quiz/templates/player.html
T

780 lines
28 KiB
HTML

<!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:#8a9bb5;--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:.85rem;color:var(--text);margin-bottom:2rem;opacity:.75;}
.field-wrap{width:100%;max-width:380px;margin-bottom:1rem;text-align:left;}
.field-wrap label{font-family:var(--pixel);font-size:.55rem;color:var(--accent);letter-spacing:.12em;display:block;margin-bottom:.5rem;}
.field-wrap input, .field-wrap select{
width:100%;padding:1rem 1.1rem;
background:var(--surface2);border:1.5px solid var(--border);border-radius:6px;
color:var(--text);font-size:1.1rem;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:#5a6a80;}
.field-wrap select option{background:var(--surface2);}
#join-btn{
width:100%;max-width:380px;padding:1.1rem;margin-top:.6rem;
background:var(--accent);border:none;border-radius:6px;
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);}
#projector-hint{
font-family:var(--pixel);font-size:.42rem;
color:var(--muted);line-height:1.5;text-align:center;
letter-spacing:.08em;padding:.3rem 0;
animation:blink 2s step-end infinite;
}
/* ── 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>
<!-- SPLASH — goes straight to join -->
<div class="screen active" id="lang-screen">
<div class="title">🌐 NI QUIZ</div>
<div class="subtitle">NETWORK INTELLIGENCE · LCPR</div>
<button class="lang-btn es" onclick="goJoin()">&nbsp; JUGAR / PLAY</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>Finanzas</option>
<option>People</option>
<option>Legal</option>
<option>B2B</option>
<option>B2C</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="projector-hint"></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 goJoin() {
lang = 'es';
document.getElementById('lbl-fname').textContent = 'NOMBRE / NAME';
document.getElementById('lbl-lname').textContent = 'APELLIDO / LAST NAME';
document.getElementById('lbl-dept').textContent = 'DEPARTAMENTO / DEPARTMENT';
document.getElementById('join-btn').textContent = '▶ ENTRAR / JOIN';
document.getElementById('inp-dept').options[0].text = '-- Selecciona / Select --';
show('join-screen');
}
function setLang(l) { goJoin(); }
// ── 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.phase === 'join') {
myName = ''; myScore = 0; myDept = '';
show('join-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') {
stopBGM();
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}`;
startBGM();
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 = ['#5b9cf6','#cc44ff','#00ff88','#ffcc00']; // A=blue B=purple C=green D=yellow
function startGame(msg) {
resizeCanvas();
const W = canvas.width, H = canvas.height;
const colW = W / 4;
// Fixed invader size — no text inside, just letter
const invW = Math.min(colW - 16, 90);
const invH = invW; // square-ish UFO shape
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: 0,
targetY: 0,
w: invW,
h: invH,
idx,
hit: false,
hitTimer: 0,
})),
particles: [],
answered: false,
correctIdx: msg.correct_idx_hint ?? -1,
timeLeft: msg.time_limit,
timerStart: performance.now(),
correctAnswer: msg.correct_idx_hint,
W, H, colW,
totalTime: msg.time_limit,
qNum: msg.number,
qTotal: msg.total,
};
document.getElementById('projector-hint').textContent = lang === 'es' ? '👆 MIRA EL PROYECTOR' : '👆 LOOK AT THE SCREEN';
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;
// Invaders start above screen and descend into position
gameState.invaders.forEach((inv, i) => {
inv.y = -invH - i * 30;
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;
}
const color = COL_COLORS[i];
const letters = ['A','B','C','D'];
const urgent = timeLeft <= 3 && !gs.answered && i === gs.correctIdx;
// Shake offset when urgent
const shakeX = urgent ? Math.sin(ts * 0.045 + i * 1.3) * 4 : 0;
const shakeY = urgent ? Math.cos(ts * 0.05 + i * 0.9) * 3 : 0;
const cx = inv.x + shakeX, cy = inv.y + shakeY;
const r = inv.w / 2;
// Urgent pulse: glow intensity oscillates fast
const pulse = urgent ? 0.5 + 0.5 * Math.sin(ts * 0.025) : 0;
// UFO dome (top half ellipse)
ctx.fillStyle = `rgba(${hexToRgb(color)},${0.18 + pulse * 0.22})`;
ctx.strokeStyle = color;
ctx.lineWidth = urgent ? 2 + pulse * 2 : 2;
ctx.shadowColor = color;
ctx.shadowBlur = urgent ? 10 + pulse * 24 : 0;
ctx.beginPath();
ctx.ellipse(cx, cy, r, r * 0.55, 0, Math.PI, 0);
ctx.fill(); ctx.stroke();
// UFO saucer body (full ellipse)
ctx.fillStyle = `rgba(${hexToRgb(color)},${0.25 + pulse * 0.2})`;
ctx.strokeStyle = color;
ctx.lineWidth = urgent ? 2 + pulse * 2 : 2;
ctx.shadowColor = color;
ctx.shadowBlur = urgent ? 14 + pulse * 20 : 0;
ctx.beginPath();
ctx.ellipse(cx, cy, r, r * 0.32, 0, 0, Math.PI * 2);
ctx.fill(); ctx.stroke();
ctx.shadowBlur = 0;
// Glow under saucer
ctx.shadowColor = color;
ctx.shadowBlur = urgent ? 18 + pulse * 28 : 14;
ctx.beginPath();
ctx.ellipse(cx, cy + r * 0.1, r * 0.6, r * 0.12, 0, 0, Math.PI * 2);
ctx.fillStyle = `rgba(${hexToRgb(color)},${0.35 + pulse * 0.35})`;
ctx.fill();
ctx.shadowBlur = 0;
// Big centered letter
const letterSize = Math.max(18, r * 0.85);
ctx.fillStyle = color;
ctx.font = `bold ${letterSize}px 'Press Start 2P', monospace`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.shadowColor = color;
ctx.shadowBlur = urgent ? 16 + pulse * 20 : 10;
ctx.fillText(letters[i], cx, cy - r * 0.28);
ctx.shadowBlur = 0;
});
// ── 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 — circular hitbox matching UFO saucer radius
if (!gs.answered) {
gs.invaders.forEach((inv, i) => {
if (inv.hit) return;
const r = inv.w / 2;
const dx = b.x - inv.x;
const dy = b.y - inv.y;
if (Math.sqrt(dx*dx + dy*dy) < r) {
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}`;
}
// ── BACKGROUND MUSIC ─────────────────────────────────────────────────────────
const bgm = document.getElementById('bgm');
let bgmStarted = false;
function startBGM() {
if (bgmStarted) return;
bgmStarted = true;
bgm.volume = 0.35;
bgm.play().catch(() => {});
}
function stopBGM() {
bgm.pause();
bgm.currentTime = 0;
bgmStarted = false;
}
connectWS();
function exitGame() {
if (ws) { ws.onclose = null; ws.close(); }
myName = ''; myScore = 0; myDept = '';
show('lang-screen');
connectWS();
}
</script>
<audio id="bgm" src="/static/music.mp3" loop preload="auto"></audio>
</body>
</html>