const state = { roomId: null, participantId: null, snapshot: null, socket: null, reconnectTimer: null, }; const dom = {}; document.addEventListener("DOMContentLoaded", () => { bindDom(); bindEvents(); restoreSession(); }); function bindDom() { dom.flash = document.getElementById("flash"); dom.createRoomForm = document.getElementById("create-room-form"); dom.joinRoomForm = document.getElementById("join-room-form"); dom.joinRoomId = document.getElementById("join-room-id"); dom.roomView = document.getElementById("room-view"); dom.roomCode = document.getElementById("room-code"); dom.shareLink = document.getElementById("share-link"); dom.copyLinkButton = document.getElementById("copy-link-button"); dom.renameForm = document.getElementById("rename-form"); dom.renameInput = document.getElementById("rename-input"); dom.viewerRole = document.getElementById("viewer-role"); dom.connectionState = document.getElementById("connection-state"); dom.startGameButton = document.getElementById("start-game-button"); dom.leaveSeatButton = document.getElementById("leave-seat-button"); dom.seatGrid = document.getElementById("seat-grid"); dom.participantList = document.getElementById("participant-list"); dom.activityList = document.getElementById("activity-list"); dom.turnTitle = document.getElementById("turn-title"); dom.redRemaining = document.getElementById("red-remaining"); dom.blueRemaining = document.getElementById("blue-remaining"); dom.clueHelp = document.getElementById("clue-help"); dom.clueForm = document.getElementById("clue-form"); dom.clueWord = document.getElementById("clue-word"); dom.clueCount = document.getElementById("clue-count"); dom.activeClue = document.getElementById("active-clue"); dom.endTurnButton = document.getElementById("end-turn-button"); dom.boardGrid = document.getElementById("board-grid"); dom.boardEmpty = document.getElementById("board-empty"); } function bindEvents() { dom.createRoomForm.addEventListener("submit", async (event) => { event.preventDefault(); const formData = new FormData(dom.createRoomForm); const payload = await api("/api/rooms", { method: "POST", body: JSON.stringify({ displayName: formData.get("displayName") }), }); enterRoom(payload); }); dom.joinRoomForm.addEventListener("submit", async (event) => { event.preventDefault(); const formData = new FormData(dom.joinRoomForm); const roomId = String(formData.get("roomId") || "").trim().toUpperCase(); const payload = await api(`/api/rooms/${encodeURIComponent(roomId)}/join`, { method: "POST", body: JSON.stringify({ displayName: formData.get("displayName") }), }); enterRoom(payload); }); dom.renameForm.addEventListener("submit", (event) => { event.preventDefault(); sendAction("rename", { displayName: dom.renameInput.value.trim() }); }); dom.copyLinkButton.addEventListener("click", async () => { await navigator.clipboard.writeText(dom.shareLink.value); flash("Join-Link in die Zwischenablage kopiert."); }); dom.startGameButton.addEventListener("click", () => sendAction("start-game")); dom.leaveSeatButton.addEventListener("click", () => sendAction("leave-seat")); dom.endTurnButton.addEventListener("click", () => sendAction("end-turn")); dom.clueForm.addEventListener("submit", (event) => { event.preventDefault(); sendAction("submit-clue", { word: dom.clueWord.value.trim(), count: Number(dom.clueCount.value), }); dom.clueWord.value = ""; }); document.addEventListener("click", (event) => { const button = event.target.closest("[data-action]"); if (!button) { return; } const action = button.dataset.action; if (action === "take-seat") { sendAction("take-seat", { team: button.dataset.team, role: button.dataset.role, }); } if (action === "guess-card") { sendAction("guess-card", { cardIndex: Number(button.dataset.cardIndex), }); } }); } async function restoreSession() { const params = new URLSearchParams(window.location.search); const roomId = (params.get("room") || "").trim().toUpperCase(); const participantId = params.get("player") || ""; if (roomId) { dom.joinRoomId.value = roomId; } if (!roomId || !participantId) { return; } try { const snapshot = await api(`/api/rooms/${encodeURIComponent(roomId)}?participantId=${encodeURIComponent(participantId)}`); state.roomId = roomId; state.participantId = participantId; state.snapshot = snapshot; render(); connectSocket(); } catch (error) { flash(error.message); } } async function enterRoom(payload) { state.roomId = payload.roomId; state.participantId = payload.participantId; state.snapshot = payload.snapshot; const params = new URLSearchParams({ room: state.roomId, player: state.participantId }); window.history.replaceState({}, "", `${window.location.pathname}?${params.toString()}`); render(); connectSocket(true); } function connectSocket(forceReconnect = false) { if (forceReconnect && state.socket) { state.socket.close(); } if (!state.roomId || !state.participantId) { return; } if (state.socket && state.socket.readyState === WebSocket.OPEN) { return; } const protocol = window.location.protocol === "https:" ? "wss" : "ws"; const socketUrl = `${protocol}://${window.location.host}/ws?roomId=${encodeURIComponent(state.roomId)}&participantId=${encodeURIComponent(state.participantId)}`; state.socket = new WebSocket(socketUrl); setConnectionState("verbinde ..."); state.socket.addEventListener("open", () => { setConnectionState("verbunden"); }); state.socket.addEventListener("message", (event) => { const message = JSON.parse(event.data); if (message.type === "snapshot") { state.snapshot = message.snapshot; render(); return; } if (message.type === "error") { flash(message.message); } }); state.socket.addEventListener("close", () => { setConnectionState("getrennt"); if (state.reconnectTimer) { window.clearTimeout(state.reconnectTimer); } state.reconnectTimer = window.setTimeout(() => connectSocket(), 1500); }); } function sendAction(type, payload = {}) { if (!state.socket || state.socket.readyState !== WebSocket.OPEN) { flash("Die WebSocket-Verbindung ist noch nicht bereit."); return; } state.socket.send(JSON.stringify({ type, ...payload })); } function render() { const snapshot = state.snapshot; const inRoom = Boolean(snapshot); dom.roomView.classList.toggle("hidden", !inRoom); if (!inRoom) { return; } document.title = `Codenames ${snapshot.roomId}`; dom.roomCode.textContent = snapshot.roomId; dom.shareLink.value = `${window.location.origin}${window.location.pathname}?room=${snapshot.roomId}`; dom.renameInput.value = snapshot.viewer.displayName; dom.viewerRole.textContent = formatViewerRole(snapshot.viewer); renderSeats(snapshot); renderParticipants(snapshot); renderActivity(snapshot); renderGame(snapshot); renderControls(snapshot); } function renderSeats(snapshot) { const canChangeSeats = snapshot.game.status !== "IN_PROGRESS"; dom.seatGrid.innerHTML = snapshot.seats.map((seat) => { const occupied = Boolean(seat.participantId); return ` `; }).join(""); const hasSeat = snapshot.viewer.team && snapshot.viewer.role; dom.leaveSeatButton.classList.toggle("hidden", !(hasSeat && canChangeSeats)); } function renderParticipants(snapshot) { dom.participantList.innerHTML = snapshot.participants.map((participant) => { const role = participant.team && participant.role ? `${labelForTeam(participant.team)} ${labelForRole(participant.role)}` : "Zuschauer"; return `
${escapeHtml(participant.displayName)} ${role}
${participant.participantId === snapshot.viewer.participantId ? "Du" : participant.host ? "Host" : ""}
`; }).join(""); } function renderActivity(snapshot) { dom.activityList.innerHTML = snapshot.activity.map((item) => { const time = new Date(item.timestamp).toLocaleTimeString("de-AT", { hour: "2-digit", minute: "2-digit", }); return `
${time}

${escapeHtml(item.message)}

`; }).join(""); } function renderGame(snapshot) { const game = snapshot.game; dom.redRemaining.textContent = String(game.redRemaining); dom.blueRemaining.textContent = String(game.blueRemaining); if (game.status === "LOBBY") { dom.turnTitle.textContent = "Lobby bereit machen"; dom.boardGrid.innerHTML = ""; dom.boardEmpty.classList.remove("hidden"); dom.boardEmpty.textContent = "Sobald der Host startet, werden hier 25 Begriffe ausgespielt."; dom.activeClue.classList.add("hidden"); return; } dom.turnTitle.textContent = describeTurn(game); dom.boardEmpty.classList.add("hidden"); dom.boardGrid.innerHTML = game.cards.map((card) => { const classes = [ "board-card", card.revealed ? "revealed" : "hidden-card", card.visibleAffiliation ? card.visibleAffiliation.toLowerCase() : "unknown", card.clickable ? "clickable" : "", ].join(" "); return ` `; }).join(""); if (game.clue) { const guesses = game.remainingGuesses == null ? "?" : String(game.remainingGuesses); dom.activeClue.classList.remove("hidden"); dom.activeClue.innerHTML = ` Aktueller Hinweis ${escapeHtml(game.clue.word)}

${game.clue.count} Karten, noch ${guesses} Versuche.

`; } else { dom.activeClue.classList.add("hidden"); } } function renderControls(snapshot) { const viewer = snapshot.viewer; const game = snapshot.game; dom.startGameButton.classList.toggle("hidden", !viewer.canStartGame); dom.clueForm.classList.toggle("hidden", !viewer.canGiveClue); dom.endTurnButton.classList.toggle("hidden", !viewer.canEndTurn); if (game.status === "FINISHED") { dom.clueHelp.textContent = `${labelForTeam(game.winner)} gewinnt diese Runde. Rollen koennen in derselben Lobby neu verteilt werden.`; return; } if (viewer.canGiveClue) { dom.clueHelp.textContent = "Du bist Hinweisgeber. Gib jetzt ein einziges Wort plus Anzahl weiter."; return; } if (viewer.canGuess) { dom.clueHelp.textContent = "Du bist Ermittler. Klickt die passenden Karten oder beendet den Zug freiwillig."; return; } if (game.status === "LOBBY") { dom.clueHelp.textContent = "Vier Rollen besetzen, dann kann der Host das Match starten."; return; } if (game.phase === "CLUE") { dom.clueHelp.textContent = `${labelForTeam(game.currentTeam)} wartet gerade auf den naechsten Hinweis.`; return; } dom.clueHelp.textContent = "Die Runde laeuft. Beobachte den aktuellen Hinweis und das Board."; } function describeTurn(game) { if (game.status === "FINISHED") { return `${labelForTeam(game.winner)} gewinnt`; } if (game.phase === "CLUE") { return `${labelForTeam(game.currentTeam)} gibt den Hinweis`; } return `${labelForTeam(game.currentTeam)} raet`; } function formatViewerRole(viewer) { if (!viewer.team || !viewer.role) { return viewer.host ? "Host und Zuschauer" : "Zuschauer"; } return `${labelForTeam(viewer.team)} ${labelForRole(viewer.role)}${viewer.host ? " und Host" : ""}`; } function setConnectionState(text) { dom.connectionState.textContent = text; } async function api(url, options = {}) { const response = await fetch(url, { headers: { "Content-Type": "application/json", }, ...options, }); const body = await response.json().catch(() => ({})); if (!response.ok) { throw new Error(body.message || "Die Anfrage konnte nicht verarbeitet werden."); } return body; } function flash(message) { dom.flash.textContent = message; dom.flash.classList.remove("hidden"); window.setTimeout(() => dom.flash.classList.add("hidden"), 3200); } function labelForTeam(team) { return team === "RED" ? "Rot" : "Blau"; } function labelForRole(role) { return role === "SPYMASTER" ? "Hinweisgeber" : "Ermittler"; } function labelForAffiliation(affiliation) { if (affiliation === "ASSASSIN") { return "Attentaeter"; } if (affiliation === "NEUTRAL") { return "Neutral"; } return labelForTeam(affiliation); } function escapeHtml(text) { return String(text) .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll("\"", """) .replaceAll("'", "'"); }