diff --git a/.gitignore b/.gitignore index 4320f48..bdc5a4b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ npm-debug.log .DS_Store server.pid server.log +data/ diff --git a/README.md b/README.md index fa3819e..da73459 100644 --- a/README.md +++ b/README.md @@ -1,98 +1,73 @@ -# fuesse.sexy — „Where's the Feet" 🦶 +# fuesse.sexy — „Wanted!" 🦶 -Ein Wimmel-Suchspiel im Stil von „Wo ist Waldo?" — nur werden **Füße** in einem -detailreichen Wimmelbild gesucht. Pro Szene sind **3 verschiedene Füße** versteckt; -finde alle drei, dann kommt die nächste zufällige Szene. +Ein kleines Arcade-Spiel nach dem Vorbild des **„Wanted!"-Minispiels aus New Super Mario +Bros.**: Oben ein Fahndungsplakat mit *einem* gesuchten Fuß, unten wuseln viele Füße (in +verschiedenen Farben) + etwas Deko herum. **Schnapp dir den gesuchten Fuß, bevor die Zeit +abläuft** — dann kommt sofort ein neues Plakat mit einer anderen Farbe. -- **Kein Start-Screen, keine Auswahl** — Seite öffnen und sofort losspielen. -- **Google-Maps-Gefühl:** ziehen, Mausrad-Zoom am PC, Zwei-Finger-Pinch am Handy. -- **Prozedural generiert:** Der Server würfelt jede Szene aus einer gemeinsamen - **Library** neu zusammen und prüft Klick-Treffer serverseitig (nicht durch „im Code - spicken" austrickbar). -- **Library statt Level:** Normale Szenen ziehen Deko + Füße aus `library/`. Themen sind - unsichtbar/zufällig gemischt. **Spezial-Level** (z.B. Emoji-Chaos) sind die Ausnahme. +- **Zeitdruck:** Countdown; jeder Treffer gibt Zeit dazu, Fehlklick zieht Zeit ab. Bei 0 → Game-Over. +- **Wird schwerer:** erst ordentliches **Raster**, dann **wild verteilt**, dann **Bewegung** + (erst in 2–3 Gruppen, später einzeln) — und je weiter, desto **schneller** und **mehr** Füße. +- **Gesucht ist immer ein Fuß, aber die Farbe wechselt jede Runde** — nicht auf Autopilot klicken! +- **Rangliste:** die Top 10 auf der Startseite, serverseitig gespeichert. Nach dem Spiel + wird **einmal** nach dem Namen gefragt (danach gecacht). ## Installation ```sh -./install.sh # prüft Node, installiert Abhängigkeiten -npm start # startet den Server +./install.sh ``` -Dann im Browser: **http://localhost:3000** +Das Skript installiert alles Nötige, startet den Server **im Hintergrund** und zeigt die +URL. Dann im Browser öffnen: **http://:3000** (lokal: http://localhost:3000). -(Alternativ: `npm run setup` statt `./install.sh`.) +Manuell: `npm install && npm start`. -## Die Library erweitern (der Normalfall) +## Die Füße & Deko erweitern -Normale Szenen ziehen ihre Bildchen aus der gemeinsamen Library: +Alle Bildchen liegen in `library/`: ``` library/ - props/ ← Deko-Bildchen (alles, was KEIN Fuß ist: Ball, Muschel, Krabbe …) - feet/ ← Fuß-Bildchen. Der Server wählt pro Szene 3 als Ziele aus, - die übrigen streut er als Ablenker mit ein. + feet/ ← die Füße (= die gesuchten Figuren). Farbe = Dateiname. + props/ ← Deko ("andere Bildchen") als Zusatz-Ablenker. ``` -Zum Erweitern einfach neue Bildchen in den passenden Ordner legen und **Server neu -starten** — fertig. Kein Code, kein Level. +Neue Bildchen einfach in den passenden Ordner legen und **Server neu starten** — fertig. +Formate: `.svg`, `.png`, `.webp`, `.jpg`. Für Füße gilt: **mind. 2** in `library/feet/` +(mehr = mehr Farb-Vielfalt). Am besten freigestellt (transparenter Hintergrund), grob +quadratisch. -- **Deko** (kein Fuß) → `library/props/` -- **Füße** (zum Suchen) → `library/feet/` · **mind. 3** müssen vorhanden sein -- **Formate:** `.svg`, `.png`, `.webp`, `.jpg` -- **Tipp:** freigestellte Bildchen mit **transparentem Hintergrund**, grob quadratisch — - dann sitzt die Trefferzone gut. Der Dateiname wird zum Namen in der „Gesucht"-Leiste. +### Woher Bildchen nehmen? +openclipart.org (CC0), svgrepo.com (CC0-Filter), kenney.nl (CC0), pixabay.com. Bei +Flaticon/Freepik Namensnennung beachten. -## Spezial-Level (die Ausnahme) +## Feintuning -Ein Spezial-Level ist ein eigenständiger Ordner unter `scenes/` mit eigener `config.json`. -Es taucht gelegentlich zwischen den normalen Library-Szenen auf. +In `public/js/app.js` oben: +- `START_TIME`, `MAX_TIME`, `BONUS`, `PENALTY` — Timer/Balance. +- `difficulty(score)` — Anzahl, Bewegungs-Stufen (`grid`/`scatter`/`groups`/`individual`) + und Tempo je Score. -- `emoji` — Emojis statt Bilddateien; man sucht den Fuß 🦶 im Emoji-Gewimmel. - Siehe `scenes/emoji-chaos/config.json`. Einheitlicher Look: `public/emoji/README.md`. -- `photo` — freigestellte Fotos statt Zeichnungen (eigene `props/` + `targets/`). - Vorlage: `scenes/strand-foto/README.md`. +## Rangliste -Wie oft normale Library-Szenen im Vergleich zu Spezial-Leveln kommen, steuert -`LIBRARY_WEIGHT` in `server/index.js`. - -## Woher bekomme ich Bildchen? (Gratis-Quellen) - -**Clipart (SVG/PNG):** -- **openclipart.org** — gemeinfrei (CC0), riesig. *Sicherste Quelle.* -- **svgrepo.com** — nach Lizenz „CC0 / Public Domain" filtern. -- **kenney.nl** — hochwertige Asset-Packs, CC0. -- **pixabay.com** — Vektoren + freigestellte PNGs (kommerziell ok). -- **flaticon.com / freepik.com** — riesig, aber Gratis-Tier braucht Namensnennung. -- **game-icons.net** — 4000+ SVG-Icons (CC-BY). - -**Fotos (für Foto-Level, danach freistellen):** -- **pexels.com**, **unsplash.com**, **pixabay.com** (kostenlos, kommerziell nutzbar) -- Freistellen: **remove.bg** (1-Klick) oder **photopea.com** (Gratis-Editor) - -**Emoji:** OpenMoji / Twemoji / Noto — siehe `public/emoji/README.md`. - -> Achte pro Datei auf die jeweilige Lizenz. Am sorgenfreisten sind openclipart, SVGRepo -> (CC0-Filter), Kenney und Pixabay. +- Gespeichert in `data/leaderboard.json` (wird automatisch angelegt, überlebt Neustarts). +- Endpoints: `GET /api/leaderboard` (Top 10), `POST /api/score` `{name, score}`. +- Namen werden beim Anzeigen escaped (kein XSS). Kein Anti-Cheat — Spaßspiel. ## Deployment (Ausblick) -Das Spiel ist ein normaler Node-Server. Zum Livestellen auf **fuesse.sexy**: - -1. Auf einen Node-Host bringen (z.B. **Railway**, **Render**, oder ein **VPS**). -2. `npm install` und `npm start` (Standard-Port 3000; via `PORT`-Env änderbar). -3. Die Domain **fuesse.sexy** per DNS/Reverse-Proxy auf den Server zeigen (HTTPS via - dem Host oder z.B. Caddy/Nginx + Let's Encrypt). +Normaler Node-Server. Auf einen Node-Host bringen (VPS/LXC/Railway/Render), `./install.sh`, +Domain **fuesse.sexy** per Reverse-Proxy (z.B. Nginx Proxy Manager) darauf zeigen. Für +HTTPS hinter Cloudflare: DNS-Challenge (Cloudflare-Token) im Proxy nutzen. ## Projektstruktur ``` -server/ Express-Server + Szenen-Generator (die "Wimmelbild-Fabrik") -public/ Frontend (Vanilla JS + SVG, Szene wird als 1 Bild gerendert) - emoji/ optionales Emoji-Bildset -library/ die Fundgrube für normale Szenen - props/ Deko (kein Fuß) - feet/ Fuß-Bildchen (Ziele + Ablenker) -scenes/ Spezial-Level (Ausnahmen: emoji, photo) -install.sh Ein-Befehl-Setup +server/index.js schlanker Express-Server (Bildchen-Liste + Rangliste) +public/ Frontend (Startseite, Spiel, Game-Over) — Vanilla JS +library/feet/ die Füße (gesuchte Figuren) +library/props/ Deko-Ablenker +data/ Rangliste (leaderboard.json, zur Laufzeit) +install.sh Ein-Befehl-Setup + detached Start ``` diff --git a/library/feet/blauer-fuss.svg b/library/feet/blau.svg similarity index 86% rename from library/feet/blauer-fuss.svg rename to library/feet/blau.svg index ba9402b..4d6d8f1 100644 --- a/library/feet/blauer-fuss.svg +++ b/library/feet/blau.svg @@ -5,5 +5,4 @@ - - + diff --git a/library/feet/brauner-fuss.svg b/library/feet/brauner-fuss.svg deleted file mode 100644 index 471a491..0000000 --- a/library/feet/brauner-fuss.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/library/feet/dunkler-fuss.svg b/library/feet/dunkler-fuss.svg deleted file mode 100644 index 74f3f3f..0000000 --- a/library/feet/dunkler-fuss.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/library/feet/flipflop-fuss.svg b/library/feet/flipflop-fuss.svg deleted file mode 100644 index e82c4d6..0000000 --- a/library/feet/flipflop-fuss.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/library/feet/gelb.svg b/library/feet/gelb.svg new file mode 100644 index 0000000..291a9ea --- /dev/null +++ b/library/feet/gelb.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/library/feet/gruen.svg b/library/feet/gruen.svg new file mode 100644 index 0000000..62d05da --- /dev/null +++ b/library/feet/gruen.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/library/feet/gruener-fuss.svg b/library/feet/gruener-fuss.svg deleted file mode 100644 index 30059f4..0000000 --- a/library/feet/gruener-fuss.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/library/feet/heller-fuss.svg b/library/feet/heller-fuss.svg deleted file mode 100644 index f272db3..0000000 --- a/library/feet/heller-fuss.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/library/feet/lila-fuss.svg b/library/feet/lila-fuss.svg deleted file mode 100644 index e18ff1f..0000000 --- a/library/feet/lila-fuss.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/library/feet/lila.svg b/library/feet/lila.svg new file mode 100644 index 0000000..081c930 --- /dev/null +++ b/library/feet/lila.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/library/feet/orange.svg b/library/feet/orange.svg new file mode 100644 index 0000000..da20c80 --- /dev/null +++ b/library/feet/orange.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/library/feet/pink.svg b/library/feet/pink.svg new file mode 100644 index 0000000..58a0887 --- /dev/null +++ b/library/feet/pink.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/library/feet/roter-fuss.svg b/library/feet/rot.svg similarity index 86% rename from library/feet/roter-fuss.svg rename to library/feet/rot.svg index dc55ae3..f295011 100644 --- a/library/feet/roter-fuss.svg +++ b/library/feet/rot.svg @@ -5,5 +5,4 @@ - - + diff --git a/library/feet/tuerkis.svg b/library/feet/tuerkis.svg new file mode 100644 index 0000000..cabbe90 --- /dev/null +++ b/library/feet/tuerkis.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/package-lock.json b/package-lock.json index 84fcf05..ea523a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "dependencies": { "express": "^4.19.2", + "image-size": "^1.2.1", "panzoom": "^9.4.3" } }, @@ -442,6 +443,21 @@ "node": ">=0.10.0" } }, + "node_modules/image-size": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz", + "integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==", + "license": "MIT", + "dependencies": { + "queue": "6.0.2" + }, + "bin": { + "image-size": "bin/image-size.js" + }, + "engines": { + "node": ">=16.x" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -626,6 +642,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/queue": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", + "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==", + "license": "MIT", + "dependencies": { + "inherits": "~2.0.3" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", diff --git a/package.json b/package.json index 53f538c..63cecc8 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,14 @@ { "name": "fuesse-sexy", "version": "1.0.0", - "description": "Where's the Feet — ein Wimmel-Suchspiel: finde die versteckten Fuesse.", + "description": "fuesse.sexy — 'Wanted!' Minigame: schnapp den gesuchten Fuss.", "type": "module", "scripts": { "start": "node server/index.js", - "setup": "sh install.sh" + "setup": "sh install.sh", + "reset-stats": "node scripts/reset-leaderboard.mjs" }, "dependencies": { - "express": "^4.19.2", - "panzoom": "^9.4.3" + "express": "^4.19.2" } } diff --git a/public/css/style.css b/public/css/style.css index 160e39d..b40de83 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -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; } } diff --git a/public/index.html b/public/index.html index 8e38e31..ce19b38 100644 --- a/public/index.html +++ b/public/index.html @@ -1,53 +1,70 @@ -fuesse.sexy — finde die Füße +fuesse.sexy — WANTED! -
- -
-
- fuesse.sexy - Finde diese Füße: +
+ +
+
+

fuesse.sexy

+

WANTED! — schnapp dir den gesuchten Fuß, bevor die Zeit abläuft.

+ + +
+

🏆 Rangliste

+
    +
    + +
    -
    -
    0 / 3
    -
    + - -
    -
    - Wimmelbild -
    + + - -
    - - - -
    + +
    +
    +

    🏆 Rangliste

    +
      +
      - -
      - diff --git a/public/js/app.js b/public/js/app.js index 471cb92..154dfda 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -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(); diff --git a/public/js/legend.js b/public/js/legend.js deleted file mode 100644 index 98055d3..0000000 --- a/public/js/legend.js +++ /dev/null @@ -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}`; - } - } -} diff --git a/public/js/scene.js b/public/js/scene.js deleted file mode 100644 index 11c9530..0000000 --- a/public/js/scene.js +++ /dev/null @@ -1,287 +0,0 @@ -// Rendert eine Szene und liefert das "Google-Maps-Gefuehl" via panzoom. -// -// Die komplette Szene wird EINMAL in ein Bild (Canvas -> ) 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); - } -} diff --git a/scripts/reset-leaderboard.mjs b/scripts/reset-leaderboard.mjs new file mode 100644 index 0000000..62bdb59 --- /dev/null +++ b/scripts/reset-leaderboard.mjs @@ -0,0 +1,15 @@ +// Setzt die Rangliste zurück (leert data/leaderboard.json). +// Aufruf: npm run reset-stats +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { writeFileSync, mkdirSync, existsSync } from 'node:fs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const DATA_DIR = join(__dirname, '..', 'data'); +const FILE = join(DATA_DIR, 'leaderboard.json'); + +if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true }); +writeFileSync(FILE, '[]\n'); + +console.log('✅ Rangliste zurückgesetzt (data/leaderboard.json geleert).'); +console.log(' Wirkt sofort — kein Server-Neustart nötig.'); diff --git a/server/index.js b/server/index.js index e832642..22a7636 100644 --- a/server/index.js +++ b/server/index.js @@ -1,107 +1,102 @@ -// fuesse.sexy — "Where's the Feet" -// Express-Server: liefert das Frontend aus, scannt die Szenen-Ordner beim Start, -// generiert auf Anfrage eine zufaellige Szene und prueft Klick-Treffer serverseitig. +// fuesse.sexy — "Wanted!" (Fuss-Fahndung) +// Schlanker Express-Server: liefert das Frontend + die Bildchen aus, stellt die Liste +// der verfuegbaren Fuesse/Deko bereit und verwaltet eine persistente Rangliste. import express from 'express'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { existsSync } from 'node:fs'; - -import { scanLibrary, scanScenes } from './sceneScanner.js'; -import { generateScene } from './sceneGenerator.js'; -import { getScene } from './sceneStore.js'; +import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = join(__dirname, '..'); -const SCENES_DIR = join(ROOT, 'scenes'); -const LIBRARY_DIR = join(ROOT, 'library'); const PUBLIC_DIR = join(ROOT, 'public'); -const PANZOOM_DIST = join(ROOT, 'node_modules', 'panzoom', 'dist'); +const LIBRARY_DIR = join(ROOT, 'library'); +const DATA_DIR = join(ROOT, 'data'); +const LEADERBOARD_FILE = join(DATA_DIR, 'leaderboard.json'); const PORT = process.env.PORT || 3000; +const IMAGE_EXT = ['.svg', '.png', '.webp', '.jpg', '.jpeg', '.gif']; -// Wie oft die normale Library-Szene im Vergleich zu einem Spezial-Level drankommt. -const LIBRARY_WEIGHT = 3; - -// Beim Start: Library (Normalfall) + Spezial-Level (Ausnahmen) einlesen und zu einem -// gewichteten Zufalls-Pool zusammenbauen. -const library = scanLibrary(LIBRARY_DIR); -const specials = scanScenes(SCENES_DIR); - -const scenePool = []; -if (library) { - const libCategory = { - id: 'library', - name: '', - type: 'clipart', - isLibrary: true, - background: null, // pro Szene zufaellig - minSize: 68, - maxSize: 128, - cellFactor: 0.82, - props: library.props, - targets: library.feet, - }; - for (let i = 0; i < LIBRARY_WEIGHT; i++) scenePool.push(libCategory); +// ---- Bildchen aus der Library einlesen ---- +function isImage(f) { + return IMAGE_EXT.some((ext) => f.toLowerCase().endsWith(ext)); +} +function prettyName(file) { + return file + .replace(/\.[^.]+$/, '') + .replace(/[-_]+/g, ' ') + .replace(/\b\w/g, (c) => c.toUpperCase()); +} +function listImages(dir, urlPrefix) { + if (!existsSync(dir)) return []; + return readdirSync(dir) + .filter(isImage) + .sort() + .map((f) => ({ id: f.replace(/\.[^.]+$/, ''), name: prettyName(f), url: `${urlPrefix}/${f}` })); } -for (const s of specials) scenePool.push(s); -if (scenePool.length === 0) { - console.warn('[server] Keine spielbaren Szenen — fuelle library/ oder scenes/.'); +const feet = listImages(join(LIBRARY_DIR, 'feet'), '/library/feet'); +const props = listImages(join(LIBRARY_DIR, 'props'), '/library/props'); + +if (feet.length < 2) { + console.warn(`[server] Achtung: nur ${feet.length} Fuss/Fuesse in library/feet — brauche mind. 2.`); } else { - console.log( - `[server] bereit: Library=${library ? 'ja' : 'nein'}, Spezial-Level: ${specials.map((c) => c.id).join(', ') || '(keine)'}` - ); + console.log(`[server] ${feet.length} Fuesse, ${props.length} Deko-Bildchen geladen.`); +} + +// ---- Rangliste (persistente JSON-Datei) ---- +function readLeaderboard() { + try { + if (!existsSync(LEADERBOARD_FILE)) return []; + const data = JSON.parse(readFileSync(LEADERBOARD_FILE, 'utf8')); + return Array.isArray(data) ? data : []; + } catch (err) { + console.warn('[server] Rangliste unlesbar:', err.message); + return []; + } +} +function writeLeaderboard(list) { + if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true }); + writeFileSync(LEADERBOARD_FILE, JSON.stringify(list, null, 2)); +} +function topN(list, n) { + return [...list].sort((a, b) => b.score - a.score).slice(0, n); } const app = express(); app.use(express.json()); -// Statisches Frontend + Assets. -// no-cache fuers Frontend (HTML/JS/CSS): Browser/Proxy muss revalidieren -> nie altes -// Skript nach einem Update (haeufige Ursache fuer "geht gar nix" nach git pull). -app.use( - express.static(PUBLIC_DIR, { - setHeaders: (res) => res.setHeader('Cache-Control', 'no-cache'), - }) -); -app.use('/scenes', express.static(SCENES_DIR)); +// Statisches Frontend + Bildchen (no-cache -> nie veraltetes JS nach Update) +app.use(express.static(PUBLIC_DIR, { setHeaders: (res) => res.setHeader('Cache-Control', 'no-cache') })); app.use('/library', express.static(LIBRARY_DIR)); -if (existsSync(PANZOOM_DIST)) { - app.use('/vendor', express.static(PANZOOM_DIST)); -} -// Eine zufaellige, frisch gemischte Szene liefern (meist Library, selten Spezial-Level). -app.get('/api/scene', (req, res) => { - if (scenePool.length === 0) { - return res.status(503).json({ error: 'Noch keine Szenen vorhanden.' }); - } - const cat = scenePool[Math.floor(Math.random() * scenePool.length)]; - const payload = generateScene(cat); - res.json(payload); +// Verfuegbare Bildchen +app.get('/api/feet', (req, res) => { + res.json({ feet, props }); }); -// Klick pruefen: liegt (x, y) in einer der Ziel-Trefferzonen dieser Szene? -app.post('/api/scene/:sceneId/guess', (req, res) => { - const entry = getScene(req.params.sceneId); - if (!entry) { - return res.status(404).json({ error: 'Szene unbekannt oder abgelaufen.' }); - } - const { x, y } = req.body || {}; - if (typeof x !== 'number' || typeof y !== 'number') { - return res.status(400).json({ error: 'x und y muessen Zahlen sein.' }); - } +// Rangliste: Top 10 +app.get('/api/leaderboard', (req, res) => { + res.json(topN(readLeaderboard(), 10)); +}); - let hit = null; - for (const t of entry.targets) { - if (Math.hypot(t.cx - x, t.cy - y) <= t.radius) { - hit = t.id; - break; - } +// Score eintragen -> aktualisierte Top 10 zurueck +app.post('/api/score', (req, res) => { + let { name, score } = req.body || {}; + score = Number(score); + if (!Number.isFinite(score) || score < 0) { + return res.status(400).json({ error: 'Ungueltiger Score.' }); } - res.json({ hit }); + name = String(name || 'Anonym').trim().slice(0, 20) || 'Anonym'; + score = Math.min(Math.floor(score), 100000); // grobe Plausibilitaet + + const list = readLeaderboard(); + list.push({ name, score, at: new Date().toISOString() }); + const trimmed = topN(list, 100); // Datei klein halten + writeLeaderboard(trimmed); + res.json(topN(trimmed, 10)); }); app.listen(PORT, () => { - console.log(`[server] fuesse.sexy laeuft auf http://localhost:${PORT}`); + console.log(`[server] fuesse.sexy (Wanted!) laeuft auf http://localhost:${PORT}`); }); diff --git a/server/sceneGenerator.js b/server/sceneGenerator.js deleted file mode 100644 index dc22b2a..0000000 --- a/server/sceneGenerator.js +++ /dev/null @@ -1,213 +0,0 @@ -// Baut aus einer Kategorie ein frisch gemischtes Szenen-Layout: Deko wird zufaellig -// gestreut, dann werden 3 Ziele darueber versteckt. Weil wir hier platzieren, kennen -// wir die Ziel-Trefferzonen exakt -> die Klickpruefung kann serverseitig laufen. - -import { saveScene } from './sceneStore.js'; - -const CANVAS_WIDTH = 2000; -const CANVAS_HEIGHT = 1400; -const TARGET_COUNT = 3; -const EDGE_MARGIN = 60; -const MIN_TARGET_DISTANCE = 220; // Ziele nicht zu dicht beieinander - -// Zufaellige, angenehme Hintergrundfarben fuer normale (nicht-thematische) Szenen. -const PALETTE = [ - '#fbe7b6', '#d7ecff', '#ffe0ec', '#e5f7e0', - '#efe6ff', '#fff2cc', '#dfeff2', '#f4e2d0', -]; - -function rand(min, max) { - return min + Math.random() * (max - min); -} - -function randInt(min, max) { - return Math.floor(rand(min, max + 1)); -} - -function pick(array) { - return array[randInt(0, array.length - 1)]; -} - -function makeSceneId() { - return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; -} - -// 🦶 -> "1f9b6" ; entfernt die Variation-Selector-Bytes (FE0F), joined mit "-". -// Damit passt es zu gaengigen Emoji-Bildsets (Datei je Codepoint-Sequenz). -function toCodepoint(glyph) { - return [...glyph] - .map((ch) => ch.codePointAt(0)) - .filter((cp) => cp !== 0xfe0f) - .map((cp) => cp.toString(16)) - .join('-'); -} - -function randomPosition() { - return { - cx: rand(EDGE_MARGIN, CANVAS_WIDTH - EDGE_MARGIN), - cy: rand(EDGE_MARGIN, CANVAS_HEIGHT - EDGE_MARGIN), - }; -} - -// Jittered Grid: fuellt die ganze Flaeche lueckenlos, wirkt durch Zufalls-Versatz, -// -Groesse und -Drehung aber organisch/chaotisch (echtes "Wimmel"-Gefuehl). -// cellFactor < 1 => Zellen kleiner als die Items => sie ueberlappen => randvoll. -function gridPositions(cellFactor, avgSize) { - const cell = Math.max(16, avgSize * cellFactor); - const cols = Math.ceil(CANVAS_WIDTH / cell); - const rows = Math.ceil(CANVAS_HEIGHT / cell); - const jitter = cell * 0.45; - const out = []; - for (let r = 0; r < rows; r++) { - for (let c = 0; c < cols; c++) { - out.push({ - cx: c * cell + cell / 2 + rand(-jitter, jitter), - cy: r * cell + cell / 2 + rand(-jitter, jitter), - }); - } - } - return out; -} - -// Reihenfolge mischen, damit gleiche Emojis/Props nicht sichtbar "in Reihe" liegen. -function shuffle(arr) { - for (let i = arr.length - 1; i > 0; i--) { - const j = randInt(0, i); - [arr[i], arr[j]] = [arr[j], arr[i]]; - } - return arr; -} - -// Platz fuer ein Ziel finden, das genug Abstand zu bereits gesetzten Zielen haelt. -function findTargetPosition(placedTargets) { - for (let attempt = 0; attempt < 40; attempt++) { - const pos = randomPosition(); - const clear = placedTargets.every((t) => { - const dx = t.cx - pos.cx; - const dy = t.cy - pos.cy; - return Math.hypot(dx, dy) >= MIN_TARGET_DISTANCE; - }); - if (clear) return pos; - } - return randomPosition(); // Notfall: nimm irgendeine Position -} - -function buildImageItem(cat, pos) { - return { - kind: 'image', - assetUrl: pick(cat.props), - cx: pos.cx, - cy: pos.cy, - size: rand(cat.minSize, cat.maxSize), - rot: rand(-14, 14), // aufrecht mit leichter Neigung -> wirkt platziert, nicht hingekippt - }; -} - -function buildEmojiItem(cat, pos) { - const glyph = pick(cat.propGlyphs); - return { - kind: 'emoji', - glyph, - codepoint: toCodepoint(glyph), - cx: pos.cx, - cy: pos.cy, - size: rand(cat.minSize, cat.maxSize), - rot: rand(-18, 18), - }; -} - -// Erzeugt eine Szene fuer die gegebene Kategorie und liefert: -// { payload } -> geht ans Frontend (ohne Kennzeichnung, welche Items Ziele sind) -// und speichert die Trefferzonen serverseitig unter der sceneId. -export function generateScene(cat) { - const sceneId = makeSceneId(); - const isEmoji = cat.type === 'emoji'; - - // 1) Deko lueckenlos ueber ein jittered Grid streuen -> randvolles Wimmelbild - const items = []; - const avgSize = (cat.minSize + cat.maxSize) / 2; - const cellFactor = cat.cellFactor || (isEmoji ? 0.8 : 0.82); - const positions = shuffle(gridPositions(cellFactor, avgSize)); - for (const pos of positions) { - items.push(isEmoji ? buildEmojiItem(cat, pos) : buildImageItem(cat, pos)); - } - - // 2) Ziele auswaehlen - const chosenTargets = []; - if (isEmoji) { - const glyphPool = shuffle([...cat.targetGlyphs]); - for (let i = 0; i < TARGET_COUNT; i++) { - const glyph = glyphPool.length > 0 ? glyphPool.pop() : pick(cat.targetGlyphs); - chosenTargets.push({ glyph, codepoint: toCodepoint(glyph), name: 'Fuss-Emoji' }); - } - } else { - // 3 moeglichst unterschiedliche Ziel-Bilder (Duplikate nur, wenn zu wenige da sind) - const pool = shuffle([...cat.targets]); - for (let i = 0; i < TARGET_COUNT; i++) { - const picked = pool.length > 0 ? pool.pop() : pick(cat.targets); - chosenTargets.push({ url: picked.url, name: picked.name }); - } - } - - // 2b) Ablenker-Fuesse streuen: die NICHT gewaehlten Fuesse (nur normale/Clipart-Level). - // So muss man die 3 richtigen aus der Legende finden, nicht irgendeinen Fuss. - if (!isEmoji && Array.isArray(cat.targets) && cat.targets.length > TARGET_COUNT) { - const chosenUrls = new Set(chosenTargets.map((t) => t.url)); - const decoyPool = cat.targets.filter((t) => !chosenUrls.has(t.url)); - if (decoyPool.length > 0) { - const decoyCount = Math.min(12, decoyPool.length * 2 + 2); - for (let i = 0; i < decoyCount; i++) { - const d = pick(decoyPool); - const pos = randomPosition(); - items.push({ - kind: 'image', - assetUrl: d.url, - cx: pos.cx, - cy: pos.cy, - size: rand((cat.minSize + cat.maxSize) / 2, cat.maxSize), - rot: rand(-16, 16), - }); - } - } - } - - // 3) Ziele platzieren (kommen ans Ende von items -> werden oben gerendert, also sichtbar) - const placedTargets = []; - const legend = []; - chosenTargets.forEach((t, i) => { - const pos = findTargetPosition(placedTargets); - const size = isEmoji - ? rand(cat.minSize, cat.maxSize) - : rand((cat.minSize + cat.maxSize) / 2, cat.maxSize); - const id = `t${i}`; - - placedTargets.push({ id, cx: pos.cx, cy: pos.cy, radius: size * 0.55 }); - - const item = isEmoji - ? { kind: 'emoji', glyph: t.glyph, codepoint: t.codepoint, cx: pos.cx, cy: pos.cy, size, rot: rand(-12, 12) } - : { kind: 'image', assetUrl: t.url, cx: pos.cx, cy: pos.cy, size, rot: rand(-14, 14) }; - items.push(item); - - legend.push( - isEmoji - ? { id, name: t.name, glyph: t.glyph, codepoint: t.codepoint } - : { id, name: t.name, assetUrl: t.url } - ); - }); - - // Trefferzonen nur serverseitig ablegen - saveScene(sceneId, placedTargets); - - const payload = { - sceneId, - type: cat.type, - category: cat.name, - width: CANVAS_WIDTH, - height: CANVAS_HEIGHT, - background: cat.background || pick(PALETTE), - targets: legend, - items, - }; - - return payload; -} diff --git a/server/sceneScanner.js b/server/sceneScanner.js deleted file mode 100644 index 9446c47..0000000 --- a/server/sceneScanner.js +++ /dev/null @@ -1,137 +0,0 @@ -// Liest den scenes/-Ordner ein und stellt die Kategorie-Metadaten bereit. -// Eine "Kategorie" = ein Unterordner von scenes/. Der Scan passiert einmal beim -// Serverstart; ein neuer Ordner wird also erst nach einem Neustart sichtbar. - -import { readdirSync, readFileSync, statSync, existsSync } from 'node:fs'; -import { join } from 'node:path'; - -const IMAGE_EXTENSIONS = ['.svg', '.png', '.webp', '.jpg', '.jpeg', '.gif']; - -function isImage(filename) { - const lower = filename.toLowerCase(); - return IMAGE_EXTENSIONS.some((ext) => lower.endsWith(ext)); -} - -function listImages(dir) { - if (!existsSync(dir)) return []; - return readdirSync(dir) - .filter((name) => isImage(name)) - .sort(); -} - -function readConfig(categoryDir) { - const configPath = join(categoryDir, 'config.json'); - if (!existsSync(configPath)) return {}; - try { - return JSON.parse(readFileSync(configPath, 'utf8')); - } catch (err) { - console.warn(`[scanner] config.json in ${categoryDir} ist ungueltig:`, err.message); - return {}; - } -} - -// Liest die gemeinsame Library (Fundgrube): library/props/ (Deko) + library/feet/ -// (alle Fuesse). Daraus generiert der Server normale, nicht-thematische Szenen. -export function scanLibrary(libraryRoot) { - const propsDir = join(libraryRoot, 'props'); - const feetDir = join(libraryRoot, 'feet'); - - const props = listImages(propsDir).map((f) => `/library/props/${f}`); - const feet = listImages(feetDir).map((f) => ({ - id: f.replace(/\.[^.]+$/, ''), - name: prettyName(f), - url: `/library/feet/${f}`, - })); - - if (feet.length < 3) { - console.warn(`[scanner] Library hat < 3 Fuesse (${feet.length}) — normale Szenen deaktiviert.`); - return null; - } - if (props.length < 1) { - console.warn('[scanner] Library hat keine Deko in props/ — normale Szenen deaktiviert.'); - return null; - } - return { props, feet }; -} - -// Baut die Liste aller SPEZIAL-Level (Unterordner von scenes/). Ungueltige Ordner -// werden mit einer Warnung uebersprungen, statt den Start zu blockieren. -export function scanScenes(scenesRoot) { - if (!existsSync(scenesRoot)) { - console.warn(`[scanner] scenes/-Ordner fehlt: ${scenesRoot}`); - return []; - } - - const categories = []; - - for (const entry of readdirSync(scenesRoot)) { - const categoryDir = join(scenesRoot, entry); - if (!statSync(categoryDir).isDirectory()) continue; - - const config = readConfig(categoryDir); - const type = config.type || 'clipart'; - const name = config.name || entry; - - if (type === 'emoji') { - const targets = Array.isArray(config.targets) ? config.targets : []; - const props = Array.isArray(config.props) ? config.props : []; - if (targets.length < 1 || props.length < 3) { - console.warn(`[scanner] "${entry}" (emoji) uebersprungen: braucht targets + genug props in config.json`); - continue; - } - categories.push({ - id: entry, - name, - type, - background: config.background || '#f4f4f8', - propCount: config.propCount || 250, - minSize: config.minSize || 42, - maxSize: config.maxSize || 78, - targetGlyphs: targets, - propGlyphs: props, - }); - continue; - } - - // clipart / photo: Bilddateien aus props/ und targets/ - const propsDir = join(categoryDir, 'props'); - const targetsDir = join(categoryDir, 'targets'); - const props = listImages(propsDir).map((f) => `/scenes/${entry}/props/${f}`); - const targets = listImages(targetsDir).map((f) => ({ - id: f.replace(/\.[^.]+$/, ''), - name: prettyName(f), - url: `/scenes/${entry}/targets/${f}`, - })); - - if (targets.length < 3) { - console.warn(`[scanner] "${entry}" uebersprungen: braucht mind. 3 Bilder in targets/ (gefunden: ${targets.length})`); - continue; - } - if (props.length < 1) { - console.warn(`[scanner] "${entry}" uebersprungen: keine Bilder in props/`); - continue; - } - - categories.push({ - id: entry, - name, - type, - background: config.background || '#f4f4f8', - propCount: config.propCount || 60, - minSize: config.minSize || 70, - maxSize: config.maxSize || 130, - props, - targets, - }); - } - - return categories; -} - -// "ballerina-fuss.svg" -> "Ballerina Fuss" -function prettyName(filename) { - return filename - .replace(/\.[^.]+$/, '') - .replace(/[-_]+/g, ' ') - .replace(/\b\w/g, (c) => c.toUpperCase()); -} diff --git a/server/sceneStore.js b/server/sceneStore.js deleted file mode 100644 index 34146da..0000000 --- a/server/sceneStore.js +++ /dev/null @@ -1,29 +0,0 @@ -// Haelt die Trefferzonen jeder generierten Szene serverseitig, damit Klicks -// geprueft werden koennen, ohne die Zielpositionen je ans Frontend zu schicken. -// Einfache Groessen- und Zeitbegrenzung verhindert ein Speicher-Leck bei vielen -// Aufrufen. - -const MAX_ENTRIES = 500; -const TTL_MS = 1000 * 60 * 30; // 30 Minuten - -const scenes = new Map(); // sceneId -> { targets, createdAt } - -function evictIfNeeded() { - const now = Date.now(); - for (const [id, entry] of scenes) { - if (now - entry.createdAt > TTL_MS) scenes.delete(id); - } - while (scenes.size > MAX_ENTRIES) { - const oldest = scenes.keys().next().value; // Map behaelt Einfuege-Reihenfolge - scenes.delete(oldest); - } -} - -export function saveScene(sceneId, targets) { - scenes.set(sceneId, { targets, createdAt: Date.now() }); - evictIfNeeded(); -} - -export function getScene(sceneId) { - return scenes.get(sceneId) || null; -}