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(item.message)}
${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("'", "'"); }