Files
route-rush/templates/player.html
T

771 lines
26 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Route Rush</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Exo+2:wght@400;600;800;900&display=swap');
*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent;}
:root{
--bg:#070b14;
--surface:#0d1220;
--surface2:#131928;
--accent:#00ffe0;
--accent2:#ff3e6c;
--warn:#ffb800;
--slow:#ff6b00;
--boost:#00ff88;
--blocked:#ff1744;
--text:#ddeeff;
--muted:#3a4a66;
--mono:'Share Tech Mono',monospace;
--sans:'Exo 2',sans-serif;
}
html,body{
width:100%;height:100%;
background:var(--bg);
color:var(--text);
font-family:var(--sans);
overflow:hidden;
touch-action:none;
}
/* scanline */
body::after{
content:'';position:fixed;inset:0;
background:repeating-linear-gradient(0deg,transparent,transparent 3px,rgba(0,255,224,0.015) 3px,rgba(0,255,224,0.015) 4px);
pointer-events:none;z-index:9999;
}
/* grid bg */
body::before{
content:'';position:fixed;inset:0;
background-image:
linear-gradient(rgba(0,255,224,0.03) 1px,transparent 1px),
linear-gradient(90deg,rgba(0,255,224,0.03) 1px,transparent 1px);
background-size:32px 32px;
pointer-events:none;
}
/* ── SCREENS ── */
.screen{display:none;flex-direction:column;align-items:center;justify-content:center;
min-height:100vh;min-height:100dvh;padding:1.5rem;text-align:center;}
.screen.active{display:flex;}
/* ── JOIN ── */
#join-screen{
height:100vh;height:100dvh;
overflow-y:auto;
justify-content:center;
}
/* ── JOIN ── */
#join-screen .logo{
font-family:var(--sans);font-weight:900;font-size: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>