Files
fuesse-sexy/public/js/app.js
Guido ecdbd2ee0d 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>
2026-08-05 11:23:30 +02:00

486 lines
15 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 = 30; // Deckel (Timerleiste = timeLeft / MAX_TIME)
const BONUS = 2.0; // Zeit pro Treffer
const PENALTY = 2.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;
// ---- 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;
}
// Schwierigkeit aus dem Score ableiten
function difficulty(score) {
const count = Math.min(10 + Math.floor(score * 1.5), 46);
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 =================
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 };
$('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() {
const r = $('field').getBoundingClientRect();
return { w: r.width, h: r.height };
}
// Baut eine neue Runde: neuer (zufaelliger) Ziel-Fuss + Ablenker, Layout je nach Stufe.
function buildRound() {
const field = $('field');
field.innerHTML = '';
state.items = [];
state.groups = [];
const { w, h } = fieldSize();
const diff = difficulty(state.score);
state.stage = diff.stage;
// Ziel-Fusstyp zufaellig (Farbe wechselt jede Runde)
const target = pick(FEET);
$('wanted-img').src = target.url;
// Item-Groessenwahl: schrumpft mit steigender Anzahl, bleibt tippfreundlich
const size = clamp(Math.sqrt((w * h) / (diff.count * 3.4)), 40, 66);
// Typen fuer die Items zusammenstellen: 1 Ziel + Ablenker
const otherFeet = FEET.filter((f) => f.id !== target.id);
const decoyPool = [];
// Ablenker: v.a. andersfarbige Fuesse (inkl. aehnliche), plus ein paar Deko-Props
for (let i = 0; i < diff.count - 1; i++) {
const useProp = PROPS.length && Math.random() < 0.22;
decoyPool.push({ type: useProp ? pick(PROPS) : pick(otherFeet), isTarget: false });
}
const specs = shuffle([{ type: target, isTarget: true }, ...decoyPool]);
// Positionen je Stufe
const positions = layoutPositions(diff, specs.length, w, h, size);
specs.forEach((spec, i) => {
const el = document.createElement('img');
el.className = 'item';
el.src = spec.type.url;
el.draggable = false;
el.style.width = size + 'px';
el.style.height = size + 'px';
field.appendChild(el);
const p = positions[i];
const item = {
el,
isTarget: spec.isTarget,
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) 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();
}
// 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();
}
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);
$('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() {
$('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();