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