From 739db427ab17cccd38f6ed105e8f451d76236405 Mon Sep 17 00:00:00 2001 From: eric <3024947@stud.hs-mannheim.de> Date: Thu, 14 Aug 2025 18:33:35 +0200 Subject: [PATCH] =?UTF-8?q?mehrere=20ausw=C3=A4hlbar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../websocket/GameWebSocketHandler.java | 435 +++++++++--------- src/main/resources/public/js/game.js | 137 +++++- 2 files changed, 321 insertions(+), 251 deletions(-) diff --git a/src/main/java/eric/Roullette/websocket/GameWebSocketHandler.java b/src/main/java/eric/Roullette/websocket/GameWebSocketHandler.java index e61040c..cf50926 100644 --- a/src/main/java/eric/Roullette/websocket/GameWebSocketHandler.java +++ b/src/main/java/eric/Roullette/websocket/GameWebSocketHandler.java @@ -1,251 +1,232 @@ -package eric.Roullette.websocket; +// java + package eric.Roullette.websocket; -import com.fasterxml.jackson.databind.JsonNode; -import eric.Roullette.service.GameService; -//import eric.Roullette.service.SpotifyAuthService; -import eric.Roullette.util.JsonUtil; -import io.javalin.websocket.WsConfig; -import io.javalin.websocket.WsContext; + import com.fasterxml.jackson.databind.JsonNode; + import eric.Roullette.service.GameService; + import eric.Roullette.util.JsonUtil; + import io.javalin.websocket.WsConfig; + import io.javalin.websocket.WsContext; -import java.util.*; -import java.util.concurrent.ConcurrentHashMap; + import java.util.*; + import java.util.concurrent.ConcurrentHashMap; -/** - * WebSocket-Handler für Spiel-Sessions. Verwaltet Spieler-Sessions, - * broadcastet Spieler-Listen, Runden-Starts und Rundenergebnisse. - */ -public class GameWebSocketHandler { + /** + * WebSocket-Handler für Spiel-Sessions. Verwaltet Spieler-Sessions, + * broadcastet Spieler-Listen, Runden-Starts und Rundenergebnisse. + */ + public class GameWebSocketHandler { - private final GameService service; - //private final SpotifyAuthService authService; + private final GameService service; - // Spiel-ID → (Username → deren Guess) - private final Map> currentGuesses = new ConcurrentHashMap<>(); + // Spiel-ID → (Username → deren Guesses) + private final Map>> currentGuesses = new ConcurrentHashMap<>(); -// private final Map> trackInfoCache = new ConcurrentHashMap<>(); -// private final Map> allTracksCache = new ConcurrentHashMap<>(); - //Map> - //private Map>> playerTracksCache = new ConcurrentHashMap<>(); - private Map>> playerTrackInfoCache = new ConcurrentHashMap<>(); + // Cache: Spiel-ID → (Username → TrackInfos) + private final Map>> playerTrackInfoCache = new ConcurrentHashMap<>(); - public GameWebSocketHandler(GameService gameService) { - this.service = gameService; - //this.authService = authService; - } - - /** - * Registriert Connect/Close/Message-Handler für eine WebSocket-Route. - */ - public void register(WsConfig ws) { - - // Neue Connection - ws.onConnect(ctx -> { - String gameId = ctx.pathParam("gameId"); - String username = ctx.queryParam("username"); - // Spiel- und Session-Registrierung - service.addPlayer(gameId, username); - // Alle Clients über neue Spielerliste informieren - service.registerSession(gameId, ctx); - service.broadcastPlayers(gameId); - }); - - // Connection geschlossen - ws.onClose(ctx -> { - String gameId = ctx.pathParam("gameId"); - String username = ctx.queryParam("username"); - service.removeSession(gameId, ctx); - - // Spieler aus der Spielerliste entfernen - var game = service.getOrCreateGame(gameId); - if (username != null && game.players().contains(username)) { - game.players().remove(username); - game.scores().remove(username); - // Optional: auch die Tracks entfernen - game.playerTracks().remove(username); + public GameWebSocketHandler(GameService gameService) { + this.service = gameService; } - service.broadcastPlayers(gameId); - }); + /** + * Registriert Connect/Close/Message-Handler für eine WebSocket-Route. + */ + public void register(WsConfig ws) { - // Eingehende Nachrichten (Guesses & Player-Requests) - ws.onMessage(ctx -> { - String gameId = ctx.pathParam("gameId"); - JsonNode node = JsonUtil.fromJson(ctx.message(), JsonNode.class); - String type = node.get("type").asText(); + // Neue Connection + ws.onConnect(ctx -> { + String gameId = ctx.pathParam("gameId"); + String username = ctx.queryParam("username"); + service.addPlayer(gameId, username); + service.registerSession(gameId, ctx); + service.broadcastPlayers(gameId); + }); - switch (type) { - case "guess" -> { - String user = node.get("username").asText(); - String guess = node.get("guess").asText(); - // Guess speichern - currentGuesses - .computeIfAbsent(gameId, id -> new ConcurrentHashMap<>()) - .put(user, guess); - // Wenn alle getippt haben, Ergebnis broadcasten - int numPlayers = service.getOrCreateGame(gameId).players().size(); - if (currentGuesses.get(gameId).size() == numPlayers) { - broadcastRoundResult(gameId); + // Connection geschlossen + ws.onClose(ctx -> { + String gameId = ctx.pathParam("gameId"); + String username = ctx.queryParam("username"); + service.removeSession(gameId, ctx); + + var game = service.getOrCreateGame(gameId); + if (username != null && game.players().contains(username)) { + game.players().remove(username); + game.scores().remove(username); + game.playerTracks().remove(username); } - } - case "requestPlayers" -> service.broadcastPlayers(gameId); + service.broadcastPlayers(gameId); + }); - case "next-round" -> nextround(gameId); - case "start-round" -> { - var currentGame = service.getOrCreateGame(gameId); - if (currentGame.players().isEmpty()) return; - // Tracks pro Spieler sammeln - Map> allPlayerTracks = currentGame.playerTracks(); - // alle tracks sammeln - List allTracks = allPlayerTracks.values().stream() - .flatMap(List::stream) - .toList(); - System.out.println("AlltracksCache für Spiel " + gameId + " hat " + allTracks.size() + " Songs (rundenstart)"); - //Trackinfos für alle Spieler sammeln + // Eingehende Nachrichten (Guesses & Player-Requests) + ws.onMessage(ctx -> { + String gameId = ctx.pathParam("gameId"); + JsonNode node = JsonUtil.fromJson(ctx.message(), JsonNode.class); + String type = node.get("type").asText(); - if(playerTrackInfoCache.containsKey(gameId)){ - // Wenn der Cache schon existiert, dann nur die Trackinfos nutzen - System.out.println("TrackInfosCache für Spiel " + gameId + " existiert bereits (rundenstart)"); - // prüfen ob ein neuer spieler dazugekommen ist - if( allPlayerTracks.size() > playerTrackInfoCache.get(gameId).size()) { - System.out.println("Neuer Spieler hinzugefügt, Trackinfos werden aktualisiert (rundenstart)"); - Map> 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)"); + switch (type) { + case "guess" -> { // Legacy: Single-Guess + String user = node.get("username").asText(); + String single = node.get("guess").asText(); + Map> byUser = + currentGuesses.computeIfAbsent(gameId, id -> new ConcurrentHashMap<>()); + byUser.put(user, List.of(single)); + + int numPlayers = service.getOrCreateGame(gameId).players().size(); + if (byUser.size() == numPlayers) { + broadcastRoundResult(gameId); + } } - } else { - // Wenn der Cache nicht existiert, dann Trackinfos sammeln - System.out.println("TrackInfosCache für Spiel " + gameId + " wird erstellt (rundenstart)"); - Map> allTrackInfos = service.getTrackInfos(allPlayerTracks); -// Cache für Trackinfos pro Spiel-ID - playerTrackInfoCache.put(gameId, allTrackInfos); + case "submit-guesses" -> { // Multi-Select + String user = node.get("username").asText(); + List picks = new ArrayList<>(); + JsonNode arr = node.get("guesses"); + if (arr != null && arr.isArray()) { + arr.forEach(j -> picks.add(j.asText())); + } + + Map> byUser = + currentGuesses.computeIfAbsent(gameId, id -> new ConcurrentHashMap<>()); + if (user != null && !picks.isEmpty()) { + byUser.put(user, new ArrayList<>(picks)); + } + + int numPlayers = service.getOrCreateGame(gameId).players().size(); + if (byUser.size() == numPlayers) { + broadcastRoundResult(gameId); + } + } + + case "requestPlayers" -> service.broadcastPlayers(gameId); + + case "next-round" -> nextround(gameId); + + case "start-round" -> { + // Guesses für dieses Spiel zurücksetzen + currentGuesses.put(gameId, new ConcurrentHashMap<>()); + + var currentGame = service.getOrCreateGame(gameId); + if (currentGame.players().isEmpty()) return; + + Map> allPlayerTracks = currentGame.playerTracks(); + List allTracks = allPlayerTracks.values().stream() + .flatMap(List::stream) + .toList(); + + if (playerTrackInfoCache.containsKey(gameId)) { + if (allPlayerTracks.size() > playerTrackInfoCache.get(gameId).size()) { + Map> allTrackInfos = service.getTrackInfos(allPlayerTracks); + playerTrackInfoCache.put(gameId, allTrackInfos); + } + } else { + Map> allTrackInfos = service.getTrackInfos(allPlayerTracks); + playerTrackInfoCache.put(gameId, allTrackInfos); + } + + if (!allTracks.isEmpty()) { + service.startRound(gameId, allTracks); + } + broadcastRoundStart(gameId); + } } + }); + } - System.out.println("TrackInfosCache für Spiel " + gameId + " hat " + playerTrackInfoCache.get(gameId).size() + " Spieler (rundenstart)"); + public void nextround(String gameId) { + currentGuesses.put(gameId, new ConcurrentHashMap<>()); // nur dieses Spiel leeren + var game = service.getOrCreateGame(gameId); + if (game.players().isEmpty()) return; + List allTracks = new ArrayList<>(); + for (String player : game.players()) { + allTracks.addAll(game.playerTracks().getOrDefault(player, List.of())); + } + if (allTracks.isEmpty()) { + broadcastToAll(gameId, JsonUtil.toJson(Map.of("type","error","message","Keine Tracks geladen"))); + return; + } - if (!allTracks.isEmpty()) { - service.startRound(gameId, allTracks); - } -// - broadcastRoundStart(gameId); + service.startRound(gameId, allTracks); + broadcastRoundStart(gameId); + } + + /** Broadcastet den Runden-Start (Song + Optionen) an alle Clients. */ + public void broadcastRoundStart(String gameId) { + var game = service.getOrCreateGame(gameId); + List opts = game.players(); + String songUri = game.currentSong(); + List allTracks = game.allTracks(); + Map> trackInfos = playerTrackInfoCache.get(gameId); + + String msg = JsonUtil.toJson(Map.of( + "type", "round-start", + "ownerOptions", opts, + "songUri", songUri, + "allTracks", allTracks, + "trackInfos", trackInfos + )); + broadcastToAll(gameId, msg); + } + + /** Broadcastet das Rundenergebnis (Scores, wer richtig, wer getippt hat). */ + private void broadcastRoundResult(String gameId) { + var game = service.getOrCreateGame(gameId); + String owner = game.currentOwner(); + + Map> byUser = + currentGuesses.getOrDefault(gameId, Collections.emptyMap()); + + // Scoring: +3 falls Auswahl Owner enthält, -1 pro falschem Tipp + for (var e : byUser.entrySet()) { + String user = e.getKey(); + List 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; + if (delta != 0) game.scores().merge(user, delta, Integer::sum); + } + + var scores = game.scores(); + + String msg = JsonUtil.toJson(Map.of( + "type", "round-result", + "scores", scores, + "guesses", byUser, + "owner", owner + )); + broadcastToAll(gameId, msg); + + // Gewinner prüfen + int winScore = 6; + int max = scores.values().stream().mapToInt(Integer::intValue).max().orElse(0); + List topScorers = scores.entrySet().stream() + .filter(e -> e.getValue() == max && max >= winScore) + .map(Map.Entry::getKey) + .toList(); + + if (topScorers.size() == 1) { + String winner = topScorers.get(0); + String winMsg = JsonUtil.toJson(Map.of( + "type", "game-end", + "winner", winner, + "scores", scores + )); + broadcastToAll(gameId, winMsg); + game.scores().replaceAll((user, pts) -> 0); // Reset Scores } } - }); - } - public void nextround(String gameId) { - var game = service.getOrCreateGame(gameId); - if (game.players().isEmpty()) return; - // Songs von allen Spielern sammeln - List allTracks = new ArrayList<>(); - for (String player : game.players()) { - allTracks.addAll(game.playerTracks().getOrDefault(player, List.of())); - } - if (allTracks.isEmpty()) { - broadcastToAll(gameId, JsonUtil.toJson(Map.of("type","error","message","Keine Tracks geladen"))); - return; - } - - // Runde im Service starten, um Song und Owner zu setzen - service.startRound(gameId, allTracks); - // Jetzt Broadcast mit den aktuellen Daten - broadcastRoundStart(gameId); - } - // ----- Broadcast-Methoden ----- - - /** Broadcastet den Runden-Start (Song + Optionen) an alle Clients. */ - public void broadcastRoundStart(String gameId) { - var game = service.getOrCreateGame(gameId); - List opts = game.players(); - String songUri = game.currentSong(); - List allTracks = game.allTracks(); - Map> trackInfos = playerTrackInfoCache.get(gameId); - // Cache pro Spiel-ID nutzen - - String msg = JsonUtil.toJson(Map.of( - "type", "round-start", - "ownerOptions", opts, - "songUri", songUri, - "allTracks", allTracks, - "trackInfos", trackInfos - )); - broadcastToAll(gameId, msg); - } - - /** Broadcastet das Rundenergebnis (Scores, wer richtig, wer getippt hat). */ -// Punkte für alle Guess-Teilnehmer anpassen -private void broadcastRoundResult(String gameId) { - var game = service.getOrCreateGame(gameId); - Map scores = game.scores(); - Map guesses = currentGuesses.remove(gameId); - String owner = game.currentOwner(); - -// System.out.println("Owner: " + owner); -// System.out.println("Guesses: " + guesses); -// System.out.println("Scores vor Auswertung: " + scores); - - // Für jeden Tippenden Score anpassen - for (Map.Entry entry : guesses.entrySet()) { - String guesser = entry.getKey(); - boolean correct = owner.equals(entry.getValue()); - scores.merge(guesser, correct ? 3 : -1, Integer::sum); - } -// System.out.println("Owner: " + owner); -// System.out.println("Guesses: " + guesses); -// System.out.println("Scores nach Auswertung: " + scores); - - String msg = JsonUtil.toJson(Map.of( - "type", "round-result", - "scores", scores, - "guesses", guesses, - "owner", owner - )); - broadcastToAll(gameId, msg); - - // Prüfe auf Gewinner - // Nur beenden, wenn EIN Spieler allein die höchste Punktzahl >= score hat - int score = 6; - int max = scores.values().stream().max(Integer::compareTo).orElse(0); - List topScorers = scores.entrySet().stream() - .filter(e -> e.getValue() == max && max >= score) - .map(Map.Entry::getKey) - .toList(); - - if (topScorers.size() == 1) { - String winner = topScorers.get(0); - String winMsg = JsonUtil.toJson(Map.of( - "type", "game-end", - "winner", winner, - "scores", scores - )); - broadcastToAll(gameId, winMsg); - game.scores().replaceAll((user , pts) -> 0); // Reset Scores + /** Hilfsmethode: Sendet eine Nachricht an alle WebSocket-Sessions eines Spiels. */ + private void broadcastToAll(String gameId, String msg) { + Set sessions = service.getSessions(gameId); + if (sessions == null) return; + sessions.stream() + .filter(s -> s.session.isOpen()) + .forEach(ctx -> { + try { + ctx.send(msg); + } catch (Exception ignore) {} + }); } -// else{ -// // nächste Runde starten -// // ... -//// new Thread(() -> { -//// try { Thread.sleep(2000); } catch (InterruptedException ignored) {} -//// nextround(gameId); -//// }).start(); -// } -} - - /** Hilfsmethode: Sendet eine Nachricht an alle WebSocket-Sessions eines Spiels. */ - private void broadcastToAll(String gameId, String msg) { - // Holt alle WsContext, die der Service beim Connect registriert hat - Set sessions = service.getSessions(gameId); - if (sessions == null) return; - sessions.stream() - .filter(s -> s.session.isOpen()) - .forEach(ctx -> { - try { - ctx.send(msg); - } catch (Exception ignore) {} - }); - } - -} \ No newline at end of file + } \ No newline at end of file diff --git a/src/main/resources/public/js/game.js b/src/main/resources/public/js/game.js index 7b91a8b..6121b45 100644 --- a/src/main/resources/public/js/game.js +++ b/src/main/resources/public/js/game.js @@ -133,6 +133,25 @@ function wedgePath(cx, cy, r, a0, a1){ return `M ${cx} ${cy} L ${p0.x} ${p0.y} A ${r} ${r} 0 ${largeArc} 1 ${p1.x} ${p1.y} Z`; } +// Auswahl-Set für Multi-Guess (pro Runde zurückgesetzt) +let selectedGuesses = new Set(); +let lastScores = null; + +function ensureSubmitBtn(optionsDiv) { + let btn = document.getElementById("submitGuesses"); + if (!btn) { + btn = document.createElement("button"); + btn.id = "submitGuesses"; + btn.className = "btn btn-primary"; + btn.style.marginTop = "8px"; + btn.textContent = "Tipps abgeben"; + // Button direkt unter den Optionen platzieren + optionsDiv.parentElement.appendChild(btn); + } + return btn; +} + + // 6) Neue Runde anzeigen async function handleRoundStart({ ownerOptions, songUri, trackInfos }) { // UI zurücksetzen @@ -141,6 +160,7 @@ async function handleRoundStart({ ownerOptions, songUri, trackInfos }) { songEmbed.innerHTML = ""; //scoreboard zurücksetzen scoreboard.innerHTML = ""; + selectedGuesses = new Set(); ownerOptions.forEach(user => { const li = document.createElement("li"); li.textContent = `${user}: 0 Punkte`; @@ -176,14 +196,23 @@ async function handleRoundStart({ ownerOptions, songUri, trackInfos }) { path.setAttribute("d", wedgePath(CX, CY, R, a0, a1)); path.setAttribute("class", "wedge"); path.setAttribute("data-user", user); + // path.addEventListener("click", () => { + // socket.send(JSON.stringify({ + // type: "guess", + // username: username, + // guess: user + // })); + // svg.querySelectorAll(".wedge").forEach(w => w.classList.add("disabled")); + // path.classList.add("selected"); + // }); + + // Toggle-Selection statt sofort zu senden path.addEventListener("click", () => { - socket.send(JSON.stringify({ - type: "guess", - username: username, - guess: user - })); - svg.querySelectorAll(".wedge").forEach(w => w.classList.add("disabled")); - path.classList.add("selected"); + const u = path.getAttribute("data-user"); + if (path.classList.contains("disabled")) return; + if (path.classList.toggle("selected")) selectedGuesses.add(u); + else selectedGuesses.delete(u); + submitBtn.disabled = selectedGuesses.size === 0; }); // Label mittig im Segment @@ -212,6 +241,32 @@ async function handleRoundStart({ ownerOptions, songUri, trackInfos }) { svg.appendChild(text); }); + // Submit-Button dynamisch hinzufügen + let submitBtn = document.getElementById("submitGuesses"); + if (!submitBtn) { + submitBtn = document.createElement("button"); + submitBtn.id = "submitGuesses"; + submitBtn.className = "btn btn-primary"; + submitBtn.style.marginTop = "8px"; + submitBtn.textContent = "submit"; + optionsDiv.parentElement.appendChild(submitBtn); + } + submitBtn.hidden = false; + // warum disabled? + submitBtn.disabled = true; + submitBtn.onclick = () => { + if (!selectedGuesses.size) return; + const guesses = Array.from(selectedGuesses); + socket.send(JSON.stringify({ + type: "submit-guesses", + username, + guesses + })); + submitBtn.disabled = true; + // Sperre Eingaben nach Abgabe + optionsDiv.querySelectorAll(".wedge").forEach(w => w.classList.add("disabled")); + }; + // Start-Button ausblenden + Rundensektion einblenden startBtn.hidden = true; startBtn.disabled = true; @@ -260,13 +315,20 @@ function renderScoreboard(scores) { } } -let lastScores = null; +//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"); @@ -277,26 +339,51 @@ function handleRoundResult({ scores, guesses, owner }) { }); // 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 - } - } + // 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 (e) {} + } 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, guess]) => { - const correct = guess === owner; + 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 icon = correct ? "✅" : "❌"; - const delta = correct ? "+3" : "-1"; + const picks = list.length ? list.join(", ") : "—"; const p = document.createElement("p"); - p.textContent = `${icon} ${user} hat auf ${guess} getippt${correct ? " (richtig!)" : " (falsch)"} [${delta}]`; + p.textContent = `${icon} ${user} hat auf ${picks} getippt${correct ? " (richtig!)" : ""} [${delta >= 0 ? "+" : ""}${delta}]`; resultP.appendChild(p); }); @@ -311,6 +398,8 @@ function handleRoundResult({ scores, guesses, owner }) { startBtn.hidden = true; startBtn.disabled = true; roundArea.hidden = true; + const submitBtn = document.getElementById("submitGuesses"); + if (submitBtn) submitBtn.hidden = true; }; }