Initial commit

This commit is contained in:
2026-05-12 03:10:52 +00:00
commit b88f47b72d
9 changed files with 2210 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
.env
*.log
__pycache__/
node_modules/
venv/
dist/
build/
+7
View File
@@ -0,0 +1,7 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
+247
View File
@@ -0,0 +1,247 @@
# 🌐 NI Quiz
Multiplayer quiz game — self-hosted, no app install required. Built for the Network Intelligence team's show-and-tell event at LCPR. Players join from their mobile browser, answer questions via a Space Invaders arcade interface, and scores are tracked across sessions.
**Live at:** `https://ni-quiz.carloselugo.com`
---
## Stack
| Layer | Tech |
|---|---|
| Backend | Python · FastAPI · WebSockets |
| Storage | SQLite (Docker volume) |
| Frontend | Vanilla HTML/CSS/JS · Canvas 2D |
| Container | Docker Compose |
| Reverse proxy | Caddy (shared `web_web-net`) |
| Host | labmini-01 · Dell Optiplex · Ubuntu |
---
## Architecture
```
Player (mobile browser)
↓ HTTPS / WSS
Caddy (ni-quiz.carloselugo.com)
↓ reverse_proxy :8000
ni-quiz container (FastAPI)
SQLite (/data/quiz.db) — Docker volume quiz-data
```
All game state lives in memory on the server. SQLite persists session scores across restarts.
---
## File Structure
```
/opt/ni-quiz/
├── main.py # FastAPI app — game logic, WebSockets, SQLite
├── requirements.txt
├── Dockerfile
├── docker-compose.yml
├── templates/
│ ├── player.html # Mobile arcade game (Space Invaders)
│ ├── host.html # Projector/host control panel
│ └── scores.html # Score history — global, by dept, by session
└── static/ # Static assets (empty — all inline)
```
---
## Features
### Player (`/`)
- Language selector — English / Español
- Registration — first name, last name, department
- Space Invaders arcade — 4 lanes, one per answer; ship at bottom fires at the correct invader
- 8-bit sound effects via Web Audio API
- Particle explosions on hit
- Score flash after each answer — points earned + running total
- Podium screen — top 10 with medals at game end
- Exit button — leaves session cleanly from lobby or podium
### Host Panel (`/host`) — projector view
- Password-protected — blocks access before WebSocket connects
- QR code auto-generated with the player join URL
- Live lobby — player count + name chips update in real time
- Live leaderboard during questions — who answered, time taken, correct/wrong
- Full top 10 leaderboard after each question with department
- Game controls — Start, Next Question, Show Results, Reset
- Clear Scores — wipes all SQLite history with confirmation
- Auto-reset — if host disconnects mid-game, server resets automatically after 5 minutes if no host reconnects
### Score History (`/scores`)
- Top 5 cards — all-time best scores across all sessions
- **Global tab** — full player ranking by best historical score, with department and session count
- **By department tab** — each department ranked internally, departments sorted by their top player
- **Sessions tab** — collapsible session list with full rankings and department per player
---
## Scoring System
```
points = 200 + 800 × (time_remaining / time_limit)
```
| Condition | Points |
|---|---|
| Correct, fastest possible | 1000 |
| Correct, just before timeout | 200 |
| Wrong answer | 0 |
| No answer / timeout | 0 |
Time limit: **20 seconds** per question.
---
## SQLite Schema
```sql
CREATE TABLE sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
started_at TEXT NOT NULL,
ended_at TEXT,
total_players INTEGER DEFAULT 0
);
CREATE TABLE scores (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL REFERENCES sessions(id),
player TEXT NOT NULL,
score INTEGER NOT NULL DEFAULT 0,
rank INTEGER,
dept TEXT NOT NULL DEFAULT '',
finished_at TEXT NOT NULL
);
```
Scores are saved when the host advances past the last question. The `dept` column is added automatically via migration on startup if it doesn't exist (safe for existing DBs).
---
## API Endpoints
| Method | Path | Description |
|---|---|---|
| `GET` | `/` | Player game page |
| `GET` | `/host` | Host control panel (password protected) |
| `GET` | `/scores` | Score history page |
| `GET` | `/api/scores` | JSON — all sessions and scores |
| `DELETE` | `/api/scores/clear` | Wipe all sessions and scores |
| `WS` | `/ws/player/{id}` | Player WebSocket |
| `WS` | `/ws/host` | Host WebSocket |
---
## Docker Compose
```yaml
services:
ni-quiz:
build: .
volumes:
- quiz-data:/data
environment:
- DB_PATH=/data/quiz.db
restart: unless-stopped
networks:
- web-net
volumes:
quiz-data:
networks:
web-net:
external: true
name: web_web-net
```
---
## Caddyfile Block
```caddy
ni-quiz.carloselugo.com {
import security_headers
reverse_proxy ni-quiz:8000 {
header_up Host {host}
header_up X-Real-IP {remote_host}
}
log {
output file /var/log/caddy/ni-quiz.log
format json
}
}
```
> No `encode gzip` — gzip breaks WebSocket upgrades.
> No `internal_only` — players need public access from mobile devices.
---
## Deploy
```bash
# First deploy
cd /opt/ni-quiz
docker compose up -d --build
docker network connect web_web-net ni-quiz-ni-quiz-1
# Subsequent deploys (network defined in docker-compose.yml)
docker compose down
docker compose up -d --build
# Restart only (no rebuild — e.g. after editing main.py)
docker compose restart ni-quiz
# Backup DB
docker compose cp ni-quiz:/data/quiz.db ./quiz-backup-$(date +%Y%m%d).db
```
---
## Configuration
### Host password
Edit in `templates/host.html`:
```js
const HOST_PASSWORD = 'ni2026'; // ← change this
```
The password is checked client-side and stored in `sessionStorage` — valid for the browser tab session only.
### Auto-reset timeout
Edit in `main.py` (`ConnectionManager.disconnect_host`):
```python
self._reset_task = asyncio.create_task(self._auto_reset(delay=300)) # seconds
```
### Question time limit
Edit in `main.py` (two places must match):
```python
TIME_LIMIT = 20 # server-side validation
"time_limit": 20, # sent to client for countdown display
```
---
## Known Issues / Notes
- **Network on rebuild** — `docker compose up --build` recreates the container and drops it from `web_web-net`. Fix: define the network in `docker-compose.yml` (already done), or manually reconnect with `docker network connect web_web-net ni-quiz-ni-quiz-1`.
- **No gzip on WS proxies** — Caddy's `encode gzip` breaks WebSocket upgrades. Always omit for WS-heavy services.
- **Host password is client-side** — suitable for access control at an internal event. For stricter security, move auth to the backend with `fastapi.security`.
- **Single game instance** — all game state is a single global `GameState` object. Concurrent games are not supported.
---
## URLs
| | URL |
|---|---|
| Players | https://ni-quiz.carloselugo.com |
| Host / Projector | https://ni-quiz.carloselugo.com/host |
| Score History | https://ni-quiz.carloselugo.com/scores |
+18
View File
@@ -0,0 +1,18 @@
services:
ni-quiz:
build: .
volumes:
- quiz-data:/data
environment:
- DB_PATH=/data/quiz.db
restart: unless-stopped
networks:
- web-net
volumes:
quiz-data:
networks:
web-net:
external: true
name: web_web-net
+397
View File
@@ -0,0 +1,397 @@
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse, JSONResponse
import json, time, random, sqlite3, os, asyncio
from datetime import datetime
DB_PATH = os.environ.get("DB_PATH", "/data/quiz.db")
# ── DATABASE ──────────────────────────────────────────────────────────────────
def get_db():
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def init_db():
with get_db() as conn:
conn.executescript("""
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
started_at TEXT NOT NULL,
ended_at TEXT,
total_players INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS scores (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL REFERENCES sessions(id),
player TEXT NOT NULL,
score INTEGER NOT NULL DEFAULT 0,
rank INTEGER,
dept TEXT NOT NULL DEFAULT '',
finished_at TEXT NOT NULL
);
""")
# Safe migration: add dept column if it doesn't exist yet
try:
conn.execute("ALTER TABLE scores ADD COLUMN dept TEXT NOT NULL DEFAULT ''")
except Exception:
pass # column already exists
init_db()
def save_session(players: list) -> int:
now = datetime.utcnow().isoformat()
with get_db() as conn:
cur = conn.execute(
"INSERT INTO sessions (started_at, ended_at, total_players) VALUES (?,?,?)",
(now, now, len(players))
)
session_id = cur.lastrowid
ranked = sorted(players, key=lambda p: p["score"], reverse=True)
for rank, p in enumerate(ranked, 1):
conn.execute(
"INSERT INTO scores (session_id, player, score, rank, dept, finished_at) VALUES (?,?,?,?,?,?)",
(session_id, p.get("display", p["name"]), p["score"], rank, p.get("dept", ""), now)
)
return session_id
# ── APP ───────────────────────────────────────────────────────────────────────
app = FastAPI()
app.mount("/static", StaticFiles(directory="static"), name="static")
templates = Jinja2Templates(directory="templates")
# ── QUESTIONS ─────────────────────────────────────────────────────────────────
QUESTIONS = [
{
"q": "¿Cuál es el enfoque principal del equipo de Network Intelligence?",
"options": [
"Vender licencias de software a otras empresas",
"Construir herramientas propias que empoderan a los equipos operacionales",
"Administrar el presupuesto de TI de LCPR",
"Instalar equipos físicos en planta externa"
],
"correct": 1,
"fun_fact": "¡Exacto! Construimos para quienes operan la red día a día — no solo dashboards bonitos."
},
{
"q": "¿Qué problema resolvió el equipo al reemplazar Power BI con frontend propio?",
"options": [
"La pantalla se veía más bonita",
"Eliminamos la dependencia de licencias costosas y ganamos control total",
"Fue más fácil de instalar en los servidores",
"Los colores del dashboard coincidían con los de Liberty"
],
"correct": 1,
"fun_fact": "$0 en licencias de visualización. El código es nuestro y lo extendemos como queremos."
},
{
"q": "¿Qué es Pathfinder?",
"options": [
"Una aplicación para rastrear camiones de campo",
"Un sistema de GPS para la red de fibra",
"Una plataforma web que centraliza herramientas operacionales de red en un solo lugar",
"Un reporte mensual de disponibilidad de red"
],
"correct": 2,
"fun_fact": "Pathfinder es el 'panel de control' propio de LCPR — construido exactamente para nuestros flujos de trabajo."
},
{
"q": "¿Qué logró el proyecto de Firmware Upgrade Automation?",
"options": [
"Actualizar manualmente 10 modems por semana",
"Contratar más técnicos para visitas a domicilio",
"Automatizar la actualización masiva de modems sin intervención humana",
"Comprar equipos nuevos para reemplazar los modems viejos"
],
"correct": 2,
"fun_fact": "400+ modems actualizados en batches automáticos. Antes era trabajo manual uno por uno."
},
{
"q": "¿Hacia dónde se dirige el equipo en su visión AIOps?",
"options": [
"Depender más de herramientas comerciales como ServiceNow",
"Detectar y predecir fallas antes de que el cliente las reporte",
"Reducir el equipo de ingeniería a la mitad",
"Migrar toda la red a la nube pública"
],
"correct": 1,
"fun_fact": "La meta: que el técnico salga a campo con el diagnóstico ya hecho — no a diagnosticar."
}
]
# ── GAME STATE ────────────────────────────────────────────────────────────────
class GameState:
def __init__(self):
self.reset()
def reset(self):
self.phase = "lobby"
self.players: dict = {}
self.current_q = -1
self.q_start_time = 0
self.answers_this_round = []
self.question_order = list(range(len(QUESTIONS)))
random.shuffle(self.question_order)
game = GameState()
# ── CONNECTION MANAGER ────────────────────────────────────────────────────────
class ConnectionManager:
def __init__(self):
self.players: dict = {}
self.hosts: list = []
self._reset_task: asyncio.Task = None
async def connect_player(self, ws_id: str, ws: WebSocket):
await ws.accept()
self.players[ws_id] = ws
async def connect_host(self, ws: WebSocket):
await ws.accept()
self.hosts.append(ws)
# Cancel pending auto-reset if host reconnected in time
if self._reset_task and not self._reset_task.done():
self._reset_task.cancel()
self._reset_task = None
def disconnect(self, ws_id: str):
self.players.pop(ws_id, None)
game.players.pop(ws_id, None)
def disconnect_host(self, ws: WebSocket):
if ws in self.hosts:
self.hosts.remove(ws)
# If no hosts remain and game is not in lobby, schedule auto-reset
if not self.hosts and game.phase != "lobby":
self._reset_task = asyncio.create_task(self._auto_reset(delay=300))
async def _auto_reset(self, delay: int):
"""Wait `delay` seconds, then reset the game if no host has reconnected."""
await asyncio.sleep(delay)
if not self.hosts:
game.reset()
await self.broadcast_players({"type": "phase", "phase": "lobby"})
async def send_to(self, ws_id: str, data: dict):
ws = self.players.get(ws_id)
if ws:
try:
await ws.send_json(data)
except Exception:
pass
async def broadcast_players(self, data: dict):
dead = []
for ws_id, ws in list(self.players.items()):
try:
await ws.send_json(data)
except Exception:
dead.append(ws_id)
for d in dead:
self.disconnect(d)
async def broadcast_hosts(self, data: dict):
dead = []
for ws in list(self.hosts):
try:
await ws.send_json(data)
except Exception:
dead.append(ws)
for d in dead:
self.disconnect_host(d)
async def broadcast_all(self, data: dict):
await self.broadcast_players(data)
await self.broadcast_hosts(data)
mgr = ConnectionManager()
def get_leaderboard():
ranked = sorted(game.players.values(), key=lambda p: p["score"], reverse=True)
return [{"name": p.get("display", p["name"]), "score": p["score"], "dept": p.get("dept","")} for p in ranked]
# ── ROUTES ────────────────────────────────────────────────────────────────────
@app.get("/", response_class=HTMLResponse)
async def player_page(request: Request):
return templates.TemplateResponse("player.html", {"request": request})
@app.get("/host", response_class=HTMLResponse)
async def host_page(request: Request):
return templates.TemplateResponse("host.html", {"request": request})
@app.get("/scores", response_class=HTMLResponse)
async def scores_page(request: Request):
return templates.TemplateResponse("scores.html", {"request": request})
@app.delete("/api/scores/clear")
async def clear_scores():
with get_db() as conn:
conn.execute("DELETE FROM scores")
conn.execute("DELETE FROM sessions")
return {"ok": True}
@app.get("/api/scores")
async def api_scores():
with get_db() as conn:
sessions = conn.execute(
"SELECT * FROM sessions ORDER BY started_at DESC LIMIT 50"
).fetchall()
result = []
for s in sessions:
players = conn.execute(
"SELECT player, score, rank, dept FROM scores WHERE session_id=? ORDER BY rank",
(s["id"],)
).fetchall()
result.append({
"id": s["id"],
"started_at": s["started_at"],
"ended_at": s["ended_at"],
"total_players": s["total_players"],
"players": [{"player": p["player"], "score": p["score"], "rank": p["rank"], "dept": p["dept"] if "dept" in p.keys() else ""} for p in players]
})
return JSONResponse(result)
# ── PLAYER WEBSOCKET ──────────────────────────────────────────────────────────
@app.websocket("/ws/player/{ws_id}")
async def player_ws(ws: WebSocket, ws_id: str):
await mgr.connect_player(ws_id, ws)
try:
await mgr.send_to(ws_id, {"type": "phase", "phase": game.phase})
async for raw in ws.iter_text():
msg = json.loads(raw)
if msg["type"] == "join":
name = msg["name"].strip()[:40]
dept = msg.get("dept", "").strip()[:40]
display = msg.get("display", name).strip()[:40]
if not name:
continue
game.players[ws_id] = {"name": name, "score": 0, "answered": False, "dept": dept, "display": display}
await mgr.send_to(ws_id, {"type": "joined", "name": name, "display": display, "dept": dept})
await mgr.broadcast_hosts({
"type": "lobby_update",
"players": [{"name": p["name"], "display": p.get("display", p["name"]), "dept": p.get("dept","")} for p in game.players.values()],
"count": len(game.players)
})
elif msg["type"] == "answer":
if game.phase != "question":
continue
player = game.players.get(ws_id)
if not player or player["answered"]:
continue
elapsed = time.time() - game.q_start_time
TIME_LIMIT = 20
if elapsed > TIME_LIMIT:
continue
player["answered"] = True
chosen = msg["choice"]
q_idx = game.question_order[game.current_q]
correct = QUESTIONS[q_idx]["correct"]
is_correct = chosen == correct
points = 0
if is_correct:
speed_bonus = max(0, (TIME_LIMIT - elapsed) / TIME_LIMIT)
points = int(200 + 800 * speed_bonus)
player["score"] += points
game.answers_this_round.append({
"name": player["name"], "correct": is_correct, "elapsed": round(elapsed, 2)
})
await mgr.send_to(ws_id, {
"type": "answer_result",
"correct": is_correct,
"points": points,
"total": player["score"],
"correct_idx": correct
})
answered = sum(1 for p in game.players.values() if p["answered"])
await mgr.broadcast_hosts({
"type": "answer_update",
"answered": answered,
"total": len(game.players),
"details": game.answers_this_round
})
except WebSocketDisconnect:
mgr.disconnect(ws_id)
await mgr.broadcast_hosts({
"type": "lobby_update",
"players": [{"name": p["name"], "display": p.get("display", p["name"]), "dept": p.get("dept","")} for p in game.players.values()],
"count": len(game.players)
})
# ── HOST WEBSOCKET ────────────────────────────────────────────────────────────
@app.websocket("/ws/host")
async def host_ws(ws: WebSocket):
await mgr.connect_host(ws)
try:
await ws.send_json({
"type": "lobby_update",
"players": [{"name": p["name"], "display": p.get("display", p["name"]), "dept": p.get("dept","")} for p in game.players.values()],
"count": len(game.players)
})
async for raw in ws.iter_text():
msg = json.loads(raw)
if msg["type"] == "start_game":
existing_players = dict(game.players) # preserve joined players
game.reset()
game.players = existing_players # restore them
# Reset scores and state for a fresh game
for p in game.players.values():
p["score"] = 0
p["answered"] = False
await mgr.broadcast_all({"type": "phase", "phase": "lobby"})
elif msg["type"] == "next_question":
game.current_q += 1
if game.current_q >= len(QUESTIONS):
lb = get_leaderboard()
save_session(list(game.players.values())) # ← persist to SQLite
game.phase = "podium"
await mgr.broadcast_all({"type": "podium", "leaderboard": lb})
continue
game.phase = "question"
game.q_start_time = time.time()
game.answers_this_round = []
for p in game.players.values():
p["answered"] = False
q_idx = game.question_order[game.current_q]
q = QUESTIONS[q_idx]
await mgr.broadcast_all({
"type": "question",
"number": game.current_q + 1,
"total": len(QUESTIONS),
"q": q["q"],
"options": q["options"],
"time_limit": 20,
"correct_idx_hint": -1 # not revealed until show_results
})
elif msg["type"] == "show_results":
game.phase = "results"
q_idx = game.question_order[game.current_q]
q = QUESTIONS[q_idx]
await mgr.broadcast_all({
"type": "results",
"correct_idx": q["correct"],
"fun_fact": q["fun_fact"],
"leaderboard": get_leaderboard()
})
elif msg["type"] == "reset":
game.reset()
await mgr.broadcast_all({"type": "phase", "phase": "lobby"})
except WebSocketDisconnect:
mgr.disconnect_host(ws)
+4
View File
@@ -0,0 +1,4 @@
fastapi==0.111.0
uvicorn[standard]==0.29.0
jinja2==3.1.4
websockets==12.0
+442
View File
@@ -0,0 +1,442 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NI Quiz · Host</title>
<!-- QR Code library -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&family=DM+Sans:wght@400;500&display=swap');
*{margin:0;padding:0;box-sizing:border-box;}
:root{
--bg:#000510;--surface:#0d1117;--surface2:#161b22;
--accent:#00c8ff;--green:#00ff88;--red:#ff3355;--amber:#ffcc00;--purple:#cc44ff;
--text:#e8edf5;--muted:#6b7a99;--border:rgba(255,255,255,0.08);
--pixel:'Press Start 2P',monospace;
}
html,body{width:100%;height:100vh;background:var(--bg);color:var(--text);font-family:'DM Sans',sans-serif;overflow:hidden;}
body::after{content:'';position:fixed;inset:0;background:repeating-linear-gradient(0deg,transparent,transparent 2px,rgba(0,0,0,0.1) 2px,rgba(0,0,0,0.1) 4px);pointer-events:none;z-index:9999;}
.grid-bg{position:fixed;inset:0;background-image:linear-gradient(rgba(0,200,255,0.03) 1px,transparent 1px),linear-gradient(90deg,rgba(0,200,255,0.03) 1px,transparent 1px);background-size:40px 40px;pointer-events:none;}
.screen{display:none;height:100vh;padding:1.5rem 2rem;flex-direction:column;}
.screen.active{display:flex;}
/* ── LOBBY ── */
#lobby-screen{align-items:center;justify-content:center;text-align:center;}
.logo{font-family:var(--pixel);font-size:1.6rem;color:var(--accent);text-shadow:0 0 30px var(--accent);margin-bottom:.3rem;}
.tagline{font-size:.8rem;color:var(--muted);margin-bottom:2rem;}
.lobby-main{display:flex;gap:2.5rem;align-items:flex-start;justify-content:center;width:100%;max-width:900px;}
/* QR */
.qr-box{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:1.2rem;text-align:center;min-width:200px;}
.qr-label{font-family:var(--pixel);font-size:.4rem;color:var(--muted);letter-spacing:.1em;margin-bottom:.8rem;}
#qrcode{display:flex;justify-content:center;margin-bottom:.8rem;}
#qrcode canvas,#qrcode img{border-radius:8px;}
.qr-url{font-size:.75rem;color:var(--accent);word-break:break-all;}
/* Player list */
.players-box{flex:1;background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:1.2rem;}
.players-box-title{font-family:var(--pixel);font-size:.45rem;color:var(--muted);letter-spacing:.1em;margin-bottom:.8rem;}
.player-count-big{font-family:var(--pixel);font-size:2.5rem;color:var(--accent);line-height:1;margin-bottom:.3rem;}
.player-count-sub{font-size:.75rem;color:var(--muted);margin-bottom:.8rem;}
.p-chips{display:flex;flex-wrap:wrap;gap:.4rem;max-height:120px;overflow-y:auto;}
.p-chip{padding:.25rem .6rem;border-radius:100px;background:var(--surface2);border:1px solid var(--border);font-size:.72rem;}
.start-row{display:flex;gap:.8rem;margin-top:2rem;}
.hbtn{padding:.8rem 1.8rem;border-radius:6px;font-family:var(--pixel);font-size:.5rem;letter-spacing:.08em;cursor:pointer;border:2px solid;transition:all .15s;}
.hbtn:active{transform:scale(.96);}
.hbtn-primary{border-color:var(--accent);color:#000;background:var(--accent);}
.hbtn-primary:disabled{opacity:.35;cursor:not-allowed;}
.hbtn-secondary{border-color:var(--border);color:var(--muted);background:transparent;}
.hbtn-secondary:hover{border-color:var(--muted);color:var(--text);}
.hbtn-danger{border-color:var(--red);color:var(--red);background:transparent;}
.hbtn-danger:hover{background:rgba(255,51,85,.1);}
/* ── QUESTION ── */
#question-screen{justify-content:space-between;}
.q-top{display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;}
.q-meta{font-family:var(--pixel);font-size:.5rem;color:var(--muted);}
.timer-big{font-family:var(--pixel);font-size:2.5rem;color:var(--amber);}
.timer-big.urgent{color:var(--red);animation:blink .5s step-end infinite;}
.q-body{display:flex;gap:1.5rem;flex:1;}
.q-left{flex:1;}
.q-text{font-family:var(--pixel);font-size:clamp(.55rem,1.2vw,.75rem);line-height:2;margin-bottom:1.2rem;color:var(--text);}
.opts-grid{display:grid;grid-template-columns:1fr 1fr;gap:.6rem;}
.opt-box{background:var(--surface);border:1.5px solid var(--border);border-radius:8px;padding:.8rem 1rem;font-size:.82rem;display:flex;align-items:center;gap:.6rem;}
.opt-box.correct{border-color:var(--green);background:rgba(0,255,136,.07);}
.opt-letter{font-family:var(--pixel);font-size:.55rem;min-width:24px;color:var(--muted);}
.q-right{width:240px;display:flex;flex-direction:column;gap:.8rem;}
.ans-box,.lb-box{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:1rem;}
.box-title{font-family:var(--pixel);font-size:.38rem;color:var(--muted);letter-spacing:.1em;margin-bottom:.6rem;}
.ans-count{font-family:var(--pixel);font-size:2rem;color:var(--accent);}
.ans-sub{font-size:.72rem;color:var(--muted);}
.ans-bar-wrap{margin-top:.6rem;height:4px;background:var(--surface2);border-radius:2px;}
.ans-bar{height:100%;background:var(--accent);border-radius:2px;transition:width .3s;}
.lb-box{flex:1;overflow:hidden;}
.lb-item{display:flex;align-items:center;gap:.5rem;margin-bottom:.45rem;font-size:.78rem;}
.lb-rank{font-family:var(--pixel);font-size:.38rem;color:var(--muted);min-width:18px;}
.lb-name{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:.78rem;}
.lb-score{font-family:var(--pixel);font-size:.38rem;color:var(--accent);}
.host-controls{display:flex;gap:.8rem;margin-top:.8rem;}
/* ── RESULTS ── */
#results-screen{justify-content:center;align-items:center;}
.res-inner{width:100%;display:flex;gap:2.5rem;align-items:flex-start;justify-content:center;}
.res-left{flex:1;max-width:480px;}
.res-q-num{font-family:var(--pixel);font-size:.4rem;color:var(--muted);letter-spacing:.1em;margin-bottom:.8rem;}
.correct-box{background:rgba(0,255,136,.06);border:1px solid rgba(0,255,136,.25);border-radius:10px;padding:1rem;margin-bottom:.8rem;}
.correct-box-lbl{font-family:var(--pixel);font-size:.38rem;color:var(--green);letter-spacing:.1em;margin-bottom:.4rem;}
.correct-box-text{font-size:.95rem;font-weight:500;}
.fun-fact-box{background:var(--surface);border:1px solid var(--border);border-left:3px solid var(--accent);border-radius:0 10px 10px 0;padding:.9rem 1.1rem;font-size:.85rem;line-height:1.65;}
.res-right{width:260px;}
.full-lb{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:1rem;}
.full-lb-title{font-family:var(--pixel);font-size:.5rem;margin-bottom:.8rem;}
.flb-item{display:flex;align-items:center;gap:.6rem;padding:.45rem 0;border-bottom:1px solid var(--border);font-size:.82rem;}
.flb-item:last-child{border:none;}
.flb-rank{font-size:.9rem;min-width:24px;}
.flb-name{flex:1;}
.flb-score{font-family:var(--pixel);font-size:.38rem;color:var(--accent);}
.flb-dept{font-size:.68rem;color:var(--muted);}
/* ── PODIUM ── */
#podium-screen{align-items:center;justify-content:center;text-align:center;}
#podium-screen h1{font-family:var(--pixel);font-size:1.2rem;color:var(--amber);text-shadow:0 0 20px var(--amber);margin-bottom:.3rem;}
.podium-stage{display:flex;align-items:flex-end;gap:1rem;margin:1.5rem 0;}
.p-block{display:flex;flex-direction:column;align-items:center;width:160px;}
.p-avatar{font-size:1.8rem;margin-bottom:.4rem;}
.p-pname{font-family:var(--pixel);font-size:.45rem;margin-bottom:.2rem;line-height:1.5;}
.p-pdept{font-size:.7rem;color:var(--muted);margin-bottom:.4rem;}
.p-pscore{font-size:.65rem;color:var(--muted);margin-bottom:.4rem;}
.p-bar{width:100%;display:flex;align-items:center;justify-content:center;border-radius:8px 8px 0 0;font-family:var(--pixel);font-size:1.5rem;font-weight:800;}
.pb1{background:rgba(255,204,0,.15);border:1px solid rgba(255,204,0,.4);height:120px;color:var(--amber);}
.pb2{background:rgba(200,200,200,.08);border:1px solid rgba(200,200,200,.3);height:85px;color:#9ca3af;}
.pb3{background:rgba(180,120,60,.08);border:1px solid rgba(180,120,60,.3);height:60px;color:#b4783c;}
.rest-list{display:flex;flex-direction:column;gap:.4rem;max-width:380px;}
.rest-item{display:flex;align-items:center;gap:.7rem;background:var(--surface);border:1px solid var(--border);border-radius:6px;padding:.5rem .8rem;font-size:.8rem;}
.rest-rank{font-family:var(--pixel);font-size:.38rem;color:var(--muted);min-width:22px;}
.rest-name{flex:1;}
.rest-score{font-family:var(--pixel);font-size:.38rem;color:var(--accent);}
@keyframes blink{0%,100%{opacity:1}50%{opacity:0}}
@keyframes confetti{from{transform:translateY(-20px) rotate(0);opacity:1}to{transform:translateY(100vh) rotate(720deg);opacity:0}}
.cfp{position:fixed;width:8px;height:8px;border-radius:2px;animation:confetti linear forwards;pointer-events:none;}
/* ── AUTH GATE ── */
#auth-gate{
position:fixed;inset:0;z-index:9000;
background:var(--bg);
display:flex;align-items:center;justify-content:center;
}
#auth-box{
display:flex;flex-direction:column;align-items:center;gap:1rem;
background:var(--surface);border:1px solid var(--border);
border-radius:16px;padding:2.5rem 2rem;width:100%;max-width:340px;
}
#auth-logo{font-family:var(--pixel);font-size:1rem;color:var(--accent);text-shadow:0 0 20px var(--accent);}
#auth-sub{font-size:.75rem;color:var(--muted);margin-bottom:.5rem;}
#auth-input{
width:100%;padding:.85rem 1rem;
background:var(--surface2);border:1px solid var(--border);border-radius:6px;
color:var(--text);font-size:1rem;font-family:'DM Sans',sans-serif;
outline:none;text-align:center;letter-spacing:.15em;
}
#auth-input:focus{border-color:var(--accent);}
#auth-btn{
width:100%;padding:.85rem;
background:var(--accent);border:none;border-radius:6px;
color:#000;font-family:var(--pixel);font-size:.55rem;letter-spacing:.1em;
cursor:pointer;transition:opacity .15s;
}
#auth-btn:hover{opacity:.85;}
#auth-err{font-family:var(--pixel);font-size:.4rem;color:var(--red);min-height:1rem;}
</style>
</head>
<body>
<div class="grid-bg"></div>
<!-- LOBBY -->
<div class="screen active" id="lobby-screen">
<div class="logo">🌐 NI QUIZ</div>
<div class="tagline">Network Intelligence · LCPR — Host Panel</div>
<div class="lobby-main">
<div class="qr-box">
<div class="qr-label">SCAN TO PLAY</div>
<div id="qrcode"></div>
<div class="qr-url" id="join-url"></div>
</div>
<div class="players-box">
<div class="players-box-title">PLAYERS CONNECTED</div>
<div class="player-count-big" id="player-count">0</div>
<div class="player-count-sub">participants ready</div>
<div class="p-chips" id="p-chips"></div>
</div>
</div>
<div class="start-row">
<button class="hbtn hbtn-primary" id="start-btn" disabled onclick="startGame()">▶ START GAME</button>
<button class="hbtn hbtn-danger" onclick="clearScores()">🗑 CLEAR SCORES</button>
<button class="hbtn hbtn-secondary" onclick="location.href='/scores'">📊 HISTORY</button>
</div>
</div>
<!-- QUESTION -->
<div class="screen" id="question-screen">
<div class="q-top">
<span class="q-meta" id="q-meta">Q 1 / 5</span>
<div class="timer-big" id="host-timer">15</div>
<button class="hbtn hbtn-secondary" onclick="showResults()">RESULTS →</button>
</div>
<div class="q-body">
<div class="q-left">
<div class="q-text" id="host-q-text"></div>
<div class="opts-grid" id="host-opts"></div>
</div>
<div class="q-right">
<div class="ans-box">
<div class="box-title">ANSWERED</div>
<div class="ans-count" id="ans-count">0</div>
<div class="ans-sub" id="ans-sub">of 0 players</div>
<div class="ans-bar-wrap"><div class="ans-bar" id="ans-bar" style="width:0%"></div></div>
</div>
<div class="lb-box">
<div class="box-title">LIVE RANKING</div>
<div id="live-lb"></div>
</div>
</div>
</div>
</div>
<!-- RESULTS -->
<div class="screen" id="results-screen">
<div class="res-inner">
<div class="res-left">
<div class="res-q-num" id="res-q-num">QUESTION 1</div>
<div class="correct-box">
<div class="correct-box-lbl">✓ CORRECT ANSWER</div>
<div class="correct-box-text" id="correct-ans-text"></div>
</div>
<div class="fun-fact-box" id="host-fun-fact"></div>
<div style="display:flex;gap:.8rem;margin-top:1.2rem;">
<button class="hbtn hbtn-primary" id="next-btn" onclick="nextQuestion()">NEXT →</button>
<button class="hbtn hbtn-secondary" onclick="resetGame()">RESET</button>
</div>
</div>
<div class="res-right">
<div class="full-lb">
<div class="full-lb-title">🏆 TOP 10</div>
<div id="full-lb-list"></div>
</div>
</div>
</div>
</div>
<!-- PODIUM -->
<div class="screen" id="podium-screen">
<h1>🏆 GAME OVER!</h1>
<div class="podium-stage" id="podium-stage"></div>
<div class="rest-list" id="rest-list"></div>
<div style="display:flex;gap:.8rem;margin-top:1.5rem;">
<button class="hbtn hbtn-primary" onclick="resetGame()">▶ PLAY AGAIN</button>
<button class="hbtn hbtn-secondary" onclick="location.href='/scores'">📊 FULL HISTORY</button>
</div>
</div>
<script>
let ws, currentTotal = 0, currentQ = 0, timerInterval = null;
// ── QR CODE ──────────────────────────────────────────────────────────────────
const baseUrl = `${location.protocol}//${location.hostname}${location.port?':'+location.port:''}`;
document.getElementById('join-url').textContent = baseUrl;
new QRCode(document.getElementById('qrcode'), {
text: baseUrl,
width: 160, height: 160,
colorDark: '#00c8ff', colorLight: '#0d1117',
correctLevel: QRCode.CorrectLevel.M
});
// ── WS ────────────────────────────────────────────────────────────────────────
function connectWS() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
ws = new WebSocket(`${proto}://${location.host}/ws/host`);
ws.onmessage = e => handleMessage(JSON.parse(e.data));
ws.onclose = () => setTimeout(connectWS, 2000);
}
function show(id) {
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
document.getElementById(id).classList.add('active');
}
function handleMessage(msg) {
if (msg.type === 'lobby_update') {
document.getElementById('player-count').textContent = msg.count;
document.getElementById('start-btn').disabled = msg.count < 1;
currentTotal = msg.count;
const chips = document.getElementById('p-chips');
chips.innerHTML = msg.players.map(p =>
`<span class="p-chip">${p.display || p.name}</span>`).join('');
}
else if (msg.type === 'question') {
currentQ = msg.number;
document.getElementById('q-meta').textContent = `Q ${msg.number} / ${msg.total}`;
document.getElementById('host-q-text').textContent = msg.q;
const letters = ['A','B','C','D'];
document.getElementById('host-opts').innerHTML = msg.options.map((o,i) =>
`<div class="opt-box" id="hopt-${i}"><span class="opt-letter">${letters[i]}</span>${o}</div>`
).join('');
document.getElementById('ans-count').textContent = '0';
document.getElementById('ans-sub').textContent = `of ${currentTotal} players`;
document.getElementById('ans-bar').style.width = '0%';
document.getElementById('live-lb').innerHTML = '';
show('question-screen');
startTimer(msg.time_limit);
}
else if (msg.type === 'answer_update') {
const pct = currentTotal > 0 ? (msg.answered / currentTotal * 100) : 0;
document.getElementById('ans-count').textContent = msg.answered;
document.getElementById('ans-sub').textContent = `of ${msg.total} players`;
document.getElementById('ans-bar').style.width = pct + '%';
// Live ranking from answer details
const lb = document.getElementById('live-lb');
lb.innerHTML = (msg.details || []).slice(0,10).map((d,i) =>
`<div class="lb-item">
<span class="lb-rank">${i+1}</span>
<span class="lb-name">${d.name}</span>
<span class="lb-score" style="color:${d.correct?'var(--green)':'var(--red)'}">${d.correct?d.elapsed+'s ✓':'✗'}</span>
</div>`).join('');
}
else if (msg.type === 'results') {
clearInterval(timerInterval);
document.getElementById('res-q-num').textContent = `QUESTION ${currentQ} / 5`;
document.getElementById('host-fun-fact').textContent = msg.fun_fact;
const opts = document.querySelectorAll('#host-opts .opt-box');
opts.forEach((o,i) => { if(i === msg.correct_idx) o.classList.add('correct'); });
document.getElementById('correct-ans-text').textContent =
opts[msg.correct_idx]?.textContent.trim().slice(1) || '';
document.getElementById('next-btn').textContent = currentQ >= 5 ? 'PODIUM →' : 'NEXT →';
// Top 10 leaderboard
const medals = ['🥇','🥈','🥉'];
document.getElementById('full-lb-list').innerHTML = msg.leaderboard.slice(0,10).map((p,i) =>
`<div class="flb-item">
<span class="flb-rank">${i<3?medals[i]:i+1}</span>
<span class="flb-name">${p.name}<br><span class="flb-dept">${p.dept||''}</span></span>
<span class="flb-score">${p.score.toLocaleString()}</span>
</div>`).join('');
show('results-screen');
}
else if (msg.type === 'podium') {
clearInterval(timerInterval);
buildPodium(msg.leaderboard);
}
}
function startTimer(secs) {
clearInterval(timerInterval);
let rem = secs;
const el = document.getElementById('host-timer');
function tick() {
el.textContent = rem;
el.classList.toggle('urgent', rem <= 5);
if(rem <= 0) clearInterval(timerInterval);
rem--;
}
tick();
timerInterval = setInterval(tick, 1000);
}
function startGame() { ws.send(JSON.stringify({type:'start_game'})); nextQuestion(); }
function nextQuestion() { ws.send(JSON.stringify({type:'next_question'})); }
function showResults() { clearInterval(timerInterval); ws.send(JSON.stringify({type:'show_results'})); }
function resetGame() { clearInterval(timerInterval); ws.send(JSON.stringify({type:'reset'})); show('lobby-screen'); }
async function clearScores() {
if (!confirm('¿Borrar todos los scores guardados? Esta acción no se puede deshacer.')) return;
const r = await fetch('/api/scores/clear', {method:'DELETE'});
if (r.ok) alert('Scores borrados.');
else alert('Error al borrar scores.');
}
function buildPodium(lb) {
const medals = ['🥇','🥈','🥉'];
const pbClass = ['pb1','pb2','pb3'];
const order = [1,0,2];
const stage = document.getElementById('podium-stage');
stage.innerHTML = order.map(i => {
const p = lb[i]; if(!p) return '';
return `<div class="p-block">
<div class="p-avatar">${medals[i]}</div>
<div class="p-pname">${p.name}</div>
<div class="p-pdept">${p.dept||''}</div>
<div class="p-pscore">${p.score.toLocaleString()} pts</div>
<div class="p-bar ${pbClass[i]}">${i+1}</div>
</div>`;
}).join('');
document.getElementById('rest-list').innerHTML = lb.slice(3).map((p,i) =>
`<div class="rest-item">
<span class="rest-rank">${i+4}</span>
<span class="rest-name">${p.name} <span style="color:var(--muted);font-size:.72rem;">${p.dept||''}</span></span>
<span class="rest-score">${p.score.toLocaleString()}</span>
</div>`).join('');
show('podium-screen');
confetti();
}
function confetti() {
const colors = ['#00c8ff','#cc44ff','#00ff88','#ffcc00','#fff'];
for(let i=0;i<80;i++) {
setTimeout(()=>{
const el = document.createElement('div');
el.className = 'cfp';
el.style.cssText = `left:${Math.random()*100}vw;background:${colors[Math.floor(Math.random()*colors.length)]};animation-duration:${2+Math.random()*2}s;top:-10px;`;
document.body.appendChild(el);
setTimeout(()=>el.remove(), 4000);
}, i*40);
}
}
// ── AUTH ─────────────────────────────────────────────────────────────────────
const HOST_PASSWORD = 'ni2026'; // ← cambia esto
function checkAuth() {
const saved = sessionStorage.getItem('host_auth');
if (saved === HOST_PASSWORD) { connectWS(); return; }
// Build gate overlay
const gate = document.createElement('div');
gate.id = 'auth-gate';
gate.innerHTML = `
<div id="auth-box">
<div id="auth-logo">🌐 NI QUIZ</div>
<div id="auth-sub">Host Panel · Acceso restringido</div>
<input id="auth-input" type="password" placeholder="contraseña" autocomplete="off" />
<button id="auth-btn">ENTRAR →</button>
<div id="auth-err"></div>
</div>`;
document.body.appendChild(gate);
const input = document.getElementById('auth-input');
const btn = document.getElementById('auth-btn');
const err = document.getElementById('auth-err');
function attempt() {
if (input.value === HOST_PASSWORD) {
sessionStorage.setItem('host_auth', HOST_PASSWORD);
gate.remove();
connectWS();
} else {
err.textContent = 'contraseña incorrecta';
input.value = '';
input.focus();
setTimeout(() => err.textContent = '', 2000);
}
}
btn.addEventListener('click', attempt);
input.addEventListener('keydown', e => { if (e.key === 'Enter') attempt(); });
input.focus();
}
checkAuth();
</script>
</body>
</html>
+763
View File
@@ -0,0 +1,763 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>NI Quiz · Arcade</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&family=DM+Sans:wght@400;500&display=swap');
*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent;}
:root{
--bg:#000510;--surface:#0a0e1a;--surface2:#111827;
--accent:#00c8ff;--green:#00ff88;--red:#ff3355;--amber:#ffcc00;--purple:#cc44ff;
--text:#e8edf5;--muted:#4a5568;--border:rgba(255,255,255,0.08);
--pixel:'Press Start 2P',monospace;
}
html,body{width:100%;height:100%;background:var(--bg);color:var(--text);font-family:'DM Sans',sans-serif;overflow:hidden;touch-action:none;}
/* SCANLINES */
body::after{content:'';position:fixed;inset:0;background:repeating-linear-gradient(0deg,transparent,transparent 2px,rgba(0,0,0,0.15) 2px,rgba(0,0,0,0.15) 4px);pointer-events:none;z-index:9999;}
.screen{display:none;flex-direction:column;align-items:center;justify-content:center;height:100vh;padding:1.5rem;text-align:center;}
.screen.active{display:flex;}
#lobby-screen{height:auto;min-height:100vh;overflow-y:auto;padding:2rem 1.5rem;justify-content:center;}
/* ── LANGUAGE SELECT ── */
#lang-screen .title{font-family:var(--pixel);font-size:1.2rem;color:var(--accent);margin-bottom:.3rem;text-shadow:0 0 20px var(--accent);line-height:1.8;}
#lang-screen .subtitle{font-family:var(--pixel);font-size:.45rem;color:var(--muted);margin-bottom:2.5rem;letter-spacing:.1em;}
.lang-btn{
display:block;width:100%;max-width:280px;margin:.6rem auto;
padding:1rem;border-radius:4px;
font-family:var(--pixel);font-size:.65rem;letter-spacing:.1em;
cursor:pointer;border:2px solid;transition:all .15s;
}
.lang-btn.en{border-color:var(--accent);color:var(--accent);background:rgba(0,200,255,.05);}
.lang-btn.es{border-color:var(--amber);color:var(--amber);background:rgba(255,204,0,.05);}
.lang-btn:active{transform:scale(.96);}
.lang-btn:hover{filter:brightness(1.3);}
.pixel-stars{position:fixed;inset:0;pointer-events:none;}
/* ── JOIN ── */
#join-screen .logo{font-family:var(--pixel);font-size:1rem;color:var(--accent);margin-bottom:.3rem;text-shadow:0 0 20px var(--accent);line-height:1.8;}
#join-screen .sub{font-size:.75rem;color:var(--muted);margin-bottom:2rem;}
.field-wrap{width:100%;max-width:320px;margin-bottom:.8rem;text-align:left;}
.field-wrap label{font-family:var(--pixel);font-size:.45rem;color:var(--muted);letter-spacing:.1em;display:block;margin-bottom:.4rem;}
.field-wrap input, .field-wrap select{
width:100%;padding:.85rem 1rem;
background:var(--surface2);border:1px solid var(--border);border-radius:4px;
color:var(--text);font-size:.9rem;font-family:'DM Sans',sans-serif;
outline:none;
}
.field-wrap input:focus,.field-wrap select:focus{border-color:var(--accent);}
.field-wrap input::placeholder{color:var(--muted);}
.field-wrap select option{background:var(--surface2);}
#join-btn{
width:100%;max-width:320px;padding:1rem;margin-top:.5rem;
background:var(--accent);border:none;border-radius:4px;
color:#000;font-family:var(--pixel);font-size:.6rem;letter-spacing:.1em;
cursor:pointer;
}
#join-btn:active{opacity:.85;transform:scale(.97);}
/* ── LOBBY ── */
#lobby-screen{
justify-content:center;
min-height:100vh;
height:auto;
padding:2rem 1.5rem;
overflow-y:auto;
}
#lobby-screen .big{font-size:3.5rem;margin-bottom:1rem;animation:float 2s ease-in-out infinite;}
#lobby-screen .pname{font-family:var(--pixel);font-size:.7rem;color:var(--accent);margin:.5rem 0;}
#lobby-screen .wait{font-family:var(--pixel);font-size:.4rem;color:var(--muted);animation:blink 1s step-end infinite;margin-top:1rem;}
.exit-btn{
margin-top:1.5rem;
background:transparent;border:1px solid var(--muted);
color:var(--muted);font-family:var(--pixel);font-size:.4rem;
letter-spacing:.08em;padding:.6rem 1.4rem;border-radius:4px;
cursor:pointer;transition:all .2s;
}
.exit-btn:hover,.exit-btn:active{border-color:var(--red);color:var(--red);}
/* ── GAME CANVAS ── */
#game-screen{padding:0;position:relative;background:var(--bg);}
#game-canvas{display:block;width:100%;height:100vh;}
/* ── HUD + BANNER — stacked, no overlap ── */
#game-overlay{
position:absolute;top:0;left:0;right:0;
pointer-events:none;
display:flex;flex-direction:column;
padding:8px 12px 0;
gap:6px;
}
#score-hud{
display:flex;justify-content:space-between;align-items:center;
}
#score-hud .hud-score{font-family:var(--pixel);font-size:.5rem;color:var(--accent);}
#score-hud .hud-timer{font-family:var(--pixel);font-size:.75rem;color:var(--amber);}
#score-hud .hud-q{font-family:var(--pixel);font-size:.4rem;color:var(--muted);}
#question-banner{
font-family:'DM Sans',sans-serif;font-size:.88rem;font-weight:500;
color:var(--text);line-height:1.5;text-align:center;
background:rgba(0,5,16,.9);border:1px solid var(--border);
border-radius:6px;padding:.55rem .9rem;
/* clamp so it never grows past ~25% of viewport */
max-height:28vh;overflow:hidden;
}
/* ── RESULT FLASH ── */
#result-flash{
position:absolute;inset:0;display:none;
flex-direction:column;align-items:center;justify-content:center;
background:rgba(0,5,16,.92);z-index:50;
}
#result-flash.active{display:flex;}
#flash-icon{font-size:4rem;margin-bottom:.5rem;}
#flash-title{font-family:var(--pixel);font-size:.9rem;margin-bottom:.4rem;}
#flash-pts{font-family:var(--pixel);font-size:1.4rem;color:var(--accent);}
#flash-total{font-family:var(--pixel);font-size:.45rem;color:var(--muted);margin-top:.3rem;}
/* ── RESULTS / FUNFACT ── */
#results-screen{background:var(--bg);}
#results-screen .r-label{font-family:var(--pixel);font-size:.45rem;color:var(--green);letter-spacing:.1em;margin-bottom:1rem;}
#results-screen .r-fact{
background:var(--surface2);border:1px solid var(--border);border-left:3px solid var(--accent);
border-radius:0 8px 8px 0;padding:1rem 1.2rem;
font-size:.88rem;line-height:1.65;text-align:left;max-width:360px;margin:0 auto 1rem;
}
#results-screen .r-score{font-family:var(--pixel);font-size:.65rem;color:var(--accent);margin-bottom:.5rem;}
#results-screen .r-wait{font-family:var(--pixel);font-size:.38rem;color:var(--muted);animation:blink 1s step-end infinite;}
/* ── PODIUM ── */
#podium-screen{background:var(--bg);height:auto;min-height:100vh;overflow-y:auto;padding:2rem 1.5rem;justify-content:center;}
#podium-screen h2{font-family:var(--pixel);font-size:.85rem;color:var(--amber);margin-bottom:.3rem;text-shadow:0 0 20px var(--amber);}
#podium-screen .sub{font-size:.75rem;color:var(--muted);margin-bottom:1.5rem;}
.podium-list{width:100%;max-width:360px;}
.podium-item{
display:flex;align-items:center;gap:.8rem;
background:var(--surface2);border:1px solid var(--border);
border-radius:4px;padding:.7rem 1rem;margin-bottom:.5rem;
}
.podium-item.r1{border-color:var(--amber);background:rgba(255,204,0,.06);}
.podium-item.r2{border-color:rgba(200,200,200,.4);}
.podium-item.r3{border-color:rgba(180,120,60,.4);}
.p-rank{font-family:var(--pixel);font-size:.7rem;min-width:28px;}
.p-name{flex:1;font-size:.85rem;}
.p-score{font-family:var(--pixel);font-size:.6rem;color:var(--accent);}
@keyframes float{0%,100%{transform:translateY(0)}50%{transform:translateY(-8px)}}
@keyframes blink{0%,100%{opacity:1}50%{opacity:0}}
@keyframes popIn{from{transform:scale(.7);opacity:0}to{transform:scale(1);opacity:1}}
.pop{animation:popIn .3s cubic-bezier(.34,1.56,.64,1) both;}
</style>
</head>
<body>
<!-- STARS BG -->
<canvas class="pixel-stars" id="stars-canvas"></canvas>
<!-- LANGUAGE SELECT -->
<div class="screen active" id="lang-screen">
<div class="title">🌐 NI QUIZ</div>
<div class="subtitle">NETWORK INTELLIGENCE · LCPR</div>
<button class="lang-btn en" onclick="setLang('en')">🇺🇸 &nbsp; ENGLISH</button>
<button class="lang-btn es" onclick="setLang('es')">🇵🇷 &nbsp; ESPAÑOL</button>
</div>
<!-- JOIN -->
<div class="screen" id="join-screen">
<div class="logo">🚀 NI QUIZ</div>
<div class="sub" id="join-sub">Network Intelligence · LCPR</div>
<div class="field-wrap">
<label id="lbl-fname">FIRST NAME</label>
<input id="inp-fname" type="text" maxlength="20" autocomplete="off" autocorrect="off" autocapitalize="words">
</div>
<div class="field-wrap">
<label id="lbl-lname">LAST NAME</label>
<input id="inp-lname" type="text" maxlength="20" autocomplete="off" autocorrect="off" autocapitalize="words">
</div>
<div class="field-wrap">
<label id="lbl-dept">DEPARTMENT</label>
<select id="inp-dept">
<option value="">-- Select --</option>
<option>Network Intelligence</option>
<option>NOC</option>
<option>Network Engineering</option>
<option>Field Operations</option>
<option>IT</option>
<option>Planning</option>
<option>Other / Otro</option>
</select>
</div>
<button id="join-btn">▶ ENTER GAME</button>
</div>
<!-- LOBBY -->
<div class="screen" id="lobby-screen">
<div class="big">🛸</div>
<div class="pname" id="lobby-name"></div>
<div style="font-family:var(--pixel);font-size:.4rem;color:var(--muted);margin-bottom:.5rem;" id="lobby-dept"></div>
<div class="wait" id="lobby-wait">WAITING FOR HOST...</div>
<button class="exit-btn" id="exit-btn" onclick="exitGame()">✕ salir</button>
</div>
<!-- GAME -->
<div class="screen" id="game-screen">
<canvas id="game-canvas"></canvas>
<div id="game-overlay">
<div id="score-hud">
<span class="hud-score" id="hud-score">000000</span>
<span class="hud-timer" id="hud-timer">15</span>
<span class="hud-q" id="hud-q">Q1/5</span>
</div>
<div id="question-banner"></div>
</div>
<div id="result-flash">
<div id="flash-icon"></div>
<div id="flash-title"></div>
<div id="flash-pts"></div>
<div id="flash-total"></div>
</div>
</div>
<!-- RESULTS -->
<div class="screen" id="results-screen">
<div class="r-label" id="r-label">✓ CORRECT ANSWER</div>
<div class="r-fact" id="r-fact"></div>
<div class="r-score" id="r-score"></div>
<div class="r-wait" id="r-wait">WAITING FOR HOST...</div>
</div>
<!-- PODIUM -->
<div class="screen" id="podium-screen">
<h2 id="pod-title">🏆 GAME OVER</h2>
<p class="sub" id="pod-sub">Network Intelligence · LCPR</p>
<div class="podium-list" id="podium-list"></div>
<button class="exit-btn" onclick="exitGame()">✕ salir</button>
</div>
<script>
// ── STARS ────────────────────────────────────────────────────────────────────
(function() {
const c = document.getElementById('stars-canvas');
const ctx = c.getContext('2d');
let stars = [];
function resize() {
c.width = window.innerWidth; c.height = window.innerHeight;
stars = Array.from({length:80}, () => ({
x: Math.random()*c.width, y: Math.random()*c.height,
s: Math.random()*1.5+.5, b: Math.random()
}));
}
function draw() {
ctx.clearRect(0,0,c.width,c.height);
stars.forEach(s => {
s.b += .005; if(s.b>1) s.b=0;
ctx.fillStyle = `rgba(255,255,255,${Math.abs(Math.sin(s.b*Math.PI))*.6})`;
ctx.fillRect(s.x, s.y, s.s, s.s);
});
requestAnimationFrame(draw);
}
window.addEventListener('resize', resize);
resize(); draw();
})();
// ── I18N ─────────────────────────────────────────────────────────────────────
const T = {
en: {
fname:'FIRST NAME', lname:'LAST NAME', dept:'DEPARTMENT',
enter:'▶ ENTER GAME', waiting:'WAITING FOR HOST...',
correct:'✓ CORRECT!', wrong:'✗ WRONG!', pts:'+{n} PTS', zero:'+0 PTS',
total:'TOTAL: {n} PTS', waithost:'WAITING FOR HOST...',
correct_ans:'✓ CORRECT ANSWER', your_score:'YOUR SCORE: {n} PTS',
gameover:'🏆 GAME OVER', dept_select:'-- Select --',
q_prefix:'Q', of:'/'
},
es: {
fname:'NOMBRE', lname:'APELLIDO', dept:'DEPARTAMENTO',
enter:'▶ ENTRAR AL JUEGO', waiting:'ESPERANDO AL HOST...',
correct:'✓ ¡CORRECTO!', wrong:'✗ ¡INCORRECTO!', pts:'+{n} PTS', zero:'+0 PTS',
total:'TOTAL: {n} PTS', waithost:'ESPERANDO AL HOST...',
correct_ans:'✓ RESPUESTA CORRECTA', your_score:'TU PUNTAJE: {n} PTS',
gameover:'🏆 ¡JUEGO TERMINADO!', dept_select:'-- Selecciona --',
q_prefix:'P', of:'/'
}
};
let lang = 'es';
function t(key, vars={}) {
let s = T[lang][key] || key;
Object.entries(vars).forEach(([k,v]) => s = s.replace(`{${k}}`, v));
return s;
}
function setLang(l) {
lang = l;
// Update join labels
document.getElementById('lbl-fname').textContent = t('fname');
document.getElementById('lbl-lname').textContent = t('lname');
document.getElementById('lbl-dept').textContent = t('dept');
document.getElementById('join-btn').textContent = t('enter');
document.getElementById('inp-dept').options[0].text = t('dept_select');
show('join-screen');
}
// ── SCREENS ──────────────────────────────────────────────────────────────────
function show(id) {
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
document.getElementById(id).classList.add('active');
}
// ── WS ───────────────────────────────────────────────────────────────────────
const WS_ID = crypto.randomUUID();
let ws, myName = '', myScore = 0, myDept = '';
function connectWS() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
ws = new WebSocket(`${proto}://${location.host}/ws/player/${WS_ID}`);
ws.onmessage = e => handleMessage(JSON.parse(e.data));
ws.onclose = () => setTimeout(connectWS, 2000);
}
function handleMessage(msg) {
if (msg.type === 'joined') {
myName = msg.name;
document.getElementById('lobby-name').textContent = msg.display;
document.getElementById('lobby-dept').textContent = msg.dept;
document.getElementById('lobby-wait').textContent = t('waiting');
show('lobby-screen');
}
else if (msg.type === 'phase') {
if (msg.phase === 'lobby' && myName) show('lobby-screen');
}
else if (msg.type === 'question') {
startGame(msg);
}
else if (msg.type === 'answer_result') {
showFlash(msg);
}
else if (msg.type === 'results') {
stopGame();
myScore = msg.leaderboard.find(p => p.name === myName)?.score ?? myScore;
document.getElementById('r-label').textContent = t('correct_ans');
document.getElementById('r-fact').textContent = msg.fun_fact;
document.getElementById('r-score').textContent = t('your_score', {n: myScore.toLocaleString()});
document.getElementById('r-wait').textContent = t('waithost');
show('results-screen');
}
else if (msg.type === 'podium') {
stopGame();
showPodium(msg.leaderboard);
}
}
// ── JOIN ─────────────────────────────────────────────────────────────────────
document.getElementById('join-btn').addEventListener('click', () => {
const fn = document.getElementById('inp-fname').value.trim();
const ln = document.getElementById('inp-lname').value.trim();
const dept = document.getElementById('inp-dept').value;
if (!fn || !ln || !dept) {
// Shake empty fields
[fn ? null : 'inp-fname', ln ? null : 'inp-lname', dept ? null : 'inp-dept']
.filter(Boolean).forEach(id => {
const el = document.getElementById(id);
el.style.borderColor = 'var(--red)';
setTimeout(() => el.style.borderColor = '', 1000);
});
return;
}
myDept = dept;
const displayName = `${fn} ${ln}`;
ws.send(JSON.stringify({type: 'join', name: `${fn} ${ln}`, dept, display: displayName}));
});
// ── GAME ENGINE ──────────────────────────────────────────────────────────────
const canvas = document.getElementById('game-canvas');
const ctx = canvas.getContext('2d');
let gameLoop = null;
let gameState = {
ship: {x: 0, targetX: 0, y: 0, w: 36, h: 28},
bullets: [],
invaders: [], // {x, y, w, h, text, idx, hit, hitTimer, correct}
particles: [],
answered: false,
correctIdx: -1,
timeLeft: 15,
timerStart: 0,
};
function resizeCanvas() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
window.addEventListener('resize', resizeCanvas);
resizeCanvas();
const COL_COLORS = ['#00c8ff','#cc44ff','#00ff88','#ffcc00'];
function startGame(msg) {
resizeCanvas();
const W = canvas.width, H = canvas.height;
const colW = W / 4;
// Invader block sizing — responsive to screen width
// On 375px wide: colW=93, invW≈77, invH≈90 (enough for 4-5 lines at 13px)
const invW = colW - 12;
const invPadX = 20; // space for letter badge on left
const textAreaW = invW - invPadX - 10;
const fontSize = Math.max(12, Math.min(14, W * 0.034)); // 1214px
const lineH = fontSize * 1.4;
// Pre-measure how many lines each option needs, then set uniform height
const measCtx = canvas.getContext('2d');
measCtx.font = `${fontSize}px 'DM Sans', sans-serif`;
function countLines(text, maxW) {
const words = text.split(' ');
let line = '', count = 1;
words.forEach(w => {
const test = line ? line + ' ' + w : w;
if (measCtx.measureText(test).width > maxW && line) { count++; line = w; }
else line = test;
});
return count;
}
const maxLines = Math.max(...msg.options.map(o => countLines(o, textAreaW)));
const badgeH = 20; // letter badge row
const invH = badgeH + maxLines * lineH + 20; // 20px vertical padding
gameState = {
ship: {x: W/2, targetX: W/2, y: H - 120, w: 36, h: 28},
bullets: [],
invaders: msg.options.map((text, idx) => ({
x: colW * idx + colW/2,
y: 120,
targetY: 120,
w: invW,
h: invH,
text,
idx,
hit: false,
hitTimer: 0,
correct: idx === msg.correct_idx_hint,
fontSize,
lineH,
textAreaW,
})),
particles: [],
answered: false,
correctIdx: msg.correct_idx_hint ?? -1,
timeLeft: msg.time_limit,
timerStart: performance.now(),
options: msg.options,
correctAnswer: msg.correct_idx_hint,
W, H, colW,
totalTime: msg.time_limit,
qNum: msg.number,
qTotal: msg.total,
};
document.getElementById('question-banner').textContent = msg.q;
document.getElementById('hud-q').textContent = `${t('q_prefix')}${msg.number}${t('of')}${msg.total}`;
document.getElementById('result-flash').classList.remove('active');
// Measure overlay height so invaders don't spawn behind it
const overlay = document.getElementById('game-overlay');
const overlayH = overlay ? overlay.getBoundingClientRect().height : 100;
const spawnY = overlayH + invH / 2 + 20; // first visible position just below overlay
// Invaders start high and slowly descend
gameState.invaders.forEach((inv, i) => {
inv.y = -invH - i * 25;
inv.targetY = spawnY;
});
show('game-screen');
if (gameLoop) cancelAnimationFrame(gameLoop);
gameLoop = requestAnimationFrame(gameFrame);
// Touch controls — tap column
canvas.ontouchstart = (e) => {
e.preventDefault();
if (gameState.answered) return;
const touch = e.touches[0];
const col = Math.floor(touch.clientX / gameState.colW);
moveAndShoot(col);
};
canvas.onmousedown = (e) => {
if (gameState.answered) return;
const col = Math.floor(e.clientX / gameState.colW);
moveAndShoot(col);
};
}
function moveAndShoot(col) {
const gs = gameState;
if (gs.answered) return;
const targetX = gs.colW * col + gs.colW / 2;
gs.ship.targetX = targetX;
// Fire bullet from current position toward that column
gs.bullets.push({
x: gs.ship.x, y: gs.ship.y - 20,
targetCol: col,
vx: (targetX - gs.ship.x) * 0.04,
vy: -18, w: 4, h: 16,
col
});
playBeep(880, 0.08, 0.05);
}
function stopGame() {
if (gameLoop) { cancelAnimationFrame(gameLoop); gameLoop = null; }
canvas.ontouchstart = null;
canvas.onmousedown = null;
}
function gameFrame(ts) {
const gs = gameState;
const W = gs.W, H = gs.H;
ctx.clearRect(0, 0, W, H);
// ── BACKGROUND ──
ctx.fillStyle = '#000510';
ctx.fillRect(0, 0, W, H);
// Column dividers
for (let i = 1; i < 4; i++) {
ctx.strokeStyle = 'rgba(255,255,255,0.04)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(gs.colW * i, 0);
ctx.lineTo(gs.colW * i, H);
ctx.stroke();
}
// Timer update
const elapsed = (performance.now() - gs.timerStart) / 1000;
const timeLeft = Math.max(0, gs.totalTime - elapsed);
document.getElementById('hud-timer').textContent = Math.ceil(timeLeft);
document.getElementById('hud-timer').style.color = timeLeft < 5 ? 'var(--red)' : 'var(--amber)';
// ── INVADERS ──
gs.invaders.forEach((inv, i) => {
if (inv.hit) {
inv.hitTimer--;
return;
}
// Slowly descend after appearing
if (!gs.answered) {
inv.y += (inv.targetY - inv.y) * 0.08;
inv.targetY += 0.25; // drift down slowly
}
const color = COL_COLORS[i];
// Box
ctx.fillStyle = `rgba(${hexToRgb(color)},0.10)`;
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
roundRect(ctx, inv.x - inv.w/2, inv.y - inv.h/2, inv.w, inv.h, 8);
ctx.fill(); ctx.stroke();
// Letter badge — top-left, full row
const letters = ['A','B','C','D'];
const badgeSize = 13;
ctx.fillStyle = color;
ctx.font = `bold ${badgeSize}px 'Press Start 2P', monospace`;
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
ctx.fillText(letters[i], inv.x - inv.w/2 + 8, inv.y - inv.h/2 + 7);
// Answer text — wrap inside block, below badge row
const fz = inv.fontSize || 12;
const lh = inv.lineH || 17;
const maxW = inv.textAreaW || (inv.w - 28);
ctx.fillStyle = '#e8edf5';
ctx.font = `${fz}px 'DM Sans', sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// Text block starts after badge (20px) + padding (4px)
const textBlockTop = inv.y - inv.h/2 + 24;
const textBlockH = inv.h - 28;
wrapText(ctx, inv.text, inv.x, textBlockTop + textBlockH/2, maxW, lh);
});
// ── SHIP ──
gs.ship.x += (gs.ship.targetX - gs.ship.x) * 0.18;
const sx = gs.ship.x, sy = gs.ship.y;
// Ship body
ctx.fillStyle = '#00c8ff';
ctx.beginPath();
ctx.moveTo(sx, sy - 16);
ctx.lineTo(sx - 14, sy + 12);
ctx.lineTo(sx + 14, sy + 12);
ctx.closePath();
ctx.fill();
// Engine glow
ctx.fillStyle = `rgba(0,200,255,${0.3 + Math.sin(ts*0.01)*0.2})`;
ctx.beginPath();
ctx.arc(sx, sy + 14, 8, 0, Math.PI*2);
ctx.fill();
// Cockpit
ctx.fillStyle = '#fff';
ctx.beginPath();
ctx.arc(sx, sy - 2, 4, 0, Math.PI*2);
ctx.fill();
// ── BULLETS ──
gs.bullets = gs.bullets.filter(b => b.y > -20);
gs.bullets.forEach(b => {
b.x += b.vx;
b.y += b.vy;
ctx.fillStyle = '#ffcc00';
ctx.shadowColor = '#ffcc00';
ctx.shadowBlur = 8;
ctx.fillRect(b.x - 2, b.y, 4, 16);
ctx.shadowBlur = 0;
// Collision check
if (!gs.answered) {
gs.invaders.forEach((inv, i) => {
if (inv.hit) return;
if (b.x > inv.x - inv.w/2 && b.x < inv.x + inv.w/2 &&
b.y > inv.y - inv.h/2 && b.y < inv.y + inv.h/2) {
b.y = -999; // destroy bullet
if (!gs.answered) {
gs.answered = true;
ws.send(JSON.stringify({type: 'answer', choice: i}));
spawnParticles(inv.x, inv.y, COL_COLORS[i], 20);
inv.hit = true;
playBeep(i === gs.correctAnswer ? 1046 : 220, 0.3, 0.3);
}
}
});
}
});
// ── PARTICLES ──
gs.particles = gs.particles.filter(p => p.life > 0);
gs.particles.forEach(p => {
p.x += p.vx; p.y += p.vy; p.vy += 0.3; p.life--;
const a = p.life / p.maxLife;
ctx.fillStyle = p.color + Math.floor(a*255).toString(16).padStart(2,'0');
ctx.fillRect(p.x, p.y, p.s, p.s);
});
// ── GROUND LINE ──
ctx.strokeStyle = 'rgba(0,200,255,0.2)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, H - 90);
ctx.lineTo(W, H - 90);
ctx.stroke();
gameLoop = requestAnimationFrame(gameFrame);
}
function spawnParticles(x, y, color, count) {
for (let i = 0; i < count; i++) {
gameState.particles.push({
x, y,
vx: (Math.random()-0.5)*8,
vy: (Math.random()-0.5)*8,
s: Math.random()*4+2,
life: 30+Math.random()*20,
maxLife: 50,
color
});
}
}
function showFlash(msg) {
stopGame();
const flash = document.getElementById('result-flash');
document.getElementById('flash-icon').textContent = msg.correct ? '🎯' : '💥';
document.getElementById('flash-title').textContent = msg.correct ? t('correct') : t('wrong');
document.getElementById('flash-pts').textContent = msg.correct ? t('pts',{n:msg.points}) : t('zero');
document.getElementById('flash-total').textContent = t('total',{n:msg.total.toLocaleString()});
flash.classList.add('active');
flash.classList.add('pop');
show('game-screen');
}
function showPodium(leaderboard) {
const medals = ['🥇','🥈','🥉'];
const rc = ['r1','r2','r3'];
document.getElementById('pod-title').textContent = t('gameover');
const list = document.getElementById('podium-list');
list.innerHTML = '';
leaderboard.slice(0,10).forEach((p,i) => {
const div = document.createElement('div');
div.className = `podium-item ${rc[i]||''}`;
const isMe = p.name === myName;
div.innerHTML = `
<span class="p-rank">${i<3?medals[i]:i+1}</span>
<span class="p-name">${p.name}${isMe?' 👈':''}</span>
<span class="p-score">${p.score.toLocaleString()}</span>`;
list.appendChild(div);
});
show('podium-screen');
}
// ── AUDIO ────────────────────────────────────────────────────────────────────
let audioCtx;
function playBeep(freq, vol, dur) {
try {
if (!audioCtx) audioCtx = new (window.AudioContext||window.webkitAudioContext)();
const o = audioCtx.createOscillator();
const g = audioCtx.createGain();
o.connect(g); g.connect(audioCtx.destination);
o.type = 'square';
o.frequency.value = freq;
g.gain.setValueAtTime(vol, audioCtx.currentTime);
g.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + dur);
o.start(); o.stop(audioCtx.currentTime + dur);
} catch(e) {}
}
// ── UTILS ────────────────────────────────────────────────────────────────────
function wrapText(ctx, text, x, centerY, maxW, lineH) {
const words = text.split(' ');
let line = '';
const lines = [];
words.forEach(w => {
const test = line ? line + ' ' + w : w;
if (ctx.measureText(test).width > maxW && line) {
lines.push(line); line = w;
} else line = test;
});
lines.push(line);
const totalH = lines.length * lineH;
const startY = centerY - totalH / 2 + lineH / 2;
lines.forEach((l, i) => ctx.fillText(l, x, startY + i * lineH));
}
function roundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x+r, y);
ctx.lineTo(x+w-r, y);
ctx.quadraticCurveTo(x+w, y, x+w, y+r);
ctx.lineTo(x+w, y+h-r);
ctx.quadraticCurveTo(x+w, y+h, x+w-r, y+h);
ctx.lineTo(x+r, y+h);
ctx.quadraticCurveTo(x, y+h, x, y+h-r);
ctx.lineTo(x, y+r);
ctx.quadraticCurveTo(x, y, x+r, y);
ctx.closePath();
}
function hexToRgb(hex) {
const r = parseInt(hex.slice(1,3),16);
const g = parseInt(hex.slice(3,5),16);
const b = parseInt(hex.slice(5,7),16);
return `${r},${g},${b}`;
}
connectWS();
function exitGame() {
if (ws) { ws.onclose = null; ws.close(); }
myName = ''; myScore = 0; myDept = '';
show('lang-screen');
connectWS();
}
</script>
</body>
</html>
+325
View File
@@ -0,0 +1,325 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NI Quiz · Historial de Scores</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Syne:wght@700;800&family=DM+Sans:wght@400;500&display=swap');
*{margin:0;padding:0;box-sizing:border-box;}
:root{
--bg:#0a0e1a;--surface:#111827;--surface2:#1a2235;
--accent:#00c8ff;--green:#10b981;--amber:#f59e0b;--purple:#7c3aed;
--text:#e8edf5;--muted:#6b7a99;--border:rgba(255,255,255,0.08);
}
html,body{min-height:100vh;background:var(--bg);color:var(--text);font-family:'DM Sans',sans-serif;}
.grid-bg{position:fixed;inset:0;background-image:linear-gradient(rgba(0,200,255,0.03) 1px,transparent 1px),linear-gradient(90deg,rgba(0,200,255,0.03) 1px,transparent 1px);background-size:40px 40px;pointer-events:none;}
header{padding:2rem 2rem 1rem;border-bottom:1px solid var(--border);position:relative;z-index:1;}
header h1{font-family:'Syne',sans-serif;font-size:1.8rem;color:var(--accent);}
header p{font-size:.85rem;color:var(--muted);margin-top:.2rem;}
.nav-links{margin-top:.8rem;display:flex;gap:.8rem;}
.nav-links a{font-size:.8rem;color:var(--muted);text-decoration:none;padding:.25rem .7rem;border:1px solid var(--border);border-radius:6px;}
.nav-links a:hover{color:var(--text);}
main{padding:2rem;max-width:900px;margin:0 auto;position:relative;z-index:1;}
#loading{text-align:center;color:var(--muted);padding:3rem;font-size:.9rem;}
#empty{display:none;text-align:center;color:var(--muted);padding:3rem;}
#empty .icon{font-size:3rem;margin-bottom:1rem;}
/* ALL-TIME TOP 5 */
.all-time{margin-bottom:2.5rem;}
.section-title{font-family:'Syne',sans-serif;font-size:1rem;margin-bottom:1rem;display:flex;align-items:center;gap:.6rem;}
.section-title span{font-size:.7rem;letter-spacing:.12em;text-transform:uppercase;color:var(--muted);}
.top-row{display:flex;gap:.75rem;flex-wrap:wrap;}
.top-card{
background:var(--surface);border:1px solid var(--border);border-radius:12px;
padding:.9rem 1.1rem;display:flex;align-items:center;gap:.8rem;
min-width:160px;flex:1;
}
.top-card.gold{border-color:rgba(245,158,11,.5);background:rgba(245,158,11,.05);}
.top-card.silver{border-color:rgba(200,200,200,.3);}
.top-card.bronze{border-color:rgba(180,120,60,.3);}
.top-medal{font-size:1.4rem;}
.top-info{flex:1;}
.top-name{font-weight:500;font-size:.9rem;}
.top-score{font-family:'Syne',sans-serif;font-size:1.1rem;color:var(--accent);font-weight:700;}
.top-sessions{font-size:.7rem;color:var(--muted);}
/* SESSIONS */
.session-card{
background:var(--surface);border:1px solid var(--border);border-radius:14px;
margin-bottom:1rem;overflow:hidden;
}
.session-header{
padding:1rem 1.2rem;display:flex;align-items:center;justify-content:space-between;
cursor:pointer;
}
.session-header:hover{background:var(--surface2);}
.session-meta{display:flex;align-items:center;gap:1rem;}
.session-id{font-size:.7rem;letter-spacing:.1em;color:var(--muted);font-family:'Syne',sans-serif;}
.session-date{font-size:.85rem;font-weight:500;}
.session-badge{
padding:.2rem .6rem;border-radius:100px;font-size:.7rem;
background:rgba(0,200,255,.08);border:1px solid rgba(0,200,255,.2);color:var(--accent);
}
.chevron{color:var(--muted);transition:transform .2s;font-size:1rem;}
.session-body{display:none;border-top:1px solid var(--border);}
.session-body.open{display:block;}
.scores-table{width:100%;border-collapse:collapse;}
.scores-table th{
text-align:left;font-size:.7rem;letter-spacing:.12em;text-transform:uppercase;
color:var(--muted);padding:.7rem 1.2rem;border-bottom:1px solid var(--border);
}
.scores-table td{padding:.65rem 1.2rem;font-size:.85rem;border-bottom:1px solid rgba(255,255,255,.03);}
.scores-table tr:last-child td{border:none;}
.rank-cell{font-family:'Syne',sans-serif;font-weight:700;color:var(--muted);}
.score-cell{font-family:'Syne',sans-serif;color:var(--accent);font-weight:700;}
.medal-cell{font-size:1.1rem;}
/* TABS */
.tabs{display:flex;gap:.5rem;margin-bottom:1.5rem;flex-wrap:wrap;}
.tab-btn{
padding:.4rem 1rem;border-radius:100px;font-size:.78rem;font-weight:500;
border:1px solid var(--border);background:transparent;color:var(--muted);
cursor:pointer;transition:all .15s;
}
.tab-btn.active{background:var(--accent);border-color:var(--accent);color:#000;font-weight:700;}
.tab-btn:hover:not(.active){border-color:var(--accent);color:var(--accent);}
.tab-panel{display:none;}.tab-panel.active{display:block;}
/* DEPT SECTION */
.dept-block{margin-bottom:1.5rem;}
.dept-header{
display:flex;align-items:center;gap:.7rem;
padding:.6rem .9rem;border-radius:10px 10px 0 0;
background:var(--surface2);border:1px solid var(--border);border-bottom:none;
}
.dept-dot{width:10px;height:10px;border-radius:50%;}
.dept-name{font-family:'Syne',sans-serif;font-size:.95rem;font-weight:700;}
.dept-count{font-size:.75rem;color:var(--muted);}
.dept-table{width:100%;border-collapse:collapse;background:var(--surface);border:1px solid var(--border);border-radius:0 0 10px 10px;overflow:hidden;}
.dept-table td{padding:.6rem 1rem;font-size:.85rem;border-bottom:1px solid rgba(255,255,255,.03);}
.dept-table tr:last-child td{border:none;}
.dept-rank{font-family:'Syne',sans-serif;font-weight:700;color:var(--muted);width:36px;}
.dept-score{font-family:'Syne',sans-serif;color:var(--accent);font-weight:700;text-align:right;}
</style>
</head>
<body>
<div class="grid-bg"></div>
<header>
<h1>🌐 NI Quiz · Historial</h1>
<p>Network Intelligence · LCPR — Scores guardados por sesión</p>
<div class="nav-links">
<a href="/host">← Host Panel</a>
<a href="/">Jugar</a>
</div>
</header>
<main>
<div id="loading">Cargando historial...</div>
<div id="empty"><div class="icon">📭</div><p>Aún no hay sesiones guardadas.<br>Los scores se guardan al terminar el juego.</p></div>
<div id="content" style="display:none;">
<div class="all-time">
<div class="section-title">🏆 <span>Top jugadores — todas las sesiones</span></div>
<div class="top-row" id="top-row"></div>
</div>
<!-- TABS -->
<div class="tabs">
<button class="tab-btn active" onclick="switchTab('global')">🌐 Global</button>
<button class="tab-btn" onclick="switchTab('dept')">🏢 Por departamento</button>
<button class="tab-btn" onclick="switchTab('sessions')">📋 Sesiones</button>
</div>
<!-- GLOBAL TAB -->
<div class="tab-panel active" id="tab-global">
<div class="section-title">🥇 <span>Ranking global — mejor score histórico</span></div>
<div id="global-list"></div>
</div>
<!-- DEPT TAB -->
<div class="tab-panel" id="tab-dept">
<div class="section-title">🏢 <span>Ranking por departamento</span></div>
<div id="dept-list"></div>
</div>
<!-- SESSIONS TAB -->
<div class="tab-panel" id="tab-sessions">
<div class="section-title">📋 <span>Sesiones recientes</span></div>
<div id="sessions-list"></div>
</div>
</div>
</main>
<script>
const medals = ['🥇','🥈','🥉'];
const rankClasses = ['gold','silver','bronze'];
const DEPT_COLORS = [
'#00c8ff','#cc44ff','#00ff88','#ffcc00','#ff6b35','#5b9cf6','#f472b6','#34d399'
];
function switchTab(name) {
document.querySelectorAll('.tab-btn').forEach((b,i) => {
b.classList.toggle('active', ['global','dept','sessions'][i] === name);
});
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
document.getElementById('tab-' + name).classList.add('active');
}
async function load() {
const res = await fetch('/api/scores');
const data = await res.json();
document.getElementById('loading').style.display = 'none';
if (!data.length) {
document.getElementById('empty').style.display = 'block';
return;
}
document.getElementById('content').style.display = 'block';
// ── BUILD PLAYER MAP ──
// allScores[name] = { best, sessions, dept }
const allScores = {};
data.forEach(s => {
s.players.forEach(p => {
const key = p.player;
if (!allScores[key]) {
allScores[key] = { best: p.score, sessions: 1, dept: p.dept || 'Sin departamento' };
} else {
if (p.score > allScores[key].best) allScores[key].best = p.score;
allScores[key].sessions++;
if (p.dept) allScores[key].dept = p.dept; // keep most recent dept
}
});
});
const sorted = Object.entries(allScores).sort((a,b) => b[1].best - a[1].best);
// ── TOP 5 CARDS ──
const topRow = document.getElementById('top-row');
sorted.slice(0, 5).forEach(([name, info], i) => {
const card = document.createElement('div');
card.className = `top-card ${rankClasses[i] || ''}`;
card.innerHTML = `
<div class="top-medal">${i < 3 ? medals[i] : `#${i+1}`}</div>
<div class="top-info">
<div class="top-name">${name}</div>
<div class="top-score">${info.best.toLocaleString()} pts</div>
<div class="top-sessions">${info.dept || '—'} · ${info.sessions} sesión${info.sessions > 1 ? 'es' : ''}</div>
</div>`;
topRow.appendChild(card);
});
// ── GLOBAL RANKING TABLE ──
const globalList = document.getElementById('global-list');
const globalTable = document.createElement('table');
globalTable.className = 'scores-table';
globalTable.style.cssText = 'background:var(--surface);border:1px solid var(--border);border-radius:10px;overflow:hidden;';
globalTable.innerHTML = `
<thead><tr>
<th></th><th>Jugador</th><th>Departamento</th><th>Mejor score</th><th>Sesiones</th>
</tr></thead>
<tbody>${sorted.map(([ name, info], i) => `
<tr>
<td class="medal-cell">${i < 3 ? medals[i] : `<span style="font-family:'Syne',sans-serif;color:var(--muted)">#${i+1}</span>`}</td>
<td style="font-weight:500">${name}</td>
<td style="font-size:.78rem;color:var(--muted)">${info.dept || '—'}</td>
<td class="score-cell">${info.best.toLocaleString()}</td>
<td style="font-size:.78rem;color:var(--muted)">${info.sessions}</td>
</tr>`).join('')}
</tbody>`;
globalList.appendChild(globalTable);
// ── DEPT RANKING ──
// Group players by dept, pick best score per player, sum for dept total
const depts = {};
sorted.forEach(([name, info]) => {
const d = info.dept || 'Sin departamento';
if (!depts[d]) depts[d] = [];
depts[d].push({ name, best: info.best, sessions: info.sessions });
});
// Sort depts by their top player's score
const deptsSorted = Object.entries(depts).sort((a, b) => b[1][0].best - a[1][0].best);
const deptList = document.getElementById('dept-list');
deptsSorted.forEach(([dept, players], di) => {
const color = DEPT_COLORS[di % DEPT_COLORS.length];
const block = document.createElement('div');
block.className = 'dept-block';
block.innerHTML = `
<div class="dept-header">
<div class="dept-dot" style="background:${color}"></div>
<div class="dept-name">${dept}</div>
<div class="dept-count">${players.length} jugador${players.length > 1 ? 'es' : ''}</div>
</div>
<table class="dept-table">
<tbody>${players.map((p, i) => `
<tr>
<td class="dept-rank">${i < 3 ? medals[i] : '#' + (i+1)}</td>
<td style="font-weight:500">${p.name}</td>
<td class="dept-score">${p.best.toLocaleString()} pts</td>
</tr>`).join('')}
</tbody>
</table>`;
deptList.appendChild(block);
});
// ── SESSIONS ──
const list = document.getElementById('sessions-list');
data.forEach(session => {
const date = new Date(session.started_at + 'Z').toLocaleString('es-PR', {
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
});
const winner = session.players[0];
const card = document.createElement('div');
card.className = 'session-card';
card.innerHTML = `
<div class="session-header" onclick="toggle(this)">
<div class="session-meta">
<span class="session-id">SESIÓN #${session.id}</span>
<span class="session-date">${date}</span>
${winner ? `<span class="session-badge">🥇 ${winner.player} · ${winner.score.toLocaleString()} pts</span>` : ''}
</div>
<div style="display:flex;align-items:center;gap:.8rem;">
<span style="font-size:.75rem;color:var(--muted);">${session.total_players} jugadores</span>
<span class="chevron">▼</span>
</div>
</div>
<div class="session-body">
<table class="scores-table">
<thead><tr>
<th></th><th>Jugador</th><th>Departamento</th><th>Puntaje</th>
</tr></thead>
<tbody>${session.players.map((p,i) => `
<tr>
<td class="medal-cell">${i < 3 ? medals[i] : ''}</td>
<td>${p.player}</td>
<td style="font-size:.78rem;color:var(--muted)">${p.dept || '—'}</td>
<td class="score-cell">${p.score.toLocaleString()}</td>
</tr>`).join('')}
</tbody>
</table>
</div>`;
list.appendChild(card);
});
}
function toggle(header) {
const body = header.nextElementSibling;
const chevron = header.querySelector('.chevron');
body.classList.toggle('open');
chevron.style.transform = body.classList.contains('open') ? 'rotate(180deg)' : '';
}
load();
</script>
</body>
</html>