983e57cd8d
- 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
402 lines
12 KiB
JavaScript
402 lines
12 KiB
JavaScript
/* 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();
|
||
})();
|