- Alle Antworten mit Cache-Control: no-store,no-cache,must-revalidate,max-age=0 + Pragma/Expires -> Browser UND Proxys (Cloudflare) speichern nichts mehr - ETag/Last-Modified fuer statische Assets aus - Behebt: bei manchen Nutzern laden nach Updates alte JS/CSS-Dateien Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
486 lines
15 KiB
JavaScript
486 lines
15 KiB
JavaScript
// 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;
|
||
|
||
// ---- 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();
|