1593 lines
59 KiB
JavaScript
1593 lines
59 KiB
JavaScript
'use strict';
|
||
|
||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||
const RING_CIRCUMFERENCE = 219.9; // 2π × r(35)
|
||
const ELO_RANKS = [
|
||
{ label: 'PRIVATE', min: 0, icon: '⚡' },
|
||
{ label: 'SERGEANT', min: 1100, icon: '★' },
|
||
{ label: 'LIEUTENANT', min: 1300, icon: '☆' },
|
||
{ label: 'CAPTAIN', min: 1500, icon: '⚔' },
|
||
{ label: 'GENERAL', min: 1700, icon: '🏆' },
|
||
];
|
||
|
||
function eloRankFor(elo) {
|
||
for (let i = ELO_RANKS.length - 1; i >= 0; i--) {
|
||
if (elo >= ELO_RANKS[i].min) return ELO_RANKS[i];
|
||
}
|
||
return ELO_RANKS[0];
|
||
}
|
||
|
||
// ── State ─────────────────────────────────────────────────────────────────────
|
||
const state = {
|
||
user: null,
|
||
currentView: 'login',
|
||
stratagems: [],
|
||
|
||
settings: {
|
||
timerDuration: 30, // 15 | 30 | 45
|
||
difficulty: 'normal', // 'easy' | 'normal' | 'hard'
|
||
},
|
||
|
||
practice: {
|
||
active: false,
|
||
mode: 'timed',
|
||
current: null,
|
||
queue: [], // upcoming stratagems (for queue preview)
|
||
progress: 0,
|
||
timeLeft: 30,
|
||
timerHandle: null,
|
||
startTime: null,
|
||
score: 0,
|
||
streak: 0,
|
||
selectedCats: new Set(),
|
||
dailyTarget: null,
|
||
|
||
// Endless mode
|
||
lives: 3,
|
||
|
||
// Drill mode
|
||
drillPool: [],
|
||
drillCompleted: 0,
|
||
drillTotal: 0,
|
||
|
||
// Speedrun mode
|
||
speedrunStart: null,
|
||
speedrunPool: [],
|
||
speedrunElapsed: 0,
|
||
|
||
// Session stats
|
||
sessionStats: { completed: 0, missed: 0, bestTime: Infinity, stratagems: {} },
|
||
},
|
||
|
||
lobby: {
|
||
online: [],
|
||
incoming: [],
|
||
pendingChallenge: null,
|
||
},
|
||
|
||
match: {
|
||
roomId: null,
|
||
opponent: null,
|
||
matchScores: {},
|
||
current: null,
|
||
myProgress: 0,
|
||
oppProgress: 0,
|
||
roundActive: false,
|
||
roundHistory: [],
|
||
},
|
||
|
||
leaderboard: { activeTab: 'practice' },
|
||
|
||
history: { page: 1, total: 0 },
|
||
|
||
ws: null,
|
||
wsReconnectTimer: null,
|
||
};
|
||
|
||
// ── Settings ──────────────────────────────────────────────────────────────────
|
||
function loadSettings() {
|
||
try {
|
||
const raw = localStorage.getItem('hd2-settings');
|
||
if (raw) {
|
||
const s = JSON.parse(raw);
|
||
if ([15, 30, 45].includes(s.timerDuration)) state.settings.timerDuration = s.timerDuration;
|
||
if (['easy', 'normal', 'hard'].includes(s.difficulty)) state.settings.difficulty = s.difficulty;
|
||
}
|
||
} catch { /* ignore */ }
|
||
applySettingsToUI();
|
||
}
|
||
|
||
function saveSettings() {
|
||
localStorage.setItem('hd2-settings', JSON.stringify(state.settings));
|
||
}
|
||
|
||
function applySettingsToUI() {
|
||
document.querySelectorAll('[data-setting="timer"]').forEach(btn => {
|
||
btn.classList.toggle('active', Number(btn.dataset.value) === state.settings.timerDuration);
|
||
});
|
||
document.querySelectorAll('[data-setting="difficulty"]').forEach(btn => {
|
||
btn.classList.toggle('active', btn.dataset.value === state.settings.difficulty);
|
||
});
|
||
}
|
||
|
||
// ── API ───────────────────────────────────────────────────────────────────────
|
||
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');
|
||
v.classList.remove('view-fade-in');
|
||
});
|
||
const el = document.getElementById('view-' + name);
|
||
if (el) {
|
||
el.classList.remove('hidden');
|
||
requestAnimationFrame(() => el.classList.add('view-fade-in'));
|
||
}
|
||
state.currentView = name;
|
||
|
||
document.querySelectorAll('.nav-btn').forEach(b => {
|
||
b.classList.toggle('active', b.dataset.view === name);
|
||
});
|
||
|
||
if (name !== 'practice') stopPracticeTimer();
|
||
if (name === 'dashboard') loadDashboard();
|
||
if (name === 'leaderboard') loadLeaderboard();
|
||
if (name === 'admin') loadAdmin();
|
||
if (name === 'practice') initPracticeView();
|
||
if (name === 'lobby') updateLobbyView();
|
||
if (name === 'history') loadHistory();
|
||
}
|
||
|
||
// ── Auth ──────────────────────────────────────────────────────────────────────
|
||
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'); }
|
||
}
|
||
|
||
async 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');
|
||
document.getElementById('drawer-admin')?.classList.toggle('hidden', state.user.role !== 'admin');
|
||
state.stratagems = await api('GET', '/stratagems').catch(() => []);
|
||
populateCategoryFilter();
|
||
loadSettings();
|
||
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');
|
||
}
|
||
|
||
function populateCategoryFilter() {
|
||
const cats = [...new Set(state.stratagems.map(s => s.category))];
|
||
const sel = document.getElementById('history-filter-cat');
|
||
if (!sel) return;
|
||
// Remove old options except the first
|
||
while (sel.options.length > 1) sel.remove(1);
|
||
cats.forEach(cat => {
|
||
const opt = document.createElement('option');
|
||
opt.value = cat;
|
||
opt.textContent = cat;
|
||
sel.appendChild(opt);
|
||
});
|
||
}
|
||
|
||
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');
|
||
}
|
||
});
|
||
|
||
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');
|
||
}
|
||
});
|
||
|
||
document.querySelectorAll('.nav-btn[data-view]').forEach(btn => {
|
||
btn.addEventListener('click', () => showView(btn.dataset.view));
|
||
});
|
||
|
||
// ── Hamburger nav ─────────────────────────────────────────────────────────────
|
||
function openDrawer() {
|
||
document.getElementById('nav-drawer').classList.add('open');
|
||
document.getElementById('nav-overlay').classList.add('open');
|
||
document.getElementById('nav-hamburger').setAttribute('aria-expanded', 'true');
|
||
}
|
||
|
||
function closeDrawer() {
|
||
document.getElementById('nav-drawer').classList.remove('open');
|
||
document.getElementById('nav-overlay').classList.remove('open');
|
||
document.getElementById('nav-hamburger').setAttribute('aria-expanded', 'false');
|
||
}
|
||
|
||
document.getElementById('nav-hamburger').addEventListener('click', openDrawer);
|
||
document.getElementById('nav-overlay').addEventListener('click', closeDrawer);
|
||
document.querySelectorAll('.drawer-btn[data-view]').forEach(btn => {
|
||
btn.addEventListener('click', () => { showView(btn.dataset.view); closeDrawer(); });
|
||
});
|
||
document.getElementById('btn-logout-drawer')?.addEventListener('click', () => { closeDrawer(); logout(); });
|
||
|
||
// ── 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();
|
||
openChallengeModal(payload.from, payload.elo ?? '?');
|
||
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;
|
||
state.match.roundHistory = [];
|
||
closeChallengeModal();
|
||
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();
|
||
}
|
||
break;
|
||
|
||
case 'round-complete':
|
||
state.match.roundActive = false;
|
||
state.match.matchScores = payload.matchScores;
|
||
state.match.roundHistory.push({ stratagem: state.match.current?.name, winner: payload.winner });
|
||
renderRoundResult(payload.winner);
|
||
break;
|
||
|
||
case 'match-end':
|
||
state.match.matchScores = payload.matchScores;
|
||
openMatchResultModal({
|
||
winner: payload.winner,
|
||
eloChanges: payload.eloChanges,
|
||
roundHistory: payload.roundHistory || state.match.roundHistory,
|
||
});
|
||
break;
|
||
|
||
case 'opponent-left':
|
||
showToast('Opponent left the match.');
|
||
setTimeout(() => showView('lobby'), 1800);
|
||
break;
|
||
}
|
||
}
|
||
|
||
// ── Dashboard ─────────────────────────────────────────────────────────────────
|
||
async function loadDashboard() {
|
||
setText('dash-hero-name', state.user?.user || '—');
|
||
try {
|
||
const data = await api('GET', '/dashboard');
|
||
renderDashboard(data);
|
||
} catch { /* ignore */ }
|
||
}
|
||
|
||
function renderDashboard({ stats, rank, elo, eloRank: rankLabel, online, recent, daily }) {
|
||
const myRank = eloRankFor(elo || 1000);
|
||
setText('dash-hero-name', state.user.user);
|
||
setText('dash-rank-label', rankLabel || myRank.label);
|
||
setText('dash-elo', elo || 1000);
|
||
setText('dash-rank-icon', myRank.icon);
|
||
|
||
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 || 0) / 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');
|
||
state.practice.dailyTarget = daily.stratagem.name;
|
||
renderDailySequencePreview(daily.stratagem.sequence);
|
||
setIcon(document.getElementById('dash-daily-icon'), daily.stratagem.icon);
|
||
}
|
||
|
||
const tbody = document.getElementById('dash-recent');
|
||
if (!recent?.length) {
|
||
tbody.innerHTML = '<tr><td colspan="4" class="muted">No sessions yet</td></tr>';
|
||
} else {
|
||
tbody.innerHTML = recent.map(r => {
|
||
const icon = state.stratagems.find(s => s.name === r.stratagem)?.icon || '';
|
||
return `<tr>
|
||
<td><img class="stratagem-icon-sm" src="${esc(icon)}" alt="" ${icon ? '' : 'style="display:none"'}>${esc(r.stratagem)}</td>
|
||
<td><span class="badge">${esc(r.mode || 'timed')}</span></td>
|
||
<td>${r.score}</td>
|
||
<td>${(r.time_ms / 1000).toFixed(2)}s</td>
|
||
</tr>`;
|
||
}).join('');
|
||
}
|
||
|
||
updateDashboardOnline(online);
|
||
}
|
||
|
||
function renderDailySequencePreview(sequence) {
|
||
const ARROW = { up: '↑', down: '↓', left: '←', right: '→' };
|
||
const el = document.getElementById('dash-daily-sequence');
|
||
if (!el) return;
|
||
el.innerHTML = sequence.map(d => `<div class="daily-arrow">${ARROW[d]}</div>`).join('');
|
||
}
|
||
|
||
function updateDashboardOnline(online) {
|
||
const el = document.getElementById('dash-online');
|
||
if (!el) return;
|
||
const players = (online || []).filter(u => u.name !== state.user?.user);
|
||
if (!players.length) {
|
||
el.innerHTML = '<span class="muted">No other Helldivers online</span>';
|
||
} else {
|
||
el.innerHTML = players.map(u =>
|
||
`<div class="online-user">
|
||
<span class="online-dot"></span>
|
||
<span style="flex:1;font-family:var(--font-mono)">${esc(u.name)}</span>
|
||
${u.elo ? `<span class="player-elo">${u.elo}</span>` : ''}
|
||
<button class="btn btn-sm btn-accent" data-action="challenge" data-user="${esc(u.name)}">⚔ Challenge</button>
|
||
</div>`
|
||
).join('');
|
||
}
|
||
}
|
||
|
||
function startDailyChallenge() {
|
||
if (!state.practice.dailyTarget) return;
|
||
state.practice.selectedCats.clear();
|
||
showView('practice');
|
||
const strat = state.stratagems.find(s => s.name === state.practice.dailyTarget);
|
||
if (strat) {
|
||
state.practice.selectedCats.add(strat.category);
|
||
startPractice();
|
||
}
|
||
}
|
||
|
||
// ── Practice ──────────────────────────────────────────────────────────────────
|
||
function initPracticeView() {
|
||
renderCategoryFilters();
|
||
if (!state.practice.active) showPracticeIdle();
|
||
updateModeLabel();
|
||
}
|
||
|
||
function renderCategoryFilters() {
|
||
const cats = [...new Set(state.stratagems.map(s => s.category))];
|
||
const el = document.getElementById('practice-categories');
|
||
if (!el) return;
|
||
el.innerHTML = cats.map(cat => {
|
||
const active = state.practice.selectedCats.size === 0 || state.practice.selectedCats.has(cat);
|
||
return `<button class="cat-btn ${active ? 'active' : ''}" data-action="toggle-cat" data-cat="${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');
|
||
document.getElementById('drill-progress-wrap').classList.add('hidden');
|
||
document.getElementById('hud-lives-wrap').classList.add('hidden');
|
||
document.getElementById('hud-timer-wrap').classList.remove('hidden');
|
||
document.getElementById('danger-vignette').classList.add('hidden');
|
||
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 resetSessionStats() {
|
||
state.practice.sessionStats = { completed: 0, missed: 0, bestTime: Infinity, stratagems: {} };
|
||
}
|
||
|
||
function startPractice() {
|
||
const pool = getPool();
|
||
if (!pool.length) { showToast('No stratagems match the selected filters'); return; }
|
||
|
||
const mode = state.practice.mode;
|
||
state.practice.active = true;
|
||
state.practice.score = 0;
|
||
state.practice.streak = 0;
|
||
state.practice.lives = 3;
|
||
state.practice.progress = 0;
|
||
resetSessionStats();
|
||
|
||
document.getElementById('practice-idle').classList.add('hidden');
|
||
document.getElementById('practice-active').classList.remove('hidden');
|
||
|
||
if (mode === 'drill') {
|
||
state.practice.drillPool = shuffleArray([...pool]);
|
||
state.practice.drillCompleted = 0;
|
||
state.practice.drillTotal = pool.length;
|
||
document.getElementById('drill-progress-wrap').classList.remove('hidden');
|
||
document.getElementById('hud-timer-wrap').classList.remove('hidden');
|
||
document.getElementById('hud-lives-wrap').classList.add('hidden');
|
||
setText('hud-timer-label', 'TIME');
|
||
} else if (mode === 'endless') {
|
||
document.getElementById('hud-lives-wrap').classList.remove('hidden');
|
||
document.getElementById('hud-timer-wrap').classList.add('hidden');
|
||
document.getElementById('drill-progress-wrap').classList.add('hidden');
|
||
updateLivesDisplay();
|
||
} else if (mode === 'speedrun') {
|
||
state.practice.speedrunPool = shuffleArray([...state.stratagems]);
|
||
state.practice.speedrunStart = Date.now();
|
||
state.practice.speedrunElapsed = 0;
|
||
document.getElementById('hud-timer-wrap').classList.remove('hidden');
|
||
document.getElementById('hud-lives-wrap').classList.add('hidden');
|
||
document.getElementById('drill-progress-wrap').classList.add('hidden');
|
||
setText('hud-timer-label', 'ELAPSED');
|
||
} else {
|
||
// Timed
|
||
document.getElementById('hud-timer-wrap').classList.remove('hidden');
|
||
document.getElementById('hud-lives-wrap').classList.add('hidden');
|
||
document.getElementById('drill-progress-wrap').classList.add('hidden');
|
||
setText('hud-timer-label', 'TIME');
|
||
}
|
||
|
||
nextStratagem();
|
||
}
|
||
|
||
function shuffleArray(arr) {
|
||
for (let i = arr.length - 1; i > 0; i--) {
|
||
const j = Math.floor(Math.random() * (i + 1));
|
||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||
}
|
||
return arr;
|
||
}
|
||
|
||
function stopPracticeUI() {
|
||
const p = state.practice;
|
||
if (p.active && (p.sessionStats.completed > 0 || p.sessionStats.missed > 0)) {
|
||
openSessionSummary();
|
||
} else {
|
||
stopPracticeTimer();
|
||
showPracticeIdle();
|
||
}
|
||
}
|
||
|
||
function stopPracticeTimer() {
|
||
clearInterval(state.practice.timerHandle);
|
||
state.practice.timerHandle = null;
|
||
state.practice.active = false;
|
||
}
|
||
|
||
function buildQueue() {
|
||
const p = state.practice;
|
||
const mode = p.mode;
|
||
const pool = mode === 'speedrun' ? p.speedrunPool.slice(1, 4)
|
||
: mode === 'drill' ? p.drillPool.slice(1, 4)
|
||
: (() => {
|
||
const fullPool = getPool().filter(s => s !== p.current);
|
||
return shuffleArray([...fullPool]).slice(0, 3);
|
||
})();
|
||
p.queue = pool;
|
||
renderQueue(pool);
|
||
}
|
||
|
||
function renderQueue(queue) {
|
||
const el = document.getElementById('practice-queue');
|
||
if (!el) return;
|
||
if (!queue?.length) { el.innerHTML = ''; return; }
|
||
el.innerHTML = queue.map(s => {
|
||
const iconHtml = s.icon
|
||
? `<img class="queue-icon" src="${esc(s.icon)}" alt="${esc(s.name)}">`
|
||
: `<div class="queue-icon-fallback">⚡</div>`;
|
||
return `<div class="queue-item">
|
||
${iconHtml}
|
||
<div class="queue-label">${esc(s.name)}</div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function nextStratagem() {
|
||
const p = state.practice;
|
||
const mode = p.mode;
|
||
let strat;
|
||
|
||
if (mode === 'drill') {
|
||
if (!p.drillPool.length) {
|
||
clearInterval(p.timerHandle);
|
||
showToast('Drill complete! All stratagems mastered.');
|
||
openSessionSummary();
|
||
return;
|
||
}
|
||
strat = p.drillPool[0];
|
||
} else if (mode === 'speedrun') {
|
||
if (!p.speedrunPool.length) {
|
||
const totalMs = Date.now() - p.speedrunStart;
|
||
clearInterval(p.timerHandle);
|
||
showToast(`Speedrun complete! ${(totalMs / 1000).toFixed(2)}s`);
|
||
openSessionSummary();
|
||
return;
|
||
}
|
||
strat = p.speedrunPool[0];
|
||
} else {
|
||
// timed & endless: pick random from pool
|
||
const pool = getPool();
|
||
if (!pool.length) { showPracticeIdle(); return; }
|
||
strat = pool[Math.floor(Math.random() * pool.length)];
|
||
}
|
||
|
||
p.current = strat;
|
||
p.progress = 0;
|
||
p.startTime = Date.now();
|
||
|
||
if (mode === 'timed') {
|
||
p.timeLeft = state.settings.timerDuration;
|
||
startPracticeTimer();
|
||
} else if (mode === 'drill') {
|
||
p.timeLeft = 60;
|
||
startPracticeTimer();
|
||
updateDrillProgress();
|
||
} else if (mode === 'speedrun') {
|
||
startSpeedrunTimer();
|
||
}
|
||
|
||
renderPracticeStratagem();
|
||
buildQueue();
|
||
}
|
||
|
||
function startPracticeTimer() {
|
||
clearInterval(state.practice.timerHandle);
|
||
const total = state.practice.mode === 'drill' ? 60 : state.settings.timerDuration;
|
||
state.practice.timerHandle = setInterval(() => {
|
||
state.practice.timeLeft--;
|
||
updateTimerDisplay(total);
|
||
const showVignette = state.practice.timeLeft <= 5 && state.practice.timeLeft > 0;
|
||
document.getElementById('danger-vignette').classList.toggle('hidden', !showVignette);
|
||
if (state.practice.timeLeft <= 0) {
|
||
clearInterval(state.practice.timerHandle);
|
||
document.getElementById('danger-vignette').classList.add('hidden');
|
||
state.practice.streak = 0;
|
||
state.practice.sessionStats.missed++;
|
||
updateStreakDisplay();
|
||
shakeIcon();
|
||
setTimeout(nextStratagem, 500);
|
||
}
|
||
}, 1000);
|
||
}
|
||
|
||
function startSpeedrunTimer() {
|
||
clearInterval(state.practice.timerHandle);
|
||
state.practice.timerHandle = setInterval(() => {
|
||
state.practice.speedrunElapsed = Date.now() - state.practice.speedrunStart;
|
||
const secs = (state.practice.speedrunElapsed / 1000).toFixed(1);
|
||
setText('practice-timer', secs + 's');
|
||
const ring = document.getElementById('timer-ring-fill');
|
||
if (ring) ring.style.strokeDashoffset = '0';
|
||
}, 100);
|
||
}
|
||
|
||
function renderPracticeStratagem() {
|
||
const s = state.practice.current;
|
||
const diff = state.settings.difficulty;
|
||
|
||
setText('practice-category', diff === 'easy' ? s.category : '');
|
||
if (diff === 'hard') {
|
||
setText('practice-name', '— ' + '● '.repeat(s.sequence.length).trim() + ' —');
|
||
} else {
|
||
setText('practice-name', s.name);
|
||
}
|
||
|
||
renderArrows('practice-sequence', s.sequence, 0);
|
||
updateTimerDisplay(state.settings.timerDuration);
|
||
updateScoreDisplay();
|
||
updateStreakDisplay();
|
||
|
||
// Show icon
|
||
const iconEl = document.getElementById('practice-icon');
|
||
const fallbackEl = document.getElementById('practice-icon-fallback');
|
||
if (s.icon) {
|
||
setIcon(iconEl, s.icon);
|
||
iconEl.classList.remove('icon-complete', 'icon-wrong');
|
||
if (fallbackEl) fallbackEl.style.display = 'none';
|
||
} else {
|
||
if (iconEl) iconEl.style.display = 'none';
|
||
if (fallbackEl) fallbackEl.style.display = '';
|
||
}
|
||
}
|
||
|
||
function setIcon(imgEl, src) {
|
||
if (!imgEl || !src) return;
|
||
imgEl.src = src;
|
||
imgEl.alt = '';
|
||
imgEl.style.display = '';
|
||
imgEl.onerror = () => { imgEl.style.display = 'none'; };
|
||
}
|
||
|
||
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(total) {
|
||
if (state.practice.mode === 'speedrun') return;
|
||
const el = document.getElementById('practice-timer');
|
||
if (!el) return;
|
||
const t = state.practice.timeLeft;
|
||
const tot = total || state.settings.timerDuration;
|
||
el.textContent = t;
|
||
const isDanger = t <= 5 && t > 0;
|
||
el.className = 'timer-ring-val' + (isDanger ? ' danger' : '');
|
||
|
||
const ring = document.getElementById('timer-ring-fill');
|
||
if (ring) {
|
||
const fraction = Math.max(0, t / tot);
|
||
ring.style.strokeDashoffset = String(RING_CIRCUMFERENCE * (1 - fraction));
|
||
ring.classList.toggle('danger', isDanger);
|
||
}
|
||
}
|
||
|
||
function updateScoreDisplay() { setText('practice-score', state.practice.score); }
|
||
|
||
function updateStreakDisplay() {
|
||
setText('practice-streak', state.practice.streak);
|
||
const streakItem = document.getElementById('hud-streak-item');
|
||
const comboBadge = document.getElementById('practice-combo');
|
||
const streak = state.practice.streak;
|
||
|
||
if (streakItem) streakItem.classList.toggle('streak-fire', streak >= 5);
|
||
if (comboBadge) {
|
||
if (streak >= 2) {
|
||
comboBadge.textContent = '×' + (1 + streak * 0.1).toFixed(1);
|
||
comboBadge.classList.remove('hidden');
|
||
} else {
|
||
comboBadge.classList.add('hidden');
|
||
}
|
||
}
|
||
}
|
||
|
||
function updateLivesDisplay() {
|
||
const el = document.getElementById('practice-lives');
|
||
if (!el) return;
|
||
el.innerHTML = Array.from({ length: 3 }, (_, i) =>
|
||
`<span class="life-icon${i >= state.practice.lives ? ' lost' : ''}">❤</span>`
|
||
).join('');
|
||
}
|
||
|
||
function updateDrillProgress() {
|
||
const p = state.practice;
|
||
setText('drill-progress-text', p.drillCompleted + ' / ' + p.drillTotal);
|
||
const fill = document.getElementById('drill-progress-fill');
|
||
if (fill) fill.style.width = p.drillTotal > 0 ? Math.round((p.drillCompleted / p.drillTotal) * 100) + '%' : '0%';
|
||
}
|
||
|
||
function shakeIcon() {
|
||
const iconEl = document.getElementById('practice-icon');
|
||
if (!iconEl || iconEl.style.display === 'none') return;
|
||
iconEl.classList.remove('icon-wrong');
|
||
requestAnimationFrame(() => iconEl.classList.add('icon-wrong'));
|
||
setTimeout(() => iconEl.classList.remove('icon-wrong'), 400);
|
||
}
|
||
|
||
function handlePracticeInput(dir) {
|
||
const p = state.practice;
|
||
if (!p.active || !p.current) return;
|
||
|
||
const mode = p.mode;
|
||
const seq = p.current.sequence;
|
||
const arrows = document.querySelectorAll('#practice-sequence .arrow-key');
|
||
const cur = arrows[p.progress];
|
||
|
||
if (dir === seq[p.progress]) {
|
||
cur?.classList.add('flash-correct');
|
||
p.progress++;
|
||
|
||
if (p.progress === seq.length) {
|
||
clearInterval(p.timerHandle);
|
||
document.getElementById('danger-vignette').classList.add('hidden');
|
||
|
||
const elapsed = Date.now() - p.startTime;
|
||
const mult = 1 + p.streak * 0.1;
|
||
let pts = 0;
|
||
|
||
if (mode === 'timed' || mode === 'drill') {
|
||
const secs = Math.min(state.settings.timerDuration, elapsed / 1000);
|
||
pts = Math.round((100 + (state.settings.timerDuration - secs) * 3) * mult);
|
||
} else if (mode === 'endless') {
|
||
pts = Math.round(100 * mult);
|
||
} else if (mode === 'speedrun') {
|
||
pts = Math.round(50 * mult);
|
||
}
|
||
|
||
p.score += pts;
|
||
p.streak++;
|
||
p.sessionStats.completed++;
|
||
if (elapsed < p.sessionStats.bestTime) p.sessionStats.bestTime = elapsed;
|
||
|
||
// Track per-stratagem stats
|
||
if (!p.sessionStats.stratagems[p.current.name]) {
|
||
p.sessionStats.stratagems[p.current.name] = { count: 0, totalMs: 0 };
|
||
}
|
||
p.sessionStats.stratagems[p.current.name].count++;
|
||
p.sessionStats.stratagems[p.current.name].totalMs += elapsed;
|
||
|
||
updateScoreDisplay();
|
||
updateStreakDisplay();
|
||
|
||
// Visual: all arrows complete + icon flash
|
||
document.querySelectorAll('#practice-sequence .arrow-key').forEach(el => {
|
||
el.classList.remove('flash-correct');
|
||
el.classList.add('completed');
|
||
});
|
||
const iconEl = document.getElementById('practice-icon');
|
||
iconEl?.classList.add('icon-complete');
|
||
setTimeout(() => iconEl?.classList.remove('icon-complete'), 300);
|
||
|
||
// Score popup
|
||
showScorePopup('+' + pts);
|
||
|
||
api('POST', '/scores/practice', {
|
||
stratagem: p.current.name,
|
||
category: p.current.category,
|
||
time_ms: elapsed,
|
||
score: pts,
|
||
mode: mode,
|
||
}).catch(() => {});
|
||
|
||
if (mode === 'drill') {
|
||
p.drillPool.shift();
|
||
p.drillCompleted++;
|
||
updateDrillProgress();
|
||
} else if (mode === 'speedrun') {
|
||
p.speedrunPool.shift();
|
||
}
|
||
|
||
setTimeout(nextStratagem, 550);
|
||
} else {
|
||
renderArrows('practice-sequence', seq, p.progress);
|
||
}
|
||
|
||
} else {
|
||
// Wrong input
|
||
cur?.classList.add('flash-wrong');
|
||
p.progress = 0;
|
||
shakeIcon();
|
||
|
||
if (mode === 'endless') {
|
||
p.lives--;
|
||
p.streak = 0;
|
||
updateLivesDisplay();
|
||
updateStreakDisplay();
|
||
p.sessionStats.missed++;
|
||
if (p.lives <= 0) {
|
||
setTimeout(() => openSessionSummary(), 600);
|
||
return;
|
||
}
|
||
} else {
|
||
p.streak = 0;
|
||
updateStreakDisplay();
|
||
}
|
||
|
||
setTimeout(() => renderArrows('practice-sequence', seq, 0), 350);
|
||
}
|
||
}
|
||
|
||
// ── Settings modal ────────────────────────────────────────────────────────────
|
||
function openSettingsModal() {
|
||
applySettingsToUI();
|
||
document.getElementById('modal-settings').classList.remove('hidden');
|
||
}
|
||
|
||
function closeSettingsModal() {
|
||
document.getElementById('modal-settings').classList.add('hidden');
|
||
}
|
||
|
||
document.getElementById('btn-practice-settings')?.addEventListener('click', openSettingsModal);
|
||
document.getElementById('btn-settings-close')?.addEventListener('click', closeSettingsModal);
|
||
document.getElementById('modal-settings')?.addEventListener('click', (e) => {
|
||
if (e.target === document.getElementById('modal-settings')) closeSettingsModal();
|
||
});
|
||
|
||
// Settings option click (delegation via main delegation handler)
|
||
// handled in the data-action delegation below
|
||
|
||
// ── Mode card selection ───────────────────────────────────────────────────────
|
||
document.getElementById('practice-mode-grid')?.addEventListener('click', (e) => {
|
||
const card = e.target.closest('[data-mode]');
|
||
if (!card || state.practice.active) return;
|
||
state.practice.mode = card.dataset.mode;
|
||
document.querySelectorAll('.mode-card').forEach(c => c.classList.remove('active'));
|
||
card.classList.add('active');
|
||
updateModeLabel();
|
||
});
|
||
|
||
function updateModeLabel() {
|
||
const dur = state.settings.timerDuration;
|
||
const labels = {
|
||
timed: `Timed mode — ${dur}s per stratagem`,
|
||
endless: 'Endless mode — 3 lives, no timer',
|
||
drill: 'Category Drill — master your selection',
|
||
speedrun: 'Speed Run — all stratagems, fastest time',
|
||
};
|
||
setText('practice-mode-label', labels[state.practice.mode] || '');
|
||
}
|
||
|
||
// ── Session summary modal ─────────────────────────────────────────────────────
|
||
function openSessionSummary() {
|
||
stopPracticeTimer();
|
||
document.getElementById('danger-vignette').classList.add('hidden');
|
||
|
||
const p = state.practice;
|
||
const s = p.sessionStats;
|
||
|
||
// Stats grid
|
||
const accuracy = (s.completed + s.missed) > 0
|
||
? Math.round(s.completed / (s.completed + s.missed) * 100) + '%'
|
||
: '—';
|
||
const bestTimeStr = s.bestTime < Infinity ? (s.bestTime / 1000).toFixed(2) + 's' : '—';
|
||
|
||
const grid = document.getElementById('summary-grid');
|
||
if (grid) {
|
||
grid.innerHTML = [
|
||
{ label: 'Score', val: p.score },
|
||
{ label: 'Completed', val: s.completed },
|
||
{ label: 'Streak Max',val: p.streak },
|
||
{ label: 'Accuracy', val: accuracy },
|
||
{ label: 'Best Time', val: bestTimeStr },
|
||
{ label: 'Mode', val: p.mode },
|
||
].map(x => `<div class="summary-stat">
|
||
<div class="summary-stat-val">${esc(String(x.val))}</div>
|
||
<div class="summary-stat-label">${esc(x.label)}</div>
|
||
</div>`).join('');
|
||
}
|
||
|
||
// Top stratagems by count
|
||
const topEl = document.getElementById('summary-top-stratagems');
|
||
if (topEl) {
|
||
const tops = Object.entries(s.stratagems)
|
||
.sort((a, b) => b[1].count - a[1].count)
|
||
.slice(0, 5);
|
||
if (tops.length) {
|
||
topEl.innerHTML = tops.map(([name, stat], i) => {
|
||
const strat = state.stratagems.find(x => x.name === name);
|
||
const avgMs = (stat.totalMs / stat.count).toFixed(0);
|
||
const iconHtml = strat?.icon ? `<img class="stratagem-icon-sm" src="${esc(strat.icon)}" alt="">` : '';
|
||
return `<div class="summary-top-item">
|
||
<span class="summary-top-rank">${i + 1}.</span>
|
||
${iconHtml}
|
||
<span class="summary-top-name">${esc(name)}</span>
|
||
<span class="summary-top-time">${(avgMs / 1000).toFixed(2)}s avg</span>
|
||
</div>`;
|
||
}).join('');
|
||
} else {
|
||
topEl.innerHTML = '<p class="muted" style="font-size:0.85rem">No data</p>';
|
||
}
|
||
}
|
||
|
||
document.getElementById('modal-session-summary').classList.remove('hidden');
|
||
}
|
||
|
||
function closeSessionSummary() {
|
||
document.getElementById('modal-session-summary').classList.add('hidden');
|
||
showPracticeIdle();
|
||
}
|
||
|
||
document.getElementById('btn-summary-dashboard')?.addEventListener('click', () => {
|
||
closeSessionSummary();
|
||
showView('dashboard');
|
||
});
|
||
document.getElementById('btn-summary-restart')?.addEventListener('click', () => {
|
||
document.getElementById('modal-session-summary').classList.add('hidden');
|
||
startPractice();
|
||
});
|
||
|
||
// ── Lobby ─────────────────────────────────────────────────────────────────────
|
||
function updateLobbyView() {
|
||
const others = state.lobby.online.filter(u => u.name !== state.user?.user);
|
||
const el = document.getElementById('lobby-players');
|
||
if (!el) return;
|
||
|
||
if (!others.length) {
|
||
el.innerHTML = `<div class="lobby-empty">
|
||
<div class="lobby-empty-icon">📡</div>
|
||
<p>No other Helldivers online.<br>Waiting for reinforcements...</p>
|
||
</div>`;
|
||
} else {
|
||
el.innerHTML = others.map(u =>
|
||
`<div class="lobby-player">
|
||
<span class="online-dot"></span>
|
||
<span class="player-name">${esc(u.name)}</span>
|
||
${u.elo ? `<span class="player-elo">${esc(u.rank)} · ${u.elo}</span>` : ''}
|
||
<button class="btn btn-sm btn-accent" data-action="challenge" data-user="${esc(u.name)}">⚔ Challenge</button>
|
||
</div>`
|
||
).join('');
|
||
}
|
||
|
||
const challEl = document.getElementById('lobby-challenges');
|
||
if (!challEl) return;
|
||
const inc = state.lobby.incoming;
|
||
if (!inc.length) {
|
||
challEl.innerHTML = '<p class="muted">No incoming challenges</p>';
|
||
} else {
|
||
challEl.innerHTML = inc.map(from =>
|
||
`<div class="challenge-item">
|
||
<span style="flex:1"><strong>${esc(from)}</strong> challenges you!</span>
|
||
<button class="btn btn-sm btn-accent" data-action="accept" data-user="${esc(from)}">Accept</button>
|
||
<button class="btn btn-sm btn-muted" data-action="decline" data-user="${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 });
|
||
state.lobby.incoming = state.lobby.incoming.filter(u => u !== from);
|
||
closeChallengeModal();
|
||
updateChallengeBadge();
|
||
}
|
||
|
||
function declineChallenge(from) {
|
||
wsSend('decline-challenge', { challengerId: from });
|
||
state.lobby.incoming = state.lobby.incoming.filter(u => u !== from);
|
||
closeChallengeModal();
|
||
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');
|
||
}
|
||
}
|
||
|
||
function openChallengeModal(from, elo) {
|
||
state.lobby.pendingChallenge = { from, elo };
|
||
setText('modal-challenger-name', from);
|
||
setText('modal-challenger-elo', elo);
|
||
document.getElementById('modal-challenge').classList.remove('hidden');
|
||
}
|
||
|
||
function closeChallengeModal() {
|
||
document.getElementById('modal-challenge').classList.add('hidden');
|
||
state.lobby.pendingChallenge = null;
|
||
}
|
||
|
||
document.getElementById('btn-accept-challenge')?.addEventListener('click', () => {
|
||
if (state.lobby.pendingChallenge) acceptChallenge(state.lobby.pendingChallenge.from);
|
||
});
|
||
document.getElementById('btn-decline-challenge')?.addEventListener('click', () => {
|
||
if (state.lobby.pendingChallenge) declineChallenge(state.lobby.pendingChallenge.from);
|
||
});
|
||
document.getElementById('modal-challenge')?.addEventListener('click', (e) => {
|
||
if (e.target === document.getElementById('modal-challenge')) closeChallengeModal();
|
||
});
|
||
|
||
// ── 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 btn = document.getElementById('match-ready-btn');
|
||
btn.textContent = 'READY';
|
||
btn.disabled = false;
|
||
btn.classList.remove('hidden');
|
||
// Hide match icon
|
||
const matchIcon = document.getElementById('match-icon');
|
||
if (matchIcon) matchIcon.style.display = 'none';
|
||
}
|
||
|
||
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').classList.add('hidden');
|
||
renderArrows('match-me-sequence', m.current.sequence, 0);
|
||
renderArrows('match-opp-sequence', m.current.sequence, 0);
|
||
|
||
// Show stratagem icon in match
|
||
const strat = state.stratagems.find(s => s.name === m.current.name);
|
||
const matchIcon = document.getElementById('match-icon');
|
||
if (strat?.icon) setIcon(matchIcon, strat.icon);
|
||
else if (matchIcon) matchIcon.style.display = 'none';
|
||
}
|
||
|
||
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() {
|
||
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();
|
||
const matchIcon = document.getElementById('match-icon');
|
||
if (matchIcon) matchIcon.style.display = 'none';
|
||
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.classList.remove('hidden');
|
||
setText('match-category', '');
|
||
}, 1600);
|
||
}
|
||
|
||
function openMatchResultModal({ winner, eloChanges, roundHistory }) {
|
||
const isWinner = winner === state.user.user;
|
||
const resultEl = document.getElementById('result-winner-text');
|
||
if (resultEl) {
|
||
resultEl.textContent = isWinner ? '🏆 VICTORY' : '☠ DEFEAT';
|
||
resultEl.className = 'result-winner ' + (isWinner ? 'win' : 'loss');
|
||
}
|
||
|
||
if (eloChanges && state.user?.user) {
|
||
const myChange = eloChanges[state.user.user];
|
||
if (myChange) {
|
||
setText('result-elo-old', myChange.old);
|
||
setText('result-elo-new', myChange.new);
|
||
const delta = myChange.delta;
|
||
const deltaEl = document.getElementById('result-elo-delta');
|
||
if (deltaEl) {
|
||
deltaEl.textContent = (delta >= 0 ? '+' : '') + delta;
|
||
deltaEl.className = 'elo-delta-val ' + (delta >= 0 ? 'positive' : 'negative');
|
||
}
|
||
}
|
||
}
|
||
|
||
const histEl = document.getElementById('result-round-history');
|
||
if (histEl && roundHistory?.length) {
|
||
histEl.innerHTML = roundHistory.map((r, i) => {
|
||
const iMine = r.winner === state.user.user;
|
||
const stratName = r.stratagem || '—';
|
||
const strat = state.stratagems.find(s => s.name === stratName);
|
||
const iconHtml = strat?.icon ? `<img class="stratagem-icon-sm" src="${esc(strat.icon)}" alt="">` : '';
|
||
return `<div class="round-row ${iMine ? 'won' : 'lost'}">
|
||
<span class="round-num">R${r.round || (i + 1)}</span>
|
||
${iconHtml}
|
||
<span class="round-strat">${esc(stratName)}</span>
|
||
<span class="round-result">${iMine ? '✓ Won' : '✗ Lost'}</span>
|
||
</div>`;
|
||
}).join('');
|
||
} else if (histEl) {
|
||
histEl.innerHTML = '';
|
||
}
|
||
|
||
document.getElementById('modal-match-result').classList.remove('hidden');
|
||
}
|
||
|
||
function closeMatchResultModal() {
|
||
document.getElementById('modal-match-result').classList.add('hidden');
|
||
}
|
||
|
||
function leaveMatch() {
|
||
wsSend('leave-room');
|
||
closeMatchResultModal();
|
||
showView('lobby');
|
||
}
|
||
|
||
document.getElementById('btn-result-lobby')?.addEventListener('click', () => {
|
||
closeMatchResultModal();
|
||
showView('lobby');
|
||
});
|
||
document.getElementById('btn-result-rematch')?.addEventListener('click', () => {
|
||
closeMatchResultModal();
|
||
const opp = state.match.opponent;
|
||
showView('lobby');
|
||
if (opp) sendChallenge(opp);
|
||
});
|
||
|
||
// ── Leaderboard ───────────────────────────────────────────────────────────────
|
||
async function loadLeaderboard() {
|
||
const tab = state.leaderboard.activeTab;
|
||
const tbody = document.getElementById('leaderboard-table-body');
|
||
const thead = document.getElementById('leaderboard-thead');
|
||
tbody.innerHTML = '<tr><td colspan="6" class="muted">Loading...</td></tr>';
|
||
|
||
try {
|
||
if (tab === 'practice') {
|
||
if (thead) thead.innerHTML = '<tr><th>#</th><th>Helldiver</th><th>Rank</th><th>Total Score</th><th>Sessions</th><th>Match W/Total</th></tr>';
|
||
const rows = await api('GET', '/scores/leaderboard');
|
||
if (!rows.length) {
|
||
tbody.innerHTML = '<tr><td colspan="6" class="muted">No scores yet. Start practicing!</td></tr>';
|
||
} else {
|
||
tbody.innerHTML = rows.map((r, i) => {
|
||
const rank = eloRankFor(r.elo || 1000);
|
||
return `<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><span class="badge badge-rank">${rank.icon} ${rank.label}</span></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('');
|
||
}
|
||
} else if (tab === 'elo') {
|
||
if (thead) thead.innerHTML = '<tr><th>#</th><th>Helldiver</th><th>ELO</th><th>Rank</th><th colspan="2">Matches W/Total</th></tr>';
|
||
const rows = await api('GET', '/scores/leaderboard/elo');
|
||
if (!rows.length) {
|
||
tbody.innerHTML = '<tr><td colspan="6" class="muted">No ELO data yet. Play some 1v1 matches!</td></tr>';
|
||
} else {
|
||
tbody.innerHTML = rows.map((r, i) => {
|
||
const rank = eloRankFor(r.elo);
|
||
return `<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);color:var(--accent)">${r.elo}</td>
|
||
<td><span class="badge badge-rank">${rank.icon} ${rank.label}</span></td>
|
||
<td colspan="2" style="font-family:var(--font-mono)">${r.wins || 0}/${r.matches || 0}</td>
|
||
</tr>`;
|
||
}).join('');
|
||
}
|
||
} else if (tab === 'speedrun') {
|
||
if (thead) thead.innerHTML = '<tr><th>#</th><th>Helldiver</th><th>Total Time</th><th colspan="3"></th></tr>';
|
||
const rows = await api('GET', '/scores/leaderboard/speedrun');
|
||
if (!rows.length) {
|
||
tbody.innerHTML = '<tr><td colspan="6" class="muted">No speedrun data yet. Try Speed Run mode!</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);color:var(--accent)">${(r.totalTime / 1000).toFixed(2)}s</td>
|
||
<td colspan="3"></td>
|
||
</tr>`
|
||
).join('');
|
||
}
|
||
}
|
||
} catch {
|
||
tbody.innerHTML = '<tr><td colspan="6" class="muted">Error loading leaderboard</td></tr>';
|
||
}
|
||
}
|
||
|
||
document.querySelectorAll('.tab-btn[data-tab]').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
state.leaderboard.activeTab = btn.dataset.tab;
|
||
loadLeaderboard();
|
||
});
|
||
});
|
||
|
||
// ── History ───────────────────────────────────────────────────────────────────
|
||
async function loadHistory() {
|
||
const h = state.history;
|
||
const tbody = document.getElementById('history-table-body');
|
||
if (tbody) tbody.innerHTML = '<tr><td colspan="6" class="muted">Loading...</td></tr>';
|
||
|
||
const mode = document.getElementById('history-filter-mode')?.value || '';
|
||
const cat = document.getElementById('history-filter-cat')?.value || '';
|
||
const limit = 10;
|
||
|
||
try {
|
||
let url = `/history?page=${h.page}&limit=${limit}`;
|
||
if (mode) url += '&mode=' + encodeURIComponent(mode);
|
||
if (cat) url += '&cat=' + encodeURIComponent(cat);
|
||
|
||
const data = await api('GET', url);
|
||
h.total = data.total || 0;
|
||
|
||
if (!data.rows?.length) {
|
||
if (tbody) tbody.innerHTML = '<tr><td colspan="6" class="muted">No sessions yet</td></tr>';
|
||
} else {
|
||
if (tbody) {
|
||
tbody.innerHTML = data.rows.map(r => {
|
||
const strat = state.stratagems.find(s => s.name === r.stratagem);
|
||
const date = new Date(r.created_at || Date.now()).toLocaleDateString('de-DE');
|
||
const iconHtml = strat?.icon ? `<img class="stratagem-icon-sm" src="${esc(strat.icon)}" alt="">` : '';
|
||
return `<tr>
|
||
<td>${iconHtml}${esc(r.stratagem)}</td>
|
||
<td style="font-size:0.8rem;color:var(--text-muted)">${esc(r.category || '—')}</td>
|
||
<td><span class="badge">${esc(r.mode || 'timed')}</span></td>
|
||
<td>${r.score}</td>
|
||
<td>${(r.time_ms / 1000).toFixed(2)}s</td>
|
||
<td style="font-size:0.8rem;color:var(--text-muted)">${date}</td>
|
||
</tr>`;
|
||
}).join('');
|
||
}
|
||
renderHistoryChart(data.rows);
|
||
}
|
||
|
||
renderHistoryPagination(limit);
|
||
loadStratagemStats();
|
||
} catch {
|
||
if (tbody) tbody.innerHTML = '<tr><td colspan="6" class="muted">Error loading history</td></tr>';
|
||
}
|
||
}
|
||
|
||
function renderHistoryChart(sessions) {
|
||
const svg = document.getElementById('history-chart-svg');
|
||
if (!svg || sessions.length < 2) {
|
||
if (svg) svg.innerHTML = '<text x="50%" y="50%" text-anchor="middle" fill="var(--text-muted)" font-size="13">Not enough data</text>';
|
||
return;
|
||
}
|
||
const W = 800, H = 160, PAD = 20;
|
||
const scores = sessions.map(s => s.score || 0);
|
||
const maxScore = Math.max(...scores, 1);
|
||
const minScore = Math.min(...scores);
|
||
const range = maxScore - minScore || 1;
|
||
const points = scores.map((s, i) => {
|
||
const x = PAD + (i / (scores.length - 1)) * (W - PAD * 2);
|
||
const y = H - PAD - ((s - minScore) / range) * (H - PAD * 2);
|
||
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||
}).join(' ');
|
||
|
||
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
|
||
svg.innerHTML = `<polyline fill="none" stroke="var(--accent)" stroke-width="2"
|
||
stroke-linejoin="round" stroke-linecap="round" points="${points}"/>
|
||
${scores.map((s, i) => {
|
||
const x = PAD + (i / (scores.length - 1)) * (W - PAD * 2);
|
||
const y = H - PAD - ((s - minScore) / range) * (H - PAD * 2);
|
||
return `<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="3" fill="var(--accent)" opacity="0.7"/>`;
|
||
}).join('')}`;
|
||
}
|
||
|
||
function renderHistoryPagination(limit) {
|
||
const el = document.getElementById('history-pagination');
|
||
if (!el) return;
|
||
const total = state.history.total;
|
||
const page = state.history.page;
|
||
const pages = Math.ceil(total / limit);
|
||
if (pages <= 1) { el.innerHTML = ''; return; }
|
||
el.innerHTML = `
|
||
<button class="page-btn" ${page <= 1 ? 'disabled' : ''} data-page="${page - 1}">← Prev</button>
|
||
<span class="page-info">Page ${page} / ${pages}</span>
|
||
<button class="page-btn" ${page >= pages ? 'disabled' : ''} data-page="${page + 1}">Next →</button>
|
||
`;
|
||
}
|
||
|
||
document.getElementById('history-pagination')?.addEventListener('click', (e) => {
|
||
const btn = e.target.closest('[data-page]');
|
||
if (!btn) return;
|
||
state.history.page = Number(btn.dataset.page);
|
||
loadHistory();
|
||
});
|
||
|
||
document.getElementById('history-filter-mode')?.addEventListener('change', () => { state.history.page = 1; loadHistory(); });
|
||
document.getElementById('history-filter-cat')?.addEventListener('change', () => { state.history.page = 1; loadHistory(); });
|
||
|
||
async function loadStratagemStats() {
|
||
try {
|
||
const rows = await api('GET', '/stats/stratagems');
|
||
const tbody = document.getElementById('best-per-stratagem-body');
|
||
if (!tbody) return;
|
||
if (!rows?.length) {
|
||
tbody.innerHTML = '<tr><td colspan="5" class="muted">No data yet</td></tr>';
|
||
} else {
|
||
tbody.innerHTML = rows.map(r => {
|
||
const strat = state.stratagems.find(s => s.name === r.stratagem);
|
||
const iconHtml = strat?.icon ? `<img class="stratagem-icon-sm" src="${esc(strat.icon)}" alt="">` : '';
|
||
return `<tr>
|
||
<td>${iconHtml}</td>
|
||
<td>${esc(r.stratagem)}</td>
|
||
<td style="font-size:0.8rem;color:var(--text-muted)">${esc(strat?.category || '—')}</td>
|
||
<td style="color:var(--accent)">${r.best_time ? (r.best_time / 1000).toFixed(2) + 's' : '—'}</td>
|
||
<td>${r.attempts || 0}</td>
|
||
</tr>`;
|
||
}).join('');
|
||
}
|
||
} catch { /* ignore */ }
|
||
}
|
||
|
||
// ── Admin ─────────────────────────────────────────────────────────────────────
|
||
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 style="font-family:var(--font-mono);flex:1">${esc(u.username)}</span>
|
||
<span class="badge badge-${u.role}">${u.role}</span>
|
||
${u.mustChange ? '<span class="badge badge-warning">temp pw</span>' : ''}
|
||
${u.username !== state.user.user
|
||
? `<button class="btn btn-sm btn-danger" data-action="delete-user" data-user="${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 user "${username}"? This cannot be undone.`)) return;
|
||
try {
|
||
await api('DELETE', '/users/' + encodeURIComponent(username));
|
||
loadAdmin();
|
||
} catch (err) {
|
||
showToast('Error: ' + err.message);
|
||
}
|
||
}
|
||
|
||
// ── Event delegation ──────────────────────────────────────────────────────────
|
||
document.addEventListener('click', (e) => {
|
||
const btn = e.target.closest('[data-action]');
|
||
if (!btn) return;
|
||
const action = btn.dataset.action;
|
||
const user = btn.dataset.user;
|
||
const cat = btn.dataset.cat;
|
||
|
||
if (action === 'challenge' && user) sendChallenge(user);
|
||
if (action === 'accept' && user) acceptChallenge(user);
|
||
if (action === 'decline' && user) declineChallenge(user);
|
||
if (action === 'delete-user' && user) deleteUser(user);
|
||
if (action === 'toggle-cat' && cat) toggleCategory(cat);
|
||
|
||
// Settings options
|
||
const settingBtn = e.target.closest('[data-setting]');
|
||
if (settingBtn) {
|
||
const setting = settingBtn.dataset.setting;
|
||
const value = settingBtn.dataset.value;
|
||
if (setting === 'timer') state.settings.timerDuration = Number(value);
|
||
else if (setting === 'difficulty') state.settings.difficulty = value;
|
||
saveSettings();
|
||
applySettingsToUI();
|
||
updateModeLabel();
|
||
}
|
||
});
|
||
|
||
// ── Keyboard ──────────────────────────────────────────────────────────────────
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape') {
|
||
closeSettingsModal();
|
||
closeChallengeModal();
|
||
closeMatchResultModal();
|
||
if (document.getElementById('modal-session-summary')?.classList.contains('hidden') === false) {
|
||
closeSessionSummary();
|
||
}
|
||
if (state.currentView === 'practice' && state.practice.active) {
|
||
stopPracticeUI();
|
||
}
|
||
return;
|
||
}
|
||
if (e.key === 'Enter' && state.currentView === 'practice' && !state.practice.active) {
|
||
startPractice();
|
||
return;
|
||
}
|
||
|
||
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();
|
||
dpadInput(dir);
|
||
}
|
||
});
|
||
|
||
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');
|
||
if (container.children.length >= 3) container.firstChild?.remove();
|
||
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');
|
||
toast.addEventListener('transitionend', () => toast.remove(), { once: true });
|
||
}, 3200);
|
||
}
|
||
|
||
function showScorePopup(text) {
|
||
const el = document.getElementById('score-popup');
|
||
if (!el) return;
|
||
el.textContent = text;
|
||
el.classList.remove('show', 'hidden');
|
||
requestAnimationFrame(() => {
|
||
el.classList.add('show');
|
||
el.addEventListener('animationend', () => {
|
||
el.classList.remove('show');
|
||
}, { once: true });
|
||
});
|
||
}
|
||
|
||
// ── Static button bindings ───────────────────────────────────────────────────
|
||
document.getElementById('btn-logout') ?.addEventListener('click', logout);
|
||
document.getElementById('btn-daily-challenge') ?.addEventListener('click', startDailyChallenge);
|
||
document.getElementById('btn-start-practice') ?.addEventListener('click', startPractice);
|
||
document.getElementById('btn-stop-practice') ?.addEventListener('click', stopPracticeUI);
|
||
document.getElementById('match-ready-btn') ?.addEventListener('click', setReady);
|
||
document.getElementById('btn-leave-match') ?.addEventListener('click', leaveMatch);
|
||
document.getElementById('btn-create-user') ?.addEventListener('click', createUser);
|
||
|
||
document.getElementById('practice-dpad')?.addEventListener('click', (e) => {
|
||
const dir = e.target.closest('[data-dir]')?.dataset.dir;
|
||
if (dir) dpadInput(dir);
|
||
});
|
||
document.getElementById('match-dpad')?.addEventListener('click', (e) => {
|
||
const dir = e.target.closest('[data-dir]')?.dataset.dir;
|
||
if (dir) dpadInput(dir);
|
||
});
|
||
|
||
// ── Init ──────────────────────────────────────────────────────────────────────
|
||
document.addEventListener('DOMContentLoaded', checkAuth);
|