Files
fuesse-sexy/public/js/app.js
Guido 221cba417f Anti-Cheat: server-autoritative Runden (blindes Treffer-Spam wirkungslos)
Der Server bestimmt pro Runde Ziel + alle Items (mit zufaelligen IDs) und validiert
jeden Treffer gegen die Ziel-ID der aktuellen Runde (nonce + itemId). Blindes Spammen
von /api/game/hit bringt damit 0 Punkte. Zusaetzlich Reaktionszeit-Untergrenze und
Mindestabstand; Score weiterhin nach Spielzeit gedeckelt.

- Server: makeRound() + /api/game/start liefert erste Runde, /api/game/hit erwartet
  {nonce,itemId} und gibt bei korrektem Treffer die naechste Runde zurueck
- Client: rendert server-gelieferte Runden, sendet geklickte itemId, Vorschub nur bei
  Server-Bestaetigung (Sofort-Flash fuers Gefuehl)
- README: Ban-Befehle dokumentiert, Anti-Cheat-Abschnitt aktualisiert

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 12:39:19 +02:00

537 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// fuesse.sexy — "Wanted!"-Minigame
// Oben ein Fahndungs-Fuss, im Feld wuseln viele Fuesse/Deko. Schnapp den gesuchten,
// bevor die Zeit ablaeuft. Schwierigkeit (Anzahl, Layout, Bewegung, Tempo) steigt mit Score.
const $ = (id) => document.getElementById(id);
// ---- Konstanten (leicht justierbar) ----
const START_TIME = 22; // Sekunden zu Beginn
const MAX_TIME = 20; // Deckel (Timerleiste = timeLeft / MAX_TIME)
const BONUS = 1.0; // Zeit pro Treffer
const PENALTY = 3.0; // Zeitabzug pro Fehlklick
const NAME_KEY = 'fuesse_name';
// ---- Zustand ----
let FEET = []; // [{id,name,url}]
let PROPS = []; // [{id,name,url}]
let state = null;
let rafId = null;
let lastT = 0;
let modePollId = null;
// ---- Utils ----
const rand = (a, b) => a + Math.random() * (b - a);
const randInt = (a, b) => Math.floor(rand(a, b + 1));
const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
function shuffle(a) {
for (let i = a.length - 1; i > 0; i--) {
const j = randInt(0, i);
[a[i], a[j]] = [a[j], a[i]];
}
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 = 15; // ab diesem Score kommen Stufen dazu
const STEP_EVERY = 3; // alle N Punkte eine Stufe
const STEP_ADD = 5; // +M Objekte pro Stufe
const COUNT_CAP = 100; // harte Obergrenze (Performance/Spielbarkeit)
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);
}
// Schwierigkeit aus dem Score ableiten
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) };
}
// ---- 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() : EMPTY_BOARDS;
} catch {
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) {
const li = document.createElement('li');
li.className = 'lb-empty';
li.textContent = 'Noch keine Einträge — sei die/der Erste!';
el.appendChild(li);
return;
}
list.forEach((entry, i) => {
const li = document.createElement('li');
const rank = document.createElement('span');
rank.className = 'lb-rank';
rank.textContent = `${i + 1}.`;
const name = document.createElement('span');
name.className = 'lb-name';
name.textContent = entry.name; // textContent -> kein XSS
const score = document.createElement('span');
score.className = 'lb-score';
score.textContent = entry.score;
li.append(rank, name, score);
if (i < 3) li.classList.add('top3');
el.appendChild(li);
});
}
// ================= Spiel =================
// Runden sind SERVER-AUTORITATIV: der Server bestimmt Ziel + Items (mit IDs) und
// validiert jeden Treffer. Der Client rendert nur, was der Server schickt.
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, sessionId: null, nonce: null, awaiting: false };
$('score').textContent = '0';
const start = await startSession();
if (!start || !start.sessionId || !start.round) {
showFeedback('Server nicht erreichbar');
return goHome();
}
state.sessionId = start.sessionId;
buildRound(start.round);
lastT = performance.now();
cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(loop);
startModeWatch(); // laufendes Spiel abbrechen, falls live auf "privat" geschaltet wird
}
async function startSession() {
try {
const res = await fetch('/api/game/start', { method: 'POST' });
if (res.ok) return await res.json(); // { sessionId, round }
} catch {}
return null;
}
// Live-Pruefung des Betriebsmodus: darf auf DIESEM Pfad gerade gespielt werden?
async function checkPlayAllowed() {
try {
const slug = location.pathname.replace(/^\/+/, '');
const res = await fetch('/api/status?path=' + encodeURIComponent(slug));
if (!res.ok) return true; // im Zweifel weiterspielen lassen
return (await res.json()).allowed !== false;
} catch {
return true;
}
}
function startModeWatch() {
stopModeWatch();
modePollId = setInterval(async () => {
if (!state || !state.running) return;
if (!(await checkPlayAllowed())) {
stopModeWatch();
location.reload(); // laufendes Spiel abbrechen -> Server-Gate entscheidet neu
}
}, 4000);
}
function stopModeWatch() {
if (modePollId) {
clearInterval(modePollId);
modePollId = null;
}
}
function fieldSize() {
const r = $('field').getBoundingClientRect();
return { w: r.width, h: r.height };
}
// Baut eine Runde aus SERVER-Daten auf (Ziel + Items mit IDs). Layout/Bewegung laufen
// clientseitig (nur kosmetisch); die Trefferpruefung macht der Server anhand der IDs.
function buildRound(round) {
const field = $('field');
field.innerHTML = '';
state.items = [];
state.groups = [];
state.nonce = round.nonce;
state.score = round.score;
$('score').textContent = state.score;
const { w, h } = fieldSize();
const diff = difficulty(round.score);
state.stage = diff.stage;
$('wanted-img').src = round.targetUrl;
const n = round.items.length;
// Item-Groessenwahl: schrumpft mit steigender Anzahl, bleibt tippfreundlich
const size = clamp(Math.sqrt((w * h) / (n * 3.4)), 34, 66);
const positions = layoutPositions(diff, n, w, h, size);
round.items.forEach((srv, i) => {
const el = document.createElement('img');
el.className = 'item';
el.src = srv.url;
el.draggable = false;
el.style.width = size + 'px';
el.style.height = size + 'px';
field.appendChild(el);
const p = positions[i];
const item = {
el,
id: srv.id, // Server-ID des Items (fuer die Trefferpruefung)
isTarget: srv.url === round.targetUrl, // nur fuer instantes Feedback; Server validiert final
x: p.x,
y: p.y,
vx: 0,
vy: 0,
rot: rand(-12, 12),
vr: rand(-25, 25),
size,
gi: p.gi ?? 0,
ox: p.ox ?? 0,
oy: p.oy ?? 0,
};
el.__item = item;
state.items.push(item);
applyTransform(item);
});
// Bewegung vorbereiten
if (diff.stage === 'groups') {
for (let g = 0; g < diff.groups; g++) {
const ang = rand(0, Math.PI * 2);
state.groups.push({
cx: rand(w * 0.25, w * 0.75),
cy: rand(h * 0.25, h * 0.75),
vx: Math.cos(ang) * diff.speed,
vy: Math.sin(ang) * diff.speed,
});
}
// Item-Positionen relativ zum Gruppenzentrum (Offset) neu setzen
state.items.forEach((it) => {
const grp = state.groups[it.gi];
it.ox = it.x - grp.cx;
it.oy = it.y - grp.cy;
});
} else if (diff.stage === 'individual') {
state.items.forEach((it) => {
const ang = rand(0, Math.PI * 2);
it.vx = Math.cos(ang) * diff.speed;
it.vy = Math.sin(ang) * diff.speed;
});
}
}
// Startpositionen je Stufe. Gibt {x,y,gi,ox,oy}-Liste (Top-Left-Koordinaten).
function layoutPositions(diff, n, w, h, size) {
const pad = 6;
const out = [];
if (diff.stage === 'grid') {
const cols = Math.max(1, Math.ceil(Math.sqrt(n * (w / h))));
const rows = Math.ceil(n / cols);
const cellW = w / cols;
const cellH = h / rows;
const cells = [];
for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) cells.push({ r, c });
shuffle(cells);
for (let i = 0; i < n; i++) {
const { r, c } = cells[i];
out.push({
x: clamp(c * cellW + (cellW - size) / 2, pad, w - size - pad),
y: clamp(r * cellH + (cellH - size) / 2, pad, h - size - pad),
});
}
return out;
}
if (diff.stage === 'groups') {
// Cluster-Zentren, Items rundherum verteilt
const centers = [];
for (let g = 0; g < diff.groups; g++) centers.push({ cx: rand(w * 0.3, w * 0.7), cy: rand(h * 0.3, h * 0.7) });
const radius = clamp(Math.min(w, h) * 0.22, 60, 160);
for (let i = 0; i < n; i++) {
const gi = i % diff.groups;
const c = centers[gi];
const a = rand(0, Math.PI * 2);
const rr = rand(0, radius);
out.push({
x: clamp(c.cx + Math.cos(a) * rr - size / 2, pad, w - size - pad),
y: clamp(c.cy + Math.sin(a) * rr - size / 2, pad, h - size - pad),
gi,
});
}
return out;
}
// scatter / individual: frei verteilt
for (let i = 0; i < n; i++) {
out.push({ x: rand(pad, w - size - pad), y: rand(pad, h - size - pad) });
}
return out;
}
function applyTransform(it) {
it.el.style.transform = `translate(${it.x}px, ${it.y}px) rotate(${it.rot}deg)`;
}
// ---- Animationsschleife ----
function loop(now) {
if (!state || !state.running) return;
let dt = (now - lastT) / 1000;
lastT = now;
dt = Math.min(dt, 0.05);
const { w, h } = fieldSize();
if (state.stage === 'groups') {
for (const grp of state.groups) {
grp.cx += grp.vx * dt;
grp.cy += grp.vy * dt;
const m = 90;
if (grp.cx < m) { grp.cx = m; grp.vx = Math.abs(grp.vx); }
if (grp.cx > w - m) { grp.cx = w - m; grp.vx = -Math.abs(grp.vx); }
if (grp.cy < m) { grp.cy = m; grp.vy = Math.abs(grp.vy); }
if (grp.cy > h - m) { grp.cy = h - m; grp.vy = -Math.abs(grp.vy); }
}
for (const it of state.items) {
const grp = state.groups[it.gi];
it.x = clamp(grp.cx + it.ox, 0, w - it.size);
it.y = clamp(grp.cy + it.oy, 0, h - it.size);
it.rot += it.vr * dt;
applyTransform(it);
}
} else if (state.stage === 'individual') {
for (const it of state.items) {
it.x += it.vx * dt;
it.y += it.vy * dt;
if (it.x < 0) { it.x = 0; it.vx = Math.abs(it.vx); }
if (it.x > w - it.size) { it.x = w - it.size; it.vx = -Math.abs(it.vx); }
if (it.y < 0) { it.y = 0; it.vy = Math.abs(it.vy); }
if (it.y > h - it.size) { it.y = h - it.size; it.vy = -Math.abs(it.vy); }
it.rot += it.vr * dt;
applyTransform(it);
}
}
// Timer
state.timeLeft -= dt;
if (state.timeLeft <= 0) {
state.timeLeft = 0;
updateTimerBar();
return gameOver();
}
updateTimerBar();
rafId = requestAnimationFrame(loop);
}
function updateTimerBar() {
const pct = clamp(state.timeLeft / MAX_TIME, 0, 1) * 100;
const bar = $('timer-bar');
bar.style.width = pct + '%';
bar.classList.toggle('low', state.timeLeft <= 6);
}
// ---- 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 || state.awaiting) return;
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(target);
}
// 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
}
// Treffer -> Server validiert (nonce + itemId). Nur bei Bestaetigung gibt es Punkt +
// naechste Runde. Sofort-Feedback (flash) fuers Gefuehl, Feld-Wechsel nach Server-Antwort.
async function hitTarget(target) {
if (state.awaiting) return;
state.awaiting = true;
flashField('ok');
try {
const res = await fetch('/api/game/hit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId: state.sessionId, nonce: state.nonce, itemId: target.id }),
});
const data = res.ok ? await res.json() : null;
if (data && data.correct && data.round) {
state.timeLeft = Math.min(state.timeLeft + BONUS, MAX_TIME);
$('score').parentElement.classList.remove('bump');
void $('score').parentElement.offsetWidth;
$('score').parentElement.classList.add('bump');
buildRound(data.round); // neues Plakat (Score kommt vom Server)
}
// sonst: Server hat NICHT gezaehlt (z.B. zu schnell) -> Runde bleibt, kein Punkt
} catch {
// Netzwerkfehler -> Runde bleibt bestehen
} finally {
if (state) state.awaiting = false;
}
}
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();
}
let feedbackT = null;
function showFeedback(msg) {
const f = $('feedback');
f.textContent = msg;
f.classList.add('show');
clearTimeout(feedbackT);
feedbackT = setTimeout(() => f.classList.remove('show'), 700);
}
let flashT = null;
function flashField(kind) {
const field = $('field');
field.classList.remove('flash-ok', 'flash-miss');
void field.offsetWidth;
field.classList.add(kind === 'ok' ? 'flash-ok' : 'flash-miss');
clearTimeout(flashT);
flashT = setTimeout(() => field.classList.remove('flash-ok', 'flash-miss'), 250);
}
// ---- Game Over ----
async function gameOver() {
if (!state) return;
state.running = false;
cancelAnimationFrame(rafId);
stopModeWatch();
$('final-score').textContent = state.score;
$('gameover').classList.remove('hidden');
const cachedName = localStorage.getItem(NAME_KEY);
if (cachedName) {
$('name-ask').classList.add('hidden');
renderBoards(await submitScore(cachedName), $('go-alltime'), $('go-today'));
} else {
// Namensabfrage (nur beim ersten Mal)
$('name-ask').classList.remove('hidden');
renderBoards(await fetchLeaderboard(), $('go-alltime'), $('go-today'));
$('name-input').focus();
}
}
// 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, sessionId: state && state.sessionId }),
});
if (res.ok) return await res.json();
} catch {}
return await fetchLeaderboard();
}
// ---- Setup / Events ----
function goHome() {
stopModeWatch();
$('gameover').classList.add('hidden');
$('game').classList.add('hidden');
$('home').classList.remove('hidden');
refreshHomeBoard();
}
async function refreshHomeBoard() {
renderBoards(await fetchLeaderboard(), $('lb-alltime'), $('lb-today'));
}
async function init() {
// Bildchen laden
try {
const res = await fetch('/api/feet');
const data = await res.json();
FEET = data.feet || [];
PROPS = data.props || [];
} catch (err) {
console.error(err);
}
if (FEET.length < 2) {
$('home').querySelector('.tagline').textContent = 'Fehler: zu wenige Füße in library/feet.';
}
await refreshHomeBoard();
$('play-btn').addEventListener('click', startGame);
$('play-again').addEventListener('click', startGame);
$('to-home').addEventListener('click', goHome);
$('field').addEventListener('pointerdown', onFieldPointerDown);
$('save-score').addEventListener('click', async () => {
const name = ($('name-input').value || '').trim().slice(0, 20) || 'Anonym';
localStorage.setItem(NAME_KEY, name);
$('name-ask').classList.add('hidden');
renderBoards(await submitScore(name), $('go-alltime'), $('go-today'));
});
$('name-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter') $('save-score').click();
});
$('change-name').addEventListener('click', () => {
const current = localStorage.getItem(NAME_KEY) || '';
const next = prompt('Dein Name für die Rangliste:', current);
if (next !== null) localStorage.setItem(NAME_KEY, next.trim().slice(0, 20) || 'Anonym');
});
window.addEventListener('resize', () => {
// Positionen bei Groessenaenderung grob im Feld halten
if (!state || !state.running) return;
const { w, h } = fieldSize();
for (const it of state.items) {
it.x = clamp(it.x, 0, w - it.size);
it.y = clamp(it.y, 0, h - it.size);
}
});
}
init();