add-frontend-code-comments #11
10
README.md
10
README.md
@ -401,11 +401,11 @@ const normalized = str.normalize('NFD');
|
||||
|
||||
## Team
|
||||
|
||||
| Name |
|
||||
|------|
|
||||
| **Florin Gartmann** |
|
||||
| **Daniela Studer-Müller** |
|
||||
| **Adrian Joost** |
|
||||
| Name | Rolle & Verantwortungsbereich |
|
||||
|------|-------------------------------|
|
||||
| *Adrian Joost* | *Projektleitung & Architektur*: Gesamtüberblick, Projektinitialisierung, Definition der Schnittstellen sowie Analyse von Sicherheitslücken und Edge-Cases (inkl. „Cheating“-Szenarien). |
|
||||
| *Daniela Studer-Müller* | *Core-Game-Logic*: Implementierung der Spielmechanik, Text-Generierung sowie Entwicklung der Message- und Challenge-Logik. |
|
||||
| *Florin Gartmann* | *Score-System & Ranking*: Entwicklung der Bewertungslogik (Scoring-Algorithmus) und Implementierung des globalen Leaderboards. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -1,9 +1,25 @@
|
||||
/**
|
||||
* Service-Klasse für Duelle/Herausforderungen (Challenge Service).
|
||||
* Ermöglicht das Erstellen und Abschließen von direkt verlinkten Challenges im Backend.
|
||||
*/
|
||||
class ChallengeService {
|
||||
/**
|
||||
* Erstellt eine Instanz des ChallengeService.
|
||||
* @param {Object} config - Das globale Konfigurationsobjekt.
|
||||
*/
|
||||
constructor(config) {
|
||||
this.baseUrl = config.API_BASE_URL;
|
||||
this.urlTail = "challenges";
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt eine neue Herausforderung für einen gegnerischen Spieler.
|
||||
* @param {string} username - Name des herausfordernden Spielers.
|
||||
* @param {string} password - Passwort des herausfordernden Spielers.
|
||||
* @param {string} opponent - Name des herausgeforderten Spielers.
|
||||
* @param {string} text - Der Rundentext und optionale Zusatznachrichten.
|
||||
* @returns {Promise<Object>} Ein Promise mit den erstellten Challenge-Details im Body.
|
||||
*/
|
||||
async postChallenge(username, password, opponent, text) {
|
||||
const response = await fetch(`${this.baseUrl}${this.urlTail}`, {
|
||||
method: "POST",
|
||||
@ -32,6 +48,17 @@ class ChallengeService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Schließt eine bestehende Herausforderung ab, indem das Spielergebnis übermittelt wird.
|
||||
* @param {string} username - Name des Spielers, der die Challenge abschließt.
|
||||
* @param {string} password - Passwort des Spielers.
|
||||
* @param {string|number} challengeId - Eindeutige ID der Challenge im Backend.
|
||||
* @param {number} score - Die erreichte Punktzahl.
|
||||
* @param {number} time - Die benötigte Zeit in Sekunden.
|
||||
* @param {string} text - Der Originaltext der Challenge-Runde.
|
||||
* @param {string} userWrittenText - Der vom Spieler eingegebene Text.
|
||||
* @returns {Promise<Object>} Ein Promise mit der Bestätigung und dem berechneten Ergebnis.
|
||||
*/
|
||||
async completeChallenge(username, password, challengeId, score, time, text, userWrittenText) {
|
||||
const response = await fetch(`${this.baseUrl}${this.urlTail}/${challengeId}/complete`, {
|
||||
method: "POST",
|
||||
@ -63,4 +90,6 @@ class ChallengeService {
|
||||
}
|
||||
}
|
||||
|
||||
// Global verfügbar machen
|
||||
window.ChallengeService = ChallengeService;
|
||||
|
||||
|
||||
@ -1,5 +1,12 @@
|
||||
/**
|
||||
* Konfiguration für die Backend-Verbindung.
|
||||
* Enthält die Basis-URL für alle API-Anfragen der Anwendung.
|
||||
*/
|
||||
const config = {
|
||||
// Die Basis-URL des Backends, an die alle REST-Anfragen gesendet werden.
|
||||
API_BASE_URL: "https://webdev.iten-web.ch/10001/api/"
|
||||
}
|
||||
|
||||
// Globales Konfigurationsobjekt im window-Scope registrieren,
|
||||
// damit alle anderen Service-Klassen darauf zugreifen können.
|
||||
window.config = config;
|
||||
@ -1,9 +1,23 @@
|
||||
/**
|
||||
* Service-Klasse für die Bestenliste (Leaderboard Service).
|
||||
* Kapselt den REST-API-Aufruf zur Abfrage der globalen Highscore-Liste.
|
||||
*/
|
||||
class LeaderboardService {
|
||||
/**
|
||||
* Erstellt eine Instanz des LeaderboardService.
|
||||
* @param {Object} config - Das globale Konfigurationsobjekt.
|
||||
*/
|
||||
constructor(config) {
|
||||
this.baseUrl = config.API_BASE_URL;
|
||||
this.urlTail = "leaderboard";
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruft einen Ausschnitt der globalen Bestenliste ab (Paging).
|
||||
* @param {number} offset - Der Startindex der abzufragenden Einträge (0-basiert).
|
||||
* @param {number} limit - Die maximale Anzahl der zurückzugebenden Einträge (z.B. 10 für Top 10).
|
||||
* @returns {Promise<Object>} Ein Promise mit der Liste der Top-Spieler im Body.
|
||||
*/
|
||||
async getLeaderboard(offset, limit) {
|
||||
const response = await fetch(
|
||||
`${this.baseUrl}${this.urlTail}?offset=${offset}&limit=${limit}`,
|
||||
@ -26,4 +40,6 @@ class LeaderboardService {
|
||||
}
|
||||
}
|
||||
|
||||
// Global verfügbar machen
|
||||
window.LeaderboardService = LeaderboardService;
|
||||
|
||||
|
||||
@ -1,9 +1,24 @@
|
||||
/**
|
||||
* Service-Klasse für das Nachrichtensystem (Message Service).
|
||||
* Ermöglicht den Abruf und Versand von Systemnachrichten und Herausforderungen (Challenges)
|
||||
* sowie das Ändern des Gelesen-Status von Nachrichten.
|
||||
*/
|
||||
class MessageService {
|
||||
/**
|
||||
* Erstellt eine Instanz des MessageService.
|
||||
* @param {Object} config - Das globale Konfigurationsobjekt.
|
||||
*/
|
||||
constructor(config) {
|
||||
this.baseUrl = config.API_BASE_URL;
|
||||
this.urlTail = "messages";
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt alle Nachrichten (eingehend und ausgehend) für den authentifizierten Benutzer.
|
||||
* @param {string} username - Der Benutzername.
|
||||
* @param {string} password - Das Passwort des Benutzers.
|
||||
* @returns {Promise<Object>} Ein Promise mit der Liste aller Nachrichten im Body.
|
||||
*/
|
||||
async getMessages(username, password) {
|
||||
const response = await fetch(`${this.baseUrl}${this.urlTail}`, {
|
||||
method: "GET",
|
||||
@ -27,6 +42,15 @@ class MessageService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sendet eine Nachricht (oder eine eingebettete Challenge) an einen anderen Benutzer.
|
||||
* @param {string} username - Der Benutzername des Senders.
|
||||
* @param {string} password - Das Passwort des Senders.
|
||||
* @param {string} recipient - Der Empfänger der Nachricht.
|
||||
* @param {string} type - Der Typ der Nachricht (z. B. "standard" oder "challenge").
|
||||
* @param {string} text - Der eigentliche Textinhalt (kann eingebettete Challenge-JSON-Daten enthalten).
|
||||
* @returns {Promise<Object>} Ein Promise mit der Antwort des Servers im Body.
|
||||
*/
|
||||
async postMessage(username, password, recipient, type, text) {
|
||||
const response = await fetch(`${this.baseUrl}${this.urlTail}`, {
|
||||
method: "POST",
|
||||
@ -56,6 +80,13 @@ class MessageService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Markiert eine spezifische Nachricht als gelesen.
|
||||
* @param {string} username - Der Benutzername des Empfängers.
|
||||
* @param {string} password - Das Passwort des Empfängers.
|
||||
* @param {string|number} messageId - Eindeutige ID der Nachricht.
|
||||
* @returns {Promise<Object>} Ein Promise mit dem Resultat der PATCH-Anfrage.
|
||||
*/
|
||||
async markMessageAsRead(username, password, messageId) {
|
||||
const response = await fetch(`${this.baseUrl}${this.urlTail}/${messageId}/read`, {
|
||||
method: "PATCH",
|
||||
@ -79,6 +110,12 @@ class MessageService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Markiert alle ungelesenen Nachrichten des Benutzers auf einmal als gelesen.
|
||||
* @param {string} username - Der Benutzername.
|
||||
* @param {string} password - Das Passwort des Benutzers.
|
||||
* @returns {Promise<Object>} Ein Promise mit dem Status der Bulk-Operation.
|
||||
*/
|
||||
async markAllMessagesAsRead(username, password) {
|
||||
const response = await fetch(`${this.baseUrl}${this.urlTail}/read`, {
|
||||
method: "PATCH",
|
||||
@ -103,4 +140,6 @@ class MessageService {
|
||||
}
|
||||
}
|
||||
|
||||
// Global verfügbar machen
|
||||
window.MessageService = MessageService;
|
||||
|
||||
|
||||
@ -1,11 +1,25 @@
|
||||
/**
|
||||
* Service-Klasse für die Spielstände (Score Service).
|
||||
* Kapselt alle REST-Anfragen an das Backend zur Speicherung und zum Laden von Scores.
|
||||
*/
|
||||
class ScoreService {
|
||||
/**
|
||||
* Erstellt eine Instanz des ScoreService.
|
||||
* @param {Object} config - Das globale Konfigurationsobjekt.
|
||||
*/
|
||||
constructor(config) {
|
||||
this.baseUrl = config.API_BASE_URL;
|
||||
this.urlTail = "score";
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt alle Scores eines bestimmten Benutzers.
|
||||
* @param {string} username - Der Name des Benutzers, dessen Scores geladen werden sollen.
|
||||
* @returns {Promise<Object>} Ein Promise mit der Liste aller Benutzer-Scores im Body.
|
||||
*/
|
||||
async getScoreByName(username) {
|
||||
// Note: When user does not exist, we get a 200 with empty array, not a 404
|
||||
// Hinweis: Wenn der Benutzer nicht existiert, liefert das Backend einen Status 200
|
||||
// mit einem leeren Array statt eines 404-Fehlers.
|
||||
const response = await fetch(`${this.baseUrl}${this.urlTail}/${username}`, {
|
||||
method: "GET",
|
||||
});
|
||||
@ -23,6 +37,16 @@ class ScoreService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Speichert einen neu erzielten Score im Backend.
|
||||
* @param {string} username - Benutzername des Spielers.
|
||||
* @param {string} password - Passwort des Spielers zur Autorisierung.
|
||||
* @param {number} score - Die erreichte Punktzahl (Anzahl korrekter Wörter).
|
||||
* @param {number} time - Die für die Eingabe benötigte Zeit in Sekunden.
|
||||
* @param {string} text - Der vorgegebene Originaltext der Spielrunde.
|
||||
* @param {string} userWrittenText - Der vom Benutzer eingegebene Text.
|
||||
* @returns {Promise<Object>} Ein Promise mit der Antwort des Servers (inkl. vergebenem Rang im Body).
|
||||
*/
|
||||
async postScore(username, password, score, time, text, userWrittenText) {
|
||||
const response = await fetch(`${this.baseUrl}${this.urlTail}`, {
|
||||
method: "POST",
|
||||
@ -53,6 +77,13 @@ class ScoreService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Löscht ein bestimmtes Score-Ergebnis anhand seiner ID (falls vom Backend unterstützt).
|
||||
* @param {string} username - Benutzername des Spielers.
|
||||
* @param {string} password - Passwort des Spielers zur Autorisierung.
|
||||
* @param {string|number} scoreId - Die eindeutige ID des zu löschenden Scores.
|
||||
* @returns {Promise<Object>} Ein Promise mit dem Resultat der Löschung.
|
||||
*/
|
||||
async deleteScore(username, password, scoreId) {
|
||||
const response = await fetch(`${this.baseUrl}${this.urlTail}/${scoreId}`, {
|
||||
method: "DELETE",
|
||||
@ -76,4 +107,6 @@ class ScoreService {
|
||||
}
|
||||
}
|
||||
|
||||
// Global verfügbar machen
|
||||
window.ScoreService = ScoreService;
|
||||
|
||||
|
||||
@ -1,13 +1,29 @@
|
||||
/**
|
||||
* Service-Klasse für die Benutzerverwaltung (User Service).
|
||||
* Kapselt alle REST-Anfragen an das Backend bezüglich Login, Registrierung,
|
||||
* Abfrage aller Benutzer und Löschung von Konten.
|
||||
*/
|
||||
class UserService {
|
||||
/**
|
||||
* Erstellt eine Instanz des UserService.
|
||||
* @param {Object} config - Das globale Konfigurationsobjekt (enthält API_BASE_URL).
|
||||
*/
|
||||
constructor(config) {
|
||||
this.baseUrl = config.API_BASE_URL;
|
||||
this.urlTail = "user";
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentifiziert einen Benutzer und ruft dessen Kontoinformationen ab.
|
||||
* @param {string} username - Der Benutzername.
|
||||
* @param {string} password - Das Passwort des Benutzers.
|
||||
* @returns {Promise<Object>} Ein Promise mit dem HTTP-Status, dem OK-Flag und den Benutzerdaten (Body).
|
||||
*/
|
||||
async getUser(username, password) {
|
||||
const response = await fetch(`${this.baseUrl}${this.urlTail}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
// Authentifizierung erfolgt über benutzerdefinierte HTTP-Header
|
||||
"X-Username": username,
|
||||
"X-Password": password,
|
||||
},
|
||||
@ -26,6 +42,12 @@ class UserService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruft eine Liste aller im System registrierten Benutzer ab (z. B. für die Challenge-Auswahl).
|
||||
* @param {string} username - Der Benutzername des anfragenden Benutzers.
|
||||
* @param {string} password - Das Passwort des anfragenden Benutzers.
|
||||
* @returns {Promise<Object>} Ein Promise mit der Benutzerliste im Response-Body.
|
||||
*/
|
||||
async getUsers(username, password) {
|
||||
const response = await fetch(`${this.baseUrl}users`, {
|
||||
method: "GET",
|
||||
@ -48,6 +70,12 @@ class UserService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Löscht das eigene Benutzerkonto unwiderruflich aus dem System.
|
||||
* @param {string} username - Der Benutzername des zu löschenden Kontos.
|
||||
* @param {string} password - Das Passwort des Kontos.
|
||||
* @returns {Promise<Object>} Ein Promise mit dem Status der Löschoperation.
|
||||
*/
|
||||
async deleteUser(username, password) {
|
||||
const response = await fetch(`${this.baseUrl}${this.urlTail}`, {
|
||||
method: "DELETE",
|
||||
@ -70,6 +98,11 @@ class UserService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Registriert einen neuen Benutzer im System. Das Passwort wird vom Backend generiert und zurückgegeben.
|
||||
* @param {string} username - Der gewünschte neue Benutzername.
|
||||
* @returns {Promise<Object>} Ein Promise mit den erstellten Account-Daten (inkl. generiertem Passwort) im Body.
|
||||
*/
|
||||
async postUser(username) {
|
||||
const response = await fetch(`${this.baseUrl}${this.urlTail}`, {
|
||||
method: "POST",
|
||||
@ -96,4 +129,6 @@ class UserService {
|
||||
}
|
||||
}
|
||||
|
||||
// Global verfügbar machen
|
||||
window.UserService = UserService;
|
||||
|
||||
|
||||
39
index.html
39
index.html
@ -4,12 +4,15 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Lorem Ipsum - Das Spiel</title>
|
||||
<link rel="icon" type="image/png" href="image/icon_l.png">
|
||||
<!-- Bootstrap CSS -->
|
||||
<!-- Favicon -->
|
||||
<link class="icon" type="image/png" href="image/icon_l.png">
|
||||
|
||||
<!-- Bootstrap CSS (Lokale Distribution) -->
|
||||
<link rel="stylesheet" href="assets/bootstrap-5.3.8-dist/css/bootstrap.min.css">
|
||||
<!-- Eigene Styles für Design-Anpassungen -->
|
||||
<link rel="stylesheet" href="assets/css/custom.css?v=challenge-flow-20260528e">
|
||||
|
||||
<!-- Kleine Korrektur direkt hier, falls Sie custom.css nicht sofort ändern wollen -->
|
||||
<!-- Inline-CSS-Anpassungen zur Behebung von Flexbox-Layout-Verschiebungen -->
|
||||
<style>
|
||||
/* Sicherstellen, dass der Main-Area den ganzen Platz einnimmt */
|
||||
#main-area {
|
||||
@ -36,9 +39,8 @@
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<!-- class="p-4" entfernt -->
|
||||
<body>
|
||||
<!-- Navigation -->
|
||||
<!-- Navigation: Navbar am oberen Bildschirmrand -->
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="#">Lorem Ipsum - Das Spiel</a>
|
||||
@ -47,6 +49,7 @@
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav ms-auto">
|
||||
<!-- Navigationslinks für Nachrichten (Inbox) und den Login/Registrierungsstatus -->
|
||||
<li class ="nav-item"><a class="nav-link" href="#" id="navbar-messages">Nachrichten</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="#" id="navbar-login">Login / Registrieren</a></li>
|
||||
</ul>
|
||||
@ -54,8 +57,9 @@
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Hauptseiten-Wrapper für das Flexbox-Layout -->
|
||||
<div id="page-wrapper">
|
||||
<!-- Sidebar -->
|
||||
<!-- Sidebar Navigation auf der linken Seite (oder einklappbar auf Mobilgeräten) -->
|
||||
<nav id="sidebar" class="bg-dark text-white p-3">
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item"><a class="nav-link" href="#" id="nav-home">Home</a></li>
|
||||
@ -65,22 +69,22 @@
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<!-- Main Area: Enthält jetzt Topbar, Content UND Footer -->
|
||||
<!-- Hauptbereich: Enthält Topbar, Content und Footer -->
|
||||
<div id="main-area">
|
||||
<!-- Topbar -->
|
||||
<!-- Topbar (Seiten-Überschrift) -->
|
||||
<header id="topbar">
|
||||
<h1 class="text-center mb-4">Dashboard</h1>
|
||||
</header>
|
||||
|
||||
<!-- Content -->
|
||||
<!-- Dynamischer Inhalts-Container: Hier werden die HTML-Dateien aus pages/ hineingeladen -->
|
||||
<main class="container mt-4" id="main-content">
|
||||
<!-- Hier werden die pages.html geladen -->
|
||||
</main>
|
||||
|
||||
<!-- Footer: Jetzt INSIDE main-area, damit Flexbox funktioniert -->
|
||||
<!-- Footer-Bereich mit Impressums- und Urheberrechtsangaben -->
|
||||
<footer class="bg-dark text-black py-4 border-top border-secondary">
|
||||
<div class="container">
|
||||
<!-- Oberer Bereich -->
|
||||
<!-- Modul-Informationen -->
|
||||
<div class="row mb-3">
|
||||
<div class="col-12">Made with <span class="text-danger">♥</span> für das Modul Frontend
|
||||
<h5 class="text-warning">Made with Bootstrap 5.3.8.</h5>
|
||||
@ -90,7 +94,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Impressum -->
|
||||
<!-- Impressumsdetails -->
|
||||
<div class="row mt-2">
|
||||
<div class="col-12">
|
||||
<span class="text-warning fw-bold d-block mb-2" style="font-size: 0.7rem;">Impressum</span>
|
||||
@ -113,6 +117,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Copyright-Angabe -->
|
||||
<div class="text-center mt-2">
|
||||
<p class="mb-0 text-black-50" style="font-size: 0.65rem;">
|
||||
© 2026 Modul Frontend Projekt. Alle Rechte vorbehalten.
|
||||
@ -125,20 +130,26 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bootstrap JS Bundle -->
|
||||
<!-- JavaScript-Einbindungen -->
|
||||
<!-- Bootstrap JS Bundle (Lokale Distribution) -->
|
||||
<script src="assets/bootstrap-5.3.8-dist/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<!-- Backend API Services -->
|
||||
<script src="assets/src/service/config-service.js"></script>
|
||||
<script src="assets/src/service/user-service.js"></script>
|
||||
<script src="assets/src/service/score-service.js"></script>
|
||||
<script src="assets/src/service/leaderboard-service.js"></script>
|
||||
<script src="assets/src/service/message-service.js"></script>
|
||||
<script src="assets/src/service/challenge-service.js"></script>
|
||||
|
||||
<!-- Frontend Logik-Skripte -->
|
||||
<script src="js/login.js"></script>
|
||||
<script src="js/leaderboard.js"></script>
|
||||
<script src="js/scores.js"></script>
|
||||
<script src="js/messages.js?v=challenge-flow-20260531b"></script>
|
||||
<!--Navigation Script -->
|
||||
<script src="js/play.js?v=challenge-flow-20260531b"></script>
|
||||
<!-- Hauptnavigations-Steuerung -->
|
||||
<script src="js/navigation.js?v=challenge-flow-20260528"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
@ -1,4 +1,16 @@
|
||||
// Formatiert Sekunden als m:ss. Bei ungültigem Wert wird ein Platzhalter angezeigt.
|
||||
/**
|
||||
* Bestenliste (Leaderboard) verwalten und anzeigen.
|
||||
* Dieses Modul lädt die Top-10-Spieler vom Server. Falls der aktuell angemeldete
|
||||
* Benutzer nicht in diesen Top-10 vertreten ist, lädt es zusätzlich dessen
|
||||
* persönliches Bestergebnis und zeigt dieses optisch getrennt darunter an.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Formatiert Sekunden in ein lesbares MM:SS Format (z.B. 75 Sekunden -> "1:15").
|
||||
* Bei ungültigen Werten wird ein Strich ausgegeben.
|
||||
* @param {number} seconds - Anzahl Sekunden.
|
||||
* @returns {string} Formatiertes Zeit-String.
|
||||
*/
|
||||
function formatTime(seconds) {
|
||||
if (typeof seconds !== "number" || Number.isNaN(seconds)) {
|
||||
return "-";
|
||||
@ -9,7 +21,10 @@ function formatTime(seconds) {
|
||||
return `${minutes}:${String(remainingSeconds).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
// Liefert den aktuellen Login-Kontext, falls Auth global verfügbar ist.
|
||||
/**
|
||||
* Holt die aktuellen Authentifizierungsdaten aus dem globalen Auth-Modul.
|
||||
* @returns {Object|null} Auth-Objekt (mit username) oder null.
|
||||
*/
|
||||
function getLoggedInAuth() {
|
||||
if (!window.AppAuth || typeof window.AppAuth.getAuth !== "function") {
|
||||
return null;
|
||||
@ -23,11 +38,19 @@ function getLoggedInAuth() {
|
||||
return auth;
|
||||
}
|
||||
|
||||
// Vereinheitlicht Benutzernamen für robuste Vergleiche (z. B. Groß-/Kleinschreibung).
|
||||
/**
|
||||
* Normalisiert einen Benutzernamen (Trimming und Kleinschreibung) für robuste Vergleiche.
|
||||
* @param {string} username - Der Benutzername.
|
||||
* @returns {string} Der bereinigte Benutzername.
|
||||
*/
|
||||
function normalizeUsername(username) {
|
||||
return String(username ?? "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ermittelt den normalisierten Benutzernamen des aktuell angemeldeten Benutzers.
|
||||
* @returns {string|null} Der bereinigte Name oder null.
|
||||
*/
|
||||
function getLoggedInUsername() {
|
||||
const auth = getLoggedInAuth();
|
||||
if (!auth) {
|
||||
@ -37,7 +60,13 @@ function getLoggedInUsername() {
|
||||
return normalizeUsername(auth.username);
|
||||
}
|
||||
|
||||
// Nutzt den vom Backend gelieferten Rang, fallback auf die aktuelle Listenposition.
|
||||
/**
|
||||
* Ermittelt den anzuzeigenden Rang eines Bestenlisten-Eintrags.
|
||||
* Nutzt vorrangig den vom Backend gelieferten Platz ("place"), andernfalls den Listenindex.
|
||||
* @param {Object} entry - Der Listeneintrag.
|
||||
* @param {number} index - Der Listenindex.
|
||||
* @returns {number} Der anzuzeigende Rang.
|
||||
*/
|
||||
function getDisplayedRank(entry, index) {
|
||||
const place = Number(entry?.place);
|
||||
if (!Number.isNaN(place) && place > 0) {
|
||||
@ -47,7 +76,12 @@ function getDisplayedRank(entry, index) {
|
||||
return index + 1;
|
||||
}
|
||||
|
||||
// Bestes Ergebnis: höchste Punktzahl, bei Gleichstand die geringere Zeit.
|
||||
/**
|
||||
* Ermittelt das beste Ergebnis aus einer Liste von Scores.
|
||||
* Sortiert nach Score (absteigend) und bei Gleichstand nach Zeit (aufsteigend).
|
||||
* @param {Array<Object>} entries - Eine Liste von Score-Einträgen.
|
||||
* @returns {Object|null} Der beste Score-Eintrag.
|
||||
*/
|
||||
function getBestScoreEntry(entries) {
|
||||
return entries
|
||||
.slice()
|
||||
@ -64,6 +98,11 @@ function getBestScoreEntry(entries) {
|
||||
})[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt das beste Ergebnis des aktuell angemeldeten Benutzers aus dem Backend.
|
||||
* @param {string} username - Der Benutzername des eingeloggten Spielers.
|
||||
* @returns {Promise<Object|null>} Das beste Spielergebnis oder null.
|
||||
*/
|
||||
async function getCurrentUserLeaderboardEntry(username) {
|
||||
if (!window.ScoreService || !username) {
|
||||
return null;
|
||||
@ -79,7 +118,14 @@ async function getCurrentUserLeaderboardEntry(username) {
|
||||
return getBestScoreEntry(result.body);
|
||||
}
|
||||
|
||||
// Rendert die Top-Liste und markiert den eingeloggten Nutzer visuell.
|
||||
/**
|
||||
* Rendert die Bestenliste im DOM.
|
||||
* Hebt die Zeile des aktuell angemeldeten Benutzers farblich hervor.
|
||||
* Falls extraUserEntry übergeben wird (wenn der User nicht in den Top-10 ist),
|
||||
* wird am Ende eine Trennzeile und der Eintrag des Benutzers angehängt.
|
||||
* @param {Array<Object>} entries - Die Top-10 Leaderboard-Einträge.
|
||||
* @param {Object|null} extraUserEntry - Der separate Bestenlisten-Eintrag des angemeldeten Benutzers.
|
||||
*/
|
||||
function renderLeaderboard(entries, extraUserEntry = null) {
|
||||
const tableBody = document.getElementById("leaderboard-body");
|
||||
if (!tableBody) {
|
||||
@ -90,10 +136,12 @@ function renderLeaderboard(entries, extraUserEntry = null) {
|
||||
|
||||
tableBody.innerHTML = "";
|
||||
|
||||
// Rendern der Top-10
|
||||
entries.forEach((entry, index) => {
|
||||
const row = document.createElement("tr");
|
||||
const rowUsername = normalizeUsername(entry.username);
|
||||
|
||||
// Zeile grün hervorheben, wenn es sich um den eigenen Account handelt
|
||||
if (loggedInUsername && rowUsername === loggedInUsername) {
|
||||
row.classList.add("leaderboard-row-current-user");
|
||||
}
|
||||
@ -108,13 +156,15 @@ function renderLeaderboard(entries, extraUserEntry = null) {
|
||||
tableBody.appendChild(row);
|
||||
});
|
||||
|
||||
// Wenn der User eingeloggt ist, aber nicht in den Top-10 vertreten war, hängen wir ihn unten an
|
||||
if (extraUserEntry) {
|
||||
// Trennt Top-10 und eigenen Eintrag optisch, wenn der Nutzer nicht in den Top-10 ist.
|
||||
// Optische Lücke (Leerzeile) einfügen
|
||||
const spacerRow = document.createElement("tr");
|
||||
spacerRow.classList.add("leaderboard-row-gap");
|
||||
spacerRow.innerHTML = '<td colspan="4"></td>';
|
||||
tableBody.appendChild(spacerRow);
|
||||
|
||||
// Zeile des angemeldeten Benutzers anhängen
|
||||
const userRow = document.createElement("tr");
|
||||
userRow.classList.add("leaderboard-row-current-user");
|
||||
userRow.classList.add("leaderboard-row-current-user-extra");
|
||||
@ -129,8 +179,13 @@ function renderLeaderboard(entries, extraUserEntry = null) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt die Top-10-Bestenliste vom Backend und prüft,
|
||||
* ob der angemeldete Benutzer separat geladen werden muss.
|
||||
*/
|
||||
async function loadTopTenLeaderboard() {
|
||||
const leaderboardService = new window.LeaderboardService(window.config);
|
||||
// Holt die Bestenliste beginnend bei Rang 1 (Offset 0) mit maximal 10 Einträgen
|
||||
const result = await leaderboardService.getLeaderboard(0, 10);
|
||||
|
||||
if (!result.ok || !Array.isArray(result.body)) {
|
||||
@ -143,11 +198,12 @@ async function loadTopTenLeaderboard() {
|
||||
|
||||
if (auth && auth.username) {
|
||||
const loggedInUsername = normalizeUsername(auth.username);
|
||||
// Falls der Nutzer nicht in den Top-10 erscheint, wird sein bestes Ergebnis separat gezeigt.
|
||||
// Prüfen, ob der angemeldete Benutzer bereits in den Top-10 vorhanden ist
|
||||
const isInTopTen = result.body.some(
|
||||
(entry) => normalizeUsername(entry.username) === loggedInUsername,
|
||||
);
|
||||
|
||||
// Wenn er nicht in den Top-10 ist, rufen wir sein bestes Ergebnis separat ab
|
||||
if (!isInTopTen) {
|
||||
extraUserEntry = await getCurrentUserLeaderboardEntry(auth.username);
|
||||
}
|
||||
@ -156,9 +212,14 @@ async function loadTopTenLeaderboard() {
|
||||
renderLeaderboard(result.body, extraUserEntry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Globale Initialisierungsfunktion, die von navigation.js aufgerufen wird,
|
||||
* sobald die leaderboard.html-Teilseite geladen wurde.
|
||||
*/
|
||||
window.initLeaderboardPage = function initLeaderboardPage() {
|
||||
loadTopTenLeaderboard().catch((error) => {
|
||||
console.error("Fehler beim Laden des Leaderboards:", error);
|
||||
renderLeaderboard([]);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
94
js/login.js
94
js/login.js
@ -1,6 +1,16 @@
|
||||
/**
|
||||
* Benutzer-Authentifizierung und Account-Verwaltung.
|
||||
* Verwaltet den Login-Status über den LocalStorage, steuert die Anzeige im Header,
|
||||
* verarbeitet Login- und Registrierungsformulare und ermöglicht das Löschen des Kontos.
|
||||
*/
|
||||
(function () {
|
||||
// Der Schlüssel, unter dem die Benutzerdaten im LocalStorage abgelegt werden.
|
||||
const AUTH_STORAGE_KEY = "loremIpsumAuth";
|
||||
|
||||
/**
|
||||
* Liest die aktuellen Anmeldedaten aus dem LocalStorage des Browsers.
|
||||
* @returns {Object|null} Ein Objekt mit {username, password} oder null, falls nicht angemeldet oder ungültig.
|
||||
*/
|
||||
function readAuth() {
|
||||
const raw = localStorage.getItem(AUTH_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
@ -9,15 +19,22 @@
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
// Validieren, dass die Pflichtfelder vorhanden sind
|
||||
if (!parsed.username || !parsed.password) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
// Fehler beim JSON-Parsen (z.B. manipulierte Daten) -> als nicht angemeldet behandeln
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Speichert die Benutzerdaten verschlüsselt als JSON-String im LocalStorage.
|
||||
* @param {string} username - Der Benutzername.
|
||||
* @param {string} password - Das generierte/eingegebene Passwort.
|
||||
*/
|
||||
function saveAuth(username, password) {
|
||||
localStorage.setItem(
|
||||
AUTH_STORAGE_KEY,
|
||||
@ -25,24 +42,37 @@
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernt die Anmeldedaten aus dem LocalStorage (Logout).
|
||||
*/
|
||||
function clearAuth() {
|
||||
localStorage.removeItem(AUTH_STORAGE_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualisiert die Anzeige des Benutzernamens im Header und den Login-Button.
|
||||
*/
|
||||
function updateHeaderUsername() {
|
||||
const usernameDisplay = document.getElementById("username-display");
|
||||
const navbarLogin = document.getElementById("navbar-login");
|
||||
const auth = readAuth();
|
||||
|
||||
// Text im Header anpassen ("User: Guest" oder "User: <Name>")
|
||||
if (usernameDisplay) {
|
||||
usernameDisplay.textContent = auth ? "User: " + auth.username : "User: Guest";
|
||||
}
|
||||
|
||||
// Text des Login-Links in der Navbar anpassen
|
||||
if (navbarLogin) {
|
||||
navbarLogin.textContent = auth ? auth.username : "Login / Registrieren";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeigt dem Benutzer Feedback (z. B. Fehlermeldungen) auf der Login-Seite an.
|
||||
* @param {string} message - Die anzuzeigende Nachricht.
|
||||
* @param {string} type - Der Bootstrap-Alert-Typ (danger, warning, success, info).
|
||||
*/
|
||||
function setFeedback(message, type) {
|
||||
const feedback = document.getElementById("auth-feedback");
|
||||
if (!feedback) {
|
||||
@ -51,9 +81,13 @@
|
||||
|
||||
feedback.className = "alert alert-" + type;
|
||||
feedback.textContent = message;
|
||||
feedback.classList.remove("d-none");
|
||||
feedback.classList.remove("d-none"); // Alert einblenden
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualisiert den Zustand der Session-Box (Anzeige der aktuellen Anmeldung).
|
||||
* Blendet die Forms aus bzw. ein und aktiviert/deaktiviert die Buttons.
|
||||
*/
|
||||
function updateSessionBox() {
|
||||
const sessionText = document.getElementById("current-session-text");
|
||||
const logoutButton = document.getElementById("logout-button");
|
||||
@ -67,12 +101,14 @@
|
||||
|
||||
const auth = readAuth();
|
||||
if (auth) {
|
||||
// Wenn eingeloggt: Session-Info anzeigen und Buttons aktivieren
|
||||
sessionText.textContent = "Eingeloggt als " + auth.username + ".";
|
||||
logoutButton.disabled = false;
|
||||
deleteAccountButton.disabled = false;
|
||||
currentSessionBox.classList.remove("d-none");
|
||||
authFormsRow.classList.remove("d-none");
|
||||
} else {
|
||||
// Wenn nicht eingeloggt: Info setzen und Session-Box verbergen
|
||||
sessionText.textContent = "Nicht eingeloggt.";
|
||||
logoutButton.disabled = true;
|
||||
deleteAccountButton.disabled = true;
|
||||
@ -81,6 +117,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hilfsfunktion zum Instanziieren des UserService.
|
||||
* @returns {UserService|null} Eine UserService-Instanz oder null.
|
||||
*/
|
||||
function getUserService() {
|
||||
if (!window.config || !window.UserService) {
|
||||
return null;
|
||||
@ -89,6 +129,11 @@
|
||||
return new window.UserService(window.config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Behandelt das Absenden des Login-Formulars.
|
||||
* Authentifiziert den Benutzer beim Backend und speichert die Session bei Erfolg.
|
||||
* @param {Event} event - Das Submit-Event des Formulars.
|
||||
*/
|
||||
async function handleLoginSubmit(event) {
|
||||
event.preventDefault();
|
||||
|
||||
@ -100,7 +145,7 @@
|
||||
|
||||
const usernameInput = document.getElementById("login-username");
|
||||
const passwordInput = document.getElementById("login-password");
|
||||
const submitButton = event.submitter;
|
||||
const submitButton = event.submitter; // Der geklickte Button
|
||||
const username = usernameInput.value.trim();
|
||||
const password = passwordInput.value.trim();
|
||||
|
||||
@ -109,6 +154,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Button während der Anfrage sperren und Ladeindikator zeigen
|
||||
if (submitButton) {
|
||||
submitButton.disabled = true;
|
||||
submitButton.textContent = "Einloggen...";
|
||||
@ -127,19 +173,25 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Button wieder freigeben
|
||||
if (submitButton) {
|
||||
submitButton.disabled = false;
|
||||
submitButton.textContent = "Einloggen";
|
||||
}
|
||||
|
||||
if (result.ok) {
|
||||
// Login erfolgreich: Daten speichern, Ansicht aktualisieren und zur Startseite leiten
|
||||
saveAuth(username, password);
|
||||
setFeedback("Login erfolgreich.", "success");
|
||||
updateSessionBox();
|
||||
updateHeaderUsername();
|
||||
|
||||
// Falls das Navigationsmenü geöffnet ist, dessen Status aktualisieren
|
||||
if (typeof window.updateMessagesNavState === "function") {
|
||||
window.updateMessagesNavState();
|
||||
}
|
||||
|
||||
// Kurze Verzögerung für visuelles Feedback vor dem Seitenwechsel
|
||||
setTimeout(function() {
|
||||
if (typeof window.loadPage === "function") {
|
||||
window.loadPage("home", "nav-home");
|
||||
@ -156,6 +208,12 @@
|
||||
setFeedback("Login fehlgeschlagen (Status " + result.status + ").", "danger");
|
||||
}
|
||||
|
||||
/**
|
||||
* Behandelt das Absenden des Registrierungsformulars.
|
||||
* Sendet den gewünschten Benutzernamen ans Backend, welches das Passwort generiert.
|
||||
* Zeigt das Passwort anschließend in einem Bootstrap-Modal an, da es nur einmalig sichtbar ist.
|
||||
* @param {Event} event - Das Submit-Event des Formulars.
|
||||
*/
|
||||
async function handleRegisterSubmit(event) {
|
||||
event.preventDefault();
|
||||
|
||||
@ -175,9 +233,11 @@
|
||||
|
||||
const result = await userService.postUser(username);
|
||||
if (result.ok && result.body) {
|
||||
// Registrierung erfolgreich: Antwortdaten auslesen
|
||||
const createdName = result.body.name || username;
|
||||
const createdPassword = result.body.password || "";
|
||||
|
||||
// Direkt einloggen mit den erhaltenen Daten
|
||||
saveAuth(createdName, createdPassword);
|
||||
updateSessionBox();
|
||||
updateHeaderUsername();
|
||||
@ -185,17 +245,18 @@
|
||||
window.updateMessagesNavState();
|
||||
}
|
||||
|
||||
// Modal mit Daten füllen und anzeigen
|
||||
// Modal mit den generierten Zugangsdaten befüllen
|
||||
const modalUsername = document.getElementById("modal-username");
|
||||
const modalPassword = document.getElementById("modal-password");
|
||||
if (modalUsername) modalUsername.textContent = createdName;
|
||||
if (modalPassword) modalPassword.textContent = createdPassword;
|
||||
|
||||
// Das Passwort-Modal anzeigen
|
||||
const passwordModalElement = document.getElementById("password-modal");
|
||||
if (passwordModalElement) {
|
||||
const passwordModal = new window.bootstrap.Modal(passwordModalElement);
|
||||
|
||||
// Listener für Modals-Schließen: zur Startseite navigieren
|
||||
// Nach Schließen des Modals automatisch auf die Startseite weiterleiten
|
||||
passwordModalElement.addEventListener("hidden.bs.modal", function handleClose() {
|
||||
if (typeof window.loadPage === "function") {
|
||||
window.loadPage("home", "nav-home");
|
||||
@ -205,7 +266,7 @@
|
||||
|
||||
passwordModal.show();
|
||||
} else {
|
||||
// Fallback wenn Modal nicht gefunden
|
||||
// Fallback, falls das Bootstrap-Modal im DOM nicht gefunden wurde
|
||||
setFeedback(
|
||||
"Account erstellt. Username: " + createdName + ", Passwort: " + createdPassword,
|
||||
"success",
|
||||
@ -217,6 +278,7 @@
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
// Formularfelder für eventuelle spätere Logins vorausfüllen
|
||||
const loginUsernameInput = document.getElementById("login-username");
|
||||
const loginPasswordInput = document.getElementById("login-password");
|
||||
if (loginUsernameInput && loginPasswordInput) {
|
||||
@ -227,6 +289,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Fehlerbehandlung
|
||||
if (result.status === 400) {
|
||||
const errorMessage = result.body && result.body.message
|
||||
? result.body.message
|
||||
@ -241,6 +304,9 @@
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loggt den aktuellen Benutzer aus (löscht Auth-Daten aus LocalStorage und aktualisiert UI).
|
||||
*/
|
||||
function handleLogout() {
|
||||
clearAuth();
|
||||
setFeedback("Du wurdest ausgeloggt.", "info");
|
||||
@ -251,6 +317,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Behandelt das Löschen des eigenen Accounts.
|
||||
* Fordert eine Sicherheitsbestätigung an, sendet die Löschanfrage ans Backend
|
||||
* und führt bei Erfolg einen automatischen Logout aus.
|
||||
*/
|
||||
async function handleDeleteAccount() {
|
||||
const auth = readAuth();
|
||||
if (!auth) {
|
||||
@ -258,6 +329,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Sicherheitsabfrage im Browser
|
||||
const wantsDelete = window.confirm(
|
||||
"Möchtest du den Account \"" + auth.username + "\" wirklich löschen?",
|
||||
);
|
||||
@ -273,6 +345,7 @@
|
||||
|
||||
const result = await userService.deleteUser(auth.username, auth.password);
|
||||
if (result.ok) {
|
||||
// Account erfolgreich gelöscht: Lokal ausloggen und Feedback geben
|
||||
clearAuth();
|
||||
updateSessionBox();
|
||||
updateHeaderUsername();
|
||||
@ -294,6 +367,10 @@
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialisiert die Login-Seite.
|
||||
* Bindet alle Event-Listener an die Formulare und Buttons nach dem Laden des HTML-Inhalts.
|
||||
*/
|
||||
function initLoginPage() {
|
||||
const loginForm = document.getElementById("login-form");
|
||||
const registerForm = document.getElementById("register-form");
|
||||
@ -309,15 +386,22 @@
|
||||
logoutButton.addEventListener("click", handleLogout);
|
||||
deleteAccountButton.addEventListener("click", handleDeleteAccount);
|
||||
|
||||
// Initialen UI-Zustand herstellen
|
||||
updateSessionBox();
|
||||
updateHeaderUsername();
|
||||
}
|
||||
|
||||
// Bindet die Initialisierungsfunktion an das globale Fensterobjekt für navigation.js
|
||||
window.initLoginPage = initLoginPage;
|
||||
|
||||
// Stellt Authentifizierungsfunktionen global bereit, damit andere Module (z. B. play.js)
|
||||
// prüfen können, ob und wer eingeloggt ist.
|
||||
window.AppAuth = {
|
||||
getAuth: readAuth,
|
||||
clearAuth: clearAuth,
|
||||
};
|
||||
|
||||
// Beim allerersten Laden der Anwendung direkt den Header anpassen
|
||||
document.addEventListener("DOMContentLoaded", updateHeaderUsername);
|
||||
})();
|
||||
|
||||
|
||||
264
js/messages.js
264
js/messages.js
@ -1,9 +1,24 @@
|
||||
/**
|
||||
* Nachrichten-Dashboard und Duell-System (Challenges).
|
||||
* Dieses Modul verwaltet den Abruf und Versand von Textnachrichten, die Gruppierung von
|
||||
* Nachrichten zu Duell-Konversationen, das Erstellen neuer Herausforderungen mit zufälligen
|
||||
* Texten und die visuelle Aufbereitung von Spielergebnissen (Gewonnen/Verloren/Unentschieden).
|
||||
*/
|
||||
(function () {
|
||||
// --- Konstanten & Konfiguration ---
|
||||
const MESSAGE_TYPE_CHALLENGE = "challenge";
|
||||
const MESSAGE_TYPE_CHALLENGE_RESULT = "challenge-result";
|
||||
|
||||
// Abfrage-Intervall für neue Nachrichten (30 Sekunden)
|
||||
const MESSAGE_POLL_INTERVAL_MS = 30000;
|
||||
|
||||
// Key für die aktive Challenge im SessionStorage
|
||||
const ACTIVE_CHALLENGE_STORAGE_KEY = "loremIpsumActiveChallenge";
|
||||
|
||||
// Eindeutiges Kennzeichen für JSON-strukturierte Challenge-Daten innerhalb normaler Textnachrichten
|
||||
const CHALLENGE_DATA_PREFIX = "[[loremIpsumChallenge:";
|
||||
|
||||
// Satzteile zur Generierung der zu merkenden Sätze in Duellen
|
||||
const CHALLENGE_TEXT_PARTS = {
|
||||
subjects: [
|
||||
"Der flinke Entwickler",
|
||||
@ -55,14 +70,24 @@
|
||||
],
|
||||
};
|
||||
|
||||
let currentMessages = [];
|
||||
let currentUsers = [];
|
||||
let messagePollingInterval = null;
|
||||
// --- Modulweiter Status ---
|
||||
let currentMessages = []; // Cache für alle geladenen Nachrichten
|
||||
let currentUsers = []; // Liste aller anderen Benutzer im System
|
||||
let messagePollingInterval = null; // ID des Polling-Timers
|
||||
|
||||
/**
|
||||
* Holt ein zufälliges Element aus einem Array.
|
||||
* @param {Array} items - Das Quell-Array.
|
||||
*/
|
||||
function getRandomChallengeTextPart(items) {
|
||||
return items[Math.floor(Math.random() * items.length)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generiert einen grammatikalisch korrekten, zufälligen Text für ein Duell.
|
||||
* Besteht aus zwei Hauptsätzen und einem Schlusssatz.
|
||||
* @returns {string} Der generierte Challenge-Text.
|
||||
*/
|
||||
function generateChallengeText() {
|
||||
const firstSentence =
|
||||
[
|
||||
@ -87,6 +112,12 @@
|
||||
getRandomChallengeTextPart(CHALLENGE_TEXT_PARTS.endings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut einen Nachrichtentext auf, in den die Spieldaten (JSON) eingebettet sind.
|
||||
* @param {Object} challengeData - Die strukturierten Spieldaten.
|
||||
* @param {string} displayText - Der für den Nutzer lesbare Textteil der Nachricht.
|
||||
* @returns {string} Der präparierte Gesamttext für die API.
|
||||
*/
|
||||
function buildEmbeddedChallengeText(challengeData, displayText) {
|
||||
return CHALLENGE_DATA_PREFIX +
|
||||
JSON.stringify(challengeData) +
|
||||
@ -95,6 +126,9 @@
|
||||
displayText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt die aktuellen Zugangsdaten aus dem AppAuth-Modul.
|
||||
*/
|
||||
function getAuth() {
|
||||
if (!window.AppAuth || typeof window.AppAuth.getAuth !== "function") {
|
||||
return null;
|
||||
@ -108,10 +142,16 @@
|
||||
return auth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bereinigt Benutzernamen für Vergleiche.
|
||||
*/
|
||||
function normalizeUsername(username) {
|
||||
return String(username || "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt eine Instanz des MessageService.
|
||||
*/
|
||||
function getMessageService() {
|
||||
if (!window.config || !window.MessageService) {
|
||||
return null;
|
||||
@ -120,6 +160,9 @@
|
||||
return new window.MessageService(window.config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt eine Instanz des UserService.
|
||||
*/
|
||||
function getUserService() {
|
||||
if (!window.config || !window.UserService) {
|
||||
return null;
|
||||
@ -128,6 +171,9 @@
|
||||
return new window.UserService(window.config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt eine Instanz des ChallengeService.
|
||||
*/
|
||||
function getChallengeService() {
|
||||
if (!window.config || !window.ChallengeService) {
|
||||
return null;
|
||||
@ -136,9 +182,15 @@
|
||||
return new window.ChallengeService(window.config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalisiert ein vom Server empfangenes Nachrichtenobjekt.
|
||||
* Extrahiert ggf. eingebettete JSON-Challenge-Daten und vereinheitlicht
|
||||
* abweichende Backend-Feldnamen (z.B. date/time/createdAt).
|
||||
* @param {Object} message - Das rohe Nachrichtenobjekt.
|
||||
* @returns {Object} Die normalisierte Nachricht.
|
||||
*/
|
||||
function normalizeMessage(message) {
|
||||
// Das Backend und unser Nachrichten-Fallback liefern Challenge-Daten leicht unterschiedlich.
|
||||
// Hier werden beide Formen in ein einheitliches Message-Objekt gebracht.
|
||||
// Verschiedene mögliche Text-Eigenschaften des Backends prüfen
|
||||
const textCandidates = [
|
||||
message.text,
|
||||
message.content,
|
||||
@ -146,14 +198,20 @@
|
||||
message.challenge?.text,
|
||||
message.result?.text,
|
||||
];
|
||||
// Findet den Text, der unsere eingebetteten JSON-Daten enthält, oder den ersten nicht-leeren Text
|
||||
const rawText = textCandidates.find((value) => String(value ?? "").includes(CHALLENGE_DATA_PREFIX))
|
||||
?? textCandidates.find((value) => value !== null && value !== undefined)
|
||||
?? "";
|
||||
|
||||
// Versuchen, eingebettetes JSON aus dem Text zu parsen
|
||||
const embeddedChallenge = extractEmbeddedChallenge(rawText);
|
||||
const type = message.type ?? MESSAGE_TYPE_CHALLENGE;
|
||||
|
||||
const backendChallenge = message.challenge
|
||||
?? message.result
|
||||
?? (type === MESSAGE_TYPE_CHALLENGE || type === MESSAGE_TYPE_CHALLENGE_RESULT ? message : null);
|
||||
|
||||
// Zusammenführen von Backend-Challenge-Daten und lokal extrahierten Daten
|
||||
const challenge = embeddedChallenge.challenge
|
||||
? { ...(backendChallenge ?? {}), ...embeddedChallenge.challenge }
|
||||
: backendChallenge;
|
||||
@ -163,16 +221,20 @@
|
||||
sender: message.sender ?? message.from ?? "",
|
||||
recipient: message.recipient ?? message.to ?? "",
|
||||
type: type,
|
||||
text: embeddedChallenge.text,
|
||||
text: embeddedChallenge.text, // Nur der lesbare Teil
|
||||
read: Boolean(message.read),
|
||||
createdAt: message.createdAt ?? message.time ?? message.date ?? "",
|
||||
challenge: challenge,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrahiert ein eingebettetes JSON-Objekt aus einem Nachrichtentext.
|
||||
* Sucht nach dem Muster: [[loremIpsumChallenge: {JSON} ]]
|
||||
* @param {string} text - Der rohe Nachrichtentext.
|
||||
* @returns {Object} Ein Objekt mit dem bereinigten Anzeigetext und dem extrahierten JSON-Objekt (oder null).
|
||||
*/
|
||||
function extractEmbeddedChallenge(text) {
|
||||
// Fallback fuer Challenge-Daten, die im Nachrichtentext mitgesendet werden.
|
||||
// So koennen wir bestehende Message-Endpunkte nutzen, ohne neue Backend-Felder zu verlangen.
|
||||
const rawText = String(text ?? "");
|
||||
const startIndex = rawText.indexOf(CHALLENGE_DATA_PREFIX);
|
||||
if (startIndex === -1) {
|
||||
@ -190,7 +252,9 @@
|
||||
};
|
||||
}
|
||||
|
||||
// JSON-String ausschneiden
|
||||
const json = rawText.slice(startIndex + CHALLENGE_DATA_PREFIX.length, endIndex);
|
||||
// Den JSON-Teil aus dem Text entfernen, damit der Empfänger ihn nicht sieht
|
||||
const displayText = (
|
||||
rawText.slice(0, startIndex) +
|
||||
rawText.slice(endIndex + 2)
|
||||
@ -202,6 +266,7 @@
|
||||
challenge: JSON.parse(json),
|
||||
};
|
||||
} catch {
|
||||
// Falls JSON beschädigt ist, den Text unverändert zurückgeben
|
||||
return {
|
||||
text: rawText,
|
||||
challenge: null,
|
||||
@ -209,10 +274,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt die eindeutige ID einer Challenge aus verschiedenen Backend-Datenstrukturen.
|
||||
*/
|
||||
function getChallengeId(challenge) {
|
||||
return challenge?.challengeId ?? challenge?.challenge_id ?? challenge?.id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ermittelt den Namen des Herausforderers.
|
||||
*/
|
||||
function getChallengeChallenger(challenge, fallbackName) {
|
||||
return challenge?.challenger
|
||||
?? challenge?.challengerName
|
||||
@ -222,6 +293,9 @@
|
||||
?? "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Ermittelt den Namen des Herausgeforderten (Gegners).
|
||||
*/
|
||||
function getChallengeOpponent(challenge, fallbackName) {
|
||||
return challenge?.opponent
|
||||
?? challenge?.opponentName
|
||||
@ -232,6 +306,9 @@
|
||||
?? "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Ermittelt den Score des Gegners.
|
||||
*/
|
||||
function getOpponentScore(challenge) {
|
||||
return challenge?.opponentScore
|
||||
?? challenge?.challengedScore
|
||||
@ -239,6 +316,9 @@
|
||||
?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ermittelt den vorgegebenen Rundentext der Challenge.
|
||||
*/
|
||||
function getChallengeText(challenge) {
|
||||
return challenge?.challengeText
|
||||
?? challenge?.roundText
|
||||
@ -246,10 +326,18 @@
|
||||
?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, ob ein Wert eine gültige Punktzahl darstellt.
|
||||
*/
|
||||
function hasScore(value) {
|
||||
return value !== null && value !== undefined && value !== "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Bestimmt die Rolle des aktuell angemeldeten Benutzers in einer Challenge.
|
||||
* @param {Object} message - Die normalisierte Nachricht.
|
||||
* @returns {string|null} "opponent" (Geforderter), "challenger" (Herausforderer) oder null.
|
||||
*/
|
||||
function getChallengeRole(message) {
|
||||
const auth = getAuth();
|
||||
const challenge = message.challenge;
|
||||
@ -272,6 +360,10 @@
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, ob der Geforderte (Opponent) die Challenge annehmen und spielen darf.
|
||||
* Das ist der Fall, wenn er noch keinen Score hat und auch der Herausforderer noch nicht gespielt hat.
|
||||
*/
|
||||
function canOpponentAcceptChallenge(message) {
|
||||
if (message.type !== MESSAGE_TYPE_CHALLENGE || !message.challenge) {
|
||||
return false;
|
||||
@ -284,6 +376,11 @@
|
||||
return role === "opponent" && !hasScore(opponentScore) && !hasScore(challenge.challengerScore);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, ob der Herausforderer (Challenger) seine Runde spielen darf.
|
||||
* Das ist der Fall, wenn der Gegner (Opponent) bereits gespielt hat,
|
||||
* der Herausforderer selbst aber noch nicht.
|
||||
*/
|
||||
function canChallengerPlayChallenge(message) {
|
||||
const role = getChallengeRole(message);
|
||||
const challenge = message.challenge;
|
||||
@ -293,9 +390,12 @@
|
||||
&& !hasScore(challenge.challengerScore);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ermittelt den Beschriftungs- und Aktivierungsstatus des Buttons für eine Challenge.
|
||||
* @param {Object} message - Die normalisierte Nachricht.
|
||||
* @returns {Object|null} Button-Konfiguration mit {disabled, label, role} oder null.
|
||||
*/
|
||||
function getChallengeButtonState(message) {
|
||||
// Bestimmt den sichtbaren Status einer Challenge aus Sicht des eingeloggten Users.
|
||||
// Dadurch gibt es pro Challenge genau einen aktiven oder deaktivierten Button.
|
||||
const challenge = message.challenge;
|
||||
const role = getChallengeRole(message);
|
||||
if (!challenge || !role) {
|
||||
@ -306,6 +406,7 @@
|
||||
const challengerHasScore = hasScore(challenge.challengerScore);
|
||||
const opponentHasScore = hasScore(opponentScore);
|
||||
|
||||
// Szenario 1: Beide haben bereits gespielt -> Duell vorbei
|
||||
if (challengerHasScore && opponentHasScore) {
|
||||
return {
|
||||
disabled: true,
|
||||
@ -313,6 +414,7 @@
|
||||
};
|
||||
}
|
||||
|
||||
// Szenario 2: Der angemeldete User wurde herausgefordert (Rolle Opponent)
|
||||
if (role === "opponent") {
|
||||
if (!opponentHasScore && !challengerHasScore) {
|
||||
return {
|
||||
@ -328,7 +430,9 @@
|
||||
};
|
||||
}
|
||||
|
||||
// Szenario 3: Der angemeldete User hat das Duell gestartet (Rolle Challenger)
|
||||
if (role === "challenger") {
|
||||
// Herausforderer spielt als zweites, nachdem der Gegner vorgelegt hat
|
||||
if (opponentHasScore && !challengerHasScore) {
|
||||
return {
|
||||
disabled: false,
|
||||
@ -337,6 +441,7 @@
|
||||
};
|
||||
}
|
||||
|
||||
// Gegner hat noch nicht reagiert
|
||||
return {
|
||||
disabled: true,
|
||||
label: "Warte auf Gegner",
|
||||
@ -346,15 +451,20 @@
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Startet die Spielrunde für eine Challenge.
|
||||
* Speichert die Challenge-Daten im SessionStorage ab und leitet auf die Spielseite weiter.
|
||||
* @param {Object} message - Die Challenge-Nachricht.
|
||||
* @param {string} role - Die zugewiesene Spielrolle ("opponent" / "challenger").
|
||||
*/
|
||||
function startChallenge(message, role) {
|
||||
// Der aktive Challenge-Kontext wird nur fuer die naechste Spielrunde im Session Storage abgelegt.
|
||||
// play.js liest diesen Zustand aus und weiss dadurch, ob es die erste oder finale Runde ist.
|
||||
const challenge = message.challenge;
|
||||
const challenger = getChallengeChallenger(challenge, message.sender);
|
||||
const opponent = getChallengeOpponent(challenge, message.recipient);
|
||||
const auth = getAuth();
|
||||
const otherUser = role === "opponent" ? challenger : opponent;
|
||||
|
||||
// Challenge-Kontext sichern, damit play.js weiß, in welchem Modus gestartet wird
|
||||
sessionStorage.setItem(
|
||||
ACTIVE_CHALLENGE_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
@ -368,11 +478,15 @@
|
||||
}),
|
||||
);
|
||||
|
||||
// Dynamisch auf die Spielseite navigieren
|
||||
if (typeof window.loadPage === "function") {
|
||||
window.loadPage("play", "nav-play");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalisiert ein Benutzerobjekt/String zu einem Namen.
|
||||
*/
|
||||
function normalizeUser(user) {
|
||||
if (typeof user === "string") {
|
||||
return user;
|
||||
@ -381,6 +495,9 @@
|
||||
return user?.name ?? user?.username ?? "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatiert einen ISO-Zeitstempel in de-CH Lokale Format (z.B. "09.06. 18:07").
|
||||
*/
|
||||
function formatMessageTime(value) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
@ -395,6 +512,9 @@
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Blendet eine Feedbackmeldung ein.
|
||||
*/
|
||||
function setFeedback(message, type) {
|
||||
const feedback = document.getElementById("messages-feedback");
|
||||
if (!feedback) {
|
||||
@ -406,6 +526,9 @@
|
||||
feedback.classList.remove("d-none");
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktiviert oder deaktiviert alle Formular-Interaktionen in der Inbox (während Ladezeiten).
|
||||
*/
|
||||
function setFormEnabled(enabled) {
|
||||
const formElements = document.querySelectorAll(
|
||||
"#challenge-form button, #challenge-form select, #challenge-form textarea, #mark-read-button, #refresh-messages-button",
|
||||
@ -416,6 +539,11 @@
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualisiert den visuellen Zustand des Nachrichten-Links in der Navbar.
|
||||
* Falls ungelesene Nachrichten für den Benutzer vorliegen, wird der Link rot hinterlegt.
|
||||
* @param {Array<Object>} messages - Die Liste der Nachrichten.
|
||||
*/
|
||||
function updateMessagesNavState(messages = currentMessages) {
|
||||
const navLink = document.getElementById("navbar-messages");
|
||||
if (!navLink) {
|
||||
@ -424,14 +552,19 @@
|
||||
|
||||
const auth = getAuth();
|
||||
const ownName = normalizeUsername(auth?.username);
|
||||
// Prüfen, ob eine eingehende Nachricht ungelesen ist
|
||||
const hasUnreadMessages = messages.some((message) => {
|
||||
const isIncoming = normalizeUsername(message.recipient) === ownName
|
||||
|| normalizeUsername(message.sender) !== ownName;
|
||||
return isIncoming && !message.read;
|
||||
});
|
||||
// Toggle der CSS-Klasse "has-unread-messages"
|
||||
navLink.classList.toggle("has-unread-messages", hasUnreadMessages);
|
||||
}
|
||||
|
||||
/**
|
||||
* Markiert eine Nachricht beim Anklicken als gelesen und sendet dies an den Server.
|
||||
*/
|
||||
async function markMessageReadOnClick(message, item) {
|
||||
const auth = getAuth();
|
||||
const messageService = getMessageService();
|
||||
@ -445,6 +578,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Optimistisches UI-Update: Sofort als gelesen markieren
|
||||
message.read = true;
|
||||
item.classList.remove("message-item-unread");
|
||||
updateMessagesNavState(currentMessages);
|
||||
@ -456,6 +590,7 @@
|
||||
);
|
||||
|
||||
if (!result.ok) {
|
||||
// Rollback bei Fehler im Backend
|
||||
message.read = false;
|
||||
item.classList.add("message-item-unread");
|
||||
updateMessagesNavState(currentMessages);
|
||||
@ -463,15 +598,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Markiert alle ungelesenen Nachrichten einer Challenge-Gruppe beim Klicken auf gelesen.
|
||||
*/
|
||||
async function markMessageGroupReadOnClick(messages, item) {
|
||||
// Eine gruppierte Challenge-Karte kann mehrere einzelne Nachrichten enthalten.
|
||||
// Beim Anklicken werden deshalb alle ungelesenen eingehenden Nachrichten der Gruppe markiert.
|
||||
const auth = getAuth();
|
||||
const messageService = getMessageService();
|
||||
if (!auth || !messageService) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Alle ungelesenen eingehenden Nachrichten der Gruppe filtern
|
||||
const unreadIncomingMessages = messages.filter((message) => {
|
||||
const isIncoming = normalizeUsername(message.recipient) === normalizeUsername(auth.username)
|
||||
|| normalizeUsername(message.sender) !== normalizeUsername(auth.username);
|
||||
@ -482,12 +619,14 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Optimistisches Update
|
||||
unreadIncomingMessages.forEach((message) => {
|
||||
message.read = true;
|
||||
});
|
||||
item.classList.remove("message-item-unread");
|
||||
updateMessagesNavState(currentMessages);
|
||||
|
||||
// Alle Requests parallel an das Backend senden
|
||||
const results = await Promise.all(
|
||||
unreadIncomingMessages.map((message) =>
|
||||
messageService.markMessageAsRead(auth.username, auth.password, message.id),
|
||||
@ -495,6 +634,7 @@
|
||||
);
|
||||
|
||||
if (results.some((result) => !result.ok)) {
|
||||
// Rollback bei Fehlern
|
||||
unreadIncomingMessages.forEach((message) => {
|
||||
message.read = false;
|
||||
});
|
||||
@ -504,22 +644,32 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hilfsfunktion zur Ermittlung des Zeitstempels einer Nachricht.
|
||||
*/
|
||||
function getMessageTimeValue(message) {
|
||||
const time = new Date(message.createdAt).getTime();
|
||||
return Number.isNaN(time) ? 0 : time;
|
||||
}
|
||||
|
||||
/**
|
||||
* Führt die Teildaten aus verschiedenen Nachrichten einer Challenge zusammen.
|
||||
* Wenn z.B. Nachricht 1 die ID und den Text liefert, und Nachricht 2 den Gegner-Score,
|
||||
* enthält das Ergebnis-Objekt alle Eigenschaften.
|
||||
* @param {Array<Object>} messages - Die sortierte Liste der Challenge-Nachrichten.
|
||||
* @returns {Object} Ein zusammengeführtes Datenobjekt der Challenge.
|
||||
*/
|
||||
function mergeChallengeData(messages) {
|
||||
// Fuer die Challenge-Karte werden Teildaten aus mehreren Nachrichten zusammengefuehrt
|
||||
// (z. B. zuerst Challenge-ID, spaeter Gegner-Score, am Schluss Gewinner).
|
||||
return messages
|
||||
.slice()
|
||||
// Chronologisch aufsteigend sortieren für korrektes Überschreiben
|
||||
.sort((a, b) => getMessageTimeValue(a) - getMessageTimeValue(b))
|
||||
.reduce((merged, message) => {
|
||||
if (!message.challenge) {
|
||||
return merged;
|
||||
}
|
||||
|
||||
// Alle nicht-leeren Werte in das Sammelobjekt übernehmen
|
||||
Object.keys(message.challenge).forEach((key) => {
|
||||
const value = message.challenge[key];
|
||||
if (value !== null && value !== undefined && value !== "") {
|
||||
@ -531,14 +681,21 @@
|
||||
}, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gruppiert eine Liste von Nachrichten.
|
||||
* Normale Nachrichten bleiben eigenständig, während Nachrichten mit derselben Challenge-ID
|
||||
* zu einer einzigen "Challenge-Gruppe" zusammengefasst werden. Das verhindert Inbox-Spam.
|
||||
* @param {Array<Object>} messages - Liste aller Nachrichten.
|
||||
* @returns {Array<Object>} Die gruppierten Einträge, absteigend nach Aktualität sortiert.
|
||||
*/
|
||||
function groupMessagesByChallenge(messages) {
|
||||
// Nachrichten mit gleicher Challenge-ID werden zu einer Karte gruppiert,
|
||||
// damit alte Zwischen-Nachrichten nicht einzeln die Inbox ueberladen.
|
||||
const groupsByChallenge = new Map();
|
||||
const standaloneGroups = [];
|
||||
|
||||
messages.forEach((message) => {
|
||||
const challengeId = getChallengeId(message.challenge);
|
||||
|
||||
// Standardnachrichten ohne Challenge-ID werden direkt verarbeitet
|
||||
if (challengeId === null || challengeId === undefined) {
|
||||
standaloneGroups.push({
|
||||
kind: "message",
|
||||
@ -548,6 +705,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Challenge-Nachrichten werden nach ID gruppiert
|
||||
const key = String(challengeId);
|
||||
if (!groupsByChallenge.has(key)) {
|
||||
groupsByChallenge.set(key, []);
|
||||
@ -555,11 +713,14 @@
|
||||
groupsByChallenge.get(key).push(message);
|
||||
});
|
||||
|
||||
// Die Gruppierungen konsolidieren
|
||||
const challengeGroups = Array.from(groupsByChallenge.values()).map((groupMessages) => {
|
||||
// Absteigend sortieren, damit das neueste Element die Basis bildet
|
||||
const sortedMessages = groupMessages
|
||||
.slice()
|
||||
.sort((a, b) => getMessageTimeValue(b) - getMessageTimeValue(a));
|
||||
const latest = { ...sortedMessages[0] };
|
||||
// Spieldaten aller Nachrichten der Gruppe verschmelzen
|
||||
latest.challenge = mergeChallengeData(sortedMessages);
|
||||
|
||||
return {
|
||||
@ -569,11 +730,17 @@
|
||||
};
|
||||
});
|
||||
|
||||
// Zusammenführen und absteigend nach Datum der neuesten Nachricht sortieren
|
||||
return standaloneGroups
|
||||
.concat(challengeGroups)
|
||||
.sort((a, b) => getMessageTimeValue(b.latest) - getMessageTimeValue(a.latest));
|
||||
}
|
||||
|
||||
/**
|
||||
* Rendert die Benutzerliste im Sidebar-Bereich der Inbox zum schnellen Herausfordern.
|
||||
* Befüllt auch das Empfänger-Dropdown im Formular.
|
||||
* @param {Array<string|Object>} users - Liste aller User.
|
||||
*/
|
||||
function renderUserList(users) {
|
||||
const userList = document.getElementById("messages-user-list");
|
||||
const recipientSelect = document.getElementById("challenge-recipient");
|
||||
@ -583,6 +750,8 @@
|
||||
|
||||
const auth = getAuth();
|
||||
const ownName = normalizeUsername(auth?.username);
|
||||
|
||||
// Benutzernamen extrahieren, bereinigen, Duplikate entfernen und eigenen Namen filtern
|
||||
const uniqueUsers = Array.from(new Set(users.map(normalizeUser)))
|
||||
.filter(Boolean)
|
||||
.filter((username) => normalizeUsername(username) !== ownName)
|
||||
@ -600,11 +769,13 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Für jeden Benutzer einen Button in der Liste und eine Option im Select erstellen
|
||||
uniqueUsers.forEach((username) => {
|
||||
const userButton = document.createElement("button");
|
||||
userButton.type = "button";
|
||||
userButton.className = "messages-user-button";
|
||||
userButton.textContent = username;
|
||||
// Bei Klick den User als Empfänger auswählen und Fokus ins Textfeld setzen
|
||||
userButton.addEventListener("click", () => {
|
||||
recipientSelect.value = username;
|
||||
document.getElementById("challenge-text")?.focus();
|
||||
@ -618,8 +789,12 @@
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rendert die Nachrichtenliste (Inbox) im DOM.
|
||||
* Erstellt Standard-Nachrichtenkarten oder komplexe Challenge-Karten.
|
||||
* @param {Array<Object>} messages - Die anzuzeigenden Nachrichten.
|
||||
*/
|
||||
function renderMessages(messages = currentMessages) {
|
||||
// Rendert normale Nachrichten einzeln und Challenge-Nachrichten als gruppierte Karten.
|
||||
const messageList = document.getElementById("message-list");
|
||||
if (!messageList) {
|
||||
return;
|
||||
@ -637,11 +812,13 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Nachrichten nach Gruppierung rendern
|
||||
groupMessagesByChallenge(messages)
|
||||
.forEach((messageGroup) => {
|
||||
const message = messageGroup.latest;
|
||||
const item = document.createElement("article");
|
||||
const isOutgoing = normalizeUsername(message.sender) === ownName;
|
||||
// Prüfen, ob mindestens eine Nachricht in der Gruppe ungelesen ist
|
||||
const hasUnreadIncoming = messageGroup.messages.some((groupMessage) => {
|
||||
const isIncoming = normalizeUsername(groupMessage.recipient) === ownName
|
||||
|| normalizeUsername(groupMessage.sender) !== ownName;
|
||||
@ -654,9 +831,11 @@
|
||||
}
|
||||
if (hasUnreadIncoming) {
|
||||
item.classList.add("message-item-unread");
|
||||
// Bei Klick alle ungelesenen Nachrichten dieser Gruppe als gelesen markieren
|
||||
item.addEventListener("click", () => markMessageGroupReadOnClick(messageGroup.messages, item));
|
||||
}
|
||||
|
||||
// Absender-/Empfängerzeile bestimmen
|
||||
const fromToText = messageGroup.kind === "challenge"
|
||||
? "Challenge"
|
||||
: isOutgoing
|
||||
@ -678,6 +857,7 @@
|
||||
meta.append(sender, time);
|
||||
item.append(meta, text);
|
||||
|
||||
// Bei Challenge-Gruppen mit mehreren Nachrichten einen "Verlauf einblenden"-Button anbieten
|
||||
if (messageGroup.kind === "challenge" && messageGroup.messages.length > 1) {
|
||||
const toggleButton = document.createElement("button");
|
||||
toggleButton.type = "button";
|
||||
@ -704,7 +884,7 @@
|
||||
});
|
||||
|
||||
toggleButton.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
event.stopPropagation(); // Verhindert das Auslösen des Gelesen-Markierens der ganzen Karte
|
||||
const isCollapsed = thread.classList.toggle("d-none");
|
||||
toggleButton.textContent = isCollapsed ? "Verlauf anzeigen" : "Verlauf ausblenden";
|
||||
});
|
||||
@ -713,10 +893,12 @@
|
||||
item.appendChild(thread);
|
||||
}
|
||||
|
||||
// Falls die Challenge beendet ist (beide Scores vorhanden), eine Ergebnis-Grafik zeichnen
|
||||
if (message.challenge && hasScore(message.challenge.challengerScore) && hasScore(message.challenge.opponentScore)) {
|
||||
item.appendChild(createChallengeResultGraphic(message.challenge));
|
||||
}
|
||||
|
||||
// Ggf. Button für "Annehmen" oder "Spielen" einblenden
|
||||
const challengeButtonState = getChallengeButtonState(message);
|
||||
if (challengeButtonState) {
|
||||
const challengeButton = document.createElement("button");
|
||||
@ -734,8 +916,12 @@
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut die HTML-Ergebnisgrafik für eine beendete Challenge zusammen.
|
||||
* @param {Object} result - Die zusammengeführten Challenge-Daten.
|
||||
* @returns {HTMLElement} Die erstellte Ergebnis-Komponente.
|
||||
*/
|
||||
function createChallengeResultGraphic(result) {
|
||||
// Baut die kompakte Ergebnisgrafik mit passendem Bild aus Sicht des aktuellen Users.
|
||||
const graphic = document.createElement("div");
|
||||
graphic.className = "challenge-result-graphic";
|
||||
|
||||
@ -743,6 +929,8 @@
|
||||
const isDraw = result.winner === null || result.winner === "draw";
|
||||
const auth = getAuth();
|
||||
const isOwnWin = !isDraw && normalizeUsername(winnerName) === normalizeUsername(auth?.username);
|
||||
|
||||
// Status bestimmen aus Sicht des eingeloggten Users (Sieg / Niederlage / Draw)
|
||||
const outcome = isDraw ? "draw" : isOwnWin ? "win" : "loss";
|
||||
const outcomeText = outcome === "draw"
|
||||
? "Unentschieden"
|
||||
@ -792,6 +980,9 @@
|
||||
return graphic;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt die Liste aller registrierten Benutzer vom Server.
|
||||
*/
|
||||
async function loadUsers() {
|
||||
const auth = getAuth();
|
||||
const userService = getUserService();
|
||||
@ -810,13 +1001,18 @@
|
||||
renderUserList(result.body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt alle Nachrichten vom Backend und aktualisiert die Ansichten.
|
||||
* Schaltet bei fehlendem Login auf den Platzhalter-Zustand um.
|
||||
* @param {Object} options - Z. B. { showFeedback: true } für Aktualisierungsmeldung.
|
||||
*/
|
||||
async function loadMessages(options = {}) {
|
||||
// Laedt Nachrichten vom Backend und blendet die Seite bei fehlendem Login in einen Hinweiszustand.
|
||||
const auth = getAuth();
|
||||
const messageService = getMessageService();
|
||||
const loggedInDiv = document.getElementById("messages-content");
|
||||
const loggedOutDiv = document.getElementById("messages-login-placeholder");
|
||||
|
||||
// Falls nicht angemeldet: Platzhalter einblenden, Daten leeren
|
||||
if (!auth) {
|
||||
if (loggedInDiv) loggedInDiv.classList.add("d-none");
|
||||
if (loggedOutDiv) loggedOutDiv.classList.remove("d-none");
|
||||
@ -828,6 +1024,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Falls angemeldet: Dashboard zeigen
|
||||
if (loggedInDiv) loggedInDiv.classList.remove("d-none");
|
||||
if (loggedOutDiv) loggedOutDiv.classList.add("d-none");
|
||||
|
||||
@ -847,6 +1044,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Nachrichten normalisieren, im Cache sichern und rendern
|
||||
currentMessages = result.body.map(normalizeMessage);
|
||||
renderMessages();
|
||||
updateMessagesNavState();
|
||||
@ -857,8 +1055,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt ein neues Duell (Challenge). Generiert den Spieltext,
|
||||
* bettet ihn als JSON ein und sendet die Nachricht ab.
|
||||
*/
|
||||
async function handleChallengeSubmit(event) {
|
||||
// Erstellt eine Challenge. Der Gegner spielt zuerst; der weitere Ablauf wird ueber Nachrichten gesteuert.
|
||||
event.preventDefault();
|
||||
|
||||
const auth = getAuth();
|
||||
@ -877,7 +1078,9 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Challenge-Satz generieren
|
||||
const challengeText = generateChallengeText();
|
||||
// JSON-Metadaten einbetten
|
||||
const challengeMessage = buildEmbeddedChallengeText(
|
||||
{ challengeText: challengeText },
|
||||
text,
|
||||
@ -895,14 +1098,19 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Reste im Storage säubern
|
||||
sessionStorage.removeItem(ACTIVE_CHALLENGE_STORAGE_KEY);
|
||||
setFeedback(
|
||||
"Challenge an " + recipient + " wurde gesendet. Der Gegner spielt zuerst; danach bekommst du sein Resultat.",
|
||||
"success",
|
||||
);
|
||||
textInput.value = ""; // Textfeld leeren
|
||||
await loadMessages({ showFeedback: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* Markiert alle Nachrichten auf einmal als gelesen.
|
||||
*/
|
||||
async function handleMarkRead() {
|
||||
const auth = getAuth();
|
||||
const messageService = getMessageService();
|
||||
@ -913,6 +1121,7 @@
|
||||
|
||||
const result = await messageService.markAllMessagesAsRead(auth.username, auth.password);
|
||||
if (!result.ok) {
|
||||
// Fallback: Einzeln nacheinander als gelesen markieren, falls die Bulk-API fehlschlägt
|
||||
const unreadMessages = currentMessages.filter((message) => !message.read);
|
||||
const readResults = await Promise.all(
|
||||
unreadMessages.map((message) =>
|
||||
@ -930,6 +1139,10 @@
|
||||
await loadMessages({ showFeedback: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialisiert die Nachrichten-Seite (Events binden).
|
||||
* Wird von navigation.js nach dem Laden von messages.html aufgerufen.
|
||||
*/
|
||||
async function initMessagesPage() {
|
||||
const challengeForm = document.getElementById("challenge-form");
|
||||
const markReadButton = document.getElementById("mark-read-button");
|
||||
@ -950,7 +1163,10 @@
|
||||
await loadMessages({ showFeedback: false });
|
||||
}
|
||||
|
||||
// Initialisierungsfunktion global registrieren
|
||||
window.initMessagesPage = initMessagesPage;
|
||||
|
||||
// Statusaktualisierung global anbieten
|
||||
window.updateMessagesNavState = function () {
|
||||
loadMessages({ showFeedback: false }).catch((error) => {
|
||||
console.error("Nachrichtenstatus konnte nicht geladen werden:", error);
|
||||
@ -958,6 +1174,7 @@
|
||||
});
|
||||
};
|
||||
|
||||
// Polling starten, um im Hintergrund alle 30s den Status (z.B. ungelesene Nachrichten) zu prüfen
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
window.updateMessagesNavState();
|
||||
if (!messagePollingInterval) {
|
||||
@ -968,3 +1185,4 @@
|
||||
}
|
||||
});
|
||||
})();
|
||||
;
|
||||
|
||||
@ -1,28 +1,51 @@
|
||||
/**
|
||||
* Navigation und dynamisches Laden von Einzelseiten (Single Page App Verhalten).
|
||||
* Dieses Skript reagiert auf Klicks im Menü/Sidebar, lädt die gewünschte HTML-Teilseite
|
||||
* per Fetch-API nach und führt die jeweilige Seite-Initialisierungsfunktion aus.
|
||||
*/
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
/**
|
||||
* Setzt die "active"-Klasse im Menü für die ausgewählte Seite,
|
||||
* damit der Benutzer sieht, in welchem Bereich er sich befindet.
|
||||
* @param {string} id - Die ID des Menü-Links (z.B. "nav-home").
|
||||
*/
|
||||
function setActiveMenu(id) {
|
||||
//Alle Sidebar-Links zurücksetzen
|
||||
// Alle Sidebar-Links zurücksetzen
|
||||
document.querySelectorAll("#sidebar .nav-link").forEach(link => link.classList.remove("active"));
|
||||
// Alle Navbar-Links zurücksetzen
|
||||
document.querySelectorAll(".navbar-nav .nav-link").forEach(link => link.classList.remove("active"));
|
||||
|
||||
//Aktiven Link setzen
|
||||
// Aktiven Link anhand seiner ID suchen und hervorheben
|
||||
const activeLink = document.getElementById(id);
|
||||
if (activeLink) activeLink.classList.add("active");
|
||||
}
|
||||
|
||||
// Laedt Teilseiten dynamisch in den Hauptbereich.
|
||||
// Danach wird die passende Init-Funktion aufgerufen, weil die Elemente erst nach dem Laden existieren.
|
||||
/**
|
||||
* Lädt eine Teilseite (.html) dynamisch aus dem "pages/"-Ordner in den Hauptbereich.
|
||||
* Nach dem Laden werden die DOM-Elemente initialisiert (z.B. Event-Listener gebunden),
|
||||
* indem die entsprechende globale "init..."-Funktion aufgerufen wird.
|
||||
* @param {string} page - Der Name der HTML-Datei (ohne Endung, z.B. "play").
|
||||
* @param {string} menuId - Die ID des zugehörigen Menüpunkts zur visuellen Aktivierung.
|
||||
*/
|
||||
window.loadPage = function loadPage(page, menuId) {
|
||||
// fetch lädt das HTML-Snippet. cache: "no-store" verhindert veraltete Stände im Browser-Cache.
|
||||
fetch("pages/" + page + ".html", { cache: "no-store" })
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error("HTTP " + response.status);
|
||||
throw new Error("HTTP-Fehler " + response.status);
|
||||
}
|
||||
return response.text();
|
||||
})
|
||||
.then(data => {
|
||||
// Das geladene HTML-Fragment in den Haupt-Container rendern
|
||||
document.getElementById("main-content").innerHTML = data;
|
||||
|
||||
// Aktiven Menüpunkt visuell kennzeichnen
|
||||
setActiveMenu(menuId);
|
||||
|
||||
// Da das DOM nun neu aufgebaut wurde, müssen wir die Event-Handler
|
||||
// für die jeweilige Seite neu registrieren (Init-Funktionen).
|
||||
if (page === "login" && typeof window.initLoginPage === "function") {
|
||||
window.initLoginPage();
|
||||
}
|
||||
@ -41,11 +64,13 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Fehler beim Laden von " + page + ":", error);
|
||||
// Fehlermeldung für den Benutzer in den Hauptbereich einbetten
|
||||
document.getElementById("main-content").innerHTML = "<div class='alert alert-danger'>Fehler beim Laden der Seite: " + error.message + "</div>";
|
||||
});
|
||||
};
|
||||
|
||||
// Navigation wird zentral verdrahtet, damit alle Seiten ueber denselben Lade-Mechanismus laufen.
|
||||
// --- Menü-Verkabelung ---
|
||||
// Alle Navigations-Elemente aus dem DOM abrufen
|
||||
const navHome = document.getElementById("nav-home");
|
||||
const navPlay = document.getElementById("nav-play");
|
||||
const navMyScores = document.getElementById("nav-my-scores");
|
||||
@ -53,14 +78,18 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
const navbarLogin = document.getElementById("navbar-login");
|
||||
const navbarMessages = document.getElementById("navbar-messages");
|
||||
|
||||
// Standard-Verhalten der Links (Seiten-Reload) verhindern und loadPage() aufrufen
|
||||
if (navHome) navHome.onclick = (e) => { e.preventDefault(); loadPage("home", "nav-home"); };
|
||||
if (navPlay) navPlay.onclick = (e) => { e.preventDefault(); loadPage("play", "nav-play"); };
|
||||
if (navMyScores) navMyScores.onclick = (e) => { e.preventDefault(); loadPage("scores", "nav-my-scores"); };
|
||||
if (navLeaderboard) navLeaderboard.onclick = (e) => { e.preventDefault(); loadPage("leaderboard", "nav-leaderboard"); };
|
||||
if (navbarLogin) navbarLogin.onclick = (e) => { e.preventDefault(); loadPage("login", "navbar-login"); };
|
||||
if (navbarMessages) navbarMessages.onclick = (e) => { e.preventDefault(); loadPage("messages", "navbar-messages"); };
|
||||
|
||||
// Prüfen, ob neue ungelesene Nachrichten vorliegen, um den Nachrichten-Link rot zu markieren
|
||||
if (typeof window.updateMessagesNavState === "function") window.updateMessagesNavState();
|
||||
|
||||
//Startseite laden
|
||||
// Beim allerersten Aufruf der App die Startseite ("home") laden
|
||||
loadPage("home", "nav-home");
|
||||
});
|
||||
|
||||
|
||||
290
js/play.js
290
js/play.js
@ -1,10 +1,22 @@
|
||||
/**
|
||||
* Spiellogik (Lorem Ipsum Merk- und Schreibspiel).
|
||||
* Dieses Modul verwaltet die verschiedenen Phasen des Spiels (Startansicht, Einprägen, Eingabe, Auswertung),
|
||||
* berechnet die Punktzahl durch Wortvergleich und speichert das Ergebnis im Backend.
|
||||
* Unterstützt auch das Duell-System (Challenges) zwischen zwei Benutzern.
|
||||
*/
|
||||
(function () {
|
||||
// --- Konfiguration ---
|
||||
// --- Konfiguration & Konstanten ---
|
||||
|
||||
// Die Zeit in Sekunden, die der Spieler hat, um sich den Text einzuprägen.
|
||||
const MEMORIZE_TIME_SECONDS = 15;
|
||||
|
||||
// Storage-Schlüssel, unter dem eine aktive Challenge im SessionStorage zwischengespeichert wird.
|
||||
const ACTIVE_CHALLENGE_STORAGE_KEY = "loremIpsumActiveChallenge";
|
||||
|
||||
// Präfix zur Kennzeichnung von eingebetteten JSON-Challenge-Daten in Textnachrichten.
|
||||
const CHALLENGE_DATA_PREFIX = "[[loremIpsumChallenge:";
|
||||
|
||||
// Bausteine fuer den zufaelligen Rundentext. Alles bleibt lokal, damit das Spiel ohne Backend starten kann.
|
||||
// Bausteine für den zufälligen Rundentext. Alles bleibt lokal, damit das Spiel ohne Backend starten kann.
|
||||
const TEXT_PARTS = {
|
||||
subjects: [
|
||||
"Der flinke Entwickler",
|
||||
@ -56,37 +68,48 @@
|
||||
],
|
||||
};
|
||||
|
||||
let timerInterval;
|
||||
let currentTime = 0;
|
||||
let inputStartMs = null;
|
||||
// --- Spielstatus Variablen ---
|
||||
let timerInterval; // Intervall-ID für den Memorize-Timer
|
||||
let currentTime = 0; // Aktueller Countdown-Stand
|
||||
let inputStartMs = null; // Zeitstempel (ms), wann der Benutzer mit der Eingabe begonnen hat
|
||||
|
||||
// Der aktuell angezeigte Text muss bis zur Auswertung stabil bleiben.
|
||||
let currentGameText = "";
|
||||
let lastGeneratedText = "";
|
||||
let lastGeneratedText = ""; // Verhindert direkt aufeinanderfolgende gleiche Texte
|
||||
|
||||
// DOM-Referenzen werden erst gesetzt, nachdem pages/play.html dynamisch geladen wurde.
|
||||
let phaseStart;
|
||||
let phaseMemorize;
|
||||
let phaseInput;
|
||||
let phaseResult;
|
||||
let targetTextDisplay;
|
||||
let timerDisplay;
|
||||
let userTextInput;
|
||||
let resultScore;
|
||||
let resultOriginal;
|
||||
let resultInput;
|
||||
let gameStatus;
|
||||
let scoreSaveFeedback;
|
||||
let btnSubmitScore;
|
||||
let activeChallenge = null;
|
||||
// DOM-Referenzen (werden in initPlayPage gesucht, da play.html dynamisch nachgeladen wird)
|
||||
let phaseStart; // Start-Container (mit "Starten"-Button)
|
||||
let phaseMemorize; // Einpräge-Container (zeigt den Text und Countdown)
|
||||
let phaseInput; // Eingabe-Container (Textarea)
|
||||
let phaseResult; // Ergebnis-Container (Vergleich und erreichte Punkte)
|
||||
|
||||
// --- Funktionen ---
|
||||
let targetTextDisplay; // Element zur Anzeige des zu merkenden Textes
|
||||
let timerDisplay; // Element zur Anzeige des Countdowns
|
||||
let userTextInput; // Textarea für die Benutzereingabe
|
||||
let resultScore; // Element zur Anzeige der Punktzahl im Ergebnis
|
||||
let resultOriginal; // Container für farblich markierten Originaltext
|
||||
let resultInput; // Container für farblich markierten eingegebenen Text
|
||||
let gameStatus; // Badge-Element zur Statusanzeige (z.B. "Lernphase", "Eingabe")
|
||||
let scoreSaveFeedback; // Container für Rückmeldungen beim Speichern
|
||||
let btnSubmitScore; // Button "Auswerten & Absenden"
|
||||
let activeChallenge = null; // Enthält geladene Challenge-Daten (falls Spiel aus Inbox gestartet)
|
||||
|
||||
// --- Hilfsfunktionen ---
|
||||
|
||||
/**
|
||||
* Wählt ein zufälliges Element aus einem Array aus.
|
||||
* @param {Array} items - Das Quell-Array.
|
||||
* @returns {*} Ein zufälliges Element.
|
||||
*/
|
||||
function getRandomItem(items) {
|
||||
return items[Math.floor(Math.random() * items.length)];
|
||||
}
|
||||
|
||||
// Erstellt pro Runde zwei zufaellige Saetze plus Schluss-Satz und vermeidet direkte Wiederholungen.
|
||||
/**
|
||||
* Erstellt pro Runde zwei zufällige Hauptsätze plus einen Schlusssatz.
|
||||
* Stellt sicher, dass sich zwei aufeinanderfolgende Runden-Texte unterscheiden.
|
||||
* @returns {string} Ein generierter Satz.
|
||||
*/
|
||||
function generateGameText() {
|
||||
let generatedText = "";
|
||||
|
||||
@ -119,15 +142,19 @@
|
||||
return generatedText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Startet eine neue Spielrunde. Blendet die Startphase aus und startet den Countdown.
|
||||
*/
|
||||
function startGame() {
|
||||
if (!phaseStart || !phaseMemorize) return;
|
||||
|
||||
inputStartMs = null;
|
||||
inputStartMs = null; // Zeitstempel zurücksetzen
|
||||
|
||||
// Startansicht ausblenden und den neu generierten Text fuer die Lernphase anzeigen.
|
||||
// Startansicht ausblenden und den Einpräge-Bildschirm anzeigen
|
||||
phaseStart.classList.add("d-none");
|
||||
phaseMemorize.classList.remove("d-none");
|
||||
|
||||
// Status-Badge aktualisieren
|
||||
if (gameStatus) {
|
||||
gameStatus.textContent = "Lernphase";
|
||||
gameStatus.className = "badge fs-6 px-3 py-2";
|
||||
@ -135,10 +162,11 @@
|
||||
gameStatus.style.color = "#1b1b2f";
|
||||
}
|
||||
|
||||
// Text ermitteln (aus Challenge oder neu generiert) und anzeigen
|
||||
currentGameText = getRoundText();
|
||||
if (targetTextDisplay) targetTextDisplay.textContent = currentGameText;
|
||||
|
||||
// Nach Ablauf des Timers wird automatisch zur Eingabe gewechselt.
|
||||
// Countdown-Timer starten
|
||||
currentTime = MEMORIZE_TIME_SECONDS;
|
||||
if (timerDisplay) timerDisplay.textContent = currentTime;
|
||||
|
||||
@ -146,21 +174,26 @@
|
||||
currentTime--;
|
||||
if (timerDisplay) timerDisplay.textContent = currentTime;
|
||||
|
||||
// Nach Ablauf des Timers automatisch in die Eingabephase wechseln
|
||||
if (currentTime <= 0) {
|
||||
endMemorizePhase();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Beendet die Einprägephase, stoppt den Timer und öffnet das Eingabefeld.
|
||||
*/
|
||||
function endMemorizePhase() {
|
||||
clearInterval(timerInterval);
|
||||
|
||||
if (!phaseMemorize || !phaseInput) return;
|
||||
|
||||
// Text verschwindet, Eingabefeld erscheint: ab hier zaehlt nur noch das Gedaechtnis.
|
||||
// Text ausblenden (wichtig gegen Abschreiben!) und Eingabefeld zeigen
|
||||
phaseMemorize.classList.add("d-none");
|
||||
phaseInput.classList.remove("d-none");
|
||||
|
||||
// Status-Badge anpassen
|
||||
if (gameStatus) {
|
||||
gameStatus.textContent = "Eingabe";
|
||||
gameStatus.className = "badge fs-6 px-3 py-2";
|
||||
@ -168,15 +201,22 @@
|
||||
gameStatus.style.color = "#fff";
|
||||
}
|
||||
|
||||
// Fokus ins Textfeld setzen, damit der Spieler sofort tippen kann
|
||||
if (userTextInput) {
|
||||
userTextInput.value = "";
|
||||
userTextInput.focus();
|
||||
}
|
||||
|
||||
// Zeitmessung für die Eingabedauer starten
|
||||
inputStartMs = Date.now();
|
||||
}
|
||||
|
||||
// Entfernt alles, was beim Vergleichen nicht zaehlen soll.
|
||||
/**
|
||||
* Normalisiert ein einzelnes Wort für den fehlertoleranten Vergleich.
|
||||
* Konvertiert zu Kleinbuchstaben, löst Umlaute/ß auf und entfernt Satzzeichen.
|
||||
* @param {string} word - Das zu bereinigende Wort.
|
||||
* @returns {string} Das normalisierte Wort.
|
||||
*/
|
||||
function normalizeWord(word) {
|
||||
return word
|
||||
.toLowerCase()
|
||||
@ -184,18 +224,31 @@
|
||||
.replace(/ö/g, "oe")
|
||||
.replace(/ü/g, "ue")
|
||||
.replace(/ß/g, "ss")
|
||||
// Entfernt Satz- und Sonderzeichen
|
||||
.replace(/[.,\/#!$%\^&\*;:{}=\-_`~()]/g, "");
|
||||
}
|
||||
|
||||
// Behält die sichtbaren Woerter separat, damit Satzzeichen in der Ergebnisanzeige erhalten bleiben.
|
||||
/**
|
||||
* Zerlegt einen Text anhand von Leerzeichen in ein Array von Wörtern.
|
||||
* Leere Elemente (z.B. durch mehrfache Leerzeichen) werden ausgefiltert.
|
||||
* @param {string} text - Der Eingabetext.
|
||||
* @returns {Array<string>} Liste der Wörter.
|
||||
*/
|
||||
function getWords(text) {
|
||||
return text.split(/\s+/).filter((word) => word.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet die Punktzahl auf Basis der korrekt geschriebenen Wörter.
|
||||
* Es wird die exakte Position der Wörter verglichen (Wort an Index i).
|
||||
* @param {string} original - Der vorgegebene Originaltext.
|
||||
* @param {string} input - Die Eingabe des Spielers.
|
||||
* @returns {number} Anzahl der übereinstimmenden Wörter.
|
||||
*/
|
||||
function calculateScore(original, input) {
|
||||
if (!original || !input) return 0;
|
||||
|
||||
// Score-Regel: gleiche Woerter an gleicher Position, Satzzeichen und Grossschreibung ignoriert.
|
||||
// Beide Texte in normalisierte Wort-Listen zerlegen
|
||||
const cleanOriginal = getWords(original)
|
||||
.map(normalizeWord)
|
||||
.filter((word) => word.length > 0);
|
||||
@ -204,6 +257,7 @@
|
||||
.filter((word) => word.length > 0);
|
||||
|
||||
let correctWords = 0;
|
||||
// Nur bis zum kürzeren der beiden Texte vergleichen, um Out-of-Bounds zu vermeiden
|
||||
const limit = Math.min(cleanOriginal.length, cleanInput.length);
|
||||
|
||||
for (let i = 0; i < limit; i++) {
|
||||
@ -215,7 +269,12 @@
|
||||
return correctWords;
|
||||
}
|
||||
|
||||
// Baut ein einzelnes farbiges Wort-Label fuer den Ergebnisvergleich.
|
||||
/**
|
||||
* Erstellt ein farbiges Badge-Element für die Ergebnisanzeige.
|
||||
* @param {string} word - Das darzustellende Wort.
|
||||
* @param {boolean} isCorrect - Bestimmt die Farbe (Grün für korrekt, Rot für falsch).
|
||||
* @returns {HTMLElement} Das erstellte span-Element.
|
||||
*/
|
||||
function createWordBadge(word, isCorrect) {
|
||||
const badge = document.createElement("span");
|
||||
badge.className =
|
||||
@ -224,7 +283,11 @@
|
||||
return badge;
|
||||
}
|
||||
|
||||
// Markiert Original und Eingabe nach derselben Positionslogik wie calculateScore().
|
||||
/**
|
||||
* Visualisiert den Wortvergleich zwischen Original und Eingabe im Ergebnisbereich.
|
||||
* @param {string} original - Der Originaltext.
|
||||
* @param {string} input - Der eingegebene Text.
|
||||
*/
|
||||
function renderWordComparison(original, input) {
|
||||
if (!resultOriginal || !resultInput) return;
|
||||
|
||||
@ -234,14 +297,14 @@
|
||||
resultOriginal.innerHTML = "";
|
||||
resultInput.innerHTML = "";
|
||||
|
||||
// Original: rot, wenn das eingegebene Wort an dieser Position fehlt oder falsch ist.
|
||||
// Originaltext-Badges: Rot markieren, wenn an dieser Position das Wort fehlt oder falsch eingegeben wurde
|
||||
originalWords.forEach((word, index) => {
|
||||
const isCorrect =
|
||||
normalizeWord(word) === normalizeWord(inputWords[index] || "");
|
||||
resultOriginal.appendChild(createWordBadge(word, isCorrect));
|
||||
});
|
||||
|
||||
// Eingabe: rot, wenn das Wort nicht zum Originalwort an derselben Position passt.
|
||||
// Eingegebene Badges: Rot markieren, wenn das Wort an dieser Stelle nicht zum Original passt
|
||||
inputWords.forEach((word, index) => {
|
||||
const isCorrect =
|
||||
normalizeWord(word) === normalizeWord(originalWords[index] || "");
|
||||
@ -249,6 +312,9 @@
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt die aktuellen Authentifizierungsdaten.
|
||||
*/
|
||||
function getAuth() {
|
||||
if (!window.AppAuth || typeof window.AppAuth.getAuth !== "function") {
|
||||
return null;
|
||||
@ -257,6 +323,9 @@
|
||||
return window.AppAuth.getAuth();
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt eine Instanz des ScoreService.
|
||||
*/
|
||||
function getScoreService() {
|
||||
if (!window.config || !window.ScoreService) {
|
||||
return null;
|
||||
@ -265,6 +334,9 @@
|
||||
return new window.ScoreService(window.config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt eine Instanz des ChallengeService.
|
||||
*/
|
||||
function getChallengeService() {
|
||||
if (!window.config || !window.ChallengeService) {
|
||||
return null;
|
||||
@ -273,6 +345,9 @@
|
||||
return new window.ChallengeService(window.config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt eine Instanz des MessageService.
|
||||
*/
|
||||
function getMessageService() {
|
||||
if (!window.config || !window.MessageService) {
|
||||
return null;
|
||||
@ -281,9 +356,13 @@
|
||||
return new window.MessageService(window.config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest eine im SessionStorage hinterlegte aktive Challenge aus.
|
||||
* @returns {Object|null} Challenge-Datenobjekt oder null.
|
||||
*/
|
||||
function readActiveChallenge() {
|
||||
// Challenge-Runden werden aus der Nachrichten-Seite gestartet.
|
||||
// Der dafuer gespeicherte Kontext entscheidet, welcher API-/Nachrichten-Flow nach dem Spiel laeuft.
|
||||
// Challenge-Runden werden auf der Nachrichten-Seite gestartet.
|
||||
// Der gespeicherte Kontext entscheidet über den API-Fluss nach Spielende.
|
||||
const raw = sessionStorage.getItem(ACTIVE_CHALLENGE_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return null;
|
||||
@ -301,44 +380,66 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, ob aktuell eine aktive Herausforderung vorliegt.
|
||||
*/
|
||||
function hasActiveChallenge() {
|
||||
return Boolean(activeChallenge && activeChallenge.id !== undefined && activeChallenge.id !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, ob es sich um die zweite Runde einer Challenge handelt.
|
||||
* In der zweiten Runde spielt der Herausforderer gegen den Score des Gegners.
|
||||
*/
|
||||
function isChallengeSecondRound() {
|
||||
// In der zweiten Runde spielt der Herausforderer gegen den bereits bekannten Gegner-Score.
|
||||
return hasActiveChallenge()
|
||||
&& activeChallenge.role === "challenger"
|
||||
&& activeChallenge.opponentScore !== null
|
||||
&& activeChallenge.opponentScore !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, ob es sich um die erste Runde einer Challenge handelt.
|
||||
* In der ersten Runde spielt der herausgeforderte User und sendet danach sein Ergebnis.
|
||||
*/
|
||||
function isChallengeFirstRound() {
|
||||
// In der ersten Runde spielt der herausgeforderte User und sendet sein Resultat an den Herausforderer.
|
||||
return hasActiveChallenge() && activeChallenge.role === "opponent";
|
||||
}
|
||||
|
||||
/**
|
||||
* Schreibt den aktuellen Challenge-Kontext in den SessionStorage.
|
||||
*/
|
||||
function writeActiveChallenge(challenge) {
|
||||
sessionStorage.setItem(ACTIVE_CHALLENGE_STORAGE_KEY, JSON.stringify(challenge));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ermittelt den Text für die Spielrunde.
|
||||
* Bei Challenges müssen beide Spieler denselben Text erhalten, welcher aus dem Kontext ausgelesen wird.
|
||||
* @returns {string} Der Rundentext.
|
||||
*/
|
||||
function getRoundText() {
|
||||
// Bei Challenges spielen beide User mit demselben Text aus dem Challenge-Kontext.
|
||||
// Falls alte Challenges diesen Text noch nicht haben, gibt es weiterhin einen lokalen Fallback.
|
||||
if (!activeChallenge) {
|
||||
return generateGameText();
|
||||
}
|
||||
|
||||
// Wenn ein fester Challenge-Text existiert, wird dieser verwendet
|
||||
if (typeof activeChallenge.challengeText === "string" && activeChallenge.challengeText.trim()) {
|
||||
return activeChallenge.challengeText;
|
||||
}
|
||||
|
||||
// Falls bei einer alten Challenge kein Text hinterlegt war, generieren wir einen und speichern ihn im Kontext
|
||||
const generatedText = generateGameText();
|
||||
activeChallenge.challengeText = generatedText;
|
||||
writeActiveChallenge(activeChallenge);
|
||||
return generatedText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeigt Feedback zum Speicherstatus des Scores (z. B. "Score erfolgreich gespeichert").
|
||||
* @param {string} message - Der Feedbacktext.
|
||||
* @param {string} type - Der Bootstrap-Alert-Typ (danger, warning, success, info).
|
||||
*/
|
||||
function showScoreSaveFeedback(message, type) {
|
||||
if (!scoreSaveFeedback) return;
|
||||
|
||||
@ -347,9 +448,11 @@
|
||||
scoreSaveFeedback.classList.remove("d-none");
|
||||
}
|
||||
|
||||
/**
|
||||
* Speichert das Ergebnis eines normalen Spiels im Backend.
|
||||
* @param {Object} scoreData - Das Score-Datenobjekt.
|
||||
*/
|
||||
async function saveScore(scoreData) {
|
||||
// Normale Spielrunden werden direkt im Score-/Leaderboard-Backend gespeichert.
|
||||
// Challenge-Runden verwenden je nach Rolle einen separaten Ablauf weiter unten.
|
||||
const auth = getAuth();
|
||||
if (!auth || !auth.username || !auth.password) {
|
||||
showScoreSaveFeedback(
|
||||
@ -359,7 +462,6 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Auth-Daten kommen aus login.js; der ScoreService setzt daraus die Backend-Header.
|
||||
const scoreService = getScoreService();
|
||||
if (!scoreService) {
|
||||
showScoreSaveFeedback(
|
||||
@ -406,9 +508,12 @@
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeigt das Endergebnis eines Duells direkt im Browser an (Sieg, Niederlage, Unentschieden).
|
||||
* Wird nach der finalen Runde einer Challenge ausgeführt.
|
||||
* @param {number} score - Die eigene Punktzahl.
|
||||
*/
|
||||
function renderChallengeResult(score) {
|
||||
// Zeigt das lokale Ergebnis der finalen Challenge-Runde sofort an,
|
||||
// unabhaengig davon, ob der Backend-Abschluss erfolgreich ist.
|
||||
if (!scoreSaveFeedback || !isChallengeSecondRound()) {
|
||||
return;
|
||||
}
|
||||
@ -421,12 +526,15 @@
|
||||
const auth = getAuth();
|
||||
const ownName = auth?.username || "Du";
|
||||
const opponentName = activeChallenge.opponent;
|
||||
|
||||
// Sieger ermitteln
|
||||
const result = score > opponentScore
|
||||
? "winner"
|
||||
: score < opponentScore
|
||||
? "loser"
|
||||
: "draw";
|
||||
|
||||
// Texte und Bilder je nach Ausgang des Spiels konfigurieren
|
||||
const headline = result === "winner"
|
||||
? "Du gewinnst die Challenge"
|
||||
: result === "loser"
|
||||
@ -443,6 +551,7 @@
|
||||
? "image/verloren.png"
|
||||
: "image/unentschieden.png";
|
||||
|
||||
// Ergebnisgrafik zusammenbauen
|
||||
const graphic = document.createElement("div");
|
||||
graphic.className = "play-challenge-result play-challenge-result-" + result;
|
||||
|
||||
@ -479,12 +588,18 @@
|
||||
|
||||
scores.append(ownScore, otherScore);
|
||||
graphic.append(outcomeHeader, title, scores);
|
||||
|
||||
// Grafik unter dem Speicher-Feedback einfügen
|
||||
scoreSaveFeedback.insertAdjacentElement("afterend", graphic);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sendet den Abschluss einer Challenge an das dafür vorgesehene Challenge-Backend.
|
||||
* Falls dieser Endpoint nicht existiert (altes Backend), liefert die Funktion false.
|
||||
* @param {Object} scoreData - Das Score-Datenobjekt.
|
||||
* @returns {Promise<boolean>} True, falls das Speichern im Challenge-Backend erfolgreich war.
|
||||
*/
|
||||
async function completeChallenge(scoreData) {
|
||||
// Primaerer Abschluss ueber das Challenge-Backend.
|
||||
// Wenn dieser Endpoint ablehnt, faellt submitScore auf den Nachrichten-Fallback zurueck.
|
||||
const auth = getAuth();
|
||||
if (!auth || !hasActiveChallenge()) {
|
||||
return;
|
||||
@ -510,10 +625,11 @@
|
||||
);
|
||||
|
||||
if (!result.ok) {
|
||||
console.warn("Challenge-Abschluss nicht moeglich, nutze Nachrichten-Fallback. Status:", result.status);
|
||||
console.warn("Challenge-Abschluss über API nicht möglich, nutze Nachrichten-Fallback. Status:", result.status);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Challenge erfolgreich abgeschlossen
|
||||
sessionStorage.removeItem(ACTIVE_CHALLENGE_STORAGE_KEY);
|
||||
activeChallenge = null;
|
||||
showScoreSaveFeedback(
|
||||
@ -528,9 +644,15 @@
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ablauf für die ERSTE Runde einer Challenge (der Geforderte hat gespielt).
|
||||
* Der Score wird normal gespeichert und der Herausforderer wird benachrichtigt.
|
||||
* @param {Object} scoreData - Die Score-Daten des Geforderten.
|
||||
*/
|
||||
async function finishFirstChallengeRound(scoreData) {
|
||||
// Der Gegner speichert zuerst seinen Score und informiert danach den Herausforderer.
|
||||
// Score im normalen Ranking ablegen
|
||||
await saveScore(scoreData);
|
||||
// Dem Herausforderer eine Nachricht schicken mit den erreichten Punkten
|
||||
await notifyChallenger(scoreData);
|
||||
|
||||
sessionStorage.removeItem(ACTIVE_CHALLENGE_STORAGE_KEY);
|
||||
@ -542,14 +664,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Benachrichtigt den Herausforderer per Inbox-Nachricht über das eigene Ergebnis.
|
||||
* Da der Challenge-Ablauf über Nachrichten läuft, betten wir die Spieldaten
|
||||
* als JSON-String in die Textnachricht ein.
|
||||
* @param {Object} scoreData - Die erzielten Spieldaten.
|
||||
*/
|
||||
async function notifyChallenger(scoreData) {
|
||||
// Nutzt den bestehenden Nachrichten-Endpunkt, um dem Herausforderer den Gegner-Score zu senden.
|
||||
const auth = getAuth();
|
||||
const messageService = getMessageService();
|
||||
if (!auth || !messageService || !activeChallenge.challenger) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Zu übermittelnde Spieldaten strukturieren
|
||||
const challengeData = {
|
||||
id: activeChallenge.id,
|
||||
challenger: activeChallenge.challenger,
|
||||
@ -558,6 +686,7 @@
|
||||
challengeText: scoreData.text,
|
||||
};
|
||||
|
||||
// Betten das JSON verschlüsselt in das Nachrichtenfeld ein
|
||||
const messageText =
|
||||
CHALLENGE_DATA_PREFIX +
|
||||
JSON.stringify(challengeData) +
|
||||
@ -584,6 +713,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ermittelt den Namen des Siegers auf Basis der Punktzahlen.
|
||||
* @param {number} challengerScore - Punkte des Herausforderers.
|
||||
* @param {number} opponentScore - Punkte des Gegners.
|
||||
* @returns {string} Der Gewinnername oder "draw" bei Gleichstand.
|
||||
*/
|
||||
function getChallengeWinner(challengerScore, opponentScore) {
|
||||
if (challengerScore > opponentScore) {
|
||||
return activeChallenge.challenger || getAuth()?.username || "Herausforderer";
|
||||
@ -596,6 +731,13 @@
|
||||
return "draw";
|
||||
}
|
||||
|
||||
/**
|
||||
* Hilfsfunktion zum Senden der Abschlussnachricht (Challenge-Resultat) an den Gegner.
|
||||
* @param {string} recipient - Der Empfänger der Nachricht.
|
||||
* @param {Object} resultData - Das Resultatsobjekt.
|
||||
* @param {string} text - Der Nachrichtentext.
|
||||
* @returns {Promise<boolean>} True bei Erfolg.
|
||||
*/
|
||||
async function sendChallengeResultMessage(recipient, resultData, text) {
|
||||
const auth = getAuth();
|
||||
const messageService = getMessageService();
|
||||
@ -621,18 +763,25 @@
|
||||
return result.ok;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback-Ablauf für die ZWEITE Runde (Herausforderer beendet das Spiel),
|
||||
* falls die direkte Complete-API des Backends fehlschlägt.
|
||||
* Das Ergebnis wird berechnet und per Nachricht an den Gegner gesendet.
|
||||
* @param {Object} scoreData - Die Spieldaten des Herausforderers.
|
||||
*/
|
||||
async function finishFinalChallengeWithMessages(scoreData) {
|
||||
// Fallback fuer den finalen Challenge-Abschluss:
|
||||
// Score speichern, Gewinner lokal berechnen und Ergebnis per Nachricht an den Gegner senden.
|
||||
const auth = getAuth();
|
||||
if (!auth || !activeChallenge) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Eigenen Score speichern
|
||||
await saveScore(scoreData);
|
||||
|
||||
const opponentScore = Number(activeChallenge.opponentScore);
|
||||
const winner = getChallengeWinner(scoreData.score, opponentScore);
|
||||
|
||||
// Resultat-JSON zusammenbauen
|
||||
const resultData = {
|
||||
id: activeChallenge.id,
|
||||
challenger: activeChallenge.challenger || auth.username,
|
||||
@ -659,6 +808,7 @@
|
||||
winnerText +
|
||||
".";
|
||||
|
||||
// Das Ergebnis per Message an den Gegner übermitteln
|
||||
const opponentMessageSent = await sendChallengeResultMessage(
|
||||
activeChallenge.opponent,
|
||||
resultData,
|
||||
@ -686,9 +836,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Beendet die Spielrunde, wertet die Eingabe aus, rendert die Fehler
|
||||
* und leitet die Speicherung je nach Spielmodus ein.
|
||||
*/
|
||||
async function submitScore() {
|
||||
// Gemeinsamer Abschluss fuer normale Spiele und Challenge-Runden.
|
||||
// Der aktive Challenge-Kontext entscheidet, welcher Speicher-/Nachrichtenfluss verwendet wird.
|
||||
if (!userTextInput) return;
|
||||
|
||||
const userInput = userTextInput.value.trim();
|
||||
@ -697,14 +849,16 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Button sperren gegen Doppelklicks
|
||||
if (btnSubmitScore) {
|
||||
btnSubmitScore.disabled = true;
|
||||
btnSubmitScore.textContent = "Wird ausgewertet...";
|
||||
}
|
||||
|
||||
// Punktzahl berechnen
|
||||
const score = calculateScore(currentGameText, userInput);
|
||||
|
||||
// Ergebnis sofort anzeigen; das Speichern im Backend passiert danach asynchron.
|
||||
// Eingabebereich ausblenden und Ergebnisbereich einblenden
|
||||
if (phaseInput) phaseInput.classList.add("d-none");
|
||||
if (phaseResult) phaseResult.classList.remove("d-none");
|
||||
|
||||
@ -715,15 +869,16 @@
|
||||
gameStatus.style.color = "#fff";
|
||||
}
|
||||
|
||||
// Ergebnis anzeigen
|
||||
if (resultScore) resultScore.textContent = score;
|
||||
renderWordComparison(currentGameText, userInput);
|
||||
renderChallengeResult(score);
|
||||
|
||||
// Dauer der Eingabe ermitteln (mindestens 1 Sekunde)
|
||||
const inputDurationSeconds = inputStartMs
|
||||
? Math.max(1, Math.round((Date.now() - inputStartMs) / 1000))
|
||||
: MEMORIZE_TIME_SECONDS;
|
||||
|
||||
// Genau dieser Rundentext wird gespeichert, damit Leaderboard/Score-Details nachvollziehbar bleiben.
|
||||
const scoreData = {
|
||||
score: score,
|
||||
time: inputDurationSeconds,
|
||||
@ -733,21 +888,26 @@
|
||||
|
||||
try {
|
||||
if (isChallengeFirstRound()) {
|
||||
// Erste Runde des Duells
|
||||
showScoreSaveFeedback("Resultat wird an den Herausforderer gesendet...", "info");
|
||||
await finishFirstChallengeRound(scoreData);
|
||||
} else if (hasActiveChallenge()) {
|
||||
// Finale Runde des Duells
|
||||
showScoreSaveFeedback("Challenge-Resultat wird gesendet...", "info");
|
||||
let completed = false;
|
||||
try {
|
||||
// Versuchen über die offizielle API abzuschließen
|
||||
completed = await completeChallenge(scoreData);
|
||||
} catch (error) {
|
||||
console.warn("Challenge-Abschluss fehlgeschlagen, nutze Nachrichten-Fallback.", error);
|
||||
}
|
||||
if (!completed) {
|
||||
// Falls API fehlschlägt, auf den Nachrichten-Ablauf ausweichen
|
||||
showScoreSaveFeedback("Backend-Abschluss nicht moeglich. Ergebnis wird ueber Nachrichten gesendet...", "info");
|
||||
await finishFinalChallengeWithMessages(scoreData);
|
||||
}
|
||||
} else {
|
||||
// Normales Spiel ohne Duell
|
||||
await saveScore(scoreData);
|
||||
}
|
||||
} catch (error) {
|
||||
@ -764,12 +924,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Globale Initialisierungsfunktion, aufgerufen von navigation.js
|
||||
* nach dem Laden von play.html.
|
||||
*/
|
||||
window.initPlayPage = function initPlayPage() {
|
||||
// Die Spielseite wird dynamisch geladen; daher werden DOM-Elemente und Events hier initialisiert.
|
||||
clearInterval(timerInterval);
|
||||
activeChallenge = readActiveChallenge();
|
||||
clearInterval(timerInterval); // Bestehende Timer vorsichtshalber stoppen
|
||||
activeChallenge = readActiveChallenge(); // Prüfen, ob wir aus einer Challenge gestartet wurden
|
||||
|
||||
// Die Navigation laedt play.html per fetch; deshalb werden die Elemente erst hier gesucht.
|
||||
// DOM-Referenzen holen
|
||||
phaseStart = document.getElementById("phaseStart");
|
||||
phaseMemorize = document.getElementById("phaseMemorize");
|
||||
phaseInput = document.getElementById("phaseInput");
|
||||
@ -789,6 +952,7 @@
|
||||
const btnRestart = document.getElementById("btnRestart");
|
||||
const btnLeaderboard = document.getElementById("btnLeaderboard");
|
||||
|
||||
// Falls eine Challenge aktiv ist, Hinweistext über den Gegner und dessen Score einblenden
|
||||
const challengeHint = document.getElementById("challengeHint");
|
||||
if (challengeHint && activeChallenge) {
|
||||
const opponentScore = activeChallenge.opponentScore !== null && activeChallenge.opponentScore !== undefined
|
||||
@ -801,6 +965,7 @@
|
||||
challengeHint.classList.remove("d-none");
|
||||
}
|
||||
|
||||
// Event-Listener binden
|
||||
if (btnStart) btnStart.addEventListener("click", startGame);
|
||||
if (btnSubmitScore) btnSubmitScore.addEventListener("click", submitScore);
|
||||
if (btnRestart)
|
||||
@ -817,7 +982,9 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Anti-Cheating Maßnahmen im Eingabebereich einrichten
|
||||
if (userTextInput) {
|
||||
// Kopieren, Einfügen, Ausschneiden und Tastenkombinationen sperren
|
||||
userTextInput.addEventListener("keydown", (e) => {
|
||||
const key = e.key.toLowerCase();
|
||||
if (
|
||||
@ -832,6 +999,8 @@
|
||||
userTextInput.addEventListener("paste", (e) => e.preventDefault());
|
||||
userTextInput.addEventListener("copy", (e) => e.preventDefault());
|
||||
userTextInput.addEventListener("cut", (e) => e.preventDefault());
|
||||
|
||||
// Sobald getippt wird, startet die Zeitmessung (falls nicht schon geschehen)
|
||||
userTextInput.addEventListener("input", () => {
|
||||
if (!inputStartMs) {
|
||||
inputStartMs = Date.now();
|
||||
@ -839,6 +1008,7 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Textauswahl auf dem zu merkenden Text verhindern, um Kopieren via Maus zu blockieren
|
||||
if (targetTextDisplay) {
|
||||
targetTextDisplay.addEventListener("copy", (e) => e.preventDefault());
|
||||
targetTextDisplay.style.userSelect = "none";
|
||||
|
||||
59
js/scores.js
59
js/scores.js
@ -1,4 +1,15 @@
|
||||
/**
|
||||
* Eigene Ergebnisse (Scores) verwalten und anzeigen.
|
||||
* Dieses Modul lädt nach der Initialisierung die bisherigen Spielergebnisse des
|
||||
* eingeloggten Benutzers und stellt diese sortiert in einer Tabelle dar.
|
||||
*/
|
||||
(function () {
|
||||
|
||||
/**
|
||||
* Formatiert Sekunden in ein lesbares MM:SS Format (z.B. 75 Sekunden -> "1:15").
|
||||
* @param {number} seconds - Anzahl Sekunden.
|
||||
* @returns {string} Formatiertes Zeit-String oder "-" bei ungültigem Wert.
|
||||
*/
|
||||
function formatTime(seconds) {
|
||||
if (typeof seconds !== "number" || Number.isNaN(seconds)) {
|
||||
return "-";
|
||||
@ -6,13 +17,23 @@
|
||||
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = Math.floor(seconds % 60);
|
||||
// padStart stellt sicher, dass Sekunden immer zweistellig ausgegeben werden (z.B. "05" statt "5")
|
||||
return `${minutes}:${String(remainingSeconds).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalisiert einen Benutzernamen (Trimming und Kleinschreibung) für robuste Vergleiche.
|
||||
* @param {string} username - Der Benutzername.
|
||||
* @returns {string} Der bereinigte Benutzername.
|
||||
*/
|
||||
function normalizeUsername(username) {
|
||||
return String(username ?? "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt die aktuellen Authentifizierungsdaten aus dem globalen Auth-Modul.
|
||||
* @returns {Object|null} Auth-Objekt (mit username/password) oder null, falls nicht angemeldet.
|
||||
*/
|
||||
function getLoggedInAuth() {
|
||||
if (!window.AppAuth || typeof window.AppAuth.getAuth !== "function") {
|
||||
return null;
|
||||
@ -26,6 +47,10 @@
|
||||
return auth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialisiert und liefert eine Instanz des ScoreService.
|
||||
* @returns {ScoreService|null} Eine ScoreService-Instanz oder null, falls Konfiguration fehlt.
|
||||
*/
|
||||
function getScoreService() {
|
||||
if (!window.config || !window.ScoreService) {
|
||||
return null;
|
||||
@ -34,6 +59,13 @@
|
||||
return new window.ScoreService(window.config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ermittelt den anzuzeigenden Rang eines Score-Eintrags.
|
||||
* Falls das Backend keinen Platz/Rang liefert, wird die Position in der Liste (+1) als Fallback genutzt.
|
||||
* @param {Object} entry - Der Score-Eintrag.
|
||||
* @param {number} index - Der Listenindex des Eintrags.
|
||||
* @returns {number} Der anzuzeigende Rang (1-basiert).
|
||||
*/
|
||||
function getDisplayedRank(entry, index) {
|
||||
const place = Number(entry?.place);
|
||||
if (!Number.isNaN(place) && place > 0) {
|
||||
@ -43,6 +75,11 @@
|
||||
return index + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Blendet eine Feedback-Nachmeldung (Erfolg/Fehler/Info) auf der Oberfläche ein.
|
||||
* @param {string} message - Der anzuzeigende Text.
|
||||
* @param {string} type - Der Bootstrap-Alert-Typ (danger, warning, info, success).
|
||||
*/
|
||||
function setFeedback(message, type) {
|
||||
const feedback = document.getElementById("scores-feedback");
|
||||
if (!feedback) {
|
||||
@ -54,6 +91,11 @@
|
||||
feedback.classList.remove("d-none");
|
||||
}
|
||||
|
||||
/**
|
||||
* Generiert die HTML-Zeilen für die Score-Tabelle aus den geladenen Einträgen.
|
||||
* Sortiert die Einträge: 1. nach Platzierung (falls vorhanden), 2. nach Score (absteigend), 3. nach Zeit (aufsteigend).
|
||||
* @param {Array<Object>} entries - Liste von Score-Einträgen vom Backend.
|
||||
*/
|
||||
function renderScores(entries) {
|
||||
const tableBody = document.getElementById("scores-body");
|
||||
if (!tableBody) {
|
||||
@ -62,29 +104,35 @@
|
||||
|
||||
tableBody.innerHTML = "";
|
||||
|
||||
// Falls keine Einträge vorhanden sind, Hinweistext ausgeben
|
||||
if (!Array.isArray(entries) || entries.length === 0) {
|
||||
tableBody.innerHTML = '<tr><td colspan="4" class="text-center text-muted py-4">Keine eigenen Scores gefunden.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Kopie des Arrays anlegen und sortieren
|
||||
const sortedEntries = entries.slice().sort((a, b) => {
|
||||
// 1. Priorität: Offizielle Platzierung (falls vorhanden)
|
||||
const placeA = Number(a?.place);
|
||||
const placeB = Number(b?.place);
|
||||
if (!Number.isNaN(placeA) && !Number.isNaN(placeB) && placeA !== placeB) {
|
||||
return placeA - placeB;
|
||||
}
|
||||
|
||||
// 2. Priorität: Höhere Punktzahl gewinnt
|
||||
const scoreA = Number(a?.score ?? 0);
|
||||
const scoreB = Number(b?.score ?? 0);
|
||||
if (scoreB !== scoreA) {
|
||||
return scoreB - scoreA;
|
||||
}
|
||||
|
||||
// 3. Priorität: Bei Punktgleichheit gewinnt die schnellere Zeit
|
||||
const timeA = Number(a?.time ?? Number.MAX_SAFE_INTEGER);
|
||||
const timeB = Number(b?.time ?? Number.MAX_SAFE_INTEGER);
|
||||
return timeA - timeB;
|
||||
});
|
||||
|
||||
// Für jeden sortierten Eintrag eine Tabellenzeile erstellen und anhängen
|
||||
sortedEntries.forEach((entry, index) => {
|
||||
const row = document.createElement("tr");
|
||||
row.innerHTML = `
|
||||
@ -97,8 +145,12 @@
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fordert die Scores des Benutzers vom Backend an und filtert sie.
|
||||
*/
|
||||
async function loadMyScores() {
|
||||
const auth = getLoggedInAuth();
|
||||
// Prüfen, ob der Benutzer überhaupt eingeloggt ist
|
||||
if (!auth || !auth.username) {
|
||||
setFeedback("Bitte logge dich ein, um deine Scores zu sehen.", "warning");
|
||||
renderScores([]);
|
||||
@ -113,6 +165,7 @@
|
||||
}
|
||||
|
||||
try {
|
||||
// Scores für diesen Namen vom Backend abrufen
|
||||
const result = await scoreService.getScoreByName(auth.username);
|
||||
|
||||
if (!result.ok || !Array.isArray(result.body)) {
|
||||
@ -121,6 +174,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Sicherheits-Filterung: Nur Scores behalten, die exakt zum aktuell eingeloggten User passen
|
||||
const ownScores = result.body.filter((entry) => {
|
||||
if (!entry || !entry.username) {
|
||||
return false;
|
||||
@ -133,6 +187,7 @@
|
||||
setFeedback("Für deinen Account wurden noch keine Scores gefunden.", "info");
|
||||
}
|
||||
|
||||
// Scores rendern
|
||||
renderScores(ownScores);
|
||||
} catch (error) {
|
||||
console.error("Fehler beim Laden der eigenen Scores:", error);
|
||||
@ -141,6 +196,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Globale Initialisierungsfunktion, die von navigation.js aufgerufen wird,
|
||||
* sobald die scores.html-Teilseite erfolgreich geladen wurde.
|
||||
*/
|
||||
window.initScoresPage = function initScoresPage() {
|
||||
loadMyScores().catch((error) => {
|
||||
console.error("Fehler beim Initialisieren der Scores-Seite:", error);
|
||||
|
||||
@ -1,8 +1,14 @@
|
||||
<!-- Startseite der Anwendung -->
|
||||
<div class="card mb-4 text-start">
|
||||
<!-- Willkommens-Überschrift und Kurzbeschreibung des Spiels -->
|
||||
<div class="mb-4">
|
||||
<h2 class="fw-bold mb-1">Willkommen beim Lorem Ipsum Game</h2>
|
||||
<p class="text-muted mb-0 fs-6">Teste deine Fähigkeiten im Umgang mit Lorem Ipsum Texten! Je schneller und genauer du bist, desto höher ist dein Score.</p>
|
||||
</div>
|
||||
<!-- Handlungsaufforderung an den Spieler -->
|
||||
<p class="card-text fs-6">Wähle eine Option aus der Navigation, um zu starten. Viel Spaß beim Spielen!</p>
|
||||
|
||||
<!-- Projekt-Logo zentriert im unteren Bereich -->
|
||||
<img id="logo_img" src="image/Logo_loremIpsum.png" alt="Lorem Ipsum Game" class="img-fluid mt-3 d-block mx-auto">
|
||||
</div>
|
||||
|
||||
@ -1,14 +1,15 @@
|
||||
<!-- Hauptbereich der Rangliste -->
|
||||
<!-- Rangliste (Leaderboard) - Zeigt die globalen Top-10 Spieler an -->
|
||||
<div class="card mb-4 text-start">
|
||||
<!-- Header-Bereich mit Titel und Kurzbeschreibung -->
|
||||
<div class="mb-4">
|
||||
<h2 class="fw-bold mb-1">Leaderboard</h2>
|
||||
<p class="text-muted mb-0 fs-6">Hier siehst du ein Leaderboard mit den 10 besten Usern.</p>
|
||||
</div>
|
||||
|
||||
<!-- Tabellarische Darstellung der besten Eintraege -->
|
||||
<!-- Tabellarische Darstellung der besten Einträge -->
|
||||
<table class="leaderboard-table">
|
||||
<thead>
|
||||
<!-- Spalten fuer Rang, Nutzer, Zeit und Punkte -->
|
||||
<!-- Spaltenköpfe für Rang, Benutzername, benötigte Zeit und Punktzahl -->
|
||||
<tr>
|
||||
<th>Rank</th>
|
||||
<th>User</th>
|
||||
@ -16,7 +17,8 @@
|
||||
<th>Score</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<!-- Wird zur Laufzeit durch js/leaderboard.js befuellt -->
|
||||
<!-- Tabellenkörper: Wird dynamisch zur Laufzeit durch js/leaderboard.js mit Zeilen befüllt -->
|
||||
<tbody id="leaderboard-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
||||
@ -1,13 +1,15 @@
|
||||
<!-- Login Seite -->
|
||||
<!-- Account-Verwaltung (Login, Registrierung, Sitzungsanzeige) -->
|
||||
<div class="card mb-4 text-start">
|
||||
<!-- Header-Bereich mit Kurzbeschreibung -->
|
||||
<div class="mb-4">
|
||||
<h2 class="fw-bold mb-1">Account</h2>
|
||||
<p class="text-muted mb-0 fs-6">Verwalte deine Sitzung und Account-Details.</p>
|
||||
</div>
|
||||
|
||||
<!-- Alert-Box für Fehlermeldungen und Erfolgsmeldungen (wird per js/login.js gesteuert) -->
|
||||
<div id="auth-feedback" class="alert d-none" role="status" aria-live="polite"></div>
|
||||
|
||||
<!-- Logout und Account löschen -->
|
||||
<!-- Aktuelle Sitzung: Zeigt den Login-Status und bietet Buttons für Logout und Account löschen -->
|
||||
<div class="card bg-light mb-3" id="current-session-box">
|
||||
<h3 class="mb-2">Aktuelle Sitzung</h3>
|
||||
<p class="h5 mb-2" id="current-session-text">Nicht eingeloggt.</p>
|
||||
@ -17,8 +19,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Login und Account erstellen -->
|
||||
<!-- Formular-Bereich: Login und Registrierung nebeneinander (auf Desktop) -->
|
||||
<div class="row g-3" id="auth-forms-row">
|
||||
<!-- Linke Spalte: Login-Formular -->
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card h-100">
|
||||
<h3 class="mb-3">Login</h3>
|
||||
@ -36,6 +39,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rechte Spalte: Registrierungs-Formular -->
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card h-100">
|
||||
<h3 class="mb-3">Neuen Account erstellen</h3>
|
||||
@ -46,14 +50,14 @@
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success">Account anlegen</button>
|
||||
</form>
|
||||
<p class="text-muted mb-0 fs-6">Hinweis: Das Passwort wird vom Backend erstellt und bei Erfolg angezeigt.</p>
|
||||
<p class="text-muted mb-0 fs-6 mt-2">Hinweis: Das Passwort wird vom Backend erstellt und bei Erfolg angezeigt.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Modal für Account-Erstellung -->
|
||||
<!-- Bootstrap Modal: Zeigt das einmalig generierte Passwort nach erfolgreicher Account-Erstellung an -->
|
||||
<div class="modal fade" id="password-modal" tabindex="-1" aria-labelledby="password-modal-label" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
@ -62,6 +66,7 @@
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<!-- Anzeige der generierten Zugangsdaten -->
|
||||
<div class="alert alert-info mb-3">
|
||||
<strong>Username:</strong> <span id="modal-username"></span><br/>
|
||||
<strong>Passwort:</strong> <span id="modal-password" style="font-family: monospace; background-color: #f0f0f0; padding: 4px 8px; border-radius: 4px;"></span>
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
<!-- Nachrichten- und Challenge-Dashboard -->
|
||||
<div class="card messages-card mb-4 text-start">
|
||||
<!-- Header-Bereich mit Aktualisierungs-Button -->
|
||||
<div class="messages-header mb-4">
|
||||
<div>
|
||||
<h2 class="fw-bold mb-1">Nachrichten</h2>
|
||||
@ -7,43 +9,53 @@
|
||||
<button class="btn btn-sm" id="refresh-messages-button" type="button">Aktualisieren</button>
|
||||
</div>
|
||||
|
||||
<!-- Platzhalter: Wird angezeigt, wenn der Benutzer nicht angemeldet ist -->
|
||||
<div id="messages-login-placeholder" class="alert alert-warning mt-3 d-none">
|
||||
Melde dich zuerst an, bevor du eine Nachricht verschicken kannst.
|
||||
</div>
|
||||
|
||||
<!-- Hauptbereich der Inbox (Sichtbar wenn eingeloggt) -->
|
||||
<div id="messages-content" class="d-none">
|
||||
<!-- Status- und Feedbackanzeige -->
|
||||
<div id="messages-feedback" class="alert d-none mt-3 mb-0" role="alert"></div>
|
||||
|
||||
<div class="row g-4 mt-2">
|
||||
<!-- Linke Spalte: Sidebar mit der Liste aller verfügbaren Benutzer -->
|
||||
<div class="col-lg-4">
|
||||
<section class="messages-panel">
|
||||
<h3>User</h3>
|
||||
<div id="messages-user-list" class="messages-user-list"></div>
|
||||
</section>
|
||||
</div>
|
||||
<section class="messages-panel">
|
||||
<h3>User</h3>
|
||||
<!-- Wird zur Laufzeit per js/messages.js mit User-Schaltflächen befüllt -->
|
||||
<div id="messages-user-list" class="messages-user-list"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-8">
|
||||
<section class="messages-panel mb-4">
|
||||
<h3>Challenge senden</h3>
|
||||
<form id="challenge-form" class="message-form">
|
||||
<label for="challenge-recipient" class="form-label">Empfaenger</label>
|
||||
<select id="challenge-recipient" class="form-select mb-3"></select>
|
||||
<!-- Rechte Spalte: Formular zum Erstellen von Challenges und die Inbox -->
|
||||
<div class="col-lg-8">
|
||||
<!-- Sektion: Challenge senden -->
|
||||
<section class="messages-panel mb-4">
|
||||
<h3>Challenge senden</h3>
|
||||
<form id="challenge-form" class="message-form">
|
||||
<label for="challenge-recipient" class="form-label">Empfaenger</label>
|
||||
<select id="challenge-recipient" class="form-select mb-3"></select>
|
||||
|
||||
<label for="challenge-text" class="form-label">Nachricht</label>
|
||||
<textarea id="challenge-text" class="form-control mb-3" rows="3">Kannst du mich schlagen? Spiel eine Runde, dann bin ich dran.</textarea>
|
||||
<label for="challenge-text" class="form-label">Nachricht</label>
|
||||
<textarea id="challenge-text" class="form-control mb-3" rows="3">Kannst du mich schlagen? Spiel eine Runde, dann bin ich dran.</textarea>
|
||||
|
||||
<button class="btn" type="submit">Challenge senden</button>
|
||||
</form>
|
||||
</section>
|
||||
<button class="btn" type="submit">Challenge senden</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="messages-panel">
|
||||
<div class="messages-inbox-title">
|
||||
<h3>Posteingang</h3>
|
||||
<button class="btn btn-sm" id="mark-read-button" type="button">Als gelesen markieren</button>
|
||||
</div>
|
||||
<div id="message-list" class="message-list"></div>
|
||||
</section>
|
||||
</div>
|
||||
<!-- Sektion: Posteingang (Liste der empfangenen/gesendeten Nachrichten) -->
|
||||
<section class="messages-panel">
|
||||
<div class="messages-inbox-title">
|
||||
<h3>Posteingang</h3>
|
||||
<button class="btn btn-sm" id="mark-read-button" type="button">Als gelesen markieren</button>
|
||||
</div>
|
||||
<!-- Wird dynamisch durch js/messages.js gerendert (normale Messages & Challenge-Karten) -->
|
||||
<div id="message-list" class="message-list"></div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -1,19 +1,21 @@
|
||||
<!-- Spielseite: Die vier Phasen werden per play.js ein- und ausgeblendet. -->
|
||||
<!-- Spielseite: Die vier Phasen werden dynamisch per play.js ein- und ausgeblendet. -->
|
||||
<div class="game-container text-start">
|
||||
|
||||
<!-- Status-Badge zeigt die aktuelle Spielphase: Bereit, Lernphase, Eingabe, Abgeschlossen. -->
|
||||
<!-- Status-Header: Zeigt Titel, Challenge-Hinweise und das Status-Badge der aktuellen Spielphase (Bereit, Lernphase, Eingabe, Abgeschlossen) -->
|
||||
<div class="d-flex justify-content-between align-items-md-center flex-column flex-md-row mb-4">
|
||||
<div class="mb-3 mb-md-0">
|
||||
<h2 class="fw-bold mb-1">Lorem Ipsum - Challenge your brain</h2>
|
||||
<!-- Challenge-Hinweis: Zeigt an, gegen wen und um welchen Score gespielt wird (wird per JS eingeblendet) -->
|
||||
<div id="challengeHint" class="challenge-hint d-none"></div>
|
||||
<p class="text-muted mb-0 fs-6">Merken Sie sich den Text so gut wie möglich.</p>
|
||||
</div>
|
||||
<!-- Status-Badge der aktuellen Spielphase -->
|
||||
<div id="gameStatus" class="badge bg-secondary fs-6 px-3 py-2" style="width: fit-content;">
|
||||
Bereit
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Phase 1: Startbildschirm vor der Textanzeige. -->
|
||||
<!-- Phase 1: Startbildschirm vor der Textanzeige -->
|
||||
<div id="phaseStart" class="card">
|
||||
<div class="card-body text-center py-5">
|
||||
<h3 class="mb-3">Sind Sie bereit?</h3>
|
||||
@ -27,14 +29,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Phase 2: Merken. Der zufaellige Rundentext wird in #targetTextDisplay eingesetzt. -->
|
||||
<!-- Phase 2: Lern-/Einprägephase. Der Runden-Text wird per JS in #targetTextDisplay eingefügt. -->
|
||||
<div id="phaseMemorize" class="d-none">
|
||||
<div class="card border-warning" style="border-left: 5px solid #ffd166; background-color: #fffbf0;">
|
||||
<div class="card-body text-center py-4">
|
||||
<h4 class="text-warning-emphasis mb-3" style="color: #b58900;">Lernphase läuft...</h4>
|
||||
<!-- Rundentext-Anzeige -->
|
||||
<p id="targetTextDisplay" class="lead fw-bold mb-4 fst-italic" style="color: #1b1b2f; font-size: 1.4rem; line-height: 1.6;">
|
||||
<!-- Text wird per JS eingefügt -->
|
||||
</p>
|
||||
<!-- Countdown-Anzeige -->
|
||||
<div class="mt-3">
|
||||
<span class="badge bg-danger fs-6 px-3 py-2 blink-animation">
|
||||
Verbleibende Zeit: <span id="timerDisplay">15</span>s
|
||||
@ -44,11 +48,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Phase 3: Eingabe aus dem Gedaechtnis. Der Originaltext ist hier bewusst ausgeblendet. -->
|
||||
<!-- Phase 3: Eingabephase. Der Originaltext wird ausgeblendet und das Textfeld wird fokussiert. -->
|
||||
<div id="phaseInput" class="d-none">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<label for="userTextInput" class="form-label fw-bold mb-2">Geben Sie den Text aus dem Gedächtnis ein:</label>
|
||||
<!-- Eingabefeld für den gemerkten Text (Cheating-Sperren in play.js verhindern Copy/Paste) -->
|
||||
<textarea id="userTextInput" class="form-control mb-3" rows="8" placeholder="Tippen Sie hier den gemerkten Text ein..."></textarea>
|
||||
<div class="d-grid">
|
||||
<button id="btnSubmitScore" class="btn btn-success btn-lg" style="background-color: #28a745; border: none;">
|
||||
@ -59,23 +64,28 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Phase 4: Auswertung inklusive Backend-Feedback und Vergleich Original/Eingabe. -->
|
||||
<!-- Phase 4: Auswertungs- und Ergebnisphase (inklusive API-Feedback und farbigem Wortvergleich) -->
|
||||
<div id="phaseResult" class="d-none">
|
||||
<div class="card text-center" style="border-top: 5px solid #28a745;">
|
||||
<div class="card-body py-5">
|
||||
<h3 class="text-success mb-2">Ergebnis</h3>
|
||||
<!-- Erzielte Punktzahl -->
|
||||
<div class="display-1 fw-bold my-4" id="resultScore" style="color: #4a6fa5;">0</div>
|
||||
<p class="text-muted mb-5">Punkte (korrekte Wörter an der richtigen Position)</p>
|
||||
<!-- Wird von saveScore() befuellt: gespeichert, nur lokal berechnet oder Fehler. -->
|
||||
|
||||
<!-- Feedback-Box für API-Antworten (wird per JS befüllt) -->
|
||||
<div id="scoreSaveFeedback" class="alert d-none mb-4" role="alert"></div>
|
||||
|
||||
<!-- Wortvergleich: Gegenüberstellung des Originaltexts und der Eingabe -->
|
||||
<div class="row g-4 text-start mb-5">
|
||||
<!-- Originaltext mit rot/grünen Wort-Badges -->
|
||||
<div class="col-md-6">
|
||||
<div class="p-3 h-100" style="background: #f8f9fa; border-radius: 8px;">
|
||||
<small class="text-muted d-block mb-2 text-uppercase fw-bold" style="font-size: 0.75rem;">Originaltext</small>
|
||||
<p class="mb-0 fst-italic" id="resultOriginal" style="font-size: 0.95rem; line-height: 1.5;"></p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Benutzereingabe mit rot/grünen Wort-Badges -->
|
||||
<div class="col-md-6">
|
||||
<div class="p-3 h-100" style="background: #f8f9fa; border-radius: 8px;">
|
||||
<small class="text-muted d-block mb-2 text-uppercase fw-bold" style="font-size: 0.75rem;">Ihre Eingabe</small>
|
||||
@ -84,6 +94,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation nach dem Spiel -->
|
||||
<div class="d-flex justify-content-center gap-3">
|
||||
<button id="btnRestart" class="btn px-4">
|
||||
Nochmal spielen
|
||||
@ -98,3 +109,5 @@
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
@ -1,11 +1,15 @@
|
||||
<!-- Eigene Scores-Ansicht des angemeldeten Benutzers -->
|
||||
<div class="card mb-4 text-start">
|
||||
<!-- Header-Bereich mit Titel und Kurzbeschreibung -->
|
||||
<div class="mb-4">
|
||||
<h2 class="fw-bold mb-1">Meine Scores</h2>
|
||||
<p class="text-muted mb-0 fs-6">Hier siehst du alle gespeicherten Scores deines Accounts.</p>
|
||||
</div>
|
||||
|
||||
<!-- Alert-Box für Rückmeldungen (wird bei Fehlern/Info-Meldungen per js/scores.js eingeblendet) -->
|
||||
<div id="scores-feedback" class="alert d-none mb-4" role="alert"></div>
|
||||
|
||||
<!-- Tabelle zur Auflistung der eigenen Scores -->
|
||||
<div class="table-responsive">
|
||||
<table class="table leaderboard-table mb-0">
|
||||
<thead>
|
||||
@ -16,6 +20,7 @@
|
||||
<th scope="col">Score</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<!-- Tabellenkörper: Wird per js/scores.js mit den eigenen Ergebnissen des Users befüllt -->
|
||||
<tbody id="scores-body">
|
||||
<tr>
|
||||
<td colspan="4" class="text-center text-muted py-4">Lade Scores ...</td>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user