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>
This commit is contained in:
Guido
2026-08-04 21:57:13 +02:00
parent adc0fae93f
commit 5a6a1f363b
28 changed files with 795 additions and 1347 deletions

View File

@@ -1,297 +1,140 @@
/* fuesse.sexy — verspielter, klarer Look im Dark Mode. Vollflaechig, kein Scrollen. */
/* fuesse.sexy — "Wanted!" · Dark Mode, vollflaechig, kein Scrollen */
:root {
--pink: #ff4d8d;
--pink-soft: #ff86b3;
--ink: #ececf5;
--muted: #a49fc0;
--bg: #0e0e18; /* Seite / Letterbox */
--panel: #1b1b2c; /* Leiste, Buttons */
--card: #262640; /* Legenden-Karten */
--border: #34345020;
--border-solid: #3a3a58;
--bg: #0e0e18;
--panel: #1b1b2c;
--card: #262640;
--border: #3a3a58;
--ok: #34d399;
--miss: #f87171;
--gold: #ffcf3f;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
height: 100%;
overflow: hidden;
margin: 0; height: 100%; overflow: hidden;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
color: var(--ink);
background: var(--bg);
-webkit-user-select: none;
user-select: none;
color: var(--ink); background: var(--bg);
-webkit-user-select: none; user-select: none;
}
#game {
display: flex;
flex-direction: column;
height: 100%;
}
/* ---- Gesucht-Leiste ---- */
#legend {
display: flex;
align-items: center;
gap: 14px;
padding: 8px 14px;
background: var(--panel);
box-shadow: 0 2px 14px rgba(0, 0, 0, 0.4);
border-bottom: 1px solid var(--border-solid);
z-index: 5;
}
.legend-label { display: flex; flex-direction: column; line-height: 1.1; }
.brand {
font-weight: 800;
font-size: 20px;
color: var(--pink);
letter-spacing: -0.5px;
}
.brand-dot { color: var(--pink-soft); }
.legend-sub { font-size: 12px; color: var(--muted); }
#legend-items {
display: flex;
gap: 10px;
flex: 1;
overflow-x: auto;
}
.legend-item {
flex: 0 0 auto;
width: 58px;
height: 58px;
border-radius: 14px;
background: var(--card);
border: 3px solid var(--border-solid);
display: grid;
place-items: center;
position: relative;
transition: transform 0.15s ease, opacity 0.2s ease, border-color 0.2s ease;
}
.legend-thumb { width: 44px; height: 44px; display: grid; place-items: center; }
.legend-thumb img { max-width: 100%; max-height: 100%; }
.legend-emoji { font-size: 34px; line-height: 1; }
.legend-item.found {
border-color: var(--ok);
opacity: 0.5;
}
.legend-item.found::after {
content: "✓";
position: absolute;
right: -6px;
top: -8px;
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--ok);
color: #0e0e18;
font-size: 14px;
font-weight: 800;
display: grid;
place-items: center;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4);
}
.legend-counter {
flex: 0 0 auto;
font-weight: 800;
font-size: 18px;
color: var(--pink-soft);
background: var(--card);
padding: 6px 12px;
border-radius: 999px;
}
/* ---- Buehne / Karte ---- */
#stage {
position: relative;
flex: 1;
overflow: hidden;
background: var(--bg);
touch-action: none; /* eigene Pinch/Pan-Gesten */
}
#stage {
cursor: grab;
-webkit-touch-callout: none; /* kein Langdruck-Menü auf iOS */
}
#stage:active { cursor: grabbing; }
#world {
position: absolute;
top: 0;
left: 0;
transform-origin: 0 0;
will-change: transform;
}
#scene-img {
display: block;
width: 100%;
height: 100%;
pointer-events: none;
-webkit-user-drag: none;
user-select: none;
}
.category-name {
position: absolute;
left: 12px;
bottom: 12px;
font-size: 12px;
font-weight: 600;
color: var(--muted);
background: rgba(0, 0, 0, 0.45);
padding: 4px 10px;
border-radius: 999px;
pointer-events: none;
}
/* ---- Zoom-Steuerung ---- */
#controls {
position: absolute;
right: 14px;
bottom: 14px;
display: flex;
flex-direction: column;
gap: 8px;
z-index: 4;
}
#controls button {
width: 46px;
height: 46px;
border-radius: 12px;
border: 1px solid var(--border-solid);
background: var(--panel);
color: var(--ink);
font-size: 22px;
font-weight: 700;
cursor: pointer;
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.5);
display: grid;
place-items: center;
}
#controls button:hover { color: var(--pink-soft); border-color: var(--pink); }
#controls button:active { transform: translateY(1px); }
/* ---- Ping-Feedback (Treffer/Daneben) ---- */
.ping {
position: absolute;
width: 46px;
height: 46px;
margin: -23px 0 0 -23px; /* auf (left/top) zentrieren */
border-radius: 50%;
border: 6px solid var(--ok);
pointer-events: none;
animation: ping 0.6s ease-out forwards;
}
.ping-ok { border-color: var(--ok); }
.ping-miss { border-color: var(--miss); }
@keyframes ping {
0% { transform: scale(0.3); opacity: 0.95; }
100% { transform: scale(2.3); opacity: 0; }
}
/* ---- Toast ---- */
#toast {
position: absolute;
left: 50%;
bottom: 78px;
transform: translateX(-50%) translateY(10px);
background: rgba(0, 0, 0, 0.85);
color: #fff;
padding: 8px 16px;
border-radius: 999px;
font-size: 14px;
border: 1px solid var(--border-solid);
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease, transform 0.2s ease;
}
#toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
/* ---- Lade-Spinner ---- */
#loading {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: rgba(14, 14, 24, 0.8);
z-index: 6;
}
.spinner {
width: 46px;
height: 46px;
border: 5px solid var(--border-solid);
border-top-color: var(--pink);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ---- Gewinn-Overlay ---- */
#win-overlay {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: rgba(0, 0, 0, 0.7);
z-index: 10;
}
.win-card {
background: var(--panel);
padding: 32px 40px;
border-radius: 24px;
text-align: center;
border: 1px solid var(--border-solid);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
animation: pop 0.3s ease;
}
.win-emoji { font-size: 52px; }
.win-card h2 { margin: 10px 0 20px; color: var(--pink-soft); }
#next-scene {
border: none;
background: var(--pink);
color: #fff;
font-size: 18px;
font-weight: 700;
padding: 12px 28px;
border-radius: 999px;
cursor: pointer;
box-shadow: 0 6px 18px rgba(255, 77, 141, 0.45);
}
#next-scene:hover { background: var(--pink-soft); }
@keyframes pop { from { transform: scale(0.85); opacity: 0; } to { transform: scale(1); opacity: 1; } }
/* ---- Fehleranzeige ---- */
#error-box {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
max-width: 82%;
background: #2a1020;
color: #ffd0e2;
border: 1px solid var(--pink);
padding: 16px 20px;
border-radius: 12px;
font-size: 14px;
line-height: 1.4;
text-align: center;
z-index: 20;
}
#app, #home, #game { height: 100%; }
.hidden { display: none !important; }
/* Mobile */
@media (max-width: 560px) {
.brand { font-size: 16px; }
.legend-item { width: 50px; height: 50px; }
.legend-thumb { width: 38px; height: 38px; }
button { font-family: inherit; cursor: pointer; }
.big-btn {
border: none; background: var(--pink); color: #fff; font-weight: 800;
font-size: 20px; padding: 14px 30px; border-radius: 999px;
box-shadow: 0 8px 22px rgba(255, 77, 141, 0.45);
}
.big-btn:hover { background: var(--pink-soft); }
.big-btn:active { transform: translateY(1px); }
.big-btn.small { font-size: 16px; padding: 10px 18px; }
.link-btn { background: none; border: none; color: var(--muted); text-decoration: underline; font-size: 14px; }
.link-btn:hover { color: var(--pink-soft); }
/* ===== 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; }
.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; }
.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; }
.leaderboard li.top3 .lb-rank { color: var(--gold); }
.lb-rank { width: 26px; font-weight: 800; color: var(--muted); text-align: right; }
.lb-name { flex: 1; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.lb-score { font-weight: 800; color: var(--pink-soft); }
.lb-empty { justify-content: center; color: var(--muted); font-size: 14px; }
/* ===== Spiel ===== */
#game { display: flex; flex-direction: column; }
#hud {
display: flex; align-items: center; gap: 14px; padding: 10px 14px;
background: var(--panel); border-bottom: 1px solid var(--border); z-index: 5;
}
/* Fahndungsplakat */
.wanted-poster {
flex: 0 0 auto; width: 150px; text-align: center;
background: #efe2c4; color: #3a2c17; border-radius: 10px; padding: 8px 8px 6px;
border: 3px solid #b79a63; box-shadow: 0 3px 10px rgba(0, 0, 0, 0.4);
transform: rotate(-2deg);
}
.wanted-title { font-weight: 900; letter-spacing: 3px; font-size: 20px; font-family: "Georgia", "Times New Roman", serif; }
.wanted-foot { background: #fbf4e2; border: 2px solid #c9b483; border-radius: 8px; margin: 4px 0; height: 92px; display: grid; place-items: center; }
.wanted-foot img { width: 78px; height: 78px; }
.wanted-reward { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; }
#hud-right { flex: 1; display: flex; flex-direction: column; gap: 10px; }
.score-box { font-size: 20px; font-weight: 800; color: var(--ink); }
.score-box #score { color: var(--pink-soft); }
.score-box.bump { animation: bump 0.28s ease; }
@keyframes bump { 0% { transform: scale(1); } 40% { transform: scale(1.35); color: var(--ok); } 100% { transform: scale(1); } }
#timer-wrap { width: 100%; height: 9px; background: #12121e; border-radius: 999px; overflow: hidden; border: 1px solid var(--border); }
#timer-bar { height: 100%; width: 100%; background: linear-gradient(90deg, var(--ok), #7be0b0); border-radius: 999px; transition: width 0.1s linear; }
#timer-bar.low { background: linear-gradient(90deg, var(--miss), #ffb0b0); }
/* Spielfeld */
#field {
position: relative; flex: 1; overflow: hidden;
background: radial-gradient(circle at 50% 40%, #1a1a2e, #0e0e18);
touch-action: none;
}
.item {
position: absolute; top: 0; left: 0; will-change: transform;
cursor: pointer; -webkit-user-drag: none; user-select: none;
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.5));
}
.item.shake { animation: shake 0.3s ease; }
@keyframes shake { 0%,100% { } 25% { filter: drop-shadow(0 0 6px var(--miss)); } 50% { } 75% { filter: drop-shadow(0 0 6px var(--miss)); } }
#field.flash-ok { animation: flashOk 0.25s ease; }
#field.flash-miss { animation: flashMiss 0.25s ease; }
@keyframes flashOk { 0% { box-shadow: inset 0 0 0 rgba(52,211,153,0); } 40% { box-shadow: inset 0 0 120px rgba(52,211,153,0.35); } 100% { box-shadow: inset 0 0 0 rgba(52,211,153,0); } }
@keyframes flashMiss { 0% { box-shadow: inset 0 0 0 rgba(248,113,113,0); } 40% { box-shadow: inset 0 0 120px rgba(248,113,113,0.4); } 100% { box-shadow: inset 0 0 0 rgba(248,113,113,0); } }
#feedback {
position: absolute; left: 50%; bottom: 22px; transform: translateX(-50%) translateY(10px);
background: rgba(0,0,0,0.85); color: #fff; padding: 8px 18px; border-radius: 999px;
font-weight: 700; border: 1px solid var(--miss); opacity: 0; pointer-events: none;
transition: opacity 0.15s, transform 0.15s;
}
#feedback.show { opacity: 1; transform: translateX(-50%) translateY(0); }
/* ===== 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);
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;
}
@keyframes pop { from { transform: scale(0.88); opacity: 0; } to { transform: scale(1); opacity: 1; } }
.go-emoji { font-size: 44px; }
.go-card h2 { margin: 0; color: var(--pink-soft); }
.go-score { margin: 0; font-size: 22px; font-weight: 800; }
.go-score span { color: var(--gold); }
#name-ask { display: flex; flex-direction: column; gap: 8px; }
#name-ask label { font-size: 14px; color: var(--muted); }
.name-row { display: flex; gap: 8px; }
#name-input { flex: 1; padding: 10px 12px; border-radius: 10px; border: 1px solid var(--border); background: var(--card); color: var(--ink); font-size: 16px; }
#name-input:focus { outline: 2px solid var(--pink); }
.go-actions { display: flex; flex-direction: column; gap: 8px; align-items: center; }
/* ===== Mobile ===== */
@media (max-width: 560px) {
.brand { font-size: 34px; }
.wanted-poster { width: 120px; }
.wanted-title { font-size: 17px; }
.wanted-foot { height: 68px; } .wanted-foot img { width: 58px; height: 58px; }
.score-box { font-size: 18px; }
}

View File

@@ -1,53 +1,70 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<title>fuesse.sexy — finde die Füße</title>
<title>fuesse.sexy — WANTED!</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="game">
<!-- "Gesucht"-Leiste -->
<header id="legend">
<div class="legend-label">
<span class="brand">fuesse<span class="brand-dot">.</span>sexy</span>
<span class="legend-sub">Finde diese Füße:</span>
<div id="app">
<!-- ===== Startseite ===== -->
<section id="home">
<div class="home-inner">
<h1 class="brand">fuesse<span class="brand-dot">.</span>sexy</h1>
<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>
<button id="change-name" class="link-btn">Name ändern</button>
</div>
<div id="legend-items"></div>
<div id="legend-counter" class="legend-counter">0 / 3</div>
</header>
</section>
<!-- Die "Karte" (Wimmelbild) -->
<main id="stage">
<div id="world">
<img id="scene-img" alt="Wimmelbild" draggable="false" />
</div>
<!-- ===== Spiel ===== -->
<section id="game" class="hidden">
<header id="hud">
<div class="wanted-poster">
<div class="wanted-title">WANTED</div>
<div class="wanted-foot"><img id="wanted-img" alt="gesuchter Fuß" draggable="false" /></div>
<div class="wanted-reward">schnapp ihn!</div>
</div>
<div id="hud-right">
<div class="score-box">Score <span id="score">0</span></div>
<div id="timer-wrap"><div id="timer-bar"></div></div>
</div>
</header>
<div id="category-name" class="category-name"></div>
<main id="field"></main>
<div id="feedback"></div>
</section>
<!-- Zoom-Steuerung -->
<div id="controls">
<button id="zoom-in" title="Reinzoomen" aria-label="Reinzoomen">+</button>
<button id="zoom-out" title="Rauszoomen" aria-label="Rauszoomen"></button>
<button id="reset-view" title="Ansicht zurücksetzen" aria-label="Ansicht zurücksetzen"></button>
</div>
<!-- ===== Game Over ===== -->
<div id="gameover" class="hidden">
<div class="go-card">
<div class="go-emoji">🦶⏱️</div>
<h2>Zeit um!</h2>
<p class="go-score">Score: <span id="final-score">0</span></p>
<!-- Lade-Spinner -->
<div id="loading" class="hidden">
<div class="spinner"></div>
</div>
<div id="name-ask" class="hidden">
<label for="name-input">Dein Name für die Rangliste:</label>
<div class="name-row">
<input id="name-input" maxlength="20" placeholder="Name" autocomplete="off" />
<button id="save-score" class="big-btn small">OK</button>
</div>
</div>
<!-- Treffer/Daneben-Feedback -->
<div id="toast"></div>
</main>
<div class="board tight">
<h2>🏆 Rangliste</h2>
<ol id="go-leaderboard" class="leaderboard"></ol>
</div>
<!-- Gewinn-Overlay -->
<div id="win-overlay" class="hidden">
<div class="win-card">
<div class="win-emoji">🦶🎉</div>
<h2>Alle Füße gefunden!</h2>
<button id="next-scene">Weiter →</button>
<div class="go-actions">
<button id="play-again" class="big-btn">▶ Nochmal</button>
<button id="to-home" class="link-btn">Startseite</button>
</div>
</div>
</div>
</div>
<script src="/vendor/panzoom.min.js"></script>
<script type="module" src="/js/app.js"></script>

View File

@@ -1,106 +1,428 @@
// App-Flow: beim Laden sofort eine zufaellige Szene holen (kein Start-Screen, keine
// Auswahl). Alle 3 Fuesse finden -> Gewinn-Overlay -> "Weiter" laedt die naechste.
// 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.
import { Scene } from './scene.js';
import { Legend } from './legend.js';
const $ = (id) => document.getElementById(id);
const stage = document.getElementById('stage');
const world = document.getElementById('world');
const sceneImg = document.getElementById('scene-img');
const loading = document.getElementById('loading');
const winOverlay = document.getElementById('win-overlay');
const toast = document.getElementById('toast');
// ---- 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';
const scene = new Scene(stage, world, sceneImg);
const legend = new Legend(
document.getElementById('legend-items'),
document.getElementById('legend-counter')
);
// ---- Zustand ----
let FEET = []; // [{id,name,url}]
let PROPS = []; // [{id,name,url}]
let state = null;
let rafId = null;
let lastT = 0;
let current = null; // aktuelles Szenen-Payload
let busy = false;
function showLoading(on) {
loading.classList.toggle('hidden', !on);
}
function showToast(message) {
toast.textContent = message;
toast.classList.add('show');
clearTimeout(showToast._t);
showToast._t = setTimeout(() => toast.classList.remove('show'), 900);
}
function showError(err) {
let box = document.getElementById('error-box');
if (!box) {
box = document.createElement('div');
box.id = 'error-box';
stage.appendChild(box);
// ---- 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]];
}
const msg = err && err.message ? err.message : String(err);
box.textContent = 'Fehler beim Laden der Szene: ' + msg;
box.classList.remove('hidden');
return a;
}
function clearError() {
const box = document.getElementById('error-box');
if (box) box.classList.add('hidden');
// 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) };
}
async function loadScene() {
showLoading(true);
clearError();
winOverlay.classList.add('hidden');
// ---- Rangliste ----
async function fetchLeaderboard() {
try {
const res = await fetch('/api/scene');
if (!res.ok) throw new Error('Server-Antwort ' + res.status);
current = await res.json();
await scene.render(current);
scene.resetView();
legend.render(current.targets);
document.getElementById('category-name').textContent = current.category || '';
} catch (err) {
console.error(err);
showError(err);
} finally {
showLoading(false);
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;
});
}
}
async function handleTap({ x, y }) {
if (busy || !current) return;
busy = true;
// 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/scene/${current.sceneId}/guess`, {
const res = await fetch('/api/score', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ x, y }),
body: JSON.stringify({ name, score }),
});
const data = await res.json();
if (data.hit) {
const isNew = legend.markFound(data.hit);
scene.pingAt(x, y, true);
if (isNew && legend.allFound()) {
setTimeout(() => winOverlay.classList.remove('hidden'), 400);
}
} else {
scene.pingAt(x, y, false);
}
} catch (err) {
console.error(err);
} finally {
busy = false;
}
if (res.ok) return await res.json();
} catch {}
return await fetchLeaderboard();
}
scene.onTap(handleTap);
// ---- Setup / Events ----
function goHome() {
$('gameover').classList.add('hidden');
$('game').classList.add('hidden');
$('home').classList.remove('hidden');
refreshHomeBoard();
}
async function refreshHomeBoard() {
renderLeaderboard($('leaderboard'), await fetchLeaderboard());
}
// Steuerung
document.getElementById('zoom-in').addEventListener('click', () => scene.zoomIn());
document.getElementById('zoom-out').addEventListener('click', () => scene.zoomOut());
document.getElementById('reset-view').addEventListener('click', () => scene.resetView());
document.getElementById('next-scene').addEventListener('click', loadScene);
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.';
}
// Los geht's — sofort.
loadScene();
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();

View File

@@ -1,70 +0,0 @@
// Die "Gesucht"-Leiste oben: zeigt die 3 zu findenden Fuesse und hakt gefundene ab.
const EMOJI_BASE = '/emoji/';
export class Legend {
constructor(container, counterEl) {
this.container = container;
this.counterEl = counterEl;
this.found = new Set();
this.total = 0;
}
render(targets) {
this.found.clear();
this.total = targets.length;
this.container.innerHTML = '';
for (const t of targets) {
const card = document.createElement('div');
card.className = 'legend-item';
card.dataset.id = t.id;
const thumb = document.createElement('div');
thumb.className = 'legend-thumb';
if (t.assetUrl) {
const img = document.createElement('img');
img.src = t.assetUrl;
img.alt = t.name || 'Fuss';
thumb.appendChild(img);
} else if (t.glyph) {
// Emoji: Bild aus Set, sonst Textzeichen
const img = document.createElement('img');
img.alt = t.name || 'Fuss';
img.onerror = () => {
const span = document.createElement('span');
span.className = 'legend-emoji';
span.textContent = t.glyph;
img.replaceWith(span);
};
img.src = EMOJI_BASE + t.codepoint + '.png';
thumb.appendChild(img);
}
card.appendChild(thumb);
this.container.appendChild(card);
}
this._updateCounter();
}
markFound(id) {
if (this.found.has(id)) return false;
this.found.add(id);
const card = this.container.querySelector(`.legend-item[data-id="${id}"]`);
if (card) card.classList.add('found');
this._updateCounter();
return true;
}
allFound() {
return this.found.size >= this.total && this.total > 0;
}
_updateCounter() {
if (this.counterEl) {
this.counterEl.textContent = `${this.found.size} / ${this.total}`;
}
}
}

View File

@@ -1,287 +0,0 @@
// Rendert eine Szene und liefert das "Google-Maps-Gefuehl" via panzoom.
//
// Die komplette Szene wird EINMAL in ein Bild (Canvas -> <img>) gerendert; panzoom
// bewegt/zoomt dann nur dieses eine Bild -> butterweich, egal wie viele Items.
// Aufbau bewusst als reines DOM (div#world > img) statt SVG-viewBox: so stimmt der
// Pinch-Fokuspunkt auf Touch, und die Szene fuellt den Schirm (Cover), statt in
// schwarzen Balken zu ertrinken.
const EMOJI_BASE = '/emoji/';
const TAP_THRESHOLD_PX = 12; // Bewegung darueber = Ziehen, kein Tippen
const TAP_MAX_MS = 500; // laenger gedrueckt = kein Tippen
const RENDER_SCALE = 2; // Aufloesung des Szenen-Bitmaps (Schaerfe beim Zoomen)
const EMOJI_FONT = '"Apple Color Emoji","Segoe UI Emoji","Noto Color Emoji",sans-serif';
let emojiAssetsAvailable = null; // pro Session: liegt ein Emoji-Bildset vor?
function loadImage(url) {
return new Promise((resolve) => {
const img = new Image();
// Kein crossOrigin: Assets sind same-origin. crossOrigin kann je nach Browser/Proxy
// dazu fuehren, dass das Canvas als "tainted" gilt und der Export fehlschlaegt.
img.onload = () => resolve({ url, img });
img.onerror = () => resolve({ url, img: null });
img.src = url;
});
}
export class Scene {
constructor(stage, world, img) {
this.stage = stage;
this.world = world;
this.img = img;
this.pz = null;
this.tapHandler = null;
this._objectUrl = null;
this._imgCache = new Map();
this._w = 2000;
this._h = 1400;
this._cover = 1;
// Tippen ueber Pointer-Events (funktioniert auch auf Touch, wo panzoom das
// synthetische click-Event unterdrueckt). Pinch/Mehrfinger zaehlt nie als Tipp.
this._pointers = new Map();
this._multiTouch = false;
this.stage.addEventListener('pointerdown', (e) => {
this._pointers.set(e.pointerId, { x: e.clientX, y: e.clientY, t: performance.now() });
if (this._pointers.size > 1) this._multiTouch = true;
});
this.stage.addEventListener('pointerup', (e) => {
const down = this._pointers.get(e.pointerId);
this._pointers.delete(e.pointerId);
const wasMulti = this._multiTouch;
if (this._pointers.size === 0) this._multiTouch = false;
if (!down || wasMulti) return;
const moved = Math.hypot(e.clientX - down.x, e.clientY - down.y);
const dt = performance.now() - down.t;
if (moved > TAP_THRESHOLD_PX || dt > TAP_MAX_MS) return; // war Ziehen/Halten
this._emitTap(e.clientX, e.clientY);
});
const forget = (e) => {
this._pointers.delete(e.pointerId);
if (this._pointers.size === 0) this._multiTouch = false;
};
this.stage.addEventListener('pointercancel', forget);
// Bei Groessen-/Orientierungswechsel die Cover-Ansicht neu einpassen.
window.addEventListener('resize', () => {
if (this.pz) this._applyCover();
});
}
onTap(handler) {
this.tapHandler = handler;
}
_emitTap(clientX, clientY) {
if (!this.tapHandler || !this.pz) return;
const r = this.stage.getBoundingClientRect();
const t = this.pz.getTransform();
const x = (clientX - r.left - t.x) / t.scale;
const y = (clientY - r.top - t.y) / t.scale;
this.tapHandler({ x, y });
}
async render(payload) {
if (this.pz) {
this.pz.dispose();
this.pz = null;
}
this._w = payload.width;
this._h = payload.height;
if (payload.type === 'emoji' && emojiAssetsAvailable === null) {
const sample = payload.items.find((i) => i.kind === 'emoji');
emojiAssetsAvailable = sample
? (await loadImage(EMOJI_BASE + sample.codepoint + '.png')).img !== null
: false;
}
await this._preloadAssets(payload);
const url = await this._rasterize(payload);
if (this._objectUrl) URL.revokeObjectURL(this._objectUrl);
this._objectUrl = url;
// Bild sicher laden, bevor wir einpassen (getBoundingClientRect etc.)
await new Promise((res) => {
this.img.onload = res;
this.img.onerror = res;
this.img.src = url;
});
this.world.style.width = this._w + 'px';
this.world.style.height = this._h + 'px';
this._initPanzoom();
}
async _preloadAssets(payload) {
const urls = new Set();
for (const it of payload.items) {
if (it.kind === 'image') urls.add(it.assetUrl);
else if (it.kind === 'emoji' && emojiAssetsAvailable) urls.add(EMOJI_BASE + it.codepoint + '.png');
}
const missing = [...urls].filter((u) => !this._imgCache.has(u));
const loaded = await Promise.all(missing.map(loadImage));
for (const { url, img } of loaded) this._imgCache.set(url, img);
}
async _rasterize(payload) {
const canvas = document.createElement('canvas');
canvas.width = payload.width * RENDER_SCALE;
canvas.height = payload.height * RENDER_SCALE;
const ctx = canvas.getContext('2d');
ctx.scale(RENDER_SCALE, RENDER_SCALE);
// Basisfarbe + dezenter Tiefen-Verlauf (oben heller, unten leichter Schatten)
ctx.fillStyle = payload.background || '#ffffff';
ctx.fillRect(0, 0, payload.width, payload.height);
const grad = ctx.createLinearGradient(0, 0, 0, payload.height);
grad.addColorStop(0, 'rgba(255,255,255,0.22)');
grad.addColorStop(0.5, 'rgba(255,255,255,0)');
grad.addColorStop(1, 'rgba(0,0,0,0.14)');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, payload.width, payload.height);
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
for (const item of payload.items) {
ctx.save();
ctx.translate(item.cx, item.cy);
ctx.rotate((item.rot * Math.PI) / 180);
this._drawItem(ctx, item);
ctx.restore();
}
// Export als Blob-URL; mit Fallback auf dataURL, falls toBlob nicht greift.
try {
const blob = await new Promise((resolve, reject) => {
try {
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob lieferte null'))), 'image/png');
} catch (err) {
reject(err);
}
});
return URL.createObjectURL(blob);
} catch (err) {
console.warn('[scene] toBlob fehlgeschlagen, versuche toDataURL:', err);
return canvas.toDataURL('image/png'); // wirft nur, wenn Canvas wirklich "tainted" ist
}
}
_drawItem(ctx, item) {
const size = item.size;
if (item.kind === 'emoji' && !emojiAssetsAvailable) {
ctx.font = `${size}px ${EMOJI_FONT}`;
ctx.fillText(item.glyph, 0, 0);
return;
}
const url = item.kind === 'image' ? item.assetUrl : EMOJI_BASE + item.codepoint + '.png';
const img = this._imgCache.get(url);
if (!img) {
if (item.kind === 'emoji') {
ctx.font = `${size}px ${EMOJI_FONT}`;
ctx.fillText(item.glyph, 0, 0);
}
return;
}
const isSvg = url.toLowerCase().endsWith('.svg');
let dw = size;
let dh = size;
if (!isSvg && img.naturalWidth && img.naturalHeight) {
const s = Math.min(size / img.naturalWidth, size / img.naturalHeight);
dw = img.naturalWidth * s;
dh = img.naturalHeight * s;
}
ctx.drawImage(img, -dw / 2, -dh / 2, dw, dh);
}
_coverScale() {
const r = this.stage.getBoundingClientRect();
return Math.max(r.width / this._w, r.height / this._h);
}
_initPanzoom() {
this._cover = this._coverScale();
if (typeof panzoom === 'function') {
this.pz = panzoom(this.world, {
maxZoom: this._cover * 9,
minZoom: this._cover, // nicht weiter raus als "Cover"
smoothScroll: true,
zoomDoubleClickSpeed: 1,
});
// Eigene Cover-Klemmung: das Bild deckt den Schirm immer voll (kein Leerraum).
this._clamping = false;
this.pz.on('transform', () => this._clampToCover());
}
this._applyCover();
}
// Szene bildschirmfuellend zentrieren (Cover).
_applyCover() {
if (!this.pz) return;
const r = this.stage.getBoundingClientRect();
this._cover = this._coverScale();
this.pz.zoomAbs(0, 0, this._cover);
const tx = (r.width - this._w * this._cover) / 2;
const ty = (r.height - this._h * this._cover) / 2;
this.pz.moveTo(tx, ty);
}
// Haelt die Translation so, dass das Bild den Viewport immer vollstaendig deckt.
_clampToCover() {
if (!this.pz || this._clamping) return;
const r = this.stage.getBoundingClientRect();
const t = this.pz.getTransform();
const w = this._w * t.scale;
const h = this._h * t.scale;
let x = t.x;
let y = t.y;
x = w >= r.width ? Math.min(0, Math.max(r.width - w, x)) : (r.width - w) / 2;
y = h >= r.height ? Math.min(0, Math.max(r.height - h, y)) : (r.height - h) / 2;
if (Math.abs(x - t.x) > 0.5 || Math.abs(y - t.y) > 0.5) {
this._clamping = true; // Rekursion durch das erneute transform-Event vermeiden
this.pz.moveTo(x, y);
this._clamping = false;
}
}
_stageCenterClient() {
const r = this.stage.getBoundingClientRect();
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
}
zoomIn() {
if (!this.pz) return;
const c = this._stageCenterClient();
this.pz.smoothZoom(c.x, c.y, 1.6);
}
zoomOut() {
if (!this.pz) return;
const c = this._stageCenterClient();
this.pz.smoothZoom(c.x, c.y, 0.625);
}
resetView() {
this._applyCover();
}
// kurze Treffer-/Daneben-Animation an einer Szenen-Position (Welt-Pixel)
pingAt(x, y, ok) {
const ping = document.createElement('div');
ping.className = 'ping ' + (ok ? 'ping-ok' : 'ping-miss');
ping.style.left = x + 'px';
ping.style.top = y + 'px';
this.world.appendChild(ping);
setTimeout(() => ping.remove(), 650);
}
}