GUI #60

Merged
3014947 merged 13 commits from GUI into main 2025-08-14 22:37:37 +02:00
9 changed files with 948 additions and 299 deletions

177
README.md
View File

@ -1,5 +1,174 @@
Setup:
# Spotify Roulette
- Spotify developer app öffnen (free)
- in user management jeden mitspieler mit email und username (anmeldename bei spotify) eintragen, geht seit mai nurnoch manuell da extended quota mode nurnoch für firmen ist
- maven projekt compilieren, ClientID, redirectURL und Clientsecret auf server eintragen und Website als service ausführen
IDK für wen das ist, aber hier ist ein Guide, wie man das bei sich selbst aufsetzt, weil Spotify kein extended quota mode für Privatpersonen mehr erlaubt (sprich: man muss jeden Mitspieler manuell im Usermanagement eintragen).
## Wichtig vorab
- Für SpotifyLogin und Playback brauchst du eine Domain mit HTTPS. Nur IP ohne Domain funktioniert für Spotify OAuth nicht.
- Jeder Spieler muss sich einmal mit Spotify anmelden. Spotify Playback erfordert in der Regel Premium.
- Tokens und Spielzustand werden im Speicher gehalten. Neustart der App ⇒ erneute Logins nötig.
## 1\) Voraussetzungen
- Server mit Ubuntu 22\.04 \(oder ähnlich\), SSHZugang.
- Domain, ARecord zeigt auf deine ServerIP.
- Spotify Developer Konto:
- App anlegen, Client ID und Client Secret notieren.
- RedirectURI setzen, z\.B. `https://deine-domain.tld/callback`
- Jeden Mitspieler in der SpotifyApp als „User“ hinzufügen \(\*ohne extended quota\*\).
## 2\) Server vorbereiten
```bash
# System aktualisieren
sudo apt update && sudo apt upgrade -y
# Java (z.B. Temurin 21), Maven, Git, Nginx, Certbot
sudo apt install -y wget gnupg2 ca-certificates lsb-release apt-transport-https
sudo apt install -y openjdk-21-jre maven git nginx certbot python3-certbot-nginx
# Java prüfen
java -version
```
## 3\) Projekt beziehen und bauen
```bash
# In ein Verzeichnis deiner Wahl wechseln
cd /opt
sudo git clone <DEIN_REPO_URL> spotify-roulette
cd spotify-roulette
# Build (ohne Tests)
mvn -DskipTests package
# Das erzeugte JAR liegt meist unter: target/<name>-<version>.jar
ls -lh target
```
## 4\) SpotifyKonfiguration eintragen
- Suche, wo `SpotifyAuthService` erzeugt wird \(WorkspaceSuche nach `new SpotifyAuthService(`\).
- Trage dort `clientId`, `clientSecret` und `redirectUri` für deine Domain ein \(oder lies sie aus Umgebungsvariablen\).
- RedirectURI muss exakt zur Spotify Developer Console passen \(Schema, Host, Pfad\), z\.B. `https://deine-domain.tld/callback`.
Hinweis:
- Die App speichert pro Benutzer eine `SpotifyApi`\-Instanz im Speicher. `exchangeCode` im Callback muss aufgerufen werden, damit `getRecentTracks` und Playback funktionieren.
- `GameService` lädt pro Spieler die zuletzt gehörten Tracks beim Beitritt \(`getRecentTracks`\) und cached sie pro Spieler.
## 5\) App als Systemdienst einrichten
Kurz erklärt: Systemd startet deine JavaApp beim Booten neu und hält sie am Laufen.
```ini
# /etc/systemd/system/spotify-roulette.service
[Unit]
Description=Spotify Roulette
After=network.target
[Service]
User=www-data
WorkingDirectory=/opt/spotify-roulette
Environment=SPOTIFY_CLIENT_ID=<deine_id>
Environment=SPOTIFY_CLIENT_SECRET=<dein_secret>
Environment=APP_BASE_URL=https://deine-domain.tld
# Falls deine App Umgebungsvariablen liest, nutzt sie. Andernfalls Werte direkt im Code setzen.
ExecStart=/usr/bin/java -jar /opt/spotify-roulette/target/<jar-name>.jar
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
```
Aktivieren und starten:
```bash
sudo systemctl daemon-reload
sudo systemctl enable spotify-roulette
sudo systemctl start spotify-roulette
sudo systemctl status spotify-roulette --no-pager
```
## 6\) Reverse Proxy mit Nginx und HTTPS
Proxy leitet Port 80/443 auf deine App \(typisch Port 8080\). WebSockets brauchen UpgradeHeader.
```nginx
# /etc/nginx/sites-available/spotify-roulette.conf
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
server_name deine-domain.tld;
location / {
proxy_pass http://127.0.0.1:8080; # ggf. Port an deine App anpassen
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 3600;
}
}
```
Aktivieren:
```bash
sudo ln -s /etc/nginx/sites-available/spotify-roulette.conf /etc/nginx/sites-enabled/spotify-roulette.conf
sudo nginx -t
sudo systemctl reload nginx
```
TLS Zertifikat:
```bash
sudo certbot --nginx -d deine-domain.tld --redirect
```
## 7\) Firewall \(falls aktiv\)
```bash
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable
sudo ufw status
```
## 8\) Start lokal \(Dev\)
- Spotify erlaubt `http://localhost` als RedirectURI. Füge z\.B. `http://localhost:8080/callback` in der Developer Console hinzu und setze denselben Wert im Code.
- Start:
```bash
mvn -DskipTests package
java -jar target/<jar-name>.jar
```
## 9\) Spielablauf \(UI\)
- Öffne `https://deine-domain.tld`.
- Spiel erstellen/beitreten.
- Jeder Spieler meldet sich mit Spotify an \(erforderliche Scopes: `user-read-recently-played`, `user-library-read`, `user-modify-playback-state`, `user-read-playback-state`, `streaming`\).
- Songliste lädt automatisch \(`getRecentTracks` + Fallback `getUsersSavedTracks`\).
- „Runde starten“ löst über WebSocket den Start aus. Es wird ein zufälliger Owner und ein Song aus dessen Tracks gewählt.
- Guesses über die KreisOptionen \(Mehrfachauswahl möglich\). Scoring:
- +3, wenn der ausgeählte den Song gehört hat
- 1 pro falschem Tipp
- +1 Bonus, wenn kein falscher Tipp \(fehlerfrei\)
- Ab 30 Punkten und eindeutiger Spitze: Spielende, Overlay mit Leaderboard, Scores werden auf 0 zurückgesetzt.
## 10\) Geräte & Playback
- Geräte laden: `GET /api/spotify/devices?username=<name>`
- Abspielen: `POST /api/spotify/play` mit JSON `{ "username": "<name>", "device_id": "<id>", "track_uri": "spotify:track:..." }`
- Der Track wird ab der Hälfte gestartet \(`position_ms = duration_ms / 2`\).
## 11\) API \& WS Übersicht
- `POST /api/create-game``{ username }` → erstellt Spiel, fügt Spieler hinzu.
- `POST /api/join-game``{ username, gameId }` → Spieler tritt bei.
- `GET /api/game/{gameId}/players` ⇒ Liste der Spieler.
- `POST /api/game/{gameId}/start-round` ⇒ Broadcast des Rundenstarts \(Fallback, primär via WebSocket `type: "start-round"`\).
- `POST /api/game/{gameId}/guess``{ username, guess }` \(Legacy SingleGuess; UI nutzt MultiGuess via WS\).
- WebSocket: Route mit Pfadparam `gameId` und Query `username` \(Client sendet `type: "start-round" | "submit-guesses" | "requestPlayers" | "next-round"`\).
- Server pusht `round-start`, `round-result`, `game-end`.
## 12\) Häufige Stolpersteine
- 404 oder OAuth Fehler: RedirectURI in Spotify Console muss exakt passen \(Schema, Host, Pfad\).
- Kein Start der Runde: Spieler ohne geladene Tracks → sicherstellen, dass Spotify Login erfolgreich war.
- WebSockets brechen ab: Nginx Upgrade/Connection Header wie oben setzen.
- HTTP statt HTTPS: Spotify akzeptiert im Internet i\.d\.R. nur HTTPS \(keine reine IP\).
- Tokens verloren nach Neustart: App speichert nur im Speicher. Spieler müssen sich erneut anmelden.
- manchmal kriegt man einen 24h ratelimit bann von alle oder bestimmte features von spotify nicht funktionieren
```

View File

@ -117,7 +117,7 @@ public class GameController {
private void startRound(Context ctx) {
String gameId = ctx.pathParam("gameId");
ctx.json(Map.of("status", "ok"));
webSocketHandler.broadcastRoundStart(gameId);
webSocketHandler.broadcastRoundStart(gameId,true);
}
private void guess(Context ctx) {
@ -132,7 +132,8 @@ public class GameController {
return;
}
boolean correct = guess.equals(owner);
if (correct) game.scores().merge(user, 1, Integer::sum);
game.scores().merge(user, correct ? 3 : -1, Integer::sum); // an WS-Logik angeglichen
//if (correct) game.scores().merge(user, 3, Integer::sum);
ctx.json(Map.of(
"correct", correct,
"owner", owner,
@ -152,7 +153,11 @@ public class GameController {
try {
//String accessToken = authService.getAccessTokenForUser(username);
String accessToken = userAccessTokens.get(username);
String accessToken = userAccessTokens.computeIfAbsent(username, authService::getAccessTokenForUser);
if (accessToken == null || accessToken.isBlank()) {
ctx.status(401).result("Kein Zugriffstoken für " + username); return;
}
//String accessToken = userAccessTokens.get(username);
OkHttpClient client = httpClient;
String trackId = trackUri.split(":")[2];
Request getTrack = new Request.Builder()

View File

@ -1,8 +1,8 @@
// java
package eric.Roullette.websocket;
import com.fasterxml.jackson.databind.JsonNode;
import eric.Roullette.service.GameService;
//import eric.Roullette.service.SpotifyAuthService;
import eric.Roullette.util.JsonUtil;
import io.javalin.websocket.WsConfig;
import io.javalin.websocket.WsContext;
@ -17,20 +17,15 @@ import java.util.concurrent.ConcurrentHashMap;
public class GameWebSocketHandler {
private final GameService service;
//private final SpotifyAuthService authService;
// Spiel-ID → (Username → deren Guess)
private final Map<String, Map<String, String>> currentGuesses = new ConcurrentHashMap<>();
// Spiel-ID → (Username → deren Guesses)
private final Map<String, Map<String, List<String>>> currentGuesses = new ConcurrentHashMap<>();
// private final Map<String, List<String>> trackInfoCache = new ConcurrentHashMap<>();
// private final Map<String, List<String>> allTracksCache = new ConcurrentHashMap<>();
//Map<gameId, Map<player, List<String>>
//private Map<String, Map<String, List<String>>> playerTracksCache = new ConcurrentHashMap<>();
private Map<String, Map<String, List<String>>> playerTrackInfoCache = new ConcurrentHashMap<>();
// Cache: Spiel-ID → (Username → TrackInfos)
private final Map<String, Map<String, List<String>>> playerTrackInfoCache = new ConcurrentHashMap<>();
public GameWebSocketHandler(GameService gameService) {
this.service = gameService;
//this.authService = authService;
}
/**
@ -42,9 +37,7 @@ public class GameWebSocketHandler {
ws.onConnect(ctx -> {
String gameId = ctx.pathParam("gameId");
String username = ctx.queryParam("username");
// Spiel- und Session-Registrierung
service.addPlayer(gameId, username);
// Alle Clients über neue Spielerliste informieren
service.registerSession(gameId, ctx);
service.broadcastPlayers(gameId);
});
@ -55,16 +48,13 @@ public class GameWebSocketHandler {
String username = ctx.queryParam("username");
service.removeSession(gameId, ctx);
// Spieler aus der Spielerliste entfernen
var game = service.getOrCreateGame(gameId);
if (username != null && game.players().contains(username)) {
game.players().remove(username);
game.scores().remove(username);
// Optional: auch die Tracks entfernen
game.playerTracks().remove(username);
}
service.broadcastPlayers(gameId);
});
// Eingehende Nachrichten (Guesses & Player-Requests)
@ -74,143 +64,149 @@ public class GameWebSocketHandler {
String type = node.get("type").asText();
switch (type) {
case "guess" -> {
case "guess" -> { // Legacy: Single-Guess
String user = node.get("username").asText();
String guess = node.get("guess").asText();
// Guess speichern
currentGuesses
.computeIfAbsent(gameId, id -> new ConcurrentHashMap<>())
.put(user, guess);
// Wenn alle getippt haben, Ergebnis broadcasten
String single = node.get("guess").asText();
Map<String, List<String>> byUser =
currentGuesses.computeIfAbsent(gameId, id -> new ConcurrentHashMap<>());
byUser.put(user, List.of(single));
int numPlayers = service.getOrCreateGame(gameId).players().size();
if (currentGuesses.get(gameId).size() == numPlayers) {
if (byUser.size() == numPlayers) {
broadcastRoundResult(gameId);
}
}
case "submit-guesses" -> { // Multi-Select
String user = node.get("username").asText();
List<String> picks = new ArrayList<>();
JsonNode arr = node.get("guesses");
if (arr != null && arr.isArray()) {
arr.forEach(j -> picks.add(j.asText()));
}
Map<String, List<String>> byUser =
currentGuesses.computeIfAbsent(gameId, id -> new ConcurrentHashMap<>());
if (user != null && !picks.isEmpty()) {
byUser.put(user, new ArrayList<>(picks));
}
int numPlayers = service.getOrCreateGame(gameId).players().size();
if (byUser.size() == numPlayers) {
broadcastRoundResult(gameId);
}
}
case "requestPlayers" -> service.broadcastPlayers(gameId);
case "next-round" -> nextround(gameId);
case "start-round" -> {
// Guesses für dieses Spiel zurücksetzen
currentGuesses.put(gameId, new ConcurrentHashMap<>());
var currentGame = service.getOrCreateGame(gameId);
if (currentGame.players().isEmpty()) return;
// Tracks pro Spieler sammeln
Map<String, List<String>> allPlayerTracks = currentGame.playerTracks();
// alle tracks sammeln
List<String> allTracks = allPlayerTracks.values().stream()
.flatMap(List::stream)
.toList();
System.out.println("AlltracksCache für Spiel " + gameId + " hat " + allTracks.size() + " Songs (rundenstart)");
//Trackinfos für alle Spieler sammeln
if (playerTrackInfoCache.containsKey(gameId)) {
// Wenn der Cache schon existiert, dann nur die Trackinfos nutzen
System.out.println("TrackInfosCache für Spiel " + gameId + " existiert bereits (rundenstart)");
// prüfen ob ein neuer spieler dazugekommen ist
if (allPlayerTracks.size() > playerTrackInfoCache.get(gameId).size()) {
System.out.println("Neuer Spieler hinzugefügt, Trackinfos werden aktualisiert (rundenstart)");
Map<String, List<String>> allTrackInfos = service.getTrackInfos(allPlayerTracks);
// Cache für Trackinfos pro Spiel-ID aktualisieren
playerTrackInfoCache.put(gameId, allTrackInfos);
} else {
System.out.println("Keine neuen Spieler, Trackinfos bleiben unverändert (rundenstart)");
}
} else {
// Wenn der Cache nicht existiert, dann Trackinfos sammeln
System.out.println("TrackInfosCache für Spiel " + gameId + " wird erstellt (rundenstart)");
Map<String, List<String>> allTrackInfos = service.getTrackInfos(allPlayerTracks);
// Cache für Trackinfos pro Spiel-ID
playerTrackInfoCache.put(gameId, allTrackInfos);
}
System.out.println("TrackInfosCache für Spiel " + gameId + " hat " + playerTrackInfoCache.get(gameId).size() + " Spieler (rundenstart)");
} else {
Map<String, List<String>> allTrackInfos = service.getTrackInfos(allPlayerTracks);
playerTrackInfoCache.put(gameId, allTrackInfos);
}
if (!allTracks.isEmpty()) {
service.startRound(gameId, allTracks);
}
//
broadcastRoundStart(gameId);
broadcastRoundStart(gameId, true);
}
}
});
}
public void nextround(String gameId) {
currentGuesses.put(gameId, new ConcurrentHashMap<>()); // nur dieses Spiel leeren
var game = service.getOrCreateGame(gameId);
if (game.players().isEmpty()) return;
// Songs von allen Spielern sammeln
List<String> allTracks = new ArrayList<>();
for (String player : game.players()) {
allTracks.addAll(game.playerTracks().getOrDefault(player, List.of()));
}
if (allTracks.isEmpty()) {
// TODO: Fehler an Client senden, dass keine Songs da sind
broadcastToAll(gameId, JsonUtil.toJson(Map.of("type","error","message","Keine Tracks geladen")));
return;
}
// TODO funktionalität bei neu joinenden Spielern überprüfen
// Runde im Service starten, um Song und Owner zu setzen
service.startRound(gameId, allTracks);
// Jetzt Broadcast mit den aktuellen Daten
broadcastRoundStart(gameId);
broadcastRoundStart(gameId, false);
}
// ----- Broadcast-Methoden -----
/** Broadcastet den Runden-Start (Song + Optionen) an alle Clients. */
public void broadcastRoundStart(String gameId) {
public void broadcastRoundStart(String gameId, boolean initial) {
var game = service.getOrCreateGame(gameId);
List<String> opts = game.players();
String songUri = game.currentSong();
List<String> allTracks = game.allTracks();
Map<String, List<String>> trackInfos = playerTrackInfoCache.get(gameId);
// Cache pro Spiel-ID nutzen
String msg = JsonUtil.toJson(Map.of(
"type", "round-start",
"ownerOptions", opts,
"songUri", songUri,
"allTracks", allTracks,
"trackInfos", trackInfos
"trackInfos", trackInfos,
"initial", initial
));
broadcastToAll(gameId, msg);
}
/** Broadcastet das Rundenergebnis (Scores, wer richtig, wer getippt hat). */
// Punkte für alle Guess-Teilnehmer anpassen
private void broadcastRoundResult(String gameId) {
var game = service.getOrCreateGame(gameId);
Map<String,Integer> scores = game.scores();
Map<String,String> guesses = currentGuesses.remove(gameId);
String owner = game.currentOwner();
// System.out.println("Owner: " + owner);
// System.out.println("Guesses: " + guesses);
// System.out.println("Scores vor Auswertung: " + scores);
Map<String, List<String>> byUser =
currentGuesses.getOrDefault(gameId, Collections.emptyMap());
// Für jeden Tippenden Score anpassen
for (Map.Entry<String, String> entry : guesses.entrySet()) {
String guesser = entry.getKey();
boolean correct = owner.equals(entry.getValue());
scores.merge(guesser, correct ? 3 : -1, Integer::sum);
// Scoring: +3 falls Auswahl Owner enthält, -1 pro falschem Tipp
// Bonus: +1, wenn kein falscher Tipp in der Runde (fehlerfrei)
for (var e : byUser.entrySet()) {
String user = e.getKey();
List<String> guesses = e.getValue();
if (guesses == null) continue;
boolean correct = owner != null && guesses.contains(owner);
int wrong = guesses.size() - (correct ? 1 : 0);
//int delta = (correct ? 3 : 0) - wrong;
int bonus = (wrong == 0) ? 1 : 0; // fehlerfrei-Bonus
int delta = (correct ? 3 : 0) - wrong + bonus;
if (delta != 0) game.scores().merge(user, delta, Integer::sum);
}
// System.out.println("Owner: " + owner);
// System.out.println("Guesses: " + guesses);
// System.out.println("Scores nach Auswertung: " + scores);
var scores = game.scores();
String msg = JsonUtil.toJson(Map.of(
"type", "round-result",
"scores", scores,
"guesses", guesses,
"guesses", byUser,
"owner", owner
));
broadcastToAll(gameId, msg);
// Prüfe auf Gewinner
// Nur beenden, wenn EIN Spieler allein die höchste Punktzahl >= score hat
int score = 6;
int max = scores.values().stream().max(Integer::compareTo).orElse(0);
// Gewinner prüfen
int winScore = 6;
int max = scores.values().stream().mapToInt(Integer::intValue).max().orElse(0);
List<String> topScorers = scores.entrySet().stream()
.filter(e -> e.getValue() == max && max >= score)
.filter(e -> e.getValue() == max && max >= winScore)
.map(Map.Entry::getKey)
.toList();
@ -224,19 +220,10 @@ private void broadcastRoundResult(String gameId) {
broadcastToAll(gameId, winMsg);
game.scores().replaceAll((user, pts) -> 0); // Reset Scores
}
// else{
// // nächste Runde starten
// // ...
//// new Thread(() -> {
//// try { Thread.sleep(2000); } catch (InterruptedException ignored) {}
//// nextround(gameId);
//// }).start();
// }
}
/** Hilfsmethode: Sendet eine Nachricht an alle WebSocket-Sessions eines Spiels. */
private void broadcastToAll(String gameId, String msg) {
// Holt alle WsContext, die der Service beim Connect registriert hat
Set<WsContext> sessions = service.getSessions(gameId);
if (sessions == null) return;
sessions.stream()
@ -247,5 +234,4 @@ private void broadcastRoundResult(String gameId) {
} catch (Exception ignore) {}
});
}
}

View File

@ -52,6 +52,10 @@
.btn-ghost{background:transparent; color:var(--text); border:1px solid var(--border)}
.btn-ghost:hover{border-color:var(--muted)}
.muted{color:var(--muted)}
/* Sichtbarer, ausgegrauter Submit-Button nur im disabled-Zustand */
#submitGuesses:disabled,
.btn:disabled{opacity:.55; cursor:not-allowed; filter:grayscale(.35);pointer-events:none; transition:opacity .12s ease-in-out;
}
/* Grid */
.grid{display:grid; grid-template-columns:1.1fr .9fr; gap:24px; margin-top:20px}
@ -146,13 +150,105 @@
.wedge.correct { fill: var(--accent) !important; } /* grün */
.wedge.wrong { fill: #e22134 !important; } /* rot */
.wedge.disabled { pointer-events:none; opacity:.85; }
.wedge-label { fill:#fff; font-weight:700; font-size:35px; pointer-events:none; }
.wedge-label { fill:#fff; font-weight:600; font-size:35px; pointer-events:none; }
/* Zahl unter dem Namen im SVG */
.wedge-delta{
font-weight:600; font-size:35px; fill:#fff; pointer-events:none;
/* Lesbarkeit auf dunklem Hintergrund */
paint-order: stroke fill; stroke:#000; stroke-width:3px;
}
.wedge-delta.correct{ fill: #ffffff; }
.wedge-delta.wrong { fill: #ffffff; }
/* === Winner Overlay === */
.win-overlay{position:fixed; inset:0; display:none; align-items:center; justify-content:center;
background: radial-gradient(1000px 600px at 50% -10%, rgba(29,185,84,.18), transparent 60%), rgba(0,0,0,.72);
z-index:999}
.win-modal{width:min(720px,92vw); background:#121212; border:1px solid var(--border);
border-radius:24px; padding:28px 26px; box-shadow:0 24px 80px rgba(0,0,0,.6); text-align:center}
.win-trophy{width:88px; height:88px; margin:0 auto 10px; color:var(--accent); filter:drop-shadow(0 0 16px rgba(29,185,84,.35))}
.win-title{font-size:clamp(22px,4.5vw,28px); color:var(--muted); margin:6px 0 4px}
.win-name{font-size:clamp(32px,6vw,48px); font-weight:900; letter-spacing:.2px; margin:0 0 10px}
.win-points{color:#cfcfcf; margin:0 0 16px}
.win-actions{display:flex; gap:10px; justify-content:center; flex-wrap:wrap; margin-top:10px}
.confetti{position:absolute; top:-10vh; width:10px; height:16px; opacity:.9; animation:fall 3.2s linear infinite}
@keyframes fall{0%{transform:translateY(-10vh) rotate(0deg)}100%{transform:translateY(110vh) rotate(360deg)}}
/* Overlay-Scoreboard */
.win-leaderboard{ margin-top:14px; text-align:left }
.win-lead-title{ font-weight:800; color:#cfcfcf; margin:6px 0 8px }
.win-board{
list-style:none; padding:0; margin:0;
display:flex; flex-direction:column; gap:8px;
max-height:40vh; overflow:auto;
}
.win-board li{
display:flex; align-items:center; justify-content:space-between;
background:#0f0f0f; border:1px solid var(--border);
border-radius:12px; padding:10px 12px;
}
.win-board .left{ display:flex; align-items:center; gap:10px }
.win-board .rank{ width:28px; text-align:center; font-weight:900; color:#9f9f9f }
.win-board .name{ font-weight:700 }
.win-board .pts{ color:#cfcfcf; font-weight:700 }
.win-board li.winner{
border-color:var(--accent);
box-shadow:0 0 0 4px var(--glow);
}
/* === Round Recap (Ergebnis der Runde) === */
#result .recap{
border:1px solid var(--border); background:#0f0f0f;
border-radius:16px; padding:14px; box-shadow:var(--shadow);
animation:recap-pop .25s ease;
}
#result .recap-hd{
display:flex; align-items:center; justify-content:space-between;
margin-bottom:10px; font-weight:800; letter-spacing:.02em;
}
#result .recap-list{
list-style:none; margin:0; padding:0;
display:flex; flex-direction:column; gap:8px;
}
#result .recap-item{
display:flex; align-items:center; justify-content:space-between; gap:10px;
background:#101010; border:1px solid var(--border);
border-radius:12px; padding:10px 12px;
}
#result .recap-left{ display:flex; align-items:center; gap:12px; flex-wrap:wrap }
#result .recap-user{ font-weight:800 }
#result .chips{ display:flex; gap:6px; flex-wrap:wrap }
#result .chip{
font-weight:700; padding:6px 10px; border-radius:999px;
border:1px solid var(--border); background:#0f0f0f; color:#fff;
}
#result .chip.correct{
background:var(--accent); color:#0a0a0a; border-color:transparent;
box-shadow:0 0 0 4px var(--glow);
}
#result .chip.wrong{ background:#2a0c11; border-color:#7a1822 }
#result .chip.muted{ color:var(--muted) }
#result .delta{ min-width:64px; text-align:right; font-weight:900 }
#result .delta.positive{ color:var(--accent) }
#result .delta.negative{ color:#e22134 }
#result .delta.neutral{ color:#cfcfcf }
@keyframes recap-pop{
from{ transform:translateY(6px); opacity:.0 }
to{ transform:none; opacity:1 }
}
</style>
</head>
<body>
<div class="container">
<header>
<div class="brand">
<!--TO DO eigenes Logo-->
<div class="logo" aria-hidden="true">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 2 2 7l10 5 10-5-10-5Zm10 7-10 5v9l10-5V9ZM2 9v9l10 5v-9L2 9Z"/>
@ -218,6 +314,32 @@
<!-- Toast -->
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<!-- Winner Overlay -->
<div id="winnerOverlay" class="win-overlay" role="dialog" aria-modal="true" aria-labelledby="winName">
<div class="win-modal">
<div class="win-trophy" aria-hidden="true">
<svg viewBox="0 0 24 24" width="88" height="88" fill="currentColor">
<path d="M19 3h-3V2a1 1 0 0 0-1-1H9a1 1 0 0 0-1 1v1H5a1 1 0 0 0-1 1v2a5 5 0 0 0 5 5h.1A5.98 5.98 0 0 0 11 14.9V17H8a1 1 0 1 0 0 2h8a1 1 0 1 0 0-2h-3v-2.1A5.98 5.98 0 0 0 15.9 11H16a5 5 0 0 0 5-5V4a1 1 0 0 0-1-1ZM6 6V5h2v2a3 3 0 0 1-3-1Zm14 0a3 3 0 0 1-3 1V5h3v1Z"/>
</svg>
</div>
<p class="win-title">Gewonnen!</p>
<h2 id="winName" class="win-name"></h2>
<p id="winPoints" class="win-points"></p>
<!-- Leaderboard im Overlay -->
<div class="win-leaderboard">
<div class="win-lead-title">Leaderboard</div>
<ol id="winBoard" class="win-board"></ol>
</div>
<div class="win-actions">
<!-- <button id="winNext" class="btn btn-primary">Neue Runde</button> -->
<button id="winClose" class="btn btn-ghost">Schließen</button>
<button id="winShare" class="btn btn-ghost">Teilen</button>
</div>
</div>
</div>
<!-- Module -->
<script type="module" src="/js/utils.js"></script>
<script type="module" src="/js/start-round.js"></script>

View File

@ -132,14 +132,91 @@ function wedgePath(cx, cy, r, a0, a1){
const largeArc = ((a1 - a0 + 360) % 360) > 180 ? 1 : 0;
return `M ${cx} ${cy} L ${p0.x} ${p0.y} A ${r} ${r} 0 ${largeArc} 1 ${p1.x} ${p1.y} Z`;
}
// Zahl unter dem Namen im Pie-Label setzen/entfernen
function setWedgeDeltaByUser(user, text, kind /* 'correct' | 'wrong' */) {
const svg = document.querySelector("#options svg.options-svg");
if (!svg) return;
const label = Array.from(svg.querySelectorAll("text.wedge-label"))
.find(t => t.getAttribute("data-user") === user);
if (!label) return;
// Entfernen?
if (!text) {
const old = Array.from(svg.querySelectorAll("text.wedge-delta"))
.find(t => t.getAttribute("data-user") === user);
if (old) old.remove();
return;
}
let delta = Array.from(svg.querySelectorAll("text.wedge-delta"))
.find(t => t.getAttribute("data-user") === user);
if (!delta) {
delta = document.createElementNS(SVGNS, "text");
delta.setAttribute("class", "wedge-delta");
delta.setAttribute("data-user", user);
delta.setAttribute("text-anchor", "middle");
delta.setAttribute("dominant-baseline", "hanging");
// Outline für bessere Lesbarkeit (Backup, falls CSS nicht greift)
delta.setAttribute("stroke", "#000");
delta.setAttribute("stroke-width", "3");
delta.setAttribute("paint-order", "stroke fill");
svg.appendChild(delta);
}
delta.setAttribute("x", label.getAttribute("x"));
delta.setAttribute("y", parseFloat(label.getAttribute("y")) + 18);
delta.textContent = text;
delta.classList.toggle("correct", kind === "correct");
delta.classList.toggle("wrong", kind === "wrong");
}
// Auswahl-Set für Multi-Guess (pro Runde zurückgesetzt)
let selectedGuesses = new Set();
let lastScores = null;
let initializedScoreboard = false; // <- neu
function ensureSubmitBtn(optionsDiv) {
let btn = document.getElementById("submitGuesses");
if (!btn) {
btn = document.createElement("button");
btn.id = "submitGuesses";
btn.className = "btn btn-primary";
btn.style.marginTop = "8px";
btn.textContent = "Tipps abgeben";
// Button direkt unter den Optionen platzieren
optionsDiv.parentElement.appendChild(btn);
}
return btn;
}
// 6) Neue Runde anzeigen
async function handleRoundStart({ ownerOptions, songUri, trackInfos }) {
async function handleRoundStart({ ownerOptions, songUri, trackInfos, initial }) {
// UI zurücksetzen
resultP.textContent = "";
optionsDiv.innerHTML = "";
songEmbed.innerHTML = "";
//scoreboard zurücksetzen
//scoreboard.innerHTML = "";
selectedGuesses = new Set();
if (initial === true && scoreboard) {
lastScores = null;
scoreboard.innerHTML = "";
(ownerOptions || []).forEach(u => {
const li = document.createElement("li");
li.innerHTML = `<span>${u}</span><b>0 Punkte</b>`;
scoreboard.appendChild(li);
});
}
const nextBtn = document.getElementById("nextRound");
if (nextBtn) {
nextBtn.hidden = true;
nextBtn.disabled = true;
}
// Song einbetten
const trackId = songUri.split(":")[2];
songEmbed.innerHTML = `
@ -170,14 +247,26 @@ async function handleRoundStart({ ownerOptions, songUri, trackInfos }) {
path.setAttribute("d", wedgePath(CX, CY, R, a0, a1));
path.setAttribute("class", "wedge");
path.setAttribute("data-user", user);
// path.addEventListener("click", () => {
// socket.send(JSON.stringify({
// type: "guess",
// username: username,
// guess: user
// }));
// svg.querySelectorAll(".wedge").forEach(w => w.classList.add("disabled"));
// path.classList.add("selected");
// });
// Toggle-Selection statt sofort zu senden
path.addEventListener("click", () => {
socket.send(JSON.stringify({
type: "guess",
username: username,
guess: user
}));
svg.querySelectorAll(".wedge").forEach(w => w.classList.add("disabled"));
path.classList.add("selected");
const u = path.getAttribute("data-user");
if (path.classList.contains("disabled")) return;
if (path.classList.toggle("selected")) selectedGuesses.add(u);
else selectedGuesses.delete(u);
//submitBtn.disabled = selectedGuesses.size === 0;
const hasSelection = selectedGuesses.size > 0;
submitBtn.hidden = !hasSelection; // ohne Auswahl: versteckt
submitBtn.disabled = !hasSelection; // mit Auswahl: enabled
});
// Label mittig im Segment
@ -198,6 +287,7 @@ async function handleRoundStart({ ownerOptions, songUri, trackInfos }) {
text.setAttribute("x", P.x);
text.setAttribute("y", P.y);
text.setAttribute("class", "wedge-label");
text.setAttribute("data-user", user); // <— NEU: verbindet Label mit User
text.setAttribute("text-anchor", "middle");
text.setAttribute("dominant-baseline", "middle");
text.textContent = user;
@ -206,6 +296,38 @@ async function handleRoundStart({ ownerOptions, songUri, trackInfos }) {
svg.appendChild(text);
});
// Submit-Button dynamisch hinzufügen
let submitBtn = document.getElementById("submitGuesses");
if (!submitBtn) {
submitBtn = document.createElement("button");
submitBtn.id = "submitGuesses";
submitBtn.className = "btn btn-primary";
submitBtn.style.marginTop = "8px";
submitBtn.textContent = "Submit";
optionsDiv.parentElement.appendChild(submitBtn);
}
//submitBtn.hidden = false;
// warum disabled?
//submitBtn.disabled = true;
// Zu Beginn der Runde: Button verstecken, bis eine Auswahl existiert
submitBtn.hidden = true;
submitBtn.disabled = true;
submitBtn.onclick = () => {
if (!selectedGuesses.size) return;
const guesses = Array.from(selectedGuesses);
socket.send(JSON.stringify({
type: "submit-guesses",
username,
guesses
}));
//submitBtn.disabled = true;
// Nach Abgabe sichtbar, aber disabled (ausgegraut)
submitBtn.hidden = false;
submitBtn.disabled = true;
// Sperre Eingaben nach Abgabe
optionsDiv.querySelectorAll(".wedge").forEach(w => w.classList.add("disabled"));
};
// Start-Button ausblenden + Rundensektion einblenden
startBtn.hidden = true;
startBtn.disabled = true;
@ -242,48 +364,211 @@ async function handleRoundStart({ ownerOptions, songUri, trackInfos }) {
// 7) Ergebnis + Weiter-Button
function renderScoreboard(scores) {
scoreboard.innerHTML = "";
Object.entries(scores).forEach(([user, pts]) => {
if (!scores) return;
const entries = Object.entries(scores)
.sort((a, b) => (b[1] - a[1]) || a[0].localeCompare(b[0])); // Punkte absteigend
for (const [user, pts] of entries) {
const li = document.createElement("li");
li.textContent = `${user}: ${pts} Punkte`;
scoreboard.append(li);
});
}
}
//let lastScores = null;
// function handleRoundResult({ scores, guesses, owner }) {
// renderScoreboard(scores);
// lastScores = scores;
//
// (() => {
// // Button verstecken/disable und Eingaben sperren
// const sb = document.getElementById('submitGuesses');
// if (sb) { sb.disabled = true; sb.hidden = true; }
// document.querySelectorAll('#options .wedge').forEach(w => w.classList.add('disabled'));
// // lokale Auswahl leeren (optional)
// try { selectedGuesses.clear?.(); } catch(_) {}
// })();
//
// try {
// const wedges = document.querySelectorAll("#options .wedge");
//
// // Owner-Slice immer grün
// wedges.forEach(w => {
// if (w.getAttribute("data-user") === owner) w.classList.add("correct");
// });
//
// // Nur die EIGENE Auswahl einfärben: rot wenn falsch, sonst grün
// // const myGuess = guesses?.[username];
// // if (myGuess) {
// // const myWedge = Array.from(wedges).find(w => w.getAttribute("data-user") === myGuess);
// // if (myWedge) {
// // if (myGuess === owner) {
// // myWedge.classList.add("correct");
// // } else {
// // myWedge.classList.add("wrong"); // nur dieser wird rot
// // }
// // }
// // }
// // } catch (e) {}
// const my = guesses?.[username];
// const myArr = Array.isArray(my) ? my : (typeof my === "string" ? [my] : []);
// if (myArr.length) {
// myArr.forEach(sel => {
// const w = Array.from(wedges).find(x => x.getAttribute("data-user") === sel);
// if (!w) return;
// if (sel === owner) w.classList.add("correct");
// else w.classList.add("wrong");
// });
// }
//
// } catch (_) {}
//
//
// // resultP.innerHTML = "";
// // Object.entries(guesses || {}).forEach(([user, guess]) => {
// // const correct = guess === owner;
// // const icon = correct ? "✅" : "❌";
// // const delta = correct ? "+3" : "-1";
// // const p = document.createElement("p");
// // p.textContent = `${icon} ${user} hat auf ${guess} getippt${correct ? " (richtig!)" : " (falsch)"} [${delta}]`;
// // resultP.appendChild(p);
// // });
// resultP.innerHTML = "";
// Object.entries(guesses || {}).forEach(([user, g]) => {
// const list = Array.isArray(g) ? g : (typeof g === "string" ? [g] : []);
// const correct = list.includes(owner);
// const wrongCount = list.length - (correct ? 1 : 0);
// //const delta = (correct ? 3 : 0) - wrongCount;
// const bonus = wrongCount === 0 ? 1 : 0; // fehlerfrei-Bonus
// const delta = (correct ? 3 : 0) - wrongCount + bonus;
// const icon = correct ? "✅" : "❌";
// const picks = list.length ? list.join(", ") : "—";
// const p = document.createElement("p");
// //p.textContent = `${icon} ${user} hat auf ${picks} getippt${correct ? " (richtig!)" : ""} [${delta >= 0 ? "+" : ""}${delta}]`;
// p.textContent = `${icon} ${user} hat auf ${picks} getippt${correct ? " (richtig!)" : ""}${bonus ? " (+1 Bonus)" : ""} [${delta >= 0 ? "+" : ""}${delta}]`;
// resultP.appendChild(p);
// });
//
// const nextBtn = document.getElementById("nextRound");
// nextBtn.hidden = false;
// nextBtn.disabled = false;
// nextBtn.onclick = () => {
// socket.send(JSON.stringify({ type: "next-round" }));
// nextBtn.hidden = true;
// nextBtn.disabled = true;
// resultP.textContent = "";
// startBtn.hidden = true;
// startBtn.disabled = true;
// roundArea.hidden = true;
// //const submitBtn = document.getElementById("submitGuesses");
// //if (submitBtn) submitBtn.hidden = true;
// const submitBtn = document.getElementById("submitGuesses");
// if (submitBtn) { submitBtn.hidden = true; submitBtn.disabled = true; }
// };
// }
// zeigt/entfernt den kleinen +3 / -1 Badge
function setWedgeDelta(wedgeEl, text) {
let tag = wedgeEl.querySelector('.delta-tag');
if (!text) { if (tag) tag.remove(); return; }
if (!tag) {
tag = document.createElement('span');
tag.className = 'delta-tag';
wedgeEl.appendChild(tag);
}
tag.textContent = text;
}
function handleRoundResult({ scores, guesses, owner }) {
renderScoreboard(scores);
lastScores = scores;
// Eingaben sperren & Submit ausblenden
(() => {
const sb = document.getElementById('submitGuesses');
if (sb) { sb.disabled = true; sb.hidden = true; }
document.querySelectorAll('#options .wedge').forEach(w => w.classList.add('disabled'));
try { selectedGuesses.clear?.(); } catch(_) {}
})();
// Wedges einfärben: Owner immer grün, eigene falsche rot
try {
const wedges = document.querySelectorAll("#options .wedge");
// (NEU) Alle alten Delta-Zahlen entfernen
const svg = document.querySelector("#options svg.options-svg");
if (svg) svg.querySelectorAll("text.wedge-delta").forEach(n => n.remove());
// Optional: global immer +3 am Owner anzeigen?
const SHOW_GLOBAL_DELTAS = false; // <- auf true setzen, wenn alle es sehen sollen
// Owner-Slice immer grün
wedges.forEach(w => {
if (w.getAttribute("data-user") === owner) w.classList.add("correct");
if (w.getAttribute("data-user") === owner) {
w.classList.add("correct");
if (SHOW_GLOBAL_DELTAS) setWedgeDeltaByUser(owner, "+3", "correct"); // (NEU)
}
});
// Nur die EIGENE Auswahl einfärben: rot wenn falsch, sonst grün
const myGuess = guesses?.[username];
if (myGuess) {
const myWedge = Array.from(wedges).find(w => w.getAttribute("data-user") === myGuess);
if (myWedge) {
if (myGuess === owner) {
myWedge.classList.add("correct");
const my = guesses?.[username];
const myArr = Array.isArray(my) ? my : (typeof my === "string" ? [my] : []);
if (myArr.length) {
myArr.forEach(sel => {
const w = Array.from(wedges).find(x => x.getAttribute("data-user") === sel);
if (!w) return;
if (sel === owner) {
w.classList.add("correct");
setWedgeDeltaByUser(sel, "+3", "correct"); // (NEU)
} else {
myWedge.classList.add("wrong"); // nur dieser wird rot
w.classList.add("wrong");
setWedgeDeltaByUser(sel, "-1", "wrong"); // (NEU)
}
}
}
} catch (e) {}
resultP.innerHTML = "";
Object.entries(guesses || {}).forEach(([user, guess]) => {
const correct = guess === owner;
const icon = correct ? "✅" : "❌";
const delta = correct ? "+3" : "-1";
const p = document.createElement("p");
p.textContent = `${icon} ${user} hat auf ${guess} getippt${correct ? " (richtig!)" : " (falsch)"} [${delta}]`;
resultP.appendChild(p);
});
}
} catch (_) {}
// Schöne Recap-Card rendern
const toChip = (text, cls="") => `<span class="chip ${cls}">${text}</span>`;
const listItems = Object.entries(guesses || {}).map(([user, g]) => {
const arr = Array.isArray(g) ? g : (typeof g === "string" ? [g] : []);
const correct = arr.includes(owner);
const wrongCount = arr.length - (correct ? 1 : 0);
const bonus = wrongCount === 0 ? 1 : 0; // fehlerfrei-Bonus
const delta = (correct ? 3 : 0) - wrongCount + bonus;
const chips = arr.length
? arr.map(sel => toChip(sel, sel === owner ? "correct" : "wrong")).join("")
: toChip("—", "muted");
const deltaCls = delta > 0 ? "positive" : delta < 0 ? "negative" : "neutral";
const deltaTxt = `${delta >= 0 ? "+" : ""}${delta}`;
return `
<li class="recap-item">
<div class="recap-left">
<div class="recap-user">${user}</div>
<div class="chips">${chips}</div>
</div>
<div class="delta ${deltaCls}">${deltaTxt}</div>
</li>`;
}).join("");
resultP.innerHTML = `
<div class="recap">
<div class="recap-hd">
<span>Rundenergebnis</span>
<span class="pill">Song von <b>${owner}</b></span>
</div>
<ul class="recap-list">${listItems}</ul>
</div>
`;
// Weiter-Button freigeben
const nextBtn = document.getElementById("nextRound");
nextBtn.hidden = false;
nextBtn.disabled = false;
@ -295,22 +580,100 @@ function handleRoundResult({ scores, guesses, owner }) {
startBtn.hidden = true;
startBtn.disabled = true;
roundArea.hidden = true;
const submitBtn = document.getElementById("submitGuesses");
if (submitBtn) { submitBtn.hidden = true; submitBtn.disabled = true; }
};
}
// 8) Spielende -> Start-Button zurück
function handleGameEnd({ winner }) {
const overlay = document.getElementById("winnerOverlay");
const nameEl = document.getElementById("winName");
const ptsEl = document.getElementById("winPoints");
//const btnNext = document.getElementById("winNext");
const btnClose= document.getElementById("winClose");
const btnShare= document.getElementById("winShare");
nameEl.textContent = winner;
const pts = lastScores && typeof lastScores[winner] !== "undefined" ? lastScores[winner] : null;
ptsEl.textContent = pts != null ? `${winner} gewinnt mit ${pts} Punkten.` : "";
// Leaderboard im Overlay rendern (aus Cache lastScores)
const boardEl = document.getElementById("winBoard");
if (boardEl) {
boardEl.innerHTML = "";
const entries = lastScores
? Object.entries(lastScores).sort((a,b)=> (b[1]-a[1]) || a[0].localeCompare(b[0]))
: [];
entries.forEach(([user, pts], i) => {
const li = document.createElement("li");
if (user === winner) li.classList.add("winner");
const medal = i===0 ? "🥇" : i===1 ? "🥈" : i===2 ? "🥉" : String(i+1);
li.innerHTML = `
<div class="left">
<div class="rank">${medal}</div>
<div class="name">${user}</div>
</div>
<div class="pts">${pts} Punkte</div>
`;
boardEl.appendChild(li);
});
}
// Konfetti erzeugen (einmalig pro Anzeige)
Array.from(overlay.querySelectorAll(".confetti")).forEach(n => n.remove());
const colors = [ "var(--accent)", "#21d07a", "#b3b3b3", "#ffffff" ];
for (let i=0;i<24;i++){
const c = document.createElement("div");
c.className = "confetti";
c.style.left = Math.random()*100 + "vw";
c.style.background = colors[i % colors.length];
c.style.animationDelay = (Math.random()*1.2) + "s";
c.style.transform = `translateY(-10vh) rotate(${Math.random()*360}deg)`;
overlay.appendChild(c);
}
// Buttons
//btnNext.onclick = () => {
// socket.send(JSON.stringify({ type: "next-round" }));
// overlay.style.display = "none";
// roundArea.hidden = true;
// resultP.textContent = "";
//};
btnClose.onclick = () => { overlay.style.display = "none"; };
btnShare.onclick = async () => {
const text = `🏆 ${winner} hat Spotify Roulette gewonnen!`;
try {
if (navigator.share) await navigator.share({ text });
else {
await navigator.clipboard.writeText(text);
showToast("Text kopiert teilen!");
}
} catch (_) {}
};
const nextBtn = document.getElementById("nextRound");
resultP.textContent = `🎉 ${winner} hat gewonnen!`;
nextBtn.hidden = true;
nextBtn.disabled = true;
setTimeout(() => {
//resultP.textContent = `🎉 ${winner} hat gewonnen!`;
//nextBtn.hidden = true;
//nextBtn.disabled = true;
if (nextBtn) { nextBtn.hidden = true; nextBtn.disabled = true; }
startBtn.hidden = false;
startBtn.disabled = false;
roundArea.hidden = true;
scoreboard.innerHTML = "";
}, 6000);
// Overlay zeigen
overlay.style.display = "flex";
//setTimeout(() => {
// startBtn.hidden = false;
// startBtn.disabled = false;
// roundArea.hidden = true;
// scoreboard.innerHTML = "";
//}, 6000);
}
// Spotify-Playback Funktion (unverändert)

View File

@ -1,25 +1,29 @@
// public/js/start-round.js
// javascript
import { getParam } from "./utils.js";
/**
* Bindet den Klick-Handler an den "Runde starten"-Button,
* der per WebSocket an den Server das Start-Event feuert.
* @param {WebSocket} socket die geöffnete WS-Verbindung
*/
export function setupStartRound(socket) {
const gameId = getParam("gameId");
if (!gameId || socket.readyState !== WebSocket.OPEN) return;
const startBtn = document.getElementById("startRound");
startBtn.addEventListener("click", () => {
// Button direkt deaktivieren, bis neue Runde kommt
startBtn.disabled = true;
if (!startBtn || !gameId) return;
// Sende das Start-Runden-Event an den Server
socket.send(JSON.stringify({
type: "start-round",
gameId: gameId
}));
});
// Mehrfaches Registrieren verhindern
if (startBtn.dataset.handlerAttached === "1") return;
startBtn.dataset.handlerAttached = "1";
startBtn.onclick = async () => {
if (startBtn.disabled) return;
startBtn.disabled = true; // Doppelklick verhindern
try {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: "start-round" }));
} else {
await fetch(`/api/game/${encodeURIComponent(gameId)}/start-round`, { method: "POST" });
}
// UI-Änderungen (hidden usw.) macht bereits handleRoundStart in game.js
} catch (_) {
// Bei Fehler kurz reaktivieren, damit der Nutzer erneut versuchen kann
setTimeout(() => { startBtn.disabled = false; }, 1200);
}
};
}