Files
route-rush/templates/host.html
T

809 lines
33 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">
<title>Route Rush · Host</title>
<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=Share+Tech+Mono&family=Exo+2:wght@400;600;800;900&display=swap');
*{margin:0;padding:0;box-sizing:border-box;}
:root{
--bg:#ffffff;
--surface:#f0f4f8;
--surface2:#e2e8f0;
--accent:#0077cc;
--accent2:#cc0033;
--warn:#b35c00;
--slow:#cc4400;
--boost:#00994d;
--blocked:#ff1744;
--text:#0a0f1e;
--muted:#4a5568;
--mono:'Share Tech Mono',monospace;
--sans:'Exo 2',sans-serif;
}
html,body{width:100%;height:100vh;background:var(--bg);color:var(--text);
font-family:var(--sans);overflow:hidden;}
body::before{
content:'';position:fixed;inset:0;
background-image:
linear-gradient(rgba(0,119,204,0.08) 1px,transparent 1px),
linear-gradient(90deg,rgba(0,119,204,0.08) 1px,transparent 1px);
background-size:40px 40px;pointer-events:none;
}
/* ── PASSWORD GATE ── */
#auth-gate{
position:fixed;inset:0;z-index:9999;
background:var(--bg);
display:flex;flex-direction:column;align-items:center;justify-content:center;
gap:1.5rem;
}
#auth-gate .logo{
font-family:var(--sans);font-weight:900;font-size:3rem;letter-spacing:-0.02em;
color:var(--accent);text-shadow:0 0 30px rgba(0,119,204,0.4);line-height:1;
}
#auth-gate .logo span{color:var(--accent2);}
#auth-gate .gate-label{
font-family:var(--mono);font-size:.7rem;letter-spacing:.15em;color:var(--muted);
}
#auth-gate .gate-field{
display:flex;flex-direction:column;align-items:center;gap:.75rem;width:100%;max-width:300px;
}
#auth-pass{
width:100%;padding:.9rem 1rem;text-align:center;
background:var(--surface);border:1px solid rgba(0,119,204,0.4);border-radius:3px;
color:var(--text);font-family:var(--mono);font-size:1.1rem;letter-spacing:.15em;
outline:none;transition:border-color .2s;
}
#auth-pass:focus{border-color:var(--accent);}
#auth-pass.shake{animation:shake .3s ease both;}
#auth-btn{
width:100%;padding:.85rem;
background:var(--accent);border:none;border-radius:3px;
color:#fff;font-family:var(--mono);font-size:.75rem;letter-spacing:.15em;
cursor:pointer;transition:opacity .2s;
}
#auth-btn:hover{opacity:.9;}
#auth-btn:active{transform:scale(.97);}
#auth-err{
font-family:var(--mono);font-size:.65rem;color:var(--accent2);
letter-spacing:.1em;opacity:0;transition:opacity .2s;
}
#auth-err.visible{opacity:1;}
@keyframes shake{0%,100%{transform:translateX(0)}25%{transform:translateX(-6px)}75%{transform:translateX(6px)}}
.screen{display:none;height:100vh;}
.screen.active{display:flex;}
/* ── LOBBY ── */
#lobby-screen{flex-direction:column;align-items:center;justify-content:center;padding:2rem;text-align:center;}
.logo{font-family:var(--sans);font-weight:900;font-size:4rem;letter-spacing:-0.02em;line-height:1;
color:var(--accent);text-shadow:0 0 40px rgba(0,119,204,0.6);margin-bottom:.25rem;}
.logo span{color:var(--accent2);}
.tagline{font-family:var(--mono);font-size:.85rem;color:var(--text);letter-spacing:.15em;margin-bottom:2.5rem;}
.lobby-row{display:flex;gap:2rem;align-items:flex-start;justify-content:center;width:100%;max-width:800px;margin-bottom:2rem;}
/* QR */
.qr-box{background:var(--surface);border:1px solid rgba(0,119,204,0.25);border-radius:4px;
padding:1.25rem;text-align:center;min-width:200px;}
.box-label{font-family:var(--mono);font-size:.75rem;color:var(--text);letter-spacing:.15em;margin-bottom:.75rem;}
#qrcode{display:flex;justify-content:center;margin-bottom:.75rem;}
#qrcode canvas,#qrcode img{border-radius:4px;}
.qr-url{font-family:var(--mono);font-size:.85rem;color:var(--accent);}
/* Players box */
.players-box{flex:1;background:var(--surface);border:1px solid rgba(0,119,204,0.25);
border-radius:4px;padding:1.25rem;}
.player-count{font-family:var(--sans);font-weight:900;font-size:4.5rem;
letter-spacing:-0.04em;color:var(--accent);line-height:1;margin-bottom:.15rem;}
.player-count-sub{font-family:var(--mono);font-size:.8rem;color:var(--text);margin-bottom:1rem;}
.p-chips{display:flex;flex-wrap:wrap;gap:.4rem;max-height:100px;overflow-y:auto;}
.p-chip{padding:.4rem .9rem;border-radius:2px;
background:var(--surface2);border:1px solid rgba(0,119,204,0.4);
font-family:var(--mono);font-size:.85rem;color:var(--accent);}
/* rounds config */
.rounds-cfg{display:flex;align-items:center;gap:.75rem;margin-top:1.5rem;
font-family:var(--mono);font-size:.8rem;color:var(--text);}
.rounds-cfg select{
background:var(--surface2);border:1px solid rgba(0,119,204,0.4);
color:var(--accent);font-family:var(--mono);font-size:.8rem;
padding:.4rem .7rem;border-radius:2px;outline:none;cursor:pointer;
}
.btn-row{display:flex;gap:.75rem;flex-wrap:wrap;justify-content:center;}
.hbtn{padding:.9rem 2rem;border-radius:3px;font-family:var(--mono);
font-size:.8rem;letter-spacing:.12em;cursor:pointer;border:1px solid;transition:all .15s;}
.hbtn:active{transform:scale(.96);}
.hbtn-primary{border-color:var(--accent);color:#070b14;background:var(--accent);}
.hbtn-primary:disabled{opacity:.3;cursor:not-allowed;}
.hbtn-secondary{border-color:rgba(255,255,255,.3);color:var(--text);background:transparent;}
.hbtn-secondary:hover{border-color:var(--text);color:var(--text);}
.hbtn-danger{border-color:var(--accent2);color:var(--accent2);background:transparent;}
.hbtn-danger:hover{background:rgba(255,62,108,.08);}
/* ── GAME SCREEN ── */
#game-screen{flex-direction:row;gap:0;overflow:hidden;}
.game-left{
flex:1;display:flex;flex-direction:column;
padding:1.25rem;border-right:1px solid rgba(0,119,204,0.15);
overflow:hidden;
}
.game-hud{display:flex;justify-content:space-between;align-items:center;
margin-bottom:1rem;}
.hud-meta{font-family:var(--mono);font-size:.85rem;color:var(--text);}
.hud-timer{font-family:var(--mono);font-size:3.5rem;font-weight:700;
color:var(--warn);line-height:1;transition:color .3s;}
.hud-timer.urgent{color:var(--accent2);animation:blink .4s step-end infinite;}
.objective-bar{
display:flex;align-items:center;gap:.75rem;
padding:.75rem 1.25rem;
background:var(--surface);border:1px solid rgba(0,119,204,0.35);border-radius:3px;
margin-bottom:1rem;font-family:var(--mono);font-size:.85rem;color:var(--text);
}
.obj-node{display:inline-flex;align-items:center;justify-content:center;
width:40px;height:40px;border-radius:50%;font-weight:800;font-size:1.1rem;}
.obj-start{background:rgba(0,119,204,0.3);border:2px solid var(--accent);color:var(--accent);}
.obj-end{background:rgba(255,62,108,.15);border:2px solid var(--accent2);color:var(--accent2);}
.grid-wrap{flex:1;display:flex;align-items:center;justify-content:center;}
#host-canvas{display:block;border-radius:4px;}
.legend{display:flex;gap:1.25rem;margin-top:.75rem;flex-wrap:wrap;}
.legend-item{display:flex;align-items:center;gap:.4rem;
font-family:var(--mono);font-size:.75rem;color:var(--text);}
.legend-line{width:26px;height:4px;border-radius:2px;}
.game-right{
width:320px;display:flex;flex-direction:column;
padding:1.25rem;gap:1rem;overflow-y:auto;
}
.panel{background:var(--surface);border:1px solid rgba(0,119,204,0.3);
border-radius:4px;padding:1rem;}
.panel-title{font-family:var(--mono);font-size:.7rem;color:var(--text);
letter-spacing:.12em;margin-bottom:.75rem;}
.ans-count{font-family:var(--sans);font-weight:900;font-size:3rem;
letter-spacing:-0.03em;color:var(--accent);line-height:1;}
.ans-sub{font-family:var(--mono);font-size:.75rem;color:var(--text);margin-bottom:.6rem;}
.ans-bar-wrap{height:4px;background:var(--surface2);border-radius:2px;}
.ans-bar{height:100%;background:var(--accent);border-radius:2px;transition:width .3s;}
.sub-list{display:flex;flex-direction:column;gap:.4rem;margin-top:.75rem;}
.sub-item{display:flex;align-items:center;gap:.5rem;
padding:.6rem .75rem;border-radius:2px;
background:var(--surface2);border:1px solid rgba(0,119,204,0.25);
font-family:var(--mono);font-size:.8rem;}
.sub-name{flex:1;color:var(--text);}
.sub-pts{color:var(--accent);font-size:.8rem;}
.sub-pts.fail{color:var(--accent2);}
.sub-time{color:var(--muted);font-size:.7rem;}
.lb-item{display:flex;align-items:center;gap:.5rem;
padding:.55rem .75rem;margin-bottom:.35rem;border-radius:2px;
background:var(--surface2);border:1px solid rgba(0,119,204,0.25);}
.lb-rank{font-family:var(--mono);font-size:.7rem;color:var(--text);min-width:22px;}
.lb-name{flex:1;font-size:1rem;font-weight:700;}
.lb-score{font-family:var(--mono);font-size:.7rem;color:var(--accent);}
.host-controls{display:flex;flex-direction:column;gap:.5rem;margin-top:auto;}
/* ── RESULTS SCREEN ── */
#results-screen{flex-direction:column;overflow:hidden;}
.results-top{
display:flex;justify-content:space-between;align-items:center;
padding:1rem 1.5rem;border-bottom:1px solid rgba(0,119,204,0.15);
}
.results-title{font-family:var(--sans);font-weight:900;font-size:2rem;
letter-spacing:-0.02em;color:var(--accent);}
.results-round{font-family:var(--mono);font-size:.8rem;color:var(--text);}
.results-body{display:flex;flex:1;gap:0;overflow:hidden;}
.results-left{flex:1;display:flex;flex-direction:column;
padding:1.25rem;border-right:1px solid rgba(0,119,204,0.15);}
.results-grid-wrap{flex:1;display:flex;align-items:center;justify-content:center;}
#results-canvas{display:block;border-radius:4px;}
.results-right{width:300px;padding:1.25rem;overflow-y:auto;display:flex;flex-direction:column;gap:1rem;}
.path-cards{display:flex;flex-direction:column;gap:.5rem;}
.path-card{
padding:.7rem .9rem;border-radius:3px;
background:var(--surface);border:1px solid rgba(0,119,204,0.15);
}
.path-card-top{display:flex;justify-content:space-between;align-items:center;margin-bottom:.3rem;}
.path-player{font-weight:700;font-size:1rem;}
.path-pts{font-family:var(--mono);font-size:.85rem;}
.path-pts.ok{color:var(--accent);}
.path-pts.fail{color:var(--accent2);}
.path-detail{font-family:var(--mono);font-size:.65rem;color:var(--text);line-height:1.8;}
.path-nodes{display:flex;flex-wrap:wrap;gap:.2rem;margin-top:.35rem;}
.path-node{
display:inline-flex;align-items:center;justify-content:center;
width:20px;height:20px;border-radius:50%;
background:var(--surface2);border:1px solid rgba(0,119,204,0.3);
font-family:var(--mono);font-size:.5rem;color:var(--accent);
}
.path-arrow{font-size:.5rem;color:var(--muted);align-self:center;}
/* ── PODIUM ── */
#podium-screen{flex-direction:column;align-items:center;justify-content:center;padding:2rem;text-align:center;}
#podium-screen h1{font-family:var(--sans);font-weight:900;font-size:3.5rem;
letter-spacing:-0.03em;color:var(--warn);
text-shadow:0 0 30px rgba(255,184,0,.4);margin-bottom:.25rem;}
#podium-screen .sub{font-family:var(--mono);font-size:.8rem;color:var(--text);margin-bottom:2rem;}
.podium-stage{display:flex;align-items:flex-end;gap:1.25rem;margin-bottom:1.5rem;}
.p-block{display:flex;flex-direction:column;align-items:center;width:170px;}
.p-avatar{font-size:2.5rem;margin-bottom:.35rem;}
.p-pname{font-family:var(--sans);font-weight:800;font-size:1.1rem;
letter-spacing:-0.01em;margin-bottom:.2rem;line-height:1.3;}
.p-pscore{font-family:var(--mono);font-size:.75rem;color:var(--text);margin-bottom:.4rem;}
.p-bar{width:100%;display:flex;align-items:center;justify-content:center;
border-radius:4px 4px 0 0;font-family:var(--sans);font-weight:900;font-size:1.8rem;}
.pb1{background:rgba(255,184,0,.12);border:1px solid rgba(255,184,0,.3);
height:110px;color:var(--warn);}
.pb2{background:rgba(180,180,180,.06);border:1px solid rgba(180,180,180,.2);
height:80px;color:#9ca3af;}
.pb3{background:rgba(160,90,30,.08);border:1px solid rgba(160,90,30,.2);
height:55px;color:#b46020;}
.rest-list{display:flex;flex-direction:column;gap:.4rem;max-width:420px;width:100%;}
.rest-item{display:flex;align-items:center;gap:.75rem;
padding:.65rem 1rem;border-radius:3px;
background:var(--surface);border:1px solid rgba(0,119,204,0.25);font-size:1rem;}
.rest-rank{font-family:var(--mono);font-size:.7rem;color:var(--text);min-width:24px;}
.rest-name{flex:1;font-weight:700;}
.rest-score{font-family:var(--mono);font-size:.7rem;color:var(--accent);}
/* confetti */
@keyframes confettiFall{
from{transform:translateY(-20px) rotate(0deg);opacity:1;}
to{transform:translateY(100vh) rotate(540deg);opacity:0;}
}
.cfp{position:fixed;width:7px;height:7px;border-radius:1px;pointer-events:none;
animation:confettiFall linear forwards;}
@keyframes blink{0%,100%{opacity:1}50%{opacity:0}}
</style>
</head>
<body>
<!-- ── AUTH GATE ── -->
<div id="auth-gate">
<div class="logo">ROUTE<span>RUSH</span></div>
<div class="gate-label">// HOST ACCESS REQUIRED</div>
<div class="gate-field">
<input type="password" id="auth-pass" placeholder="PASSWORD" autocomplete="off" autocorrect="off"
onkeydown="if(event.key==='Enter')checkAuth()">
<button id="auth-btn" onclick="checkAuth()">▶ ENTER HOST PANEL</button>
<div id="auth-err">INCORRECT PASSWORD</div>
</div>
</div>
<!-- LOBBY -->
<div class="screen" id="lobby-screen">
<div class="logo">ROUTE<span>RUSH</span></div>
<div class="tagline">// HOST CONTROL PANEL</div>
<div class="lobby-row">
<div class="qr-box">
<div class="box-label">SCAN TO PLAY</div>
<div id="qrcode"></div>
<div class="qr-url" id="join-url"></div>
</div>
<div class="players-box">
<div class="box-label">PLAYERS CONNECTED</div>
<div class="player-count" id="player-count">0</div>
<div class="player-count-sub">/ 16 MAX</div>
<div class="p-chips" id="p-chips"></div>
<div class="rounds-cfg">
<span>ROUNDS:</span>
<select id="rounds-select">
<option value="3">3</option>
<option value="5">5</option>
<option value="7">7</option>
</select>
<span>TIME/ROUND:</span>
<select id="time-select">
<option value="30">30s</option>
<option value="45">45s</option>
<option value="60">60s</option>
</select>
<span>PRIZE AVG:</span>
<input id="prize-threshold" type="number" value="3000" min="500" max="4500" step="100"
style="width:72px;background:var(--surface2);border:1px solid rgba(0,119,204,0.4);
color:var(--accent);font-family:var(--mono);font-size:.8rem;
padding:.4rem .5rem;border-radius:2px;outline:none;text-align:center;">
<span style="font-size:.65rem;color:var(--muted)">= 25 PTS</span>
</div>
</div>
</div>
<div class="btn-row">
<button class="hbtn hbtn-primary" id="start-btn" disabled onclick="startRound()">▶ START GAME</button>
<button class="hbtn hbtn-danger" onclick="clearLobby()">✕ CLEAR LOBBY</button>
<button class="hbtn hbtn-danger" onclick="clearScores()">🗑 CLEAR SCORES</button>
<button class="hbtn hbtn-secondary" onclick="location.href='scores'">📊 HISTORY</button>
</div>
</div>
<!-- GAME -->
<div class="screen" id="game-screen">
<div class="game-left">
<div class="game-hud">
<div>
<div class="hud-meta" id="g-round">ROUND 1 / 3</div>
<div class="hud-meta" id="g-status">ROUTING IN PROGRESS</div>
</div>
<div class="hud-timer" id="host-timer">30</div>
<button class="hbtn hbtn-secondary" onclick="endRound()">END ROUND →</button>
</div>
<div class="objective-bar">
<span>ROUTE FROM</span>
<div class="obj-node obj-start" id="h-obj-start">?</div>
<span style="color:var(--muted)">——▶</span>
<div class="obj-node obj-end" id="h-obj-end">?</div>
<span style="margin-left:auto;color:var(--muted);font-family:var(--mono);font-size:.5rem;" id="h-hop-hint"></span>
</div>
<div class="grid-wrap">
<canvas id="host-canvas"></canvas>
</div>
<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:.4"></div>DOWN</div>
</div>
</div>
<div class="game-right">
<div class="panel">
<div class="panel-title">SUBMISSIONS</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 class="sub-list" id="sub-list"></div>
</div>
<div class="panel">
<div class="panel-title">LEADERBOARD</div>
<div id="live-lb"></div>
</div>
<div class="host-controls">
<button class="hbtn hbtn-secondary" onclick="resetGame()">↺ RESET</button>
</div>
</div>
</div>
<!-- RESULTS -->
<div class="screen" id="results-screen">
<div class="results-top">
<div>
<div class="results-title">ROUND RESULTS</div>
<div class="results-round" id="res-round-label">ROUND 1 / 3</div>
</div>
<div style="display:flex;gap:.75rem;">
<button class="hbtn hbtn-primary" id="next-btn" onclick="nextRound()">NEXT ROUND →</button>
<button class="hbtn hbtn-secondary" onclick="resetGame()">↺ RESET</button>
</div>
</div>
<div class="results-body">
<div class="results-left">
<div style="font-family:var(--mono);font-size:.55rem;color:var(--muted);margin-bottom:.75rem;">
// OPTIMAL PATH HIGHLIGHTED
</div>
<div class="results-grid-wrap">
<canvas id="results-canvas"></canvas>
</div>
</div>
<div class="results-right">
<div>
<div class="panel-title" style="font-family:var(--mono);font-size:.5rem;color:var(--muted);letter-spacing:.12em;margin-bottom:.75rem;">PLAYER ROUTES</div>
<div class="path-cards" id="path-cards"></div>
</div>
<div class="panel">
<div class="panel-title">STANDINGS</div>
<div id="res-lb"></div>
</div>
</div>
</div>
</div>
<!-- PODIUM -->
<div class="screen" id="podium-screen">
<h1>🏁 GAME OVER</h1>
<div class="sub">// FINAL STANDINGS</div>
<div class="podium-stage" id="podium-stage"></div>
<div class="rest-list" id="rest-list"></div>
<div style="display:flex;gap:.75rem;margin-top:1.5rem;">
<button class="hbtn hbtn-primary" onclick="resetGame()">▶ PLAY AGAIN</button>
<button class="hbtn hbtn-secondary" onclick="location.href='scores'">📊 HISTORY</button>
</div>
<div style="font-family:var(--mono);font-size:.65rem;color:var(--muted);margin-top:.75rem;">
PLAY AGAIN will kick all players back to the join screen
</div>
</div>
<script>
// ── AUTH GATE ─────────────────────────────────────────────────────────────────
const HOST_PASS = 'ni2026';
function checkAuth() {
const val = document.getElementById('auth-pass').value;
if (val === HOST_PASS) {
document.getElementById('auth-gate').style.display = 'none';
initHost();
} else {
const inp = document.getElementById('auth-pass');
const err = document.getElementById('auth-err');
inp.classList.remove('shake');
void inp.offsetWidth; // reflow to restart animation
inp.classList.add('shake');
err.classList.add('visible');
inp.value = '';
inp.focus();
setTimeout(() => err.classList.remove('visible'), 2000);
}
}
// ── STATE ────────────────────────────────────────────────────────────────────
let ws, timerInterval;
let currentGrid = null, currentRound = 0, currentTotal = 0, totalPlayers = 0;
let lastSubmissions = [], lastLeaderboard = [];
// ── INIT (called after auth) ──────────────────────────────────────────────────
function initHost() {
// QR Code
const baseUrl = `${location.protocol}//${location.hostname}${location.port?':'+location.port:''}${location.pathname.replace(/\/host\/?$/,'')}`;
document.getElementById('join-url').textContent = baseUrl;
new QRCode(document.getElementById('qrcode'), {
text: baseUrl,
width: 200, height: 200,
colorDark: '#0077cc', colorLight: '#ffffff',
correctLevel: QRCode.CorrectLevel.M
});
connectWS();
show('lobby-screen');
}
// ── CANVAS ────────────────────────────────────────────────────────────────────
const GRID = 4;
const MOD_COLOR = {normal:'#0077cc', slow:'#ff6b00', boost:'#00ff88', blocked:'#ff1744'};
function setupCanvas(canvasEl, wrapEl) {
const size = Math.min(wrapEl.clientWidth, wrapEl.clientHeight, 480);
canvasEl.width = size;
canvasEl.height = size;
return size;
}
function drawGridOnCanvas(canvasEl, grid, highlightPaths=[]) {
if (!grid) return;
const size = canvasEl.width;
const ctx = canvasEl.getContext('2d');
const CELL = size / GRID;
const NODE_R = CELL * 0.14;
const OX = CELL / 2, OY = CELL / 2;
function nodePos(id) {
return { x: OX + (id % GRID) * CELL, y: OY + Math.floor(id / GRID) * CELL };
}
ctx.clearRect(0, 0, size, size);
grid.edges.forEach(e => {
const a = nodePos(e.a), b = nodePos(e.b);
const color = MOD_COLOR[e.modifier] || '#0077cc';
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
if (e.modifier === 'blocked') {
ctx.setLineDash([6,5]);
ctx.strokeStyle = 'rgba(255,23,68,0.55)';
ctx.lineWidth = 2.5;
} else if (e.modifier !== 'normal') {
ctx.setLineDash([]);
ctx.strokeStyle = `${color}bb`;
ctx.lineWidth = 3;
} else {
ctx.setLineDash([]);
ctx.strokeStyle = `${color}66`;
ctx.lineWidth = 2;
}
ctx.stroke();
ctx.setLineDash([]);
if (e.modifier !== 'normal') {
const mx = (a.x+b.x)/2, my = (a.y+b.y)/2;
const label = e.modifier==='slow'?'x2':e.modifier==='boost'?'x½':'✕';
const fontSize = Math.max(14, CELL * 0.22);
ctx.font = `bold ${fontSize}px 'Share Tech Mono',monospace`;
const tw = ctx.measureText(label).width;
const padX = 8, 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 = '#ffffff';
ctx.fill();
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.stroke();
ctx.fillStyle = color;
ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.fillText(label, mx, my);
}
});
const pathColors = ['#0077cc','#cc0033','#ffb800','#cc44ff'];
highlightPaths.forEach((p, pi) => {
if (!p || p.length < 2) return;
const color = pathColors[pi % pathColors.length];
ctx.beginPath();
const first = nodePos(p[0]);
ctx.moveTo(first.x, first.y);
for (let i = 1; i < p.length; i++) {
const pt = nodePos(p[i]);
ctx.lineTo(pt.x, pt.y);
}
ctx.strokeStyle = color;
ctx.lineWidth = 3;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.globalAlpha = 0.7;
ctx.stroke();
ctx.globalAlpha = 1;
ctx.lineCap = 'butt';
ctx.lineJoin = 'miter';
});
grid.nodes.forEach(n => {
const {x,y} = nodePos(n.id);
const isStart = n.id === grid.start;
const isEnd = n.id === grid.end;
if (isStart || isEnd) {
ctx.beginPath();
ctx.arc(x, y, NODE_R*1.7, 0, Math.PI*2);
ctx.fillStyle = isStart ? 'rgba(0,255,224,0.07)' : 'rgba(255,62,108,0.07)';
ctx.fill();
ctx.strokeStyle = isStart ? '#00ffe0' : '#ff3e6c';
ctx.lineWidth = 1.5;
ctx.setLineDash([3,3]);
ctx.stroke();
ctx.setLineDash([]);
}
ctx.beginPath();
ctx.arc(x, y, NODE_R, 0, Math.PI*2);
ctx.fillStyle = isStart ? '#00ffe0' : isEnd ? '#ff3e6c' : '#131928';
ctx.fill();
ctx.strokeStyle = isStart||isEnd ? 'transparent' : 'rgba(0,255,224,0.18)';
ctx.lineWidth = 1.5;
ctx.stroke();
ctx.fillStyle = (isStart||isEnd) ? '#070b14' : '#8899bb';
ctx.font = `bold ${Math.max(12,NODE_R)}px 'Exo 2',sans-serif`;
ctx.textAlign='center';ctx.textBaseline='middle';
ctx.fillText(n.id, 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(/\/host\/?$/, '');
ws = new WebSocket(`${proto}://${location.host}${base}/ws/host`);
ws.onmessage = e => handle(JSON.parse(e.data));
ws.onclose = () => setTimeout(connectWS, 2000);
}
function handle(msg) {
if (msg.type === 'lobby_update') {
document.getElementById('player-count').textContent = msg.count;
document.getElementById('start-btn').disabled = msg.count < 1;
totalPlayers = msg.count;
document.getElementById('p-chips').innerHTML =
msg.players.map(n => `<span class="p-chip">${n}</span>`).join('');
document.getElementById('ans-sub').textContent = `of ${msg.count} players`;
}
else if (msg.type === 'round_start') {
currentGrid = msg.grid;
currentRound = msg.round;
currentTotal = msg.total_rounds;
show('game-screen');
const hc = document.getElementById('host-canvas');
const wrap = document.querySelector('.grid-wrap');
setupCanvas(hc, wrap);
drawGridOnCanvas(hc, currentGrid);
document.getElementById('g-round').textContent = `ROUND ${msg.round} / ${msg.total_rounds}`;
document.getElementById('g-status').textContent = 'ROUTING IN PROGRESS';
document.getElementById('h-obj-start').textContent = currentGrid.start;
document.getElementById('h-obj-end').textContent = currentGrid.end;
document.getElementById('h-hop-hint').textContent = `MIN ${GRID-1} HOPS`;
document.getElementById('ans-count').textContent = '0';
document.getElementById('ans-bar').style.width = '0%';
document.getElementById('sub-list').innerHTML = '';
document.getElementById('live-lb').innerHTML = '';
startTimer(msg.time_limit);
}
else if (msg.type === 'submission_update') {
const pct = totalPlayers > 0 ? (msg.submitted / totalPlayers * 100) : 0;
document.getElementById('ans-count').textContent = msg.submitted;
document.getElementById('ans-sub').textContent = `of ${msg.total} players`;
document.getElementById('ans-bar').style.width = pct + '%';
lastSubmissions = msg.submissions;
document.getElementById('sub-list').innerHTML = msg.submissions.map(s =>
`<div class="sub-item">
<span class="sub-name">${s.name}</span>
<span class="sub-pts ${s.points>0?'ok':'fail'}">+${s.points}</span>
<span class="sub-time">${s.elapsed}s</span>
</div>`).join('');
}
else if (msg.type === 'round_results') {
clearInterval(timerInterval);
lastLeaderboard = msg.leaderboard;
lastSubmissions = msg.submissions;
showResults(msg);
}
else if (msg.type === 'phase' && msg.phase === 'lobby') {
show('lobby-screen');
}
}
function startTimer(secs) {
clearInterval(timerInterval);
let rem = secs;
const el = document.getElementById('host-timer');
function tick() {
el.textContent = rem;
el.classList.toggle('urgent', rem <= 8);
if (rem <= 0) clearInterval(timerInterval);
rem--;
}
tick();
timerInterval = setInterval(tick, 1000);
}
// ── GAME CONTROLS ─────────────────────────────────────────────────────────────
function startRound() {
const rounds = parseInt(document.getElementById('rounds-select').value);
const timeLimit = parseInt(document.getElementById('time-select').value);
const prizeThreshold = parseInt(document.getElementById('prize-threshold').value) || 3000;
ws.send(JSON.stringify({type: 'start_round', total_rounds: rounds, time_limit: timeLimit, prize_threshold: prizeThreshold}));
}
function endRound() {
clearInterval(timerInterval);
ws.send(JSON.stringify({type: 'end_round'}));
}
function nextRound() {
if (currentRound >= currentTotal) {
showPodium(lastLeaderboard);
} else {
ws.send(JSON.stringify({type: 'start_round'}));
}
}
function clearLobby() {
ws.send(JSON.stringify({type: "reset"}));
}
function resetGame() {
clearInterval(timerInterval);
ws.send(JSON.stringify({type: 'reset'}));
show('lobby-screen');
}
async function clearScores() {
if (!confirm('Clear all saved scores? This cannot be undone.')) return;
const r = await fetch('api/scores/clear', {method:'DELETE'});
if (r.ok) alert('Scores cleared.');
}
// ── RESULTS ───────────────────────────────────────────────────────────────────
function showResults(msg) {
show('results-screen');
document.getElementById('res-round-label').textContent = `ROUND ${msg.round} / ${currentTotal}`;
document.getElementById('next-btn').textContent = msg.is_final ? 'PODIUM →' : 'NEXT ROUND →';
const rc = document.getElementById('results-canvas');
const wrap = document.querySelector('.results-grid-wrap');
setupCanvas(rc, wrap);
const paths = msg.submissions.map(s => s.path);
drawGridOnCanvas(rc, msg.grid, paths);
const pathColors = ['#0077cc','#cc0033','#ffb800','#cc44ff'];
document.getElementById('path-cards').innerHTML = msg.submissions.map((s, i) => {
const color = pathColors[i % pathColors.length];
const nodes = (s.path||[]).map(n =>
`<span class="path-node" style="border-color:${color}33;color:${color}">${n}</span>`
).join(`<span class="path-arrow">→</span>`);
return `<div class="path-card" style="border-color:${color}22">
<div class="path-card-top">
<span class="path-player" style="color:${color}">${s.name}</span>
<span class="path-pts ${s.points>0?'ok':'fail'}">+${s.points} PTS</span>
</div>
<div class="path-detail">${s.elapsed}s · ${s.reason}</div>
<div class="path-nodes">${nodes}</div>
</div>`;
}).join('');
const medals = ['🥇','🥈','🥉'];
document.getElementById('res-lb').innerHTML = msg.leaderboard.map((p,i) =>
`<div class="lb-item">
<span class="lb-rank">${i<3?medals[i]:i+1}</span>
<span class="lb-name">${p.name}</span>
<span class="lb-score">${p.score.toLocaleString()} PTS</span>
</div>`).join('');
if (msg.is_final) setTimeout(() => showPodium(msg.leaderboard), 100);
}
// ── PODIUM ────────────────────────────────────────────────────────────────────
function showPodium(lb) {
show('podium-screen');
const medals = ['🥇','🥈','🥉'];
const pbClass = ['pb1','pb2','pb3'];
const order = [1,0,2];
const stage = document.getElementById('podium-stage');
stage.innerHTML = order.filter(i => lb[i]).map(i => {
const p = lb[i];
return `<div class="p-block">
<div class="p-avatar">${medals[i]}</div>
<div class="p-pname">${p.name}</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>
<span class="rest-score">${p.score.toLocaleString()} PTS</span>
</div>`).join('');
confetti();
}
function confetti() {
const colors = ['#0077cc','#cc0033','#ffb800','#00ff88','#cc44ff'];
for (let i = 0; i < 70; i++) {
setTimeout(() => {
const el = document.createElement('div');
el.className = 'cfp';
el.style.cssText = `left:${Math.random()*100}vw;top:-10px;background:${colors[Math.floor(Math.random()*colors.length)]};animation-duration:${2+Math.random()*2}s;`;
document.body.appendChild(el);
setTimeout(() => el.remove(), 4000);
}, i * 45);
}
}
</script>
</body>
</html>