Files
route-rush/templates/player.html
T

804 lines
27 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Route Rush</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Exo+2:wght@400;600;800;900&display=swap');
*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent;}
:root{
--bg:#070b14;
--surface:#0d1220;
--surface2:#131928;
--accent:#00ffe0;
--accent2:#ff3e6c;
--warn:#ffb800;
--slow:#ff6b00;
--boost:#00ff88;
--blocked:#ff1744;
--text:#ddeeff;
--muted:#3a4a66;
--mono:'Share Tech Mono',monospace;
--sans:'Exo 2',sans-serif;
}
html,body{
width:100%;height:100%;
background:var(--bg);
color:var(--text);
font-family:var(--sans);
overflow:hidden;
touch-action:none;
}
/* scanline */
body::after{
content:'';position:fixed;inset:0;
background:repeating-linear-gradient(0deg,transparent,transparent 3px,rgba(0,255,224,0.015) 3px,rgba(0,255,224,0.015) 4px);
pointer-events:none;z-index:9999;
}
/* grid bg */
body::before{
content:'';position:fixed;inset:0;
background-image:
linear-gradient(rgba(0,255,224,0.03) 1px,transparent 1px),
linear-gradient(90deg,rgba(0,255,224,0.03) 1px,transparent 1px);
background-size:32px 32px;
pointer-events:none;
}
/* ── SCREENS ── */
.screen{display:none;flex-direction:column;align-items:center;justify-content:center;
min-height:100vh;min-height:100dvh;padding:1.5rem;text-align:center;}
.screen.active{display:flex;}
/* ── JOIN ── */
#join-screen{
height:100vh;height:100dvh;
overflow-y:auto;
justify-content:center;
}
/* ── JOIN ── */
#join-screen .logo{
font-family:var(--sans);font-weight:900;font-size:4rem;
letter-spacing:-0.02em;line-height:1;margin-bottom:.35rem;
color:var(--accent);text-shadow:0 0 30px rgba(0,255,224,0.5);
}
#join-screen .logo span{color:var(--accent2);}
#join-screen .sub{font-family:var(--mono);font-size:1rem;color:var(--text);
letter-spacing:.15em;margin-bottom:2.5rem;}
.field{width:100%;max-width:420px;margin-bottom:1.25rem;text-align:left;}
.field label{font-family:var(--mono);font-size:.95rem;color:var(--text);
letter-spacing:.12em;display:block;margin-bottom:.5rem;}
.field input{
width:100%;padding:1.1rem 1.2rem;
background:var(--surface);
border:1px solid rgba(0,255,224,0.25);
border-radius:3px;
color:var(--text);font-family:var(--sans);font-size:1.3rem;font-weight:600;
outline:none;transition:border-color .2s;
}
.field input:focus{border-color:var(--accent);}
.field input::placeholder{color:#6a85a0;}
.field select{
width:100%;padding:1.1rem 1.2rem;
background:var(--surface);
border:1px solid rgba(0,255,224,0.25);
border-radius:3px;
color:var(--text);font-family:var(--sans);font-size:1.3rem;font-weight:600;
outline:none;transition:border-color .2s;cursor:pointer;
}
.field select:focus{border-color:var(--accent);}
.field select option{background:var(--surface2);}
#join-btn{
width:100%;max-width:420px;padding:1.3rem;margin-top:.5rem;
background:transparent;
border:1px solid var(--accent);
border-radius:3px;
color:var(--accent);font-family:var(--mono);font-size:1.1rem;letter-spacing:.15em;
cursor:pointer;transition:all .2s;position:relative;overflow:hidden;
}
#join-btn::before{
content:'';position:absolute;inset:0;
background:var(--accent);opacity:0;transition:opacity .2s;
}
#join-btn:hover::before{opacity:.08;}
#join-btn:active{transform:scale(.97);}
/* ── LOBBY ── */
#lobby-screen .callsign{
font-family:var(--mono);font-size:.8rem;color:var(--accent);
letter-spacing:.15em;margin-bottom:.5rem;
}
#lobby-screen .pname{
font-family:var(--sans);font-weight:900;font-size:2.5rem;
letter-spacing:-0.02em;margin-bottom:2rem;
}
.signal-wrap{display:flex;flex-direction:column;align-items:center;gap:.5rem;}
.signal-bars{display:flex;align-items:flex-end;gap:5px;height:40px;}
.signal-bars span{
width:7px;border-radius:1px;background:var(--accent);opacity:.2;
animation:signalPulse 1.2s ease-in-out infinite;
}
.signal-bars span:nth-child(1){height:10px;animation-delay:0s;}
.signal-bars span:nth-child(2){height:18px;animation-delay:.15s;}
.signal-bars span:nth-child(3){height:26px;animation-delay:.3s;}
.signal-bars span:nth-child(4){height:34px;animation-delay:.45s;}
.signal-bars span:nth-child(5){height:40px;animation-delay:.6s;}
.signal-label{font-family:var(--mono);font-size:.75rem;color:var(--text);letter-spacing:.1em;
animation:blink 1.2s step-end infinite;}
/* ── GAME ── */
#game-screen{
height:100vh;height:100dvh;
padding:.5rem .75rem;
justify-content:flex-start;
overflow:hidden;
}
.game-hud{
width:100%;max-width:420px;
display:flex;justify-content:space-between;align-items:center;
padding:.4rem .75rem;
background:var(--surface);
border:1px solid rgba(0,255,224,0.1);
border-radius:3px;
margin-bottom:.4rem;
}
.hud-round{font-family:var(--mono);font-size:.85rem;color:var(--text);}
.hud-score{font-family:var(--mono);font-size:.85rem;color:var(--accent);}
.hud-timer{
font-family:var(--mono);font-size:1.7rem;font-weight:700;
color:var(--warn);min-width:2.5ch;text-align:center;
transition:color .3s;
}
.hud-timer.urgent{color:var(--accent2);animation:blink .4s step-end infinite;}
/* objective bar */
.objective{
width:100%;max-width:420px;
display:flex;align-items:center;justify-content:center;gap:.6rem;
padding:.4rem .75rem;
background:var(--surface);
border:1px solid rgba(0,255,224,0.2);
border-radius:3px;
margin-bottom:.4rem;
font-family:var(--mono);font-size:.85rem;color:var(--text);letter-spacing:.05em;
}
.obj-node{
display:inline-flex;align-items:center;justify-content:center;
width:34px;height:34px;border-radius:50%;
font-weight:800;font-size:1rem;font-family:var(--sans);
}
.obj-start{background:rgba(0,255,224,.15);border:2px solid var(--accent);color:var(--accent);}
.obj-end{background:rgba(255,62,108,.15);border:2px solid var(--accent2);color:var(--accent2);}
.obj-arrow{color:var(--text);}
/* ── GRID CANVAS AREA ── */
.grid-wrap{
width:100%;max-width:420px;
display:flex;align-items:center;justify-content:center;
position:relative;
}
#grid-canvas{
display:block;touch-action:none;
border-radius:4px;
}
/* submit btn */
#submit-btn{
width:100%;max-width:420px;margin-top:.4rem;
padding:.8rem;
background:var(--accent);border:none;border-radius:3px;
color:#070b14;font-family:var(--mono);font-size:.85rem;letter-spacing:.15em;
cursor:pointer;font-weight:700;
transition:all .2s;
}
#submit-btn:disabled{opacity:.3;cursor:not-allowed;}
#submit-btn:not(:disabled):active{transform:scale(.97);}
/* legend */
.legend{
width:100%;max-width:420px;margin-top:.3rem;
display:flex;gap:.75rem;justify-content:center;flex-wrap:wrap;
}
.legend-item{display:flex;align-items:center;gap:.35rem;font-family:var(--mono);font-size:.75rem;color:var(--text);}
.legend-line{width:22px;height:4px;border-radius:2px;}
/* ── RESULT FLASH ── */
#result-screen{background:var(--bg);}
#result-screen .r-icon{font-size:3.5rem;margin-bottom:.75rem;}
#result-screen .r-label{
font-family:var(--mono);font-size:.85rem;letter-spacing:.15em;margin-bottom:.5rem;
}
#result-screen .r-pts{
font-family:var(--sans);font-weight:900;font-size:3.5rem;
letter-spacing:-0.03em;line-height:1;
margin-bottom:.25rem;
}
#result-screen .r-total{font-family:var(--mono);font-size:.8rem;color:var(--text);}
#result-screen .r-reason{
margin-top:1rem;font-family:var(--mono);font-size:.75rem;
color:var(--text);max-width:280px;line-height:1.8;
}
#result-screen .r-wait{
margin-top:2rem;font-family:var(--mono);font-size:.7rem;color:var(--text);
animation:blink 1s step-end infinite;
}
/* ── PODIUM ── */
#podium-screen h2{
font-family:var(--sans);font-weight:900;font-size:2.2rem;
letter-spacing:-0.02em;color:var(--warn);
text-shadow:0 0 20px rgba(255,184,0,.4);
margin-bottom:.25rem;
}
#podium-screen .sub{font-family:var(--mono);font-size:.7rem;color:var(--text);margin-bottom:1.5rem;}
.podium-list{width:100%;max-width:340px;}
.podium-item{
display:flex;align-items:center;gap:.75rem;
padding:.75rem 1rem;margin-bottom:.4rem;
background:var(--surface);
border:1px solid rgba(255,255,255,0.08);
border-radius:3px;
}
.podium-item.r1{border-color:rgba(255,184,0,.4);background:rgba(255,184,0,.05);}
.podium-item.r2{border-color:rgba(180,180,180,.3);}
.podium-item.r3{border-color:rgba(180,100,40,.3);}
.pod-rank{font-size:1.2rem;min-width:28px;}
.pod-name{flex:1;font-weight:700;font-size:1rem;}
.pod-score{font-family:var(--mono);font-size:.75rem;color:var(--accent);}
/* exit btn */
#exit-btn{
width:100%;max-width:340px;margin-top:1rem;
padding:.85rem;
background:transparent;border:1px solid rgba(255,255,255,.2);border-radius:3px;
color:var(--text);font-family:var(--mono);font-size:.75rem;letter-spacing:.15em;
cursor:pointer;transition:all .2s;
}
#exit-btn:active{transform:scale(.97);}
#exit-btn:hover{border-color:var(--accent2);color:var(--accent2);}
/* ── ANIMS ── */
@keyframes blink{0%,100%{opacity:1}50%{opacity:0}}
@keyframes signalPulse{0%,100%{opacity:.2}50%{opacity:1}}
@keyframes fadeUp{from{opacity:0;transform:translateY(16px)}to{opacity:1;transform:none}}
@keyframes popIn{from{transform:scale(.6);opacity:0}to{transform:scale(1);opacity:1}}
.fade-up{animation:fadeUp .4s ease both;}
.pop{animation:popIn .35s cubic-bezier(.34,1.56,.64,1) both;}
</style>
</head>
<body>
<!-- JOIN -->
<div class="screen active" id="join-screen">
<div class="logo">ROUTE<span>RUSH</span></div>
<div class="sub">// NETWORK ROUTING GAME</div>
<div class="field">
<label>FIRST NAME</label>
<input id="inp-fname" type="text" maxlength="20" placeholder="First name" autocomplete="off" autocorrect="off" autocapitalize="words">
</div>
<div class="field">
<label>LAST NAME</label>
<input id="inp-lname" type="text" maxlength="20" placeholder="Last name" autocomplete="off" autocorrect="off" autocapitalize="words">
</div>
<div class="field">
<label>DEPARTMENT</label>
<select id="inp-dept">
<option value="">-- Select --</option>
<option>Finanzas</option>
<option>People</option>
<option>Legal</option>
<option>B2B</option>
<option>B2C</option>
</select>
</div>
<button id="join-btn">▶ CONNECT</button>
<a href="/practice" style="display:block;width:100%;max-width:420px;margin-top:.5rem;padding:.95rem;border:1px solid rgba(255,184,0,.35);border-radius:3px;color:var(--warn);font-family:var(--mono);font-size:.95rem;letter-spacing:.15em;text-decoration:none;text-align:center;transition:all .2s;" onmouseover="this.style.background='rgba(255,184,0,.08)'" onmouseout="this.style.background=''">⚡ PRACTICE / PRÁCTICA</a>
</div>
<!-- LOBBY -->
<div class="screen" id="lobby-screen">
<div class="callsign">CONNECTED AS</div>
<div class="pname" id="lobby-name"></div>
<div class="signal-wrap">
<div class="signal-bars">
<span></span><span></span><span></span><span></span><span></span>
</div>
<div class="signal-label">AWAITING HOST...</div>
</div>
</div>
<!-- GAME -->
<div class="screen" id="game-screen">
<div class="game-hud">
<span class="hud-round" id="hud-round">RND 1/3</span>
<span class="hud-timer" id="hud-timer">30</span>
<span class="hud-score" id="hud-score">0 PTS</span>
</div>
<div class="objective">
<span>ROUTE FROM</span>
<div class="obj-node obj-start" id="obj-start">?</div>
<span class="obj-arrow">——▶</span>
<div class="obj-node obj-end" id="obj-end">?</div>
</div>
<div class="grid-wrap">
<canvas id="grid-canvas"></canvas>
</div>
<button id="submit-btn" disabled>SUBMIT ROUTE</button>
<div class="legend">
<div class="legend-item"><div class="legend-line" style="background:var(--accent);"></div>NORMAL</div>
<div class="legend-item"><div class="legend-line" style="background:var(--slow);"></div>SLOW ×2</div>
<div class="legend-item"><div class="legend-line" style="background:var(--boost);"></div>BOOST ×0.5</div>
<div class="legend-item"><div class="legend-line" style="background:var(--blocked);opacity:.5;"></div>DOWN</div>
</div>
</div>
<!-- RESULT -->
<div class="screen" id="result-screen">
<div class="r-icon" id="r-icon"></div>
<div class="r-label" id="r-label"></div>
<div class="r-pts pop" id="r-pts"></div>
<div class="r-total" id="r-total"></div>
<div class="r-reason" id="r-reason"></div>
<div class="r-wait">WAITING FOR HOST...</div>
</div>
<!-- PODIUM -->
<div class="screen" id="podium-screen">
<h2>GAME OVER</h2>
<div class="sub">// FINAL STANDINGS</div>
<div class="podium-list" id="podium-list"></div>
<button id="exit-btn" onclick="location.reload()">✕ EXIT GAME</button>
</div>
<script>
// ── STATE ────────────────────────────────────────────────────────────────────
const PID = Math.random().toString(36).slice(2) + Date.now().toString(36);
let ws, myName = '', myScore = 0;
let grid = null, path = [], submitted = false;
let timerInterval = null;
// ── CANVAS ───────────────────────────────────────────────────────────────────
const canvas = document.getElementById('grid-canvas');
const ctx = canvas.getContext('2d');
const GRID = 4;
let NODE_R, CELL, OFFSET_X, OFFSET_Y;
const MOD_COLOR = {
normal: '#00ffe0',
slow: '#ff6b00',
boost: '#00ff88',
blocked: '#ff1744',
};
function setupCanvas() {
const screenH = window.visualViewport ? window.visualViewport.height : window.innerHeight;
const screenW = window.visualViewport ? window.visualViewport.width : window.innerWidth;
const reserved = 216;
const availH = screenH - reserved;
const availW = Math.min(screenW - 24, 420);
const size = Math.min(availW, availH, 380);
canvas.width = size;
canvas.height = size;
CELL = size / GRID;
NODE_R = CELL * 0.18;
OFFSET_X = CELL / 2;
OFFSET_Y = CELL / 2;
}
function nodePos(id) {
const row = Math.floor(id / GRID);
const col = id % GRID;
return {
x: OFFSET_X + col * CELL,
y: OFFSET_Y + row * CELL,
};
}
function drawGrid() {
if (!grid) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
// ── EDGES ──
grid.edges.forEach(e => {
const a = nodePos(e.a);
const b = nodePos(e.b);
const inPath = isEdgeInPath(e.a, e.b);
const color = MOD_COLOR[e.modifier] || '#00ffe0';
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
if (e.modifier === 'blocked') {
ctx.setLineDash([6, 5]);
ctx.strokeStyle = inPath ? color : 'rgba(255,23,68,0.45)';
ctx.lineWidth = inPath ? 3.5 : 2;
} else if (e.modifier !== 'normal') {
ctx.setLineDash([]);
ctx.strokeStyle = inPath ? color : `${color}55`;
ctx.lineWidth = inPath ? 4 : 2.5;
} else {
ctx.setLineDash([]);
ctx.strokeStyle = inPath ? color : `${color}33`;
ctx.lineWidth = inPath ? 3.5 : 1.5;
}
ctx.stroke();
ctx.setLineDash([]);
if (e.modifier !== 'normal') {
const mx = (a.x + b.x) / 2;
const my = (a.y + b.y) / 2;
const label = e.modifier === 'slow' ? 'x2' : e.modifier === 'boost' ? 'x½' : '✕';
const fontSize = Math.max(13, CELL * 0.22);
ctx.font = `bold ${fontSize}px 'Share Tech Mono', monospace`;
const tw = ctx.measureText(label).width;
const padX = 7, padY = 5;
const bw = tw + padX * 2, bh = fontSize + padY * 2;
const bx = mx - bw / 2, by = my - bh / 2;
const r = 4;
ctx.beginPath();
ctx.moveTo(bx+r,by); ctx.lineTo(bx+bw-r,by);
ctx.quadraticCurveTo(bx+bw,by,bx+bw,by+r);
ctx.lineTo(bx+bw,by+bh-r);
ctx.quadraticCurveTo(bx+bw,by+bh,bx+bw-r,by+bh);
ctx.lineTo(bx+r,by+bh);
ctx.quadraticCurveTo(bx,by+bh,bx,by+bh-r);
ctx.lineTo(bx,by+r);
ctx.quadraticCurveTo(bx,by,bx+r,by);
ctx.closePath();
ctx.fillStyle = '#070b14';
ctx.fill();
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
ctx.stroke();
ctx.fillStyle = color;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(label, mx, my);
}
});
// ── PATH LINE highlight ──
if (path.length > 1) {
ctx.beginPath();
const first = nodePos(path[0]);
ctx.moveTo(first.x, first.y);
for (let i = 1; i < path.length; i++) {
const p = nodePos(path[i]);
ctx.lineTo(p.x, p.y);
}
ctx.strokeStyle = 'rgba(0,255,224,0.15)';
ctx.lineWidth = CELL * 0.35;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.stroke();
ctx.lineCap = 'butt';
ctx.lineJoin = 'miter';
}
// ── NODES ──
grid.nodes.forEach(n => {
const {x, y} = nodePos(n.id);
const isStart = n.id === grid.start;
const isEnd = n.id === grid.end;
const inP = path.includes(n.id);
const isHead = path.length > 0 && path[path.length - 1] === n.id;
if (isStart || isEnd) {
ctx.beginPath();
ctx.arc(x, y, NODE_R * 1.6, 0, Math.PI * 2);
ctx.fillStyle = isStart ? 'rgba(0,255,224,0.08)' : 'rgba(255,62,108,0.08)';
ctx.fill();
ctx.strokeStyle = isStart ? 'var(--accent)' : 'var(--accent2)';
ctx.lineWidth = 1.5;
ctx.setLineDash([3,3]);
ctx.stroke();
ctx.setLineDash([]);
}
ctx.beginPath();
ctx.arc(x, y, NODE_R, 0, Math.PI * 2);
if (isStart) {
ctx.fillStyle = '#00ffe0';
} else if (isEnd) {
ctx.fillStyle = '#ff3e6c';
} else if (isHead) {
ctx.fillStyle = '#ffffff';
} else if (inP) {
ctx.fillStyle = 'rgba(0,255,224,0.5)';
} else {
ctx.fillStyle = '#131928';
}
ctx.fill();
ctx.strokeStyle = inP ? '#00ffe0' : 'rgba(0,255,224,0.2)';
ctx.lineWidth = 1.5;
ctx.stroke();
ctx.fillStyle = (isStart || isEnd) ? '#070b14' : (inP ? '#00ffe0' : '#8899bb');
ctx.font = `bold ${Math.max(12, NODE_R * 1.1)}px 'Exo 2', sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(n.id, x, y);
});
}
function isEdgeInPath(a, b) {
for (let i = 0; i < path.length - 1; i++) {
if ((path[i] === a && path[i+1] === b) || (path[i] === b && path[i+1] === a)) return true;
}
return false;
}
function nodeAtPoint(px, py) {
if (!grid) return null;
let best = null, bestDist = NODE_R * 2.5;
grid.nodes.forEach(n => {
const {x, y} = nodePos(n.id);
const d = Math.hypot(px - x, py - y);
if (d < bestDist) { bestDist = d; best = n.id; }
});
return best;
}
function isAdjacent(a, b) {
return grid.edges.some(e =>
(e.a === a && e.b === b) || (e.a === b && e.b === a)
);
}
function tryAddNode(nid) {
if (submitted || nid === null) return;
if (path.length === 0) {
if (nid !== grid.start) return;
path.push(nid);
} else {
const last = path[path.length - 1];
if (nid === last) return;
const prev = path[path.length - 2];
if (prev !== undefined && nid === prev) {
path.pop();
} else {
if (!isAdjacent(last, nid)) return;
if (path.includes(nid)) return;
path.push(nid);
}
}
drawGrid();
updateSubmitBtn();
}
function updateSubmitBtn() {
const btn = document.getElementById('submit-btn');
const reachedEnd = path.length > 0 && path[path.length - 1] === grid.end;
btn.disabled = !reachedEnd || submitted;
}
function getCanvasPoint(e) {
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
const src = e.touches ? e.touches[0] : e;
return {
x: (src.clientX - rect.left) * scaleX,
y: (src.clientY - rect.top) * scaleY,
};
}
canvas.addEventListener('touchstart', e => {
e.preventDefault();
const {x, y} = getCanvasPoint(e);
tryAddNode(nodeAtPoint(x, y));
}, {passive: false});
canvas.addEventListener('touchmove', e => {
e.preventDefault();
const {x, y} = getCanvasPoint(e);
tryAddNode(nodeAtPoint(x, y));
}, {passive: false});
canvas.addEventListener('mousedown', e => {
const {x, y} = getCanvasPoint(e);
tryAddNode(nodeAtPoint(x, y));
});
// ── SCREENS ──────────────────────────────────────────────────────────────────
function show(id) {
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
document.getElementById(id).classList.add('active');
}
// ── WS ────────────────────────────────────────────────────────────────────────
function connectWS() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const base = location.pathname.replace(/\/$/, '');
ws = new WebSocket(`${proto}://${location.host}${base}/ws/player/${PID}`);
ws.onmessage = e => handle(JSON.parse(e.data));
ws.onclose = () => setTimeout(connectWS, 2000);
}
function handle(msg) {
if (msg.type === 'kicked') {
myName = '';
myScore = 0;
document.getElementById('inp-fname').value = '';
document.getElementById('inp-lname').value = '';
document.getElementById('inp-dept').value = '';
show('join-screen');
}
else if (msg.type === 'phase' && msg.phase === 'lobby' && myName) {
show('lobby-screen');
}
else if (msg.type === 'joined') {
myName = msg.name;
document.getElementById('lobby-name').textContent = myName;
show('lobby-screen');
}
else if (msg.type === 'error') {
alert(msg.msg);
}
else if (msg.type === 'round_start') {
startRound(msg);
}
else if (msg.type === 'path_result') {
myScore = msg.total;
showResult(msg);
}
else if (msg.type === 'round_results') {
if (!submitted) showResult({points: 0, total: myScore, reason: 'timeout'});
else showRoundResults(msg);
}
else if (msg.type === 'podium') {
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;
const errors = [
!fn && 'inp-fname',
!ln && 'inp-lname',
!dept && 'inp-dept',
].filter(Boolean);
if (errors.length) {
errors.forEach(id => {
const el = document.getElementById(id);
el.style.borderColor = 'var(--accent2)';
setTimeout(() => el.style.borderColor = '', 1200);
});
return;
}
const display = `${fn} ${ln}`;
ws.send(JSON.stringify({type: 'join', name: display, dept, display}));
});
document.getElementById('inp-fname').addEventListener('keydown', e => {
if (e.key === 'Enter') document.getElementById('inp-lname').focus();
});
document.getElementById('inp-lname').addEventListener('keydown', e => {
if (e.key === 'Enter') document.getElementById('inp-dept').focus();
});
// ── ROUND ─────────────────────────────────────────────────────────────────────
function startRound(msg) {
grid = msg.grid;
path = [grid.start];
submitted = false;
show('game-screen');
setupCanvas();
document.getElementById('hud-round').textContent = `RND ${msg.round}/${msg.total_rounds}`;
document.getElementById('hud-score').textContent = `${myScore} PTS`;
document.getElementById('obj-start').textContent = grid.start;
document.getElementById('obj-end').textContent = grid.end;
document.getElementById('submit-btn').disabled = true;
drawGrid();
startTimer(msg.time_limit);
}
function startTimer(secs) {
clearInterval(timerInterval);
let rem = secs;
const el = document.getElementById('hud-timer');
function tick() {
el.textContent = rem;
el.classList.toggle('urgent', rem <= 8);
if (rem <= 0) { clearInterval(timerInterval); autoSubmit(); }
rem--;
}
tick();
timerInterval = setInterval(tick, 1000);
}
function autoSubmit() {
if (!submitted) {
submitted = true;
document.getElementById('submit-btn').disabled = true;
ws.send(JSON.stringify({type: 'submit_path', path}));
}
}
document.getElementById('submit-btn').addEventListener('click', () => {
if (submitted) return;
submitted = true;
clearInterval(timerInterval);
document.getElementById('submit-btn').disabled = true;
ws.send(JSON.stringify({type: 'submit_path', path}));
});
// ── RESULT ────────────────────────────────────────────────────────────────────
function showResult(msg) {
clearInterval(timerInterval);
const ok = msg.points > 0;
document.getElementById('r-icon').textContent = ok ? '📡' : '💀';
document.getElementById('r-label').textContent = ok ? '// ROUTE ACCEPTED' : '// ROUTE FAILED';
document.getElementById('r-label').style.color = ok ? 'var(--boost)' : 'var(--accent2)';
document.getElementById('r-pts').textContent = `+${msg.points}`;
document.getElementById('r-pts').style.color = ok ? 'var(--accent)' : 'var(--accent2)';
document.getElementById('r-total').textContent = `TOTAL: ${msg.total} PTS`;
const reasons = {
ok: 'Route successfully delivered.',
invalid_path: 'Path contains non-adjacent nodes.',
blocked: 'Path passes through a down link.',
timeout: 'Time expired — no route submitted.',
no_path: 'No route submitted.',
};
document.getElementById('r-reason').textContent = reasons[msg.reason] || '';
show('result-screen');
}
function showRoundResults(msg) {}
// ── PODIUM ────────────────────────────────────────────────────────────────────
function showPodium(lb) {
const medals = ['🥇','🥈','🥉'];
const rc = ['r1','r2','r3'];
const list = document.getElementById('podium-list');
list.innerHTML = '';
lb.forEach((p, i) => {
const div = document.createElement('div');
div.className = `podium-item ${rc[i]||''} fade-up`;
div.style.animationDelay = `${i * 0.07}s`;
const isMe = p.name === myName;
div.innerHTML = `
<span class="pod-rank">${i < 3 ? medals[i] : i+1}</span>
<span class="pod-name">${p.name}${isMe ? ' 📡' : ''}</span>
<span class="pod-score">${p.score.toLocaleString()} PTS</span>`;
list.appendChild(div);
});
show('podium-screen');
}
// ── INIT ──────────────────────────────────────────────────────────────────────
window.addEventListener('resize', () => { if (grid) { setupCanvas(); drawGrid(); }});
connectWS();
</script>
<div class="qr-sub">// SCAN TO PRACTICE BEFORE THE GAME</div>
<div class="qr-img">
<img id="qr-img-el" src="" alt="QR Code">
</div>
<div class="qr-url" id="qr-url-text">route-rush.carloselugo.com/practice</div>
<button class="qr-close" onclick="closeQR()">✕ CLOSE</button>
</div>
</div>
</body>
</html>