Upload files to "templates"
This commit is contained in:
@@ -0,0 +1,708 @@
|
|||||||
|
<!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:#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: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,255,224,0.025) 1px,transparent 1px),
|
||||||
|
linear-gradient(90deg,rgba(0,255,224,0.025) 1px,transparent 1px);
|
||||||
|
background-size:40px 40px;pointer-events:none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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,255,224,.4);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,255,224,.1);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,255,224,.1);
|
||||||
|
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,255,224,.25);
|
||||||
|
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,255,224,.25);
|
||||||
|
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,255,224,.08);
|
||||||
|
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,255,224,.2);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,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);}
|
||||||
|
|
||||||
|
/* host grid */
|
||||||
|
.grid-wrap{flex:1;display:flex;align-items:center;justify-content:center;}
|
||||||
|
#host-canvas{display:block;border-radius:4px;}
|
||||||
|
|
||||||
|
/* legend */
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* submission panel */
|
||||||
|
.panel{background:var(--surface);border:1px solid rgba(0,255,224,.15);
|
||||||
|
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;}
|
||||||
|
|
||||||
|
/* submission list */
|
||||||
|
.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,255,224,.1);
|
||||||
|
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;}
|
||||||
|
|
||||||
|
/* leaderboard */
|
||||||
|
.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,255,224,.1);}
|
||||||
|
.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,255,224,.08);
|
||||||
|
}
|
||||||
|
.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 grid */
|
||||||
|
.results-left{flex:1;display:flex;flex-direction:column;
|
||||||
|
padding:1.25rem;border-right:1px solid rgba(0,255,224,.08);}
|
||||||
|
.results-grid-wrap{flex:1;display:flex;align-items:center;justify-content:center;}
|
||||||
|
#results-canvas{display:block;border-radius:4px;}
|
||||||
|
|
||||||
|
/* results right */
|
||||||
|
.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,255,224,.08);
|
||||||
|
}
|
||||||
|
.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,255,224,.15);
|
||||||
|
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,255,224,.12);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>
|
||||||
|
|
||||||
|
<!-- LOBBY -->
|
||||||
|
<div class="screen active" 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">/ 4 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>
|
||||||
|
</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="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>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ── STATE ────────────────────────────────────────────────────────────────────
|
||||||
|
let ws, timerInterval;
|
||||||
|
let currentGrid = null, currentRound = 0, currentTotal = 0, totalPlayers = 0;
|
||||||
|
let lastSubmissions = [], lastLeaderboard = [];
|
||||||
|
|
||||||
|
// ── QR ────────────────────────────────────────────────────────────────────────
|
||||||
|
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: '#00ffe0', colorLight: '#0d1220',
|
||||||
|
correctLevel: QRCode.CorrectLevel.M
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── CANVAS ────────────────────────────────────────────────────────────────────
|
||||||
|
const GRID = 4;
|
||||||
|
const MOD_COLOR = {normal:'#00ffe0', 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.2;
|
||||||
|
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);
|
||||||
|
|
||||||
|
// edges
|
||||||
|
grid.edges.forEach(e => {
|
||||||
|
const a = nodePos(e.a), b = nodePos(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([4,4]);
|
||||||
|
ctx.strokeStyle = 'rgba(255,23,68,0.25)';
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
} else {
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
ctx.strokeStyle = `${color}2a`;
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
|
||||||
|
// modifier label
|
||||||
|
if (e.modifier !== 'normal') {
|
||||||
|
const mx = (a.x+b.x)/2, my = (a.y+b.y)/2;
|
||||||
|
const label = e.modifier==='slow'?'2×':e.modifier==='boost'?'½×':'✕';
|
||||||
|
ctx.fillStyle='rgba(7,11,20,0.9)';
|
||||||
|
ctx.fillRect(mx-12,my-9,24,18);
|
||||||
|
ctx.fillStyle=color;
|
||||||
|
ctx.font=`bold ${Math.max(12,CELL*.16)}px 'Share Tech Mono',monospace`;
|
||||||
|
ctx.textAlign='center';ctx.textBaseline='middle';
|
||||||
|
ctx.fillText(label,mx,my);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// highlighted paths (player routes)
|
||||||
|
const pathColors = ['#00ffe0','#ff3e6c','#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';
|
||||||
|
});
|
||||||
|
|
||||||
|
// nodes
|
||||||
|
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);
|
||||||
|
ws.send(JSON.stringify({type: 'start_round', total_rounds: rounds, time_limit: timeLimit}));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 →';
|
||||||
|
|
||||||
|
// Draw grid with all paths
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Path cards
|
||||||
|
const pathColors = ['#00ffe0','#ff3e6c','#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('');
|
||||||
|
|
||||||
|
// Leaderboard
|
||||||
|
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 = ['#00ffe0','#ff3e6c','#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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
connectWS();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,771 @@
|
|||||||
|
<!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:2.8rem;
|
||||||
|
letter-spacing:-0.02em;line-height:1;margin-bottom:.25rem;
|
||||||
|
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:.8rem;color:var(--text);
|
||||||
|
letter-spacing:.15em;margin-bottom:2.5rem;}
|
||||||
|
|
||||||
|
.field{width:100%;max-width:300px;margin-bottom:1rem;text-align:left;}
|
||||||
|
.field label{font-family:var(--mono);font-size:.75rem;color:var(--text);
|
||||||
|
letter-spacing:.12em;display:block;margin-bottom:.4rem;}
|
||||||
|
.field input{
|
||||||
|
width:100%;padding:.9rem 1rem;
|
||||||
|
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.1rem;font-weight:600;
|
||||||
|
outline:none;transition:border-color .2s;
|
||||||
|
}
|
||||||
|
.field input:focus{border-color:var(--accent);}
|
||||||
|
.field input::placeholder{color:var(--muted);}
|
||||||
|
.field select{
|
||||||
|
width:100%;padding:.9rem 1rem;
|
||||||
|
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.1rem;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:300px;padding:1.1rem;margin-top:.5rem;
|
||||||
|
background:transparent;
|
||||||
|
border:1px solid var(--accent);
|
||||||
|
border-radius:3px;
|
||||||
|
color:var(--accent);font-family:var(--mono);font-size:.9rem;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>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">▶ CONNECT</button>
|
||||||
|
</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;
|
||||||
|
// Reserve: padding(16) + hud(44) + gap(8) + objective(46) + gap(8) + submit(52) + gap(8) + legend(26) + bottom(8)
|
||||||
|
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([4, 4]);
|
||||||
|
ctx.strokeStyle = inPath ? color : 'rgba(255,23,68,0.3)';
|
||||||
|
ctx.lineWidth = inPath ? 3 : 1.5;
|
||||||
|
} else {
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
ctx.strokeStyle = inPath ? color : `${color}33`;
|
||||||
|
ctx.lineWidth = inPath ? 3.5 : 1.5;
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
|
||||||
|
// modifier label on edge midpoint
|
||||||
|
if (e.modifier !== 'normal') {
|
||||||
|
const mx = (a.x + b.x) / 2;
|
||||||
|
const my = (a.y + b.y) / 2;
|
||||||
|
const label = e.modifier === 'slow' ? '2×' : e.modifier === 'boost' ? '½×' : '✕';
|
||||||
|
ctx.fillStyle = 'rgba(7,11,20,0.88)';
|
||||||
|
ctx.fillRect(mx - 12, my - 8, 24, 16);
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.font = `bold ${Math.max(11, CELL * 0.17)}px 'Share Tech Mono', monospace`;
|
||||||
|
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;
|
||||||
|
|
||||||
|
// outer ring for start/end
|
||||||
|
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([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// node circle
|
||||||
|
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();
|
||||||
|
|
||||||
|
// node label
|
||||||
|
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) {
|
||||||
|
// shouldn't happen since start is pre-selected, but safety fallback
|
||||||
|
if (nid !== grid.start) return;
|
||||||
|
path.push(nid);
|
||||||
|
} else {
|
||||||
|
const last = path[path.length - 1];
|
||||||
|
if (nid === last) return; // tapped same node, ignore
|
||||||
|
// backtrack
|
||||||
|
const prev = path[path.length - 2];
|
||||||
|
if (prev !== undefined && nid === prev) {
|
||||||
|
path.pop();
|
||||||
|
} else {
|
||||||
|
if (!isAdjacent(last, nid)) return;
|
||||||
|
if (path.includes(nid)) return; // no loops
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Touch / mouse handlers
|
||||||
|
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';
|
||||||
|
// Support sub-path proxy (e.g. /route-rush/)
|
||||||
|
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 === '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;
|
||||||
|
|
||||||
|
// highlight empty fields
|
||||||
|
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]; // auto-select start node
|
||||||
|
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) {
|
||||||
|
// Could show full grid reveal — for now stay on result screen, host controls flow
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Route Rush · History</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;}
|
||||||
|
:root{
|
||||||
|
--bg:#070b14;
|
||||||
|
--surface:#0d1220;
|
||||||
|
--surface2:#131928;
|
||||||
|
--accent:#00ffe0;
|
||||||
|
--accent2:#ff3e6c;
|
||||||
|
--warn:#ffb800;
|
||||||
|
--boost:#00ff88;
|
||||||
|
--text:#ddeeff;
|
||||||
|
--muted:#3a4a66;
|
||||||
|
--mono:'Share Tech Mono',monospace;
|
||||||
|
--sans:'Exo 2',sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,body{min-height:100vh;background:var(--bg);color:var(--text);font-family:var(--sans);}
|
||||||
|
|
||||||
|
body::before{
|
||||||
|
content:'';position:fixed;inset:0;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgba(0,255,224,0.025) 1px,transparent 1px),
|
||||||
|
linear-gradient(90deg,rgba(0,255,224,0.025) 1px,transparent 1px);
|
||||||
|
background-size:40px 40px;pointer-events:none;z-index:0;
|
||||||
|
}
|
||||||
|
|
||||||
|
header{
|
||||||
|
position:relative;z-index:1;
|
||||||
|
padding:1.75rem 2rem 1.25rem;
|
||||||
|
border-bottom:1px solid rgba(0,255,224,0.08);
|
||||||
|
display:flex;align-items:flex-end;justify-content:space-between;flex-wrap:wrap;gap:1rem;
|
||||||
|
}
|
||||||
|
.header-left h1{
|
||||||
|
font-family:var(--sans);font-weight:900;font-size:2rem;letter-spacing:-0.03em;
|
||||||
|
color:var(--accent);text-shadow:0 0 20px rgba(0,255,224,.3);line-height:1;
|
||||||
|
}
|
||||||
|
.header-left h1 span{color:var(--accent2);}
|
||||||
|
.header-left p{font-family:var(--mono);font-size:.55rem;color:var(--muted);letter-spacing:.12em;margin-top:.3rem;}
|
||||||
|
.nav-links{display:flex;gap:.5rem;}
|
||||||
|
.nav-links a{
|
||||||
|
font-family:var(--mono);font-size:.6rem;color:var(--muted);
|
||||||
|
text-decoration:none;padding:.35rem .8rem;
|
||||||
|
border:1px solid rgba(0,255,224,.1);border-radius:2px;
|
||||||
|
transition:all .15s;letter-spacing:.08em;
|
||||||
|
}
|
||||||
|
.nav-links a:hover{color:var(--accent);border-color:rgba(0,255,224,.3);}
|
||||||
|
|
||||||
|
main{position:relative;z-index:1;max-width:960px;margin:0 auto;padding:2rem;}
|
||||||
|
|
||||||
|
/* loading / empty */
|
||||||
|
#loading{text-align:center;color:var(--muted);padding:4rem;font-family:var(--mono);font-size:.75rem;
|
||||||
|
letter-spacing:.1em;}
|
||||||
|
#loading::after{content:'...';animation:dots 1.2s step-end infinite;}
|
||||||
|
@keyframes dots{0%{content:''}33%{content:'.'}66%{content:'..'}100%{content:'...'}}
|
||||||
|
#empty{display:none;text-align:center;padding:4rem;font-family:var(--mono);font-size:.7rem;color:var(--muted);}
|
||||||
|
#empty .icon{font-size:3rem;margin-bottom:1rem;display:block;}
|
||||||
|
|
||||||
|
#content{display:none;}
|
||||||
|
|
||||||
|
/* section */
|
||||||
|
.section{margin-bottom:2.5rem;}
|
||||||
|
.section-title{
|
||||||
|
display:flex;align-items:center;gap:.75rem;margin-bottom:1.25rem;
|
||||||
|
}
|
||||||
|
.section-title h2{
|
||||||
|
font-family:var(--sans);font-weight:900;font-size:1.1rem;letter-spacing:-0.01em;
|
||||||
|
}
|
||||||
|
.section-title .pill{
|
||||||
|
font-family:var(--mono);font-size:.45rem;letter-spacing:.15em;
|
||||||
|
padding:.2rem .5rem;border-radius:2px;
|
||||||
|
background:rgba(0,255,224,.06);border:1px solid rgba(0,255,224,.15);color:var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* top cards */
|
||||||
|
.top-row{display:flex;gap:.75rem;flex-wrap:wrap;}
|
||||||
|
.top-card{
|
||||||
|
flex:1;min-width:140px;
|
||||||
|
background:var(--surface);border:1px solid rgba(0,255,224,.08);border-radius:4px;
|
||||||
|
padding:.9rem 1.1rem;display:flex;align-items:center;gap:.75rem;
|
||||||
|
transition:border-color .2s;
|
||||||
|
}
|
||||||
|
.top-card:hover{border-color:rgba(0,255,224,.2);}
|
||||||
|
.top-card.gold{border-color:rgba(255,184,0,.3);background:rgba(255,184,0,.04);}
|
||||||
|
.top-card.silver{border-color:rgba(180,180,180,.2);}
|
||||||
|
.top-card.bronze{border-color:rgba(160,90,30,.2);}
|
||||||
|
.top-medal{font-size:1.6rem;}
|
||||||
|
.top-info .top-name{font-weight:700;font-size:.9rem;margin-bottom:.15rem;}
|
||||||
|
.top-info .top-score{
|
||||||
|
font-family:var(--sans);font-weight:900;font-size:1.2rem;
|
||||||
|
letter-spacing:-0.02em;color:var(--accent);line-height:1;
|
||||||
|
}
|
||||||
|
.top-info .top-meta{font-family:var(--mono);font-size:.45rem;color:var(--muted);margin-top:.15rem;}
|
||||||
|
|
||||||
|
/* sessions */
|
||||||
|
.session-card{
|
||||||
|
background:var(--surface);border:1px solid rgba(0,255,224,.08);border-radius:4px;
|
||||||
|
margin-bottom:.75rem;overflow:hidden;
|
||||||
|
}
|
||||||
|
.session-header{
|
||||||
|
display:flex;align-items:center;justify-content:space-between;
|
||||||
|
padding:.9rem 1.1rem;cursor:pointer;transition:background .15s;
|
||||||
|
}
|
||||||
|
.session-header:hover{background:var(--surface2);}
|
||||||
|
.session-meta{display:flex;align-items:center;gap:.9rem;flex-wrap:wrap;}
|
||||||
|
.session-id{font-family:var(--mono);font-size:.5rem;color:var(--muted);letter-spacing:.1em;}
|
||||||
|
.session-date{font-size:.85rem;font-weight:600;}
|
||||||
|
.session-badge{
|
||||||
|
font-family:var(--mono);font-size:.5rem;
|
||||||
|
padding:.2rem .55rem;border-radius:2px;
|
||||||
|
background:rgba(0,255,224,.06);border:1px solid rgba(0,255,224,.15);color:var(--accent);
|
||||||
|
}
|
||||||
|
.session-right{display:flex;align-items:center;gap:.75rem;}
|
||||||
|
.session-rounds{font-family:var(--mono);font-size:.5rem;color:var(--muted);}
|
||||||
|
.chevron{color:var(--muted);transition:transform .2s;font-size:.9rem;}
|
||||||
|
|
||||||
|
.session-body{display:none;border-top:1px solid rgba(0,255,224,.06);}
|
||||||
|
.session-body.open{display:block;}
|
||||||
|
|
||||||
|
table{width:100%;border-collapse:collapse;}
|
||||||
|
thead th{
|
||||||
|
text-align:left;font-family:var(--mono);font-size:.5rem;letter-spacing:.12em;
|
||||||
|
color:var(--muted);padding:.65rem 1.1rem;
|
||||||
|
border-bottom:1px solid rgba(0,255,224,.06);
|
||||||
|
}
|
||||||
|
tbody td{padding:.6rem 1.1rem;font-size:.85rem;border-bottom:1px solid rgba(255,255,255,.02);}
|
||||||
|
tbody tr:last-child td{border:none;}
|
||||||
|
.td-rank{font-family:var(--mono);font-weight:700;color:var(--muted);font-size:.8rem;}
|
||||||
|
.td-score{font-family:var(--sans);font-weight:800;color:var(--accent);font-size:.95rem;}
|
||||||
|
.td-medal{font-size:1rem;}
|
||||||
|
|
||||||
|
/* global leaderboard table */
|
||||||
|
.global-table-wrap{
|
||||||
|
background:var(--surface);border:1px solid rgba(0,255,224,.08);border-radius:4px;overflow:hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* fade-up */
|
||||||
|
@keyframes fadeUp{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:none}}
|
||||||
|
.fade-up{animation:fadeUp .4s ease both;}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<div class="header-left">
|
||||||
|
<h1>ROUTE<span>RUSH</span></h1>
|
||||||
|
<p>// SCORE HISTORY</p>
|
||||||
|
</div>
|
||||||
|
<nav class="nav-links">
|
||||||
|
<a href="host">← HOST PANEL</a>
|
||||||
|
<a href=".">PLAY</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div id="loading">LOADING HISTORY</div>
|
||||||
|
<div id="empty"><span class="icon">📡</span>No sessions yet.<br>Scores are saved when a game ends.</div>
|
||||||
|
|
||||||
|
<div id="content">
|
||||||
|
|
||||||
|
<!-- TOP PLAYERS -->
|
||||||
|
<div class="section" id="section-top">
|
||||||
|
<div class="section-title">
|
||||||
|
<h2>🏆 All-Time Top Players</h2>
|
||||||
|
<span class="pill">BEST SCORE</span>
|
||||||
|
</div>
|
||||||
|
<div class="top-row" id="top-row"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- GLOBAL RANKING -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">
|
||||||
|
<h2>📊 Global Ranking</h2>
|
||||||
|
<span class="pill">ALL SESSIONS</span>
|
||||||
|
</div>
|
||||||
|
<div class="global-table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th></th>
|
||||||
|
<th>PLAYER</th>
|
||||||
|
<th>BEST SCORE</th>
|
||||||
|
<th>SESSIONS</th>
|
||||||
|
<th>AVG SCORE</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="global-tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SESSIONS -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">
|
||||||
|
<h2>📋 Sessions</h2>
|
||||||
|
<span class="pill" id="session-count-pill">0 GAMES</span>
|
||||||
|
</div>
|
||||||
|
<div id="sessions-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const medals = ['🥇','🥈','🥉'];
|
||||||
|
const rankClass = ['gold','silver','bronze'];
|
||||||
|
|
||||||
|
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';
|
||||||
|
document.getElementById('session-count-pill').textContent = `${data.length} GAME${data.length>1?'S':''}`;
|
||||||
|
|
||||||
|
// ── BUILD PLAYER MAP ──────────────────────────────────────────────────────
|
||||||
|
const playerMap = {};
|
||||||
|
data.forEach(s => {
|
||||||
|
s.players.forEach(p => {
|
||||||
|
if (!playerMap[p.player]) {
|
||||||
|
playerMap[p.player] = { best: p.score, total: p.score, sessions: 1 };
|
||||||
|
} else {
|
||||||
|
if (p.score > playerMap[p.player].best) playerMap[p.player].best = p.score;
|
||||||
|
playerMap[p.player].total += p.score;
|
||||||
|
playerMap[p.player].sessions++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const sorted = Object.entries(playerMap).sort((a,b) => b[1].best - a[1].best);
|
||||||
|
|
||||||
|
// ── TOP CARDS ─────────────────────────────────────────────────────────────
|
||||||
|
const topRow = document.getElementById('top-row');
|
||||||
|
sorted.slice(0, 5).forEach(([name, info], i) => {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = `top-card ${rankClass[i]||''} fade-up`;
|
||||||
|
card.style.animationDelay = `${i*.06}s`;
|
||||||
|
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()}</div>
|
||||||
|
<div class="top-meta">${info.sessions} SESSION${info.sessions>1?'S':''}</div>
|
||||||
|
</div>`;
|
||||||
|
topRow.appendChild(card);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GLOBAL TABLE ──────────────────────────────────────────────────────────
|
||||||
|
const tbody = document.getElementById('global-tbody');
|
||||||
|
sorted.forEach(([name, info], i) => {
|
||||||
|
const avg = Math.round(info.total / info.sessions);
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td class="td-medal">${i<3?medals[i]:''}<span class="td-rank" style="${i>=3?'':'display:none'}">#${i+1}</span></td>
|
||||||
|
<td style="font-weight:600">${name}</td>
|
||||||
|
<td class="td-score">${info.best.toLocaleString()}</td>
|
||||||
|
<td style="font-family:var(--mono);font-size:.75rem;color:var(--muted)">${info.sessions}</td>
|
||||||
|
<td style="font-family:var(--mono);font-size:.75rem;color:var(--muted)">${avg.toLocaleString()}</td>`;
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── SESSIONS ──────────────────────────────────────────────────────────────
|
||||||
|
const list = document.getElementById('sessions-list');
|
||||||
|
data.forEach(session => {
|
||||||
|
const date = new Date(session.started_at + 'Z').toLocaleString('en-US', {
|
||||||
|
month:'short', day:'numeric', hour:'2-digit', minute:'2-digit'
|
||||||
|
});
|
||||||
|
const winner = session.players[0];
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'session-card fade-up';
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="session-header" onclick="toggle(this)">
|
||||||
|
<div class="session-meta">
|
||||||
|
<span class="session-id">SESSION #${session.id}</span>
|
||||||
|
<span class="session-date">${date}</span>
|
||||||
|
${winner ? `<span class="session-badge">🥇 ${winner.player} · ${winner.score.toLocaleString()} PTS</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="session-right">
|
||||||
|
<span class="session-rounds">${session.rounds} RND${session.rounds!==1?'S':''}</span>
|
||||||
|
<span class="chevron">▼</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="session-body">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th></th><th>PLAYER</th><th>SCORE</th><th>RANK</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
${session.players.map((p,i) => `
|
||||||
|
<tr>
|
||||||
|
<td class="td-medal">${i<3?medals[i]:''}</td>
|
||||||
|
<td style="font-weight:600">${p.player}</td>
|
||||||
|
<td class="td-score">${p.score.toLocaleString()}</td>
|
||||||
|
<td style="font-family:var(--mono);font-size:.7rem;color:var(--muted)">#${p.rank}</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