/* 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 = '' + '⚡' + '' + '' + (data.kind === 'term' ? 'Begriff' : 'Bedeutung') + '' + '' + '' + ''; 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(); })();