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