feat: initial Helldivers 2 Stratagem Trainer (practice, 1v1, leaderboard, dashboard)

This commit is contained in:
Jeremy Brandenburger
2026-03-30 13:32:55 +02:00
commit 3c22196f81
8 changed files with 2732 additions and 0 deletions
+778
View File
@@ -0,0 +1,778 @@
'use strict';
// ── State ─────────────────────────────────────────────────────────────────────
const state = {
user: null, // { user, role, mustChange }
currentView: 'login',
stratagems: [],
practice: {
active: false,
current: null,
progress: 0,
timeLeft: 30,
timerHandle: null,
startTime: null,
score: 0,
streak: 0,
selectedCats: new Set(), // empty = all categories
dailyTarget: null, // set when using daily challenge shortcut
},
lobby: {
online: [],
incoming: [], // usernames who challenged me
},
match: {
roomId: null,
opponent: null,
matchScores: {},
current: null,
myProgress: 0,
oppProgress: 0,
roundActive: false,
},
ws: null,
wsReconnectTimer: null,
};
// ── API helpers ───────────────────────────────────────────────────────────────
async function api(method, endpoint, body) {
const opts = { method, headers: { 'Content-Type': 'application/json' } };
if (body !== undefined) opts.body = JSON.stringify(body);
const res = await fetch('/api' + endpoint, opts);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || 'Request failed');
return data;
}
// ── View system ───────────────────────────────────────────────────────────────
function showView(name) {
document.querySelectorAll('.view').forEach(v => v.classList.add('hidden'));
const el = document.getElementById('view-' + name);
if (el) el.classList.remove('hidden');
state.currentView = name;
// Highlight active nav button
document.querySelectorAll('.nav-btn').forEach(b => {
b.classList.toggle('active', b.dataset.view === name);
});
// Stop practice timer when navigating away
if (name !== 'practice') stopPracticeTimer();
// View-specific init
if (name === 'dashboard') loadDashboard();
if (name === 'leaderboard') loadLeaderboard();
if (name === 'admin') loadAdmin();
if (name === 'practice') initPracticeView();
if (name === 'lobby') updateLobbyView();
}
// ── Authentication ────────────────────────────────────────────────────────────
async function checkAuth() {
try {
const data = await api('GET', '/me');
if (data.user) {
state.user = data;
if (data.mustChange) {
showView('change-password');
} else {
onLoggedIn();
}
} else {
showView('login');
}
} catch {
showView('login');
}
}
function onLoggedIn() {
document.getElementById('main-nav').classList.remove('hidden');
document.getElementById('nav-username').textContent = state.user.user;
document.getElementById('nav-admin').classList.toggle('hidden', state.user.role !== 'admin');
state.stratagems = window.STRATAGEMS || [];
connectWS();
showView('dashboard');
}
async function logout() {
stopPracticeTimer();
if (state.ws) state.ws.close();
clearTimeout(state.wsReconnectTimer);
await api('POST', '/logout').catch(() => {});
state.user = null;
document.getElementById('main-nav').classList.add('hidden');
showView('login');
}
// Login form
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const el = document.getElementById('login-error');
el.classList.add('hidden');
try {
await api('POST', '/login', {
username: document.getElementById('login-username').value.trim(),
password: document.getElementById('login-password').value,
});
await checkAuth();
} catch (err) {
el.textContent = err.message;
el.classList.remove('hidden');
}
});
// Change password form
document.getElementById('change-password-form').addEventListener('submit', async (e) => {
e.preventDefault();
const errEl = document.getElementById('cp-error');
const newPw = document.getElementById('cp-new').value;
const confPw = document.getElementById('cp-confirm').value;
errEl.classList.add('hidden');
if (newPw !== confPw) {
errEl.textContent = 'Passwords do not match';
errEl.classList.remove('hidden');
return;
}
try {
await api('POST', '/change-password', {
oldPassword: document.getElementById('cp-old').value,
newPassword: newPw,
});
state.user.mustChange = false;
onLoggedIn();
} catch (err) {
errEl.textContent = err.message;
errEl.classList.remove('hidden');
}
});
// Nav buttons
document.querySelectorAll('.nav-btn[data-view]').forEach(btn => {
btn.addEventListener('click', () => showView(btn.dataset.view));
});
// ── WebSocket ─────────────────────────────────────────────────────────────────
function connectWS() {
if (state.ws) return;
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
state.ws = new WebSocket(proto + '//' + location.host);
state.ws.onopen = () => { clearTimeout(state.wsReconnectTimer); };
state.ws.onmessage = (e) => { try { handleWSMessage(JSON.parse(e.data)); } catch {} };
state.ws.onerror = () => state.ws.close();
state.ws.onclose = () => {
state.ws = null;
if (state.user) {
state.wsReconnectTimer = setTimeout(connectWS, 3000);
}
};
}
function wsSend(type, payload) {
if (state.ws?.readyState === WebSocket.OPEN) {
state.ws.send(JSON.stringify({ type, payload: payload || {} }));
}
}
function handleWSMessage({ type, payload }) {
switch (type) {
case 'lobby-update':
state.lobby.online = payload.online || [];
state.lobby.incoming = payload.incoming || [];
if (state.currentView === 'lobby') updateLobbyView();
if (state.currentView === 'dashboard') updateDashboardOnline(payload.online);
updateChallengeBadge();
break;
case 'challenge-received':
if (!state.lobby.incoming.includes(payload.from)) state.lobby.incoming.push(payload.from);
updateChallengeBadge();
if (state.currentView === 'lobby') updateLobbyView();
showToast(esc(payload.from) + ' challenges you! Go to 1v1 to respond.');
break;
case 'challenge-declined':
showToast(esc(payload.by) + ' declined your challenge.');
break;
case 'room-joined':
state.match.roomId = payload.roomId;
state.match.opponent = payload.opponent;
state.match.matchScores = payload.matchScores;
state.match.myProgress = 0;
state.match.oppProgress = 0;
state.match.roundActive = false;
showView('match');
renderMatchWaiting();
break;
case 'round-start':
state.match.current = payload.stratagem;
state.match.myProgress = 0;
state.match.oppProgress = 0;
state.match.roundActive = true;
renderMatchRound();
break;
case 'input-result':
if (payload.userId === state.user.user) {
state.match.myProgress = payload.progress;
updateMyArrows(payload.correct);
} else {
state.match.oppProgress = payload.progress;
updateOppArrows(payload.correct);
}
break;
case 'round-complete':
state.match.roundActive = false;
state.match.matchScores = payload.matchScores;
renderRoundResult(payload.winner);
break;
case 'match-end':
state.match.matchScores = payload.matchScores;
renderMatchEnd(payload.winner);
break;
case 'opponent-left':
showToast('Opponent left the match.');
setTimeout(() => showView('lobby'), 1800);
break;
}
}
// ── Dashboard ─────────────────────────────────────────────────────────────────
async function loadDashboard() {
try {
const data = await api('GET', '/dashboard');
renderDashboard(data);
} catch {
/* silently ignore dashboard is cosmetic */
}
}
function renderDashboard({ stats, rank, online, recent, daily }) {
setText('dash-total-score', stats.totalScore || 0);
setText('dash-rank', rank ? '#' + rank.position : 'Unranked');
setText('dash-sessions', stats.sessions || 0);
const wr = (stats.matches > 0) ? Math.round((stats.wins / stats.matches) * 100) + '%' : '—';
setText('dash-win-rate', wr);
if (daily) {
setText('dash-daily-name', daily.stratagem.name);
setText('dash-daily-category', daily.stratagem.category);
setText('dash-daily-best', daily.bestTime ? (daily.bestTime / 1000).toFixed(2) + 's' : 'No record yet');
// Store for the "Practice this" shortcut
state.practice.dailyTarget = daily.stratagem.name;
}
const tbody = document.getElementById('dash-recent');
if (recent.length === 0) {
tbody.innerHTML = '<tr><td colspan="3" class="muted">No sessions yet</td></tr>';
} else {
tbody.innerHTML = recent.map(r =>
`<tr><td>${esc(r.stratagem)}</td><td>${r.score}</td><td>${(r.time_ms / 1000).toFixed(2)}s</td></tr>`
).join('');
}
updateDashboardOnline(online);
}
function updateDashboardOnline(online) {
const el = document.getElementById('dash-online');
if (!el) return;
const others = (online || []).filter(u => u !== state.user?.user);
if (others.length === 0) {
el.innerHTML = '<span class="muted">No other Helldivers online</span>';
} else {
el.innerHTML = others.map(u =>
`<div class="online-user">
<span class="online-dot"></span>
<span style="flex:1;font-family:var(--font-mono)">${esc(u)}</span>
<button class="btn btn-sm btn-accent" onclick="sendChallenge('${esc(u)}')">⚔ Challenge</button>
</div>`
).join('');
}
}
function startDailyChallenge() {
if (!state.practice.dailyTarget) return;
state.practice.selectedCats.clear();
showView('practice');
// start a practice session focused on the daily stratagem
const strat = state.stratagems.find(s => s.name === state.practice.dailyTarget);
if (strat) {
state.practice.selectedCats.add(strat.category);
startPractice();
}
}
// ── Practice mode ─────────────────────────────────────────────────────────────
function initPracticeView() {
renderCategoryFilters();
if (!state.practice.active) showPracticeIdle();
}
function renderCategoryFilters() {
const cats = [...new Set(state.stratagems.map(s => s.category))];
const el = document.getElementById('practice-categories');
el.innerHTML = cats.map(cat => {
const active = state.practice.selectedCats.size === 0 || state.practice.selectedCats.has(cat);
return `<button class="cat-btn ${active ? 'active' : ''}"
onclick="toggleCategory('${esc(cat)}')">${esc(cat)}</button>`;
}).join('');
}
function toggleCategory(cat) {
if (state.practice.selectedCats.has(cat)) {
state.practice.selectedCats.delete(cat);
} else {
state.practice.selectedCats.add(cat);
}
renderCategoryFilters();
}
function showPracticeIdle() {
document.getElementById('practice-idle').classList.remove('hidden');
document.getElementById('practice-active').classList.add('hidden');
state.practice.active = false;
}
function startPractice() {
if (getPool().length === 0) { showToast('No stratagems match the selected filters'); return; }
state.practice.active = true;
state.practice.score = 0;
state.practice.streak = 0;
document.getElementById('practice-idle').classList.add('hidden');
document.getElementById('practice-active').classList.remove('hidden');
nextStratagem();
}
function stopPracticeUI() {
stopPracticeTimer();
showPracticeIdle();
}
function stopPracticeTimer() {
clearInterval(state.practice.timerHandle);
state.practice.timerHandle = null;
state.practice.active = false;
}
function getPool() {
const cats = state.practice.selectedCats;
if (cats.size === 0) return state.stratagems;
return state.stratagems.filter(s => cats.has(s.category));
}
function nextStratagem() {
const pool = getPool();
state.practice.current = pool[Math.floor(Math.random() * pool.length)];
state.practice.progress = 0;
state.practice.timeLeft = 30;
state.practice.startTime = Date.now();
renderPracticeStratagem();
startPracticeTimer();
}
function startPracticeTimer() {
clearInterval(state.practice.timerHandle);
state.practice.timerHandle = setInterval(() => {
state.practice.timeLeft--;
updateTimerDisplay();
if (state.practice.timeLeft <= 0) {
clearInterval(state.practice.timerHandle);
state.practice.streak = 0;
updateStreakDisplay();
// Flash timer to signal timeout
const timerEl = document.getElementById('practice-timer');
timerEl.classList.add('flash-wrong');
setTimeout(() => { timerEl.classList.remove('flash-wrong'); nextStratagem(); }, 700);
}
}, 1000);
}
function renderPracticeStratagem() {
const s = state.practice.current;
setText('practice-category', s.category);
setText('practice-name', s.name);
renderArrows('practice-sequence', s.sequence, state.practice.progress);
updateTimerDisplay();
updateScoreDisplay();
updateStreakDisplay();
}
function renderArrows(containerId, sequence, progress) {
const ARROW = { up: '↑', down: '↓', left: '←', right: '→' };
const el = document.getElementById(containerId);
if (!el) return;
el.innerHTML = sequence.map((dir, i) => {
let cls = 'arrow-key';
if (i < progress) cls += ' completed';
if (i === progress) cls += ' active';
return `<div class="${cls}">${ARROW[dir]}</div>`;
}).join('');
}
function updateTimerDisplay() {
const el = document.getElementById('practice-timer');
if (!el) return;
el.textContent = state.practice.timeLeft;
el.className = 'timer' + (state.practice.timeLeft <= 5 ? ' timer-danger' : '');
}
function updateScoreDisplay() { setText('practice-score', state.practice.score); }
function updateStreakDisplay() { setText('practice-streak', state.practice.streak); }
function handlePracticeInput(dir) {
const p = state.practice;
if (!p.active || !p.current) return;
const seq = p.current.sequence;
const arrows = document.querySelectorAll('#practice-sequence .arrow-key');
const curArrow = arrows[p.progress];
if (dir === seq[p.progress]) {
// Correct input
curArrow?.classList.add('flash-correct');
p.progress++;
if (p.progress === seq.length) {
// Stratagem completed!
clearInterval(p.timerHandle);
const elapsed = Date.now() - p.startTime;
const secs = Math.min(30, elapsed / 1000);
const pts = Math.round((100 + (30 - secs) * 3) * (1 + p.streak * 0.1));
p.score += pts;
p.streak++;
updateScoreDisplay();
updateStreakDisplay();
// Flash all arrows green
document.querySelectorAll('#practice-sequence .arrow-key').forEach(el => {
el.classList.remove('flash-correct');
el.classList.add('completed');
});
// Save result (fire-and-forget)
api('POST', '/scores/practice', {
stratagem: p.current.name,
category: p.current.category,
time_ms: elapsed,
score: pts,
}).catch(() => {});
setTimeout(nextStratagem, 600);
} else {
// Re-render with updated progress (highlights next arrow)
renderArrows('practice-sequence', seq, p.progress);
}
} else {
// Wrong input reset progress
curArrow?.classList.add('flash-wrong');
p.progress = 0;
p.streak = 0;
updateStreakDisplay();
setTimeout(() => {
renderArrows('practice-sequence', seq, 0);
}, 350);
}
}
// ── Lobby ─────────────────────────────────────────────────────────────────────
function updateLobbyView() {
const others = state.lobby.online.filter(u => u !== state.user?.user);
const el = document.getElementById('lobby-players');
if (!el) return;
if (others.length === 0) {
el.innerHTML = '<p class="muted">No other Helldivers online. Waiting for reinforcements...</p>';
} else {
el.innerHTML = others.map(u =>
`<div class="lobby-player">
<span class="online-dot"></span>
<span class="player-name">${esc(u)}</span>
<button class="btn btn-sm btn-accent" onclick="sendChallenge('${esc(u)}')">⚔ Challenge</button>
</div>`
).join('');
}
// Incoming challenges
const challEl = document.getElementById('lobby-challenges');
if (!challEl) return;
const inc = state.lobby.incoming;
if (inc.length === 0) {
challEl.innerHTML = '';
} else {
challEl.innerHTML = inc.map(from =>
`<div class="challenge-item">
<span style="flex:1"><strong>${esc(from)}</strong> challenges you to a duel!</span>
<button class="btn btn-sm btn-accent" onclick="acceptChallenge('${esc(from)}')">Accept</button>
<button class="btn btn-sm btn-muted" onclick="declineChallenge('${esc(from)}')">Decline</button>
</div>`
).join('');
}
}
function sendChallenge(target) {
wsSend('challenge-user', { targetUser: target });
showToast('Challenge sent to ' + esc(target));
}
function acceptChallenge(from) {
wsSend('accept-challenge', { challengerId: from });
// Remove from incoming list
state.lobby.incoming = state.lobby.incoming.filter(u => u !== from);
updateChallengeBadge();
}
function declineChallenge(from) {
wsSend('decline-challenge', { challengerId: from });
state.lobby.incoming = state.lobby.incoming.filter(u => u !== from);
updateChallengeBadge();
if (state.currentView === 'lobby') updateLobbyView();
}
function updateChallengeBadge() {
const badge = document.getElementById('challenge-badge');
if (!badge) return;
const count = state.lobby.incoming.length;
if (count > 0) {
badge.textContent = count + ' Challenge' + (count > 1 ? 's' : '') + ' Go to 1v1';
badge.classList.remove('hidden');
badge.onclick = () => showView('lobby');
} else {
badge.classList.add('hidden');
}
}
// ── Match ─────────────────────────────────────────────────────────────────────
function renderMatchWaiting() {
const m = state.match;
setText('match-me-name', state.user.user);
setText('match-opp-name', m.opponent);
setText('match-status', 'Waiting for both players...');
setText('match-category', '');
renderMatchScores();
document.getElementById('match-round-area').classList.add('hidden');
const readyBtn = document.getElementById('match-ready-btn');
readyBtn.textContent = 'READY';
readyBtn.disabled = false;
readyBtn.style.display = 'inline-flex';
}
function renderMatchScores() {
const m = state.match;
setText('match-me-wins', m.matchScores[state.user.user] ?? 0);
setText('match-opp-wins', m.matchScores[m.opponent] ?? 0);
}
function setReady() {
wsSend('player-ready');
const btn = document.getElementById('match-ready-btn');
btn.textContent = 'Ready waiting for opponent...';
btn.disabled = true;
}
function renderMatchRound() {
const m = state.match;
setText('match-status', m.current.name);
setText('match-category', m.current.category);
document.getElementById('match-round-area').classList.remove('hidden');
document.getElementById('match-ready-btn').style.display = 'none';
renderArrows('match-me-sequence', m.current.sequence, 0);
renderArrows('match-opp-sequence', m.current.sequence, 0);
}
function updateMyArrows(correct) {
renderArrows('match-me-sequence', state.match.current.sequence, state.match.myProgress);
if (!correct) {
const el = document.getElementById('match-me-sequence');
el?.classList.add('flash-wrong-seq');
setTimeout(() => el?.classList.remove('flash-wrong-seq'), 350);
}
}
function updateOppArrows(correct) {
renderArrows('match-opp-sequence', state.match.current.sequence, state.match.oppProgress);
}
function handleMatchInput(dir) {
if (!state.match.roundActive) return;
wsSend('input-arrow', { direction: dir });
}
function renderRoundResult(winner) {
const won = winner === state.user.user;
setText('match-status', won ? '✓ ROUND WON' : '✗ ROUND LOST');
renderMatchScores();
// Short pause then show ready button for next round
setTimeout(() => {
document.getElementById('match-round-area').classList.add('hidden');
const btn = document.getElementById('match-ready-btn');
btn.textContent = 'Ready for next round';
btn.disabled = false;
btn.style.display = 'inline-flex';
setText('match-category', '');
}, 1600);
}
function renderMatchEnd(winner) {
const won = winner === state.user.user;
setText('match-status', won ? '🏆 MATCH WON!' : '☠ MATCH LOST');
renderMatchScores();
document.getElementById('match-round-area').classList.add('hidden');
document.getElementById('match-ready-btn').style.display = 'none';
setTimeout(() => showView('lobby'), 3000);
}
function leaveMatch() {
wsSend('leave-room');
showView('lobby');
}
// ── Leaderboard ───────────────────────────────────────────────────────────────
async function loadLeaderboard() {
const tbody = document.getElementById('leaderboard-table-body');
try {
const rows = await api('GET', '/scores/leaderboard');
if (rows.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="muted">No scores yet. Start practicing!</td></tr>';
} else {
tbody.innerHTML = rows.map((r, i) =>
`<tr class="${r.username === state.user?.user ? 'row-me' : ''}">
<td class="rank">${i + 1}</td>
<td style="font-family:var(--font-mono)">${esc(r.username)}</td>
<td style="font-family:var(--font-mono)">${r.totalScore}</td>
<td style="font-family:var(--font-mono)">${r.sessions}</td>
<td style="font-family:var(--font-mono)">${r.wins}/${r.matches}</td>
</tr>`
).join('');
}
} catch {
tbody.innerHTML = '<tr><td colspan="5" class="muted">Error loading leaderboard</td></tr>';
}
}
// ── Admin panel ───────────────────────────────────────────────────────────────
async function loadAdmin() {
if (state.user?.role !== 'admin') { showView('dashboard'); return; }
try {
const users = await api('GET', '/users');
renderAdminUsers(users);
} catch {
document.getElementById('admin-users').innerHTML = '<span class="muted">Error loading users</span>';
}
}
function renderAdminUsers(users) {
const el = document.getElementById('admin-users');
el.innerHTML = users.map(u =>
`<div class="admin-user-row">
<span class="user-name">${esc(u.username)}</span>
<span class="user-role badge-${u.role}">${u.role}</span>
${u.mustChange ? '<span class="badge-warning">temp pw</span>' : ''}
${u.username !== state.user.user
? `<button class="btn btn-sm btn-danger" onclick="deleteUser('${esc(u.username)}')">Delete</button>`
: ''}
</div>`
).join('');
}
async function createUser() {
const username = document.getElementById('new-username').value.trim();
const role = document.getElementById('new-role').value;
const errEl = document.getElementById('admin-error');
const pwEl = document.getElementById('new-pw-display');
errEl.classList.add('hidden');
pwEl.classList.add('hidden');
if (!username) return;
try {
const result = await api('POST', '/users', { username, role });
pwEl.textContent = 'Temp password for ' + esc(username) + ': ' + esc(result.tempPassword);
pwEl.classList.remove('hidden');
document.getElementById('new-username').value = '';
loadAdmin();
} catch (err) {
errEl.textContent = err.message;
errEl.classList.remove('hidden');
}
}
async function deleteUser(username) {
if (!confirm('Delete "' + username + '"? This cannot be undone.')) return;
try {
await api('DELETE', '/users/' + encodeURIComponent(username));
loadAdmin();
} catch (err) {
showToast('Error: ' + err.message);
}
}
// ── Keyboard input ────────────────────────────────────────────────────────────
document.addEventListener('keydown', (e) => {
const MAP = { ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right' };
const dir = MAP[e.key];
if (!dir) return;
if (state.currentView === 'practice' || state.currentView === 'match') {
e.preventDefault(); // prevent page scroll
dpadInput(dir);
}
});
// Called by on-screen D-pad and keyboard handler
function dpadInput(dir) {
if (state.currentView === 'practice') handlePracticeInput(dir);
if (state.currentView === 'match') handleMatchInput(dir);
}
// ── Utils ─────────────────────────────────────────────────────────────────────
function esc(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function setText(id, value) {
const el = document.getElementById(id);
if (el) el.textContent = value;
}
function showToast(msg) {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
toast.className = 'toast';
toast.textContent = msg;
container.appendChild(toast);
requestAnimationFrame(() => {
requestAnimationFrame(() => toast.classList.add('show'));
});
setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => toast.remove(), 300);
}, 3200);
}
// ── Init ──────────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', checkAuth);
+346
View File
@@ -0,0 +1,346 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HELLDIVERS 2 Stratagem Trainer</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Exo+2:wght@400;600;700&family=Rajdhani:wght@600;700&family=Share+Tech+Mono&display=swap" rel="stylesheet">
<link rel="stylesheet" href="styles.css">
</head>
<body>
<!-- ── Navigation ─────────────────────────────────────────────── -->
<nav id="main-nav" class="hidden">
<div class="nav-brand">
<span class="nav-logo"></span>
<span>HELLDIVERS 2</span>
</div>
<div class="nav-links">
<button class="nav-btn" data-view="dashboard">Dashboard</button>
<button class="nav-btn" data-view="practice">Training</button>
<button class="nav-btn" data-view="lobby">1v1</button>
<button class="nav-btn" data-view="leaderboard">Highscores</button>
<button class="nav-btn nav-btn-admin hidden" id="nav-admin" data-view="admin">Admin</button>
</div>
<div class="nav-user">
<span class="nav-username" id="nav-username"></span>
<button class="btn btn-muted btn-sm" onclick="logout()">Logout</button>
</div>
</nav>
<!-- Incoming challenge badge (shown anywhere) -->
<div id="challenge-badge" class="challenge-badge hidden"></div>
<!-- ── LOGIN ─────────────────────────────────────────────────── -->
<div id="view-login" class="view view-centered">
<div class="login-box">
<div class="login-header">
<div class="login-logo"></div>
<h1>HELLDIVERS 2</h1>
<p class="login-sub">STRATAGEM TRAINER — SUPER EARTH AUTHORIZED</p>
</div>
<form id="login-form" class="login-form" autocomplete="off">
<div class="field">
<label for="login-username">Helldiver ID</label>
<input id="login-username" type="text" placeholder="Username" autocomplete="username" required>
</div>
<div class="field">
<label for="login-password">Access Code</label>
<input id="login-password" type="password" placeholder="Password" autocomplete="current-password" required>
</div>
<p id="login-error" class="error hidden"></p>
<button type="submit" class="btn btn-accent btn-full">AUTHENTICATE</button>
</form>
</div>
</div>
<!-- ── CHANGE PASSWORD ───────────────────────────────────────── -->
<div id="view-change-password" class="view view-centered hidden">
<div class="login-box">
<div class="login-header">
<h2>CHANGE ACCESS CODE</h2>
<p class="login-sub">Temporary password must be changed before proceeding</p>
</div>
<form id="change-password-form" class="login-form">
<div class="field">
<label for="cp-old">Current Password</label>
<input id="cp-old" type="password" required autocomplete="current-password">
</div>
<div class="field">
<label for="cp-new">New Password (min 8 chars)</label>
<input id="cp-new" type="password" required minlength="8" autocomplete="new-password">
</div>
<div class="field">
<label for="cp-confirm">Confirm New Password</label>
<input id="cp-confirm" type="password" required autocomplete="new-password">
</div>
<p id="cp-error" class="error hidden"></p>
<button type="submit" class="btn btn-accent btn-full">SET NEW PASSWORD</button>
</form>
</div>
</div>
<!-- ── DASHBOARD ─────────────────────────────────────────────── -->
<div id="view-dashboard" class="view hidden">
<div class="page-header">
<h2 class="page-title">COMMAND CENTER</h2>
<p class="page-sub">Welcome back, Helldiver. For Super Earth.</p>
</div>
<div class="dashboard-grid">
<!-- Stats card -->
<div class="card">
<h3 class="card-title">YOUR STATS</h3>
<div class="stat-grid">
<div class="stat-item">
<div class="stat-value" id="dash-total-score"></div>
<div class="stat-label">Total Score</div>
</div>
<div class="stat-item">
<div class="stat-value accent" id="dash-rank"></div>
<div class="stat-label">Global Rank</div>
</div>
<div class="stat-item">
<div class="stat-value" id="dash-sessions"></div>
<div class="stat-label">Sessions</div>
</div>
<div class="stat-item">
<div class="stat-value" id="dash-win-rate"></div>
<div class="stat-label">Match Win Rate</div>
</div>
</div>
</div>
<!-- Daily challenge card -->
<div class="card card-accent">
<h3 class="card-title">⚡ DAILY CHALLENGE</h3>
<div class="daily-stratagem">
<div class="daily-name" id="dash-daily-name"></div>
<div class="daily-category" id="dash-daily-category"></div>
<div class="daily-best">
Best time: <span id="dash-daily-best"></span>
</div>
<button class="btn btn-accent" onclick="startDailyChallenge()">Practice this stratagem</button>
</div>
</div>
<!-- Online users card -->
<div class="card">
<h3 class="card-title">ONLINE HELLDIVERS</h3>
<div id="dash-online" class="online-list">
<span class="muted">Loading...</span>
</div>
</div>
<!-- Recent sessions card -->
<div class="card">
<h3 class="card-title">RECENT SESSIONS</h3>
<table class="data-table">
<thead>
<tr><th>Stratagem</th><th>Score</th><th>Time</th></tr>
</thead>
<tbody id="dash-recent">
<tr><td colspan="3" class="muted">No sessions yet</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- ── PRACTICE ───────────────────────────────────────────────── -->
<div id="view-practice" class="view hidden">
<div class="page-header">
<h2 class="page-title">TRAINING PROTOCOL</h2>
</div>
<!-- Category filters -->
<div class="category-row" id="practice-categories"></div>
<!-- Idle (start screen) -->
<div id="practice-idle" class="practice-idle">
<div class="idle-hint">Select categories above, then start training</div>
<button class="btn btn-accent btn-lg" onclick="startPractice()">⚡ START TRAINING</button>
</div>
<!-- Active training -->
<div id="practice-active" class="practice-active hidden">
<div class="stratagem-display card">
<div class="stratagem-category" id="practice-category"></div>
<div class="stratagem-name" id="practice-name"></div>
<div class="arrow-sequence" id="practice-sequence"></div>
<div class="practice-hint">Use Arrow Keys or D-Pad</div>
</div>
<div class="practice-hud">
<div class="hud-item">
<div class="hud-label">TIME</div>
<div class="timer" id="practice-timer">30</div>
</div>
<div class="hud-item">
<div class="hud-label">SCORE</div>
<div class="hud-value" id="practice-score">0</div>
</div>
<div class="hud-item">
<div class="hud-label">STREAK</div>
<div class="hud-value accent" id="practice-streak">0</div>
</div>
</div>
<!-- D-Pad (mobile) -->
<div class="dpad">
<div class="dpad-row">
<button class="dpad-btn dpad-up" onclick="dpadInput('up')"></button>
</div>
<div class="dpad-row">
<button class="dpad-btn dpad-left" onclick="dpadInput('left')"></button>
<div class="dpad-center"></div>
<button class="dpad-btn dpad-right" onclick="dpadInput('right')"></button>
</div>
<div class="dpad-row">
<button class="dpad-btn dpad-down" onclick="dpadInput('down')"></button>
</div>
</div>
<button class="btn btn-muted" onclick="stopPracticeUI()">Stop Training</button>
</div>
</div>
<!-- ── LOBBY ──────────────────────────────────────────────────── -->
<div id="view-lobby" class="view hidden">
<div class="page-header">
<h2 class="page-title">1v1 ARENA</h2>
<p class="page-sub">Challenge a fellow Helldiver to a stratagem duel</p>
</div>
<div class="lobby-layout">
<div class="card">
<h3 class="card-title">ONLINE HELLDIVERS</h3>
<div id="lobby-players" class="player-list">
<p class="muted">Loading...</p>
</div>
</div>
<div id="lobby-challenges" class="challenge-list"></div>
</div>
</div>
<!-- ── MATCH ──────────────────────────────────────────────────── -->
<div id="view-match" class="view hidden">
<div class="match-header">
<div class="match-status-text" id="match-status">Waiting...</div>
<div class="match-category" id="match-category"></div>
</div>
<div class="match-scoreboard">
<div class="match-player me">
<div class="match-player-name" id="match-me-name"></div>
<div class="match-wins" id="match-me-wins">0</div>
</div>
<div class="match-vs">VS</div>
<div class="match-player opp">
<div class="match-player-name" id="match-opp-name"></div>
<div class="match-wins" id="match-opp-wins">0</div>
</div>
</div>
<!-- Round area -->
<div id="match-round-area" class="match-round-area hidden">
<div class="match-sequences">
<div class="match-seq-col">
<div class="match-seq-label">YOU</div>
<div class="arrow-sequence" id="match-me-sequence"></div>
</div>
<div class="match-seq-col">
<div class="match-seq-label">OPPONENT</div>
<div class="arrow-sequence" id="match-opp-sequence"></div>
</div>
</div>
<!-- D-Pad (mobile) -->
<div class="dpad">
<div class="dpad-row">
<button class="dpad-btn dpad-up" onclick="dpadInput('up')"></button>
</div>
<div class="dpad-row">
<button class="dpad-btn dpad-left" onclick="dpadInput('left')"></button>
<div class="dpad-center"></div>
<button class="dpad-btn dpad-right" onclick="dpadInput('right')"></button>
</div>
<div class="dpad-row">
<button class="dpad-btn dpad-down" onclick="dpadInput('down')"></button>
</div>
</div>
</div>
<div class="match-actions">
<button class="btn btn-accent" id="match-ready-btn" onclick="setReady()" style="display:none">READY</button>
<button class="btn btn-muted btn-sm" onclick="leaveMatch()">Leave Match</button>
</div>
</div>
<!-- ── LEADERBOARD ────────────────────────────────────────────── -->
<div id="view-leaderboard" class="view hidden">
<div class="page-header">
<h2 class="page-title">HALL OF HEROES</h2>
<p class="page-sub">Top Helldivers ranked by total practice score</p>
</div>
<div class="card">
<table class="data-table leaderboard-table">
<thead>
<tr>
<th>#</th>
<th>Helldiver</th>
<th>Total Score</th>
<th>Sessions</th>
<th>Match W/Total</th>
</tr>
</thead>
<tbody id="leaderboard-table-body">
<tr><td colspan="5" class="muted">Loading...</td></tr>
</tbody>
</table>
</div>
</div>
<!-- ── ADMIN ──────────────────────────────────────────────────── -->
<div id="view-admin" class="view hidden">
<div class="page-header">
<h2 class="page-title">ADMIN PANEL</h2>
</div>
<div class="admin-layout">
<!-- Create user -->
<div class="card">
<h3 class="card-title">CREATE HELLDIVER</h3>
<div class="field">
<label for="new-username">Username</label>
<input id="new-username" type="text" placeholder="helldiver_name" pattern="[a-zA-Z0-9_-]{2,32}">
</div>
<div class="field">
<label for="new-role">Role</label>
<select id="new-role">
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
<p id="admin-error" class="error hidden"></p>
<button class="btn btn-accent" onclick="createUser()">Create User</button>
<div id="new-pw-display" class="pw-display hidden"></div>
</div>
<!-- User list -->
<div class="card">
<h3 class="card-title">ACTIVE HELLDIVERS</h3>
<div id="admin-users" class="admin-user-list">Loading...</div>
</div>
</div>
</div>
<!-- Toast notifications -->
<div id="toast-container"></div>
<script src="stratagems.js"></script>
<script src="app.js"></script>
</body>
</html>
+87
View File
@@ -0,0 +1,87 @@
// Helldivers 2 complete stratagem list
// Sequences use: 'up' | 'down' | 'left' | 'right'
// Source: Helldivers 2 community wiki (helldivers.wiki.gg)
const STRATAGEMS = [
// ── Patriotic Administration Center ──────────────────────────────────────
{ name: 'Reinforce', category: 'Patriotic Administration Center', sequence: ['up','down','right','left','up'] },
{ name: 'Resupply', category: 'Patriotic Administration Center', sequence: ['down','down','up','right'] },
{ name: 'SOS Beacon', category: 'Patriotic Administration Center', sequence: ['up','down','right','up'] },
{ name: 'Hellbomb', category: 'Patriotic Administration Center', sequence: ['down','up','left','down','up','right','down','up'] },
{ name: 'SEAF Artillery', category: 'Patriotic Administration Center', sequence: ['right','up','up','down'] },
{ name: 'Upload Data', category: 'Patriotic Administration Center', sequence: ['right','right','left','up','up'] },
{ name: 'Eagle Rearm', category: 'Patriotic Administration Center', sequence: ['up','up','left','up','right'] },
{ name: 'Prospecting Drill', category: 'Patriotic Administration Center', sequence: ['down','down','left','right','down'] },
// ── Orbital Cannons ───────────────────────────────────────────────────────
{ name: 'Orbital Gatling Barrage', category: 'Orbital Cannons', sequence: ['right','down','left','up','up'] },
{ name: 'Orbital Airburst Strike', category: 'Orbital Cannons', sequence: ['right','right','right'] },
{ name: 'Orbital 120MM HE Barrage', category: 'Orbital Cannons', sequence: ['right','right','down','left','right','down'] },
{ name: 'Orbital 380MM HE Barrage', category: 'Orbital Cannons', sequence: ['right','down','up','up','left','down','down'] },
{ name: 'Orbital Walking Barrage', category: 'Orbital Cannons', sequence: ['right','down','right','down','right','down'] },
{ name: 'Orbital Laser', category: 'Orbital Cannons', sequence: ['right','down','up','right','down'] },
{ name: 'Orbital Railcannon Strike', category: 'Orbital Cannons', sequence: ['right','up','down','down','right'] },
{ name: 'Orbital Precision Strike', category: 'Orbital Cannons', sequence: ['right','right','up'] },
{ name: 'Orbital Gas Strike', category: 'Orbital Cannons', sequence: ['right','right','down','right'] },
{ name: 'Orbital EMS Strike', category: 'Orbital Cannons', sequence: ['right','right','left','down'] },
{ name: 'Orbital Smoke Strike', category: 'Orbital Cannons', sequence: ['right','right','down','up'] },
{ name: 'Orbital Illumination Flare',category: 'Orbital Cannons', sequence: ['right','right','left','left'] },
// ── Hangar ────────────────────────────────────────────────────────────────
{ name: 'Eagle Strafing Run', category: 'Hangar', sequence: ['up','right','right'] },
{ name: 'Eagle Airstrike', category: 'Hangar', sequence: ['up','right','down','right'] },
{ name: 'Eagle Cluster Bomb', category: 'Hangar', sequence: ['up','right','down','down','right'] },
{ name: 'Eagle Napalm Airstrike', category: 'Hangar', sequence: ['up','right','down','up'] },
{ name: 'LIFT-850 Jump Pack', category: 'Hangar', sequence: ['down','up','up','down','up'] },
{ name: 'Eagle Smoke Strike', category: 'Hangar', sequence: ['up','right','up','down'] },
{ name: 'Eagle 110MM Rocket Pods', category: 'Hangar', sequence: ['up','right','up','left'] },
{ name: 'Eagle 500KG Bomb', category: 'Hangar', sequence: ['up','right','down','down','down'] },
// ── Bridge ────────────────────────────────────────────────────────────────
{ name: 'Patriot Exosuit', category: 'Bridge', sequence: ['left','down','right','up','left','down','right'] },
{ name: 'Emancipator Exosuit',category: 'Bridge', sequence: ['left','down','right','up','left','down','down'] },
// ── Engineering Bay Support Weapons ────────────────────────────────────
{ name: 'Machine Gun', category: 'Engineering Bay', sequence: ['down','left','down','up','right'] },
{ name: 'Anti-Materiel Rifle', category: 'Engineering Bay', sequence: ['down','left','right','up','down'] },
{ name: 'Stalwart', category: 'Engineering Bay', sequence: ['down','left','down','up','up','left'] },
{ name: 'Expendable Anti-Tank', category: 'Engineering Bay', sequence: ['down','down','left','up'] },
{ name: 'Recoilless Rifle', category: 'Engineering Bay', sequence: ['down','left','right','right','left'] },
{ name: 'Flamethrower', category: 'Engineering Bay', sequence: ['down','left','up','down','up'] },
{ name: 'Autocannon', category: 'Engineering Bay', sequence: ['down','left','down','up','up','right'] },
{ name: 'Heavy Machine Gun', category: 'Engineering Bay', sequence: ['down','left','up','down','down'] },
{ name: 'Airburst Rocket Launcher', category: 'Engineering Bay', sequence: ['down','up','up','left','right'] },
{ name: 'Commando', category: 'Engineering Bay', sequence: ['down','left','up','down','right'] },
{ name: 'Railgun', category: 'Engineering Bay', sequence: ['down','right','down','up','left','right'] },
{ name: 'Spear', category: 'Engineering Bay', sequence: ['down','down','up','down','down'] },
{ name: 'Quasar Cannon', category: 'Engineering Bay', sequence: ['down','down','up','left','right'] },
{ name: 'Arc Thrower', category: 'Engineering Bay', sequence: ['down','right','down','up','left','left'] },
{ name: 'Laser Cannon', category: 'Engineering Bay', sequence: ['down','left','down','up','left'] },
{ name: 'Grenade Launcher', category: 'Engineering Bay', sequence: ['down','left','up','left','down'] },
// ── Engineering Bay Equipment / Backpacks ───────────────────────────────
{ name: 'Supply Pack', category: 'Engineering Bay', sequence: ['down','left','down','up','up','down'] },
{ name: 'Guard Dog Rover', category: 'Engineering Bay', sequence: ['down','up','left','up','right','right'] },
{ name: 'Guard Dog', category: 'Engineering Bay', sequence: ['down','up','left','up','right','down'] },
{ name: 'Ballistic Shield Backpack', category: 'Engineering Bay', sequence: ['down','left','down','down','up','left'] },
{ name: 'Shield Generator Pack', category: 'Engineering Bay', sequence: ['down','up','left','right','left','right'] },
{ name: 'Directional Shield', category: 'Engineering Bay', sequence: ['down','left','up','up','right'] },
// ── Engineering Bay Mines ───────────────────────────────────────────────
{ name: 'Anti-Personnel Minefield', category: 'Engineering Bay', sequence: ['down','left','up','right'] },
{ name: 'Incendiary Mines', category: 'Engineering Bay', sequence: ['down','left','left','down'] },
{ name: 'Anti-Tank Mines', category: 'Engineering Bay', sequence: ['down','down','left','left'] },
// ── Robotics Workshop Sentries ──────────────────────────────────────────
{ name: 'Machine Gun Sentry', category: 'Robotics Workshop', sequence: ['down','up','right','right','up'] },
{ name: 'Gatling Sentry', category: 'Robotics Workshop', sequence: ['down','up','right','left'] },
{ name: 'Mortar Sentry', category: 'Robotics Workshop', sequence: ['down','up','right','right','down'] },
{ name: 'Autocannon Sentry', category: 'Robotics Workshop', sequence: ['down','up','right','up','left','up'] },
{ name: 'Rocket Sentry', category: 'Robotics Workshop', sequence: ['down','up','right','right','left'] },
{ name: 'EMS Mortar Sentry', category: 'Robotics Workshop', sequence: ['down','up','right','down','right'] },
{ name: 'Tesla Tower', category: 'Robotics Workshop', sequence: ['down','up','right','up','left','up','up'] },
// ── Defensive / Other ─────────────────────────────────────────────────────
{ name: 'Shield Generator Relay', category: 'Defensive', sequence: ['down','up','left','right','left','down'] },
{ name: 'Anti-Tank Emplacement', category: 'Defensive', sequence: ['down','right','right','up','left'] },
{ name: 'Orbital Shield Generator', category: 'Defensive', sequence: ['right','right','left','down','left','down'] },
];
+833
View File
@@ -0,0 +1,833 @@
/* ── Custom properties ─────────────────────────────────────────────────────── */
:root {
--bg: #0d0d14;
--bg-surface: #131325;
--bg-surface2: #1a1a2e;
--accent: #ffe710;
--accent-dim: rgba(255, 231, 16, 0.15);
--brand: #41639c;
--brand-dim: rgba(65, 99, 156, 0.12);
--danger: #ff525d;
--danger-dim: rgba(255, 82, 93, 0.15);
--success: #4dff91;
--success-dim: rgba(77, 255, 145, 0.15);
--text: #e0e0e0;
--text-muted: #556;
--border: rgba(65, 99, 156, 0.3);
--font-heading: 'Rajdhani', 'Exo 2', sans-serif;
--font-mono: 'Share Tech Mono', 'Courier New', monospace;
--font-body: 'Exo 2', system-ui, sans-serif;
--radius: 4px;
--transition: 0.15s ease;
}
/* ── Reset & base ──────────────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
background: var(--bg);
color: var(--text);
font-family: var(--font-body);
font-size: 16px;
line-height: 1.5;
overflow-x: hidden;
}
/* ── Military grid overlay ─────────────────────────────────────────────────── */
body::before {
content: '';
position: fixed;
inset: 0;
background-image:
linear-gradient(rgba(65, 99, 156, 0.06) 1px, transparent 1px),
linear-gradient(90deg, rgba(65, 99, 156, 0.06) 1px, transparent 1px);
background-size: 40px 40px;
pointer-events: none;
z-index: 0;
}
/* Scanlines */
body::after {
content: '';
position: fixed;
inset: 0;
background: repeating-linear-gradient(
0deg,
transparent,
transparent 3px,
rgba(0, 0, 0, 0.06) 3px,
rgba(0, 0, 0, 0.06) 4px
);
pointer-events: none;
z-index: 0;
}
/* ── Layout helpers ────────────────────────────────────────────────────────── */
.hidden { display: none !important; }
.view {
position: relative;
z-index: 1;
min-height: calc(100vh - 64px);
padding: 24px 20px 48px;
max-width: 1100px;
margin: 0 auto;
}
.view-centered {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 20px;
}
/* ── Navigation ────────────────────────────────────────────────────────────── */
#main-nav {
position: sticky;
top: 0;
z-index: 100;
display: flex;
align-items: center;
gap: 16px;
padding: 0 20px;
height: 56px;
background: rgba(13, 13, 20, 0.92);
border-bottom: 1px solid var(--border);
backdrop-filter: blur(8px);
}
.nav-brand {
display: flex;
align-items: center;
gap: 8px;
font-family: var(--font-heading);
font-size: 1.1rem;
font-weight: 700;
color: var(--accent);
letter-spacing: 0.05em;
white-space: nowrap;
}
.nav-logo { font-size: 1.4rem; }
.nav-links {
display: flex;
gap: 4px;
flex: 1;
justify-content: center;
}
.nav-btn {
background: transparent;
border: none;
color: var(--text-muted);
font-family: var(--font-heading);
font-size: 0.9rem;
font-weight: 600;
letter-spacing: 0.05em;
padding: 6px 14px;
border-radius: var(--radius);
cursor: pointer;
transition: color var(--transition), background var(--transition);
}
.nav-btn:hover { color: var(--text); background: var(--brand-dim); }
.nav-btn.active { color: var(--accent); background: var(--accent-dim); }
.nav-user {
display: flex;
align-items: center;
gap: 10px;
margin-left: auto;
white-space: nowrap;
}
.nav-username {
font-family: var(--font-mono);
font-size: 0.85rem;
color: var(--brand);
}
/* ── Cards ─────────────────────────────────────────────────────────────────── */
.card {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
}
.card-accent { border-color: rgba(255, 231, 16, 0.3); }
.card-title {
font-family: var(--font-heading);
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.12em;
color: var(--brand);
margin-bottom: 16px;
text-transform: uppercase;
}
/* ── Buttons ───────────────────────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 10px 20px;
border: 1px solid transparent;
border-radius: var(--radius);
font-family: var(--font-heading);
font-size: 0.9rem;
font-weight: 700;
letter-spacing: 0.08em;
cursor: pointer;
transition: all var(--transition);
text-transform: uppercase;
white-space: nowrap;
}
.btn-accent {
background: var(--accent);
color: #000;
border-color: var(--accent);
}
.btn-accent:hover { background: #fff066; box-shadow: 0 0 16px rgba(255,231,16,0.4); }
.btn-muted {
background: transparent;
color: var(--text-muted);
border-color: var(--border);
}
.btn-muted:hover { color: var(--text); border-color: var(--text-muted); }
.btn-danger {
background: var(--danger-dim);
color: var(--danger);
border-color: rgba(255,82,93,0.4);
}
.btn-danger:hover { background: rgba(255,82,93,0.25); }
.btn-sm { padding: 5px 12px; font-size: 0.8rem; }
.btn-lg { padding: 14px 32px; font-size: 1.1rem; }
.btn-full { width: 100%; }
/* ── Form elements ─────────────────────────────────────────────────────────── */
.field { margin-bottom: 14px; }
label {
display: block;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 6px;
}
input[type="text"],
input[type="password"],
select {
width: 100%;
background: var(--bg-surface2);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-family: var(--font-body);
font-size: 1rem;
padding: 10px 14px;
outline: none;
transition: border-color var(--transition);
}
input:focus, select:focus { border-color: var(--accent); }
select option { background: var(--bg-surface2); }
.error {
color: var(--danger);
font-size: 0.85rem;
margin-bottom: 10px;
}
/* ── Login ─────────────────────────────────────────────────────────────────── */
.login-box {
width: 100%;
max-width: 400px;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 36px 32px;
}
.login-header { text-align: center; margin-bottom: 28px; }
.login-logo {
font-size: 3rem;
line-height: 1;
display: block;
margin-bottom: 8px;
filter: drop-shadow(0 0 12px rgba(255,231,16,0.6));
}
.login-header h1 {
font-family: var(--font-heading);
font-size: 1.8rem;
font-weight: 700;
letter-spacing: 0.15em;
color: var(--accent);
text-shadow: 0 0 20px rgba(255,231,16,0.3);
}
.login-sub {
font-size: 0.7rem;
letter-spacing: 0.1em;
color: var(--text-muted);
margin-top: 4px;
}
/* ── Page headers ──────────────────────────────────────────────────────────── */
.page-header { margin-bottom: 24px; }
.page-title {
font-family: var(--font-heading);
font-size: 1.6rem;
font-weight: 700;
letter-spacing: 0.12em;
color: var(--accent);
text-transform: uppercase;
}
.page-sub {
font-size: 0.85rem;
color: var(--text-muted);
margin-top: 4px;
}
/* ── Dashboard grid ────────────────────────────────────────────────────────── */
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
}
.stat-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.stat-item {
text-align: center;
padding: 12px 8px;
background: var(--bg-surface2);
border-radius: var(--radius);
}
.stat-value {
font-family: var(--font-mono);
font-size: 1.6rem;
font-weight: 700;
color: var(--text);
}
.stat-value.accent { color: var(--accent); }
.stat-label { font-size: 0.7rem; letter-spacing: 0.1em; color: var(--text-muted); text-transform: uppercase; margin-top: 4px; }
/* Daily challenge */
.daily-name {
font-family: var(--font-heading);
font-size: 1.3rem;
font-weight: 700;
color: var(--accent);
margin-bottom: 4px;
}
.daily-category { font-size: 0.8rem; color: var(--text-muted); margin-bottom: 8px; }
.daily-best { font-size: 0.85rem; color: var(--text-muted); margin-bottom: 16px; }
/* Online list */
.online-list { display: flex; flex-direction: column; gap: 8px; }
.online-user {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
background: var(--bg-surface2);
border-radius: var(--radius);
}
.online-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--success);
box-shadow: 0 0 6px var(--success);
flex-shrink: 0;
}
/* Data table */
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.data-table th {
text-align: left;
font-size: 0.7rem;
letter-spacing: 0.1em;
color: var(--text-muted);
padding: 6px 8px;
border-bottom: 1px solid var(--border);
text-transform: uppercase;
}
.data-table td {
padding: 8px 8px;
border-bottom: 1px solid rgba(65, 99, 156, 0.1);
font-family: var(--font-mono);
font-size: 0.85rem;
}
.data-table tr.row-me td { color: var(--accent); }
.data-table .rank {
font-size: 1rem;
color: var(--accent);
font-weight: 700;
text-align: center;
width: 40px;
}
.muted { color: var(--text-muted); font-size: 0.85rem; }
/* ── Practice mode ─────────────────────────────────────────────────────────── */
.category-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 20px;
}
.cat-btn {
background: var(--bg-surface);
border: 1px solid var(--border);
color: var(--text-muted);
border-radius: 20px;
padding: 5px 14px;
font-family: var(--font-body);
font-size: 0.8rem;
cursor: pointer;
transition: all var(--transition);
}
.cat-btn.active {
background: var(--brand-dim);
border-color: var(--brand);
color: var(--text);
}
.cat-btn:hover { border-color: var(--text-muted); color: var(--text); }
.practice-idle {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
padding: 40px 20px;
}
.idle-hint { color: var(--text-muted); font-size: 0.9rem; }
.practice-active {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
}
.stratagem-display {
width: 100%;
max-width: 600px;
text-align: center;
}
.stratagem-category {
font-size: 0.75rem;
letter-spacing: 0.12em;
color: var(--brand);
text-transform: uppercase;
margin-bottom: 6px;
}
.stratagem-name {
font-family: var(--font-heading);
font-size: 2rem;
font-weight: 700;
color: var(--accent);
letter-spacing: 0.05em;
margin-bottom: 20px;
text-shadow: 0 0 20px rgba(255,231,16,0.2);
}
.practice-hint {
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 16px;
letter-spacing: 0.08em;
}
/* ── Arrow key indicators ──────────────────────────────────────────────────── */
.arrow-sequence {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
align-items: center;
min-height: 56px;
}
.arrow-key {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border: 2px solid var(--border);
border-radius: 6px;
font-family: var(--font-mono);
font-size: 1.3rem;
color: var(--text-muted);
background: var(--bg-surface2);
transition: all 0.12s ease;
user-select: none;
}
.arrow-key.active {
border-color: var(--accent);
color: var(--accent);
box-shadow: 0 0 12px rgba(255,231,16,0.35);
}
.arrow-key.completed {
border-color: var(--success);
color: var(--success);
background: var(--success-dim);
}
.arrow-key.flash-correct {
border-color: var(--success);
background: var(--success-dim);
color: var(--success);
animation: pop 0.25s ease;
}
.arrow-key.flash-wrong {
border-color: var(--danger);
background: var(--danger-dim);
color: var(--danger);
animation: shake 0.35s ease;
}
@keyframes pop {
0% { transform: scale(1.25); }
100% { transform: scale(1); }
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-5px); }
40% { transform: translateX(5px); }
60% { transform: translateX(-4px); }
80% { transform: translateX(4px); }
}
/* ── Practice HUD ──────────────────────────────────────────────────────────── */
.practice-hud {
display: flex;
gap: 24px;
align-items: center;
justify-content: center;
}
.hud-item { text-align: center; }
.hud-label { font-size: 0.65rem; letter-spacing: 0.15em; color: var(--text-muted); text-transform: uppercase; }
.hud-value { font-family: var(--font-mono); font-size: 2rem; font-weight: 700; }
.hud-value.accent { color: var(--accent); }
.timer {
font-family: var(--font-mono);
font-size: 2.5rem;
font-weight: 700;
color: var(--text);
transition: color var(--transition);
min-width: 60px;
text-align: center;
}
.timer.timer-danger { color: var(--danger); animation: pulse 0.8s infinite; }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.timer.flash-wrong { animation: shake 0.4s ease; }
/* ── D-Pad ─────────────────────────────────────────────────────────────────── */
.dpad {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.dpad-row {
display: flex;
gap: 4px;
align-items: center;
}
.dpad-btn {
width: 56px;
height: 56px;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-size: 1.4rem;
cursor: pointer;
transition: all var(--transition);
font-family: var(--font-mono);
display: flex;
align-items: center;
justify-content: center;
}
.dpad-btn:hover { background: var(--brand-dim); border-color: var(--brand); }
.dpad-btn:active { background: var(--accent-dim); border-color: var(--accent); color: var(--accent); transform: scale(0.92); }
.dpad-center {
width: 56px;
height: 56px;
background: var(--bg-surface2);
border-radius: var(--radius);
}
/* ── Lobby ─────────────────────────────────────────────────────────────────── */
.lobby-layout {
display: flex;
flex-direction: column;
gap: 16px;
max-width: 600px;
}
.player-list { display: flex; flex-direction: column; gap: 10px; }
.lobby-player {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
background: var(--bg-surface2);
border-radius: var(--radius);
border: 1px solid var(--border);
}
.player-name { flex: 1; font-family: var(--font-mono); }
.challenge-list { display: flex; flex-direction: column; gap: 10px; }
.challenge-item {
display: flex;
align-items: center;
gap: 10px;
padding: 14px;
background: var(--accent-dim);
border: 1px solid rgba(255,231,16,0.3);
border-radius: var(--radius);
flex-wrap: wrap;
}
.challenge-badge {
position: fixed;
top: 64px;
right: 16px;
background: var(--accent);
color: #000;
font-family: var(--font-heading);
font-size: 0.8rem;
font-weight: 700;
padding: 6px 14px;
border-radius: 20px;
cursor: pointer;
z-index: 200;
box-shadow: 0 0 12px rgba(255,231,16,0.5);
animation: pulse 1.5s infinite;
}
/* ── Match ─────────────────────────────────────────────────────────────────── */
.match-header {
position: relative;
z-index: 1;
text-align: center;
padding: 24px 20px 16px;
}
.match-status-text {
font-family: var(--font-heading);
font-size: 1.8rem;
font-weight: 700;
color: var(--accent);
letter-spacing: 0.1em;
text-transform: uppercase;
}
.match-category {
font-size: 0.8rem;
color: var(--text-muted);
letter-spacing: 0.1em;
margin-top: 4px;
}
.match-scoreboard {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 32px;
padding: 16px;
max-width: 600px;
margin: 0 auto 24px;
}
.match-player { text-align: center; }
.match-player-name { font-family: var(--font-mono); font-size: 0.9rem; color: var(--text-muted); }
.match-wins {
font-family: var(--font-mono);
font-size: 3rem;
font-weight: 700;
color: var(--text);
}
.match-player.me .match-wins { color: var(--accent); }
.match-vs { font-family: var(--font-heading); font-size: 1rem; color: var(--text-muted); }
.match-round-area {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
max-width: 700px;
margin: 0 auto;
padding: 0 20px;
}
.match-sequences {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
width: 100%;
}
.match-seq-col { display: flex; flex-direction: column; align-items: center; gap: 10px; }
.match-seq-label {
font-size: 0.7rem;
letter-spacing: 0.15em;
font-weight: 700;
color: var(--text-muted);
text-transform: uppercase;
}
.match-actions {
position: relative;
z-index: 1;
display: flex;
gap: 12px;
align-items: center;
justify-content: center;
padding: 16px;
}
/* ── Leaderboard ───────────────────────────────────────────────────────────── */
.leaderboard-table { max-width: 700px; }
/* ── Admin ─────────────────────────────────────────────────────────────────── */
.admin-layout {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 16px;
max-width: 800px;
}
.admin-user-list { display: flex; flex-direction: column; gap: 8px; }
.admin-user-row {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
background: var(--bg-surface2);
border-radius: var(--radius);
flex-wrap: wrap;
}
.user-name { flex: 1; font-family: var(--font-mono); }
.user-role {
font-size: 0.7rem;
padding: 2px 8px;
border-radius: 20px;
font-weight: 700;
letter-spacing: 0.08em;
}
.badge-admin { background: var(--accent-dim); color: var(--accent); }
.badge-user { background: var(--brand-dim); color: var(--brand); }
.badge-warning { background: var(--danger-dim); color: var(--danger); font-size: 0.7rem; padding: 2px 8px; border-radius: 20px; }
.pw-display {
margin-top: 12px;
padding: 10px 14px;
background: var(--bg-surface2);
border: 1px solid var(--accent);
border-radius: var(--radius);
font-family: var(--font-mono);
color: var(--accent);
font-size: 0.9rem;
word-break: break-all;
}
/* ── Toast notifications ───────────────────────────────────────────────────── */
#toast-container {
position: fixed;
bottom: 20px;
right: 20px;
display: flex;
flex-direction: column;
gap: 8px;
z-index: 500;
pointer-events: none;
}
.toast {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px 16px;
font-size: 0.9rem;
opacity: 0;
transform: translateX(20px);
transition: all 0.25s ease;
max-width: 320px;
pointer-events: auto;
}
.toast.show { opacity: 1; transform: translateX(0); }
/* ── Responsive ────────────────────────────────────────────────────────────── */
@media (max-width: 600px) {
.nav-links { display: none; }
.dashboard-grid { grid-template-columns: 1fr; }
.admin-layout { grid-template-columns: 1fr; }
.match-sequences { grid-template-columns: 1fr; }
.stratagem-name { font-size: 1.4rem; }
.arrow-key { width: 40px; height: 40px; font-size: 1.1rem; }
}