feat: initial Helldivers 2 Stratagem Trainer (practice, 1v1, leaderboard, dashboard)
This commit is contained in:
+778
@@ -0,0 +1,778 @@
|
||||
'use strict';
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────────────────────
|
||||
const state = {
|
||||
user: null, // { user, role, mustChange }
|
||||
currentView: 'login',
|
||||
stratagems: [],
|
||||
|
||||
practice: {
|
||||
active: false,
|
||||
current: null,
|
||||
progress: 0,
|
||||
timeLeft: 30,
|
||||
timerHandle: null,
|
||||
startTime: null,
|
||||
score: 0,
|
||||
streak: 0,
|
||||
selectedCats: new Set(), // empty = all categories
|
||||
dailyTarget: null, // set when using daily challenge shortcut
|
||||
},
|
||||
|
||||
lobby: {
|
||||
online: [],
|
||||
incoming: [], // usernames who challenged me
|
||||
},
|
||||
|
||||
match: {
|
||||
roomId: null,
|
||||
opponent: null,
|
||||
matchScores: {},
|
||||
current: null,
|
||||
myProgress: 0,
|
||||
oppProgress: 0,
|
||||
roundActive: false,
|
||||
},
|
||||
|
||||
ws: null,
|
||||
wsReconnectTimer: null,
|
||||
};
|
||||
|
||||
// ── API helpers ───────────────────────────────────────────────────────────────
|
||||
async function api(method, endpoint, body) {
|
||||
const opts = { method, headers: { 'Content-Type': 'application/json' } };
|
||||
if (body !== undefined) opts.body = JSON.stringify(body);
|
||||
const res = await fetch('/api' + endpoint, opts);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || 'Request failed');
|
||||
return data;
|
||||
}
|
||||
|
||||
// ── View system ───────────────────────────────────────────────────────────────
|
||||
function showView(name) {
|
||||
document.querySelectorAll('.view').forEach(v => v.classList.add('hidden'));
|
||||
const el = document.getElementById('view-' + name);
|
||||
if (el) el.classList.remove('hidden');
|
||||
state.currentView = name;
|
||||
|
||||
// Highlight active nav button
|
||||
document.querySelectorAll('.nav-btn').forEach(b => {
|
||||
b.classList.toggle('active', b.dataset.view === name);
|
||||
});
|
||||
|
||||
// Stop practice timer when navigating away
|
||||
if (name !== 'practice') stopPracticeTimer();
|
||||
|
||||
// View-specific init
|
||||
if (name === 'dashboard') loadDashboard();
|
||||
if (name === 'leaderboard') loadLeaderboard();
|
||||
if (name === 'admin') loadAdmin();
|
||||
if (name === 'practice') initPracticeView();
|
||||
if (name === 'lobby') updateLobbyView();
|
||||
}
|
||||
|
||||
// ── Authentication ────────────────────────────────────────────────────────────
|
||||
async function checkAuth() {
|
||||
try {
|
||||
const data = await api('GET', '/me');
|
||||
if (data.user) {
|
||||
state.user = data;
|
||||
if (data.mustChange) {
|
||||
showView('change-password');
|
||||
} else {
|
||||
onLoggedIn();
|
||||
}
|
||||
} else {
|
||||
showView('login');
|
||||
}
|
||||
} catch {
|
||||
showView('login');
|
||||
}
|
||||
}
|
||||
|
||||
function onLoggedIn() {
|
||||
document.getElementById('main-nav').classList.remove('hidden');
|
||||
document.getElementById('nav-username').textContent = state.user.user;
|
||||
document.getElementById('nav-admin').classList.toggle('hidden', state.user.role !== 'admin');
|
||||
state.stratagems = window.STRATAGEMS || [];
|
||||
connectWS();
|
||||
showView('dashboard');
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
stopPracticeTimer();
|
||||
if (state.ws) state.ws.close();
|
||||
clearTimeout(state.wsReconnectTimer);
|
||||
await api('POST', '/logout').catch(() => {});
|
||||
state.user = null;
|
||||
document.getElementById('main-nav').classList.add('hidden');
|
||||
showView('login');
|
||||
}
|
||||
|
||||
// Login form
|
||||
document.getElementById('login-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const el = document.getElementById('login-error');
|
||||
el.classList.add('hidden');
|
||||
try {
|
||||
await api('POST', '/login', {
|
||||
username: document.getElementById('login-username').value.trim(),
|
||||
password: document.getElementById('login-password').value,
|
||||
});
|
||||
await checkAuth();
|
||||
} catch (err) {
|
||||
el.textContent = err.message;
|
||||
el.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// Change password form
|
||||
document.getElementById('change-password-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const errEl = document.getElementById('cp-error');
|
||||
const newPw = document.getElementById('cp-new').value;
|
||||
const confPw = document.getElementById('cp-confirm').value;
|
||||
errEl.classList.add('hidden');
|
||||
|
||||
if (newPw !== confPw) {
|
||||
errEl.textContent = 'Passwords do not match';
|
||||
errEl.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api('POST', '/change-password', {
|
||||
oldPassword: document.getElementById('cp-old').value,
|
||||
newPassword: newPw,
|
||||
});
|
||||
state.user.mustChange = false;
|
||||
onLoggedIn();
|
||||
} catch (err) {
|
||||
errEl.textContent = err.message;
|
||||
errEl.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// Nav buttons
|
||||
document.querySelectorAll('.nav-btn[data-view]').forEach(btn => {
|
||||
btn.addEventListener('click', () => showView(btn.dataset.view));
|
||||
});
|
||||
|
||||
// ── WebSocket ─────────────────────────────────────────────────────────────────
|
||||
function connectWS() {
|
||||
if (state.ws) return;
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
state.ws = new WebSocket(proto + '//' + location.host);
|
||||
|
||||
state.ws.onopen = () => { clearTimeout(state.wsReconnectTimer); };
|
||||
state.ws.onmessage = (e) => { try { handleWSMessage(JSON.parse(e.data)); } catch {} };
|
||||
state.ws.onerror = () => state.ws.close();
|
||||
state.ws.onclose = () => {
|
||||
state.ws = null;
|
||||
if (state.user) {
|
||||
state.wsReconnectTimer = setTimeout(connectWS, 3000);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function wsSend(type, payload) {
|
||||
if (state.ws?.readyState === WebSocket.OPEN) {
|
||||
state.ws.send(JSON.stringify({ type, payload: payload || {} }));
|
||||
}
|
||||
}
|
||||
|
||||
function handleWSMessage({ type, payload }) {
|
||||
switch (type) {
|
||||
case 'lobby-update':
|
||||
state.lobby.online = payload.online || [];
|
||||
state.lobby.incoming = payload.incoming || [];
|
||||
if (state.currentView === 'lobby') updateLobbyView();
|
||||
if (state.currentView === 'dashboard') updateDashboardOnline(payload.online);
|
||||
updateChallengeBadge();
|
||||
break;
|
||||
|
||||
case 'challenge-received':
|
||||
if (!state.lobby.incoming.includes(payload.from)) state.lobby.incoming.push(payload.from);
|
||||
updateChallengeBadge();
|
||||
if (state.currentView === 'lobby') updateLobbyView();
|
||||
showToast(esc(payload.from) + ' challenges you! Go to 1v1 to respond.');
|
||||
break;
|
||||
|
||||
case 'challenge-declined':
|
||||
showToast(esc(payload.by) + ' declined your challenge.');
|
||||
break;
|
||||
|
||||
case 'room-joined':
|
||||
state.match.roomId = payload.roomId;
|
||||
state.match.opponent = payload.opponent;
|
||||
state.match.matchScores = payload.matchScores;
|
||||
state.match.myProgress = 0;
|
||||
state.match.oppProgress = 0;
|
||||
state.match.roundActive = false;
|
||||
showView('match');
|
||||
renderMatchWaiting();
|
||||
break;
|
||||
|
||||
case 'round-start':
|
||||
state.match.current = payload.stratagem;
|
||||
state.match.myProgress = 0;
|
||||
state.match.oppProgress = 0;
|
||||
state.match.roundActive = true;
|
||||
renderMatchRound();
|
||||
break;
|
||||
|
||||
case 'input-result':
|
||||
if (payload.userId === state.user.user) {
|
||||
state.match.myProgress = payload.progress;
|
||||
updateMyArrows(payload.correct);
|
||||
} else {
|
||||
state.match.oppProgress = payload.progress;
|
||||
updateOppArrows(payload.correct);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'round-complete':
|
||||
state.match.roundActive = false;
|
||||
state.match.matchScores = payload.matchScores;
|
||||
renderRoundResult(payload.winner);
|
||||
break;
|
||||
|
||||
case 'match-end':
|
||||
state.match.matchScores = payload.matchScores;
|
||||
renderMatchEnd(payload.winner);
|
||||
break;
|
||||
|
||||
case 'opponent-left':
|
||||
showToast('Opponent left the match.');
|
||||
setTimeout(() => showView('lobby'), 1800);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Dashboard ─────────────────────────────────────────────────────────────────
|
||||
async function loadDashboard() {
|
||||
try {
|
||||
const data = await api('GET', '/dashboard');
|
||||
renderDashboard(data);
|
||||
} catch {
|
||||
/* silently ignore – dashboard is cosmetic */
|
||||
}
|
||||
}
|
||||
|
||||
function renderDashboard({ stats, rank, online, recent, daily }) {
|
||||
setText('dash-total-score', stats.totalScore || 0);
|
||||
setText('dash-rank', rank ? '#' + rank.position : 'Unranked');
|
||||
setText('dash-sessions', stats.sessions || 0);
|
||||
|
||||
const wr = (stats.matches > 0) ? Math.round((stats.wins / stats.matches) * 100) + '%' : '—';
|
||||
setText('dash-win-rate', wr);
|
||||
|
||||
if (daily) {
|
||||
setText('dash-daily-name', daily.stratagem.name);
|
||||
setText('dash-daily-category', daily.stratagem.category);
|
||||
setText('dash-daily-best', daily.bestTime ? (daily.bestTime / 1000).toFixed(2) + 's' : 'No record yet');
|
||||
// Store for the "Practice this" shortcut
|
||||
state.practice.dailyTarget = daily.stratagem.name;
|
||||
}
|
||||
|
||||
const tbody = document.getElementById('dash-recent');
|
||||
if (recent.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="3" class="muted">No sessions yet</td></tr>';
|
||||
} else {
|
||||
tbody.innerHTML = recent.map(r =>
|
||||
`<tr><td>${esc(r.stratagem)}</td><td>${r.score}</td><td>${(r.time_ms / 1000).toFixed(2)}s</td></tr>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
updateDashboardOnline(online);
|
||||
}
|
||||
|
||||
function updateDashboardOnline(online) {
|
||||
const el = document.getElementById('dash-online');
|
||||
if (!el) return;
|
||||
const others = (online || []).filter(u => u !== state.user?.user);
|
||||
if (others.length === 0) {
|
||||
el.innerHTML = '<span class="muted">No other Helldivers online</span>';
|
||||
} else {
|
||||
el.innerHTML = others.map(u =>
|
||||
`<div class="online-user">
|
||||
<span class="online-dot"></span>
|
||||
<span style="flex:1;font-family:var(--font-mono)">${esc(u)}</span>
|
||||
<button class="btn btn-sm btn-accent" onclick="sendChallenge('${esc(u)}')">⚔ Challenge</button>
|
||||
</div>`
|
||||
).join('');
|
||||
}
|
||||
}
|
||||
|
||||
function startDailyChallenge() {
|
||||
if (!state.practice.dailyTarget) return;
|
||||
state.practice.selectedCats.clear();
|
||||
showView('practice');
|
||||
// start a practice session focused on the daily stratagem
|
||||
const strat = state.stratagems.find(s => s.name === state.practice.dailyTarget);
|
||||
if (strat) {
|
||||
state.practice.selectedCats.add(strat.category);
|
||||
startPractice();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Practice mode ─────────────────────────────────────────────────────────────
|
||||
function initPracticeView() {
|
||||
renderCategoryFilters();
|
||||
if (!state.practice.active) showPracticeIdle();
|
||||
}
|
||||
|
||||
function renderCategoryFilters() {
|
||||
const cats = [...new Set(state.stratagems.map(s => s.category))];
|
||||
const el = document.getElementById('practice-categories');
|
||||
el.innerHTML = cats.map(cat => {
|
||||
const active = state.practice.selectedCats.size === 0 || state.practice.selectedCats.has(cat);
|
||||
return `<button class="cat-btn ${active ? 'active' : ''}"
|
||||
onclick="toggleCategory('${esc(cat)}')">${esc(cat)}</button>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function toggleCategory(cat) {
|
||||
if (state.practice.selectedCats.has(cat)) {
|
||||
state.practice.selectedCats.delete(cat);
|
||||
} else {
|
||||
state.practice.selectedCats.add(cat);
|
||||
}
|
||||
renderCategoryFilters();
|
||||
}
|
||||
|
||||
function showPracticeIdle() {
|
||||
document.getElementById('practice-idle').classList.remove('hidden');
|
||||
document.getElementById('practice-active').classList.add('hidden');
|
||||
state.practice.active = false;
|
||||
}
|
||||
|
||||
function startPractice() {
|
||||
if (getPool().length === 0) { showToast('No stratagems match the selected filters'); return; }
|
||||
state.practice.active = true;
|
||||
state.practice.score = 0;
|
||||
state.practice.streak = 0;
|
||||
|
||||
document.getElementById('practice-idle').classList.add('hidden');
|
||||
document.getElementById('practice-active').classList.remove('hidden');
|
||||
|
||||
nextStratagem();
|
||||
}
|
||||
|
||||
function stopPracticeUI() {
|
||||
stopPracticeTimer();
|
||||
showPracticeIdle();
|
||||
}
|
||||
|
||||
function stopPracticeTimer() {
|
||||
clearInterval(state.practice.timerHandle);
|
||||
state.practice.timerHandle = null;
|
||||
state.practice.active = false;
|
||||
}
|
||||
|
||||
function getPool() {
|
||||
const cats = state.practice.selectedCats;
|
||||
if (cats.size === 0) return state.stratagems;
|
||||
return state.stratagems.filter(s => cats.has(s.category));
|
||||
}
|
||||
|
||||
function nextStratagem() {
|
||||
const pool = getPool();
|
||||
state.practice.current = pool[Math.floor(Math.random() * pool.length)];
|
||||
state.practice.progress = 0;
|
||||
state.practice.timeLeft = 30;
|
||||
state.practice.startTime = Date.now();
|
||||
|
||||
renderPracticeStratagem();
|
||||
startPracticeTimer();
|
||||
}
|
||||
|
||||
function startPracticeTimer() {
|
||||
clearInterval(state.practice.timerHandle);
|
||||
state.practice.timerHandle = setInterval(() => {
|
||||
state.practice.timeLeft--;
|
||||
updateTimerDisplay();
|
||||
if (state.practice.timeLeft <= 0) {
|
||||
clearInterval(state.practice.timerHandle);
|
||||
state.practice.streak = 0;
|
||||
updateStreakDisplay();
|
||||
// Flash timer to signal timeout
|
||||
const timerEl = document.getElementById('practice-timer');
|
||||
timerEl.classList.add('flash-wrong');
|
||||
setTimeout(() => { timerEl.classList.remove('flash-wrong'); nextStratagem(); }, 700);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function renderPracticeStratagem() {
|
||||
const s = state.practice.current;
|
||||
setText('practice-category', s.category);
|
||||
setText('practice-name', s.name);
|
||||
renderArrows('practice-sequence', s.sequence, state.practice.progress);
|
||||
updateTimerDisplay();
|
||||
updateScoreDisplay();
|
||||
updateStreakDisplay();
|
||||
}
|
||||
|
||||
function renderArrows(containerId, sequence, progress) {
|
||||
const ARROW = { up: '↑', down: '↓', left: '←', right: '→' };
|
||||
const el = document.getElementById(containerId);
|
||||
if (!el) return;
|
||||
el.innerHTML = sequence.map((dir, i) => {
|
||||
let cls = 'arrow-key';
|
||||
if (i < progress) cls += ' completed';
|
||||
if (i === progress) cls += ' active';
|
||||
return `<div class="${cls}">${ARROW[dir]}</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function updateTimerDisplay() {
|
||||
const el = document.getElementById('practice-timer');
|
||||
if (!el) return;
|
||||
el.textContent = state.practice.timeLeft;
|
||||
el.className = 'timer' + (state.practice.timeLeft <= 5 ? ' timer-danger' : '');
|
||||
}
|
||||
|
||||
function updateScoreDisplay() { setText('practice-score', state.practice.score); }
|
||||
function updateStreakDisplay() { setText('practice-streak', state.practice.streak); }
|
||||
|
||||
function handlePracticeInput(dir) {
|
||||
const p = state.practice;
|
||||
if (!p.active || !p.current) return;
|
||||
|
||||
const seq = p.current.sequence;
|
||||
const arrows = document.querySelectorAll('#practice-sequence .arrow-key');
|
||||
const curArrow = arrows[p.progress];
|
||||
|
||||
if (dir === seq[p.progress]) {
|
||||
// Correct input
|
||||
curArrow?.classList.add('flash-correct');
|
||||
p.progress++;
|
||||
|
||||
if (p.progress === seq.length) {
|
||||
// Stratagem completed!
|
||||
clearInterval(p.timerHandle);
|
||||
const elapsed = Date.now() - p.startTime;
|
||||
const secs = Math.min(30, elapsed / 1000);
|
||||
const pts = Math.round((100 + (30 - secs) * 3) * (1 + p.streak * 0.1));
|
||||
|
||||
p.score += pts;
|
||||
p.streak++;
|
||||
updateScoreDisplay();
|
||||
updateStreakDisplay();
|
||||
|
||||
// Flash all arrows green
|
||||
document.querySelectorAll('#practice-sequence .arrow-key').forEach(el => {
|
||||
el.classList.remove('flash-correct');
|
||||
el.classList.add('completed');
|
||||
});
|
||||
|
||||
// Save result (fire-and-forget)
|
||||
api('POST', '/scores/practice', {
|
||||
stratagem: p.current.name,
|
||||
category: p.current.category,
|
||||
time_ms: elapsed,
|
||||
score: pts,
|
||||
}).catch(() => {});
|
||||
|
||||
setTimeout(nextStratagem, 600);
|
||||
} else {
|
||||
// Re-render with updated progress (highlights next arrow)
|
||||
renderArrows('practice-sequence', seq, p.progress);
|
||||
}
|
||||
} else {
|
||||
// Wrong input – reset progress
|
||||
curArrow?.classList.add('flash-wrong');
|
||||
p.progress = 0;
|
||||
p.streak = 0;
|
||||
updateStreakDisplay();
|
||||
setTimeout(() => {
|
||||
renderArrows('practice-sequence', seq, 0);
|
||||
}, 350);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Lobby ─────────────────────────────────────────────────────────────────────
|
||||
function updateLobbyView() {
|
||||
const others = state.lobby.online.filter(u => u !== state.user?.user);
|
||||
const el = document.getElementById('lobby-players');
|
||||
if (!el) return;
|
||||
|
||||
if (others.length === 0) {
|
||||
el.innerHTML = '<p class="muted">No other Helldivers online. Waiting for reinforcements...</p>';
|
||||
} else {
|
||||
el.innerHTML = others.map(u =>
|
||||
`<div class="lobby-player">
|
||||
<span class="online-dot"></span>
|
||||
<span class="player-name">${esc(u)}</span>
|
||||
<button class="btn btn-sm btn-accent" onclick="sendChallenge('${esc(u)}')">⚔ Challenge</button>
|
||||
</div>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
// Incoming challenges
|
||||
const challEl = document.getElementById('lobby-challenges');
|
||||
if (!challEl) return;
|
||||
const inc = state.lobby.incoming;
|
||||
if (inc.length === 0) {
|
||||
challEl.innerHTML = '';
|
||||
} else {
|
||||
challEl.innerHTML = inc.map(from =>
|
||||
`<div class="challenge-item">
|
||||
<span style="flex:1"><strong>${esc(from)}</strong> challenges you to a duel!</span>
|
||||
<button class="btn btn-sm btn-accent" onclick="acceptChallenge('${esc(from)}')">Accept</button>
|
||||
<button class="btn btn-sm btn-muted" onclick="declineChallenge('${esc(from)}')">Decline</button>
|
||||
</div>`
|
||||
).join('');
|
||||
}
|
||||
}
|
||||
|
||||
function sendChallenge(target) {
|
||||
wsSend('challenge-user', { targetUser: target });
|
||||
showToast('Challenge sent to ' + esc(target));
|
||||
}
|
||||
|
||||
function acceptChallenge(from) {
|
||||
wsSend('accept-challenge', { challengerId: from });
|
||||
// Remove from incoming list
|
||||
state.lobby.incoming = state.lobby.incoming.filter(u => u !== from);
|
||||
updateChallengeBadge();
|
||||
}
|
||||
|
||||
function declineChallenge(from) {
|
||||
wsSend('decline-challenge', { challengerId: from });
|
||||
state.lobby.incoming = state.lobby.incoming.filter(u => u !== from);
|
||||
updateChallengeBadge();
|
||||
if (state.currentView === 'lobby') updateLobbyView();
|
||||
}
|
||||
|
||||
function updateChallengeBadge() {
|
||||
const badge = document.getElementById('challenge-badge');
|
||||
if (!badge) return;
|
||||
const count = state.lobby.incoming.length;
|
||||
if (count > 0) {
|
||||
badge.textContent = count + ' Challenge' + (count > 1 ? 's' : '') + ' – Go to 1v1';
|
||||
badge.classList.remove('hidden');
|
||||
badge.onclick = () => showView('lobby');
|
||||
} else {
|
||||
badge.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Match ─────────────────────────────────────────────────────────────────────
|
||||
function renderMatchWaiting() {
|
||||
const m = state.match;
|
||||
setText('match-me-name', state.user.user);
|
||||
setText('match-opp-name', m.opponent);
|
||||
setText('match-status', 'Waiting for both players...');
|
||||
setText('match-category', '');
|
||||
renderMatchScores();
|
||||
|
||||
document.getElementById('match-round-area').classList.add('hidden');
|
||||
|
||||
const readyBtn = document.getElementById('match-ready-btn');
|
||||
readyBtn.textContent = 'READY';
|
||||
readyBtn.disabled = false;
|
||||
readyBtn.style.display = 'inline-flex';
|
||||
}
|
||||
|
||||
function renderMatchScores() {
|
||||
const m = state.match;
|
||||
setText('match-me-wins', m.matchScores[state.user.user] ?? 0);
|
||||
setText('match-opp-wins', m.matchScores[m.opponent] ?? 0);
|
||||
}
|
||||
|
||||
function setReady() {
|
||||
wsSend('player-ready');
|
||||
const btn = document.getElementById('match-ready-btn');
|
||||
btn.textContent = 'Ready – waiting for opponent...';
|
||||
btn.disabled = true;
|
||||
}
|
||||
|
||||
function renderMatchRound() {
|
||||
const m = state.match;
|
||||
setText('match-status', m.current.name);
|
||||
setText('match-category', m.current.category);
|
||||
document.getElementById('match-round-area').classList.remove('hidden');
|
||||
document.getElementById('match-ready-btn').style.display = 'none';
|
||||
|
||||
renderArrows('match-me-sequence', m.current.sequence, 0);
|
||||
renderArrows('match-opp-sequence', m.current.sequence, 0);
|
||||
}
|
||||
|
||||
function updateMyArrows(correct) {
|
||||
renderArrows('match-me-sequence', state.match.current.sequence, state.match.myProgress);
|
||||
if (!correct) {
|
||||
const el = document.getElementById('match-me-sequence');
|
||||
el?.classList.add('flash-wrong-seq');
|
||||
setTimeout(() => el?.classList.remove('flash-wrong-seq'), 350);
|
||||
}
|
||||
}
|
||||
|
||||
function updateOppArrows(correct) {
|
||||
renderArrows('match-opp-sequence', state.match.current.sequence, state.match.oppProgress);
|
||||
}
|
||||
|
||||
function handleMatchInput(dir) {
|
||||
if (!state.match.roundActive) return;
|
||||
wsSend('input-arrow', { direction: dir });
|
||||
}
|
||||
|
||||
function renderRoundResult(winner) {
|
||||
const won = winner === state.user.user;
|
||||
setText('match-status', won ? '✓ ROUND WON' : '✗ ROUND LOST');
|
||||
renderMatchScores();
|
||||
|
||||
// Short pause then show ready button for next round
|
||||
setTimeout(() => {
|
||||
document.getElementById('match-round-area').classList.add('hidden');
|
||||
const btn = document.getElementById('match-ready-btn');
|
||||
btn.textContent = 'Ready for next round';
|
||||
btn.disabled = false;
|
||||
btn.style.display = 'inline-flex';
|
||||
setText('match-category', '');
|
||||
}, 1600);
|
||||
}
|
||||
|
||||
function renderMatchEnd(winner) {
|
||||
const won = winner === state.user.user;
|
||||
setText('match-status', won ? '🏆 MATCH WON!' : '☠ MATCH LOST');
|
||||
renderMatchScores();
|
||||
document.getElementById('match-round-area').classList.add('hidden');
|
||||
document.getElementById('match-ready-btn').style.display = 'none';
|
||||
setTimeout(() => showView('lobby'), 3000);
|
||||
}
|
||||
|
||||
function leaveMatch() {
|
||||
wsSend('leave-room');
|
||||
showView('lobby');
|
||||
}
|
||||
|
||||
// ── Leaderboard ───────────────────────────────────────────────────────────────
|
||||
async function loadLeaderboard() {
|
||||
const tbody = document.getElementById('leaderboard-table-body');
|
||||
try {
|
||||
const rows = await api('GET', '/scores/leaderboard');
|
||||
if (rows.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="muted">No scores yet. Start practicing!</td></tr>';
|
||||
} else {
|
||||
tbody.innerHTML = rows.map((r, i) =>
|
||||
`<tr class="${r.username === state.user?.user ? 'row-me' : ''}">
|
||||
<td class="rank">${i + 1}</td>
|
||||
<td style="font-family:var(--font-mono)">${esc(r.username)}</td>
|
||||
<td style="font-family:var(--font-mono)">${r.totalScore}</td>
|
||||
<td style="font-family:var(--font-mono)">${r.sessions}</td>
|
||||
<td style="font-family:var(--font-mono)">${r.wins}/${r.matches}</td>
|
||||
</tr>`
|
||||
).join('');
|
||||
}
|
||||
} catch {
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="muted">Error loading leaderboard</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Admin panel ───────────────────────────────────────────────────────────────
|
||||
async function loadAdmin() {
|
||||
if (state.user?.role !== 'admin') { showView('dashboard'); return; }
|
||||
try {
|
||||
const users = await api('GET', '/users');
|
||||
renderAdminUsers(users);
|
||||
} catch {
|
||||
document.getElementById('admin-users').innerHTML = '<span class="muted">Error loading users</span>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderAdminUsers(users) {
|
||||
const el = document.getElementById('admin-users');
|
||||
el.innerHTML = users.map(u =>
|
||||
`<div class="admin-user-row">
|
||||
<span class="user-name">${esc(u.username)}</span>
|
||||
<span class="user-role badge-${u.role}">${u.role}</span>
|
||||
${u.mustChange ? '<span class="badge-warning">temp pw</span>' : ''}
|
||||
${u.username !== state.user.user
|
||||
? `<button class="btn btn-sm btn-danger" onclick="deleteUser('${esc(u.username)}')">Delete</button>`
|
||||
: ''}
|
||||
</div>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
async function createUser() {
|
||||
const username = document.getElementById('new-username').value.trim();
|
||||
const role = document.getElementById('new-role').value;
|
||||
const errEl = document.getElementById('admin-error');
|
||||
const pwEl = document.getElementById('new-pw-display');
|
||||
errEl.classList.add('hidden');
|
||||
pwEl.classList.add('hidden');
|
||||
|
||||
if (!username) return;
|
||||
try {
|
||||
const result = await api('POST', '/users', { username, role });
|
||||
pwEl.textContent = 'Temp password for ' + esc(username) + ': ' + esc(result.tempPassword);
|
||||
pwEl.classList.remove('hidden');
|
||||
document.getElementById('new-username').value = '';
|
||||
loadAdmin();
|
||||
} catch (err) {
|
||||
errEl.textContent = err.message;
|
||||
errEl.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUser(username) {
|
||||
if (!confirm('Delete "' + username + '"? This cannot be undone.')) return;
|
||||
try {
|
||||
await api('DELETE', '/users/' + encodeURIComponent(username));
|
||||
loadAdmin();
|
||||
} catch (err) {
|
||||
showToast('Error: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Keyboard input ────────────────────────────────────────────────────────────
|
||||
document.addEventListener('keydown', (e) => {
|
||||
const MAP = { ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right' };
|
||||
const dir = MAP[e.key];
|
||||
if (!dir) return;
|
||||
|
||||
if (state.currentView === 'practice' || state.currentView === 'match') {
|
||||
e.preventDefault(); // prevent page scroll
|
||||
dpadInput(dir);
|
||||
}
|
||||
});
|
||||
|
||||
// Called by on-screen D-pad and keyboard handler
|
||||
function dpadInput(dir) {
|
||||
if (state.currentView === 'practice') handlePracticeInput(dir);
|
||||
if (state.currentView === 'match') handleMatchInput(dir);
|
||||
}
|
||||
|
||||
// ── Utils ─────────────────────────────────────────────────────────────────────
|
||||
function esc(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function setText(id, value) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = value;
|
||||
}
|
||||
|
||||
function showToast(msg) {
|
||||
const container = document.getElementById('toast-container');
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'toast';
|
||||
toast.textContent = msg;
|
||||
container.appendChild(toast);
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => toast.classList.add('show'));
|
||||
});
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('show');
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 3200);
|
||||
}
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────────────────
|
||||
document.addEventListener('DOMContentLoaded', checkAuth);
|
||||
Reference in New Issue
Block a user