feat: add realtime codenames game
This commit is contained in:
422
src/main/resources/static/app.js
Normal file
422
src/main/resources/static/app.js
Normal file
@@ -0,0 +1,422 @@
|
||||
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("'", "'");
|
||||
}
|
||||
157
src/main/resources/static/index.html
Normal file
157
src/main/resources/static/index.html
Normal file
@@ -0,0 +1,157 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Codenames Lobby</title>
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
<script defer src="/app.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="ambient ambient-left"></div>
|
||||
<div class="ambient ambient-right"></div>
|
||||
<main class="page-shell">
|
||||
<section class="hero-card">
|
||||
<p class="eyebrow">Realtime Multiplayer</p>
|
||||
<h1>Codenames fuer euren Browser.</h1>
|
||||
<p class="hero-copy">
|
||||
Raum anlegen, ID teilen, Rollen besetzen und Hinweise per WebSocket in Echtzeit spielen.
|
||||
</p>
|
||||
<div id="flash" class="flash hidden"></div>
|
||||
<div class="hero-grid">
|
||||
<form id="create-room-form" class="panel stack-form">
|
||||
<div class="panel-head">
|
||||
<h2>Neuen Raum starten</h2>
|
||||
<p>Du wirst automatisch Host und kannst das erste Match anwerfen.</p>
|
||||
</div>
|
||||
<label class="field">
|
||||
<span>Anzeigename</span>
|
||||
<input id="create-name" name="displayName" maxlength="24" placeholder="z. B. Daniela">
|
||||
</label>
|
||||
<button type="submit" class="primary-button">Raum erstellen</button>
|
||||
</form>
|
||||
|
||||
<form id="join-room-form" class="panel stack-form">
|
||||
<div class="panel-head">
|
||||
<h2>Raum beitreten</h2>
|
||||
<p>Mit Raum-ID als Spieler oder Zuschauer direkt einsteigen.</p>
|
||||
</div>
|
||||
<label class="field">
|
||||
<span>Raum-ID</span>
|
||||
<input id="join-room-id" name="roomId" maxlength="6" placeholder="ABC123" required>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Anzeigename</span>
|
||||
<input id="join-name" name="displayName" maxlength="24" placeholder="z. B. Lukas">
|
||||
</label>
|
||||
<button type="submit" class="secondary-button">Raum beitreten</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="room-view" class="room-layout hidden">
|
||||
<aside class="side-column">
|
||||
<section class="panel room-summary">
|
||||
<div class="room-head">
|
||||
<div>
|
||||
<p class="eyebrow">Raum</p>
|
||||
<h2 id="room-code">------</h2>
|
||||
</div>
|
||||
<button id="copy-link-button" class="ghost-button" type="button">Link kopieren</button>
|
||||
</div>
|
||||
<label class="field compact">
|
||||
<span>Join-Link</span>
|
||||
<input id="share-link" readonly>
|
||||
</label>
|
||||
<form id="rename-form" class="rename-row">
|
||||
<input id="rename-input" maxlength="24" placeholder="Dein Anzeigename">
|
||||
<button type="submit" class="ghost-button">Umbenennen</button>
|
||||
</form>
|
||||
<div class="meta-grid">
|
||||
<div>
|
||||
<span class="meta-label">Du spielst als</span>
|
||||
<strong id="viewer-role">Zuschauer</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="meta-label">Status</span>
|
||||
<strong id="connection-state">verbunden</strong>
|
||||
</div>
|
||||
</div>
|
||||
<button id="start-game-button" class="primary-button wide hidden" type="button">
|
||||
Spiel starten
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h3>Rollenverteilung</h3>
|
||||
<p>Ein Klick auf einen freien Platz setzt dich direkt in die Rolle.</p>
|
||||
</div>
|
||||
<div id="seat-grid" class="seat-grid"></div>
|
||||
<button id="leave-seat-button" class="ghost-button wide hidden" type="button">
|
||||
Rolle verlassen
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h3>Teilnehmer</h3>
|
||||
<p>Wer mitspielt, spymastert oder nur zuschaut.</p>
|
||||
</div>
|
||||
<div id="participant-list" class="participant-list"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h3>Spiel-Feed</h3>
|
||||
<p>Die letzten Aktionen im Raum.</p>
|
||||
</div>
|
||||
<div id="activity-list" class="activity-list"></div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<section class="board-column">
|
||||
<section class="panel status-strip">
|
||||
<div>
|
||||
<p class="eyebrow">Aktueller Zug</p>
|
||||
<h2 id="turn-title">Lobby</h2>
|
||||
</div>
|
||||
<div class="score-row">
|
||||
<span class="score-pill red">Rot <strong id="red-remaining">0</strong></span>
|
||||
<span class="score-pill blue">Blau <strong id="blue-remaining">0</strong></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel action-panel">
|
||||
<div class="panel-head">
|
||||
<h3>Hinweise und Tipps</h3>
|
||||
<p id="clue-help">Besetzt zuerst alle vier Rollen und startet danach das Match.</p>
|
||||
</div>
|
||||
|
||||
<form id="clue-form" class="clue-form hidden">
|
||||
<label class="field">
|
||||
<span>Hinweiswort</span>
|
||||
<input id="clue-word" maxlength="24" placeholder="z. B. Sternbild">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Anzahl</span>
|
||||
<input id="clue-count" type="number" min="1" max="9" value="2">
|
||||
</label>
|
||||
<button type="submit" class="primary-button">Hinweis senden</button>
|
||||
</form>
|
||||
|
||||
<div id="active-clue" class="active-clue hidden"></div>
|
||||
<button id="end-turn-button" class="secondary-button hidden" type="button">Zug beenden</button>
|
||||
</section>
|
||||
|
||||
<section class="panel board-panel">
|
||||
<div id="board-grid" class="board-grid"></div>
|
||||
<div id="board-empty" class="board-empty">
|
||||
Das Board erscheint, sobald der Host die Runde startet.
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
439
src/main/resources/static/styles.css
Normal file
439
src/main/resources/static/styles.css
Normal file
@@ -0,0 +1,439 @@
|
||||
:root {
|
||||
--paper: #f7f2e7;
|
||||
--paper-strong: #fffaf1;
|
||||
--ink: #1f2330;
|
||||
--muted: #6d7687;
|
||||
--red: #c94b4b;
|
||||
--red-strong: #942d2d;
|
||||
--blue: #296ca8;
|
||||
--blue-strong: #194a75;
|
||||
--gold: #d6a43a;
|
||||
--shadow: 0 18px 45px rgba(31, 35, 48, 0.14);
|
||||
--radius: 24px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: "Avenir Next", "Trebuchet MS", "Segoe UI", sans-serif;
|
||||
color: var(--ink);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(214, 164, 58, 0.22), transparent 28%),
|
||||
radial-gradient(circle at top right, rgba(41, 108, 168, 0.18), transparent 26%),
|
||||
linear-gradient(180deg, #efe2c4 0%, #f7f2e7 44%, #e8eef4 100%);
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.ambient {
|
||||
position: fixed;
|
||||
width: 30rem;
|
||||
height: 30rem;
|
||||
border-radius: 999px;
|
||||
filter: blur(42px);
|
||||
opacity: 0.36;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ambient-left {
|
||||
left: -10rem;
|
||||
top: 10rem;
|
||||
background: rgba(201, 75, 75, 0.28);
|
||||
}
|
||||
|
||||
.ambient-right {
|
||||
right: -12rem;
|
||||
top: -8rem;
|
||||
background: rgba(41, 108, 168, 0.24);
|
||||
}
|
||||
|
||||
.page-shell {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: min(1380px, calc(100% - 2rem));
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem 0 3rem;
|
||||
}
|
||||
|
||||
.hero-card,
|
||||
.panel {
|
||||
background: rgba(255, 250, 241, 0.8);
|
||||
border: 1px solid rgba(109, 118, 135, 0.14);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.hero-card {
|
||||
padding: 2rem;
|
||||
animation: rise 420ms ease-out both;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 0.6rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
font-size: 0.76rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-family: "Baskerville", "Palatino Linotype", serif;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(2.3rem, 3vw, 4rem);
|
||||
line-height: 0.95;
|
||||
max-width: 12ch;
|
||||
}
|
||||
|
||||
.hero-copy,
|
||||
.panel-head p,
|
||||
.meta-label,
|
||||
.field span,
|
||||
.activity-item span {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.hero-grid,
|
||||
.room-layout {
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.hero-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
margin-top: 1.75rem;
|
||||
}
|
||||
|
||||
.room-layout {
|
||||
grid-template-columns: minmax(300px, 360px) minmax(0, 1fr);
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.side-column,
|
||||
.board-column {
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.stack-form,
|
||||
.field,
|
||||
.participant-card,
|
||||
.activity-item,
|
||||
.seat-card,
|
||||
.board-card,
|
||||
.active-clue {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.field input,
|
||||
.rename-row input {
|
||||
width: 100%;
|
||||
padding: 0.86rem 1rem;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(31, 35, 48, 0.14);
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.primary-button,
|
||||
.secondary-button,
|
||||
.ghost-button {
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: transform 160ms ease, box-shadow 160ms ease, opacity 160ms ease;
|
||||
}
|
||||
|
||||
.primary-button,
|
||||
.secondary-button {
|
||||
padding: 0.92rem 1.1rem;
|
||||
border-radius: 16px;
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
background: linear-gradient(135deg, var(--blue-strong), var(--blue));
|
||||
}
|
||||
|
||||
.secondary-button {
|
||||
background: linear-gradient(135deg, var(--red-strong), var(--red));
|
||||
}
|
||||
|
||||
.ghost-button {
|
||||
padding: 0.72rem 0.92rem;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.74);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.primary-button:hover,
|
||||
.secondary-button:hover,
|
||||
.ghost-button:hover,
|
||||
.seat-card:hover,
|
||||
.board-card.clickable:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.wide {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.room-head,
|
||||
.rename-row,
|
||||
.meta-grid,
|
||||
.status-strip,
|
||||
.score-row {
|
||||
display: flex;
|
||||
gap: 0.8rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.meta-grid {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.meta-grid > div {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.seat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.seat-card {
|
||||
text-align: left;
|
||||
padding: 1rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
min-height: 7.2rem;
|
||||
}
|
||||
|
||||
.seat-card.red {
|
||||
border-color: rgba(201, 75, 75, 0.28);
|
||||
}
|
||||
|
||||
.seat-card.blue {
|
||||
border-color: rgba(41, 108, 168, 0.28);
|
||||
}
|
||||
|
||||
.seat-card.yours {
|
||||
box-shadow: inset 0 0 0 2px var(--gold);
|
||||
}
|
||||
|
||||
.seat-team {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.participant-list,
|
||||
.activity-list {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.participant-card,
|
||||
.activity-item {
|
||||
padding: 0.9rem 1rem;
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.68);
|
||||
}
|
||||
|
||||
.participant-card em {
|
||||
color: var(--blue-strong);
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.participant-card.host {
|
||||
border: 1px solid rgba(214, 164, 58, 0.42);
|
||||
}
|
||||
|
||||
.score-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.65rem 0.9rem;
|
||||
border-radius: 999px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.score-pill.red {
|
||||
background: rgba(201, 75, 75, 0.12);
|
||||
color: var(--red-strong);
|
||||
}
|
||||
|
||||
.score-pill.blue {
|
||||
background: rgba(41, 108, 168, 0.12);
|
||||
color: var(--blue-strong);
|
||||
}
|
||||
|
||||
.board-panel {
|
||||
min-height: 620px;
|
||||
}
|
||||
|
||||
.board-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 0.95rem;
|
||||
}
|
||||
|
||||
.board-card {
|
||||
position: relative;
|
||||
min-height: 120px;
|
||||
padding: 0.95rem;
|
||||
border-radius: 18px;
|
||||
border: none;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
background: linear-gradient(180deg, var(--paper-strong), var(--paper));
|
||||
box-shadow: inset 0 0 0 1px rgba(31, 35, 48, 0.08);
|
||||
}
|
||||
|
||||
.board-card .word {
|
||||
font-weight: 800;
|
||||
font-size: 0.96rem;
|
||||
}
|
||||
|
||||
.board-card .affiliation {
|
||||
align-self: end;
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.board-card.red,
|
||||
.board-card.blue,
|
||||
.board-card.neutral,
|
||||
.board-card.assassin {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.board-card.red {
|
||||
background: linear-gradient(180deg, #d26a6a, #9f3030);
|
||||
}
|
||||
|
||||
.board-card.blue {
|
||||
background: linear-gradient(180deg, #4c8fca, #205e94);
|
||||
}
|
||||
|
||||
.board-card.neutral {
|
||||
background: linear-gradient(180deg, #848c96, #59606a);
|
||||
}
|
||||
|
||||
.board-card.assassin {
|
||||
background: linear-gradient(180deg, #2b2f3d, #13151d);
|
||||
}
|
||||
|
||||
.board-card.hidden-card {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.board-card.hidden-card .affiliation {
|
||||
color: rgba(109, 118, 135, 0.6);
|
||||
}
|
||||
|
||||
.board-card.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.board-card:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.board-empty {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 520px;
|
||||
border-radius: 20px;
|
||||
border: 1px dashed rgba(31, 35, 48, 0.14);
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.active-clue {
|
||||
padding: 1rem;
|
||||
border-radius: 18px;
|
||||
background: linear-gradient(135deg, rgba(41, 108, 168, 0.12), rgba(214, 164, 58, 0.18));
|
||||
}
|
||||
|
||||
.flash {
|
||||
margin-top: 1rem;
|
||||
padding: 0.9rem 1rem;
|
||||
border-radius: 16px;
|
||||
background: rgba(201, 75, 75, 0.12);
|
||||
color: var(--red-strong);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@keyframes rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(16px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1040px) {
|
||||
.room-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.page-shell {
|
||||
width: min(100% - 1rem, 100%);
|
||||
padding-top: 0.75rem;
|
||||
}
|
||||
|
||||
.hero-card,
|
||||
.panel {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.seat-grid,
|
||||
.board-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.room-head,
|
||||
.rename-row,
|
||||
.status-strip,
|
||||
.score-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.board-card {
|
||||
min-height: 96px;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user