Files
mindflux/src/main/resources/static/js/devlabgame.js
T
AliceDarkest f9ee3092a3
mindflux-ci / build (push) Successful in 22s
Rebrand platform as SysnaptiK Neural
2026-07-14 14:44:04 +02:00

378 lines
18 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.
/* Dev Lab – polymorphe Spiel-Engine. Aufgaben kommen aus /api/devlab/games/{slug}/session
(ohne Lösung), geprüft + bewertet wird serverseitig. Eine Render-/Submit-Funktion je Typ:
cloze · choice · order · spot · match · regex. Interaktion klick-basiert. */
(function () {
'use strict';
const MAX_ATTEMPTS = 3;
const GAME = 'devlab';
const slug = document.body.dataset.game || '';
const MECH_LABEL = {
cloze: 'Lücken füllen', choice: 'Multiple Choice', order: 'Zusammensetzen',
spot: 'Bug finden', match: 'Zuordnen', regex: 'Regex'
};
const el = (id) => document.getElementById(id);
const r = {
title: el('dlg-title'), language: el('dlg-language'), taskNo: el('dlg-task-no'),
score: el('dlg-score'), time: el('dlg-time'), best: el('dlg-best'), mech: el('dlg-mech'),
kind: el('dlg-kind'), points: el('dlg-points'), prompt: el('dlg-prompt'),
narrative: el('dlg-narrative'), instruction: el('dlg-instruction'), playfield: el('dlg-playfield'),
feedback: el('dlg-feedback'), success: el('dlg-success'),
check: el('dlg-check'), hint: el('dlg-hint'), reveal: el('dlg-reveal'), next: el('dlg-next'),
result: el('dlg-result'), resultStars: el('dlg-result-stars'), resultScore: el('dlg-result-score'),
resultMax: el('dlg-result-max'), resultDetail: el('dlg-result-detail'), resultHigh: el('dlg-result-high'),
again: el('dlg-again'), description: el('dlg-description'), sideTitle: el('dlg-side-title'),
progress: el('dlg-progress'), bestLine: el('dlg-best-line')
};
let session = null, index = 0, score = 0, seconds = 0, timer = null, running = false;
let finishItems = [];
let cur = null; // { task, attempts, solved, revealed, collect, lock, mark }
// ---------- Helfer ----------
const escapeHtml = (v) => String(v).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const formatTime = (s) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
function awarded(points, attempts) {
const a = Math.max(1, attempts);
return Math.round(points * (a === 1 ? 1 : a === 2 ? 0.6 : 0.3));
}
function starString(stars) {
const n = Math.max(0, Math.min(3, stars));
return '★'.repeat(n) + '☆'.repeat(3 - n);
}
function startTimer() { if (!timer) timer = setInterval(() => { seconds++; r.time.textContent = formatTime(seconds); }, 1000); }
function stopTimer() { clearInterval(timer); timer = null; }
function updateHud() {
r.taskNo.textContent = `${Math.min(index + 1, session.tasks.length)} / ${session.tasks.length}`;
r.score.textContent = String(score);
r.progress.style.width = `${Math.round((index / session.tasks.length) * 100)}%`;
}
// ---------- Renderer je Mechanik ----------
const RENDERERS = {
cloze(task) {
const inputs = {};
const pre = document.createElement('pre');
pre.className = 'dlg-code';
const code = document.createElement('code');
const tpl = task.codeTemplate || '';
const regex = /\{\{(\d+)\}\}/g;
const gapMap = Object.fromEntries((task.gaps || []).map((g) => [g.position, g]));
let last = 0, m;
while ((m = regex.exec(tpl)) !== null) {
if (m.index > last) code.appendChild(document.createTextNode(tpl.slice(last, m.index)));
const pos = Number(m[1]);
const gap = gapMap[pos] || {};
const input = document.createElement('input');
input.type = 'text'; input.className = 'dlg-gap'; input.dataset.pos = pos;
input.autocomplete = 'off'; input.autocapitalize = 'off'; input.spellcheck = false;
input.setAttribute('autocorrect', 'off');
if (gap.placeholder) input.placeholder = gap.placeholder;
input.size = Math.max(6, (gap.placeholder || '').length + 1);
input.addEventListener('input', () => { input.size = Math.max(4, input.value.length + 1); });
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); doCheck(false); } });
inputs[pos] = input;
code.appendChild(input);
last = m.index + m[0].length;
}
if (last < tpl.length) code.appendChild(document.createTextNode(tpl.slice(last)));
pre.appendChild(code);
r.playfield.appendChild(pre);
const first = Object.values(inputs)[0]; if (first) first.focus();
return {
collect: () => ({ gaps: Object.entries(inputs).map(([pos, i]) => ({ position: Number(pos), text: i.value })) }),
lock: () => Object.values(inputs).forEach((i) => { i.disabled = true; }),
mark: (data) => (data.detail || []).forEach((d) => {
const i = inputs[d.position]; if (!i) return;
i.classList.toggle('correct', d.correct); i.classList.toggle('wrong', !d.correct);
if (d.solution) { i.value = d.solution; i.classList.remove('wrong'); i.classList.add('revealed'); }
})
};
},
choice(task) {
let selected = null;
const wrap = document.createElement('div'); wrap.className = 'dlg-choices';
if (task.code) { const pre = document.createElement('pre'); pre.className = 'dlg-code'; pre.textContent = task.code; r.playfield.appendChild(pre); }
if (task.question) { const q = document.createElement('p'); q.className = 'dlg-question'; q.textContent = task.question; r.playfield.appendChild(q); }
(task.options || []).forEach((opt) => {
const b = document.createElement('button');
b.type = 'button'; b.className = 'dlg-choice'; b.textContent = opt;
b.addEventListener('click', () => {
if (cur.solved || cur.revealed) return;
selected = opt;
wrap.querySelectorAll('.dlg-choice').forEach((x) => x.classList.toggle('selected', x === b));
});
wrap.appendChild(b);
});
r.playfield.appendChild(wrap);
return {
collect: () => ({ choice: selected || '' }),
lock: () => wrap.querySelectorAll('.dlg-choice').forEach((b) => { b.disabled = true; }),
mark: (data) => {
wrap.querySelectorAll('.dlg-choice').forEach((b) => {
if (b.textContent === selected) b.classList.add(data.correct ? 'correct' : 'wrong');
if (data.solution && b.textContent === data.solution) b.classList.add('correct');
});
}
};
},
order(task) {
const answer = document.createElement('div'); answer.className = 'dlg-order-answer'; answer.dataset.empty = 'Hier in die richtige Reihenfolge klicken …';
const bank = document.createElement('div'); bank.className = 'dlg-order-bank';
function chip(text, inAnswer) {
const b = document.createElement('button'); b.type = 'button'; b.className = 'dlg-token'; b.textContent = text;
b.addEventListener('click', () => {
if (cur.solved || cur.revealed) return;
if (inAnswer) { bank.appendChild(chip(text, false)); b.remove(); }
else { answer.appendChild(chip(text, true)); b.remove(); }
answer.classList.toggle('filled', answer.children.length > 0);
});
return b;
}
(task.items || []).forEach((it) => bank.appendChild(chip(it, false)));
const wrap = document.createElement('div'); wrap.className = 'dlg-order';
const lblA = document.createElement('p'); lblA.className = 'dlg-mini'; lblA.textContent = 'Deine Reihenfolge:';
const lblB = document.createElement('p'); lblB.className = 'dlg-mini'; lblB.textContent = 'Bausteine:';
wrap.append(lblA, answer, lblB, bank);
r.playfield.appendChild(wrap);
return {
collect: () => ({ order: Array.from(answer.children).map((c) => c.textContent) }),
lock: () => wrap.querySelectorAll('.dlg-token').forEach((b) => { b.disabled = true; }),
mark: (data) => { if (data.solution) { const s = document.createElement('p'); s.className = 'dlg-mini reveal'; s.textContent = 'Lösung: ' + data.solution; wrap.appendChild(s); } }
};
},
spot(task) {
let selected = -1;
const box = document.createElement('div'); box.className = 'dlg-spot';
(task.tokens || []).forEach((tok, i) => {
const b = document.createElement('button'); b.type = 'button'; b.className = 'dlg-spot-token'; b.textContent = tok;
b.addEventListener('click', () => {
if (cur.solved || cur.revealed) return;
selected = i;
box.querySelectorAll('.dlg-spot-token').forEach((x, j) => x.classList.toggle('selected', j === i));
});
box.appendChild(b);
});
r.playfield.appendChild(box);
return {
collect: () => ({ index: selected }),
lock: () => box.querySelectorAll('.dlg-spot-token').forEach((b) => { b.disabled = true; }),
mark: (data) => {
if (data.correct && selected >= 0) box.children[selected].classList.add('correct');
else if (selected >= 0) box.children[selected].classList.add('wrong');
const sol = data.solution; if (sol && typeof sol.index === 'number' && box.children[sol.index]) box.children[sol.index].classList.add('correct');
}
};
},
match(task) {
const left = task.left || [], right = task.right || [];
const conn = {}; // leftIndex -> rightIndex
let activeLeft = -1;
const wrap = document.createElement('div'); wrap.className = 'dlg-match';
const colL = document.createElement('div'); colL.className = 'dlg-match-col';
const colR = document.createElement('div'); colR.className = 'dlg-match-col';
function render() {
wrap.querySelectorAll('.dlg-match-item').forEach((node) => {
const side = node.dataset.side, i = Number(node.dataset.i);
node.classList.toggle('active', side === 'l' && i === activeLeft);
let badge = '';
if (side === 'l' && conn[i] != null) badge = String(Object.keys(conn).indexOf(String(i)) + 1);
if (side === 'r') { const li = Object.keys(conn).find((k) => conn[k] === i); if (li != null) badge = String(Object.keys(conn).indexOf(li) + 1); }
node.classList.toggle('connected', !!badge);
node.querySelector('.dlg-match-badge').textContent = badge;
});
}
function item(text, side, i) {
const b = document.createElement('button'); b.type = 'button'; b.className = 'dlg-match-item'; b.dataset.side = side; b.dataset.i = i;
b.innerHTML = `<span>${escapeHtml(text)}</span><span class="dlg-match-badge"></span>`;
b.addEventListener('click', () => {
if (cur.solved || cur.revealed) return;
if (side === 'l') { activeLeft = (conn[i] != null) ? (delete conn[i], -1) : i; }
else if (activeLeft >= 0) { Object.keys(conn).forEach((k) => { if (conn[k] === i) delete conn[k]; }); conn[activeLeft] = i; activeLeft = -1; }
render();
});
return b;
}
left.forEach((t, i) => colL.appendChild(item(t, 'l', i)));
right.forEach((t, i) => colR.appendChild(item(t, 'r', i)));
wrap.append(colL, colR); r.playfield.appendChild(wrap);
return {
collect: () => ({ pairs: Object.entries(conn).map(([li, ri]) => [left[li], right[ri]]) }),
lock: () => wrap.querySelectorAll('.dlg-match-item').forEach((b) => { b.disabled = true; }),
mark: (data) => {
if (data.solution) { const s = document.createElement('p'); s.className = 'dlg-mini reveal'; s.textContent = 'Lösung: ' + data.solution.map((p) => `${p[0]} → ${p[1]}`).join(' · '); wrap.parentElement.appendChild(s); }
}
};
},
regex(task) {
const input = document.createElement('input');
input.type = 'text'; input.className = 'dlg-regex-input'; input.placeholder = 'Regex-Muster …';
input.autocomplete = 'off'; input.spellcheck = false;
const list = document.createElement('div'); list.className = 'dlg-regex-samples';
const samples = task.samples || [];
function preview() {
let pat = null;
try { pat = input.value ? new RegExp(input.value) : null; } catch (e) { pat = null; }
list.innerHTML = samples.map((s) => {
const matches = pat ? pat.test(s.text) : false;
const ok = matches === s.shouldMatch;
const want = s.shouldMatch ? 'soll matchen' : 'soll nicht matchen';
return `<span class="dlg-sample ${ok ? 'ok' : 'bad'}">${escapeHtml(s.text === '' ? '∅ (leer)' : s.text)}<small>${want}</small></span>`;
}).join('');
}
input.addEventListener('input', preview);
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); doCheck(false); } });
const wrap = document.createElement('div'); wrap.className = 'dlg-regex';
wrap.append(input, list); r.playfield.appendChild(wrap); preview(); input.focus();
return {
collect: () => ({ pattern: input.value }),
lock: () => { input.disabled = true; },
mark: (data) => { if (data.solution) { const s = document.createElement('p'); s.className = 'dlg-mini reveal'; s.textContent = 'Lösung: ' + data.solution; wrap.appendChild(s); } }
};
}
};
// ---------- Ablauf ----------
function renderTask() {
const task = session.tasks[index];
cur = { task, attempts: 0, solved: false, revealed: false };
r.kind.textContent = `Aufgabe ${index + 1}`;
r.mech.textContent = MECH_LABEL[task.type] || task.type;
r.points.textContent = String(task.points || 100);
r.prompt.textContent = task.prompt || '';
r.narrative.textContent = task.narrative || '';
r.narrative.hidden = !task.narrative;
r.instruction.textContent = task.instruction || '';
r.instruction.hidden = !task.instruction;
r.feedback.textContent = ''; r.feedback.className = 'dlg-feedback';
r.success.hidden = true; r.success.textContent = '';
r.check.hidden = false; r.hint.hidden = !task.hint; r.reveal.hidden = true; r.next.hidden = true;
r.playfield.innerHTML = '';
const renderer = RENDERERS[task.type];
cur.ctrl = renderer ? renderer(task) : { collect: () => ({}), lock: () => {}, mark: () => {} };
updateHud();
}
async function doCheck(reveal) {
if (!running || !cur || cur.solved) return;
if (!reveal) cur.attempts++;
const submission = cur.ctrl.collect();
let data;
try {
const res = await fetch('/api/devlab/check', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ taskId: cur.task.taskId, reveal, submission })
});
data = await res.json();
} catch (err) { r.feedback.textContent = 'Prüfung fehlgeschlagen.'; r.feedback.className = 'dlg-feedback wrong'; return; }
cur.lastSubmission = submission;
cur.ctrl.mark(data);
if (data.correct) {
cur.solved = true;
score += awarded(cur.task.points || 100, cur.attempts);
cur.ctrl.lock();
r.feedback.textContent = 'Richtig. Sitzt!'; r.feedback.className = 'dlg-feedback correct';
finishMissionUi(data, false);
} else if (reveal) {
cur.revealed = true;
cur.ctrl.lock();
r.feedback.textContent = 'Aufgelöst – schau dir die Lösung an, dann weiter.'; r.feedback.className = 'dlg-feedback revealed';
finishMissionUi(data, true);
} else {
const hint = cur.task.hint ? ` Hinweis: ${cur.task.hint}` : '';
r.feedback.textContent = (data.error || 'Noch nicht ganz.') + hint; r.feedback.className = 'dlg-feedback wrong';
if (cur.attempts >= MAX_ATTEMPTS) r.reveal.hidden = false;
}
updateHud();
}
function finishMissionUi(data, revealed) {
r.check.hidden = true; r.hint.hidden = true; r.reveal.hidden = true; r.next.hidden = false;
if (data.successText) { r.success.textContent = data.successText; r.success.hidden = false; }
r.next.focus();
}
function showHint() {
if (!cur || !cur.task.hint) return;
r.feedback.textContent = `Hinweis: ${cur.task.hint}`; r.feedback.className = 'dlg-feedback revealed';
score = Math.max(0, score - 10); updateHud();
}
function nextTask() {
finishItems.push({ taskId: cur.task.taskId, submission: cur.lastSubmission || cur.ctrl.collect(), attempts: cur.attempts || 1, revealed: cur.revealed });
index++;
if (index >= session.tasks.length) finish();
else renderTask();
}
async function finish() {
running = false; stopTimer();
try {
const res = await fetch('/api/devlab/finish', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug, durationSeconds: seconds, items: finishItems })
});
const data = await res.json();
score = data.score; updateHud();
r.resultStars.textContent = starString(data.stars);
r.resultScore.textContent = String(data.score);
r.resultMax.textContent = String(data.maxScore);
r.resultDetail.textContent = `${data.solvedTasks} von ${data.totalTasks} auf Anhieb · ${formatTime(seconds)}`;
r.resultHigh.hidden = !data.newHighscore;
r.best.textContent = String(data.highscore);
r.result.hidden = false;
} catch (err) { console.error('Abschluss fehlgeschlagen', err); }
}
async function loadStats() {
try {
const res = await fetch(`/api/games/${GAME}/stats`);
if (!res.ok) return;
const s = await res.json();
r.best.textContent = s.highscore > 0 ? String(s.highscore) : '–';
r.bestLine.textContent = s.highscore > 0 ? `Dev-Lab-Bestwert: ${s.highscore} Punkte` : 'Noch kein Dev-Lab-Bestwert.';
} catch (err) { /* egal */ }
}
function start() {
index = 0; score = 0; seconds = 0; finishItems = []; r.time.textContent = '0:00'; r.result.hidden = true;
running = true; renderTask(); startTimer();
}
async function init() {
try {
const res = await fetch(`/api/devlab/games/${encodeURIComponent(slug)}/session`);
session = await res.json();
} catch (err) { session = null; }
if (!session || !session.found || !session.tasks || !session.tasks.length) {
r.title.textContent = 'Spiel nicht gefunden';
r.prompt.textContent = 'Für diese Disziplin gibt es noch keine Aufgaben.';
r.check.hidden = true; r.hint.hidden = true;
return;
}
document.title = `${session.title} – SysnaptiK Neural`;
r.title.textContent = session.title;
r.language.textContent = session.language || 'Dev Lab';
r.sideTitle.textContent = session.title;
r.description.textContent = session.description || '';
loadStats();
start();
}
r.check.addEventListener('click', () => doCheck(false));
r.hint.addEventListener('click', showHint);
r.reveal.addEventListener('click', () => doCheck(true));
r.next.addEventListener('click', nextTask);
r.again.addEventListener('click', start);
init();
})();