Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
780 lines
28 KiB
HTML
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()">▶ 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> |