SpotifyRoulette/src/main/resources/public/js/game.js

702 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

// public/js/game.js
import { getParam, renderList } from "./utils.js";
import { setupStartRound } from "./start-round.js";
const gameId = getParam("gameId");
const username = getParam("username");
// --- kleine Helper ---
function showToast(msg, ms = 2200) {
const t = document.getElementById("toast");
if (!t) return;
t.textContent = msg;
t.classList.add("show");
setTimeout(() => t.classList.remove("show"), ms);
}
function esc(s) {
const d = document.createElement("div");
d.textContent = String(s ?? "");
return d.innerHTML;
}
// 1) Parameter prüfen
if (!gameId || !username) {
alert("Ungültige oder fehlende URL-Parameter!");
throw new Error("Missing gameId or username");
}
// 2) Copy to clipboard (unverändert, aber mit Toast)
(function copyCodeToClipboard(code) {
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(code)
.then(() => showToast(`Spiel-Code ${code} kopiert`))
.catch(err => console.error("Clipboard write failed:", err));
} else {
const ta = document.createElement("textarea");
ta.value = code;
ta.style.position = "fixed";
ta.style.left = "-9999px";
document.body.appendChild(ta);
ta.focus();
ta.select();
try {
document.execCommand("copy");
showToast(`Spiel-Code ${code} kopiert`);
} catch (err) {
console.error("Fallback copy failed:", err);
}
document.body.removeChild(ta);
}
})(gameId);
// 3) Spiel-Code ins DOM schreiben
document.getElementById("gameId").textContent = gameId;
// 4) Drawer toggles für Songliste
const drawer = document.getElementById("songDrawer");
document.getElementById("toggleSongList")?.addEventListener("click", () => drawer?.classList.toggle("open"));
document.getElementById("closeDrawer")?.addEventListener("click", () => drawer?.classList.remove("open"));
// 5) WebSocket mit Reconnect-Logik
let socket;
function connectWebSocket() {
const protocol = location.protocol === "https:" ? "wss" : "ws";
socket = new WebSocket(
`${protocol}://${location.host}/ws/${gameId}?username=${encodeURIComponent(username)}`
);
socket.addEventListener("open", () => {
console.log("WebSocket connected. Requesting player list...");
socket.send(JSON.stringify({ type: "requestPlayers" }));
setupStartRound(socket); // deaktiviert den Start-Button beim Klicken
});
socket.addEventListener("message", async ({ data }) => {
console.log("WS-Rohdaten:", data);
const msg = JSON.parse(data);
switch (msg.type) {
case "players":
renderList("#playersList", msg.players, username);
break;
case "reload":
window.location.reload();
break;
case "round-start":
await handleRoundStart(msg);
break;
case "round-result":
handleRoundResult(msg);
break;
case "game-end":
handleGameEnd(msg);
break;
default:
console.warn("Unknown WS message type:", msg.type);
}
});
socket.addEventListener("close", () => {
console.warn("WebSocket geschlossen, versuche erneut zu verbinden...");
setTimeout(connectWebSocket, 2000);
});
socket.addEventListener("error", (e) => {
console.error("WebSocket Fehler:", e);
socket.close();
});
}
connectWebSocket();
// Zugriff auf DOM-Elemente
const startBtn = document.getElementById("startRound");
const roundArea = document.getElementById("roundArea");
const songEmbed = document.getElementById("songEmbed");
const optionsDiv = document.getElementById("options");
const resultP = document.getElementById("result");
const scoreboard = document.getElementById("scoreboard");
// --- SVG helpers für Pie-Slices ---
const SVGNS = "http://www.w3.org/2000/svg";
const VB = 1000; // viewBox 0..1000
const CX = 500, CY = 500, R = 480;
function polar(cx, cy, r, deg){
const rad = (deg - 90) * Math.PI/180; // 0° oben
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
}
function wedgePath(cx, cy, r, a0, a1){
const p0 = polar(cx, cy, r, a0);
const p1 = polar(cx, cy, r, a1);
const largeArc = ((a1 - a0 + 360) % 360) > 180 ? 1 : 0;
return `M ${cx} ${cy} L ${p0.x} ${p0.y} A ${r} ${r} 0 ${largeArc} 1 ${p1.x} ${p1.y} Z`;
}
// Zahl unter dem Namen im Pie-Label setzen/entfernen
function setWedgeDeltaByUser(user, text, kind /* 'correct' | 'wrong' */) {
const svg = document.querySelector("#options svg.options-svg");
if (!svg) return;
const label = Array.from(svg.querySelectorAll("text.wedge-label"))
.find(t => t.getAttribute("data-user") === user);
if (!label) return;
// Entfernen?
if (!text) {
const old = Array.from(svg.querySelectorAll("text.wedge-delta"))
.find(t => t.getAttribute("data-user") === user);
if (old) old.remove();
return;
}
let delta = Array.from(svg.querySelectorAll("text.wedge-delta"))
.find(t => t.getAttribute("data-user") === user);
if (!delta) {
delta = document.createElementNS(SVGNS, "text");
delta.setAttribute("class", "wedge-delta");
delta.setAttribute("data-user", user);
delta.setAttribute("text-anchor", "middle");
delta.setAttribute("dominant-baseline", "hanging");
// Outline für bessere Lesbarkeit (Backup, falls CSS nicht greift)
delta.setAttribute("stroke", "#000");
delta.setAttribute("stroke-width", "3");
delta.setAttribute("paint-order", "stroke fill");
svg.appendChild(delta);
}
delta.setAttribute("x", label.getAttribute("x"));
delta.setAttribute("y", parseFloat(label.getAttribute("y")) + 18);
delta.textContent = text;
delta.classList.toggle("correct", kind === "correct");
delta.classList.toggle("wrong", kind === "wrong");
}
// Auswahl-Set für Multi-Guess (pro Runde zurückgesetzt)
let selectedGuesses = new Set();
let lastScores = null;
let initializedScoreboard = false; // <- neu
function ensureSubmitBtn(optionsDiv) {
let btn = document.getElementById("submitGuesses");
if (!btn) {
btn = document.createElement("button");
btn.id = "submitGuesses";
btn.className = "btn btn-primary";
btn.style.marginTop = "8px";
btn.textContent = "Tipps abgeben";
// Button direkt unter den Optionen platzieren
optionsDiv.parentElement.appendChild(btn);
}
return btn;
}
// 6) Neue Runde anzeigen
async function handleRoundStart({ ownerOptions, songUri, trackInfos, initial }) {
// UI zurücksetzen
resultP.textContent = "";
optionsDiv.innerHTML = "";
songEmbed.innerHTML = "";
//scoreboard zurücksetzen
//scoreboard.innerHTML = "";
selectedGuesses = new Set();
if (initial === true && scoreboard) {
lastScores = null;
scoreboard.innerHTML = "";
(ownerOptions || []).forEach(u => {
const li = document.createElement("li");
li.innerHTML = `<span>${u}</span><b>0 Punkte</b>`;
scoreboard.appendChild(li);
});
}
const nextBtn = document.getElementById("nextRound");
if (nextBtn) {
nextBtn.hidden = true;
nextBtn.disabled = true;
}
// Song einbetten
const trackId = songUri.split(":")[2];
songEmbed.innerHTML = `
<iframe
src="https://open.spotify.com/embed/track/${trackId}"
width="100%" height="80" frameborder="0"
allow="encrypted-media">
</iframe>`;
// Spotify-Playback (optional, wenn vorhanden)
if (window.playOnSpotify && typeof window.playOnSpotify === "function") {
await window.playOnSpotify(songUri, username);
}
// Optionen als Tortenstücke (SVG) rendern
const svg = document.createElementNS(SVGNS, "svg");
svg.setAttribute("viewBox", `0 0 ${VB} ${VB}`);
svg.setAttribute("class", "options-svg");
optionsDiv.innerHTML = "";
optionsDiv.appendChild(svg);
const n = ownerOptions.length;
ownerOptions.forEach((user, i) => {
const a0 = (360 / n) * i;
const a1 = (360 / n) * (i + 1);
const path = document.createElementNS(SVGNS, "path");
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", () => {
const u = path.getAttribute("data-user");
if (path.classList.contains("disabled")) return;
if (path.classList.toggle("selected")) selectedGuesses.add(u);
else selectedGuesses.delete(u);
//submitBtn.disabled = selectedGuesses.size === 0;
const hasSelection = selectedGuesses.size > 0;
submitBtn.hidden = !hasSelection; // ohne Auswahl: versteckt
submitBtn.disabled = !hasSelection; // mit Auswahl: enabled
});
// Label mittig im Segment
const mid = (a0 + a1) / 2;
const P = polar(CX, CY, R * 0.58, mid);
const text = document.createElementNS(SVGNS, "text");
const span = a1 - a0; // Winkelbreite des Segments
const font = Math.max(18, Math.min(42, 26 * (span / 60)));
text.setAttribute("font-size", font);
text.setAttribute("font-weight", "800");
// Bessere Lesbarkeit auf dunklem Slice
text.setAttribute("paint-order", "stroke");
text.setAttribute("stroke", "#000");
text.setAttribute("stroke-width", "2");
text.setAttribute("fill", "#fff");
// überschreibt CSS-Größe
text.setAttribute("x", P.x);
text.setAttribute("y", P.y);
text.setAttribute("class", "wedge-label");
text.setAttribute("data-user", user); // <— NEU: verbindet Label mit User
text.setAttribute("text-anchor", "middle");
text.setAttribute("dominant-baseline", "middle");
text.textContent = user;
svg.appendChild(path);
svg.appendChild(text);
});
// Submit-Button dynamisch hinzufügen
let submitBtn = document.getElementById("submitGuesses");
if (!submitBtn) {
submitBtn = document.createElement("button");
submitBtn.id = "submitGuesses";
submitBtn.className = "btn btn-primary";
submitBtn.style.marginTop = "8px";
submitBtn.textContent = "Submit";
optionsDiv.parentElement.appendChild(submitBtn);
}
//submitBtn.hidden = false;
// warum disabled?
//submitBtn.disabled = true;
// Zu Beginn der Runde: Button verstecken, bis eine Auswahl existiert
submitBtn.hidden = true;
submitBtn.disabled = true;
submitBtn.onclick = () => {
if (!selectedGuesses.size) return;
const guesses = Array.from(selectedGuesses);
socket.send(JSON.stringify({
type: "submit-guesses",
username,
guesses
}));
//submitBtn.disabled = true;
// Nach Abgabe sichtbar, aber disabled (ausgegraut)
submitBtn.hidden = false;
submitBtn.disabled = true;
// Sperre Eingaben nach Abgabe
optionsDiv.querySelectorAll(".wedge").forEach(w => w.classList.add("disabled"));
};
// Start-Button ausblenden + Rundensektion einblenden
startBtn.hidden = true;
startBtn.disabled = true;
roundArea.hidden = false;
// Geladene Songs (beide Orte befüllen: Aside + Drawer)
const songList = document.getElementById("songList");
const songListArea = document.getElementById("songListArea");
const userTracks = trackInfos?.[username] ?? [];
if (songList) {
songList.innerHTML = "";
userTracks.forEach(t => {
const li = document.createElement("li");
li.textContent = t;
songList.appendChild(li);
});
}
if (songListArea) {
songListArea.innerHTML = "";
userTracks.forEach(t => {
// Erwartetes Format "Titel - Künstler"
const [title, artists] = String(t).split(" - ");
const row = document.createElement("div");
row.className = "track";
row.innerHTML = `
<div class="tcover"></div>
<div class="meta"><b>${esc(title ?? t)}</b><span>${esc(artists ?? "")}</span></div>`;
songListArea.appendChild(row);
});
}
}
// 7) Ergebnis + Weiter-Button
function renderScoreboard(scores) {
scoreboard.innerHTML = "";
if (!scores) return;
const entries = Object.entries(scores)
.sort((a, b) => (b[1] - a[1]) || a[0].localeCompare(b[0])); // Punkte absteigend
for (const [user, pts] of entries) {
const li = document.createElement("li");
li.textContent = `${user}: ${pts} Punkte`;
scoreboard.append(li);
}
}
//let lastScores = null;
// function handleRoundResult({ scores, guesses, owner }) {
// renderScoreboard(scores);
// lastScores = scores;
//
// (() => {
// // Button verstecken/disable und Eingaben sperren
// const sb = document.getElementById('submitGuesses');
// if (sb) { sb.disabled = true; sb.hidden = true; }
// document.querySelectorAll('#options .wedge').forEach(w => w.classList.add('disabled'));
// // lokale Auswahl leeren (optional)
// try { selectedGuesses.clear?.(); } catch(_) {}
// })();
//
// try {
// const wedges = document.querySelectorAll("#options .wedge");
//
// // Owner-Slice immer grün
// wedges.forEach(w => {
// if (w.getAttribute("data-user") === owner) w.classList.add("correct");
// });
//
// // Nur die EIGENE Auswahl einfärben: rot wenn falsch, sonst grün
// // const myGuess = guesses?.[username];
// // if (myGuess) {
// // const myWedge = Array.from(wedges).find(w => w.getAttribute("data-user") === myGuess);
// // if (myWedge) {
// // if (myGuess === owner) {
// // myWedge.classList.add("correct");
// // } else {
// // myWedge.classList.add("wrong"); // nur dieser wird rot
// // }
// // }
// // }
// // } catch (e) {}
// const my = guesses?.[username];
// const myArr = Array.isArray(my) ? my : (typeof my === "string" ? [my] : []);
// if (myArr.length) {
// myArr.forEach(sel => {
// const w = Array.from(wedges).find(x => x.getAttribute("data-user") === sel);
// if (!w) return;
// if (sel === owner) w.classList.add("correct");
// else w.classList.add("wrong");
// });
// }
//
// } catch (_) {}
//
//
// // resultP.innerHTML = "";
// // Object.entries(guesses || {}).forEach(([user, guess]) => {
// // const correct = guess === owner;
// // const icon = correct ? "✅" : "❌";
// // const delta = correct ? "+3" : "-1";
// // const p = document.createElement("p");
// // p.textContent = `${icon} ${user} hat auf ${guess} getippt${correct ? " (richtig!)" : " (falsch)"} [${delta}]`;
// // resultP.appendChild(p);
// // });
// resultP.innerHTML = "";
// Object.entries(guesses || {}).forEach(([user, g]) => {
// const list = Array.isArray(g) ? g : (typeof g === "string" ? [g] : []);
// const correct = list.includes(owner);
// const wrongCount = list.length - (correct ? 1 : 0);
// //const delta = (correct ? 3 : 0) - wrongCount;
// const bonus = wrongCount === 0 ? 1 : 0; // fehlerfrei-Bonus
// const delta = (correct ? 3 : 0) - wrongCount + bonus;
// const icon = correct ? "✅" : "❌";
// const picks = list.length ? list.join(", ") : "—";
// const p = document.createElement("p");
// //p.textContent = `${icon} ${user} hat auf ${picks} getippt${correct ? " (richtig!)" : ""} [${delta >= 0 ? "+" : ""}${delta}]`;
// p.textContent = `${icon} ${user} hat auf ${picks} getippt${correct ? " (richtig!)" : ""}${bonus ? " (+1 Bonus)" : ""} [${delta >= 0 ? "+" : ""}${delta}]`;
// resultP.appendChild(p);
// });
//
// const nextBtn = document.getElementById("nextRound");
// nextBtn.hidden = false;
// nextBtn.disabled = false;
// nextBtn.onclick = () => {
// socket.send(JSON.stringify({ type: "next-round" }));
// nextBtn.hidden = true;
// nextBtn.disabled = true;
// resultP.textContent = "";
// startBtn.hidden = true;
// startBtn.disabled = true;
// roundArea.hidden = true;
// //const submitBtn = document.getElementById("submitGuesses");
// //if (submitBtn) submitBtn.hidden = true;
// const submitBtn = document.getElementById("submitGuesses");
// if (submitBtn) { submitBtn.hidden = true; submitBtn.disabled = true; }
// };
// }
// zeigt/entfernt den kleinen +3 / -1 Badge
function setWedgeDelta(wedgeEl, text) {
let tag = wedgeEl.querySelector('.delta-tag');
if (!text) { if (tag) tag.remove(); return; }
if (!tag) {
tag = document.createElement('span');
tag.className = 'delta-tag';
wedgeEl.appendChild(tag);
}
tag.textContent = text;
}
function handleRoundResult({ scores, guesses, owner }) {
renderScoreboard(scores);
lastScores = scores;
// Eingaben sperren & Submit ausblenden
(() => {
const sb = document.getElementById('submitGuesses');
if (sb) { sb.disabled = true; sb.hidden = true; }
document.querySelectorAll('#options .wedge').forEach(w => w.classList.add('disabled'));
try { selectedGuesses.clear?.(); } catch(_) {}
})();
// Wedges einfärben: Owner immer grün, eigene falsche rot
try {
const wedges = document.querySelectorAll("#options .wedge");
// (NEU) Alle alten Delta-Zahlen entfernen
const svg = document.querySelector("#options svg.options-svg");
if (svg) svg.querySelectorAll("text.wedge-delta").forEach(n => n.remove());
// Optional: global immer +3 am Owner anzeigen?
const SHOW_GLOBAL_DELTAS = false; // <- auf true setzen, wenn alle es sehen sollen
// Owner-Slice immer grün
wedges.forEach(w => {
if (w.getAttribute("data-user") === owner) {
w.classList.add("correct");
if (SHOW_GLOBAL_DELTAS) setWedgeDeltaByUser(owner, "+3", "correct"); // (NEU)
}
});
const my = guesses?.[username];
const myArr = Array.isArray(my) ? my : (typeof my === "string" ? [my] : []);
if (myArr.length) {
myArr.forEach(sel => {
const w = Array.from(wedges).find(x => x.getAttribute("data-user") === sel);
if (!w) return;
if (sel === owner) {
w.classList.add("correct");
setWedgeDeltaByUser(sel, "+3", "correct"); // (NEU)
} else {
w.classList.add("wrong");
setWedgeDeltaByUser(sel, "-1", "wrong"); // (NEU)
}
});
}
} catch (_) {}
// Schöne Recap-Card rendern
const toChip = (text, cls="") => `<span class="chip ${cls}">${text}</span>`;
const listItems = Object.entries(guesses || {}).map(([user, g]) => {
const arr = Array.isArray(g) ? g : (typeof g === "string" ? [g] : []);
const correct = arr.includes(owner);
const wrongCount = arr.length - (correct ? 1 : 0);
const bonus = wrongCount === 0 ? 1 : 0; // fehlerfrei-Bonus
const delta = (correct ? 3 : 0) - wrongCount + bonus;
const chips = arr.length
? arr.map(sel => toChip(sel, sel === owner ? "correct" : "wrong")).join("")
: toChip("—", "muted");
const deltaCls = delta > 0 ? "positive" : delta < 0 ? "negative" : "neutral";
const deltaTxt = `${delta >= 0 ? "+" : ""}${delta}`;
return `
<li class="recap-item">
<div class="recap-left">
<div class="recap-user">${user}</div>
<div class="chips">${chips}</div>
</div>
<div class="delta ${deltaCls}">${deltaTxt}</div>
</li>`;
}).join("");
resultP.innerHTML = `
<div class="recap">
<div class="recap-hd">
<span>Rundenergebnis</span>
<span class="pill">Song von <b>${owner}</b></span>
</div>
<ul class="recap-list">${listItems}</ul>
</div>
`;
// Weiter-Button freigeben
const nextBtn = document.getElementById("nextRound");
nextBtn.hidden = false;
nextBtn.disabled = false;
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; submitBtn.disabled = true; }
};
}
// 8) Spielende -> Start-Button zurück
function handleGameEnd({ winner }) {
const overlay = document.getElementById("winnerOverlay");
const nameEl = document.getElementById("winName");
const ptsEl = document.getElementById("winPoints");
//const btnNext = document.getElementById("winNext");
const btnClose= document.getElementById("winClose");
const btnShare= document.getElementById("winShare");
nameEl.textContent = winner;
const pts = lastScores && typeof lastScores[winner] !== "undefined" ? lastScores[winner] : null;
ptsEl.textContent = pts != null ? `${winner} gewinnt mit ${pts} Punkten.` : "";
// Leaderboard im Overlay rendern (aus Cache lastScores)
const boardEl = document.getElementById("winBoard");
if (boardEl) {
boardEl.innerHTML = "";
const entries = lastScores
? Object.entries(lastScores).sort((a,b)=> (b[1]-a[1]) || a[0].localeCompare(b[0]))
: [];
entries.forEach(([user, pts], i) => {
const li = document.createElement("li");
if (user === winner) li.classList.add("winner");
const medal = i===0 ? "🥇" : i===1 ? "🥈" : i===2 ? "🥉" : String(i+1);
li.innerHTML = `
<div class="left">
<div class="rank">${medal}</div>
<div class="name">${user}</div>
</div>
<div class="pts">${pts} Punkte</div>
`;
boardEl.appendChild(li);
});
}
// Konfetti erzeugen (einmalig pro Anzeige)
Array.from(overlay.querySelectorAll(".confetti")).forEach(n => n.remove());
const colors = [ "var(--accent)", "#21d07a", "#b3b3b3", "#ffffff" ];
for (let i=0;i<24;i++){
const c = document.createElement("div");
c.className = "confetti";
c.style.left = Math.random()*100 + "vw";
c.style.background = colors[i % colors.length];
c.style.animationDelay = (Math.random()*1.2) + "s";
c.style.transform = `translateY(-10vh) rotate(${Math.random()*360}deg)`;
overlay.appendChild(c);
}
// Buttons
//btnNext.onclick = () => {
// socket.send(JSON.stringify({ type: "next-round" }));
// overlay.style.display = "none";
// roundArea.hidden = true;
// resultP.textContent = "";
//};
btnClose.onclick = () => { overlay.style.display = "none"; };
btnShare.onclick = async () => {
const text = `🏆 ${winner} hat Spotify Roulette gewonnen!`;
try {
if (navigator.share) await navigator.share({ text });
else {
await navigator.clipboard.writeText(text);
showToast("Text kopiert teilen!");
}
} catch (_) {}
};
const nextBtn = document.getElementById("nextRound");
//resultP.textContent = `🎉 ${winner} hat gewonnen!`;
//nextBtn.hidden = true;
//nextBtn.disabled = true;
if (nextBtn) { nextBtn.hidden = true; nextBtn.disabled = true; }
startBtn.hidden = false;
startBtn.disabled = false;
// Overlay zeigen
overlay.style.display = "flex";
//setTimeout(() => {
// startBtn.hidden = false;
// startBtn.disabled = false;
// roundArea.hidden = true;
// scoreboard.innerHTML = "";
//}, 6000);
}
// Spotify-Playback Funktion (unverändert)
async function playOnSpotify(trackUri, username) {
const deviceId = document.getElementById("deviceSelect")?.value;
if (deviceId === "") {
//alert("Bitte ein Wiedergabegerät auswählen!");
//keine warnung i guess
return;
}
try {
const response = await fetch("/api/spotify/play", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, device_id: deviceId, track_uri: trackUri })
});
if (!response.ok) {
const error = await response.text();
alert(`Fehler beim Abspielen: ${error}`);
}
} catch (err) {
alert(`Netzwerkfehler: ${err.message}`);
}
}
window.playOnSpotify = playOnSpotify;