Treffer-Toleranz, 2 Ranglisten, Anti-Cheat, CLI-Modi & Icons
- Treffer-Toleranz: Ziel wird geometrisch + bevorzugt getroffen, auch wenn verdeckt - Zwei Ranglisten: Hall of Fame (Allzeit-Top-5) + Heute (Top-10) - Anti-Cheat: server-autoritative Sessions (start/hit), Score = Server-Zahl, Mindestabstand gegen Auto-Klicker, Deckelung nach Spielzeit; Score ohne Session -> 400 - CLI (server/admin.js): list / delete <name> / clear-today / clear-all - Zwei Betriebsmodi per CLI (live, ohne Neustart): public (/ = Spiel) und private (/ gesperrt, Spiel unter geheimem /pfad) + "not allowed to play"-Seite - Board-Icons von Emojis auf echte SVG-Icons, Board heisst "Hall of Fame" Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
18
public/blocked.html
Normal file
18
public/blocked.html
Normal file
@@ -0,0 +1,18 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>füsse.sexy</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='88'>🦶</text></svg>" />
|
||||
<link rel="stylesheet" href="/css/style.css" />
|
||||
|
||||
<div id="app">
|
||||
<section id="home">
|
||||
<div class="home-inner">
|
||||
<h1 class="brand">füsse<span class="brand-dot">.</span>sexy</h1>
|
||||
<div class="blocked-card">
|
||||
<div class="blocked-emoji">🚧</div>
|
||||
<h2>You are not allowed to play</h2>
|
||||
<p>Das Spiel ist gerade nicht verfügbar. Schau später nochmal vorbei.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -38,14 +38,22 @@ button { font-family: inherit; cursor: pointer; }
|
||||
|
||||
/* ===== Startseite ===== */
|
||||
#home { display: flex; align-items: center; justify-content: center; padding: 24px; overflow: auto; }
|
||||
.home-inner { width: 100%; max-width: 460px; margin: auto; text-align: center; display: flex; flex-direction: column; gap: 16px; align-items: center; }
|
||||
.home-inner { width: 100%; max-width: 560px; margin: auto; text-align: center; display: flex; flex-direction: column; gap: 16px; align-items: center; }
|
||||
.blocked-card { width: 100%; background: var(--panel); border: 1px solid var(--border); border-radius: 18px; padding: 34px 24px; text-align: center; }
|
||||
.blocked-emoji { font-size: 52px; }
|
||||
.blocked-card h2 { margin: 10px 0 8px; color: var(--pink-soft); }
|
||||
.blocked-card p { margin: 0; color: var(--muted); }
|
||||
.boards { width: 100%; display: flex; gap: 12px; align-items: flex-start; }
|
||||
.boards .board { flex: 1; min-width: 0; }
|
||||
@media (max-width: 440px) { .boards { flex-direction: column; } }
|
||||
.brand { font-size: 42px; font-weight: 900; letter-spacing: -1px; color: var(--pink); margin: 6px 0 0; }
|
||||
.brand-dot { color: var(--pink-soft); }
|
||||
.tagline { color: var(--muted); margin: 0; font-size: 15px; line-height: 1.4; }
|
||||
|
||||
.board { width: 100%; background: var(--panel); border: 1px solid var(--border); border-radius: 18px; padding: 14px 16px; }
|
||||
.board.tight { padding: 10px 14px; }
|
||||
.board h2 { margin: 2px 0 10px; font-size: 18px; }
|
||||
.board h2 { margin: 2px 0 10px; font-size: 18px; display: flex; align-items: center; justify-content: center; gap: 8px; }
|
||||
.ico { width: 18px; height: 18px; flex: 0 0 auto; color: var(--pink-soft); }
|
||||
.leaderboard { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
|
||||
.leaderboard li { display: flex; align-items: center; gap: 10px; padding: 7px 10px; border-radius: 10px; background: var(--card); font-size: 15px; min-width: 0; }
|
||||
.leaderboard li.top3 { background: #2f2a1a; }
|
||||
@@ -114,7 +122,7 @@ button { font-family: inherit; cursor: pointer; }
|
||||
/* ===== Game Over ===== */
|
||||
#gameover { position: absolute; inset: 0; display: grid; place-items: center; background: rgba(0,0,0,0.7); z-index: 10; padding: 16px; overflow-y: auto; }
|
||||
.go-card {
|
||||
width: 100%; max-width: 420px; background: var(--panel); border: 1px solid var(--border);
|
||||
width: 100%; max-width: 520px; background: var(--panel); border: 1px solid var(--border);
|
||||
border-radius: 22px; padding: 24px; text-align: center; box-shadow: 0 20px 60px rgba(0,0,0,0.6);
|
||||
display: flex; flex-direction: column; gap: 14px; animation: pop 0.3s ease;
|
||||
}
|
||||
|
||||
@@ -12,9 +12,15 @@
|
||||
<p class="tagline"><b>WANTED!</b> — schnapp dir den gesuchten Fuß, bevor die Zeit abläuft.</p>
|
||||
<button id="play-btn" class="big-btn">▶ Spielen</button>
|
||||
|
||||
<div class="board">
|
||||
<h2>🏆 Rangliste</h2>
|
||||
<ol id="leaderboard" class="leaderboard"></ol>
|
||||
<div class="boards">
|
||||
<div class="board">
|
||||
<h2><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 21h8"/><path d="M12 17v4"/><path d="M7 4h10v5a5 5 0 0 1-10 0V4Z"/><path d="M7 6H4v1a3 3 0 0 0 3 3"/><path d="M17 6h3v1a3 3 0 0 1-3 3"/></svg> Hall of Fame</h2>
|
||||
<ol id="lb-alltime" class="leaderboard"></ol>
|
||||
</div>
|
||||
<div class="board">
|
||||
<h2><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M3 9h18M8 2v4M16 2v4"/></svg> Heute</h2>
|
||||
<ol id="lb-today" class="leaderboard"></ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="change-name" class="link-btn">Name ändern</button>
|
||||
@@ -54,9 +60,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="board tight">
|
||||
<h2>🏆 Rangliste</h2>
|
||||
<ol id="go-leaderboard" class="leaderboard"></ol>
|
||||
<div class="boards tight">
|
||||
<div class="board">
|
||||
<h2><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 21h8"/><path d="M12 17v4"/><path d="M7 4h10v5a5 5 0 0 1-10 0V4Z"/><path d="M7 6H4v1a3 3 0 0 0 3 3"/><path d="M17 6h3v1a3 3 0 0 1-3 3"/></svg> Hall of Fame</h2>
|
||||
<ol id="go-alltime" class="leaderboard"></ol>
|
||||
</div>
|
||||
<div class="board">
|
||||
<h2><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M3 9h18M8 2v4M16 2v4"/></svg> Heute</h2>
|
||||
<ol id="go-today" class="leaderboard"></ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="go-actions">
|
||||
|
||||
117
public/js/app.js
117
public/js/app.js
@@ -40,15 +40,21 @@ function difficulty(score) {
|
||||
return { count, stage: 'individual', groups: 0, speed: Math.min(70 + (score - 11) * 12, 320) };
|
||||
}
|
||||
|
||||
// ---- Rangliste ----
|
||||
// ---- 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() : [];
|
||||
return res.ok ? await res.json() : EMPTY_BOARDS;
|
||||
} catch {
|
||||
return [];
|
||||
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) {
|
||||
@@ -76,18 +82,39 @@ function renderLeaderboard(el, list) {
|
||||
}
|
||||
|
||||
// ================= Spiel =================
|
||||
function startGame() {
|
||||
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 };
|
||||
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() {
|
||||
@@ -288,30 +315,59 @@ function updateTimerBar() {
|
||||
bar.classList.toggle('low', state.timeLeft <= 6);
|
||||
}
|
||||
|
||||
// ---- Treffer ----
|
||||
// ---- 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 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);
|
||||
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();
|
||||
}
|
||||
flashField('miss');
|
||||
showFeedback('Daneben! −2s');
|
||||
if (state.timeLeft <= 0) gameOver();
|
||||
}
|
||||
|
||||
let feedbackT = null;
|
||||
@@ -344,22 +400,23 @@ async function gameOver() {
|
||||
const cachedName = localStorage.getItem(NAME_KEY);
|
||||
if (cachedName) {
|
||||
$('name-ask').classList.add('hidden');
|
||||
const list = await submitScore(cachedName, state.score);
|
||||
renderLeaderboard($('go-leaderboard'), list);
|
||||
renderBoards(await submitScore(cachedName), $('go-alltime'), $('go-today'));
|
||||
} else {
|
||||
// Namensabfrage (nur beim ersten Mal)
|
||||
$('name-ask').classList.remove('hidden');
|
||||
renderLeaderboard($('go-leaderboard'), await fetchLeaderboard());
|
||||
renderBoards(await fetchLeaderboard(), $('go-alltime'), $('go-today'));
|
||||
$('name-input').focus();
|
||||
}
|
||||
}
|
||||
|
||||
async function submitScore(name, score) {
|
||||
// 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, score }),
|
||||
body: JSON.stringify({ name, sessionId: state && state.sessionId }),
|
||||
});
|
||||
if (res.ok) return await res.json();
|
||||
} catch {}
|
||||
@@ -374,7 +431,7 @@ function goHome() {
|
||||
refreshHomeBoard();
|
||||
}
|
||||
async function refreshHomeBoard() {
|
||||
renderLeaderboard($('leaderboard'), await fetchLeaderboard());
|
||||
renderBoards(await fetchLeaderboard(), $('lb-alltime'), $('lb-today'));
|
||||
}
|
||||
|
||||
async function init() {
|
||||
@@ -402,7 +459,7 @@ async function init() {
|
||||
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));
|
||||
renderBoards(await submitScore(name), $('go-alltime'), $('go-today'));
|
||||
});
|
||||
$('name-input').addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') $('save-score').click();
|
||||
|
||||
Reference in New Issue
Block a user