Files
fuesse-sexy/public/js/app.js
Guido ff936d1106 Anti-Cheat: Tab-Wechsel beendet den Lauf; Admin-Panel breiter
- visibilitychange -> gameOver, sobald der Tab versteckt/gewechselt wird (verhindert
  das "Pausieren im Hintergrund"-Spicken, da rAF im Hintergrund gedrosselt wird)
- Admin .wrap max-width 1000 -> 1240 (Spielertabelle weniger gequetscht)

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

550 lines
18 KiB
JavaScript
Raw Permalink 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);
// ---- 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 ----
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 aus der Config (identisch zur Server-Formel)
function objectCount(score) {
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 (Grenzen/Tempo aus der Config)
function difficulty(score) {
const count = objectCount(score);
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) ----
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');
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();
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;
}
}
// Anti-Cheat: Tab-Wechsel/Verstecken beendet den laufenden Lauf sofort (sonst pausiert
// der Browser die Spielschleife im Hintergrund -> man koennte in Ruhe "spicken").
document.addEventListener('visibilitychange', () => {
if (document.hidden && state && state.running) gameOver();
});
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 / CFG.maxTime, 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 + CFG.bonus, CFG.maxTime);
$('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 - CFG.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();