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:
117
public/js/app.js
117
public/js/app.js
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user