Files
fuesse-sexy/public/js/app.js
Guido 5a6a1f363b Umbau zu "Wanted!"-Minigame
- Neues Spielprinzip: Fahndungsplakat + Feld voller Fuesse/Deko, gesuchten Fuss
  antippen bevor Timer ablaeuft; sofort neue Runde mit anderer Farbe.
- Progression: Raster -> wild verteilt -> Bewegung (2-3 Gruppen -> einzeln),
  Anzahl + Tempo steigen mit Score.
- Farben: 8 klar unterscheidbare Fuss-Farben (kein Braun/Hautton-Doppel) ->
  die gesuchte Farbe kommt garantiert nur EINMAL vor.
- Serverseitige Rangliste (data/leaderboard.json), Top 10 auf der Startseite,
  Name einmalig abgefragt + in localStorage gecacht.
- Reset per Befehl: npm run reset-stats
- Server stark vereinfacht (/api/feet, /api/leaderboard, /api/score); Pan/Zoom-,
  Wimmelbild- und Szenen-Generator-Code entfernt.
- WANTED-Plakat groesser, Timer schlanker, Startseite sauber zentriert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 21:57:13 +02:00

429 lines
13 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 ----
async function fetchLeaderboard() {
try {
const res = await fetch('/api/leaderboard');
return res.ok ? await res.json() : [];
} catch {
return [];
}
}
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 =================
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 };
$('score').textContent = '0';
buildRound();
lastT = performance.now();
cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(loop);
}
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 ----
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);
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');
const list = await submitScore(cachedName, state.score);
renderLeaderboard($('go-leaderboard'), list);
} else {
// Namensabfrage (nur beim ersten Mal)
$('name-ask').classList.remove('hidden');
renderLeaderboard($('go-leaderboard'), await fetchLeaderboard());
$('name-input').focus();
}
}
async function submitScore(name, score) {
try {
const res = await fetch('/api/score', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, score }),
});
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() {
renderLeaderboard($('leaderboard'), await fetchLeaderboard());
}
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');
renderLeaderboard($('go-leaderboard'), await submitScore(name, state.score));
});
$('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();