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) 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).
- 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 ## 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) { private void startRound(Context ctx) {
String gameId = ctx.pathParam("gameId"); String gameId = ctx.pathParam("gameId");
ctx.json(Map.of("status", "ok")); ctx.json(Map.of("status", "ok"));
webSocketHandler.broadcastRoundStart(gameId); webSocketHandler.broadcastRoundStart(gameId,true);
} }
private void guess(Context ctx) { private void guess(Context ctx) {
@ -132,7 +132,8 @@ public class GameController {
return; return;
} }
boolean correct = guess.equals(owner); 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( ctx.json(Map.of(
"correct", correct, "correct", correct,
"owner", owner, "owner", owner,
@ -152,7 +153,11 @@ public class GameController {
try { try {
//String accessToken = authService.getAccessTokenForUser(username); //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; OkHttpClient client = httpClient;
String trackId = trackUri.split(":")[2]; String trackId = trackUri.split(":")[2];
Request getTrack = new Request.Builder() Request getTrack = new Request.Builder()

View File

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

View File

@ -10,7 +10,7 @@
<style> <style>
:root{ :root{
--bg:#121212; --card:#181818; --border:#282828; --text:#fff; --muted:#b3b3b3; --bg:#121212; --card:#181818; --border:#282828; --text:#fff; --muted:#b3b3b3;
--accent:#1db954; --accent-press:#169e47; --glow:rgba(29,185,84,.25); --accent: #1db954; --accent-press:#169e47; --glow:rgba(29,185,84,.25);
--radius:16px; --shadow:0 10px 30px rgba(0,0,0,.35) --radius:16px; --shadow:0 10px 30px rgba(0,0,0,.35)
} }
*{box-sizing:border-box} *{box-sizing:border-box}

View File

@ -13,7 +13,7 @@
<style> <style>
:root{ :root{
--bg:#121212; --elev:#181818; --border:#282828; --text:#fff; --muted:#b3b3b3; --bg:#121212; --elev:#181818; --border:#282828; --text:#fff; --muted:#b3b3b3;
--accent:#1db954; --accent-press:#169e47; --glow:rgba(29,185,84,.25); --accent: #1db954; --accent-press:#169e47; --glow:rgba(29,185,84,.25);
--radius:16px; --shadow:0 10px 30px rgba(0,0,0,.35); --radius:16px; --shadow:0 10px 30px rgba(0,0,0,.35);
} }
*{box-sizing:border-box} *{box-sizing:border-box}
@ -52,6 +52,10 @@
.btn-ghost{background:transparent; color:var(--text); border:1px solid var(--border)} .btn-ghost{background:transparent; color:var(--text); border:1px solid var(--border)}
.btn-ghost:hover{border-color:var(--muted)} .btn-ghost:hover{border-color:var(--muted)}
.muted{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 */
.grid{display:grid; grid-template-columns:1.1fr .9fr; gap:24px; margin-top:20px} .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.correct { fill: var(--accent) !important; } /* grün */
.wedge.wrong { fill: #e22134 !important; } /* rot */ .wedge.wrong { fill: #e22134 !important; } /* rot */
.wedge.disabled { pointer-events:none; opacity:.85; } .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> </style>
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<header> <header>
<div class="brand"> <div class="brand">
<!--TO DO eigenes Logo-->
<div class="logo" aria-hidden="true"> <div class="logo" aria-hidden="true">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" 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"/> <path d="M12 2 2 7l10 5 10-5-10-5Zm10 7-10 5v9l10-5V9ZM2 9v9l10 5v-9L2 9Z"/>
@ -218,6 +314,32 @@
<!-- Toast --> <!-- Toast -->
<div id="toast" class="toast" role="status" aria-live="polite"></div> <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 --> <!-- Module -->
<script type="module" src="/js/utils.js"></script> <script type="module" src="/js/utils.js"></script>
<script type="module" src="/js/start-round.js"></script> <script type="module" src="/js/start-round.js"></script>

View File

@ -10,7 +10,7 @@
<style> <style>
:root{ :root{
--bg:#121212; --card:#181818; --border:#282828; --text:#fff; --bg:#121212; --card:#181818; --border:#282828; --text:#fff;
--muted:#b3b3b3; --accent:#1db954; --accent-press:#169e47; --glow:rgba(29,185,84,.25); --muted:#b3b3b3; --accent: #1db954; --accent-press:#169e47; --glow:rgba(29,185,84,.25);
--radius:16px; --shadow:0 10px 30px rgba(0,0,0,.35) --radius:16px; --shadow:0 10px 30px rgba(0,0,0,.35)
} }
*{box-sizing:border-box} *{box-sizing:border-box}

View File

@ -132,14 +132,91 @@ function wedgePath(cx, cy, r, a0, a1){
const largeArc = ((a1 - a0 + 360) % 360) > 180 ? 1 : 0; 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`; 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 // 6) Neue Runde anzeigen
async function handleRoundStart({ ownerOptions, songUri, trackInfos }) { async function handleRoundStart({ ownerOptions, songUri, trackInfos, initial }) {
// UI zurücksetzen // UI zurücksetzen
resultP.textContent = ""; resultP.textContent = "";
optionsDiv.innerHTML = ""; optionsDiv.innerHTML = "";
songEmbed.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 // Song einbetten
const trackId = songUri.split(":")[2]; const trackId = songUri.split(":")[2];
songEmbed.innerHTML = ` songEmbed.innerHTML = `
@ -170,14 +247,26 @@ async function handleRoundStart({ ownerOptions, songUri, trackInfos }) {
path.setAttribute("d", wedgePath(CX, CY, R, a0, a1)); path.setAttribute("d", wedgePath(CX, CY, R, a0, a1));
path.setAttribute("class", "wedge"); path.setAttribute("class", "wedge");
path.setAttribute("data-user", user); 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", () => { path.addEventListener("click", () => {
socket.send(JSON.stringify({ const u = path.getAttribute("data-user");
type: "guess", if (path.classList.contains("disabled")) return;
username: username, if (path.classList.toggle("selected")) selectedGuesses.add(u);
guess: user else selectedGuesses.delete(u);
})); //submitBtn.disabled = selectedGuesses.size === 0;
svg.querySelectorAll(".wedge").forEach(w => w.classList.add("disabled")); const hasSelection = selectedGuesses.size > 0;
path.classList.add("selected"); submitBtn.hidden = !hasSelection; // ohne Auswahl: versteckt
submitBtn.disabled = !hasSelection; // mit Auswahl: enabled
}); });
// Label mittig im Segment // Label mittig im Segment
@ -198,6 +287,7 @@ async function handleRoundStart({ ownerOptions, songUri, trackInfos }) {
text.setAttribute("x", P.x); text.setAttribute("x", P.x);
text.setAttribute("y", P.y); text.setAttribute("y", P.y);
text.setAttribute("class", "wedge-label"); text.setAttribute("class", "wedge-label");
text.setAttribute("data-user", user); // <— NEU: verbindet Label mit User
text.setAttribute("text-anchor", "middle"); text.setAttribute("text-anchor", "middle");
text.setAttribute("dominant-baseline", "middle"); text.setAttribute("dominant-baseline", "middle");
text.textContent = user; text.textContent = user;
@ -206,6 +296,38 @@ async function handleRoundStart({ ownerOptions, songUri, trackInfos }) {
svg.appendChild(text); 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 // Start-Button ausblenden + Rundensektion einblenden
startBtn.hidden = true; startBtn.hidden = true;
startBtn.disabled = true; startBtn.disabled = true;
@ -242,48 +364,211 @@ async function handleRoundStart({ ownerOptions, songUri, trackInfos }) {
// 7) Ergebnis + Weiter-Button // 7) Ergebnis + Weiter-Button
function renderScoreboard(scores) { function renderScoreboard(scores) {
scoreboard.innerHTML = ""; 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"); const li = document.createElement("li");
li.textContent = `${user}: ${pts} Punkte`; li.textContent = `${user}: ${pts} Punkte`;
scoreboard.append(li); 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 }) { function handleRoundResult({ scores, guesses, owner }) {
renderScoreboard(scores); 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 { try {
const wedges = document.querySelectorAll("#options .wedge"); 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 // Owner-Slice immer grün
wedges.forEach(w => { 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 my = guesses?.[username];
const myGuess = guesses?.[username]; const myArr = Array.isArray(my) ? my : (typeof my === "string" ? [my] : []);
if (myGuess) { if (myArr.length) {
const myWedge = Array.from(wedges).find(w => w.getAttribute("data-user") === myGuess); myArr.forEach(sel => {
if (myWedge) { const w = Array.from(wedges).find(x => x.getAttribute("data-user") === sel);
if (myGuess === owner) { if (!w) return;
myWedge.classList.add("correct");
if (sel === owner) {
w.classList.add("correct");
setWedgeDeltaByUser(sel, "+3", "correct"); // (NEU)
} else { } 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"); const nextBtn = document.getElementById("nextRound");
nextBtn.hidden = false; nextBtn.hidden = false;
nextBtn.disabled = false; nextBtn.disabled = false;
@ -295,22 +580,100 @@ function handleRoundResult({ scores, guesses, owner }) {
startBtn.hidden = true; startBtn.hidden = true;
startBtn.disabled = true; startBtn.disabled = true;
roundArea.hidden = true; roundArea.hidden = true;
const submitBtn = document.getElementById("submitGuesses");
if (submitBtn) { submitBtn.hidden = true; submitBtn.disabled = true; }
}; };
} }
// 8) Spielende -> Start-Button zurück // 8) Spielende -> Start-Button zurück
function handleGameEnd({ winner }) { 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"); const nextBtn = document.getElementById("nextRound");
resultP.textContent = `🎉 ${winner} hat gewonnen!`; //resultP.textContent = `🎉 ${winner} hat gewonnen!`;
nextBtn.hidden = true; //nextBtn.hidden = true;
nextBtn.disabled = true; //nextBtn.disabled = true;
setTimeout(() => { if (nextBtn) { nextBtn.hidden = true; nextBtn.disabled = true; }
startBtn.hidden = false; startBtn.hidden = false;
startBtn.disabled = false; startBtn.disabled = false;
roundArea.hidden = true;
scoreboard.innerHTML = ""; // Overlay zeigen
}, 6000); overlay.style.display = "flex";
//setTimeout(() => {
// startBtn.hidden = false;
// startBtn.disabled = false;
// roundArea.hidden = true;
// scoreboard.innerHTML = "";
//}, 6000);
} }
// Spotify-Playback Funktion (unverändert) // Spotify-Playback Funktion (unverändert)

View File

@ -1,25 +1,29 @@
// public/js/start-round.js // javascript
import { getParam } from "./utils.js";
import { getParam } from "./utils.js"; export function setupStartRound(socket) {
/**
* 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"); const gameId = getParam("gameId");
if (!gameId || socket.readyState !== WebSocket.OPEN) return;
const startBtn = document.getElementById("startRound"); const startBtn = document.getElementById("startRound");
startBtn.addEventListener("click", () => { if (!startBtn || !gameId) return;
// Button direkt deaktivieren, bis neue Runde kommt
startBtn.disabled = true;
// Sende das Start-Runden-Event an den Server // Mehrfaches Registrieren verhindern
socket.send(JSON.stringify({ if (startBtn.dataset.handlerAttached === "1") return;
type: "start-round", startBtn.dataset.handlerAttached = "1";
gameId: gameId
})); 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);
}
};
}

View File

@ -10,7 +10,7 @@
<style> <style>
:root{ :root{
--bg:#121212; --card:#181818; --border:#282828; --text:#fff; --muted:#b3b3b3; --bg:#121212; --card:#181818; --border:#282828; --text:#fff; --muted:#b3b3b3;
--accent:#1db954; --accent-press:#169e47; --glow:rgba(29,185,84,.25); --accent: #1db954; --accent-press:#169e47; --glow:rgba(29,185,84,.25);
--radius:16px; --shadow:0 10px 30px rgba(0,0,0,.35) --radius:16px; --shadow:0 10px 30px rgba(0,0,0,.35)
} }
*{box-sizing:border-box} *{box-sizing:border-box}