Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
809 lines
33 KiB
HTML
809 lines
33 KiB
HTML
<!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> |