// 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();