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>
1
.gitignore
vendored
@@ -3,3 +3,4 @@ npm-debug.log
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
server.pid
|
server.pid
|
||||||
server.log
|
server.log
|
||||||
|
data/
|
||||||
|
|||||||
113
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
|
Ein kleines Arcade-Spiel nach dem Vorbild des **„Wanted!"-Minispiels aus New Super Mario
|
||||||
detailreichen Wimmelbild gesucht. Pro Szene sind **3 verschiedene Füße** versteckt;
|
Bros.**: Oben ein Fahndungsplakat mit *einem* gesuchten Fuß, unten wuseln viele Füße (in
|
||||||
finde alle drei, dann kommt die nächste zufällige Szene.
|
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.
|
- **Zeitdruck:** Countdown; jeder Treffer gibt Zeit dazu, Fehlklick zieht Zeit ab. Bei 0 → Game-Over.
|
||||||
- **Google-Maps-Gefühl:** ziehen, Mausrad-Zoom am PC, Zwei-Finger-Pinch am Handy.
|
- **Wird schwerer:** erst ordentliches **Raster**, dann **wild verteilt**, dann **Bewegung**
|
||||||
- **Prozedural generiert:** Der Server würfelt jede Szene aus einer gemeinsamen
|
(erst in 2–3 Gruppen, später einzeln) — und je weiter, desto **schneller** und **mehr** Füße.
|
||||||
**Library** neu zusammen und prüft Klick-Treffer serverseitig (nicht durch „im Code
|
- **Gesucht ist immer ein Fuß, aber die Farbe wechselt jede Runde** — nicht auf Autopilot klicken!
|
||||||
spicken" austrickbar).
|
- **Rangliste:** die Top 10 auf der Startseite, serverseitig gespeichert. Nach dem Spiel
|
||||||
- **Library statt Level:** Normale Szenen ziehen Deko + Füße aus `library/`. Themen sind
|
wird **einmal** nach dem Namen gefragt (danach gecacht).
|
||||||
unsichtbar/zufällig gemischt. **Spezial-Level** (z.B. Emoji-Chaos) sind die Ausnahme.
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
./install.sh # prüft Node, installiert Abhängigkeiten
|
./install.sh
|
||||||
npm start # startet den Server
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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://<server-ip>: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/
|
library/
|
||||||
props/ ← Deko-Bildchen (alles, was KEIN Fuß ist: Ball, Muschel, Krabbe …)
|
feet/ ← die Füße (= die gesuchten Figuren). Farbe = Dateiname.
|
||||||
feet/ ← Fuß-Bildchen. Der Server wählt pro Szene 3 als Ziele aus,
|
props/ ← Deko ("andere Bildchen") als Zusatz-Ablenker.
|
||||||
die übrigen streut er als Ablenker mit ein.
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Zum Erweitern einfach neue Bildchen in den passenden Ordner legen und **Server neu
|
Neue Bildchen einfach in den passenden Ordner legen und **Server neu starten** — fertig.
|
||||||
starten** — fertig. Kein Code, kein Level.
|
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/`
|
### Woher Bildchen nehmen?
|
||||||
- **Füße** (zum Suchen) → `library/feet/` · **mind. 3** müssen vorhanden sein
|
openclipart.org (CC0), svgrepo.com (CC0-Filter), kenney.nl (CC0), pixabay.com. Bei
|
||||||
- **Formate:** `.svg`, `.png`, `.webp`, `.jpg`
|
Flaticon/Freepik Namensnennung beachten.
|
||||||
- **Tipp:** freigestellte Bildchen mit **transparentem Hintergrund**, grob quadratisch —
|
|
||||||
dann sitzt die Trefferzone gut. Der Dateiname wird zum Namen in der „Gesucht"-Leiste.
|
|
||||||
|
|
||||||
## Spezial-Level (die Ausnahme)
|
## Feintuning
|
||||||
|
|
||||||
Ein Spezial-Level ist ein eigenständiger Ordner unter `scenes/` mit eigener `config.json`.
|
In `public/js/app.js` oben:
|
||||||
Es taucht gelegentlich zwischen den normalen Library-Szenen auf.
|
- `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.
|
## Rangliste
|
||||||
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`.
|
|
||||||
|
|
||||||
Wie oft normale Library-Szenen im Vergleich zu Spezial-Leveln kommen, steuert
|
- Gespeichert in `data/leaderboard.json` (wird automatisch angelegt, überlebt Neustarts).
|
||||||
`LIBRARY_WEIGHT` in `server/index.js`.
|
- Endpoints: `GET /api/leaderboard` (Top 10), `POST /api/score` `{name, score}`.
|
||||||
|
- Namen werden beim Anzeigen escaped (kein XSS). Kein Anti-Cheat — Spaßspiel.
|
||||||
## 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.
|
|
||||||
|
|
||||||
## Deployment (Ausblick)
|
## Deployment (Ausblick)
|
||||||
|
|
||||||
Das Spiel ist ein normaler Node-Server. Zum Livestellen auf **fuesse.sexy**:
|
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
|
||||||
1. Auf einen Node-Host bringen (z.B. **Railway**, **Render**, oder ein **VPS**).
|
HTTPS hinter Cloudflare: DNS-Challenge (Cloudflare-Token) im Proxy nutzen.
|
||||||
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).
|
|
||||||
|
|
||||||
## Projektstruktur
|
## Projektstruktur
|
||||||
|
|
||||||
```
|
```
|
||||||
server/ Express-Server + Szenen-Generator (die "Wimmelbild-Fabrik")
|
server/index.js schlanker Express-Server (Bildchen-Liste + Rangliste)
|
||||||
public/ Frontend (Vanilla JS + SVG, Szene wird als 1 Bild gerendert)
|
public/ Frontend (Startseite, Spiel, Game-Over) — Vanilla JS
|
||||||
emoji/ optionales Emoji-Bildset
|
library/feet/ die Füße (gesuchte Figuren)
|
||||||
library/ die Fundgrube für normale Szenen
|
library/props/ Deko-Ablenker
|
||||||
props/ Deko (kein Fuß)
|
data/ Rangliste (leaderboard.json, zur Laufzeit)
|
||||||
feet/ Fuß-Bildchen (Ziele + Ablenker)
|
install.sh Ein-Befehl-Setup + detached Start
|
||||||
scenes/ Spezial-Level (Ausnahmen: emoji, photo)
|
|
||||||
install.sh Ein-Befehl-Setup
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -5,5 +5,4 @@
|
|||||||
<circle cx="45" cy="26" r="5.6" fill="#3a7bd5"/>
|
<circle cx="45" cy="26" r="5.6" fill="#3a7bd5"/>
|
||||||
<circle cx="56" cy="26" r="4.9" fill="#3a7bd5"/>
|
<circle cx="56" cy="26" r="4.9" fill="#3a7bd5"/>
|
||||||
<circle cx="65" cy="30" r="4.2" fill="#3a7bd5"/>
|
<circle cx="65" cy="30" r="4.2" fill="#3a7bd5"/>
|
||||||
<circle cx="72" cy="37" r="3.6" fill="#3a7bd5"/>
|
<circle cx="72" cy="37" r="3.6" fill="#3a7bd5"/></svg>
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 465 B After Width: | Height: | Size: 460 B |
@@ -1,9 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
|
||||||
<ellipse cx="50" cy="54" rx="21" ry="19" fill="#b97a4a"/>
|
|
||||||
<ellipse cx="50" cy="80" rx="13.5" ry="11" fill="#b97a4a"/>
|
|
||||||
<circle cx="33" cy="33" r="7.5" fill="#b97a4a"/>
|
|
||||||
<circle cx="45" cy="26" r="5.6" fill="#b97a4a"/>
|
|
||||||
<circle cx="56" cy="26" r="4.9" fill="#b97a4a"/>
|
|
||||||
<circle cx="65" cy="30" r="4.2" fill="#b97a4a"/>
|
|
||||||
<circle cx="72" cy="37" r="3.6" fill="#b97a4a"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 465 B |
@@ -1,9 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
|
||||||
<ellipse cx="50" cy="54" rx="21" ry="19" fill="#6d4327"/>
|
|
||||||
<ellipse cx="50" cy="80" rx="13.5" ry="11" fill="#6d4327"/>
|
|
||||||
<circle cx="33" cy="33" r="7.5" fill="#6d4327"/>
|
|
||||||
<circle cx="45" cy="26" r="5.6" fill="#6d4327"/>
|
|
||||||
<circle cx="56" cy="26" r="4.9" fill="#6d4327"/>
|
|
||||||
<circle cx="65" cy="30" r="4.2" fill="#6d4327"/>
|
|
||||||
<circle cx="72" cy="37" r="3.6" fill="#6d4327"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 465 B |
@@ -1,10 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
|
||||||
<ellipse cx="50" cy="54" rx="21" ry="19" fill="#f0b98a"/>
|
|
||||||
<ellipse cx="50" cy="80" rx="13.5" ry="11" fill="#f0b98a"/>
|
|
||||||
<circle cx="33" cy="33" r="7.5" fill="#f0b98a"/>
|
|
||||||
<circle cx="45" cy="26" r="5.6" fill="#f0b98a"/>
|
|
||||||
<circle cx="56" cy="26" r="4.9" fill="#f0b98a"/>
|
|
||||||
<circle cx="65" cy="30" r="4.2" fill="#f0b98a"/>
|
|
||||||
<circle cx="72" cy="37" r="3.6" fill="#f0b98a"/>
|
|
||||||
<path d="M39 33 L28 54 M39 33 L60 50" stroke="#e5484d" stroke-width="6" stroke-linecap="round" fill="none"/>
|
|
||||||
<circle cx="39" cy="33" r="5" fill="#e5484d"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 625 B |
8
library/feet/gelb.svg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||||
|
<ellipse cx="50" cy="54" rx="21" ry="19" fill="#ffd23f"/>
|
||||||
|
<ellipse cx="50" cy="80" rx="13.5" ry="11" fill="#ffd23f"/>
|
||||||
|
<circle cx="33" cy="33" r="7.5" fill="#ffd23f"/>
|
||||||
|
<circle cx="45" cy="26" r="5.6" fill="#ffd23f"/>
|
||||||
|
<circle cx="56" cy="26" r="4.9" fill="#ffd23f"/>
|
||||||
|
<circle cx="65" cy="30" r="4.2" fill="#ffd23f"/>
|
||||||
|
<circle cx="72" cy="37" r="3.6" fill="#ffd23f"/></svg>
|
||||||
|
After Width: | Height: | Size: 460 B |
8
library/feet/gruen.svg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||||
|
<ellipse cx="50" cy="54" rx="21" ry="19" fill="#35c15f"/>
|
||||||
|
<ellipse cx="50" cy="80" rx="13.5" ry="11" fill="#35c15f"/>
|
||||||
|
<circle cx="33" cy="33" r="7.5" fill="#35c15f"/>
|
||||||
|
<circle cx="45" cy="26" r="5.6" fill="#35c15f"/>
|
||||||
|
<circle cx="56" cy="26" r="4.9" fill="#35c15f"/>
|
||||||
|
<circle cx="65" cy="30" r="4.2" fill="#35c15f"/>
|
||||||
|
<circle cx="72" cy="37" r="3.6" fill="#35c15f"/></svg>
|
||||||
|
After Width: | Height: | Size: 460 B |
@@ -1,9 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
|
||||||
<ellipse cx="50" cy="54" rx="21" ry="19" fill="#4caf50"/>
|
|
||||||
<ellipse cx="50" cy="80" rx="13.5" ry="11" fill="#4caf50"/>
|
|
||||||
<circle cx="33" cy="33" r="7.5" fill="#4caf50"/>
|
|
||||||
<circle cx="45" cy="26" r="5.6" fill="#4caf50"/>
|
|
||||||
<circle cx="56" cy="26" r="4.9" fill="#4caf50"/>
|
|
||||||
<circle cx="65" cy="30" r="4.2" fill="#4caf50"/>
|
|
||||||
<circle cx="72" cy="37" r="3.6" fill="#4caf50"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 465 B |
@@ -1,9 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
|
||||||
<ellipse cx="50" cy="54" rx="21" ry="19" fill="#f0b98a"/>
|
|
||||||
<ellipse cx="50" cy="80" rx="13.5" ry="11" fill="#f0b98a"/>
|
|
||||||
<circle cx="33" cy="33" r="7.5" fill="#f0b98a"/>
|
|
||||||
<circle cx="45" cy="26" r="5.6" fill="#f0b98a"/>
|
|
||||||
<circle cx="56" cy="26" r="4.9" fill="#f0b98a"/>
|
|
||||||
<circle cx="65" cy="30" r="4.2" fill="#f0b98a"/>
|
|
||||||
<circle cx="72" cy="37" r="3.6" fill="#f0b98a"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 465 B |
@@ -1,9 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
|
||||||
<ellipse cx="50" cy="54" rx="21" ry="19" fill="#8e5bd0"/>
|
|
||||||
<ellipse cx="50" cy="80" rx="13.5" ry="11" fill="#8e5bd0"/>
|
|
||||||
<circle cx="33" cy="33" r="7.5" fill="#8e5bd0"/>
|
|
||||||
<circle cx="45" cy="26" r="5.6" fill="#8e5bd0"/>
|
|
||||||
<circle cx="56" cy="26" r="4.9" fill="#8e5bd0"/>
|
|
||||||
<circle cx="65" cy="30" r="4.2" fill="#8e5bd0"/>
|
|
||||||
<circle cx="72" cy="37" r="3.6" fill="#8e5bd0"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 465 B |
8
library/feet/lila.svg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||||
|
<ellipse cx="50" cy="54" rx="21" ry="19" fill="#9b5de5"/>
|
||||||
|
<ellipse cx="50" cy="80" rx="13.5" ry="11" fill="#9b5de5"/>
|
||||||
|
<circle cx="33" cy="33" r="7.5" fill="#9b5de5"/>
|
||||||
|
<circle cx="45" cy="26" r="5.6" fill="#9b5de5"/>
|
||||||
|
<circle cx="56" cy="26" r="4.9" fill="#9b5de5"/>
|
||||||
|
<circle cx="65" cy="30" r="4.2" fill="#9b5de5"/>
|
||||||
|
<circle cx="72" cy="37" r="3.6" fill="#9b5de5"/></svg>
|
||||||
|
After Width: | Height: | Size: 460 B |
8
library/feet/orange.svg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||||
|
<ellipse cx="50" cy="54" rx="21" ry="19" fill="#ff8a1e"/>
|
||||||
|
<ellipse cx="50" cy="80" rx="13.5" ry="11" fill="#ff8a1e"/>
|
||||||
|
<circle cx="33" cy="33" r="7.5" fill="#ff8a1e"/>
|
||||||
|
<circle cx="45" cy="26" r="5.6" fill="#ff8a1e"/>
|
||||||
|
<circle cx="56" cy="26" r="4.9" fill="#ff8a1e"/>
|
||||||
|
<circle cx="65" cy="30" r="4.2" fill="#ff8a1e"/>
|
||||||
|
<circle cx="72" cy="37" r="3.6" fill="#ff8a1e"/></svg>
|
||||||
|
After Width: | Height: | Size: 460 B |
8
library/feet/pink.svg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||||
|
<ellipse cx="50" cy="54" rx="21" ry="19" fill="#ff5aa8"/>
|
||||||
|
<ellipse cx="50" cy="80" rx="13.5" ry="11" fill="#ff5aa8"/>
|
||||||
|
<circle cx="33" cy="33" r="7.5" fill="#ff5aa8"/>
|
||||||
|
<circle cx="45" cy="26" r="5.6" fill="#ff5aa8"/>
|
||||||
|
<circle cx="56" cy="26" r="4.9" fill="#ff5aa8"/>
|
||||||
|
<circle cx="65" cy="30" r="4.2" fill="#ff5aa8"/>
|
||||||
|
<circle cx="72" cy="37" r="3.6" fill="#ff5aa8"/></svg>
|
||||||
|
After Width: | Height: | Size: 460 B |
@@ -5,5 +5,4 @@
|
|||||||
<circle cx="45" cy="26" r="5.6" fill="#e5484d"/>
|
<circle cx="45" cy="26" r="5.6" fill="#e5484d"/>
|
||||||
<circle cx="56" cy="26" r="4.9" fill="#e5484d"/>
|
<circle cx="56" cy="26" r="4.9" fill="#e5484d"/>
|
||||||
<circle cx="65" cy="30" r="4.2" fill="#e5484d"/>
|
<circle cx="65" cy="30" r="4.2" fill="#e5484d"/>
|
||||||
<circle cx="72" cy="37" r="3.6" fill="#e5484d"/>
|
<circle cx="72" cy="37" r="3.6" fill="#e5484d"/></svg>
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 465 B After Width: | Height: | Size: 460 B |
8
library/feet/tuerkis.svg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||||
|
<ellipse cx="50" cy="54" rx="21" ry="19" fill="#18c2c8"/>
|
||||||
|
<ellipse cx="50" cy="80" rx="13.5" ry="11" fill="#18c2c8"/>
|
||||||
|
<circle cx="33" cy="33" r="7.5" fill="#18c2c8"/>
|
||||||
|
<circle cx="45" cy="26" r="5.6" fill="#18c2c8"/>
|
||||||
|
<circle cx="56" cy="26" r="4.9" fill="#18c2c8"/>
|
||||||
|
<circle cx="65" cy="30" r="4.2" fill="#18c2c8"/>
|
||||||
|
<circle cx="72" cy="37" r="3.6" fill="#18c2c8"/></svg>
|
||||||
|
After Width: | Height: | Size: 460 B |
25
package-lock.json
generated
@@ -9,6 +9,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
|
"image-size": "^1.2.1",
|
||||||
"panzoom": "^9.4.3"
|
"panzoom": "^9.4.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -442,6 +443,21 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/inherits": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
@@ -626,6 +642,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/range-parser": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "fuesse-sexy",
|
"name": "fuesse-sexy",
|
||||||
"version": "1.0.0",
|
"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",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server/index.js",
|
"start": "node server/index.js",
|
||||||
"setup": "sh install.sh"
|
"setup": "sh install.sh",
|
||||||
|
"reset-stats": "node scripts/reset-leaderboard.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2"
|
||||||
"panzoom": "^9.4.3"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,297 +1,140 @@
|
|||||||
/* fuesse.sexy — verspielter, klarer Look im Dark Mode. Vollflaechig, kein Scrollen. */
|
/* fuesse.sexy — "Wanted!" · Dark Mode, vollflaechig, kein Scrollen */
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--pink: #ff4d8d;
|
--pink: #ff4d8d;
|
||||||
--pink-soft: #ff86b3;
|
--pink-soft: #ff86b3;
|
||||||
--ink: #ececf5;
|
--ink: #ececf5;
|
||||||
--muted: #a49fc0;
|
--muted: #a49fc0;
|
||||||
--bg: #0e0e18; /* Seite / Letterbox */
|
--bg: #0e0e18;
|
||||||
--panel: #1b1b2c; /* Leiste, Buttons */
|
--panel: #1b1b2c;
|
||||||
--card: #262640; /* Legenden-Karten */
|
--card: #262640;
|
||||||
--border: #34345020;
|
--border: #3a3a58;
|
||||||
--border-solid: #3a3a58;
|
|
||||||
--ok: #34d399;
|
--ok: #34d399;
|
||||||
--miss: #f87171;
|
--miss: #f87171;
|
||||||
|
--gold: #ffcf3f;
|
||||||
}
|
}
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
html, body {
|
html, body {
|
||||||
margin: 0;
|
margin: 0; height: 100%; overflow: hidden;
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
color: var(--ink);
|
color: var(--ink); background: var(--bg);
|
||||||
background: var(--bg);
|
-webkit-user-select: none; user-select: none;
|
||||||
-webkit-user-select: none;
|
|
||||||
user-select: none;
|
|
||||||
}
|
}
|
||||||
|
#app, #home, #game { height: 100%; }
|
||||||
#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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden { display: none !important; }
|
.hidden { display: none !important; }
|
||||||
|
|
||||||
/* Mobile */
|
button { font-family: inherit; cursor: pointer; }
|
||||||
@media (max-width: 560px) {
|
.big-btn {
|
||||||
.brand { font-size: 16px; }
|
border: none; background: var(--pink); color: #fff; font-weight: 800;
|
||||||
.legend-item { width: 50px; height: 50px; }
|
font-size: 20px; padding: 14px 30px; border-radius: 999px;
|
||||||
.legend-thumb { width: 38px; height: 38px; }
|
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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,53 +1,70 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
|
<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="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" />
|
<link rel="stylesheet" href="/css/style.css" />
|
||||||
|
|
||||||
<div id="game">
|
<div id="app">
|
||||||
<!-- "Gesucht"-Leiste -->
|
<!-- ===== Startseite ===== -->
|
||||||
<header id="legend">
|
<section id="home">
|
||||||
<div class="legend-label">
|
<div class="home-inner">
|
||||||
<span class="brand">fuesse<span class="brand-dot">.</span>sexy</span>
|
<h1 class="brand">fuesse<span class="brand-dot">.</span>sexy</h1>
|
||||||
<span class="legend-sub">Finde diese Füße:</span>
|
<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>
|
||||||
<div id="legend-items"></div>
|
</section>
|
||||||
<div id="legend-counter" class="legend-counter">0 / 3</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Die "Karte" (Wimmelbild) -->
|
<!-- ===== Spiel ===== -->
|
||||||
<main id="stage">
|
<section id="game" class="hidden">
|
||||||
<div id="world">
|
<header id="hud">
|
||||||
<img id="scene-img" alt="Wimmelbild" draggable="false" />
|
<div class="wanted-poster">
|
||||||
</div>
|
<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 -->
|
<!-- ===== Game Over ===== -->
|
||||||
<div id="controls">
|
<div id="gameover" class="hidden">
|
||||||
<button id="zoom-in" title="Reinzoomen" aria-label="Reinzoomen">+</button>
|
<div class="go-card">
|
||||||
<button id="zoom-out" title="Rauszoomen" aria-label="Rauszoomen">−</button>
|
<div class="go-emoji">🦶⏱️</div>
|
||||||
<button id="reset-view" title="Ansicht zurücksetzen" aria-label="Ansicht zurücksetzen">⟲</button>
|
<h2>Zeit um!</h2>
|
||||||
</div>
|
<p class="go-score">Score: <span id="final-score">0</span></p>
|
||||||
|
|
||||||
<!-- Lade-Spinner -->
|
<div id="name-ask" class="hidden">
|
||||||
<div id="loading" class="hidden">
|
<label for="name-input">Dein Name für die Rangliste:</label>
|
||||||
<div class="spinner"></div>
|
<div class="name-row">
|
||||||
</div>
|
<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 class="board tight">
|
||||||
<div id="toast"></div>
|
<h2>🏆 Rangliste</h2>
|
||||||
</main>
|
<ol id="go-leaderboard" class="leaderboard"></ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Gewinn-Overlay -->
|
<div class="go-actions">
|
||||||
<div id="win-overlay" class="hidden">
|
<button id="play-again" class="big-btn">▶ Nochmal</button>
|
||||||
<div class="win-card">
|
<button id="to-home" class="link-btn">Startseite</button>
|
||||||
<div class="win-emoji">🦶🎉</div>
|
</div>
|
||||||
<h2>Alle Füße gefunden!</h2>
|
|
||||||
<button id="next-scene">Weiter →</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/vendor/panzoom.min.js"></script>
|
|
||||||
<script type="module" src="/js/app.js"></script>
|
<script type="module" src="/js/app.js"></script>
|
||||||
|
|||||||
492
public/js/app.js
@@ -1,106 +1,428 @@
|
|||||||
// App-Flow: beim Laden sofort eine zufaellige Szene holen (kein Start-Screen, keine
|
// fuesse.sexy — "Wanted!"-Minigame
|
||||||
// Auswahl). Alle 3 Fuesse finden -> Gewinn-Overlay -> "Weiter" laedt die naechste.
|
// 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';
|
const $ = (id) => document.getElementById(id);
|
||||||
import { Legend } from './legend.js';
|
|
||||||
|
|
||||||
const stage = document.getElementById('stage');
|
// ---- Konstanten (leicht justierbar) ----
|
||||||
const world = document.getElementById('world');
|
const START_TIME = 22; // Sekunden zu Beginn
|
||||||
const sceneImg = document.getElementById('scene-img');
|
const MAX_TIME = 30; // Deckel (Timerleiste = timeLeft / MAX_TIME)
|
||||||
const loading = document.getElementById('loading');
|
const BONUS = 2.0; // Zeit pro Treffer
|
||||||
const winOverlay = document.getElementById('win-overlay');
|
const PENALTY = 2.0; // Zeitabzug pro Fehlklick
|
||||||
const toast = document.getElementById('toast');
|
const NAME_KEY = 'fuesse_name';
|
||||||
|
|
||||||
const scene = new Scene(stage, world, sceneImg);
|
// ---- Zustand ----
|
||||||
const legend = new Legend(
|
let FEET = []; // [{id,name,url}]
|
||||||
document.getElementById('legend-items'),
|
let PROPS = []; // [{id,name,url}]
|
||||||
document.getElementById('legend-counter')
|
let state = null;
|
||||||
);
|
let rafId = null;
|
||||||
|
let lastT = 0;
|
||||||
|
|
||||||
let current = null; // aktuelles Szenen-Payload
|
// ---- Utils ----
|
||||||
let busy = false;
|
const rand = (a, b) => a + Math.random() * (b - a);
|
||||||
|
const randInt = (a, b) => Math.floor(rand(a, b + 1));
|
||||||
function showLoading(on) {
|
const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
|
||||||
loading.classList.toggle('hidden', !on);
|
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
|
||||||
}
|
function shuffle(a) {
|
||||||
|
for (let i = a.length - 1; i > 0; i--) {
|
||||||
function showToast(message) {
|
const j = randInt(0, i);
|
||||||
toast.textContent = message;
|
[a[i], a[j]] = [a[j], a[i]];
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
const msg = err && err.message ? err.message : String(err);
|
return a;
|
||||||
box.textContent = 'Fehler beim Laden der Szene: ' + msg;
|
|
||||||
box.classList.remove('hidden');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearError() {
|
// Schwierigkeit aus dem Score ableiten
|
||||||
const box = document.getElementById('error-box');
|
function difficulty(score) {
|
||||||
if (box) box.classList.add('hidden');
|
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() {
|
// ---- Rangliste ----
|
||||||
showLoading(true);
|
async function fetchLeaderboard() {
|
||||||
clearError();
|
|
||||||
winOverlay.classList.add('hidden');
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/scene');
|
const res = await fetch('/api/leaderboard');
|
||||||
if (!res.ok) throw new Error('Server-Antwort ' + res.status);
|
return res.ok ? await res.json() : [];
|
||||||
current = await res.json();
|
} catch {
|
||||||
await scene.render(current);
|
return [];
|
||||||
scene.resetView();
|
}
|
||||||
legend.render(current.targets);
|
}
|
||||||
document.getElementById('category-name').textContent = current.category || '';
|
function renderLeaderboard(el, list) {
|
||||||
} catch (err) {
|
el.innerHTML = '';
|
||||||
console.error(err);
|
if (!list.length) {
|
||||||
showError(err);
|
const li = document.createElement('li');
|
||||||
} finally {
|
li.className = 'lb-empty';
|
||||||
showLoading(false);
|
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 }) {
|
// Startpositionen je Stufe. Gibt {x,y,gi,ox,oy}-Liste (Top-Left-Koordinaten).
|
||||||
if (busy || !current) return;
|
function layoutPositions(diff, n, w, h, size) {
|
||||||
busy = true;
|
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 {
|
try {
|
||||||
const res = await fetch(`/api/scene/${current.sceneId}/guess`, {
|
const res = await fetch('/api/score', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ x, y }),
|
body: JSON.stringify({ name, score }),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
if (res.ok) return await res.json();
|
||||||
if (data.hit) {
|
} catch {}
|
||||||
const isNew = legend.markFound(data.hit);
|
return await fetchLeaderboard();
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
async function init() {
|
||||||
document.getElementById('zoom-in').addEventListener('click', () => scene.zoomIn());
|
// Bildchen laden
|
||||||
document.getElementById('zoom-out').addEventListener('click', () => scene.zoomOut());
|
try {
|
||||||
document.getElementById('reset-view').addEventListener('click', () => scene.resetView());
|
const res = await fetch('/api/feet');
|
||||||
document.getElementById('next-scene').addEventListener('click', loadScene);
|
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.
|
await refreshHomeBoard();
|
||||||
loadScene();
|
|
||||||
|
$('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();
|
||||||
|
|||||||
@@ -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}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
15
scripts/reset-leaderboard.mjs
Normal file
@@ -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.');
|
||||||
151
server/index.js
@@ -1,107 +1,102 @@
|
|||||||
// fuesse.sexy — "Where's the Feet"
|
// fuesse.sexy — "Wanted!" (Fuss-Fahndung)
|
||||||
// Express-Server: liefert das Frontend aus, scannt die Szenen-Ordner beim Start,
|
// Schlanker Express-Server: liefert das Frontend + die Bildchen aus, stellt die Liste
|
||||||
// generiert auf Anfrage eine zufaellige Szene und prueft Klick-Treffer serverseitig.
|
// der verfuegbaren Fuesse/Deko bereit und verwaltet eine persistente Rangliste.
|
||||||
|
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { dirname, join } from 'node:path';
|
import { dirname, join } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { existsSync } from 'node:fs';
|
import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||||
|
|
||||||
import { scanLibrary, scanScenes } from './sceneScanner.js';
|
|
||||||
import { generateScene } from './sceneGenerator.js';
|
|
||||||
import { getScene } from './sceneStore.js';
|
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const ROOT = join(__dirname, '..');
|
const ROOT = join(__dirname, '..');
|
||||||
const SCENES_DIR = join(ROOT, 'scenes');
|
|
||||||
const LIBRARY_DIR = join(ROOT, 'library');
|
|
||||||
const PUBLIC_DIR = join(ROOT, 'public');
|
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 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.
|
// ---- Bildchen aus der Library einlesen ----
|
||||||
const LIBRARY_WEIGHT = 3;
|
function isImage(f) {
|
||||||
|
return IMAGE_EXT.some((ext) => f.toLowerCase().endsWith(ext));
|
||||||
// Beim Start: Library (Normalfall) + Spezial-Level (Ausnahmen) einlesen und zu einem
|
}
|
||||||
// gewichteten Zufalls-Pool zusammenbauen.
|
function prettyName(file) {
|
||||||
const library = scanLibrary(LIBRARY_DIR);
|
return file
|
||||||
const specials = scanScenes(SCENES_DIR);
|
.replace(/\.[^.]+$/, '')
|
||||||
|
.replace(/[-_]+/g, ' ')
|
||||||
const scenePool = [];
|
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||||
if (library) {
|
}
|
||||||
const libCategory = {
|
function listImages(dir, urlPrefix) {
|
||||||
id: 'library',
|
if (!existsSync(dir)) return [];
|
||||||
name: '',
|
return readdirSync(dir)
|
||||||
type: 'clipart',
|
.filter(isImage)
|
||||||
isLibrary: true,
|
.sort()
|
||||||
background: null, // pro Szene zufaellig
|
.map((f) => ({ id: f.replace(/\.[^.]+$/, ''), name: prettyName(f), url: `${urlPrefix}/${f}` }));
|
||||||
minSize: 68,
|
|
||||||
maxSize: 128,
|
|
||||||
cellFactor: 0.82,
|
|
||||||
props: library.props,
|
|
||||||
targets: library.feet,
|
|
||||||
};
|
|
||||||
for (let i = 0; i < LIBRARY_WEIGHT; i++) scenePool.push(libCategory);
|
|
||||||
}
|
}
|
||||||
for (const s of specials) scenePool.push(s);
|
|
||||||
|
|
||||||
if (scenePool.length === 0) {
|
const feet = listImages(join(LIBRARY_DIR, 'feet'), '/library/feet');
|
||||||
console.warn('[server] Keine spielbaren Szenen — fuelle library/ oder scenes/.');
|
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 {
|
} else {
|
||||||
console.log(
|
console.log(`[server] ${feet.length} Fuesse, ${props.length} Deko-Bildchen geladen.`);
|
||||||
`[server] bereit: Library=${library ? 'ja' : 'nein'}, Spezial-Level: ${specials.map((c) => c.id).join(', ') || '(keine)'}`
|
}
|
||||||
);
|
|
||||||
|
// ---- 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();
|
const app = express();
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
// Statisches Frontend + Assets.
|
// Statisches Frontend + Bildchen (no-cache -> nie veraltetes JS nach Update)
|
||||||
// no-cache fuers Frontend (HTML/JS/CSS): Browser/Proxy muss revalidieren -> nie altes
|
app.use(express.static(PUBLIC_DIR, { setHeaders: (res) => res.setHeader('Cache-Control', 'no-cache') }));
|
||||||
// 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));
|
|
||||||
app.use('/library', express.static(LIBRARY_DIR));
|
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).
|
// Verfuegbare Bildchen
|
||||||
app.get('/api/scene', (req, res) => {
|
app.get('/api/feet', (req, res) => {
|
||||||
if (scenePool.length === 0) {
|
res.json({ feet, props });
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Klick pruefen: liegt (x, y) in einer der Ziel-Trefferzonen dieser Szene?
|
// Rangliste: Top 10
|
||||||
app.post('/api/scene/:sceneId/guess', (req, res) => {
|
app.get('/api/leaderboard', (req, res) => {
|
||||||
const entry = getScene(req.params.sceneId);
|
res.json(topN(readLeaderboard(), 10));
|
||||||
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.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
let hit = null;
|
// Score eintragen -> aktualisierte Top 10 zurueck
|
||||||
for (const t of entry.targets) {
|
app.post('/api/score', (req, res) => {
|
||||||
if (Math.hypot(t.cx - x, t.cy - y) <= t.radius) {
|
let { name, score } = req.body || {};
|
||||||
hit = t.id;
|
score = Number(score);
|
||||||
break;
|
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, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`[server] fuesse.sexy laeuft auf http://localhost:${PORT}`);
|
console.log(`[server] fuesse.sexy (Wanted!) laeuft auf http://localhost:${PORT}`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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());
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||