Volle Mindflux-App ins Deploy-Repo übernehmen
Ersetzt das Hello-World-Gerüst durch die vollständige Anwendung (com.example.mindflux: 64 Klassen, Thymeleaf-Templates, Static-Assets, Flyway V1–V6). Deploy-Infrastruktur (.gitea, Dockerfile) bleibt erhalten. - pom: volle Abhängigkeiten (Security, Thymeleaf, JPA, Flyway), Spring Boot 3.4.4 / Java 17 - docker-compose: SPRING_DATASOURCE_* unverändert; App lauscht auf 8080 (nginx -> mindflux:8080); Uploads-Volume; Admin-Passwörter via Gitea-Secrets - Workflow: Deploy-Step injiziert MINDFLUX_ADMIN*_PASSWORD aus Secrets - .dockerignore ergänzt; Hello-World (com.mindflux) und Test-Datei 'main' entfernt Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,306 @@
|
||||
/* 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)
|
||||
|
||||
/* 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;
|
||||
|
||||
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;
|
||||
setIdle('Spiel abgebrochen – wähle ein Deck und starte ein neues Spiel.');
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
if (!deckPairs || !deckPairs.length) {
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
await loadStats();
|
||||
|
||||
if (decks.length) {
|
||||
newGame();
|
||||
} else {
|
||||
setIdle('Noch keine Decks vorhanden. Ein Admin kann sie unter „MindMatch-Decks verwalten“ anlegen.');
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
})();
|
||||
Reference in New Issue
Block a user