Treffer-Toleranz, 2 Ranglisten, Anti-Cheat, CLI-Modi & Icons

- Treffer-Toleranz: Ziel wird geometrisch + bevorzugt getroffen, auch wenn verdeckt
- Zwei Ranglisten: Hall of Fame (Allzeit-Top-5) + Heute (Top-10)
- Anti-Cheat: server-autoritative Sessions (start/hit), Score = Server-Zahl,
  Mindestabstand gegen Auto-Klicker, Deckelung nach Spielzeit; Score ohne Session -> 400
- CLI (server/admin.js): list / delete <name> / clear-today / clear-all
- Zwei Betriebsmodi per CLI (live, ohne Neustart): public (/ = Spiel) und
  private (/ gesperrt, Spiel unter geheimem /pfad) + "not allowed to play"-Seite
- Board-Icons von Emojis auf echte SVG-Icons, Board heisst "Hall of Fame"

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-05 11:23:30 +02:00
parent 39ed0e2485
commit ecdbd2ee0d
21 changed files with 472 additions and 117 deletions

View File

@@ -40,15 +40,21 @@ function difficulty(score) {
return { count, stage: 'individual', groups: 0, speed: Math.min(70 + (score - 11) * 12, 320) };
}
// ---- Rangliste ----
// ---- Rangliste (zwei Boards: Allzeit-Top-5 + Heute-Top-10) ----
const EMPTY_BOARDS = { allTime: [], today: [] };
async function fetchLeaderboard() {
try {
const res = await fetch('/api/leaderboard');
return res.ok ? await res.json() : [];
return res.ok ? await res.json() : EMPTY_BOARDS;
} catch {
return [];
return EMPTY_BOARDS;
}
}
function renderBoards(boards, alltimeEl, todayEl) {
const b = boards || EMPTY_BOARDS;
renderLeaderboard(alltimeEl, b.allTime || []);
renderLeaderboard(todayEl, b.today || []);
}
function renderLeaderboard(el, list) {
el.innerHTML = '';
if (!list.length) {
@@ -76,18 +82,39 @@ function renderLeaderboard(el, list) {
}
// ================= Spiel =================
function startGame() {
async function startGame() {
$('home').classList.add('hidden');
$('gameover').classList.add('hidden');
$('game').classList.remove('hidden');
state = { score: 0, timeLeft: START_TIME, items: [], groups: [], stage: 'grid', running: true };
state = { score: 0, timeLeft: START_TIME, items: [], groups: [], stage: 'grid', running: true, sessionId: null };
$('score').textContent = '0';
buildRound();
lastT = performance.now();
cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(loop);
// Anticheat-Session holen (der Server zaehlt die Treffer selbst)
state.sessionId = await startSession();
}
async function startSession() {
try {
const res = await fetch('/api/game/start', { method: 'POST' });
if (res.ok) return (await res.json()).sessionId;
} catch {}
return null;
}
// Treffer dem Server melden (fire-and-forget). Der Server zaehlt & prueft Timing.
function beaconHit() {
if (!state || !state.sessionId) return;
fetch('/api/game/hit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId: state.sessionId }),
}).catch(() => {});
}
function fieldSize() {
@@ -288,30 +315,59 @@ function updateTimerBar() {
bar.classList.toggle('low', state.timeLeft <= 6);
}
// ---- Treffer ----
// ---- Treffer (mit Toleranz) ----
// Klicks werden GEOMETRISCH geprueft (Abstand zum Mittelpunkt), nicht ueber das oberste
// DOM-Element. Das Ziel wird BEVORZUGT und mit grosszuegiger Toleranz getroffen — auch
// wenn andere Bildchen darueberliegen. Nur wenn man klar daneben (aber auf einem Ablenker)
// klickt, gibt es Abzug; leerer Klick kostet nichts.
function onFieldPointerDown(e) {
if (!state || !state.running) return;
const el = e.target.closest('.item');
if (!el || !el.__item) return;
const item = el.__item;
if (item.isTarget) {
state.score += 1;
$('score').textContent = state.score;
$('score').parentElement.classList.remove('bump');
void $('score').parentElement.offsetWidth;
$('score').parentElement.classList.add('bump');
state.timeLeft = Math.min(state.timeLeft + BONUS, MAX_TIME);
flashField('ok');
buildRound(); // neues Plakat, andere Farbe
} else {
state.timeLeft = Math.max(state.timeLeft - PENALTY, 0);
const rect = $('field').getBoundingClientRect();
const px = e.clientX - rect.left;
const py = e.clientY - rect.top;
// 1) Ziel bevorzugt, mit Toleranz (auch wenn verdeckt)
const target = state.items.find((it) => it.isTarget);
if (target) {
const cx = target.x + target.size / 2;
const cy = target.y + target.size / 2;
const tol = target.size / 2 + Math.max(16, target.size * 0.5);
if (Math.hypot(px - cx, py - cy) <= tol) return hitTarget();
}
// 2) sonst: wurde ein Ablenker getroffen? oberstes zuerst (state.items: spaeter = oben)
for (let i = state.items.length - 1; i >= 0; i--) {
const it = state.items[i];
if (it.isTarget) continue;
const cx = it.x + it.size / 2;
const cy = it.y + it.size / 2;
if (Math.hypot(px - cx, py - cy) <= it.size / 2) return missDecoy(it.el);
}
// 3) leerer Klick -> nichts
}
function hitTarget() {
state.score += 1;
$('score').textContent = state.score;
$('score').parentElement.classList.remove('bump');
void $('score').parentElement.offsetWidth;
$('score').parentElement.classList.add('bump');
state.timeLeft = Math.min(state.timeLeft + BONUS, MAX_TIME);
flashField('ok');
beaconHit(); // Server zaehlt den Treffer (Anticheat)
buildRound(); // neues Plakat, andere Farbe
}
function missDecoy(el) {
state.timeLeft = Math.max(state.timeLeft - PENALTY, 0);
if (el) {
el.classList.remove('shake');
void el.offsetWidth;
el.classList.add('shake');
flashField('miss');
showFeedback('Daneben! 2s');
if (state.timeLeft <= 0) gameOver();
}
flashField('miss');
showFeedback('Daneben! 2s');
if (state.timeLeft <= 0) gameOver();
}
let feedbackT = null;
@@ -344,22 +400,23 @@ async function gameOver() {
const cachedName = localStorage.getItem(NAME_KEY);
if (cachedName) {
$('name-ask').classList.add('hidden');
const list = await submitScore(cachedName, state.score);
renderLeaderboard($('go-leaderboard'), list);
renderBoards(await submitScore(cachedName), $('go-alltime'), $('go-today'));
} else {
// Namensabfrage (nur beim ersten Mal)
$('name-ask').classList.remove('hidden');
renderLeaderboard($('go-leaderboard'), await fetchLeaderboard());
renderBoards(await fetchLeaderboard(), $('go-alltime'), $('go-today'));
$('name-input').focus();
}
}
async function submitScore(name, score) {
// Score eintragen: der Score kommt SERVERSEITIG aus der Session (Anticheat) — der Client
// sendet nur Name + sessionId, keine selbst behauptete Zahl.
async function submitScore(name) {
try {
const res = await fetch('/api/score', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, score }),
body: JSON.stringify({ name, sessionId: state && state.sessionId }),
});
if (res.ok) return await res.json();
} catch {}
@@ -374,7 +431,7 @@ function goHome() {
refreshHomeBoard();
}
async function refreshHomeBoard() {
renderLeaderboard($('leaderboard'), await fetchLeaderboard());
renderBoards(await fetchLeaderboard(), $('lb-alltime'), $('lb-today'));
}
async function init() {
@@ -402,7 +459,7 @@ async function init() {
const name = ($('name-input').value || '').trim().slice(0, 20) || 'Anonym';
localStorage.setItem(NAME_KEY, name);
$('name-ask').classList.add('hidden');
renderLeaderboard($('go-leaderboard'), await submitScore(name, state.score));
renderBoards(await submitScore(name), $('go-alltime'), $('go-today'));
});
$('name-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter') $('save-score').click();