Initial commit
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
.env
|
||||||
|
*.log
|
||||||
|
__pycache__/
|
||||||
|
node_modules/
|
||||||
|
venv/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
@@ -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"]
|
||||||
@@ -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 |
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
fastapi==0.111.0
|
||||||
|
uvicorn[standard]==0.29.0
|
||||||
|
jinja2==3.1.4
|
||||||
|
websockets==12.0
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,763 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
|
<title>NI Quiz · Arcade</title>
|
||||||
|
<style>
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&family=DM+Sans:wght@400;500&display=swap');
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent;}
|
||||||
|
:root{
|
||||||
|
--bg:#000510;--surface:#0a0e1a;--surface2:#111827;
|
||||||
|
--accent:#00c8ff;--green:#00ff88;--red:#ff3355;--amber:#ffcc00;--purple:#cc44ff;
|
||||||
|
--text:#e8edf5;--muted:#4a5568;--border:rgba(255,255,255,0.08);
|
||||||
|
--pixel:'Press Start 2P',monospace;
|
||||||
|
}
|
||||||
|
html,body{width:100%;height:100%;background:var(--bg);color:var(--text);font-family:'DM Sans',sans-serif;overflow:hidden;touch-action:none;}
|
||||||
|
|
||||||
|
/* SCANLINES */
|
||||||
|
body::after{content:'';position:fixed;inset:0;background:repeating-linear-gradient(0deg,transparent,transparent 2px,rgba(0,0,0,0.15) 2px,rgba(0,0,0,0.15) 4px);pointer-events:none;z-index:9999;}
|
||||||
|
|
||||||
|
.screen{display:none;flex-direction:column;align-items:center;justify-content:center;height:100vh;padding:1.5rem;text-align:center;}
|
||||||
|
.screen.active{display:flex;}
|
||||||
|
#lobby-screen{height:auto;min-height:100vh;overflow-y:auto;padding:2rem 1.5rem;justify-content:center;}
|
||||||
|
|
||||||
|
/* ── LANGUAGE SELECT ── */
|
||||||
|
#lang-screen .title{font-family:var(--pixel);font-size:1.2rem;color:var(--accent);margin-bottom:.3rem;text-shadow:0 0 20px var(--accent);line-height:1.8;}
|
||||||
|
#lang-screen .subtitle{font-family:var(--pixel);font-size:.45rem;color:var(--muted);margin-bottom:2.5rem;letter-spacing:.1em;}
|
||||||
|
.lang-btn{
|
||||||
|
display:block;width:100%;max-width:280px;margin:.6rem auto;
|
||||||
|
padding:1rem;border-radius:4px;
|
||||||
|
font-family:var(--pixel);font-size:.65rem;letter-spacing:.1em;
|
||||||
|
cursor:pointer;border:2px solid;transition:all .15s;
|
||||||
|
}
|
||||||
|
.lang-btn.en{border-color:var(--accent);color:var(--accent);background:rgba(0,200,255,.05);}
|
||||||
|
.lang-btn.es{border-color:var(--amber);color:var(--amber);background:rgba(255,204,0,.05);}
|
||||||
|
.lang-btn:active{transform:scale(.96);}
|
||||||
|
.lang-btn:hover{filter:brightness(1.3);}
|
||||||
|
.pixel-stars{position:fixed;inset:0;pointer-events:none;}
|
||||||
|
|
||||||
|
/* ── JOIN ── */
|
||||||
|
#join-screen .logo{font-family:var(--pixel);font-size:1rem;color:var(--accent);margin-bottom:.3rem;text-shadow:0 0 20px var(--accent);line-height:1.8;}
|
||||||
|
#join-screen .sub{font-size:.75rem;color:var(--muted);margin-bottom:2rem;}
|
||||||
|
.field-wrap{width:100%;max-width:320px;margin-bottom:.8rem;text-align:left;}
|
||||||
|
.field-wrap label{font-family:var(--pixel);font-size:.45rem;color:var(--muted);letter-spacing:.1em;display:block;margin-bottom:.4rem;}
|
||||||
|
.field-wrap input, .field-wrap select{
|
||||||
|
width:100%;padding:.85rem 1rem;
|
||||||
|
background:var(--surface2);border:1px solid var(--border);border-radius:4px;
|
||||||
|
color:var(--text);font-size:.9rem;font-family:'DM Sans',sans-serif;
|
||||||
|
outline:none;
|
||||||
|
}
|
||||||
|
.field-wrap input:focus,.field-wrap select:focus{border-color:var(--accent);}
|
||||||
|
.field-wrap input::placeholder{color:var(--muted);}
|
||||||
|
.field-wrap select option{background:var(--surface2);}
|
||||||
|
#join-btn{
|
||||||
|
width:100%;max-width:320px;padding:1rem;margin-top:.5rem;
|
||||||
|
background:var(--accent);border:none;border-radius:4px;
|
||||||
|
color:#000;font-family:var(--pixel);font-size:.6rem;letter-spacing:.1em;
|
||||||
|
cursor:pointer;
|
||||||
|
}
|
||||||
|
#join-btn:active{opacity:.85;transform:scale(.97);}
|
||||||
|
|
||||||
|
/* ── LOBBY ── */
|
||||||
|
#lobby-screen{
|
||||||
|
justify-content:center;
|
||||||
|
min-height:100vh;
|
||||||
|
height:auto;
|
||||||
|
padding:2rem 1.5rem;
|
||||||
|
overflow-y:auto;
|
||||||
|
}
|
||||||
|
#lobby-screen .big{font-size:3.5rem;margin-bottom:1rem;animation:float 2s ease-in-out infinite;}
|
||||||
|
#lobby-screen .pname{font-family:var(--pixel);font-size:.7rem;color:var(--accent);margin:.5rem 0;}
|
||||||
|
#lobby-screen .wait{font-family:var(--pixel);font-size:.4rem;color:var(--muted);animation:blink 1s step-end infinite;margin-top:1rem;}
|
||||||
|
.exit-btn{
|
||||||
|
margin-top:1.5rem;
|
||||||
|
background:transparent;border:1px solid var(--muted);
|
||||||
|
color:var(--muted);font-family:var(--pixel);font-size:.4rem;
|
||||||
|
letter-spacing:.08em;padding:.6rem 1.4rem;border-radius:4px;
|
||||||
|
cursor:pointer;transition:all .2s;
|
||||||
|
}
|
||||||
|
.exit-btn:hover,.exit-btn:active{border-color:var(--red);color:var(--red);}
|
||||||
|
|
||||||
|
/* ── GAME CANVAS ── */
|
||||||
|
#game-screen{padding:0;position:relative;background:var(--bg);}
|
||||||
|
#game-canvas{display:block;width:100%;height:100vh;}
|
||||||
|
/* ── HUD + BANNER — stacked, no overlap ── */
|
||||||
|
#game-overlay{
|
||||||
|
position:absolute;top:0;left:0;right:0;
|
||||||
|
pointer-events:none;
|
||||||
|
display:flex;flex-direction:column;
|
||||||
|
padding:8px 12px 0;
|
||||||
|
gap:6px;
|
||||||
|
}
|
||||||
|
#score-hud{
|
||||||
|
display:flex;justify-content:space-between;align-items:center;
|
||||||
|
}
|
||||||
|
#score-hud .hud-score{font-family:var(--pixel);font-size:.5rem;color:var(--accent);}
|
||||||
|
#score-hud .hud-timer{font-family:var(--pixel);font-size:.75rem;color:var(--amber);}
|
||||||
|
#score-hud .hud-q{font-family:var(--pixel);font-size:.4rem;color:var(--muted);}
|
||||||
|
#question-banner{
|
||||||
|
font-family:'DM Sans',sans-serif;font-size:.88rem;font-weight:500;
|
||||||
|
color:var(--text);line-height:1.5;text-align:center;
|
||||||
|
background:rgba(0,5,16,.9);border:1px solid var(--border);
|
||||||
|
border-radius:6px;padding:.55rem .9rem;
|
||||||
|
/* clamp so it never grows past ~25% of viewport */
|
||||||
|
max-height:28vh;overflow:hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── RESULT FLASH ── */
|
||||||
|
#result-flash{
|
||||||
|
position:absolute;inset:0;display:none;
|
||||||
|
flex-direction:column;align-items:center;justify-content:center;
|
||||||
|
background:rgba(0,5,16,.92);z-index:50;
|
||||||
|
}
|
||||||
|
#result-flash.active{display:flex;}
|
||||||
|
#flash-icon{font-size:4rem;margin-bottom:.5rem;}
|
||||||
|
#flash-title{font-family:var(--pixel);font-size:.9rem;margin-bottom:.4rem;}
|
||||||
|
#flash-pts{font-family:var(--pixel);font-size:1.4rem;color:var(--accent);}
|
||||||
|
#flash-total{font-family:var(--pixel);font-size:.45rem;color:var(--muted);margin-top:.3rem;}
|
||||||
|
|
||||||
|
/* ── RESULTS / FUNFACT ── */
|
||||||
|
#results-screen{background:var(--bg);}
|
||||||
|
#results-screen .r-label{font-family:var(--pixel);font-size:.45rem;color:var(--green);letter-spacing:.1em;margin-bottom:1rem;}
|
||||||
|
#results-screen .r-fact{
|
||||||
|
background:var(--surface2);border:1px solid var(--border);border-left:3px solid var(--accent);
|
||||||
|
border-radius:0 8px 8px 0;padding:1rem 1.2rem;
|
||||||
|
font-size:.88rem;line-height:1.65;text-align:left;max-width:360px;margin:0 auto 1rem;
|
||||||
|
}
|
||||||
|
#results-screen .r-score{font-family:var(--pixel);font-size:.65rem;color:var(--accent);margin-bottom:.5rem;}
|
||||||
|
#results-screen .r-wait{font-family:var(--pixel);font-size:.38rem;color:var(--muted);animation:blink 1s step-end infinite;}
|
||||||
|
|
||||||
|
/* ── PODIUM ── */
|
||||||
|
#podium-screen{background:var(--bg);height:auto;min-height:100vh;overflow-y:auto;padding:2rem 1.5rem;justify-content:center;}
|
||||||
|
#podium-screen h2{font-family:var(--pixel);font-size:.85rem;color:var(--amber);margin-bottom:.3rem;text-shadow:0 0 20px var(--amber);}
|
||||||
|
#podium-screen .sub{font-size:.75rem;color:var(--muted);margin-bottom:1.5rem;}
|
||||||
|
.podium-list{width:100%;max-width:360px;}
|
||||||
|
.podium-item{
|
||||||
|
display:flex;align-items:center;gap:.8rem;
|
||||||
|
background:var(--surface2);border:1px solid var(--border);
|
||||||
|
border-radius:4px;padding:.7rem 1rem;margin-bottom:.5rem;
|
||||||
|
}
|
||||||
|
.podium-item.r1{border-color:var(--amber);background:rgba(255,204,0,.06);}
|
||||||
|
.podium-item.r2{border-color:rgba(200,200,200,.4);}
|
||||||
|
.podium-item.r3{border-color:rgba(180,120,60,.4);}
|
||||||
|
.p-rank{font-family:var(--pixel);font-size:.7rem;min-width:28px;}
|
||||||
|
.p-name{flex:1;font-size:.85rem;}
|
||||||
|
.p-score{font-family:var(--pixel);font-size:.6rem;color:var(--accent);}
|
||||||
|
|
||||||
|
@keyframes float{0%,100%{transform:translateY(0)}50%{transform:translateY(-8px)}}
|
||||||
|
@keyframes blink{0%,100%{opacity:1}50%{opacity:0}}
|
||||||
|
@keyframes popIn{from{transform:scale(.7);opacity:0}to{transform:scale(1);opacity:1}}
|
||||||
|
.pop{animation:popIn .3s cubic-bezier(.34,1.56,.64,1) both;}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- STARS BG -->
|
||||||
|
<canvas class="pixel-stars" id="stars-canvas"></canvas>
|
||||||
|
|
||||||
|
<!-- LANGUAGE SELECT -->
|
||||||
|
<div class="screen active" id="lang-screen">
|
||||||
|
<div class="title">🌐 NI QUIZ</div>
|
||||||
|
<div class="subtitle">NETWORK INTELLIGENCE · LCPR</div>
|
||||||
|
<button class="lang-btn en" onclick="setLang('en')">🇺🇸 ENGLISH</button>
|
||||||
|
<button class="lang-btn es" onclick="setLang('es')">🇵🇷 ESPAÑOL</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- JOIN -->
|
||||||
|
<div class="screen" id="join-screen">
|
||||||
|
<div class="logo">🚀 NI QUIZ</div>
|
||||||
|
<div class="sub" id="join-sub">Network Intelligence · LCPR</div>
|
||||||
|
<div class="field-wrap">
|
||||||
|
<label id="lbl-fname">FIRST NAME</label>
|
||||||
|
<input id="inp-fname" type="text" maxlength="20" autocomplete="off" autocorrect="off" autocapitalize="words">
|
||||||
|
</div>
|
||||||
|
<div class="field-wrap">
|
||||||
|
<label id="lbl-lname">LAST NAME</label>
|
||||||
|
<input id="inp-lname" type="text" maxlength="20" autocomplete="off" autocorrect="off" autocapitalize="words">
|
||||||
|
</div>
|
||||||
|
<div class="field-wrap">
|
||||||
|
<label id="lbl-dept">DEPARTMENT</label>
|
||||||
|
<select id="inp-dept">
|
||||||
|
<option value="">-- Select --</option>
|
||||||
|
<option>Network Intelligence</option>
|
||||||
|
<option>NOC</option>
|
||||||
|
<option>Network Engineering</option>
|
||||||
|
<option>Field Operations</option>
|
||||||
|
<option>IT</option>
|
||||||
|
<option>Planning</option>
|
||||||
|
<option>Other / Otro</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button id="join-btn">▶ ENTER GAME</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- LOBBY -->
|
||||||
|
<div class="screen" id="lobby-screen">
|
||||||
|
<div class="big">🛸</div>
|
||||||
|
<div class="pname" id="lobby-name"></div>
|
||||||
|
<div style="font-family:var(--pixel);font-size:.4rem;color:var(--muted);margin-bottom:.5rem;" id="lobby-dept"></div>
|
||||||
|
<div class="wait" id="lobby-wait">WAITING FOR HOST...</div>
|
||||||
|
<button class="exit-btn" id="exit-btn" onclick="exitGame()">✕ salir</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- GAME -->
|
||||||
|
<div class="screen" id="game-screen">
|
||||||
|
<canvas id="game-canvas"></canvas>
|
||||||
|
<div id="game-overlay">
|
||||||
|
<div id="score-hud">
|
||||||
|
<span class="hud-score" id="hud-score">000000</span>
|
||||||
|
<span class="hud-timer" id="hud-timer">15</span>
|
||||||
|
<span class="hud-q" id="hud-q">Q1/5</span>
|
||||||
|
</div>
|
||||||
|
<div id="question-banner"></div>
|
||||||
|
</div>
|
||||||
|
<div id="result-flash">
|
||||||
|
<div id="flash-icon"></div>
|
||||||
|
<div id="flash-title"></div>
|
||||||
|
<div id="flash-pts"></div>
|
||||||
|
<div id="flash-total"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- RESULTS -->
|
||||||
|
<div class="screen" id="results-screen">
|
||||||
|
<div class="r-label" id="r-label">✓ CORRECT ANSWER</div>
|
||||||
|
<div class="r-fact" id="r-fact"></div>
|
||||||
|
<div class="r-score" id="r-score"></div>
|
||||||
|
<div class="r-wait" id="r-wait">WAITING FOR HOST...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PODIUM -->
|
||||||
|
<div class="screen" id="podium-screen">
|
||||||
|
<h2 id="pod-title">🏆 GAME OVER</h2>
|
||||||
|
<p class="sub" id="pod-sub">Network Intelligence · LCPR</p>
|
||||||
|
<div class="podium-list" id="podium-list"></div>
|
||||||
|
<button class="exit-btn" onclick="exitGame()">✕ salir</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ── STARS ────────────────────────────────────────────────────────────────────
|
||||||
|
(function() {
|
||||||
|
const c = document.getElementById('stars-canvas');
|
||||||
|
const ctx = c.getContext('2d');
|
||||||
|
let stars = [];
|
||||||
|
function resize() {
|
||||||
|
c.width = window.innerWidth; c.height = window.innerHeight;
|
||||||
|
stars = Array.from({length:80}, () => ({
|
||||||
|
x: Math.random()*c.width, y: Math.random()*c.height,
|
||||||
|
s: Math.random()*1.5+.5, b: Math.random()
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
function draw() {
|
||||||
|
ctx.clearRect(0,0,c.width,c.height);
|
||||||
|
stars.forEach(s => {
|
||||||
|
s.b += .005; if(s.b>1) s.b=0;
|
||||||
|
ctx.fillStyle = `rgba(255,255,255,${Math.abs(Math.sin(s.b*Math.PI))*.6})`;
|
||||||
|
ctx.fillRect(s.x, s.y, s.s, s.s);
|
||||||
|
});
|
||||||
|
requestAnimationFrame(draw);
|
||||||
|
}
|
||||||
|
window.addEventListener('resize', resize);
|
||||||
|
resize(); draw();
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ── I18N ─────────────────────────────────────────────────────────────────────
|
||||||
|
const T = {
|
||||||
|
en: {
|
||||||
|
fname:'FIRST NAME', lname:'LAST NAME', dept:'DEPARTMENT',
|
||||||
|
enter:'▶ ENTER GAME', waiting:'WAITING FOR HOST...',
|
||||||
|
correct:'✓ CORRECT!', wrong:'✗ WRONG!', pts:'+{n} PTS', zero:'+0 PTS',
|
||||||
|
total:'TOTAL: {n} PTS', waithost:'WAITING FOR HOST...',
|
||||||
|
correct_ans:'✓ CORRECT ANSWER', your_score:'YOUR SCORE: {n} PTS',
|
||||||
|
gameover:'🏆 GAME OVER', dept_select:'-- Select --',
|
||||||
|
q_prefix:'Q', of:'/'
|
||||||
|
},
|
||||||
|
es: {
|
||||||
|
fname:'NOMBRE', lname:'APELLIDO', dept:'DEPARTAMENTO',
|
||||||
|
enter:'▶ ENTRAR AL JUEGO', waiting:'ESPERANDO AL HOST...',
|
||||||
|
correct:'✓ ¡CORRECTO!', wrong:'✗ ¡INCORRECTO!', pts:'+{n} PTS', zero:'+0 PTS',
|
||||||
|
total:'TOTAL: {n} PTS', waithost:'ESPERANDO AL HOST...',
|
||||||
|
correct_ans:'✓ RESPUESTA CORRECTA', your_score:'TU PUNTAJE: {n} PTS',
|
||||||
|
gameover:'🏆 ¡JUEGO TERMINADO!', dept_select:'-- Selecciona --',
|
||||||
|
q_prefix:'P', of:'/'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let lang = 'es';
|
||||||
|
function t(key, vars={}) {
|
||||||
|
let s = T[lang][key] || key;
|
||||||
|
Object.entries(vars).forEach(([k,v]) => s = s.replace(`{${k}}`, v));
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLang(l) {
|
||||||
|
lang = l;
|
||||||
|
// Update join labels
|
||||||
|
document.getElementById('lbl-fname').textContent = t('fname');
|
||||||
|
document.getElementById('lbl-lname').textContent = t('lname');
|
||||||
|
document.getElementById('lbl-dept').textContent = t('dept');
|
||||||
|
document.getElementById('join-btn').textContent = t('enter');
|
||||||
|
document.getElementById('inp-dept').options[0].text = t('dept_select');
|
||||||
|
show('join-screen');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SCREENS ──────────────────────────────────────────────────────────────────
|
||||||
|
function show(id) {
|
||||||
|
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
|
||||||
|
document.getElementById(id).classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── WS ───────────────────────────────────────────────────────────────────────
|
||||||
|
const WS_ID = crypto.randomUUID();
|
||||||
|
let ws, myName = '', myScore = 0, myDept = '';
|
||||||
|
|
||||||
|
function connectWS() {
|
||||||
|
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
|
ws = new WebSocket(`${proto}://${location.host}/ws/player/${WS_ID}`);
|
||||||
|
ws.onmessage = e => handleMessage(JSON.parse(e.data));
|
||||||
|
ws.onclose = () => setTimeout(connectWS, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMessage(msg) {
|
||||||
|
if (msg.type === 'joined') {
|
||||||
|
myName = msg.name;
|
||||||
|
document.getElementById('lobby-name').textContent = msg.display;
|
||||||
|
document.getElementById('lobby-dept').textContent = msg.dept;
|
||||||
|
document.getElementById('lobby-wait').textContent = t('waiting');
|
||||||
|
show('lobby-screen');
|
||||||
|
}
|
||||||
|
else if (msg.type === 'phase') {
|
||||||
|
if (msg.phase === 'lobby' && myName) show('lobby-screen');
|
||||||
|
}
|
||||||
|
else if (msg.type === 'question') {
|
||||||
|
startGame(msg);
|
||||||
|
}
|
||||||
|
else if (msg.type === 'answer_result') {
|
||||||
|
showFlash(msg);
|
||||||
|
}
|
||||||
|
else if (msg.type === 'results') {
|
||||||
|
stopGame();
|
||||||
|
myScore = msg.leaderboard.find(p => p.name === myName)?.score ?? myScore;
|
||||||
|
document.getElementById('r-label').textContent = t('correct_ans');
|
||||||
|
document.getElementById('r-fact').textContent = msg.fun_fact;
|
||||||
|
document.getElementById('r-score').textContent = t('your_score', {n: myScore.toLocaleString()});
|
||||||
|
document.getElementById('r-wait').textContent = t('waithost');
|
||||||
|
show('results-screen');
|
||||||
|
}
|
||||||
|
else if (msg.type === 'podium') {
|
||||||
|
stopGame();
|
||||||
|
showPodium(msg.leaderboard);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── JOIN ─────────────────────────────────────────────────────────────────────
|
||||||
|
document.getElementById('join-btn').addEventListener('click', () => {
|
||||||
|
const fn = document.getElementById('inp-fname').value.trim();
|
||||||
|
const ln = document.getElementById('inp-lname').value.trim();
|
||||||
|
const dept = document.getElementById('inp-dept').value;
|
||||||
|
if (!fn || !ln || !dept) {
|
||||||
|
// Shake empty fields
|
||||||
|
[fn ? null : 'inp-fname', ln ? null : 'inp-lname', dept ? null : 'inp-dept']
|
||||||
|
.filter(Boolean).forEach(id => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
el.style.borderColor = 'var(--red)';
|
||||||
|
setTimeout(() => el.style.borderColor = '', 1000);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
myDept = dept;
|
||||||
|
const displayName = `${fn} ${ln}`;
|
||||||
|
ws.send(JSON.stringify({type: 'join', name: `${fn} ${ln}`, dept, display: displayName}));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GAME ENGINE ──────────────────────────────────────────────────────────────
|
||||||
|
const canvas = document.getElementById('game-canvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
let gameLoop = null;
|
||||||
|
let gameState = {
|
||||||
|
ship: {x: 0, targetX: 0, y: 0, w: 36, h: 28},
|
||||||
|
bullets: [],
|
||||||
|
invaders: [], // {x, y, w, h, text, idx, hit, hitTimer, correct}
|
||||||
|
particles: [],
|
||||||
|
answered: false,
|
||||||
|
correctIdx: -1,
|
||||||
|
timeLeft: 15,
|
||||||
|
timerStart: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
function resizeCanvas() {
|
||||||
|
canvas.width = window.innerWidth;
|
||||||
|
canvas.height = window.innerHeight;
|
||||||
|
}
|
||||||
|
window.addEventListener('resize', resizeCanvas);
|
||||||
|
resizeCanvas();
|
||||||
|
|
||||||
|
const COL_COLORS = ['#00c8ff','#cc44ff','#00ff88','#ffcc00'];
|
||||||
|
|
||||||
|
function startGame(msg) {
|
||||||
|
resizeCanvas();
|
||||||
|
const W = canvas.width, H = canvas.height;
|
||||||
|
const colW = W / 4;
|
||||||
|
|
||||||
|
// Invader block sizing — responsive to screen width
|
||||||
|
// On 375px wide: colW=93, invW≈77, invH≈90 (enough for 4-5 lines at 13px)
|
||||||
|
const invW = colW - 12;
|
||||||
|
const invPadX = 20; // space for letter badge on left
|
||||||
|
const textAreaW = invW - invPadX - 10;
|
||||||
|
const fontSize = Math.max(12, Math.min(14, W * 0.034)); // 12–14px
|
||||||
|
const lineH = fontSize * 1.4;
|
||||||
|
|
||||||
|
// Pre-measure how many lines each option needs, then set uniform height
|
||||||
|
const measCtx = canvas.getContext('2d');
|
||||||
|
measCtx.font = `${fontSize}px 'DM Sans', sans-serif`;
|
||||||
|
function countLines(text, maxW) {
|
||||||
|
const words = text.split(' ');
|
||||||
|
let line = '', count = 1;
|
||||||
|
words.forEach(w => {
|
||||||
|
const test = line ? line + ' ' + w : w;
|
||||||
|
if (measCtx.measureText(test).width > maxW && line) { count++; line = w; }
|
||||||
|
else line = test;
|
||||||
|
});
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
const maxLines = Math.max(...msg.options.map(o => countLines(o, textAreaW)));
|
||||||
|
const badgeH = 20; // letter badge row
|
||||||
|
const invH = badgeH + maxLines * lineH + 20; // 20px vertical padding
|
||||||
|
|
||||||
|
gameState = {
|
||||||
|
ship: {x: W/2, targetX: W/2, y: H - 120, w: 36, h: 28},
|
||||||
|
bullets: [],
|
||||||
|
invaders: msg.options.map((text, idx) => ({
|
||||||
|
x: colW * idx + colW/2,
|
||||||
|
y: 120,
|
||||||
|
targetY: 120,
|
||||||
|
w: invW,
|
||||||
|
h: invH,
|
||||||
|
text,
|
||||||
|
idx,
|
||||||
|
hit: false,
|
||||||
|
hitTimer: 0,
|
||||||
|
correct: idx === msg.correct_idx_hint,
|
||||||
|
fontSize,
|
||||||
|
lineH,
|
||||||
|
textAreaW,
|
||||||
|
})),
|
||||||
|
particles: [],
|
||||||
|
answered: false,
|
||||||
|
correctIdx: msg.correct_idx_hint ?? -1,
|
||||||
|
timeLeft: msg.time_limit,
|
||||||
|
timerStart: performance.now(),
|
||||||
|
options: msg.options,
|
||||||
|
correctAnswer: msg.correct_idx_hint,
|
||||||
|
W, H, colW,
|
||||||
|
totalTime: msg.time_limit,
|
||||||
|
qNum: msg.number,
|
||||||
|
qTotal: msg.total,
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('question-banner').textContent = msg.q;
|
||||||
|
document.getElementById('hud-q').textContent = `${t('q_prefix')}${msg.number}${t('of')}${msg.total}`;
|
||||||
|
document.getElementById('result-flash').classList.remove('active');
|
||||||
|
|
||||||
|
// Measure overlay height so invaders don't spawn behind it
|
||||||
|
const overlay = document.getElementById('game-overlay');
|
||||||
|
const overlayH = overlay ? overlay.getBoundingClientRect().height : 100;
|
||||||
|
const spawnY = overlayH + invH / 2 + 20; // first visible position just below overlay
|
||||||
|
|
||||||
|
// Invaders start high and slowly descend
|
||||||
|
gameState.invaders.forEach((inv, i) => {
|
||||||
|
inv.y = -invH - i * 25;
|
||||||
|
inv.targetY = spawnY;
|
||||||
|
});
|
||||||
|
|
||||||
|
show('game-screen');
|
||||||
|
if (gameLoop) cancelAnimationFrame(gameLoop);
|
||||||
|
gameLoop = requestAnimationFrame(gameFrame);
|
||||||
|
|
||||||
|
// Touch controls — tap column
|
||||||
|
canvas.ontouchstart = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (gameState.answered) return;
|
||||||
|
const touch = e.touches[0];
|
||||||
|
const col = Math.floor(touch.clientX / gameState.colW);
|
||||||
|
moveAndShoot(col);
|
||||||
|
};
|
||||||
|
canvas.onmousedown = (e) => {
|
||||||
|
if (gameState.answered) return;
|
||||||
|
const col = Math.floor(e.clientX / gameState.colW);
|
||||||
|
moveAndShoot(col);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveAndShoot(col) {
|
||||||
|
const gs = gameState;
|
||||||
|
if (gs.answered) return;
|
||||||
|
const targetX = gs.colW * col + gs.colW / 2;
|
||||||
|
gs.ship.targetX = targetX;
|
||||||
|
// Fire bullet from current position toward that column
|
||||||
|
gs.bullets.push({
|
||||||
|
x: gs.ship.x, y: gs.ship.y - 20,
|
||||||
|
targetCol: col,
|
||||||
|
vx: (targetX - gs.ship.x) * 0.04,
|
||||||
|
vy: -18, w: 4, h: 16,
|
||||||
|
col
|
||||||
|
});
|
||||||
|
playBeep(880, 0.08, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopGame() {
|
||||||
|
if (gameLoop) { cancelAnimationFrame(gameLoop); gameLoop = null; }
|
||||||
|
canvas.ontouchstart = null;
|
||||||
|
canvas.onmousedown = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function gameFrame(ts) {
|
||||||
|
const gs = gameState;
|
||||||
|
const W = gs.W, H = gs.H;
|
||||||
|
ctx.clearRect(0, 0, W, H);
|
||||||
|
|
||||||
|
// ── BACKGROUND ──
|
||||||
|
ctx.fillStyle = '#000510';
|
||||||
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
|
||||||
|
// Column dividers
|
||||||
|
for (let i = 1; i < 4; i++) {
|
||||||
|
ctx.strokeStyle = 'rgba(255,255,255,0.04)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(gs.colW * i, 0);
|
||||||
|
ctx.lineTo(gs.colW * i, H);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timer update
|
||||||
|
const elapsed = (performance.now() - gs.timerStart) / 1000;
|
||||||
|
const timeLeft = Math.max(0, gs.totalTime - elapsed);
|
||||||
|
document.getElementById('hud-timer').textContent = Math.ceil(timeLeft);
|
||||||
|
document.getElementById('hud-timer').style.color = timeLeft < 5 ? 'var(--red)' : 'var(--amber)';
|
||||||
|
|
||||||
|
// ── INVADERS ──
|
||||||
|
gs.invaders.forEach((inv, i) => {
|
||||||
|
if (inv.hit) {
|
||||||
|
inv.hitTimer--;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Slowly descend after appearing
|
||||||
|
if (!gs.answered) {
|
||||||
|
inv.y += (inv.targetY - inv.y) * 0.08;
|
||||||
|
inv.targetY += 0.25; // drift down slowly
|
||||||
|
}
|
||||||
|
|
||||||
|
const color = COL_COLORS[i];
|
||||||
|
// Box
|
||||||
|
ctx.fillStyle = `rgba(${hexToRgb(color)},0.10)`;
|
||||||
|
ctx.strokeStyle = color;
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
roundRect(ctx, inv.x - inv.w/2, inv.y - inv.h/2, inv.w, inv.h, 8);
|
||||||
|
ctx.fill(); ctx.stroke();
|
||||||
|
|
||||||
|
// Letter badge — top-left, full row
|
||||||
|
const letters = ['A','B','C','D'];
|
||||||
|
const badgeSize = 13;
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.font = `bold ${badgeSize}px 'Press Start 2P', monospace`;
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.textBaseline = 'top';
|
||||||
|
ctx.fillText(letters[i], inv.x - inv.w/2 + 8, inv.y - inv.h/2 + 7);
|
||||||
|
|
||||||
|
// Answer text — wrap inside block, below badge row
|
||||||
|
const fz = inv.fontSize || 12;
|
||||||
|
const lh = inv.lineH || 17;
|
||||||
|
const maxW = inv.textAreaW || (inv.w - 28);
|
||||||
|
ctx.fillStyle = '#e8edf5';
|
||||||
|
ctx.font = `${fz}px 'DM Sans', sans-serif`;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
// Text block starts after badge (20px) + padding (4px)
|
||||||
|
const textBlockTop = inv.y - inv.h/2 + 24;
|
||||||
|
const textBlockH = inv.h - 28;
|
||||||
|
wrapText(ctx, inv.text, inv.x, textBlockTop + textBlockH/2, maxW, lh);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── SHIP ──
|
||||||
|
gs.ship.x += (gs.ship.targetX - gs.ship.x) * 0.18;
|
||||||
|
const sx = gs.ship.x, sy = gs.ship.y;
|
||||||
|
// Ship body
|
||||||
|
ctx.fillStyle = '#00c8ff';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(sx, sy - 16);
|
||||||
|
ctx.lineTo(sx - 14, sy + 12);
|
||||||
|
ctx.lineTo(sx + 14, sy + 12);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
// Engine glow
|
||||||
|
ctx.fillStyle = `rgba(0,200,255,${0.3 + Math.sin(ts*0.01)*0.2})`;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(sx, sy + 14, 8, 0, Math.PI*2);
|
||||||
|
ctx.fill();
|
||||||
|
// Cockpit
|
||||||
|
ctx.fillStyle = '#fff';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(sx, sy - 2, 4, 0, Math.PI*2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// ── BULLETS ──
|
||||||
|
gs.bullets = gs.bullets.filter(b => b.y > -20);
|
||||||
|
gs.bullets.forEach(b => {
|
||||||
|
b.x += b.vx;
|
||||||
|
b.y += b.vy;
|
||||||
|
ctx.fillStyle = '#ffcc00';
|
||||||
|
ctx.shadowColor = '#ffcc00';
|
||||||
|
ctx.shadowBlur = 8;
|
||||||
|
ctx.fillRect(b.x - 2, b.y, 4, 16);
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
|
||||||
|
// Collision check
|
||||||
|
if (!gs.answered) {
|
||||||
|
gs.invaders.forEach((inv, i) => {
|
||||||
|
if (inv.hit) return;
|
||||||
|
if (b.x > inv.x - inv.w/2 && b.x < inv.x + inv.w/2 &&
|
||||||
|
b.y > inv.y - inv.h/2 && b.y < inv.y + inv.h/2) {
|
||||||
|
b.y = -999; // destroy bullet
|
||||||
|
if (!gs.answered) {
|
||||||
|
gs.answered = true;
|
||||||
|
ws.send(JSON.stringify({type: 'answer', choice: i}));
|
||||||
|
spawnParticles(inv.x, inv.y, COL_COLORS[i], 20);
|
||||||
|
inv.hit = true;
|
||||||
|
playBeep(i === gs.correctAnswer ? 1046 : 220, 0.3, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── PARTICLES ──
|
||||||
|
gs.particles = gs.particles.filter(p => p.life > 0);
|
||||||
|
gs.particles.forEach(p => {
|
||||||
|
p.x += p.vx; p.y += p.vy; p.vy += 0.3; p.life--;
|
||||||
|
const a = p.life / p.maxLife;
|
||||||
|
ctx.fillStyle = p.color + Math.floor(a*255).toString(16).padStart(2,'0');
|
||||||
|
ctx.fillRect(p.x, p.y, p.s, p.s);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GROUND LINE ──
|
||||||
|
ctx.strokeStyle = 'rgba(0,200,255,0.2)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, H - 90);
|
||||||
|
ctx.lineTo(W, H - 90);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
gameLoop = requestAnimationFrame(gameFrame);
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnParticles(x, y, color, count) {
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
gameState.particles.push({
|
||||||
|
x, y,
|
||||||
|
vx: (Math.random()-0.5)*8,
|
||||||
|
vy: (Math.random()-0.5)*8,
|
||||||
|
s: Math.random()*4+2,
|
||||||
|
life: 30+Math.random()*20,
|
||||||
|
maxLife: 50,
|
||||||
|
color
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showFlash(msg) {
|
||||||
|
stopGame();
|
||||||
|
const flash = document.getElementById('result-flash');
|
||||||
|
document.getElementById('flash-icon').textContent = msg.correct ? '🎯' : '💥';
|
||||||
|
document.getElementById('flash-title').textContent = msg.correct ? t('correct') : t('wrong');
|
||||||
|
document.getElementById('flash-pts').textContent = msg.correct ? t('pts',{n:msg.points}) : t('zero');
|
||||||
|
document.getElementById('flash-total').textContent = t('total',{n:msg.total.toLocaleString()});
|
||||||
|
flash.classList.add('active');
|
||||||
|
flash.classList.add('pop');
|
||||||
|
show('game-screen');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showPodium(leaderboard) {
|
||||||
|
const medals = ['🥇','🥈','🥉'];
|
||||||
|
const rc = ['r1','r2','r3'];
|
||||||
|
document.getElementById('pod-title').textContent = t('gameover');
|
||||||
|
const list = document.getElementById('podium-list');
|
||||||
|
list.innerHTML = '';
|
||||||
|
leaderboard.slice(0,10).forEach((p,i) => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = `podium-item ${rc[i]||''}`;
|
||||||
|
const isMe = p.name === myName;
|
||||||
|
div.innerHTML = `
|
||||||
|
<span class="p-rank">${i<3?medals[i]:i+1}</span>
|
||||||
|
<span class="p-name">${p.name}${isMe?' 👈':''}</span>
|
||||||
|
<span class="p-score">${p.score.toLocaleString()}</span>`;
|
||||||
|
list.appendChild(div);
|
||||||
|
});
|
||||||
|
show('podium-screen');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AUDIO ────────────────────────────────────────────────────────────────────
|
||||||
|
let audioCtx;
|
||||||
|
function playBeep(freq, vol, dur) {
|
||||||
|
try {
|
||||||
|
if (!audioCtx) audioCtx = new (window.AudioContext||window.webkitAudioContext)();
|
||||||
|
const o = audioCtx.createOscillator();
|
||||||
|
const g = audioCtx.createGain();
|
||||||
|
o.connect(g); g.connect(audioCtx.destination);
|
||||||
|
o.type = 'square';
|
||||||
|
o.frequency.value = freq;
|
||||||
|
g.gain.setValueAtTime(vol, audioCtx.currentTime);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + dur);
|
||||||
|
o.start(); o.stop(audioCtx.currentTime + dur);
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── UTILS ────────────────────────────────────────────────────────────────────
|
||||||
|
function wrapText(ctx, text, x, centerY, maxW, lineH) {
|
||||||
|
const words = text.split(' ');
|
||||||
|
let line = '';
|
||||||
|
const lines = [];
|
||||||
|
words.forEach(w => {
|
||||||
|
const test = line ? line + ' ' + w : w;
|
||||||
|
if (ctx.measureText(test).width > maxW && line) {
|
||||||
|
lines.push(line); line = w;
|
||||||
|
} else line = test;
|
||||||
|
});
|
||||||
|
lines.push(line);
|
||||||
|
const totalH = lines.length * lineH;
|
||||||
|
const startY = centerY - totalH / 2 + lineH / 2;
|
||||||
|
lines.forEach((l, i) => ctx.fillText(l, x, startY + i * lineH));
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundRect(ctx, x, y, w, h, r) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x+r, y);
|
||||||
|
ctx.lineTo(x+w-r, y);
|
||||||
|
ctx.quadraticCurveTo(x+w, y, x+w, y+r);
|
||||||
|
ctx.lineTo(x+w, y+h-r);
|
||||||
|
ctx.quadraticCurveTo(x+w, y+h, x+w-r, y+h);
|
||||||
|
ctx.lineTo(x+r, y+h);
|
||||||
|
ctx.quadraticCurveTo(x, y+h, x, y+h-r);
|
||||||
|
ctx.lineTo(x, y+r);
|
||||||
|
ctx.quadraticCurveTo(x, y, x+r, y);
|
||||||
|
ctx.closePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToRgb(hex) {
|
||||||
|
const r = parseInt(hex.slice(1,3),16);
|
||||||
|
const g = parseInt(hex.slice(3,5),16);
|
||||||
|
const b = parseInt(hex.slice(5,7),16);
|
||||||
|
return `${r},${g},${b}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
connectWS();
|
||||||
|
|
||||||
|
function exitGame() {
|
||||||
|
if (ws) { ws.onclose = null; ws.close(); }
|
||||||
|
myName = ''; myScore = 0; myDept = '';
|
||||||
|
show('lang-screen');
|
||||||
|
connectWS();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user