Initial commit
This commit is contained in:
@@ -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')">🇺🇸 ENGLISH</button>
|
||||
<button class="lang-btn es" onclick="setLang('es')">🇵🇷 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)); // 12–14px
|
||||
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>
|
||||
Reference in New Issue
Block a user