Admin-Panel (/admin): Auth, Spieler-Tracking, Live-Tuning, Bild-Verwaltung
- Passwortgeschütztes Panel unter /admin (Auto-Passwort 20 Zeichen, per CLI abrufbar: `node server/admin.js password` / `password reset`), In-Memory-Sessions + Cookie, Login-Rate-Limit. - Spieler-Tracking: pro Score IP (CF/XFF/req.ip), Gerät/Browser (ua-parser-js), grober Ort (geoip-lite, offline) -> data/players.json; Aggregation pro Name. - Live-Tuning: configStore (data/config.json) für Timer + Schwierigkeitskurve; Server (makeRound) und Client (/api/config) lesen daraus -> Änderungen ohne Neustart. - Bild-Verwaltung: Upload/Löschen von Füßen & Deko (multer) + rescanLibrary(). - Panel-Aktionen: Spieler bannen/entbannen, Rangliste heute/alles leeren. - Fix: .item object-fit:contain -> nicht-quadratische Bilder werden nicht mehr verzerrt. - trust proxy für korrekte Client-IP hinter Reverse-Proxy/Cloudflare. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
137
public/admin.html
Normal file
137
public/admin.html
Normal file
@@ -0,0 +1,137 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>füsse.sexy — Admin</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='88'>🛠️</text></svg>" />
|
||||
<style>
|
||||
:root { --bg:#0f1020; --panel:#1a1b33; --panel2:#232544; --pink:#ff4d8d; --text:#e9e9f5; --muted:#a0a0c0; --ok:#2ecc71; --warn:#ff5a5f; --border:#33345a; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin:0; background:var(--bg); color:var(--text); font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif; }
|
||||
a { color:var(--pink); }
|
||||
h1 { color:var(--pink); margin:0; font-size:22px; }
|
||||
.wrap { max-width:1000px; margin:0 auto; padding:18px; }
|
||||
input, button, select { font:inherit; }
|
||||
input[type=password], input[type=text], input[type=number] {
|
||||
background:var(--panel2); border:1px solid var(--border); color:var(--text);
|
||||
border-radius:8px; padding:8px 10px; width:100%;
|
||||
}
|
||||
button { cursor:pointer; border:none; border-radius:8px; padding:9px 14px; font-weight:600; background:var(--panel2); color:var(--text); border:1px solid var(--border); }
|
||||
button.primary { background:var(--pink); color:#fff; border:none; }
|
||||
button.danger { background:#3a1420; color:#ff9bb2; border:1px solid #5a2233; }
|
||||
button:hover { filter:brightness(1.12); }
|
||||
.muted { color:var(--muted); font-size:13px; }
|
||||
.card { background:var(--panel); border:1px solid var(--border); border-radius:14px; padding:16px; margin-bottom:16px; }
|
||||
|
||||
/* Login */
|
||||
#login { max-width:380px; margin:12vh auto; }
|
||||
#login .row { display:flex; gap:8px; margin-top:10px; }
|
||||
#login-err { color:var(--warn); min-height:18px; margin-top:8px; font-size:14px; }
|
||||
|
||||
/* Panel */
|
||||
header.top { display:flex; align-items:center; justify-content:space-between; margin-bottom:14px; }
|
||||
.tabs { display:flex; gap:6px; flex-wrap:wrap; margin-bottom:14px; }
|
||||
.tabs button.active { background:var(--pink); color:#fff; border:none; }
|
||||
section.tab { display:none; }
|
||||
section.tab.active { display:block; }
|
||||
|
||||
table { width:100%; border-collapse:collapse; font-size:14px; }
|
||||
th, td { text-align:left; padding:8px 10px; border-bottom:1px solid var(--border); vertical-align:top; }
|
||||
th { color:var(--muted); font-weight:600; }
|
||||
.chips { display:flex; flex-wrap:wrap; gap:4px; }
|
||||
.chip { background:var(--panel2); border:1px solid var(--border); border-radius:999px; padding:2px 8px; font-size:12px; }
|
||||
|
||||
/* Settings */
|
||||
.grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(220px,1fr)); gap:12px; }
|
||||
.field label { display:block; font-size:13px; color:var(--muted); margin-bottom:4px; }
|
||||
|
||||
/* Images */
|
||||
.gallery { display:grid; grid-template-columns:repeat(auto-fill,minmax(110px,1fr)); gap:10px; }
|
||||
.thumb { background:var(--panel2); border:1px solid var(--border); border-radius:10px; padding:8px; text-align:center; }
|
||||
.thumb img { width:100%; height:72px; object-fit:contain; }
|
||||
.thumb .fn { font-size:11px; color:var(--muted); word-break:break-all; margin:6px 0; }
|
||||
.uprow { display:flex; gap:8px; align-items:center; margin-top:10px; flex-wrap:wrap; }
|
||||
.toast { position:fixed; left:50%; bottom:20px; transform:translateX(-50%); background:#000a; border:1px solid var(--border); color:#fff; padding:10px 16px; border-radius:10px; opacity:0; transition:opacity .2s; }
|
||||
.toast.show { opacity:1; }
|
||||
.hidden { display:none !important; }
|
||||
code { background:var(--panel2); padding:1px 5px; border-radius:5px; }
|
||||
</style>
|
||||
|
||||
<div class="wrap">
|
||||
<!-- Login -->
|
||||
<div id="login" class="card hidden">
|
||||
<h1>🛠️ Admin-Login</h1>
|
||||
<p class="muted">Passwort per CLI: <code>node server/admin.js password</code></p>
|
||||
<div class="row">
|
||||
<input id="pw" type="password" placeholder="Passwort" autocomplete="current-password" />
|
||||
<button id="login-btn" class="primary">Login</button>
|
||||
</div>
|
||||
<div id="login-err"></div>
|
||||
</div>
|
||||
|
||||
<!-- Panel -->
|
||||
<div id="panel" class="hidden">
|
||||
<header class="top">
|
||||
<h1>🛠️ füsse.sexy — Admin</h1>
|
||||
<button id="logout-btn">Logout</button>
|
||||
</header>
|
||||
|
||||
<div class="tabs">
|
||||
<button data-tab="players" class="active">Spieler</button>
|
||||
<button data-tab="settings">Einstellungen</button>
|
||||
<button data-tab="images">Bilder</button>
|
||||
<button data-tab="board">Rangliste</button>
|
||||
</div>
|
||||
|
||||
<section class="tab active" id="tab-players">
|
||||
<div class="card">
|
||||
<p class="muted">Approximative Zuordnung über IP, groben Ort, Gerät & Browser (kein Login vorhanden).</p>
|
||||
<div id="players-wrap">Lade …</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="tab" id="tab-settings">
|
||||
<div class="card">
|
||||
<p class="muted">Änderungen wirken sofort beim nächsten Spielstart (kein Neustart).</p>
|
||||
<div id="settings-grid" class="grid"></div>
|
||||
<div class="uprow">
|
||||
<button id="cfg-save" class="primary">Speichern</button>
|
||||
<span id="cfg-status" class="muted"></span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="tab" id="tab-images">
|
||||
<div class="card">
|
||||
<h3>Füße <span class="muted">(die gesuchten Figuren)</span></h3>
|
||||
<div id="feet-gallery" class="gallery"></div>
|
||||
<div class="uprow">
|
||||
<input type="file" id="feet-file" accept=".svg,.png,.webp,.jpg,.jpeg,.gif" />
|
||||
<button class="primary" data-up="feet">Hochladen</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Deko <span class="muted">(Verwirr-Bilder, nie gesucht)</span></h3>
|
||||
<div id="props-gallery" class="gallery"></div>
|
||||
<div class="uprow">
|
||||
<input type="file" id="props-file" accept=".svg,.png,.webp,.jpg,.jpeg,.gif" />
|
||||
<button class="primary" data-up="props">Hochladen</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="muted">💡 Am besten <b>quadratische</b> Bilder: 256×256 oder 512×512 px PNG mit transparentem
|
||||
Hintergrund (oder SVG mit quadratischem viewBox). Nicht-quadratische werden dank <code>object-fit</code>
|
||||
nicht verzerrt, sondern nur eingepasst.</p>
|
||||
</section>
|
||||
|
||||
<section class="tab" id="tab-board">
|
||||
<div class="card">
|
||||
<h3>Rangliste zurücksetzen</h3>
|
||||
<div class="uprow">
|
||||
<button class="danger" data-reset="today">Heute leeren</button>
|
||||
<button class="danger" data-reset="all">Alles leeren</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast" class="toast"></div>
|
||||
<script src="/js/admin.js"></script>
|
||||
@@ -101,6 +101,7 @@ button { font-family: inherit; cursor: pointer; }
|
||||
.item {
|
||||
position: absolute; top: 0; left: 0; will-change: transform;
|
||||
cursor: pointer; -webkit-user-drag: none; user-select: none;
|
||||
object-fit: contain; /* nicht-quadratische Bilder werden NICHT verzerrt */
|
||||
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.5));
|
||||
}
|
||||
.item.shake { animation: shake 0.3s ease; }
|
||||
|
||||
189
public/js/admin.js
Normal file
189
public/js/admin.js
Normal file
@@ -0,0 +1,189 @@
|
||||
// Admin-Panel-Logik. Auth laeuft ueber Cookie-Session; alle /api/admin/* sind geschuetzt.
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
async function api(path, opts = {}) {
|
||||
const res = await fetch(path, opts);
|
||||
let data = null;
|
||||
try { data = await res.json(); } catch {}
|
||||
return { ok: res.ok, status: res.status, data };
|
||||
}
|
||||
function toast(msg) {
|
||||
const t = $('toast');
|
||||
t.textContent = msg;
|
||||
t.classList.add('show');
|
||||
clearTimeout(toast._t);
|
||||
toast._t = setTimeout(() => t.classList.remove('show'), 1800);
|
||||
}
|
||||
const esc = (s) => String(s == null ? '' : s);
|
||||
function fmt(iso) {
|
||||
try { return new Date(iso).toLocaleString('de-DE'); } catch { return iso; }
|
||||
}
|
||||
|
||||
// ---------- Login ----------
|
||||
function showLogin() {
|
||||
$('login').classList.remove('hidden');
|
||||
$('panel').classList.add('hidden');
|
||||
$('pw').focus();
|
||||
}
|
||||
function showPanel() {
|
||||
$('login').classList.add('hidden');
|
||||
$('panel').classList.remove('hidden');
|
||||
loadPlayers();
|
||||
}
|
||||
async function doLogin() {
|
||||
const password = $('pw').value;
|
||||
const { ok, status } = await api('/api/admin/login', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }),
|
||||
});
|
||||
if (ok) { $('pw').value = ''; $('login-err').textContent = ''; showPanel(); }
|
||||
else $('login-err').textContent = status === 429 ? 'Zu viele Versuche — kurz warten.' : 'Falsches Passwort.';
|
||||
}
|
||||
$('login-btn').addEventListener('click', doLogin);
|
||||
$('pw').addEventListener('keydown', (e) => { if (e.key === 'Enter') doLogin(); });
|
||||
$('logout-btn').addEventListener('click', async () => { await api('/api/admin/logout', { method: 'POST' }); showLogin(); });
|
||||
|
||||
// ---------- Tabs ----------
|
||||
document.querySelectorAll('.tabs button').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.tabs button').forEach((b) => b.classList.remove('active'));
|
||||
document.querySelectorAll('section.tab').forEach((s) => s.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
const tab = btn.dataset.tab;
|
||||
$('tab-' + tab).classList.add('active');
|
||||
if (tab === 'players') loadPlayers();
|
||||
if (tab === 'settings') loadSettings();
|
||||
if (tab === 'images') loadImages();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- Spieler ----------
|
||||
async function loadPlayers() {
|
||||
const { ok, data } = await api('/api/admin/players');
|
||||
if (!ok) return showLogin();
|
||||
const bans = new Set((data.bans || []).map((b) => String(b).toLowerCase()));
|
||||
const players = data.players || [];
|
||||
if (!players.length) { $('players-wrap').innerHTML = '<p class="muted">Noch keine Spieler erfasst.</p>'; return; }
|
||||
|
||||
const rows = players.map((p) => {
|
||||
const banned = bans.has(String(p.name).toLowerCase());
|
||||
const chips = (arr) => (arr || []).map((x) => `<span class="chip"></span>`).join('');
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML =
|
||||
`<td><b class="pname"></b>${banned ? ' <span class="chip" style="color:#ff9bb2">gebannt</span>' : ''}</td>` +
|
||||
`<td>${p.bestScore}</td><td>${p.games}</td><td>${fmt(p.lastAt)}</td>` +
|
||||
`<td class="ips chips"></td><td class="locs chips"></td><td class="devs chips"></td><td class="brs chips"></td>` +
|
||||
`<td><button class="${banned ? '' : 'danger'} act">${banned ? 'Entbannen' : 'Bannen'}</button></td>`;
|
||||
tr.querySelector('.pname').textContent = p.name; // XSS-safe
|
||||
const fill = (sel, arr) => { const c = tr.querySelector(sel); (arr || []).forEach((x) => { const s = document.createElement('span'); s.className = 'chip'; s.textContent = x; c.appendChild(s); }); };
|
||||
fill('.ips', p.ips); fill('.locs', p.locations); fill('.devs', p.devices); fill('.brs', p.browsers);
|
||||
tr.querySelector('.act').addEventListener('click', () => (banned ? unban(p.name) : ban(p.name)));
|
||||
return tr;
|
||||
});
|
||||
|
||||
const table = document.createElement('table');
|
||||
table.innerHTML = '<thead><tr><th>Name</th><th>Best</th><th>Spiele</th><th>Zuletzt</th><th>IP(s)</th><th>Ort</th><th>Gerät</th><th>Browser</th><th></th></tr></thead>';
|
||||
const tbody = document.createElement('tbody');
|
||||
rows.forEach((r) => tbody.appendChild(r));
|
||||
table.appendChild(tbody);
|
||||
$('players-wrap').innerHTML = '';
|
||||
$('players-wrap').appendChild(table);
|
||||
}
|
||||
async function ban(name) {
|
||||
if (!confirm(`"${name}" bannen? Bestehende Einträge werden entfernt.`)) return;
|
||||
await api('/api/admin/ban', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) });
|
||||
toast('Gebannt: ' + name); loadPlayers();
|
||||
}
|
||||
async function unban(name) {
|
||||
await api('/api/admin/unban', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) });
|
||||
toast('Entbannt: ' + name); loadPlayers();
|
||||
}
|
||||
|
||||
// ---------- Einstellungen ----------
|
||||
const CFG_LABELS = {
|
||||
startTime: 'Startzeit (s)', maxTime: 'Max-Zeit (s)', bonus: 'Bonus pro Treffer (s)', penalty: 'Strafe pro Fehlklick (s)',
|
||||
countBase: 'Objekte: Basis', countPerScore: 'Objekte: pro Score', stepStart: 'Stufen ab Score', stepEvery: 'Stufe alle N Punkte', stepAdd: 'Objekte pro Stufe', countCap: 'Objekte max.',
|
||||
stageScatter: 'Ab Score: verstreut', stageGroups: 'Ab Score: Gruppen', stageIndividual: 'Ab Score: einzeln',
|
||||
speedGroupsBase: 'Tempo Gruppen (Basis)', speedGroupsPer: 'Tempo Gruppen (pro Score)', speedGroupsMax: 'Tempo Gruppen (max)',
|
||||
speedIndivBase: 'Tempo einzeln (Basis)', speedIndivPer: 'Tempo einzeln (pro Score)', speedIndivMax: 'Tempo einzeln (max)',
|
||||
};
|
||||
async function loadSettings() {
|
||||
const { ok, data } = await api('/api/admin/config');
|
||||
if (!ok) return showLogin();
|
||||
const grid = $('settings-grid');
|
||||
grid.innerHTML = '';
|
||||
Object.keys(data).forEach((key) => {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'field';
|
||||
const label = document.createElement('label');
|
||||
label.textContent = CFG_LABELS[key] || key;
|
||||
const input = document.createElement('input');
|
||||
input.type = 'number'; input.step = 'any'; input.dataset.key = key; input.value = data[key];
|
||||
wrap.append(label, input);
|
||||
grid.appendChild(wrap);
|
||||
});
|
||||
}
|
||||
$('cfg-save').addEventListener('click', async () => {
|
||||
const body = {};
|
||||
document.querySelectorAll('#settings-grid input').forEach((i) => (body[i.dataset.key] = Number(i.value)));
|
||||
const { ok } = await api('/api/admin/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
$('cfg-status').textContent = ok ? '✓ gespeichert' : 'Fehler';
|
||||
if (ok) toast('Einstellungen gespeichert');
|
||||
setTimeout(() => ($('cfg-status').textContent = ''), 2500);
|
||||
});
|
||||
|
||||
// ---------- Bilder ----------
|
||||
async function loadImages() {
|
||||
const { ok, data } = await api('/api/admin/images');
|
||||
if (!ok) return showLogin();
|
||||
renderGallery('feet-gallery', 'feet', data.feet || []);
|
||||
renderGallery('props-gallery', 'props', data.props || []);
|
||||
}
|
||||
function renderGallery(elId, kind, list) {
|
||||
const el = $(elId);
|
||||
el.innerHTML = '';
|
||||
if (!list.length) { el.innerHTML = '<p class="muted">Keine Bilder.</p>'; return; }
|
||||
list.forEach((img) => {
|
||||
const file = img.url.split('/').pop();
|
||||
const div = document.createElement('div');
|
||||
div.className = 'thumb';
|
||||
const im = document.createElement('img'); im.src = img.url; im.alt = img.name;
|
||||
const fn = document.createElement('div'); fn.className = 'fn'; fn.textContent = file;
|
||||
const del = document.createElement('button'); del.className = 'danger'; del.textContent = 'Löschen'; del.style.width = '100%';
|
||||
del.addEventListener('click', () => deleteImage(kind, file));
|
||||
div.append(im, fn, del);
|
||||
el.appendChild(div);
|
||||
});
|
||||
}
|
||||
async function deleteImage(kind, file) {
|
||||
if (!confirm(`"${file}" löschen?`)) return;
|
||||
const { ok } = await api(`/api/admin/images/${kind}/${encodeURIComponent(file)}`, { method: 'DELETE' });
|
||||
if (ok) { toast('Gelöscht'); loadImages(); } else toast('Löschen fehlgeschlagen');
|
||||
}
|
||||
document.querySelectorAll('button[data-up]').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const kind = btn.dataset.up;
|
||||
const input = $(kind + '-file');
|
||||
if (!input.files || !input.files[0]) return toast('Keine Datei gewählt');
|
||||
const fd = new FormData();
|
||||
fd.append('image', input.files[0]);
|
||||
const res = await fetch(`/api/admin/images/${kind}`, { method: 'POST', body: fd });
|
||||
if (res.ok) { toast('Hochgeladen'); input.value = ''; loadImages(); }
|
||||
else { const d = await res.json().catch(() => ({})); toast(d.error || 'Upload fehlgeschlagen'); }
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- Rangliste ----------
|
||||
document.querySelectorAll('button[data-reset]').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const scope = btn.dataset.reset;
|
||||
if (!confirm(scope === 'all' ? 'WIRKLICH die komplette Rangliste leeren?' : 'Heutige Rangliste leeren?')) return;
|
||||
const { ok, data } = await api('/api/admin/reset', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ scope }) });
|
||||
toast(ok ? `Geleert (${data.removed} Einträge)` : 'Fehler');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- Init ----------
|
||||
(async () => {
|
||||
const { ok } = await api('/api/admin/me');
|
||||
if (ok) showPanel(); else showLogin();
|
||||
})();
|
||||
@@ -4,11 +4,21 @@
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
// ---- Konstanten (leicht justierbar) ----
|
||||
const START_TIME = 15; // Sekunden zu Beginn
|
||||
const MAX_TIME = 30; // Deckel (Timerleiste = timeLeft / MAX_TIME)
|
||||
const BONUS = 1.2; // Zeit pro Treffer
|
||||
const PENALTY = 2.0; // Zeitabzug pro Fehlklick
|
||||
// ---- Konfiguration: kommt LIVE vom Server (/api/config); hier nur Fallback-Defaults ----
|
||||
const DEFAULT_CFG = {
|
||||
startTime: 15, maxTime: 30, bonus: 1.2, penalty: 2.0,
|
||||
countBase: 10, countPerScore: 1.5, stepStart: 25, stepEvery: 3, stepAdd: 2, countCap: 100,
|
||||
stageScatter: 3, stageGroups: 6, stageIndividual: 11,
|
||||
speedGroupsBase: 45, speedGroupsPer: 10, speedGroupsMax: 150,
|
||||
speedIndivBase: 70, speedIndivPer: 12, speedIndivMax: 320,
|
||||
};
|
||||
let CFG = { ...DEFAULT_CFG };
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const r = await fetch('/api/config');
|
||||
if (r.ok) CFG = { ...DEFAULT_CFG, ...(await r.json()) };
|
||||
} catch {}
|
||||
}
|
||||
const NAME_KEY = 'fuesse_name';
|
||||
|
||||
// ---- Zustand ----
|
||||
@@ -32,28 +42,24 @@ function shuffle(a) {
|
||||
return a;
|
||||
}
|
||||
|
||||
// Objekt-Anzahl: erst linear, ab STEP_START zusaetzlich SCHRITTWEISE mehr (mit Deckel).
|
||||
// Alle Werte hier leicht justierbar.
|
||||
const COUNT_BASE = 10; // Startanzahl
|
||||
const COUNT_PER_SCORE = 1.5; // frühes lineares Wachstum
|
||||
const STEP_START = 25; // ab diesem Score kommen Stufen dazu
|
||||
const STEP_EVERY = 3; // alle N Punkte eine Stufe
|
||||
const STEP_ADD = 2; // +M Objekte pro Stufe
|
||||
const COUNT_CAP = 100; // harte Obergrenze (Performance/Spielbarkeit)
|
||||
|
||||
// Objekt-Anzahl aus der Config (identisch zur Server-Formel)
|
||||
function objectCount(score) {
|
||||
let count = COUNT_BASE + Math.floor(score * COUNT_PER_SCORE);
|
||||
if (score >= STEP_START) count += Math.floor((score - STEP_START) / STEP_EVERY) * STEP_ADD;
|
||||
return Math.min(count, COUNT_CAP);
|
||||
let count = CFG.countBase + Math.floor(score * CFG.countPerScore);
|
||||
if (score >= CFG.stepStart) count += Math.floor((score - CFG.stepStart) / CFG.stepEvery) * CFG.stepAdd;
|
||||
return Math.min(count, CFG.countCap);
|
||||
}
|
||||
|
||||
// Schwierigkeit aus dem Score ableiten
|
||||
// Schwierigkeit aus dem Score ableiten (Grenzen/Tempo aus der Config)
|
||||
function difficulty(score) {
|
||||
const count = objectCount(score);
|
||||
if (score < 3) return { count, stage: 'grid', groups: 0, speed: 0 };
|
||||
if (score < 6) return { count, stage: 'scatter', groups: 0, speed: 0 };
|
||||
if (score < 11) return { count, stage: 'groups', groups: score < 9 ? 2 : 3, speed: Math.min(45 + (score - 6) * 10, 150) };
|
||||
return { count, stage: 'individual', groups: 0, speed: Math.min(70 + (score - 11) * 12, 320) };
|
||||
if (score < CFG.stageScatter) return { count, stage: 'grid', groups: 0, speed: 0 };
|
||||
if (score < CFG.stageGroups) return { count, stage: 'scatter', groups: 0, speed: 0 };
|
||||
if (score < CFG.stageIndividual) {
|
||||
const speed = Math.min(CFG.speedGroupsBase + (score - CFG.stageGroups) * CFG.speedGroupsPer, CFG.speedGroupsMax);
|
||||
return { count, stage: 'groups', groups: score < CFG.stageGroups + 3 ? 2 : 3, speed };
|
||||
}
|
||||
const speed = Math.min(CFG.speedIndivBase + (score - CFG.stageIndividual) * CFG.speedIndivPer, CFG.speedIndivMax);
|
||||
return { count, stage: 'individual', groups: 0, speed };
|
||||
}
|
||||
|
||||
// ---- Rangliste (zwei Boards: Allzeit-Top-5 + Heute-Top-10) ----
|
||||
@@ -105,7 +111,8 @@ async function startGame() {
|
||||
$('gameover').classList.add('hidden');
|
||||
$('game').classList.remove('hidden');
|
||||
|
||||
state = { score: 0, timeLeft: START_TIME, items: [], groups: [], stage: 'grid', running: true, sessionId: null, nonce: null, awaiting: false };
|
||||
await loadConfig(); // aktuelle Werte aus dem Admin-Panel holen
|
||||
state = { score: 0, timeLeft: CFG.startTime, items: [], groups: [], stage: 'grid', running: true, sessionId: null, nonce: null, awaiting: false };
|
||||
$('score').textContent = '0';
|
||||
|
||||
const start = await startSession();
|
||||
@@ -342,7 +349,7 @@ function loop(now) {
|
||||
}
|
||||
|
||||
function updateTimerBar() {
|
||||
const pct = clamp(state.timeLeft / MAX_TIME, 0, 1) * 100;
|
||||
const pct = clamp(state.timeLeft / CFG.maxTime, 0, 1) * 100;
|
||||
const bar = $('timer-bar');
|
||||
bar.style.width = pct + '%';
|
||||
bar.classList.toggle('low', state.timeLeft <= 6);
|
||||
@@ -393,7 +400,7 @@ async function hitTarget(target) {
|
||||
});
|
||||
const data = res.ok ? await res.json() : null;
|
||||
if (data && data.correct && data.round) {
|
||||
state.timeLeft = Math.min(state.timeLeft + BONUS, MAX_TIME);
|
||||
state.timeLeft = Math.min(state.timeLeft + CFG.bonus, CFG.maxTime);
|
||||
$('score').parentElement.classList.remove('bump');
|
||||
void $('score').parentElement.offsetWidth;
|
||||
$('score').parentElement.classList.add('bump');
|
||||
@@ -408,7 +415,7 @@ async function hitTarget(target) {
|
||||
}
|
||||
|
||||
function missDecoy(el) {
|
||||
state.timeLeft = Math.max(state.timeLeft - PENALTY, 0);
|
||||
state.timeLeft = Math.max(state.timeLeft - CFG.penalty, 0);
|
||||
if (el) {
|
||||
el.classList.remove('shake');
|
||||
void el.offsetWidth;
|
||||
|
||||
Reference in New Issue
Block a user