423 lines
14 KiB
JavaScript
423 lines
14 KiB
JavaScript
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 `
|
|
<button
|
|
type="button"
|
|
class="seat-card ${seat.team.toLowerCase()} ${occupied ? "occupied" : "vacant"} ${seat.yours ? "yours" : ""}"
|
|
data-action="${occupied || !canChangeSeats ? "" : "take-seat"}"
|
|
data-team="${seat.team}"
|
|
data-role="${seat.role}"
|
|
>
|
|
<span class="seat-team">${labelForTeam(seat.team)}</span>
|
|
<strong>${labelForRole(seat.role)}</strong>
|
|
<span>${occupied ? seat.participantName : "Frei"}</span>
|
|
</button>
|
|
`;
|
|
}).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 `
|
|
<article class="participant-card ${participant.host ? "host" : ""}">
|
|
<div>
|
|
<strong>${escapeHtml(participant.displayName)}</strong>
|
|
<span>${role}</span>
|
|
</div>
|
|
${participant.participantId === snapshot.viewer.participantId ? "<em>Du</em>" : participant.host ? "<em>Host</em>" : ""}
|
|
</article>
|
|
`;
|
|
}).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 `
|
|
<article class="activity-item">
|
|
<span>${time}</span>
|
|
<p>${escapeHtml(item.message)}</p>
|
|
</article>
|
|
`;
|
|
}).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 `
|
|
<button
|
|
type="button"
|
|
class="${classes}"
|
|
${card.clickable ? `data-action="guess-card" data-card-index="${card.index}"` : "disabled"}
|
|
>
|
|
<span class="word">${escapeHtml(card.word)}</span>
|
|
<span class="affiliation">${card.visibleAffiliation ? labelForAffiliation(card.visibleAffiliation) : "verdeckt"}</span>
|
|
</button>
|
|
`;
|
|
}).join("");
|
|
|
|
if (game.clue) {
|
|
const guesses = game.remainingGuesses == null ? "?" : String(game.remainingGuesses);
|
|
dom.activeClue.classList.remove("hidden");
|
|
dom.activeClue.innerHTML = `
|
|
<span>Aktueller Hinweis</span>
|
|
<strong>${escapeHtml(game.clue.word)}</strong>
|
|
<p>${game.clue.count} Karten, noch ${guesses} Versuche.</p>
|
|
`;
|
|
} 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("'", "'");
|
|
}
|