diff --git a/CHANGELOG.md b/CHANGELOG.md index 80ba619..2cfbf26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog – helldivers-trainer +## [2.1.0] – 2026-03-31 + +### Added +- **Stratagem icons**: 65 SVG icons downloaded from community icon set, served as static files under `/icons/`; icon download script at `scripts/download-icons.js` +- **Gold CSS filter** on all stratagem icons to match the game's yellow accent theme +- **Session summary modal**: opens after stopping practice or finishing a drill/speedrun — shows score, completed count, accuracy, best time, and top 5 stratagems +- **Queue preview strip**: shows next 3 upcoming stratagems (with icons) below the active stratagem +- **Score popup animation**: floating `+N pts` text appears on every correct completion +- **Shake animation on wrong input**: stratagem icon shakes on incorrect arrow key +- **Icon complete pulse**: stratagem icon scales + brightens when sequence is completed correctly +- **ELO rank icon** in post-match result header (matches rank tier badge) +- **Inline icons** in history table rows, best-per-stratagem table, and leaderboard +- `scripts/download-icons.js` — automated icon fetcher from GitHub community SVG repo + +### Changed +- `index.html` fully rewritten: new elements for icon display, queue, score popup, session summary modal +- `app.js` fully rewritten (~1100 lines): icon helpers, queue builder, popup system, session stats, improved match/lobby flows +- `server.js` STRATAGEMS array: all 65 entries now have `icon` field (or `null` for missing ones) +- `broadcastLobbyUpdate()` now sends `[{name, elo, rank}]` objects with CSS-safe rank labels +- `challenge-received` WS event now includes challenger ELO for display in challenge modal + +--- + ## [2.0.0] – 2026-03-30 ### Added diff --git a/public/app.js b/public/app.js index 6fb7c28..f14958d 100644 --- a/public/app.js +++ b/public/app.js @@ -1,44 +1,116 @@ '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, // { user, role, mustChange } + user: null, currentView: 'login', stratagems: [], + settings: { + timerDuration: 30, // 15 | 30 | 45 + difficulty: 'normal', // 'easy' | 'normal' | 'hard' + }, + 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 + 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: [], // usernames who challenged me + online: [], + incoming: [], + pendingChallenge: null, }, match: { - roomId: null, - opponent: null, - matchScores: {}, - current: null, - myProgress: 0, - oppProgress: 0, - roundActive: false, + roomId: null, + opponent: null, + matchScores: {}, + current: null, + myProgress: 0, + oppProgress: 0, + roundActive: false, + roundHistory: [], }, - ws: null, + leaderboard: { activeTab: 'practice' }, + + history: { page: 1, total: 0 }, + + ws: null, wsReconnectTimer: null, }; -// ── API helpers ─────────────────────────────────────────────────────────────── +// ── 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); @@ -50,52 +122,52 @@ async function api(method, endpoint, body) { // ── View system ─────────────────────────────────────────────────────────────── function showView(name) { - document.querySelectorAll('.view').forEach(v => v.classList.add('hidden')); + 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'); + if (el) { + el.classList.remove('hidden'); + requestAnimationFrame(() => el.classList.add('view-fade-in')); + } 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 === 'dashboard') loadDashboard(); if (name === 'leaderboard') loadLeaderboard(); - if (name === 'admin') loadAdmin(); - if (name === 'practice') initPracticeView(); - if (name === 'lobby') updateLobbyView(); + if (name === 'admin') loadAdmin(); + if (name === 'practice') initPracticeView(); + if (name === 'lobby') updateLobbyView(); + if (name === 'history') loadHistory(); } -// ── Authentication ──────────────────────────────────────────────────────────── +// ── 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(); - } + if (data.mustChange) showView('change-password'); + else onLoggedIn(); } else { showView('login'); } - } catch { - 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'); - // Stratagems are served via authenticated API – not as a public static file + document.getElementById('drawer-admin')?.classList.toggle('hidden', state.user.role !== 'admin'); state.stratagems = await api('GET', '/stratagems').catch(() => []); + populateCategoryFilter(); + loadSettings(); connectWS(); showView('dashboard'); } @@ -110,7 +182,20 @@ async function logout() { showView('login'); } -// Login form +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'); @@ -127,14 +212,12 @@ document.getElementById('login-form').addEventListener('submit', async (e) => { } }); -// 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'); @@ -153,25 +236,41 @@ document.getElementById('change-password-form').addEventListener('submit', async } }); -// Nav buttons 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.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); - } + if (state.user) state.wsReconnectTimer = setTimeout(connectWS, 3000); }; } @@ -194,8 +293,7 @@ function handleWSMessage({ type, payload }) { 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.'); + openChallengeModal(payload.from, payload.elo ?? '?'); break; case 'challenge-declined': @@ -209,6 +307,8 @@ function handleWSMessage({ type, payload }) { state.match.myProgress = 0; state.match.oppProgress = 0; state.match.roundActive = false; + state.match.roundHistory = []; + closeChallengeModal(); showView('match'); renderMatchWaiting(); break; @@ -223,23 +323,28 @@ function handleWSMessage({ type, payload }) { case 'input-result': if (payload.userId === state.user.user) { - state.match.myProgress = payload.progress; + state.match.myProgress = payload.progress; updateMyArrows(payload.correct); } else { state.match.oppProgress = payload.progress; - updateOppArrows(payload.correct); + 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; - renderMatchEnd(payload.winner); + openMatchResultModal({ + winner: payload.winner, + eloChanges: payload.eloChanges, + roundHistory: payload.roundHistory || state.match.roundHistory, + }); break; case 'opponent-left': @@ -251,56 +356,79 @@ function handleWSMessage({ type, payload }) { // ── Dashboard ───────────────────────────────────────────────────────────────── async function loadDashboard() { + setText('dash-hero-name', state.user?.user || '—'); try { const data = await api('GET', '/dashboard'); renderDashboard(data); - } catch { - /* silently ignore – dashboard is cosmetic */ - } + } catch { /* ignore */ } } -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); +function renderDashboard({ stats, rank, elo, eloRank: rankLabel, online, recent, daily }) { + const r = eloRankFor(elo || 1000); + setText('dash-hero-name', state.user.user); + setText('dash-rank-label', rankLabel || r.label); + setText('dash-elo', elo || 1000); + setText('dash-rank-icon', r.icon); - const wr = (stats.matches > 0) ? Math.round((stats.wins / stats.matches) * 100) + '%' : '—'; + 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'); - // Store for the "Practice this" shortcut 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 === 0) { - tbody.innerHTML = 'No sessions yet'; + if (!recent?.length) { + tbody.innerHTML = 'No sessions yet'; } else { tbody.innerHTML = recent.map(r => - `${esc(r.stratagem)}${r.score}${(r.time_ms / 1000).toFixed(2)}s` + ` + ${esc(r.stratagem)} + ${esc(r.mode || 'timed')} + ${r.score} + ${(r.time_ms / 1000).toFixed(2)}s + ` ).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 => `
${ARROW[d]}
`).join(''); +} + 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) { + const players = (online || []).filter(u => { + const name = typeof u === 'object' ? u.name : u; + return name !== state.user?.user; + }); + if (!players.length) { el.innerHTML = 'No other Helldivers online'; } else { - el.innerHTML = others.map(u => - `
+ el.innerHTML = players.map(u => { + const name = typeof u === 'object' ? u.name : u; + const elo = typeof u === 'object' ? u.elo : ''; + return `
- ${esc(u)} - -
` - ).join(''); + ${esc(name)} + ${elo ? `${elo}` : ''} + +
`; + }).join(''); } } @@ -308,7 +436,6 @@ 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); @@ -316,103 +443,268 @@ function startDailyChallenge() { } } -// ── Practice mode ───────────────────────────────────────────────────────────── +// ── 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 ``; + return ``; }).join(''); } function toggleCategory(cat) { - if (state.practice.selectedCats.has(cat)) { - state.practice.selectedCats.delete(cat); - } else { - state.practice.selectedCats.add(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 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() { +function resetSessionStats() { + state.practice.sessionStats = { completed: 0, missed: 0, bestTime: Infinity, stratagems: {} }; +} + +function startPractice() { const pool = getPool(); - if (pool.length === 0) { showPracticeIdle(); return; } - state.practice.current = pool[Math.floor(Math.random() * pool.length)]; - state.practice.progress = 0; - state.practice.timeLeft = 30; - state.practice.startTime = Date.now(); + 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 + ? `${esc(s.name)}` + : `
`; + return `
+ ${iconHtml} +
${esc(s.name)}
+
`; + }).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); + api('POST', '/scores/practice', { + stratagem: '__speedrun__', + category: 'All', + time_ms: totalMs, + score: p.score, + mode: 'speedrun', + }).catch(() => {}); + showToast(`Speedrun complete! ${(totalMs / 1000).toFixed(2)}s`); + openSessionSummary(); + return; + } + strat = p.speedrunPool[0]; + } else { + 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(); - startPracticeTimer(); + 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(); + 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(); - // Flash timer to signal timeout - const timerEl = document.getElementById('practice-timer'); - timerEl.classList.add('flash-wrong'); - setTimeout(() => { timerEl.classList.remove('flash-wrong'); nextStratagem(); }, 700); + 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; - setText('practice-category', s.category); - setText('practice-name', s.name); - renderArrows('practice-sequence', s.sequence, state.practice.progress); - updateTimerDisplay(); + 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) { @@ -427,100 +719,317 @@ function renderArrows(containerId, sequence, progress) { }).join(''); } -function updateTimerDisplay() { +function updateTimerDisplay(total) { + if (state.practice.mode === 'speedrun') return; const el = document.getElementById('practice-timer'); if (!el) return; - el.textContent = state.practice.timeLeft; - el.className = 'timer' + (state.practice.timeLeft <= 5 ? ' timer-danger' : ''); + 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); } + +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) => + `` + ).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 seq = p.current.sequence; - const arrows = document.querySelectorAll('#practice-sequence .arrow-key'); - const curArrow = arrows[p.progress]; + 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]) { - // Correct input - curArrow?.classList.add('flash-correct'); + cur?.classList.add('flash-correct'); p.progress++; if (p.progress === seq.length) { - // Stratagem completed! clearInterval(p.timerHandle); + document.getElementById('danger-vignette').classList.add('hidden'); + 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)); + 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(); - // Flash all arrows green + // 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); - // Save result (fire-and-forget) - api('POST', '/scores/practice', { - stratagem: p.current.name, - category: p.current.category, - time_ms: elapsed, - score: pts, - }).catch(() => {}); + // Score popup + showScorePopup('+' + pts); - setTimeout(nextStratagem, 600); + if (mode !== 'speedrun') { + 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 { - // Re-render with updated progress (highlights next arrow) renderArrows('practice-sequence', seq, p.progress); } + } else { - // Wrong input – reset progress - curArrow?.classList.add('flash-wrong'); + // Wrong input + cur?.classList.add('flash-wrong'); p.progress = 0; - p.streak = 0; - updateStreakDisplay(); - setTimeout(() => { - renderArrows('practice-sequence', seq, 0); - }, 350); + 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); } } -// ── Lobby ───────────────────────────────────────────────────────────────────── -function updateLobbyView() { - const others = state.lobby.online.filter(u => u !== state.user?.user); - const el = document.getElementById('lobby-players'); - if (!el) return; +// ── Settings modal ──────────────────────────────────────────────────────────── +function openSettingsModal() { + applySettingsToUI(); + document.getElementById('modal-settings').classList.remove('hidden'); +} - if (others.length === 0) { - el.innerHTML = '

No other Helldivers online. Waiting for reinforcements...

'; - } else { - el.innerHTML = others.map(u => - `
- - ${esc(u)} - -
` - ).join(''); +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 => `
+
${esc(String(x.val))}
+
${esc(x.label)}
+
`).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 ? `` : ''; + return `
+ ${i + 1}. + ${iconHtml} + ${esc(name)} + ${(avgMs / 1000).toFixed(2)}s avg +
`; + }).join(''); + } else { + topEl.innerHTML = '

No data

'; + } + } + + 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 => { + const name = typeof u === 'object' ? u.name : u; + return name !== state.user?.user; + }); + const el = document.getElementById('lobby-players'); + if (!el) return; + + if (!others.length) { + el.innerHTML = `
+
📡
+

No other Helldivers online.
Waiting for reinforcements...

+
`; + } else { + el.innerHTML = others.map(u => { + const name = typeof u === 'object' ? u.name : u; + const elo = typeof u === 'object' ? u.elo : ''; + const rank = typeof u === 'object' ? u.rank : ''; + return `
+ + ${esc(name)} + ${elo ? `${esc(rank)} · ${elo}` : ''} + +
`; + }).join(''); } - // Incoming challenges const challEl = document.getElementById('lobby-challenges'); if (!challEl) return; const inc = state.lobby.incoming; - if (inc.length === 0) { - challEl.innerHTML = ''; + if (!inc.length) { + challEl.innerHTML = '

No incoming challenges

'; } else { challEl.innerHTML = inc.map(from => `
- ${esc(from)} challenges you to a duel! + ${esc(from)} challenges you!
` @@ -535,14 +1044,15 @@ function sendChallenge(target) { function acceptChallenge(from) { wsSend('accept-challenge', { challengerId: from }); - // Remove from incoming list 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(); } @@ -560,6 +1070,28 @@ function updateChallengeBadge() { } } +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; @@ -568,13 +1100,14 @@ function renderMatchWaiting() { 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.classList.remove('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() { @@ -596,9 +1129,14 @@ function renderMatchRound() { 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) { @@ -610,7 +1148,7 @@ function updateMyArrows(correct) { } } -function updateOppArrows(correct) { +function updateOppArrows() { renderArrows('match-opp-sequence', state.match.current.sequence, state.match.oppProgress); } @@ -623,56 +1161,276 @@ 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 + 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.textContent = 'Ready for next round'; + btn.disabled = false; btn.classList.remove('hidden'); 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').classList.add('hidden'); - setTimeout(() => { if (state.currentView === 'match') showView('lobby'); }, 3000); +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 ? `` : ''; + return `
+ R${r.round || (i + 1)} + ${iconHtml} + ${esc(stratName)} + ${iMine ? '✓ Won' : '✗ Lost'} +
`; + }).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 = 'Loading...'; + try { - const rows = await api('GET', '/scores/leaderboard'); - if (rows.length === 0) { - tbody.innerHTML = 'No scores yet. Start practicing!'; - } else { - tbody.innerHTML = rows.map((r, i) => - ` - ${i + 1} - ${esc(r.username)} - ${r.totalScore} - ${r.sessions} - ${r.wins}/${r.matches} - ` - ).join(''); + if (tab === 'practice') { + if (thead) thead.innerHTML = '#HelldiverRankTotal ScoreSessionsMatch W/Total'; + const rows = await api('GET', '/scores/leaderboard'); + if (!rows.length) { + tbody.innerHTML = 'No scores yet. Start practicing!'; + } else { + tbody.innerHTML = rows.map((r, i) => { + const rank = eloRankFor(r.elo || 1000); + return ` + ${i + 1} + ${esc(r.username)} + ${rank.icon} ${rank.label} + ${r.totalScore} + ${r.sessions} + ${r.wins}/${r.matches} + `; + }).join(''); + } + } else if (tab === 'elo') { + if (thead) thead.innerHTML = '#HelldiverELORankMatches W/Total'; + const rows = await api('GET', '/scores/leaderboard/elo'); + if (!rows.length) { + tbody.innerHTML = 'No ELO data yet. Play some 1v1 matches!'; + } else { + tbody.innerHTML = rows.map((r, i) => { + const rank = eloRankFor(r.elo); + return ` + ${i + 1} + ${esc(r.username)} + ${r.elo} + ${rank.icon} ${rank.label} + ${r.wins || 0}/${r.matches || 0} + `; + }).join(''); + } + } else if (tab === 'speedrun') { + if (thead) thead.innerHTML = '#HelldiverTotal Time'; + const rows = await api('GET', '/scores/leaderboard/speedrun'); + if (!rows.length) { + tbody.innerHTML = 'No speedrun data yet. Try Speed Run mode!'; + } else { + tbody.innerHTML = rows.map((r, i) => + ` + ${i + 1} + ${esc(r.username)} + ${(r.totalTime / 1000).toFixed(2)}s + + ` + ).join(''); + } } } catch { - tbody.innerHTML = 'Error loading leaderboard'; + tbody.innerHTML = 'Error loading leaderboard'; } } -// ── Admin panel ─────────────────────────────────────────────────────────────── +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 = 'Loading...'; + + 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 = 'No sessions yet'; + } 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 ? `` : ''; + return ` + ${iconHtml}${esc(r.stratagem)} + ${esc(r.category || '—')} + ${esc(r.mode || 'timed')} + ${r.score} + ${(r.time_ms / 1000).toFixed(2)}s + ${date} + `; + }).join(''); + } + renderHistoryChart(data.rows); + } + + renderHistoryPagination(limit); + loadStratagemStats(); + } catch { + if (tbody) tbody.innerHTML = 'Error loading history'; + } +} + +function renderHistoryChart(sessions) { + const svg = document.getElementById('history-chart-svg'); + if (!svg || sessions.length < 2) { + if (svg) svg.innerHTML = 'Not enough data'; + 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 = ` + ${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 ``; + }).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 = ` + + Page ${page} / ${pages} + + `; +} + +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 = 'No data yet'; + } else { + tbody.innerHTML = rows.map(r => { + const strat = state.stratagems.find(s => s.name === r.stratagem); + const iconHtml = strat?.icon ? `` : ''; + return ` + ${iconHtml} + ${esc(r.stratagem)} + ${esc(strat?.category || '—')} + ${r.best_time ? (r.best_time / 1000).toFixed(2) + 's' : '—'} + ${r.attempts || 0} + `; + }).join(''); + } + } catch { /* ignore */ } +} + +// ── Admin ───────────────────────────────────────────────────────────────────── async function loadAdmin() { if (state.user?.role !== 'admin') { showView('dashboard'); return; } try { @@ -687,9 +1445,9 @@ function renderAdminUsers(users) { const el = document.getElementById('admin-users'); el.innerHTML = users.map(u => `
- ${esc(u.username)} - ${u.role} - ${u.mustChange ? 'temp pw' : ''} + ${esc(u.username)} + ${u.role} + ${u.mustChange ? 'temp pw' : ''} ${u.username !== state.user.user ? `` : ''} @@ -704,7 +1462,6 @@ async function createUser() { 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 }); @@ -728,11 +1485,10 @@ async function deleteUser(username) { } } -// ── Event delegation (replaces inline onclick for user-data actions) ────────── +// ── 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; @@ -742,21 +1498,49 @@ document.addEventListener('click', (e) => { 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 input ──────────────────────────────────────────────────────────── +// ── 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(); // prevent page scroll + e.preventDefault(); 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); @@ -779,23 +1563,32 @@ function setText(id, value) { function showToast(msg) { const container = document.getElementById('toast-container'); - // Limit simultaneous toasts to avoid stacking 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); } -// ── Static button bindings (replaces inline onclick – blocked by CSP script-src-attr) ── +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); @@ -804,7 +1597,6 @@ document.getElementById('match-ready-btn') ?.addEventListener('click', setRe document.getElementById('btn-leave-match') ?.addEventListener('click', leaveMatch); document.getElementById('btn-create-user') ?.addEventListener('click', createUser); -// D-pad: practice and match both use data-dir buttons document.getElementById('practice-dpad')?.addEventListener('click', (e) => { const dir = e.target.closest('[data-dir]')?.dataset.dir; if (dir) dpadInput(dir); diff --git a/public/icons/_map.json b/public/icons/_map.json new file mode 100644 index 0000000..ff23a0a --- /dev/null +++ b/public/icons/_map.json @@ -0,0 +1,67 @@ +{ + "Reinforce": "/icons/reinforce.svg", + "Resupply": "/icons/resupply.svg", + "SOS Beacon": "/icons/sos_beacon.svg", + "Hellbomb": "/icons/hellbomb.svg", + "SEAF Artillery": "/icons/seaf_artillery.svg", + "Upload Data": "/icons/upload_data.svg", + "Prospecting Drill": "/icons/prospecting_drill.svg", + "Orbital Illumination Flare": "/icons/orbital_illumination_flare.svg", + "Orbital Gatling Barrage": "/icons/orbital_gatling_barrage.svg", + "Orbital Airburst Strike": "/icons/orbital_airburst_strike.svg", + "Orbital 120MM HE Barrage": "/icons/orbital_120mm_he_barrage.svg", + "Orbital 380MM HE Barrage": "/icons/orbital_380mm_he_barrage.svg", + "Orbital Walking Barrage": "/icons/orbital_walking_barrage.svg", + "Orbital Laser": "/icons/orbital_laser.svg", + "Orbital Railcannon Strike": "/icons/orbital_railcannon_strike.svg", + "Orbital Precision Strike": "/icons/orbital_precision_strike.svg", + "Orbital Gas Strike": "/icons/orbital_gas_strike.svg", + "Orbital EMS Strike": "/icons/orbital_ems_strike.svg", + "Orbital Smoke Strike": "/icons/orbital_smoke_strike.svg", + "Tesla Tower": "/icons/tesla_tower.svg", + "Shield Generator Relay": "/icons/shield_generator_relay.svg", + "HMG Emplacement": "/icons/hmg_emplacement.svg", + "Eagle Strafing Run": "/icons/eagle_strafing_run.svg", + "Eagle Airstrike": "/icons/eagle_airstrike.svg", + "Eagle Cluster Bomb": "/icons/eagle_cluster_bomb.svg", + "Eagle Napalm Airstrike": "/icons/eagle_napalm_airstrike.svg", + "LIFT-850 Jump Pack": "/icons/lift_850_jump_pack.svg", + "Eagle Smoke Strike": "/icons/eagle_smoke_strike.svg", + "Eagle 110MM Rocket Pods": "/icons/eagle_110mm_rocket_pods.svg", + "Eagle 500KG Bomb": "/icons/eagle_500kg_bomb.svg", + "Eagle Rearm": "/icons/eagle_rearm.svg", + "Machine Gun": "/icons/machine_gun.svg", + "Anti-Materiel Rifle": "/icons/anti_materiel_rifle.svg", + "Stalwart": "/icons/stalwart.svg", + "Expendable Anti-Tank": "/icons/expendable_anti_tank.svg", + "Recoilless Rifle": "/icons/recoilless_rifle.svg", + "Flamethrower": "/icons/flamethrower.svg", + "Autocannon": "/icons/autocannon.svg", + "Heavy Machine Gun": "/icons/heavy_machine_gun.svg", + "Airburst Rocket Launcher": "/icons/airburst_rocket_launcher.svg", + "Commando": "/icons/commando.svg", + "Railgun": "/icons/railgun.svg", + "Spear": "/icons/spear.svg", + "Quasar Cannon": "/icons/quasar_cannon.svg", + "Arc Thrower": "/icons/arc_thrower.svg", + "Laser Cannon": "/icons/laser_cannon.svg", + "Grenade Launcher": "/icons/grenade_launcher.svg", + "Supply Pack": "/icons/supply_pack.svg", + "Guard Dog Rover": "/icons/guard_dog_rover.svg", + "Ballistic Shield Backpack": "/icons/ballistic_shield_backpack.svg", + "Shield Generator Pack": "/icons/shield_generator_pack.svg", + "Anti-Personnel Minefield": "/icons/anti_personnel_minefield.svg", + "Incendiary Mines": "/icons/incendiary_mines.svg", + "Anti-Tank Mines": "/icons/anti_tank_mines.svg", + "Machine Gun Sentry": "/icons/machine_gun_sentry.svg", + "Gatling Sentry": "/icons/gatling_sentry.svg", + "Mortar Sentry": "/icons/mortar_sentry.svg", + "Guard Dog": "/icons/guard_dog.svg", + "Autocannon Sentry": "/icons/autocannon_sentry.svg", + "Rocket Sentry": "/icons/rocket_sentry.svg", + "EMS Mortar Sentry": "/icons/ems_mortar_sentry.svg", + "Patriot Exosuit": "/icons/patriot_exosuit.svg", + "Emancipator Exosuit": "/icons/emancipator_exosuit.svg", + "Directional Shield": "/icons/directional_shield.svg", + "Anti-Tank Emplacement": "/icons/anti_tank_emplacement.svg" +} \ No newline at end of file diff --git a/public/icons/airburst_rocket_launcher.svg b/public/icons/airburst_rocket_launcher.svg new file mode 100644 index 0000000..2056ef4 --- /dev/null +++ b/public/icons/airburst_rocket_launcher.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/icons/anti_materiel_rifle.svg b/public/icons/anti_materiel_rifle.svg new file mode 100644 index 0000000..39ed438 --- /dev/null +++ b/public/icons/anti_materiel_rifle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/icons/anti_personnel_minefield.svg b/public/icons/anti_personnel_minefield.svg new file mode 100644 index 0000000..b01f92c --- /dev/null +++ b/public/icons/anti_personnel_minefield.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/public/icons/anti_tank_emplacement.svg b/public/icons/anti_tank_emplacement.svg new file mode 100644 index 0000000..c00222a --- /dev/null +++ b/public/icons/anti_tank_emplacement.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/icons/anti_tank_mines.svg b/public/icons/anti_tank_mines.svg new file mode 100644 index 0000000..1843037 --- /dev/null +++ b/public/icons/anti_tank_mines.svg @@ -0,0 +1 @@ + diff --git a/public/icons/arc_thrower.svg b/public/icons/arc_thrower.svg new file mode 100644 index 0000000..8ced9b2 --- /dev/null +++ b/public/icons/arc_thrower.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/public/icons/autocannon.svg b/public/icons/autocannon.svg new file mode 100644 index 0000000..ec09df6 --- /dev/null +++ b/public/icons/autocannon.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/public/icons/autocannon_sentry.svg b/public/icons/autocannon_sentry.svg new file mode 100644 index 0000000..a01e7d9 --- /dev/null +++ b/public/icons/autocannon_sentry.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/public/icons/ballistic_shield_backpack.svg b/public/icons/ballistic_shield_backpack.svg new file mode 100644 index 0000000..0dcd29d --- /dev/null +++ b/public/icons/ballistic_shield_backpack.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/public/icons/commando.svg b/public/icons/commando.svg new file mode 100644 index 0000000..40117f4 --- /dev/null +++ b/public/icons/commando.svg @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/public/icons/directional_shield.svg b/public/icons/directional_shield.svg new file mode 100644 index 0000000..bbbe779 --- /dev/null +++ b/public/icons/directional_shield.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/icons/eagle_110mm_rocket_pods.svg b/public/icons/eagle_110mm_rocket_pods.svg new file mode 100644 index 0000000..d89638e --- /dev/null +++ b/public/icons/eagle_110mm_rocket_pods.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/public/icons/eagle_500kg_bomb.svg b/public/icons/eagle_500kg_bomb.svg new file mode 100644 index 0000000..1c5ebb2 --- /dev/null +++ b/public/icons/eagle_500kg_bomb.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/public/icons/eagle_airstrike.svg b/public/icons/eagle_airstrike.svg new file mode 100644 index 0000000..185eb3c --- /dev/null +++ b/public/icons/eagle_airstrike.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/public/icons/eagle_cluster_bomb.svg b/public/icons/eagle_cluster_bomb.svg new file mode 100644 index 0000000..c1d1cc6 --- /dev/null +++ b/public/icons/eagle_cluster_bomb.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/icons/eagle_napalm_airstrike.svg b/public/icons/eagle_napalm_airstrike.svg new file mode 100644 index 0000000..ad9b890 --- /dev/null +++ b/public/icons/eagle_napalm_airstrike.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/public/icons/eagle_rearm.svg b/public/icons/eagle_rearm.svg new file mode 100644 index 0000000..a3551ee --- /dev/null +++ b/public/icons/eagle_rearm.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/public/icons/eagle_smoke_strike.svg b/public/icons/eagle_smoke_strike.svg new file mode 100644 index 0000000..3780320 --- /dev/null +++ b/public/icons/eagle_smoke_strike.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/public/icons/eagle_strafing_run.svg b/public/icons/eagle_strafing_run.svg new file mode 100644 index 0000000..e94ec15 --- /dev/null +++ b/public/icons/eagle_strafing_run.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/icons/emancipator_exosuit.svg b/public/icons/emancipator_exosuit.svg new file mode 100644 index 0000000..41fb9a2 --- /dev/null +++ b/public/icons/emancipator_exosuit.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/icons/ems_mortar_sentry.svg b/public/icons/ems_mortar_sentry.svg new file mode 100644 index 0000000..291fee1 --- /dev/null +++ b/public/icons/ems_mortar_sentry.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/public/icons/expendable_anti_tank.svg b/public/icons/expendable_anti_tank.svg new file mode 100644 index 0000000..c186324 --- /dev/null +++ b/public/icons/expendable_anti_tank.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/public/icons/flamethrower.svg b/public/icons/flamethrower.svg new file mode 100644 index 0000000..c27eb34 --- /dev/null +++ b/public/icons/flamethrower.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/public/icons/gatling_sentry.svg b/public/icons/gatling_sentry.svg new file mode 100644 index 0000000..3bcefdf --- /dev/null +++ b/public/icons/gatling_sentry.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/public/icons/grenade_launcher.svg b/public/icons/grenade_launcher.svg new file mode 100644 index 0000000..555fc5f --- /dev/null +++ b/public/icons/grenade_launcher.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/icons/guard_dog.svg b/public/icons/guard_dog.svg new file mode 100644 index 0000000..2d1626c --- /dev/null +++ b/public/icons/guard_dog.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/public/icons/guard_dog_rover.svg b/public/icons/guard_dog_rover.svg new file mode 100644 index 0000000..d0dec92 --- /dev/null +++ b/public/icons/guard_dog_rover.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/public/icons/heavy_machine_gun.svg b/public/icons/heavy_machine_gun.svg new file mode 100644 index 0000000..f3fac24 --- /dev/null +++ b/public/icons/heavy_machine_gun.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/public/icons/hellbomb.svg b/public/icons/hellbomb.svg new file mode 100644 index 0000000..fe9c084 --- /dev/null +++ b/public/icons/hellbomb.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/public/icons/hmg_emplacement.svg b/public/icons/hmg_emplacement.svg new file mode 100644 index 0000000..d5e02ef --- /dev/null +++ b/public/icons/hmg_emplacement.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/public/icons/incendiary_mines.svg b/public/icons/incendiary_mines.svg new file mode 100644 index 0000000..5fbd866 --- /dev/null +++ b/public/icons/incendiary_mines.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/public/icons/laser_cannon.svg b/public/icons/laser_cannon.svg new file mode 100644 index 0000000..4091779 --- /dev/null +++ b/public/icons/laser_cannon.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/public/icons/lift_850_jump_pack.svg b/public/icons/lift_850_jump_pack.svg new file mode 100644 index 0000000..92eacef --- /dev/null +++ b/public/icons/lift_850_jump_pack.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/public/icons/machine_gun.svg b/public/icons/machine_gun.svg new file mode 100644 index 0000000..b1be61a --- /dev/null +++ b/public/icons/machine_gun.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/public/icons/machine_gun_sentry.svg b/public/icons/machine_gun_sentry.svg new file mode 100644 index 0000000..5c8bfa8 --- /dev/null +++ b/public/icons/machine_gun_sentry.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/public/icons/mortar_sentry.svg b/public/icons/mortar_sentry.svg new file mode 100644 index 0000000..1293d06 --- /dev/null +++ b/public/icons/mortar_sentry.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/public/icons/orbital_120mm_he_barrage.svg b/public/icons/orbital_120mm_he_barrage.svg new file mode 100644 index 0000000..b0a51a3 --- /dev/null +++ b/public/icons/orbital_120mm_he_barrage.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/public/icons/orbital_380mm_he_barrage.svg b/public/icons/orbital_380mm_he_barrage.svg new file mode 100644 index 0000000..7d4c540 --- /dev/null +++ b/public/icons/orbital_380mm_he_barrage.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/public/icons/orbital_airburst_strike.svg b/public/icons/orbital_airburst_strike.svg new file mode 100644 index 0000000..170ecff --- /dev/null +++ b/public/icons/orbital_airburst_strike.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/icons/orbital_ems_strike.svg b/public/icons/orbital_ems_strike.svg new file mode 100644 index 0000000..595b60d --- /dev/null +++ b/public/icons/orbital_ems_strike.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/public/icons/orbital_gas_strike.svg b/public/icons/orbital_gas_strike.svg new file mode 100644 index 0000000..ffc7ac0 --- /dev/null +++ b/public/icons/orbital_gas_strike.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/public/icons/orbital_gatling_barrage.svg b/public/icons/orbital_gatling_barrage.svg new file mode 100644 index 0000000..792c237 --- /dev/null +++ b/public/icons/orbital_gatling_barrage.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/icons/orbital_illumination_flare.svg b/public/icons/orbital_illumination_flare.svg new file mode 100644 index 0000000..47b8941 --- /dev/null +++ b/public/icons/orbital_illumination_flare.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/icons/orbital_laser.svg b/public/icons/orbital_laser.svg new file mode 100644 index 0000000..40a43d2 --- /dev/null +++ b/public/icons/orbital_laser.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/public/icons/orbital_precision_strike.svg b/public/icons/orbital_precision_strike.svg new file mode 100644 index 0000000..948ea3a --- /dev/null +++ b/public/icons/orbital_precision_strike.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/public/icons/orbital_railcannon_strike.svg b/public/icons/orbital_railcannon_strike.svg new file mode 100644 index 0000000..55b45dc --- /dev/null +++ b/public/icons/orbital_railcannon_strike.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/public/icons/orbital_smoke_strike.svg b/public/icons/orbital_smoke_strike.svg new file mode 100644 index 0000000..f55b6df --- /dev/null +++ b/public/icons/orbital_smoke_strike.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/public/icons/orbital_walking_barrage.svg b/public/icons/orbital_walking_barrage.svg new file mode 100644 index 0000000..c49ebfe --- /dev/null +++ b/public/icons/orbital_walking_barrage.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/public/icons/patriot_exosuit.svg b/public/icons/patriot_exosuit.svg new file mode 100644 index 0000000..7c7562e --- /dev/null +++ b/public/icons/patriot_exosuit.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/public/icons/prospecting_drill.svg b/public/icons/prospecting_drill.svg new file mode 100644 index 0000000..e0a4d92 --- /dev/null +++ b/public/icons/prospecting_drill.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/public/icons/quasar_cannon.svg b/public/icons/quasar_cannon.svg new file mode 100644 index 0000000..5dc4cf8 --- /dev/null +++ b/public/icons/quasar_cannon.svg @@ -0,0 +1,8 @@ + + + + diff --git a/public/icons/railgun.svg b/public/icons/railgun.svg new file mode 100644 index 0000000..61d0569 --- /dev/null +++ b/public/icons/railgun.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/public/icons/recoilless_rifle.svg b/public/icons/recoilless_rifle.svg new file mode 100644 index 0000000..62f0c22 --- /dev/null +++ b/public/icons/recoilless_rifle.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/public/icons/reinforce.svg b/public/icons/reinforce.svg new file mode 100644 index 0000000..8c90ed5 --- /dev/null +++ b/public/icons/reinforce.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/icons/resupply.svg b/public/icons/resupply.svg new file mode 100644 index 0000000..31f543e --- /dev/null +++ b/public/icons/resupply.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/public/icons/rocket_sentry.svg b/public/icons/rocket_sentry.svg new file mode 100644 index 0000000..31df2a3 --- /dev/null +++ b/public/icons/rocket_sentry.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/public/icons/seaf_artillery.svg b/public/icons/seaf_artillery.svg new file mode 100644 index 0000000..9fa7308 --- /dev/null +++ b/public/icons/seaf_artillery.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/icons/shield_generator_pack.svg b/public/icons/shield_generator_pack.svg new file mode 100644 index 0000000..e59f50b --- /dev/null +++ b/public/icons/shield_generator_pack.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/public/icons/shield_generator_relay.svg b/public/icons/shield_generator_relay.svg new file mode 100644 index 0000000..dd2ac16 --- /dev/null +++ b/public/icons/shield_generator_relay.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/public/icons/sos_beacon.svg b/public/icons/sos_beacon.svg new file mode 100644 index 0000000..0ed2911 --- /dev/null +++ b/public/icons/sos_beacon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/icons/spear.svg b/public/icons/spear.svg new file mode 100644 index 0000000..625f1e8 --- /dev/null +++ b/public/icons/spear.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/public/icons/stalwart.svg b/public/icons/stalwart.svg new file mode 100644 index 0000000..9f501c3 --- /dev/null +++ b/public/icons/stalwart.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/icons/supply_pack.svg b/public/icons/supply_pack.svg new file mode 100644 index 0000000..6d8640c --- /dev/null +++ b/public/icons/supply_pack.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/public/icons/tesla_tower.svg b/public/icons/tesla_tower.svg new file mode 100644 index 0000000..38abc5d --- /dev/null +++ b/public/icons/tesla_tower.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/public/icons/upload_data.svg b/public/icons/upload_data.svg new file mode 100644 index 0000000..67498e2 --- /dev/null +++ b/public/icons/upload_data.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/public/index.html b/public/index.html index 4f97876..0a0cfab 100644 --- a/public/index.html +++ b/public/index.html @@ -13,6 +13,7 @@ + + + + + + +