commit
c14606cd2b
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
|
// java
|
||||||
package eric.Roullette.websocket;
|
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;
|
||||||
|
|
@ -17,20 +17,15 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||||
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
|
|
||||||
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()) {
|
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);
|
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);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
System.out.println("TrackInfosCache für Spiel " + gameId + " hat " + playerTrackInfoCache.get(gameId).size() + " Spieler (rundenstart)");
|
Map<String, List<String>> allTrackInfos = service.getTrackInfos(allPlayerTracks);
|
||||||
|
playerTrackInfoCache.put(gameId, allTrackInfos);
|
||||||
|
}
|
||||||
|
|
||||||
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();
|
||||||
|
|
||||||
|
|
@ -224,19 +220,10 @@ private void broadcastRoundResult(String gameId) {
|
||||||
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) {}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,29 @@
|
||||||
// public/js/start-round.js
|
// javascript
|
||||||
|
|
||||||
import { getParam } from "./utils.js";
|
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) {
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue