Files
mindflux/src/main/resources/static/js/mindmatch.js
T
AliceDarkest 983e57cd8d AP4 Spielanleitungen & Hints: gemeinsames Guide-Modul + MindMatch Paar-Blitz
- game-guide.js/.css (neu): gemeinsames "So funktioniert's"-Overlay mit ?-Button
  in der Header-Tools-Leiste, Auto-Open beim Erstbesuch (localStorage
  mf-guide-seen:<slug>), Escape/Backdrop schliesst, Fokus-Trap; deutsches Inline-
  Registry fuer 7 Spiele (Networker bleibt bewusst bei seinem Inline-Guide). API
  window.MindfluxGuide = {open, close, registerHintProvider} fuer einheitliche
  Tipp-Buttons. Injektion via theme.js nur auf Game-Routen (wie FluxTimer), keine
  Template-Edits.
- mindmatch.js: Hint "Paar-Blitz" (3 Ladungen) deckt ~1s ein ungematchtes Paar
  auf und kostet +1 Zug -> Score/Sterne degradieren ueber die bestehende
  Serverformel (null Backend-Aenderung); Reset in newGame, Verstecken in
  finish/cancelGame; .mm-card.hinted-Glow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WFxcjtc9HNP4VECT6Ur1tE
2026-07-21 00:32:11 +02:00

402 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* MindMatch – Lern-Memory (Begriff <-> Bedeutung).
Decks kommen aus der DB-API (/api/mindmatch/decks), Highscore aus /api/games/mindmatch. */
(() => {
'use strict';
const GAME = 'mindmatch';
const board = document.getElementById('mm-board');
const deckSelect = document.getElementById('mm-deck');
const newBtn = document.getElementById('mm-new');
const cancelBtn = document.getElementById('mm-cancel');
const movesEl = document.getElementById('mm-moves');
const timeEl = document.getElementById('mm-time');
const pairsEl = document.getElementById('mm-pairs');
const bestEl = document.getElementById('mm-best');
const resultEl = document.getElementById('mm-result');
const resultStars = document.getElementById('mm-result-stars');
const resultScore = document.getElementById('mm-result-score');
const resultDetail= document.getElementById('mm-result-detail');
const resultHigh = document.getElementById('mm-result-highscore');
const againBtn = document.getElementById('mm-again');
const decksBySlug = new Map(); // slug -> [{term, match}]
// Laufender Spielzustand
let pairs = [];
let totalPairs = 0;
let firstCard = null; // erste aufgedeckte, noch nicht zugeordnete Karte
let lock = false; // Eingabesperre während der Auswertung
let moves = 0;
let matched = 0;
let seconds = 0;
let timerId = null;
let running = false; // läuft gerade eine Runde? (steuert den Abbrechen-Button)
// Tipp „Paar-Blitz": deckt kurz ein noch nicht gematchtes Paar auf (kostet +1 Zug).
let hintsLeft = 3;
let hintUi = null; // Handle des Tipp-Buttons (von MindfluxGuide oder eigen)
/* Punkteformel – identisch zu GameScoreService.calculateScore (Server bleibt autoritativ). */
function calculateScore(p, mv, secs) {
if (p <= 0) return 0;
const safeMoves = Math.max(mv, p);
const safeSecs = Math.max(secs, 0);
const accuracy = Math.round(1000 * p / safeMoves);
const timeBonus = Math.max(0, 500 - safeSecs * 5);
return accuracy + timeBonus;
}
function starsFor(p, mv) {
if (mv <= Math.ceil(p * 1.4)) return 3;
if (mv <= p * 2) return 2;
return 1;
}
function formatTime(s) {
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
}
function shuffle(arr) {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
function startTimer() {
if (timerId) return;
timerId = setInterval(() => {
seconds++;
timeEl.textContent = formatTime(seconds);
}, 1000);
}
function stopTimer() {
clearInterval(timerId);
timerId = null;
}
function resetHud() {
seconds = 0; moves = 0; matched = 0;
timeEl.textContent = '0:00';
movesEl.textContent = '0';
pairsEl.textContent = '0 / 0';
}
/* Leeres Brett mit Hinweis; räumt die per-JS gesetzten Grid-Inline-Styles ab. */
function setIdle(message) {
board.style.gridTemplateColumns = '';
board.style.maxWidth = '';
board.innerHTML = '';
const hint = document.createElement('p');
hint.className = 'mm-idle';
hint.textContent = message;
board.appendChild(hint);
}
function buildCards(pairList) {
const cards = [];
pairList.forEach((pair, index) => {
cards.push({ pairId: index, kind: 'term', text: pair.term });
cards.push({ pairId: index, kind: 'def', text: pair.match });
});
return shuffle(cards);
}
function createCard(data) {
const card = document.createElement('button');
card.type = 'button';
card.className = 'mm-card';
card.dataset.pairId = String(data.pairId);
card.innerHTML =
'<span class="mm-card-inner">' +
'<span class="mm-card-face mm-card-front">⚡</span>' +
'<span class="mm-card-face mm-card-back mm-' + data.kind + '">' +
'<span class="mm-kind">' + (data.kind === 'term' ? 'Begriff' : 'Bedeutung') + '</span>' +
'<span class="mm-text"></span>' +
'</span>' +
'</span>';
card.querySelector('.mm-text').textContent = data.text; // sicher gegen HTML im Text
card.addEventListener('click', () => onCardClick(card));
return card;
}
function renderBoard(cards) {
board.innerHTML = '';
// Spaltenzahl ~ Wurzel der Kartenzahl; die Reihenhöhe regelt das CSS (grid-auto-rows: 1fr),
// sodass das ganze Brett ohne Scrollen in die feste Stage-Höhe passt.
const cols = Math.ceil(Math.sqrt(cards.length));
board.style.gridTemplateColumns = `repeat(${cols}, minmax(0, 1fr))`;
board.style.maxWidth = (cols * 220) + 'px';
cards.forEach((data) => board.appendChild(createCard(data)));
}
function onCardClick(card) {
if (lock) return;
if (card.classList.contains('flipped') || card.classList.contains('matched')) return;
startTimer();
card.classList.add('flipped');
if (!firstCard) {
firstCard = card;
return;
}
// zweite Karte -> ein Zug
moves++;
movesEl.textContent = String(moves);
const a = firstCard;
const b = card;
if (a.dataset.pairId === b.dataset.pairId) {
a.classList.add('matched');
b.classList.add('matched');
firstCard = null;
matched++;
pairsEl.textContent = `${matched} / ${totalPairs}`;
if (matched === totalPairs) {
finish();
}
} else {
lock = true;
a.classList.add('wrong');
b.classList.add('wrong');
setTimeout(() => {
a.classList.remove('flipped', 'wrong');
b.classList.remove('flipped', 'wrong');
firstCard = null;
lock = false;
}, 900);
}
}
async function finish() {
stopTimer();
running = false;
cancelBtn.hidden = true;
hideHint();
const localScore = calculateScore(totalPairs, moves, seconds);
const starCount = starsFor(totalPairs, moves);
resultStars.textContent = '★'.repeat(starCount) + '☆'.repeat(3 - starCount);
resultScore.textContent = String(localScore);
resultDetail.textContent = `${totalPairs} Paare · ${moves} Züge · ${formatTime(seconds)}`;
resultHigh.hidden = true;
resultEl.hidden = false;
try {
const res = await fetch(`/api/games/${GAME}/scores`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
deck: deckSelect.value,
pairs: totalPairs,
moves: moves,
durationSeconds: seconds
})
});
if (res.ok) {
const data = await res.json();
resultScore.textContent = String(data.score); // Server ist autoritativ
resultHigh.hidden = !data.newHighscore;
bestEl.textContent = String(data.highscore);
}
} catch (err) {
console.error('Score konnte nicht gespeichert werden:', err);
}
}
/* Laufendes Spiel abbrechen: Timer stoppen, Zustand zurücksetzen, KEIN Score-POST. */
function cancelGame() {
if (!running) return;
// Rückfrage nur, wenn schon etwas passiert ist (kein Nerv-Dialog auf frischem Brett).
const touched = moves > 0 || matched > 0 || seconds > 0 || firstCard !== null;
if (touched && !window.confirm(
'Aktuelles Spiel wirklich abbrechen? Der Fortschritt geht verloren und wird nicht gespeichert.')) {
return;
}
stopTimer();
resetHud();
running = false;
firstCard = null;
lock = false;
resultEl.hidden = true;
cancelBtn.hidden = true;
hideHint();
setIdle('Spiel abgebrochen – wähle ein Deck und starte ein neues Spiel.');
}
/* ---------- Tipp „Paar-Blitz" ----------
Deckt EIN noch nicht gematchtes Paar ~1 s auf und kostet +1 Zug, damit Score/Sterne
über die bestehende (Server-)Formel degradieren. Keine Backend-Änderung nötig. */
function revealHintPair() {
if (hintsLeft <= 0 || !running || lock) return hintsLeft;
const cards = Array.from(board.querySelectorAll('.mm-card:not(.matched)'));
if (!cards.length) return hintsLeft;
let a = null;
if (firstCard && !firstCard.classList.contains('matched')) {
a = firstCard; // schon eine Karte offen -> dessen Partner zeigen
} else {
const closed = cards.filter((c) => !c.classList.contains('flipped'));
const pool = closed.length ? closed : cards;
a = pool[Math.floor(Math.random() * pool.length)];
}
const b = cards.find((c) => c !== a && c.dataset.pairId === a.dataset.pairId);
if (!a || !b) return hintsLeft;
hintsLeft--;
// +1 Zug: der Server rechnet Score/Sterne allein aus moves.
moves++;
movesEl.textContent = String(moves);
[a, b].forEach((c) => c.classList.add('flipped', 'hinted'));
lock = true; // kurz sperren, damit niemand ins aufgedeckte Paar klickt
setTimeout(() => {
[a, b].forEach((c) => {
c.classList.remove('hinted');
if (c === firstCard || c.classList.contains('matched')) return;
c.classList.remove('flipped');
});
lock = false;
}, 1000);
return hintsLeft;
}
function refreshHint() { if (hintUi && hintUi.update) hintUi.update(hintsLeft); }
function showHint() { if (hintUi) { if (hintUi.show) hintUi.show(); refreshHint(); } }
function hideHint() { if (hintUi && hintUi.hide) hintUi.hide(); }
/* Fallback-Button in der .mm-bar-group, falls MindfluxGuide nicht verfügbar ist. */
function buildOwnHintButton() {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'mm-btn mm-btn-hint';
function paint() {
btn.textContent = `Tipp (${hintsLeft})`;
btn.disabled = hintsLeft <= 0 || !running;
}
btn.addEventListener('click', () => { revealHintPair(); paint(); });
const group = document.querySelector('.mm-bar-group');
if (group) group.appendChild(btn);
paint();
return {
update: paint,
show() { btn.hidden = false; paint(); },
hide() { btn.hidden = true; }
};
}
/* Bevorzugt den einheitlichen Tipp-Button von MindfluxGuide; wartet kurz auf dessen
API (Ladereihenfolge nicht garantiert) und fällt sonst auf einen eigenen Button zurück. */
function setupHint(tries) {
if (hintUi) return;
const api = window.MindfluxGuide;
if (api && typeof api.registerHintProvider === 'function') {
hintUi = api.registerHintProvider({
label: 'Tipp',
remaining: () => hintsLeft,
onHint: revealHintPair
});
} else if ((tries || 0) < 25) {
setTimeout(() => setupHint((tries || 0) + 1), 60);
return;
} else {
hintUi = buildOwnHintButton();
}
if (running) showHint(); else hideHint();
}
async function loadStats() {
try {
const res = await fetch(`/api/games/${GAME}/stats`);
if (!res.ok) return;
const stats = await res.json();
bestEl.textContent = stats.highscore > 0 ? String(stats.highscore) : '–';
} catch (err) {
/* still ohne Highscore weiter */
}
}
function newGame() {
const slug = deckSelect.value;
const deckPairs = decksBySlug.get(slug);
stopTimer();
running = false;
cancelBtn.hidden = true;
resetHud();
resultEl.hidden = true;
firstCard = null;
lock = false;
hintsLeft = 3;
if (!deckPairs || !deckPairs.length) {
hideHint();
setIdle('Dieses Deck hat noch keine Paare.');
return;
}
pairs = deckPairs;
totalPairs = pairs.length;
pairsEl.textContent = `0 / ${totalPairs}`;
renderBoard(buildCards(pairs));
running = true;
cancelBtn.hidden = false;
showHint();
}
async function loadDecks() {
const res = await fetch('/api/mindmatch/decks');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
async function init() {
let decks = [];
try {
decks = await loadDecks();
} catch (err) {
console.error('Decks konnten nicht geladen werden:', err);
}
deckSelect.innerHTML = '';
decksBySlug.clear();
decks.forEach((deck) => {
decksBySlug.set(deck.slug, deck.pairs || []);
const option = document.createElement('option');
option.value = deck.slug;
option.textContent = deck.title;
deckSelect.appendChild(option);
});
newBtn.addEventListener('click', newGame);
againBtn.addEventListener('click', newGame);
cancelBtn.addEventListener('click', cancelGame);
deckSelect.addEventListener('change', newGame);
setupHint();
await loadStats();
if (decks.length) {
newGame();
} else {
setIdle('Noch keine Decks vorhanden. Ein Admin kann sie unter „MindMatch-Decks verwalten“ anlegen.');
}
}
init();
})();