diff --git a/src/main/resources/static/css/game-guide.css b/src/main/resources/static/css/game-guide.css new file mode 100644 index 0000000..be6a730 --- /dev/null +++ b/src/main/resources/static/css/game-guide.css @@ -0,0 +1,167 @@ +/* Mindflux – Gemeinsames Guide-Modul „So funktioniert's". + Styles für den „?"-Trigger, den Tipp-Button, das Overlay und den MindMatch-Hint-Glow. + Theme-bewusst: dunkel als Standard, helle Überschreibungen für data-theme="light". */ + +/* ---------- Buttons in der Header-Tools-Leiste ---------- */ +.mf-guide-trigger, +.mf-guide-hint { + font: inherit; + color: inherit; + cursor: pointer; + border: 1px solid rgba(255, 255, 255, 0.28); + background: rgba(255, 255, 255, 0.06); + border-radius: 999px; + transition: background-color .2s ease, border-color .2s ease, transform .12s ease; +} +.mf-guide-trigger { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.1rem; + height: 2.1rem; + font-size: 1.05rem; + font-weight: 700; + line-height: 1; +} +.mf-guide-hint { + padding: .34rem .8rem; + font-size: .85rem; + font-weight: 600; + white-space: nowrap; +} +.mf-guide-trigger:hover, +.mf-guide-hint:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.16); + border-color: rgba(255, 255, 255, 0.5); + transform: translateY(-1px); +} +.mf-guide-trigger:focus-visible, +.mf-guide-hint:focus-visible { + outline: 2px solid currentColor; + outline-offset: 2px; +} +.mf-guide-hint:disabled { + opacity: .45; + cursor: not-allowed; +} + +/* ---------- Overlay ---------- */ +.mf-guide-backdrop { + position: fixed; + inset: 0; + z-index: 99999; + display: flex; + align-items: center; + justify-content: center; + padding: 1.25rem; + background: rgba(5, 8, 16, 0.72); + backdrop-filter: blur(4px); +} +.mf-guide-backdrop[hidden] { display: none; } + +.mf-guide-panel { + width: min(34rem, 100%); + max-height: min(82vh, 640px); + overflow-y: auto; + box-sizing: border-box; + padding: 1.4rem 1.5rem 1.6rem; + border-radius: 18px; + border: 1px solid rgba(255, 255, 255, 0.14); + background: #141a26; + color: #eef2f8; + box-shadow: 0 24px 60px rgba(0, 0, 0, 0.5); + animation: mf-guide-in .22s ease; +} +@keyframes mf-guide-in { + from { opacity: 0; transform: translateY(12px) scale(.98); } + to { opacity: 1; transform: translateY(0) scale(1); } +} +.mf-guide-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: .4rem; +} +.mf-guide-kicker { + font-size: .72rem; + letter-spacing: .16em; + text-transform: uppercase; + opacity: .7; +} +.mf-guide-close { + flex: none; + width: 2rem; + height: 2rem; + border: none; + border-radius: 50%; + cursor: pointer; + font-size: 1.3rem; + line-height: 1; + color: inherit; + background: rgba(255, 255, 255, 0.08); +} +.mf-guide-close:hover { background: rgba(255, 255, 255, 0.18); } +.mf-guide-title { + margin: 0 0 .9rem; + font-size: 1.3rem; + line-height: 1.25; +} +.mf-guide-steps { + margin: 0 0 1rem; + padding-left: 1.2rem; + display: grid; + gap: .5rem; +} +.mf-guide-steps li { line-height: 1.45; } +.mf-guide-tip { + margin: 0; + padding: .75rem .9rem; + border-radius: 12px; + line-height: 1.45; + background: rgba(120, 170, 255, 0.14); + border: 1px solid rgba(120, 170, 255, 0.3); +} +body.mf-guide-open { overflow: hidden; } + +/* ---------- Helles Theme ---------- */ +:root[data-theme="light"] .mf-guide-trigger, +:root[data-theme="light"] .mf-guide-hint { + border-color: rgba(20, 30, 50, 0.28); + background: rgba(20, 30, 50, 0.05); +} +:root[data-theme="light"] .mf-guide-trigger:hover, +:root[data-theme="light"] .mf-guide-hint:hover:not(:disabled) { + background: rgba(20, 30, 50, 0.12); + border-color: rgba(20, 30, 50, 0.45); +} +:root[data-theme="light"] .mf-guide-backdrop { background: rgba(15, 23, 42, 0.5); } +:root[data-theme="light"] .mf-guide-panel { + background: #ffffff; + color: #16202e; + border-color: rgba(20, 30, 50, 0.12); +} +:root[data-theme="light"] .mf-guide-close { background: rgba(20, 30, 50, 0.08); } +:root[data-theme="light"] .mf-guide-close:hover { background: rgba(20, 30, 50, 0.16); } +:root[data-theme="light"] .mf-guide-tip { + background: rgba(60, 110, 220, 0.12); + border-color: rgba(60, 110, 220, 0.28); +} + +/* ---------- MindMatch – kurz aufgedecktes Tipp-Paar ---------- */ +.mm-card.hinted { + position: relative; + z-index: 2; + box-shadow: 0 0 0 2px #ffd54a, 0 0 22px 5px rgba(255, 213, 74, 0.6); + border-radius: 14px; + animation: mf-hint-pulse 1s ease; +} +@keyframes mf-hint-pulse { + 0%, 100% { filter: none; } + 50% { filter: brightness(1.12); } +} + +@media (prefers-reduced-motion: reduce) { + .mf-guide-panel { animation: none; } + .mm-card.hinted { animation: none; } +} diff --git a/src/main/resources/static/js/game-guide.js b/src/main/resources/static/js/game-guide.js new file mode 100644 index 0000000..d610fd8 --- /dev/null +++ b/src/main/resources/static/js/game-guide.js @@ -0,0 +1,280 @@ +/* Mindflux – Gemeinsames Guide-Modul „So funktioniert's". + - Erkennt das aktuelle Spiel über DOM-Marker (analog accessibility.js applySkin). + - Hängt einen „?"-Button in die Header-Tools-Leiste des Spiels ein. + - Öffnet ein Overlay mit einer kurzen Anleitung; der Erstbesuch öffnet automatisch. + - Stellt window.MindfluxGuide = { open, close, registerHintProvider } bereit. + + Kein fetch: alle Inhalte liegen als deutsches Inline-Registry vor (Vorbild: networker-guide.js). + + API window.MindfluxGuide: + open() – Anleitung anzeigen. + close() – Anleitung schließen. + registerHintProvider({ label, remaining, onHint }) – einheitlichen Tipp-Button + in der Header-Tools-Leiste erzeugen. + label : String – Beschriftung des Buttons (z.B. "Tipp"). + remaining : Function -> Number – liefert die aktuelle Restanzahl (für die Beschriftung). + onHint : Function – wird beim Klick aufgerufen; darf die neue Restanzahl zurückgeben. + Rückgabe: { update(n), show(), hide(), remove(), button } zum Steuern des Buttons. */ +(function () { + 'use strict'; + + // NetWorker bringt seinen eigenen Inline-Guide mit -> hier bewusst nichts tun. + if (document.querySelector('.nw-app')) return; + + // ---------- Spiel-Erkennung über DOM-Marker ---------- + var MARKERS = [ + ['.mm-body', 'mindmatch'], + ['.tt-body', 'techtacular'], + ['.ss-body', 'syntaxsaga'], + ['.esc-app', 'codeescape'], + ['.dlg-body', 'devlab'], + ['.bh-app', 'bughunter'], + ['.sc-app', 'serviceclash'] + ]; + var slug = ''; + for (var i = 0; i < MARKERS.length; i++) { + if (document.querySelector(MARKERS[i][0])) { slug = MARKERS[i][1]; break; } + } + if (!slug) return; + + // ---------- Registry: je Spiel Titel + „So funktioniert's" (Schritte + 1 Tipp) ---------- + var GUIDES = { + mindmatch: { + title: 'MindMatch – So funktioniert’s', + steps: [ + 'Wähle oben ein Deck und klicke auf „Neues Spiel".', + 'Decke zwei Karten auf und finde einen Begriff und die passende Bedeutung.', + 'Ein richtiges Paar bleibt liegen, ein Fehlgriff dreht wieder zu.', + 'Finde alle Paare – mit möglichst wenigen Zügen.' + ], + tip: 'Je weniger Züge, desto mehr Sterne. Der „Tipp"-Button deckt kurz ein Paar auf, kostet dich dafür aber einen Zug.' + }, + techtacular: { + title: 'TechTacular – So funktioniert’s', + steps: [ + 'Starte mit „Neues Spiel" – das Jeopardy-Board mit Kategorien erscheint.', + 'Wähle eine Kachel: mehr Punkte bedeuten eine kniffligere Aufgabe.', + 'Lies die Antwort und wähle aus den Optionen die dazu passende Frage.', + 'Sammle über alle Fragen hinweg möglichst viele Punkte.' + ], + tip: 'Der 50:50-Joker entfernt zwei falsche Optionen – dafür gibt es bei dieser Frage nur die halben Punkte.' + }, + syntaxsaga: { + title: 'SyntaxSaga – So funktioniert’s', + steps: [ + 'Wähle oben eine Stufe: Junior, Professional oder Senior.', + 'Lies die Geschichte und fülle die Code-Lücken in den Eingabefeldern.', + 'Klicke „Prüfen" (oder drücke Enter) und arbeite dich durch die Missionen.' + ], + tip: 'Der erste richtige Versuch bringt volle Punkte – nutze einen Hinweis, wenn du feststeckst.' + }, + codeescape: { + title: 'SystemEscape – So funktioniert’s', + steps: [ + 'Wähle ein Szenario und generiere einen Run mit vier Räumen.', + 'Löse in jedem Raum die Rätsel und sichere so die Fragmente.', + 'Setze die Fragmente von links nach rechts zum Türcode zusammen und öffne die Schleuse.' + ], + tip: 'Die Uhr läuft: „Hinweis" kostet nichts, „Auflösen" bringt dich schneller weiter – dafür ohne den vollen Ruhm.' + }, + devlab: { + title: 'Dev Lab – So funktioniert’s', + steps: [ + 'Lies die Aufgabe und den Hinweistext oben in der Karte.', + 'Löse sie im Spielfeld – je nach Typ: Lücken füllen, auswählen, zuordnen, Bug finden oder Regex.', + 'Klicke „Prüfen" und danach „Weiter" zur nächsten Aufgabe.' + ], + tip: 'Der erste richtige Versuch zählt voll. „Hinweis" und „Auflösung" helfen, wenn es klemmt.' + }, + bughunter: { + title: 'BugHunter – So funktioniert’s', + steps: [ + 'Öffne eine Fallakte und lies Briefing sowie Konsolenausgabe.', + 'Orte die fehlerhafte Zeile und klassifiziere den Fehler (Syntax, Logik, Laufzeit, Sicherheit).', + 'Wähle den sauberen Fix und bestätige mit „Prüfen".' + ], + tip: 'Löst du eine Akte im ersten Versuch fehlerfrei, baust du eine Combo-Serie mit Bonuspunkten auf.' + }, + serviceclash: { + title: 'ServiceClash – So funktioniert’s', + steps: [ + 'Wähle eine Schicht und öffne den Leitstand.', + 'Entscheide je Ticket zuerst: Störung oder Anfrage, dann die Priorität.', + 'Wähle die beste erste Maßnahme – das Ticket wird dokumentiert.' + ], + tip: '„Antwort aufdecken" zeigt dir die Begründung, gibt für diesen Schritt aber keine Punkte.' + } + }; + + var guide = GUIDES[slug]; + if (!guide) return; + + var SEEN_KEY = 'mf-guide-seen:' + slug; + + // ---------- Header-Tools-Leiste finden (Container kann spät im DOM erscheinen) ---------- + var HOST_SEL = '.mm-header-tools,.tt-header-tools,.ss-header-tools,.dlg-header-tools,.mf-a11y-host'; + var pending = []; + var hostObserver = null; + + function flushPending() { + var host = document.querySelector(HOST_SEL); + if (!host) return false; + while (pending.length) { pending.shift()(host); } + return true; + } + + // Ruft fn(host) auf, sobald die Leiste existiert – sofort oder per MutationObserver. + function whenHost(fn) { + pending.push(fn); + if (flushPending()) return; + if (!hostObserver) { + hostObserver = new MutationObserver(function () { + if (flushPending() && !pending.length && hostObserver) { + hostObserver.disconnect(); + hostObserver = null; + } + }); + hostObserver.observe(document.documentElement, { childList: true, subtree: true }); + } + } + + // ---------- Overlay (Backdrop + Panel) ---------- + var backdrop = null; + var panel = null; + var closeBtn = null; + var trigger = null; + var lastFocus = null; + + function buildOverlay() { + if (backdrop) return; + backdrop = document.createElement('div'); + backdrop.className = 'mf-guide-backdrop'; + backdrop.hidden = true; + + panel = document.createElement('section'); + panel.className = 'mf-guide-panel'; + panel.setAttribute('role', 'dialog'); + panel.setAttribute('aria-modal', 'true'); + panel.setAttribute('aria-labelledby', 'mf-guide-title'); + + var steps = guide.steps.map(function (s) { return '
Tipp: ' + guide.tip + '
'; + + backdrop.appendChild(panel); + document.body.appendChild(backdrop); + + closeBtn = panel.querySelector('.mf-guide-close'); + closeBtn.addEventListener('click', close); + backdrop.addEventListener('mousedown', function (e) { + if (e.target === backdrop) close(); + }); + } + + function onKeydown(e) { + if (e.key === 'Escape') { e.stopPropagation(); close(); return; } + if (e.key !== 'Tab') return; + var f = panel.querySelectorAll('button, [href], input, [tabindex]:not([tabindex="-1"])'); + if (!f.length) return; + var first = f[0], last = f[f.length - 1]; + if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } + else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } + } + + function open() { + buildOverlay(); + if (!backdrop.hidden) return; + lastFocus = document.activeElement; + backdrop.hidden = false; + document.body.classList.add('mf-guide-open'); + document.addEventListener('keydown', onKeydown, true); + if (closeBtn) closeBtn.focus(); + } + + function close() { + if (!backdrop || backdrop.hidden) return; + backdrop.hidden = true; + document.body.classList.remove('mf-guide-open'); + document.removeEventListener('keydown', onKeydown, true); + var back = trigger || lastFocus; + if (back && typeof back.focus === 'function') back.focus(); + } + + // ---------- „?"-Trigger in die Header-Tools-Leiste ---------- + function mountTrigger(host) { + if (trigger || host.querySelector('.mf-guide-trigger')) return; + trigger = document.createElement('button'); + trigger.type = 'button'; + trigger.className = 'mf-guide-trigger'; + trigger.setAttribute('aria-label', 'Spielanleitung öffnen'); + trigger.setAttribute('aria-haspopup', 'dialog'); + trigger.innerHTML = ''; + trigger.addEventListener('click', open); + host.insertBefore(trigger, host.firstChild); + } + + // ---------- Einheitlicher Tipp-Button ---------- + function registerHintProvider(opts) { + opts = opts || {}; + var label = opts.label || 'Tipp'; + var btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'mf-guide-hint'; + + function readRemaining(explicit) { + if (typeof explicit === 'number') return explicit; + if (typeof opts.remaining === 'function') { + var v = opts.remaining(); + return typeof v === 'number' ? v : null; + } + return null; + } + function paint(explicit) { + var rest = readRemaining(explicit); + btn.textContent = rest === null ? label : label + ' (' + rest + ')'; + btn.disabled = rest !== null && rest <= 0; + btn.setAttribute('aria-label', label + (rest === null ? '' : ' – noch ' + rest + ' übrig')); + } + btn.addEventListener('click', function () { + if (btn.disabled) return; + var ret = typeof opts.onHint === 'function' ? opts.onHint() : null; + paint(typeof ret === 'number' ? ret : undefined); + }); + paint(); + whenHost(function (host) { host.insertBefore(btn, host.firstChild); }); + + return { + update: function (n) { paint(n); }, + show: function () { btn.hidden = false; paint(); }, + hide: function () { btn.hidden = true; }, + remove: function () { if (btn.parentNode) btn.parentNode.removeChild(btn); }, + button: btn + }; + } + + // ---------- Start ---------- + whenHost(mountTrigger); + + // Erstbesuch: Anleitung automatisch öffnen, danach nur noch per „?"-Button. + var seen = false; + try { seen = !!localStorage.getItem(SEEN_KEY); } catch (e) { seen = false; } + if (!seen) { + var openOnce = function () { + open(); + try { localStorage.setItem(SEEN_KEY, '1'); } catch (e) {} + }; + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', openOnce); + } else { + openOnce(); + } + } + + window.MindfluxGuide = { open: open, close: close, registerHintProvider: registerHintProvider }; +})(); diff --git a/src/main/resources/static/js/mindmatch.js b/src/main/resources/static/js/mindmatch.js index 3b658dd..05917b3 100644 --- a/src/main/resources/static/js/mindmatch.js +++ b/src/main/resources/static/js/mindmatch.js @@ -34,6 +34,10 @@ 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; @@ -175,6 +179,7 @@ stopTimer(); running = false; cancelBtn.hidden = true; + hideHint(); const localScore = calculateScore(totalPairs, moves, seconds); const starCount = starsFor(totalPairs, moves); @@ -224,9 +229,95 @@ 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`); @@ -249,8 +340,10 @@ resultEl.hidden = true; firstCard = null; lock = false; + hintsLeft = 3; if (!deckPairs || !deckPairs.length) { + hideHint(); setIdle('Dieses Deck hat noch keine Paare.'); return; } @@ -262,6 +355,7 @@ running = true; cancelBtn.hidden = false; + showHint(); } async function loadDecks() { @@ -292,6 +386,7 @@ againBtn.addEventListener('click', newGame); cancelBtn.addEventListener('click', cancelGame); deckSelect.addEventListener('change', newGame); + setupHint(); await loadStats(); diff --git a/src/main/resources/static/js/theme.js b/src/main/resources/static/js/theme.js index 52a6a53..fa20a3d 100644 --- a/src/main/resources/static/js/theme.js +++ b/src/main/resources/static/js/theme.js @@ -52,6 +52,25 @@ document.head.appendChild(accessibilityScript); } + // Spielanleitungen & Hints („So funktioniert's" + einheitliche Tipp-Buttons): + // nur auf den eigenständigen Spielrouten laden, analog zur FluxTimer-Injektion. + if (isGameRoute) { + if (!document.querySelector('link[data-game-guide]')) { + var guideCss = document.createElement('link'); + guideCss.rel = 'stylesheet'; + guideCss.href = '/css/game-guide.css?v=1'; + guideCss.setAttribute('data-game-guide', ''); + document.head.appendChild(guideCss); + } + if (!document.querySelector('script[data-game-guide]')) { + var guideScript = document.createElement('script'); + guideScript.src = '/js/game-guide.js?v=1'; + guideScript.defer = true; + guideScript.setAttribute('data-game-guide', ''); + document.head.appendChild(guideScript); + } + } + var SUN = ''; var MOON = '';