1018 lines
33 KiB
JavaScript
1018 lines
33 KiB
JavaScript
/**
|
|
* 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 & 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 für den zufälligen Rundentext. Alles bleibt lokal, damit das Spiel ohne Backend starten kann.
|
|
const TEXT_PARTS = {
|
|
subjects: [
|
|
"Der flinke Entwickler",
|
|
"Die neugierige Studentin",
|
|
"Ein mueder Professor",
|
|
"Das kleine Frontend",
|
|
"Der mutige Browser",
|
|
"Eine schlaue Funktion",
|
|
"Der vergessliche Server",
|
|
"Die kreative Gruppe",
|
|
],
|
|
actions: [
|
|
"sortiert leise",
|
|
"debuggt geduldig",
|
|
"vergleicht heimlich",
|
|
"speichert vorsichtig",
|
|
"rendert ploetzlich",
|
|
"zaehlt konzentriert",
|
|
"testet neugierig",
|
|
"kompiliert langsam",
|
|
],
|
|
objects: [
|
|
"sieben blaue Buttons",
|
|
"drei lange Variablen",
|
|
"neun goldene Woerter",
|
|
"vier kaputte Formulare",
|
|
"acht schnelle Requests",
|
|
"zwei leuchtende Karten",
|
|
"fuenf stille Fehlermeldungen",
|
|
"sechs winzige Icons",
|
|
],
|
|
places: [
|
|
"im hellen Dashboard",
|
|
"unter dem dunklen Navbar",
|
|
"neben dem alten Footer",
|
|
"zwischen Login und Leaderboard",
|
|
"vor dem ersten Kaffee",
|
|
"waehrend der Lernphase",
|
|
"hinter dem lokalen Server",
|
|
"mitten im Semesterprojekt",
|
|
],
|
|
endings: [
|
|
"Danach lacht der Code, weil alles endlich funktioniert.",
|
|
"Am Ende merkt sich niemand die Semikolons, aber alle die Punkte.",
|
|
"Kurz darauf blinkt die Konsole und behauptet, sie sei unschuldig.",
|
|
"Spaeter landet der Score im Ranking und wartet auf Applaus.",
|
|
"Dabei bleibt die Seite ruhig, obwohl der Timer dramatisch tickt.",
|
|
"Zum Schluss gewinnt, wer die Woerter sauber in Reihenfolge bringt.",
|
|
],
|
|
};
|
|
|
|
// --- 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 = ""; // Verhindert direkt aufeinanderfolgende gleiche Texte
|
|
|
|
// 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)
|
|
|
|
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 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 = "";
|
|
|
|
do {
|
|
const firstSentence =
|
|
[
|
|
getRandomItem(TEXT_PARTS.subjects),
|
|
getRandomItem(TEXT_PARTS.actions),
|
|
getRandomItem(TEXT_PARTS.objects),
|
|
getRandomItem(TEXT_PARTS.places),
|
|
].join(" ") + ".";
|
|
|
|
const secondSentence =
|
|
[
|
|
getRandomItem(TEXT_PARTS.subjects),
|
|
getRandomItem(TEXT_PARTS.actions),
|
|
getRandomItem(TEXT_PARTS.objects),
|
|
getRandomItem(TEXT_PARTS.places),
|
|
].join(" ") + ".";
|
|
|
|
generatedText =
|
|
firstSentence +
|
|
" " +
|
|
secondSentence +
|
|
" " +
|
|
getRandomItem(TEXT_PARTS.endings);
|
|
} while (generatedText === lastGeneratedText);
|
|
|
|
lastGeneratedText = generatedText;
|
|
return generatedText;
|
|
}
|
|
|
|
/**
|
|
* Startet eine neue Spielrunde. Blendet die Startphase aus und startet den Countdown.
|
|
*/
|
|
function startGame() {
|
|
if (!phaseStart || !phaseMemorize) return;
|
|
|
|
inputStartMs = null; // Zeitstempel zurücksetzen
|
|
|
|
// 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";
|
|
gameStatus.style.backgroundColor = "#ffd166";
|
|
gameStatus.style.color = "#1b1b2f";
|
|
}
|
|
|
|
// Text ermitteln (aus Challenge oder neu generiert) und anzeigen
|
|
currentGameText = getRoundText();
|
|
if (targetTextDisplay) targetTextDisplay.textContent = currentGameText;
|
|
|
|
// Countdown-Timer starten
|
|
currentTime = MEMORIZE_TIME_SECONDS;
|
|
if (timerDisplay) timerDisplay.textContent = currentTime;
|
|
|
|
timerInterval = setInterval(() => {
|
|
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 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";
|
|
gameStatus.style.backgroundColor = "#4a6fa5";
|
|
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();
|
|
}
|
|
|
|
/**
|
|
* 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()
|
|
.replace(/ä/g, "ae")
|
|
.replace(/ö/g, "oe")
|
|
.replace(/ü/g, "ue")
|
|
.replace(/ß/g, "ss")
|
|
// Entfernt Satz- und Sonderzeichen
|
|
.replace(/[.,\/#!$%\^&\*;:{}=\-_`~()]/g, "");
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
|
|
// Beide Texte in normalisierte Wort-Listen zerlegen
|
|
const cleanOriginal = getWords(original)
|
|
.map(normalizeWord)
|
|
.filter((word) => word.length > 0);
|
|
const cleanInput = getWords(input)
|
|
.map(normalizeWord)
|
|
.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++) {
|
|
if (cleanInput[i] === cleanOriginal[i]) {
|
|
correctWords++;
|
|
}
|
|
}
|
|
|
|
return correctWords;
|
|
}
|
|
|
|
/**
|
|
* 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 =
|
|
"badge me-1 mb-1 " + (isCorrect ? "text-bg-success" : "text-bg-danger");
|
|
badge.textContent = word;
|
|
return badge;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
|
|
const originalWords = getWords(original);
|
|
const inputWords = getWords(input);
|
|
|
|
resultOriginal.innerHTML = "";
|
|
resultInput.innerHTML = "";
|
|
|
|
// 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));
|
|
});
|
|
|
|
// 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] || "");
|
|
resultInput.appendChild(createWordBadge(word, isCorrect));
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Holt die aktuellen Authentifizierungsdaten.
|
|
*/
|
|
function getAuth() {
|
|
if (!window.AppAuth || typeof window.AppAuth.getAuth !== "function") {
|
|
return null;
|
|
}
|
|
|
|
return window.AppAuth.getAuth();
|
|
}
|
|
|
|
/**
|
|
* Holt eine Instanz des ScoreService.
|
|
*/
|
|
function getScoreService() {
|
|
if (!window.config || !window.ScoreService) {
|
|
return null;
|
|
}
|
|
|
|
return new window.ScoreService(window.config);
|
|
}
|
|
|
|
/**
|
|
* Holt eine Instanz des ChallengeService.
|
|
*/
|
|
function getChallengeService() {
|
|
if (!window.config || !window.ChallengeService) {
|
|
return null;
|
|
}
|
|
|
|
return new window.ChallengeService(window.config);
|
|
}
|
|
|
|
/**
|
|
* Holt eine Instanz des MessageService.
|
|
*/
|
|
function getMessageService() {
|
|
if (!window.config || !window.MessageService) {
|
|
return null;
|
|
}
|
|
|
|
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 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;
|
|
}
|
|
|
|
try {
|
|
const challenge = JSON.parse(raw);
|
|
if (!challenge || challenge.id === undefined || challenge.id === null || !challenge.opponent) {
|
|
return null;
|
|
}
|
|
|
|
return challenge;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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() {
|
|
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() {
|
|
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() {
|
|
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;
|
|
|
|
scoreSaveFeedback.className = "alert alert-" + type + " mb-4";
|
|
scoreSaveFeedback.textContent = message;
|
|
scoreSaveFeedback.classList.remove("d-none");
|
|
}
|
|
|
|
/**
|
|
* Speichert das Ergebnis eines normalen Spiels im Backend.
|
|
* @param {Object} scoreData - Das Score-Datenobjekt.
|
|
*/
|
|
async function saveScore(scoreData) {
|
|
const auth = getAuth();
|
|
if (!auth || !auth.username || !auth.password) {
|
|
showScoreSaveFeedback(
|
|
"Score wurde nur lokal berechnet. Bitte einloggen, damit er im Leaderboard gespeichert wird.",
|
|
"warning",
|
|
);
|
|
return;
|
|
}
|
|
|
|
const scoreService = getScoreService();
|
|
if (!scoreService) {
|
|
showScoreSaveFeedback(
|
|
"Score-Service konnte nicht geladen werden.",
|
|
"danger",
|
|
);
|
|
return;
|
|
}
|
|
|
|
showScoreSaveFeedback("Score wird gespeichert...", "info");
|
|
|
|
const result = await scoreService.postScore(
|
|
auth.username,
|
|
auth.password,
|
|
scoreData.score,
|
|
scoreData.time,
|
|
scoreData.text,
|
|
scoreData.userWrittenText,
|
|
);
|
|
|
|
if (result.ok) {
|
|
const place =
|
|
result.body && result.body.place
|
|
? " Platz " + result.body.place + "."
|
|
: "";
|
|
showScoreSaveFeedback(
|
|
"Score erfolgreich gespeichert." + place,
|
|
"success",
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (result.status === 401) {
|
|
showScoreSaveFeedback(
|
|
"Score konnte nicht gespeichert werden: Login ist nicht gültig.",
|
|
"danger",
|
|
);
|
|
return;
|
|
}
|
|
|
|
showScoreSaveFeedback(
|
|
"Score konnte nicht gespeichert werden (Status " + result.status + ").",
|
|
"danger",
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
if (!scoreSaveFeedback || !isChallengeSecondRound()) {
|
|
return;
|
|
}
|
|
|
|
const opponentScore = Number(activeChallenge.opponentScore);
|
|
if (Number.isNaN(opponentScore)) {
|
|
return;
|
|
}
|
|
|
|
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"
|
|
? opponentName + " gewinnt die Challenge"
|
|
: "Unentschieden";
|
|
const outcomeText = result === "winner"
|
|
? "Gewonnen"
|
|
: result === "loser"
|
|
? "Verloren"
|
|
: "Unentschieden";
|
|
const outcomeImage = result === "winner"
|
|
? "image/sieg.png"
|
|
: result === "loser"
|
|
? "image/verloren.png"
|
|
: "image/unentschieden.png";
|
|
|
|
// Ergebnisgrafik zusammenbauen
|
|
const graphic = document.createElement("div");
|
|
graphic.className = "play-challenge-result play-challenge-result-" + result;
|
|
|
|
const outcomeHeader = document.createElement("div");
|
|
outcomeHeader.className = "challenge-outcome challenge-outcome-" + (result === "winner" ? "win" : result === "loser" ? "loss" : "draw");
|
|
|
|
const outcomeImg = document.createElement("img");
|
|
outcomeImg.src = outcomeImage;
|
|
outcomeImg.alt = outcomeText;
|
|
|
|
const outcomeLabel = document.createElement("strong");
|
|
outcomeLabel.textContent = outcomeText;
|
|
|
|
outcomeHeader.append(outcomeImg, outcomeLabel);
|
|
|
|
const title = document.createElement("div");
|
|
title.className = "play-challenge-result-title";
|
|
title.textContent = headline;
|
|
|
|
const scores = document.createElement("div");
|
|
scores.className = "play-challenge-result-scores";
|
|
|
|
const ownScore = document.createElement("div");
|
|
ownScore.className = "play-challenge-result-score";
|
|
ownScore.innerHTML = "<strong></strong><span></span>";
|
|
ownScore.querySelector("strong").textContent = ownName;
|
|
ownScore.querySelector("span").textContent = score + " Punkte";
|
|
|
|
const otherScore = document.createElement("div");
|
|
otherScore.className = "play-challenge-result-score";
|
|
otherScore.innerHTML = "<strong></strong><span></span>";
|
|
otherScore.querySelector("strong").textContent = opponentName;
|
|
otherScore.querySelector("span").textContent = opponentScore + " Punkte";
|
|
|
|
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) {
|
|
const auth = getAuth();
|
|
if (!auth || !hasActiveChallenge()) {
|
|
return;
|
|
}
|
|
|
|
const challengeService = getChallengeService();
|
|
if (!challengeService) {
|
|
showScoreSaveFeedback(
|
|
"Score gespeichert, aber der Challenge-Service konnte nicht geladen werden.",
|
|
"warning",
|
|
);
|
|
return;
|
|
}
|
|
|
|
const result = await challengeService.completeChallenge(
|
|
auth.username,
|
|
auth.password,
|
|
activeChallenge.id,
|
|
scoreData.score,
|
|
scoreData.time,
|
|
scoreData.text,
|
|
scoreData.userWrittenText,
|
|
);
|
|
|
|
if (!result.ok) {
|
|
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(
|
|
"Challenge abgeschlossen. Beide User erhalten eine Ergebnisnachricht.",
|
|
"success",
|
|
);
|
|
|
|
if (typeof window.updateMessagesNavState === "function") {
|
|
window.updateMessagesNavState();
|
|
}
|
|
|
|
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) {
|
|
// 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);
|
|
activeChallenge = null;
|
|
showScoreSaveFeedback("Resultat wurde an den Herausforderer gesendet.", "success");
|
|
|
|
if (typeof window.updateMessagesNavState === "function") {
|
|
window.updateMessagesNavState();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
const auth = getAuth();
|
|
const messageService = getMessageService();
|
|
if (!auth || !messageService || !activeChallenge.challenger) {
|
|
return;
|
|
}
|
|
|
|
// Zu übermittelnde Spieldaten strukturieren
|
|
const challengeData = {
|
|
id: activeChallenge.id,
|
|
challenger: activeChallenge.challenger,
|
|
opponent: auth.username,
|
|
opponentScore: scoreData.score,
|
|
challengeText: scoreData.text,
|
|
};
|
|
|
|
// Betten das JSON verschlüsselt in das Nachrichtenfeld ein
|
|
const messageText =
|
|
CHALLENGE_DATA_PREFIX +
|
|
JSON.stringify(challengeData) +
|
|
"]]" +
|
|
"\n" +
|
|
auth.username +
|
|
" hat gespielt und " +
|
|
scoreData.score +
|
|
" Punkte erreicht. Jetzt bist du dran.";
|
|
|
|
const result = await messageService.postMessage(
|
|
auth.username,
|
|
auth.password,
|
|
activeChallenge.challenger,
|
|
"challenge",
|
|
messageText,
|
|
);
|
|
|
|
if (!result.ok) {
|
|
showScoreSaveFeedback(
|
|
"Challenge wurde gespeichert, aber die Nachricht an den Herausforderer konnte nicht gesendet werden.",
|
|
"warning",
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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";
|
|
}
|
|
|
|
if (challengerScore < opponentScore) {
|
|
return activeChallenge.opponent || "Gegner";
|
|
}
|
|
|
|
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();
|
|
if (!auth || !messageService || !recipient) {
|
|
return false;
|
|
}
|
|
|
|
const messageText =
|
|
CHALLENGE_DATA_PREFIX +
|
|
JSON.stringify(resultData) +
|
|
"]]" +
|
|
"\n" +
|
|
text;
|
|
|
|
const result = await messageService.postMessage(
|
|
auth.username,
|
|
auth.password,
|
|
recipient,
|
|
"challenge",
|
|
messageText,
|
|
);
|
|
|
|
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) {
|
|
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,
|
|
opponent: activeChallenge.opponent,
|
|
challengerScore: scoreData.score,
|
|
opponentScore: opponentScore,
|
|
challengeText: scoreData.text,
|
|
winner: winner,
|
|
};
|
|
|
|
const winnerText = winner === "draw"
|
|
? "Unentschieden"
|
|
: "Gewinner: " + winner;
|
|
const resultText =
|
|
"Challenge abgeschlossen. " +
|
|
resultData.challenger +
|
|
": " +
|
|
resultData.challengerScore +
|
|
" Punkte, " +
|
|
resultData.opponent +
|
|
": " +
|
|
resultData.opponentScore +
|
|
" Punkte. " +
|
|
winnerText +
|
|
".";
|
|
|
|
// Das Ergebnis per Message an den Gegner übermitteln
|
|
const opponentMessageSent = await sendChallengeResultMessage(
|
|
activeChallenge.opponent,
|
|
resultData,
|
|
resultText,
|
|
);
|
|
|
|
sessionStorage.removeItem(ACTIVE_CHALLENGE_STORAGE_KEY);
|
|
activeChallenge = null;
|
|
|
|
if (!opponentMessageSent) {
|
|
showScoreSaveFeedback(
|
|
"Score gespeichert, aber die Ergebnisnachricht an den Gegner konnte nicht gesendet werden.",
|
|
"warning",
|
|
);
|
|
return;
|
|
}
|
|
|
|
showScoreSaveFeedback(
|
|
"Challenge abgeschlossen. Ergebnisnachricht wurde an den Gegner gesendet.",
|
|
"success",
|
|
);
|
|
|
|
if (typeof window.updateMessagesNavState === "function") {
|
|
window.updateMessagesNavState();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Beendet die Spielrunde, wertet die Eingabe aus, rendert die Fehler
|
|
* und leitet die Speicherung je nach Spielmodus ein.
|
|
*/
|
|
async function submitScore() {
|
|
if (!userTextInput) return;
|
|
|
|
const userInput = userTextInput.value.trim();
|
|
if (!userInput) {
|
|
alert("Bitte geben Sie einen Text ein.");
|
|
return;
|
|
}
|
|
|
|
// Button sperren gegen Doppelklicks
|
|
if (btnSubmitScore) {
|
|
btnSubmitScore.disabled = true;
|
|
btnSubmitScore.textContent = "Wird ausgewertet...";
|
|
}
|
|
|
|
// Punktzahl berechnen
|
|
const score = calculateScore(currentGameText, userInput);
|
|
|
|
// Eingabebereich ausblenden und Ergebnisbereich einblenden
|
|
if (phaseInput) phaseInput.classList.add("d-none");
|
|
if (phaseResult) phaseResult.classList.remove("d-none");
|
|
|
|
if (gameStatus) {
|
|
gameStatus.textContent = "Abgeschlossen";
|
|
gameStatus.className = "badge fs-6 px-3 py-2";
|
|
gameStatus.style.backgroundColor = "#28a745";
|
|
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;
|
|
|
|
const scoreData = {
|
|
score: score,
|
|
time: inputDurationSeconds,
|
|
text: currentGameText,
|
|
userWrittenText: userInput,
|
|
};
|
|
|
|
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) {
|
|
console.error("Fehler beim Speichern des Scores:", error);
|
|
showScoreSaveFeedback(
|
|
"Score konnte wegen eines technischen Fehlers nicht gespeichert werden.",
|
|
"danger",
|
|
);
|
|
} finally {
|
|
if (btnSubmitScore) {
|
|
btnSubmitScore.disabled = false;
|
|
btnSubmitScore.textContent = "Auswerten & Absenden";
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Globale Initialisierungsfunktion, aufgerufen von navigation.js
|
|
* nach dem Laden von play.html.
|
|
*/
|
|
window.initPlayPage = function initPlayPage() {
|
|
clearInterval(timerInterval); // Bestehende Timer vorsichtshalber stoppen
|
|
activeChallenge = readActiveChallenge(); // Prüfen, ob wir aus einer Challenge gestartet wurden
|
|
|
|
// DOM-Referenzen holen
|
|
phaseStart = document.getElementById("phaseStart");
|
|
phaseMemorize = document.getElementById("phaseMemorize");
|
|
phaseInput = document.getElementById("phaseInput");
|
|
phaseResult = document.getElementById("phaseResult");
|
|
|
|
targetTextDisplay = document.getElementById("targetTextDisplay");
|
|
timerDisplay = document.getElementById("timerDisplay");
|
|
userTextInput = document.getElementById("userTextInput");
|
|
resultScore = document.getElementById("resultScore");
|
|
resultOriginal = document.getElementById("resultOriginal");
|
|
resultInput = document.getElementById("resultInput");
|
|
gameStatus = document.getElementById("gameStatus");
|
|
scoreSaveFeedback = document.getElementById("scoreSaveFeedback");
|
|
|
|
const btnStart = document.getElementById("btnStartGame");
|
|
btnSubmitScore = document.getElementById("btnSubmitScore");
|
|
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
|
|
? " Score: " + activeChallenge.opponentScore + "."
|
|
: "";
|
|
const hintPrefix = activeChallenge.role === "opponent"
|
|
? "Du spielst zuerst gegen "
|
|
: "Finale Runde gegen ";
|
|
challengeHint.textContent = hintPrefix + activeChallenge.opponent + "." + opponentScore;
|
|
challengeHint.classList.remove("d-none");
|
|
}
|
|
|
|
// Event-Listener binden
|
|
if (btnStart) btnStart.addEventListener("click", startGame);
|
|
if (btnSubmitScore) btnSubmitScore.addEventListener("click", submitScore);
|
|
if (btnRestart)
|
|
btnRestart.addEventListener("click", () =>
|
|
window.loadPage("play", "nav-play"),
|
|
);
|
|
if (btnLeaderboard)
|
|
btnLeaderboard.addEventListener("click", () => {
|
|
const navLink = document.getElementById("nav-leaderboard");
|
|
if (navLink) {
|
|
navLink.click(); // Nutzt die bestehende Navigation inklusive Active-State.
|
|
} else {
|
|
console.warn("Sidebar Link #nav-leaderboard nicht gefunden.");
|
|
}
|
|
});
|
|
|
|
// 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 (
|
|
(e.ctrlKey || e.metaKey) &&
|
|
(key === "c" || key === "v" || key === "x" || key === "a")
|
|
) {
|
|
e.preventDefault();
|
|
alert("Yeah, cheating is not sooo nice...");
|
|
}
|
|
});
|
|
|
|
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();
|
|
}
|
|
});
|
|
}
|
|
|
|
// 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";
|
|
}
|
|
};
|
|
})();
|