Syntax Saga: neues Spiel (server-autoritativ) + 34 Seed-Missionen
Drittes Mindflux-Spiel: interaktive IT-Geschichten, in denen man den fehlenden Code (Schlüsselwörter, Operatoren, ganze Zeilen) in eine kleine Story tippt. Freitext-Eingabe, drei wählbare Stufen (Junior/Professional/ Senior), mehrere Lücken je Codeblock und mehrere gültige Antworten je Lücke (optional case-insensitive). Prüfung und Score ausschließlich serverseitig – die Lösungen verlassen nie das Backend (Anti-Cheat wie TechTacular/MindMatch). Fehler-Mechanik: bis zu 3 Versuche mit Hinweis, dann Auflösung; kein Game-Over. Inhalte DB-gestützt und im Admin pflegbar (/admin/syntaxsaga); 34 Seed- Missionen (Java/SQL/Bash/Git/HTML/Python) aus syntaxsaga/content.json. - Entities SyntaxSagaMission/Gap/Solution + Flyway V7__syntax_saga.sql - SyntaxSagaService (Story ohne Lösung / serverseitige Prüfung / Score-Recompute) - API /api/syntaxsaga (CSRF-ausgenommen) + Admin-CRUD - Frontend syntaxsaga.html/js/css (Prefix ss-, theme.css-Tokens) - Dashboard-Kachel aktiv (vorher "coming soon") Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
-- Syntax Saga: interaktive IT-Geschichten mit Code-Lücken (Junior/Professional/Senior).
|
||||
-- Mission -> Lücken (eine oder mehrere je Codeblock) -> Lösungen (eine Lücke kann mehrere
|
||||
-- gültige Antworten haben). Inhalte DB-basiert und Admin-pflegbar; gefüllt per Seeder
|
||||
-- aus syntaxsaga/content.json. Server-autoritativ: Lösungen verlassen nie das Backend.
|
||||
|
||||
CREATE TABLE syntax_saga_missions (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
slug VARCHAR(150) NOT NULL UNIQUE,
|
||||
title VARCHAR(200) NOT NULL,
|
||||
difficulty VARCHAR(30) NOT NULL,
|
||||
narrative VARCHAR(2000) NOT NULL,
|
||||
dialog VARCHAR(1000),
|
||||
code_language VARCHAR(30),
|
||||
code_template VARCHAR(2000) NOT NULL,
|
||||
success_text VARCHAR(2000),
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE
|
||||
);
|
||||
|
||||
CREATE TABLE syntax_saga_gaps (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
mission_id BIGINT NOT NULL REFERENCES syntax_saga_missions (id) ON DELETE CASCADE,
|
||||
gap_position INT NOT NULL,
|
||||
points INT NOT NULL DEFAULT 100,
|
||||
case_sensitive BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
hint VARCHAR(500),
|
||||
placeholder VARCHAR(120)
|
||||
);
|
||||
|
||||
CREATE TABLE syntax_saga_solutions (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
gap_id BIGINT NOT NULL REFERENCES syntax_saga_gaps (id) ON DELETE CASCADE,
|
||||
solution_text VARCHAR(500) NOT NULL,
|
||||
sort_order INT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX idx_syntax_saga_gaps_mission ON syntax_saga_gaps (mission_id);
|
||||
CREATE INDEX idx_syntax_saga_solutions_gap ON syntax_saga_solutions (gap_id);
|
||||
@@ -0,0 +1,247 @@
|
||||
/* === Syntax Saga === Interaktive IT-Geschichten mit Code-Lücken.
|
||||
Farben/Tokens zentral aus theme.css (richtig = Blau, falsch = Rot, Akzent = Amber).
|
||||
Anders als TechTacular ist der Inhalt scrollbar (Story + Codeblock können hoch werden). */
|
||||
|
||||
@keyframes ssFadeInUp {
|
||||
0% { opacity: 0; transform: translateY(16px); }
|
||||
100% { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
body.ss-body {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.ss-shell {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
max-width: 980px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.7rem 0.9rem 1.4rem;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
/* --- Kopf + Leiste --- */
|
||||
.ss-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 1.1rem;
|
||||
background-color: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--g-soft), 0 0 8px var(--glow-w);
|
||||
}
|
||||
.ss-header-tools { display: flex; align-items: center; gap: 0.7rem; }
|
||||
.ss-title { font-size: 1.3rem; color: var(--accent); margin: 0; text-shadow: 0 0 8px var(--glow-s); }
|
||||
.ss-back, .ss-logout { text-decoration: none; font-weight: bold; }
|
||||
.ss-back { color: var(--accent); }
|
||||
.ss-logout { color: var(--danger); }
|
||||
|
||||
.ss-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.55rem 0.85rem;
|
||||
}
|
||||
.ss-bar-group { display: flex; align-items: center; flex-wrap: wrap; gap: 0.5rem; }
|
||||
.ss-bar-label { color: var(--muted); font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.06em; }
|
||||
|
||||
.ss-diff {
|
||||
padding: 0.45rem 0.9rem;
|
||||
border: 1px solid var(--border-accent);
|
||||
border-radius: 999px;
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease, color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
.ss-diff:hover { border-color: var(--accent); }
|
||||
.ss-diff.active {
|
||||
background: linear-gradient(100deg, var(--accent), var(--accent-2));
|
||||
color: var(--accent-ink);
|
||||
box-shadow: 0 0 16px var(--glow-s);
|
||||
}
|
||||
|
||||
/* --- HUD --- */
|
||||
.ss-hud { gap: 0.4rem; }
|
||||
.ss-stat {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.4rem;
|
||||
padding: 0.35rem 0.7rem;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ss-stat b { font-size: 1.05rem; color: var(--accent); }
|
||||
|
||||
/* --- Bühne / Szene --- */
|
||||
.ss-stage { flex: 1; display: flex; flex-direction: column; }
|
||||
.ss-idle { margin: auto; color: var(--muted); text-align: center; padding: 2.4rem 1rem; max-width: 540px; line-height: 1.6; }
|
||||
|
||||
.ss-scene {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-accent);
|
||||
border-radius: 16px;
|
||||
padding: 1.3rem 1.5rem 1.5rem;
|
||||
box-shadow: 0 0 24px var(--glow-w);
|
||||
animation: ssFadeInUp 0.35s ease-out;
|
||||
}
|
||||
|
||||
.ss-scene-head { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 0.5rem; }
|
||||
.ss-mission-no { color: var(--muted); font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.07em; font-weight: 700; }
|
||||
.ss-badge {
|
||||
font-size: 0.66rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.16rem 0.5rem;
|
||||
border-radius: 7px;
|
||||
border: 1px solid var(--border-accent);
|
||||
}
|
||||
.ss-badge-diff { background: color-mix(in srgb, var(--accent) 14%, transparent); color: var(--accent); }
|
||||
.ss-badge-lang { background: color-mix(in srgb, var(--secondary) 16%, transparent); color: var(--secondary); border-color: var(--secondary); font-family: monospace; }
|
||||
|
||||
.ss-mission-title { color: var(--heading); font-size: 1.4rem; font-weight: 800; margin: 0.1rem 0 0.6rem; }
|
||||
.ss-narrative { color: var(--text); line-height: 1.6; margin-bottom: 0.6rem; }
|
||||
.ss-dialog {
|
||||
color: var(--accent);
|
||||
font-style: italic;
|
||||
border-left: 3px solid var(--accent);
|
||||
padding-left: 0.8rem;
|
||||
margin-bottom: 0.9rem;
|
||||
text-shadow: 0 0 10px var(--glow-w);
|
||||
}
|
||||
|
||||
/* --- Codeblock mit Lücken --- */
|
||||
.ss-code {
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1rem 1.1rem;
|
||||
margin: 0.4rem 0 0.9rem;
|
||||
overflow-x: auto;
|
||||
box-shadow: inset 0 0 22px var(--glow-w);
|
||||
}
|
||||
.ss-code code {
|
||||
font-family: "JetBrains Mono", "Fira Code", "Consolas", "Courier New", monospace;
|
||||
font-size: 0.95rem;
|
||||
line-height: 2;
|
||||
white-space: pre;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.ss-gap {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
||||
border: 1px solid var(--border-accent);
|
||||
border-radius: 5px;
|
||||
padding: 0.05rem 0.35rem;
|
||||
margin: 0 0.12rem;
|
||||
min-width: 3ch;
|
||||
vertical-align: baseline;
|
||||
transition: border-color 0.2s ease, background 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
.ss-gap::placeholder { color: var(--muted); opacity: 0.7; }
|
||||
.ss-gap:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 2px var(--glow-w); background: color-mix(in srgb, var(--accent) 16%, transparent); }
|
||||
.ss-gap.correct { color: var(--success); border-color: var(--success); background: color-mix(in srgb, var(--success) 16%, transparent); }
|
||||
.ss-gap.wrong { color: var(--danger); border-color: var(--danger); background: color-mix(in srgb, var(--danger) 16%, transparent); }
|
||||
.ss-gap.revealed { color: var(--secondary); border-color: var(--secondary); background: color-mix(in srgb, var(--secondary) 14%, transparent); }
|
||||
.ss-gap:disabled { cursor: default; }
|
||||
|
||||
/* --- Feedback / Erfolg --- */
|
||||
.ss-feedback { min-height: 1.3rem; font-weight: 700; margin-bottom: 0.5rem; line-height: 1.5; }
|
||||
.ss-feedback.correct { color: var(--success); }
|
||||
.ss-feedback.correct::before { content: "\2713 "; font-weight: 800; }
|
||||
.ss-feedback.wrong { color: var(--danger); }
|
||||
.ss-feedback.wrong::before { content: "\2717 "; font-weight: 800; }
|
||||
.ss-feedback.revealed { color: var(--secondary); }
|
||||
|
||||
.ss-success {
|
||||
color: var(--secondary);
|
||||
background: color-mix(in srgb, var(--secondary) 10%, transparent);
|
||||
border-left: 3px solid var(--secondary);
|
||||
border-radius: 6px;
|
||||
padding: 0.7rem 0.9rem;
|
||||
margin-bottom: 0.8rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.ss-success[hidden] { display: none; }
|
||||
|
||||
/* --- Buttons --- */
|
||||
.ss-scene-actions { display: flex; gap: 0.7rem; flex-wrap: wrap; }
|
||||
.ss-btn {
|
||||
padding: 0.55rem 1.2rem;
|
||||
border: 0;
|
||||
border-radius: 9px;
|
||||
background: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
font-weight: 800;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 0 16px var(--glow-w);
|
||||
transition: filter 0.2s ease, transform 0.15s ease;
|
||||
}
|
||||
.ss-btn:hover { filter: brightness(1.07); transform: translateY(-2px); }
|
||||
.ss-btn[hidden] { display: none; }
|
||||
.ss-btn-secondary { background: var(--surface-2); border: 1px solid var(--border-accent); color: var(--text); box-shadow: none; }
|
||||
.ss-btn-ghost { background: transparent; border: 1px solid var(--border-accent); color: var(--accent); box-shadow: none; }
|
||||
.ss-btn-ghost:hover { background: var(--surface-2); }
|
||||
|
||||
/* --- Ergebnis-Overlay --- */
|
||||
.ss-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-glow), rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 1000;
|
||||
padding: 1rem;
|
||||
}
|
||||
.ss-overlay[hidden] { display: none; }
|
||||
.ss-result-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-accent);
|
||||
border-radius: 16px;
|
||||
padding: 1.6rem 1.8rem;
|
||||
width: min(440px, 94vw);
|
||||
text-align: center;
|
||||
box-shadow: 0 0 30px var(--glow-w);
|
||||
animation: ssFadeInUp 0.4s ease-out;
|
||||
}
|
||||
.ss-result-card h2 { color: var(--accent); margin-bottom: 0.4rem; }
|
||||
.ss-result-stars { font-size: 2rem; color: var(--accent); letter-spacing: 0.2rem; margin-bottom: 0.5rem; text-shadow: 0 0 12px var(--glow-s); }
|
||||
.ss-result-score { font-size: 1.4rem; margin-bottom: 0.4rem; color: var(--text); }
|
||||
.ss-result-score strong { color: var(--accent); }
|
||||
.ss-result-detail { color: var(--muted); margin-bottom: 0.6rem; }
|
||||
.ss-result-high { color: var(--accent); font-weight: 800; margin-bottom: 1rem; }
|
||||
.ss-result-high[hidden] { display: none; }
|
||||
.ss-result-actions { display: flex; flex-wrap: wrap; gap: 0.7rem; justify-content: center; margin-top: 1rem; }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.ss-title { font-size: 1.15rem; }
|
||||
.ss-stat { font-size: 0.62rem; padding: 0.3rem 0.5rem; }
|
||||
.ss-scene { padding: 1.1rem 1rem 1.2rem; }
|
||||
.ss-mission-title { font-size: 1.2rem; }
|
||||
.ss-code code { font-size: 0.85rem; }
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
/* Syntax Saga – interaktive IT-Geschichten mit Code-Lücken.
|
||||
Story kommt aus /api/syntaxsaga/story (ohne Lösung), Prüfung + Score serverseitig. */
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
const GAME = 'syntax-saga';
|
||||
const MAX_ATTEMPTS = 3;
|
||||
|
||||
const storyEl = document.getElementById('ss-story');
|
||||
const diffBtns = Array.from(document.querySelectorAll('.ss-diff'));
|
||||
const scoreEl = document.getElementById('ss-score');
|
||||
const missionEl = document.getElementById('ss-mission');
|
||||
const timeEl = document.getElementById('ss-time');
|
||||
const bestEl = document.getElementById('ss-best');
|
||||
|
||||
const resultEl = document.getElementById('ss-result');
|
||||
const resultStars = document.getElementById('ss-result-stars');
|
||||
const resultScore = document.getElementById('ss-result-score');
|
||||
const resultMax = document.getElementById('ss-result-max');
|
||||
const resultDetail = document.getElementById('ss-result-detail');
|
||||
const resultHigh = document.getElementById('ss-result-high');
|
||||
const againBtn = document.getElementById('ss-again');
|
||||
const chooseBtn = document.getElementById('ss-choose');
|
||||
|
||||
const DIFF_LABEL = { junior: 'Junior', professional: 'Professional', senior: 'Senior' };
|
||||
|
||||
let story = null; // StoryView vom Server
|
||||
let missionIndex = 0;
|
||||
let finishGaps = []; // gesammelte Lücken über alle Missionen -> /finish
|
||||
let score = 0; // laufender Client-Schätzwert (spiegelt die Serverformel)
|
||||
let seconds = 0;
|
||||
let timerId = null;
|
||||
let running = false;
|
||||
|
||||
let cur = null; // Laufzeitzustand der aktuellen Mission
|
||||
let curInputs = {}; // position -> <input>
|
||||
let curGaps = {}; // position -> Gap-Daten (points/hint/placeholder)
|
||||
|
||||
// ---------- Helfer ----------
|
||||
|
||||
function formatTime(s) {
|
||||
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function startTimer() {
|
||||
if (timerId) return;
|
||||
timerId = setInterval(() => { seconds++; timeEl.textContent = formatTime(seconds); }, 1000);
|
||||
}
|
||||
function stopTimer() { clearInterval(timerId); timerId = null; }
|
||||
|
||||
/** Punkte aus Versuch (Erstversuch voll, danach weniger) – identisch zur Serverformel. */
|
||||
function awarded(points, attempts) {
|
||||
const a = Math.max(1, attempts);
|
||||
const factor = a === 1 ? 1.0 : a === 2 ? 0.6 : 0.3;
|
||||
return Math.round(points * factor);
|
||||
}
|
||||
|
||||
function setIdle(message) {
|
||||
storyEl.innerHTML = `<p class="ss-idle">${message}</p>`;
|
||||
}
|
||||
|
||||
function updateHud() {
|
||||
scoreEl.textContent = String(score);
|
||||
if (story && story.missions.length) {
|
||||
missionEl.textContent = `${Math.min(missionIndex + 1, story.missions.length)} / ${story.missions.length}`;
|
||||
} else {
|
||||
missionEl.textContent = '– / –';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Code-Rendering ----------
|
||||
|
||||
/** Baut <code>-Inhalt: Textsegmente als Textknoten, {{n}} als Eingabefelder. */
|
||||
function renderCode(codeEl, template) {
|
||||
codeEl.textContent = '';
|
||||
curInputs = {};
|
||||
const regex = /\{\{(\d+)\}\}/g;
|
||||
let last = 0;
|
||||
let m;
|
||||
while ((m = regex.exec(template)) !== null) {
|
||||
if (m.index > last) {
|
||||
codeEl.appendChild(document.createTextNode(template.slice(last, m.index)));
|
||||
}
|
||||
const pos = parseInt(m[1], 10);
|
||||
const gap = curGaps[pos];
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.className = 'ss-gap';
|
||||
input.dataset.pos = String(pos);
|
||||
input.autocomplete = 'off';
|
||||
input.autocapitalize = 'off';
|
||||
input.spellcheck = false;
|
||||
input.setAttribute('autocorrect', 'off');
|
||||
if (gap && gap.placeholder) input.placeholder = gap.placeholder;
|
||||
input.size = Math.max(6, gap && gap.placeholder ? gap.placeholder.length : 8);
|
||||
input.addEventListener('input', () => {
|
||||
input.size = Math.max(4, input.value.length + 1);
|
||||
});
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); onCheck(false); }
|
||||
});
|
||||
curInputs[pos] = input;
|
||||
codeEl.appendChild(input);
|
||||
last = m.index + m[0].length;
|
||||
}
|
||||
if (last < template.length) {
|
||||
codeEl.appendChild(document.createTextNode(template.slice(last)));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Mission rendern ----------
|
||||
|
||||
function renderMission() {
|
||||
const mission = story.missions[missionIndex];
|
||||
cur = { mission, attempts: {}, revealed: {}, solved: {}, scored: {} };
|
||||
curGaps = {};
|
||||
(mission.gaps || []).forEach((g) => { curGaps[g.position] = g; });
|
||||
|
||||
storyEl.innerHTML = '';
|
||||
const scene = document.createElement('div');
|
||||
scene.className = 'ss-scene';
|
||||
|
||||
// Kopf
|
||||
const head = document.createElement('div');
|
||||
head.className = 'ss-scene-head';
|
||||
const no = document.createElement('span');
|
||||
no.className = 'ss-mission-no';
|
||||
no.textContent = `Mission ${missionIndex + 1} / ${story.missions.length}`;
|
||||
head.appendChild(no);
|
||||
const diff = document.createElement('span');
|
||||
diff.className = 'ss-badge ss-badge-diff';
|
||||
diff.textContent = DIFF_LABEL[mission.difficulty] || mission.difficulty;
|
||||
head.appendChild(diff);
|
||||
if (mission.language) {
|
||||
const lang = document.createElement('span');
|
||||
lang.className = 'ss-badge ss-badge-lang';
|
||||
lang.textContent = mission.language;
|
||||
head.appendChild(lang);
|
||||
}
|
||||
scene.appendChild(head);
|
||||
|
||||
// Titel + Story
|
||||
const title = document.createElement('h2');
|
||||
title.className = 'ss-mission-title';
|
||||
title.textContent = mission.title;
|
||||
scene.appendChild(title);
|
||||
|
||||
if (mission.narrative) {
|
||||
const nar = document.createElement('p');
|
||||
nar.className = 'ss-narrative';
|
||||
nar.textContent = mission.narrative;
|
||||
scene.appendChild(nar);
|
||||
}
|
||||
if (mission.dialog) {
|
||||
const dlg = document.createElement('p');
|
||||
dlg.className = 'ss-dialog';
|
||||
dlg.textContent = `„${mission.dialog}“`;
|
||||
scene.appendChild(dlg);
|
||||
}
|
||||
|
||||
// Codeblock mit Lücken
|
||||
const pre = document.createElement('pre');
|
||||
pre.className = 'ss-code';
|
||||
const code = document.createElement('code');
|
||||
renderCode(code, mission.codeTemplate || '');
|
||||
pre.appendChild(code);
|
||||
scene.appendChild(pre);
|
||||
|
||||
// Feedback
|
||||
const feedback = document.createElement('p');
|
||||
feedback.className = 'ss-feedback';
|
||||
scene.appendChild(feedback);
|
||||
|
||||
// Erfolgstext (nach Lösen)
|
||||
const success = document.createElement('p');
|
||||
success.className = 'ss-success';
|
||||
success.hidden = true;
|
||||
scene.appendChild(success);
|
||||
|
||||
// Aktionen
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'ss-scene-actions';
|
||||
const checkBtn = document.createElement('button');
|
||||
checkBtn.type = 'button';
|
||||
checkBtn.className = 'ss-btn';
|
||||
checkBtn.textContent = 'Prüfen';
|
||||
checkBtn.addEventListener('click', () => onCheck(false));
|
||||
const revealBtn = document.createElement('button');
|
||||
revealBtn.type = 'button';
|
||||
revealBtn.className = 'ss-btn ss-btn-ghost';
|
||||
revealBtn.textContent = 'Auflösung anzeigen';
|
||||
revealBtn.hidden = true;
|
||||
revealBtn.addEventListener('click', () => onCheck(true));
|
||||
const nextBtn = document.createElement('button');
|
||||
nextBtn.type = 'button';
|
||||
nextBtn.className = 'ss-btn';
|
||||
nextBtn.textContent = missionIndex + 1 < story.missions.length ? 'Weiter' : 'Saga abschließen';
|
||||
nextBtn.hidden = true;
|
||||
nextBtn.addEventListener('click', nextMission);
|
||||
actions.appendChild(checkBtn);
|
||||
actions.appendChild(revealBtn);
|
||||
actions.appendChild(nextBtn);
|
||||
scene.appendChild(actions);
|
||||
|
||||
cur.els = { scene, feedback, success, checkBtn, revealBtn, nextBtn };
|
||||
|
||||
storyEl.appendChild(scene);
|
||||
updateHud();
|
||||
const first = Object.values(curInputs)[0];
|
||||
if (first) first.focus();
|
||||
}
|
||||
|
||||
// ---------- Prüfen / Auflösen ----------
|
||||
|
||||
async function onCheck(reveal) {
|
||||
if (!running || !cur) return;
|
||||
const gaps = cur.mission.gaps || [];
|
||||
|
||||
if (!reveal) {
|
||||
gaps.forEach((g) => {
|
||||
if (!cur.solved[g.position] && !cur.revealed[g.position]) {
|
||||
cur.attempts[g.position] = (cur.attempts[g.position] || 0) + 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const answers = gaps.map((g) => ({
|
||||
position: g.position,
|
||||
text: (curInputs[g.position] && curInputs[g.position].value) || ''
|
||||
}));
|
||||
|
||||
let data;
|
||||
try {
|
||||
const res = await fetch('/api/syntaxsaga/check', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ missionId: cur.mission.missionId, reveal, answers })
|
||||
});
|
||||
data = await res.json();
|
||||
} catch (err) {
|
||||
cur.els.feedback.textContent = 'Antwort konnte nicht geprüft werden.';
|
||||
cur.els.feedback.className = 'ss-feedback wrong';
|
||||
console.error(err);
|
||||
return;
|
||||
}
|
||||
|
||||
applyVerdicts(data, reveal);
|
||||
refreshSceneState();
|
||||
}
|
||||
|
||||
function applyVerdicts(data, reveal) {
|
||||
(data.results || []).forEach((v) => {
|
||||
const input = curInputs[v.position];
|
||||
if (!input) return;
|
||||
if (cur.solved[v.position] || cur.revealed[v.position]) return;
|
||||
|
||||
if (v.correct) {
|
||||
input.classList.add('correct');
|
||||
input.classList.remove('wrong');
|
||||
input.disabled = true;
|
||||
cur.solved[v.position] = true;
|
||||
if (!cur.scored[v.position]) {
|
||||
cur.scored[v.position] = true;
|
||||
const gap = curGaps[v.position];
|
||||
if (gap) { score += awarded(gap.points, cur.attempts[v.position] || 1); }
|
||||
}
|
||||
} else if (reveal && v.solution) {
|
||||
input.value = v.solution;
|
||||
input.classList.add('revealed');
|
||||
input.classList.remove('wrong');
|
||||
input.disabled = true;
|
||||
cur.revealed[v.position] = true;
|
||||
} else {
|
||||
input.classList.add('wrong');
|
||||
}
|
||||
});
|
||||
updateHud();
|
||||
}
|
||||
|
||||
function refreshSceneState() {
|
||||
const gaps = cur.mission.gaps || [];
|
||||
const open = gaps.filter((g) => !cur.solved[g.position] && !cur.revealed[g.position]);
|
||||
const fb = cur.els.feedback;
|
||||
|
||||
if (open.length === 0) {
|
||||
// Mission gelöst (oder aufgelöst)
|
||||
cur.els.checkBtn.hidden = true;
|
||||
cur.els.revealBtn.hidden = true;
|
||||
cur.els.nextBtn.hidden = false;
|
||||
const anyRevealed = gaps.some((g) => cur.revealed[g.position]);
|
||||
fb.textContent = anyRevealed ? 'Aufgelöst – weiter geht die Geschichte.' : 'Richtig! Sauber gelöst.';
|
||||
fb.className = anyRevealed ? 'ss-feedback revealed' : 'ss-feedback correct';
|
||||
if (cur.mission.successText) {
|
||||
cur.els.success.textContent = cur.mission.successText;
|
||||
cur.els.success.hidden = false;
|
||||
}
|
||||
cur.els.nextBtn.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
// noch offene Lücken
|
||||
const hints = open.map((g) => curGaps[g.position] && curGaps[g.position].hint).filter(Boolean);
|
||||
fb.className = 'ss-feedback wrong';
|
||||
fb.textContent = hints.length ? `Noch nicht ganz. Tipp: ${hints.join(' · ')}` : 'Noch nicht ganz – versuch es noch einmal.';
|
||||
|
||||
const exhausted = open.some((g) => (cur.attempts[g.position] || 0) >= MAX_ATTEMPTS);
|
||||
cur.els.revealBtn.hidden = !exhausted;
|
||||
if (exhausted) {
|
||||
fb.textContent = hints.length
|
||||
? `Versuche aufgebraucht. Tipp: ${hints.join(' · ')} – oder zeig dir die Auflösung.`
|
||||
: 'Versuche aufgebraucht – zeig dir die Auflösung.';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Navigation ----------
|
||||
|
||||
function collectCurrentMission() {
|
||||
(cur.mission.gaps || []).forEach((g) => {
|
||||
finishGaps.push({
|
||||
missionId: cur.mission.missionId,
|
||||
position: g.position,
|
||||
text: (curInputs[g.position] && curInputs[g.position].value) || '',
|
||||
attempts: cur.attempts[g.position] || 1,
|
||||
revealed: !!cur.revealed[g.position]
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function nextMission() {
|
||||
collectCurrentMission();
|
||||
missionIndex++;
|
||||
if (missionIndex < story.missions.length) {
|
||||
renderMission();
|
||||
} else {
|
||||
finishRun();
|
||||
}
|
||||
}
|
||||
|
||||
async function finishRun() {
|
||||
running = false;
|
||||
stopTimer();
|
||||
try {
|
||||
const res = await fetch('/api/syntaxsaga/finish', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
difficulty: story.difficulty,
|
||||
durationSeconds: seconds,
|
||||
gaps: finishGaps
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
score = data.score;
|
||||
updateHud();
|
||||
const stars = Math.max(0, Math.min(3, data.stars));
|
||||
resultStars.textContent = '★'.repeat(stars) + '☆'.repeat(3 - stars);
|
||||
resultScore.textContent = String(data.score);
|
||||
resultMax.textContent = String(data.maxScore);
|
||||
resultDetail.textContent =
|
||||
`${data.solvedGaps} von ${data.totalGaps} Lücken auf Anhieb · ${formatTime(seconds)}`;
|
||||
resultHigh.hidden = !data.newHighscore;
|
||||
bestEl.textContent = String(data.highscore);
|
||||
resultEl.hidden = false;
|
||||
} catch (err) {
|
||||
console.error('Saga konnte nicht gespeichert werden:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Start / Stats ----------
|
||||
|
||||
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) { /* egal */ }
|
||||
}
|
||||
|
||||
async function startRun(difficulty) {
|
||||
resultEl.hidden = true;
|
||||
stopTimer();
|
||||
seconds = 0; score = 0; missionIndex = 0; finishGaps = [];
|
||||
timeEl.textContent = '0:00';
|
||||
diffBtns.forEach((b) => b.classList.toggle('active', b.dataset.diff === difficulty));
|
||||
setIdle('Lade Mission …');
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/syntaxsaga/story?difficulty=' + encodeURIComponent(difficulty));
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
story = await res.json();
|
||||
} catch (err) {
|
||||
console.error('Story konnte nicht geladen werden:', err);
|
||||
setIdle('Story konnte nicht geladen werden.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!story.missions || !story.missions.length) {
|
||||
setIdle('Für diese Stufe gibt es noch keine Missionen. Ein Admin kann sie unter „Syntax Saga verwalten“ anlegen.');
|
||||
running = false;
|
||||
updateHud();
|
||||
return;
|
||||
}
|
||||
|
||||
running = true;
|
||||
renderMission();
|
||||
startTimer();
|
||||
}
|
||||
|
||||
diffBtns.forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
if (running && finishGaps.length === 0 && missionIndex === 0 && cur &&
|
||||
Object.keys(cur.solved).length === 0) {
|
||||
// noch nichts gespielt -> einfach wechseln
|
||||
} else if (running && !window.confirm('Aktuelle Saga abbrechen und neu starten?')) {
|
||||
return;
|
||||
}
|
||||
startRun(btn.dataset.diff);
|
||||
});
|
||||
});
|
||||
|
||||
againBtn.addEventListener('click', () => startRun(story ? story.difficulty : 'junior'));
|
||||
chooseBtn.addEventListener('click', () => {
|
||||
resultEl.hidden = true;
|
||||
running = false;
|
||||
story = null;
|
||||
diffBtns.forEach((b) => b.classList.remove('active'));
|
||||
setIdle('Wähle oben eine Stufe, um deine Saga zu starten.');
|
||||
updateHud();
|
||||
});
|
||||
|
||||
loadStats();
|
||||
setIdle('Wähle oben eine Stufe – Junior, Professional oder Senior – um deine Saga zu starten.');
|
||||
})();
|
||||
@@ -0,0 +1,424 @@
|
||||
{
|
||||
"missions": [
|
||||
{
|
||||
"difficulty": "junior",
|
||||
"title": "Hello World",
|
||||
"language": "java",
|
||||
"narrative": "Dein erster Tag als Praktikant. Der Serverraum ist still, das Terminal reagiert nicht – es fehlt der Einstiegspunkt des Programms.",
|
||||
"dialog": null,
|
||||
"code": "public class Main {\n public static void {{1}}(String[] args) {\n System.out.println(\"Hello World\");\n }\n}",
|
||||
"successText": "Das Terminal erwacht zum Leben und begrüßt dich: \"Hello World\".",
|
||||
"gaps": [
|
||||
{ "position": 1, "points": 100, "caseSensitive": true, "placeholder": "Methodenname", "hint": "Der Einstiegspunkt jeder Java-Anwendung.", "solutions": ["main"] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"difficulty": "junior",
|
||||
"title": "Die verschwundene Variable",
|
||||
"language": "java",
|
||||
"narrative": "Ein Kunde meldet einen Fehler: Auf dem Bildschirm soll die Anzahl der Leben erscheinen – doch da steht nichts.",
|
||||
"dialog": null,
|
||||
"code": "int lives = 3;\nSystem.out.println({{1}});",
|
||||
"successText": "Die Zahl 3 leuchtet auf dem Monitor. Der Kunde atmet auf.",
|
||||
"gaps": [
|
||||
{ "position": 1, "points": 100, "caseSensitive": true, "placeholder": "Variable", "hint": "Welche Variable soll ausgegeben werden?", "solutions": ["lives"] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"difficulty": "junior",
|
||||
"title": "Der flackernde Monitor",
|
||||
"language": "java",
|
||||
"narrative": "Der Serverraum ist dunkel. Nur ein Monitor flackert und wartet auf eine Eingabe, die er nicht lesen kann.",
|
||||
"dialog": "Bitte… repariere mich…",
|
||||
"code": "Scanner sc = new {{1}}(System.in);",
|
||||
"successText": "Eingaben sind wieder möglich. Der Monitor beruhigt sich.",
|
||||
"gaps": [
|
||||
{ "position": 1, "points": 100, "caseSensitive": true, "placeholder": "Klassenname", "hint": "Dieselbe Klasse wie links vom Gleichheitszeichen.", "solutions": ["Scanner"] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"difficulty": "junior",
|
||||
"title": "Zehn Server",
|
||||
"language": "java",
|
||||
"narrative": "Zehn Server müssen der Reihe nach geprüft werden – aber die Schleife, die sie durchläuft, fehlt.",
|
||||
"dialog": null,
|
||||
"code": "{{1}} (int i = 0; i < 10; i++) {\n check(server[i]);\n}",
|
||||
"successText": "Alle zehn Server melden nacheinander: OK.",
|
||||
"gaps": [
|
||||
{ "position": 1, "points": 100, "caseSensitive": true, "placeholder": "Schlüsselwort", "hint": "Die Zählschleife mit Start, Bedingung und Schritt.", "solutions": ["for"] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"difficulty": "junior",
|
||||
"title": "Der Virus",
|
||||
"language": "java",
|
||||
"narrative": "Eine Schadsoftware hat eine Bedingung verdreht. Das Achievement schaltet nicht mehr frei, wenn der Score 100 erreicht.",
|
||||
"dialog": null,
|
||||
"code": "if (score {{1}} 100) {\n unlockAchievement();\n}",
|
||||
"successText": "Bei 100 Punkten schaltet das Achievement wieder frei. Der Virus ist neutralisiert.",
|
||||
"gaps": [
|
||||
{ "position": 1, "points": 100, "caseSensitive": true, "placeholder": "Operator", "hint": "Mindestens 100 – größer oder gleich.", "solutions": [">="] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"difficulty": "junior",
|
||||
"title": "Die Datenbank",
|
||||
"language": "sql",
|
||||
"narrative": "Die KI hat alle Datensätze durcheinandergebracht. Du brauchst nur die volljährigen Nutzer – die Filter-Klausel fehlt.",
|
||||
"dialog": null,
|
||||
"code": "SELECT * FROM users\n{{1}} age > 18;",
|
||||
"successText": "Nur noch die passenden Datensätze erscheinen in der Liste.",
|
||||
"gaps": [
|
||||
{ "position": 1, "points": 100, "caseSensitive": false, "placeholder": "SQL-Keyword", "hint": "Die SQL-Klausel zum Filtern von Zeilen.", "solutions": ["WHERE"] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"difficulty": "junior",
|
||||
"title": "Netzwerk",
|
||||
"language": "bash",
|
||||
"narrative": "Du musst einen Server wieder erreichbar machen. Ein Ping soll zeigen, ob der lokale Rechner überhaupt antwortet.",
|
||||
"dialog": null,
|
||||
"code": "ping {{1}}",
|
||||
"successText": "Reply… reply… reply. Die Verbindung steht wieder.",
|
||||
"gaps": [
|
||||
{ "position": 1, "points": 100, "caseSensitive": false, "placeholder": "Host / IP", "hint": "Die Adresse des eigenen Rechners – als Name oder als IP.", "solutions": ["localhost", "192.168.0.1", "127.0.0.1"] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"difficulty": "junior",
|
||||
"title": "Der Dauerdienst",
|
||||
"language": "java",
|
||||
"narrative": "Ein Hintergrunddienst soll laufen, solange er aktiv ist. Das Schleifen-Schlüsselwort wurde gelöscht.",
|
||||
"dialog": null,
|
||||
"code": "{{1}} (running) {\n handleRequests();\n}",
|
||||
"successText": "Der Dienst nimmt wieder Anfragen entgegen.",
|
||||
"gaps": [
|
||||
{ "position": 1, "points": 100, "caseSensitive": true, "placeholder": "Schlüsselwort", "hint": "Kopfgesteuerte Schleife mit nur einer Bedingung.", "solutions": ["while"] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"difficulty": "junior",
|
||||
"title": "Was zurückkommt",
|
||||
"language": "java",
|
||||
"narrative": "Eine Methode soll die Summe zweier Zahlen liefern – doch das Ergebnis kommt nie beim Aufrufer an.",
|
||||
"dialog": null,
|
||||
"code": "int sum(int a, int b) {\n {{1}} a + b;\n}",
|
||||
"successText": "Die Methode liefert endlich ihren Wert zurück.",
|
||||
"gaps": [
|
||||
{ "position": 1, "points": 100, "caseSensitive": true, "placeholder": "Schlüsselwort", "hint": "Beendet die Methode und gibt einen Wert zurück.", "solutions": ["return"] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"difficulty": "junior",
|
||||
"title": "Die leere Liste",
|
||||
"language": "java",
|
||||
"narrative": "Eine Namensliste soll angelegt werden, aber die konkrete Implementierung der Liste fehlt.",
|
||||
"dialog": null,
|
||||
"code": "List<String> namen = new {{1}}<>();",
|
||||
"successText": "Die Liste ist bereit, Namen aufzunehmen.",
|
||||
"gaps": [
|
||||
{ "position": 1, "points": 100, "caseSensitive": true, "placeholder": "Klasse", "hint": "Die gängigste List-Implementierung (dynamisches Array).", "solutions": ["ArrayList"] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"difficulty": "junior",
|
||||
"title": "Ins Log-Verzeichnis",
|
||||
"language": "bash",
|
||||
"narrative": "Um den Fehler zu finden, musst du in das Verzeichnis mit den Logdateien wechseln.",
|
||||
"dialog": null,
|
||||
"code": "{{1}} /var/log",
|
||||
"successText": "Du stehst jetzt mitten in den Logs.",
|
||||
"gaps": [
|
||||
{ "position": 1, "points": 100, "caseSensitive": true, "placeholder": "Befehl", "hint": "\"change directory\" – Verzeichnis wechseln.", "solutions": ["cd"] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"difficulty": "junior",
|
||||
"title": "Sicher im Verlauf",
|
||||
"language": "bash",
|
||||
"narrative": "Deine Änderung ist fertig. Sichere sie mit einer Nachricht im Git-Verlauf.",
|
||||
"dialog": null,
|
||||
"code": "git {{1}} -m \"fix: login\"",
|
||||
"successText": "Der Stand ist im Verlauf festgehalten.",
|
||||
"gaps": [
|
||||
{ "position": 1, "points": 100, "caseSensitive": true, "placeholder": "Git-Befehl", "hint": "Schreibt die vorgemerkten Änderungen in die Historie.", "solutions": ["commit"] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"difficulty": "junior",
|
||||
"title": "Der tote Link",
|
||||
"language": "html",
|
||||
"narrative": "Auf der Kundenseite zeigt ein Link ins Leere – das Attribut für die Zieladresse fehlt.",
|
||||
"dialog": null,
|
||||
"code": "<a {{1}}=\"/home\">Start</a>",
|
||||
"successText": "Der Link führt jetzt sauber zur Startseite.",
|
||||
"gaps": [
|
||||
{ "position": 1, "points": 100, "caseSensitive": false, "placeholder": "Attribut", "hint": "Attribut für die Zieladresse eines Links.", "solutions": ["href"] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"difficulty": "junior",
|
||||
"title": "Sortierte Ware",
|
||||
"language": "sql",
|
||||
"narrative": "Die Produktliste des Shops kommt wild durcheinander. Sie soll nach Preis absteigend sortiert werden.",
|
||||
"dialog": null,
|
||||
"code": "SELECT * FROM produkte\n{{1}} preis DESC;",
|
||||
"successText": "Das teuerste Produkt steht jetzt oben.",
|
||||
"gaps": [
|
||||
{ "position": 1, "points": 100, "caseSensitive": false, "placeholder": "SQL-Klausel", "hint": "Sortiert das Ergebnis (zwei Wörter).", "solutions": ["ORDER BY"] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"difficulty": "junior",
|
||||
"title": "Das stumme Skript",
|
||||
"language": "python",
|
||||
"narrative": "Im Wartungsskript des Kunden soll nach dem Backup eine Meldung erscheinen – der Ausgabebefehl fehlt.",
|
||||
"dialog": null,
|
||||
"code": "{{1}}(\"Backup fertig\")",
|
||||
"successText": "\"Backup fertig\" erscheint im Terminal.",
|
||||
"gaps": [
|
||||
{ "position": 1, "points": 100, "caseSensitive": true, "placeholder": "Funktion", "hint": "Pythons Standard-Ausgabefunktion.", "solutions": ["print"] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"difficulty": "junior",
|
||||
"title": "Der Türcode",
|
||||
"language": "java",
|
||||
"narrative": "Die Sicherheitstür soll nur öffnen, wenn der eingegebene PIN exakt stimmt – der Vergleich fehlt.",
|
||||
"dialog": null,
|
||||
"code": "if (pin {{1}} 1234) {\n openDoor();\n}",
|
||||
"successText": "Bei richtigem PIN öffnet die Tür – und nur dann.",
|
||||
"gaps": [
|
||||
{ "position": 1, "points": 100, "caseSensitive": true, "placeholder": "Operator", "hint": "Vergleich auf Gleichheit (nicht die Zuweisung).", "solutions": ["=="] }
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
"difficulty": "professional",
|
||||
"title": "Die halbe Schleife",
|
||||
"language": "java",
|
||||
"narrative": "Ein Praktikant hat die Schleife halb fertig gelassen: Sowohl die Abbruchbedingung als auch der Zählschritt fehlen.",
|
||||
"dialog": null,
|
||||
"code": "for (int i = 0; i {{1}} 10; i{{2}}) {\n process(i);\n}",
|
||||
"successText": "Die Schleife läuft sauber von 0 bis 9 durch.",
|
||||
"gaps": [
|
||||
{ "position": 1, "points": 100, "caseSensitive": true, "placeholder": "Bedingung", "hint": "Solange i kleiner als 10 ist.", "solutions": ["<"] },
|
||||
{ "position": 2, "points": 100, "caseSensitive": true, "placeholder": "Schritt", "hint": "i um eins erhöhen.", "solutions": ["++"] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"difficulty": "professional",
|
||||
"title": "Fehler abfangen",
|
||||
"language": "java",
|
||||
"narrative": "Ein Dateizugriff lässt das Programm abstürzen. Du sollst den Fehler abfangen, statt alles crashen zu lassen.",
|
||||
"dialog": null,
|
||||
"code": "{{1}} {\n readFile(\"daten.txt\");\n} {{2}} (IOException e) {\n log(e);\n}",
|
||||
"successText": "Der Fehler wird sauber protokolliert – kein Absturz mehr.",
|
||||
"gaps": [
|
||||
{ "position": 1, "points": 100, "caseSensitive": true, "placeholder": "Block", "hint": "Der Block, der den riskanten Code umschließt.", "solutions": ["try"] },
|
||||
{ "position": 2, "points": 100, "caseSensitive": true, "placeholder": "Block", "hint": "Der Block, der die Exception auffängt.", "solutions": ["catch"] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"difficulty": "professional",
|
||||
"title": "Neuer Eintrag",
|
||||
"language": "sql",
|
||||
"narrative": "Ein neuer Nutzer soll angelegt werden, aber das INSERT-Statement ist an zwei Stellen unvollständig.",
|
||||
"dialog": null,
|
||||
"code": "{{1}} INTO users (name, age)\n{{2}} ('Alice', 28);",
|
||||
"successText": "Alice ist jetzt in der Datenbank gespeichert.",
|
||||
"gaps": [
|
||||
{ "position": 1, "points": 100, "caseSensitive": false, "placeholder": "SQL-Keyword", "hint": "Mit welchem Befehl fügt man eine Zeile ein?", "solutions": ["INSERT"] },
|
||||
{ "position": 2, "points": 100, "caseSensitive": false, "placeholder": "SQL-Keyword", "hint": "Leitet die einzufügenden Werte ein.", "solutions": ["VALUES"] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"difficulty": "professional",
|
||||
"title": "Durch die Liste",
|
||||
"language": "java",
|
||||
"narrative": "Jeder Eintrag der Liste soll ausgegeben werden. In der erweiterten for-Schleife fehlen Trenner und Ausgabe.",
|
||||
"dialog": null,
|
||||
"code": "for (String s {{1}} liste) {\n System.out.{{2}}(s);\n}",
|
||||
"successText": "Alle Einträge der Liste laufen über den Bildschirm.",
|
||||
"gaps": [
|
||||
{ "position": 1, "points": 100, "caseSensitive": true, "placeholder": "Trenner", "hint": "Trennt in der for-each-Schleife Variable und Sammlung.", "solutions": [":"] },
|
||||
{ "position": 2, "points": 100, "caseSensitive": true, "placeholder": "Methode", "hint": "Gibt eine Zeile mit Zeilenumbruch aus.", "solutions": ["println"] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"difficulty": "professional",
|
||||
"title": "Das Fließband",
|
||||
"language": "java",
|
||||
"narrative": "Eine Auto-Klasse braucht ihren Bauplan. Klassen-Schlüsselwort und der Verweis aufs eigene Objekt fehlen.",
|
||||
"dialog": null,
|
||||
"code": "public {{1}} Auto {\n String marke;\n Auto(String marke) {\n {{2}}.marke = marke;\n }\n}",
|
||||
"successText": "Vom Fließband rollt das erste Auto-Objekt.",
|
||||
"gaps": [
|
||||
{ "position": 1, "points": 100, "caseSensitive": true, "placeholder": "Schlüsselwort", "hint": "Leitet die Definition eines Bauplans ein.", "solutions": ["class"] },
|
||||
{ "position": 2, "points": 100, "caseSensitive": true, "placeholder": "Referenz", "hint": "Verweis auf das aktuelle Objekt.", "solutions": ["this"] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"difficulty": "professional",
|
||||
"title": "Bestellungen verknüpfen",
|
||||
"language": "sql",
|
||||
"narrative": "Nutzer und ihre Bestellungen sollen zusammengeführt werden – die Verknüpfung und ihre Bedingung fehlen.",
|
||||
"dialog": null,
|
||||
"code": "SELECT * FROM users u\n{{1}} orders o {{2}} u.id = o.user_id;",
|
||||
"successText": "Jede Bestellung steht jetzt neben ihrem Nutzer.",
|
||||
"gaps": [
|
||||
{ "position": 1, "points": 100, "caseSensitive": false, "placeholder": "SQL-Keyword", "hint": "Verbindet zwei Tabellen.", "solutions": ["JOIN", "INNER JOIN"] },
|
||||
{ "position": 2, "points": 100, "caseSensitive": false, "placeholder": "SQL-Keyword", "hint": "Leitet die Verknüpfungsbedingung ein.", "solutions": ["ON"] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"difficulty": "professional",
|
||||
"title": "Auf Fehlersuche",
|
||||
"language": "bash",
|
||||
"narrative": "Im riesigen Logfile sollst du nur die Fehlerzeilen sehen. Verbinde die Ausgabe mit dem Suchwerkzeug.",
|
||||
"dialog": null,
|
||||
"code": "cat app.log {{1}} {{2}} ERROR",
|
||||
"successText": "Nur noch die ERROR-Zeilen rauschen über den Schirm.",
|
||||
"gaps": [
|
||||
{ "position": 1, "points": 100, "caseSensitive": true, "placeholder": "Zeichen", "hint": "Leitet die Ausgabe an den nächsten Befehl weiter (Pipe).", "solutions": ["|"] },
|
||||
{ "position": 2, "points": 100, "caseSensitive": true, "placeholder": "Befehl", "hint": "Filtert Zeilen nach einem Muster.", "solutions": ["grep"] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"difficulty": "professional",
|
||||
"title": "Ungültiges Alter",
|
||||
"language": "java",
|
||||
"narrative": "Ein negatives Alter darf nicht durchrutschen. Bedingung und das Auslösen der Ausnahme fehlen.",
|
||||
"dialog": null,
|
||||
"code": "{{1}} (alter < 0) {\n {{2}} new IllegalArgumentException(\"negativ\");\n}",
|
||||
"successText": "Negative Werte werden jetzt sofort abgewiesen.",
|
||||
"gaps": [
|
||||
{ "position": 1, "points": 100, "caseSensitive": true, "placeholder": "Schlüsselwort", "hint": "Prüft eine Bedingung.", "solutions": ["if"] },
|
||||
{ "position": 2, "points": 100, "caseSensitive": true, "placeholder": "Schlüsselwort", "hint": "Wirft eine Exception aktiv aus.", "solutions": ["throw"] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"difficulty": "professional",
|
||||
"title": "Die Summe im Array",
|
||||
"language": "java",
|
||||
"narrative": "Alle Zahlen eines Arrays sollen aufsummiert werden. Die Längenabfrage und die Aufsummierung fehlen.",
|
||||
"dialog": null,
|
||||
"code": "int sum = 0;\nfor (int i = 0; i < zahlen.{{1}}; i++) {\n sum {{2}} zahlen[i];\n}",
|
||||
"successText": "Die Summe steht – ohne ein einziges Element zu verpassen.",
|
||||
"gaps": [
|
||||
{ "position": 1, "points": 100, "caseSensitive": true, "placeholder": "Eigenschaft", "hint": "Die Anzahl der Elemente eines Arrays.", "solutions": ["length"] },
|
||||
{ "position": 2, "points": 100, "caseSensitive": true, "placeholder": "Operator", "hint": "sum um zahlen[i] erhöhen (Kurzform).", "solutions": ["+="] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"difficulty": "professional",
|
||||
"title": "Das Login-Formular",
|
||||
"language": "html",
|
||||
"narrative": "Das Anmeldeformular schickt nichts ab und zeigt das Passwort im Klartext. Versandart und Feldtyp fehlen.",
|
||||
"dialog": null,
|
||||
"code": "<form action=\"/login\" {{1}}=\"post\">\n <input {{2}}=\"password\">\n</form>",
|
||||
"successText": "Das Formular sendet sicher per POST, das Passwort bleibt verdeckt.",
|
||||
"gaps": [
|
||||
{ "position": 1, "points": 100, "caseSensitive": false, "placeholder": "Attribut", "hint": "Legt die HTTP-Methode des Formulars fest.", "solutions": ["method"] },
|
||||
{ "position": 2, "points": 100, "caseSensitive": false, "placeholder": "Attribut", "hint": "Bestimmt die Art des Eingabefeldes.", "solutions": ["type"] }
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
"difficulty": "senior",
|
||||
"title": "Stille Konsole",
|
||||
"language": "java",
|
||||
"narrative": "Vom Logging-Modul ist nur noch eine halbe Zeile übrig. Rekonstruiere den kompletten Ausgabebefehl.",
|
||||
"dialog": null,
|
||||
"code": "{{1}}(\"Hallo\");",
|
||||
"successText": "\"Hallo\" erscheint in der Konsole. Das Logging lebt wieder.",
|
||||
"gaps": [
|
||||
{ "position": 1, "points": 200, "caseSensitive": true, "placeholder": "ganze Anweisung", "hint": "Der vollständige Standard-Ausgabebefehl in Java (Objektkette).", "solutions": ["System.out.println"] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"difficulty": "senior",
|
||||
"title": "Der Einstiegspunkt",
|
||||
"language": "java",
|
||||
"narrative": "Eine Schadroutine hat die komplette Signatur der Hauptmethode gelöscht – nur Klammern blieben übrig. Stell die ganze Zeile wieder her.",
|
||||
"dialog": null,
|
||||
"code": "public class App {\n {{1}} {\n run();\n }\n}",
|
||||
"successText": "Die App findet ihren Einstiegspunkt wieder und startet.",
|
||||
"gaps": [
|
||||
{ "position": 1, "points": 200, "caseSensitive": true, "placeholder": "ganze Signatur", "hint": "Sichtbarkeit, static, Rückgabetyp void, Name und String-Array-Parameter.", "solutions": ["public static void main(String[] args)", "static public void main(String[] args)", "public static void main(String args[])"] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"difficulty": "senior",
|
||||
"title": "Geburtstag in der DB",
|
||||
"language": "sql",
|
||||
"narrative": "Alice hat Geburtstag und ist jetzt 29. Schreibe die komplette UPDATE-Zeile, die ihr Alter setzt (ohne die WHERE-Bedingung).",
|
||||
"dialog": null,
|
||||
"code": "{{1}}\nWHERE name = 'Alice';",
|
||||
"successText": "Alices Alter ist aktualisiert. Herzlichen Glückwunsch!",
|
||||
"gaps": [
|
||||
{ "position": 1, "points": 200, "caseSensitive": false, "placeholder": "ganze Zeile", "hint": "UPDATE <tabelle> SET <spalte> = <wert>", "solutions": ["UPDATE users SET age = 29"] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"difficulty": "senior",
|
||||
"title": "Der Bauplan des Roboters",
|
||||
"language": "java",
|
||||
"narrative": "Die Klassendeklaration des Roboters wurde gelöscht – nur der Rumpf blieb. Schreib die komplette öffentliche Klassenzeile (Klasse: Roboter).",
|
||||
"dialog": null,
|
||||
"code": "{{1}} {\n // Roboter-Logik\n}",
|
||||
"successText": "Der Roboter hat wieder einen Bauplan und kann gebaut werden.",
|
||||
"gaps": [
|
||||
{ "position": 1, "points": 200, "caseSensitive": true, "placeholder": "ganze Zeile", "hint": "Sichtbarkeit + Schlüsselwort + Name der Klasse.", "solutions": ["public class Roboter"] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"difficulty": "senior",
|
||||
"title": "Eingabe von vorn",
|
||||
"language": "java",
|
||||
"narrative": "Die komplette Initialisierung der Tastatureingabe wurde gelöscht. Stell die ganze Zeile wieder her (Variable: sc).",
|
||||
"dialog": null,
|
||||
"code": "{{1}};",
|
||||
"successText": "Die Tastatureingabe ist wieder bereit.",
|
||||
"gaps": [
|
||||
{ "position": 1, "points": 200, "caseSensitive": true, "placeholder": "ganze Zeile", "hint": "Deklaration + Zuweisung eines neuen Scanners auf System.in.", "solutions": ["Scanner sc = new Scanner(System.in)"] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"difficulty": "senior",
|
||||
"title": "Jede Zahl zählt",
|
||||
"language": "java",
|
||||
"narrative": "Die erweiterte for-Schleife über das Array 'zahlen' fehlt komplett. Rekonstruiere sie (Variable n, Typ int).",
|
||||
"dialog": null,
|
||||
"code": "{{1}} {\n summe += n;\n}",
|
||||
"successText": "Jede Zahl des Arrays fließt in die Summe ein.",
|
||||
"gaps": [
|
||||
{ "position": 1, "points": 200, "caseSensitive": true, "placeholder": "ganze Zeile", "hint": "for ( <Typ> <Variable> : <Array> )", "solutions": ["for (int n : zahlen)", "for(int n : zahlen)"] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"difficulty": "senior",
|
||||
"title": "Alte Logs entsorgen",
|
||||
"language": "sql",
|
||||
"narrative": "Uralte Logeinträge sollen gelöscht werden. Schreibe die komplette DELETE-Zeile (Tabelle: logs), die WHERE-Bedingung steht schon.",
|
||||
"dialog": null,
|
||||
"code": "{{1}}\nWHERE created < '2020-01-01';",
|
||||
"successText": "Die alten Logs sind Geschichte – die Platte atmet auf.",
|
||||
"gaps": [
|
||||
{ "position": 1, "points": 200, "caseSensitive": false, "placeholder": "ganze Zeile", "hint": "DELETE FROM <tabelle>", "solutions": ["DELETE FROM logs"] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"difficulty": "senior",
|
||||
"title": "Die Methode zurückbauen",
|
||||
"language": "java",
|
||||
"narrative": "Von einer Additions-Methode ist nur der Rumpf übrig. Schreibe die komplette Signatur (public, gibt int zurück, heißt add, nimmt int a und int b).",
|
||||
"dialog": null,
|
||||
"code": "{{1}} {\n return a + b;\n}",
|
||||
"successText": "Die Methode add ist wiederhergestellt und liefert die Summe.",
|
||||
"gaps": [
|
||||
{ "position": 1, "points": 200, "caseSensitive": true, "placeholder": "ganze Signatur", "hint": "<Sichtbarkeit> <Rückgabetyp> <Name>(<Parameter>)", "solutions": ["public int add(int a, int b)", "public int add(int a,int b)"] }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -46,6 +46,7 @@
|
||||
<a th:href="@{/admin/users}" class="admin-card">👤 Benutzer verwalten</a>
|
||||
<a th:href="@{/admin/mindmatch}" class="admin-card">🧩 MindMatch-Decks verwalten</a>
|
||||
<a th:href="@{/admin/techtacular}" class="admin-card">🎯 TechTacular-Fragen verwalten</a>
|
||||
<a th:href="@{/admin/syntaxsaga}" class="admin-card">📜 Syntax-Saga-Missionen verwalten</a>
|
||||
</div>
|
||||
<p style="margin-top:1.1rem; color:var(--muted);">
|
||||
Hier entsteht noch Großes... ✨ Bleib dran!
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de" data-theme="dark"
|
||||
xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:sec="https://www.thymeleaf.org/thymeleaf-extras-springsecurity6">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title th:text="${mission.title} + ' – Syntax Saga'">Syntax-Saga-Mission</title>
|
||||
<link rel="stylesheet" href="/css/theme.css">
|
||||
<script src="/js/theme.js"></script>
|
||||
<style>
|
||||
.ssm { max-width: 1000px; margin: 0 auto; padding: 0 1rem; width: 100%; }
|
||||
.ssm .grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0.7rem; }
|
||||
.ssm .field { margin-bottom: 0.7rem; display: flex; flex-direction: column; gap: 0.35rem; }
|
||||
.ssm .field > label { color: var(--accent); font-weight: 700; font-size: 0.85rem; }
|
||||
.ssm textarea.mf-input { font-family: monospace; resize: vertical; min-height: 4.5rem; }
|
||||
.ssm textarea.prose { font-family: inherit; }
|
||||
.ssm .gap {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 4px solid var(--secondary);
|
||||
border-radius: 12px; padding: 0.9rem 1.1rem; margin-bottom: 0.7rem;
|
||||
box-shadow: var(--g-soft);
|
||||
}
|
||||
.ssm .gap-head { display: flex; align-items: center; gap: 0.6rem; justify-content: space-between; flex-wrap: wrap; }
|
||||
.ssm .pos { background: var(--glow-w); color: var(--accent); font-weight: 800; border: 1px solid var(--border-accent); border-radius: 7px; padding: 0.14rem 0.55rem; font-size: 0.85rem; }
|
||||
.ssm .meta { color: var(--muted); font-size: 0.82rem; }
|
||||
.ssm ul.sols { list-style: none; padding: 0; margin: 0.4rem 0 0; }
|
||||
.ssm ul.sols li { color: var(--secondary); font-family: monospace; padding: 0.12rem 0; }
|
||||
.ssm ul.sols li::before { content: "✓ "; }
|
||||
.ssm .gap-hint { color: var(--muted); font-size: 0.85rem; margin-top: 0.35rem; }
|
||||
.ssm .addbox { margin-top: 1.6rem; }
|
||||
.ssm .hint { color: var(--muted); font-size: 0.82rem; margin-bottom: 0.8rem; line-height: 1.5; }
|
||||
.ssm .hint code { color: var(--accent); }
|
||||
.ssm .empty { color: var(--danger); }
|
||||
.ssm .check-row { display: flex; align-items: center; gap: 0.45rem; color: var(--accent); font-size: 0.85rem; }
|
||||
@media (max-width: 600px) { .ssm .grid2 { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="mf-theme-float"><button class="mf-theme"></button></div>
|
||||
|
||||
<div class="mf-container" style="max-width:1040px">
|
||||
|
||||
<div class="mf-page-head">
|
||||
<a th:href="@{/admin/syntaxsaga}" class="mf-back">⟵ Zurück zur Missionsliste</a>
|
||||
</div>
|
||||
|
||||
<header style="margin-bottom:1.4rem">
|
||||
<div class="mf-eyebrow">Syntax Saga · Mission</div>
|
||||
<h1 style="color:var(--heading); font-size:1.75rem; font-weight:800; margin-top:.35rem" th:text="'📜 ' + ${mission.title}">Mission</h1>
|
||||
</header>
|
||||
|
||||
<main class="ssm">
|
||||
|
||||
<!-- Mission bearbeiten -->
|
||||
<div class="mf-panel">
|
||||
<h4>Mission bearbeiten</h4>
|
||||
<form th:action="@{'/admin/syntaxsaga/missions/' + ${mission.id} + '/update'}" method="post">
|
||||
<div class="grid2">
|
||||
<div class="field">
|
||||
<label>Titel</label>
|
||||
<input class="mf-input" type="text" name="title" th:value="${mission.title}" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Schwierigkeit</label>
|
||||
<select class="mf-select" name="difficulty">
|
||||
<option value="junior" th:selected="${mission.difficulty == 'junior'}">Junior</option>
|
||||
<option value="professional" th:selected="${mission.difficulty == 'professional'}">Professional</option>
|
||||
<option value="senior" th:selected="${mission.difficulty == 'senior'}">Senior</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Erzähltext / Szene</label>
|
||||
<textarea class="mf-input prose" name="narrative" th:text="${mission.narrative}"></textarea>
|
||||
</div>
|
||||
<div class="grid2">
|
||||
<div class="field">
|
||||
<label>Dialog (optional)</label>
|
||||
<input class="mf-input" type="text" name="dialog" th:value="${mission.dialog}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Sprache (optional)</label>
|
||||
<input class="mf-input" type="text" name="language" th:value="${mission.language}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Code mit Lücken (<code>{{1}}</code>, <code>{{2}}</code> …)</label>
|
||||
<textarea class="mf-input" name="codeTemplate" rows="6" th:text="${mission.codeTemplate}" required></textarea>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Erfolgstext (optional)</label>
|
||||
<textarea class="mf-input prose" name="successText" th:text="${mission.successText}"></textarea>
|
||||
</div>
|
||||
<button type="submit" class="mf-btn">💾 Mission speichern</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Vorhandene Lücken -->
|
||||
<h4 style="color:var(--accent); margin:1.4rem 0 0.7rem; text-transform:uppercase; letter-spacing:.08em; font-size:.8rem">Lücken</h4>
|
||||
|
||||
<div class="gap" th:each="g : ${mission.gaps}">
|
||||
<div class="gap-head">
|
||||
<span class="pos" th:text="'Lücke {{' + ${g.position} + '}}'">Lücke {{1}}</span>
|
||||
<span class="meta">
|
||||
<span th:text="${g.points} + ' Pkt'">100 Pkt</span> ·
|
||||
<span th:text="${g.caseSensitive} ? 'Groß/Klein beachten' : 'Groß/Klein egal'">Groß/Klein</span>
|
||||
</span>
|
||||
<form th:action="@{'/admin/syntaxsaga/gaps/' + ${g.id} + '/delete'}" method="post"
|
||||
onsubmit="return confirm('Lücke löschen?');">
|
||||
<input type="hidden" name="missionId" th:value="${mission.id}">
|
||||
<button type="submit" class="mf-btn danger">✕ Lücke löschen</button>
|
||||
</form>
|
||||
</div>
|
||||
<ul class="sols">
|
||||
<li th:each="s : ${g.solutions}" th:text="${s.text}">Lösung</li>
|
||||
</ul>
|
||||
<p class="gap-hint" th:if="${g.hint}" th:text="'Hinweis: ' + ${g.hint}">Hinweis</p>
|
||||
</div>
|
||||
|
||||
<p th:if="${mission.gaps.isEmpty()}" class="empty">Noch keine Lücken – unten hinzufügen. Ohne Lücken erscheint die Mission nicht im Spiel.</p>
|
||||
|
||||
<!-- Lücke hinzufügen -->
|
||||
<div class="mf-panel accent addbox">
|
||||
<h4>Lücke hinzufügen</h4>
|
||||
<p class="hint">Die <strong>Position</strong> muss zum Marker <code>{{n}}</code> im Code passen.
|
||||
Trage die gültigen <strong>Lösungen</strong> ein – eine pro Zeile (mehrere Zeilen = mehrere akzeptierte Antworten,
|
||||
z. B. <code>192.168.0.1</code> und <code>localhost</code>).</p>
|
||||
<form th:action="@{'/admin/syntaxsaga/missions/' + ${mission.id} + '/gaps'}" method="post">
|
||||
<div class="grid2">
|
||||
<div class="field">
|
||||
<label>Position ({{n}})</label>
|
||||
<input class="mf-input" type="number" name="position" min="1" placeholder="1" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Punkte</label>
|
||||
<input class="mf-input" type="number" name="points" min="10" max="1000" value="100">
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid2">
|
||||
<div class="field">
|
||||
<label>Platzhalter (optional)</label>
|
||||
<input class="mf-input" type="text" name="placeholder" placeholder="z. B. Methodenname">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Hinweis (optional)</label>
|
||||
<input class="mf-input" type="text" name="hint" placeholder="kurzer Tipp nach falschem Versuch">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Gültige Lösungen (eine pro Zeile)</label>
|
||||
<textarea class="mf-input" name="solutions" rows="3" placeholder="main" required></textarea>
|
||||
</div>
|
||||
<label class="check-row" style="margin-bottom:.7rem">
|
||||
<input type="checkbox" name="caseSensitive" value="true" checked> Groß-/Kleinschreibung beachten (für Java-Bezeichner; bei SQL-Keywords abwählen)
|
||||
</label>
|
||||
<button type="submit" class="mf-btn">➕ Lücke speichern</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
</div>
|
||||
|
||||
<footer class="mf-footer">
|
||||
<a href="#">Support</a>
|
||||
<a href="#">Feedback</a>
|
||||
<a href="/nutzungsbedingungen">Nutzungsbedingungen</a>
|
||||
<a href="/richtlinien">Richtlinien</a>
|
||||
<a href="/datenschutz">Datenschutz</a>
|
||||
<a href="/impressum">Impressum</a>
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,140 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de" data-theme="dark"
|
||||
xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:sec="https://www.thymeleaf.org/thymeleaf-extras-springsecurity6">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Syntax-Saga-Verwaltung – Admin</title>
|
||||
<link rel="stylesheet" href="/css/theme.css">
|
||||
<script src="/js/theme.js"></script>
|
||||
<style>
|
||||
.ssadmin { max-width: 1000px; margin: 0 auto; padding: 0 1rem; width: 100%; }
|
||||
.ssadmin .new-mission { margin-bottom: 1.6rem; }
|
||||
.ssadmin .grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0.7rem; }
|
||||
.ssadmin .mission {
|
||||
display: flex; align-items: center; flex-wrap: wrap; gap: 0.6rem;
|
||||
justify-content: space-between;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 4px solid var(--accent);
|
||||
border-radius: 12px; padding: 0.9rem 1.1rem; margin-bottom: 0.7rem;
|
||||
box-shadow: var(--g-soft);
|
||||
}
|
||||
.ssadmin .mission.inactive { border-left-color: var(--danger); opacity: 0.7; }
|
||||
.ssadmin .mission h2 { color: var(--heading); margin: 0; font-size: 1.05rem; }
|
||||
.ssadmin .slug { color: var(--accent); font-size: 0.78rem; font-family: monospace; }
|
||||
.ssadmin .count { color: var(--muted); font-size: 0.85rem; }
|
||||
.ssadmin .badge { font-size: 0.66rem; text-transform: uppercase; padding: 0.14rem 0.45rem; border-radius: 7px; border: 1px solid var(--border-accent); color: var(--secondary); border-color: var(--secondary); }
|
||||
.ssadmin .badge.on { background: var(--glow-w); color: var(--accent); border-color: var(--border-accent); }
|
||||
.ssadmin .badge.off { background: rgba(255,95,107,.12); color: var(--danger); border-color: var(--danger); }
|
||||
.ssadmin .actions { display: flex; gap: 0.45rem; flex-wrap: wrap; align-items: center; }
|
||||
.ssadmin .field { margin-bottom: 0.7rem; display: flex; flex-direction: column; gap: 0.35rem; }
|
||||
.ssadmin .field > label { color: var(--accent); font-weight: 700; font-size: 0.85rem; }
|
||||
.ssadmin textarea.mf-input { min-height: 4.5rem; font-family: monospace; resize: vertical; }
|
||||
.ssadmin .hint { color: var(--muted); font-size: 0.82rem; margin-bottom: 0.8rem; line-height: 1.5; }
|
||||
.ssadmin .hint code { color: var(--accent); }
|
||||
.ssadmin .empty { color: var(--muted); }
|
||||
@media (max-width: 600px) { .ssadmin .grid2 { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="mf-theme-float"><button class="mf-theme"></button></div>
|
||||
|
||||
<div class="mf-container" style="max-width:1040px">
|
||||
|
||||
<div class="mf-page-head">
|
||||
<a th:href="@{/admin}" class="mf-back">⟵ Zurück zum Admin-Dashboard</a>
|
||||
</div>
|
||||
|
||||
<header style="margin-bottom:1.4rem">
|
||||
<div class="mf-eyebrow">Syntax Saga</div>
|
||||
<h1 style="color:var(--heading); font-size:1.75rem; font-weight:800; margin-top:.35rem">📜 Syntax-Saga-Verwaltung</h1>
|
||||
</header>
|
||||
|
||||
<main class="ssadmin">
|
||||
|
||||
<div class="mf-panel accent new-mission">
|
||||
<h4>Neue Mission anlegen</h4>
|
||||
<p class="hint">Markiere die Lücken im Code mit <code>{{1}}</code>, <code>{{2}}</code> … – die Nummer
|
||||
ist die Position der Lücke. Die gültigen Lösungen trägst du nach dem Anlegen je Lücke ein.</p>
|
||||
<form th:action="@{/admin/syntaxsaga/missions}" method="post">
|
||||
<div class="grid2">
|
||||
<div class="field">
|
||||
<label>Titel</label>
|
||||
<input class="mf-input" type="text" name="title" placeholder="z. B. Hello World" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Schwierigkeit</label>
|
||||
<select class="mf-select" name="difficulty">
|
||||
<option value="junior">Junior (eine Lücke)</option>
|
||||
<option value="professional">Professional (mehrere Lücken)</option>
|
||||
<option value="senior">Senior (ganze Zeile)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Erzähltext / Szene</label>
|
||||
<textarea class="mf-input" name="narrative" placeholder="Worum geht es in dieser Mission?" style="font-family:inherit"></textarea>
|
||||
</div>
|
||||
<div class="grid2">
|
||||
<div class="field">
|
||||
<label>Dialog (optional)</label>
|
||||
<input class="mf-input" type="text" name="dialog" placeholder="z. B. Bitte… repariere mich…">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Sprache (optional)</label>
|
||||
<input class="mf-input" type="text" name="language" placeholder="java / sql / bash">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Code mit Lücken</label>
|
||||
<textarea class="mf-input" name="codeTemplate" rows="5" placeholder="public class Main { public static void {{1}}(String[] args) { ... } }" required></textarea>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Erfolgstext (optional)</label>
|
||||
<textarea class="mf-input" name="successText" placeholder="Was passiert, wenn alles gelöst ist?" style="font-family:inherit"></textarea>
|
||||
</div>
|
||||
<button type="submit" class="mf-btn">➕ Mission anlegen & Lücken hinzufügen</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="mission" th:each="m : ${missions}" th:classappend="${m.active} ? '' : 'inactive'">
|
||||
<div>
|
||||
<h2 th:text="${m.title}">Mission</h2>
|
||||
<span class="slug" th:text="'/' + ${m.slug}">/slug</span>
|
||||
<span class="badge" th:text="${m.difficulty}">junior</span>
|
||||
<span class="badge on" th:if="${m.active}">aktiv</span>
|
||||
<span class="badge off" th:unless="${m.active}">inaktiv</span>
|
||||
<span class="count" th:text="'· ' + ${m.gapCount} + ' Lücken'">· 0 Lücken</span>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<a class="mf-btn" th:href="@{'/admin/syntaxsaga/missions/' + ${m.id}}">Lücken verwalten</a>
|
||||
<form th:action="@{'/admin/syntaxsaga/missions/' + ${m.id} + '/toggle'}" method="post">
|
||||
<button type="submit" class="mf-btn outline" th:text="${m.active} ? 'Deaktivieren' : 'Aktivieren'">Toggle</button>
|
||||
</form>
|
||||
<form th:action="@{'/admin/syntaxsaga/missions/' + ${m.id} + '/delete'}" method="post"
|
||||
onsubmit="return confirm('Mission inkl. aller Lücken löschen?');">
|
||||
<button type="submit" class="mf-btn danger">Löschen</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p th:if="${missions.isEmpty()}" class="empty">Noch keine Missionen. Lege oben die erste an.</p>
|
||||
|
||||
</main>
|
||||
|
||||
</div>
|
||||
|
||||
<footer class="mf-footer">
|
||||
<a href="#">Support</a>
|
||||
<a href="#">Feedback</a>
|
||||
<a href="/nutzungsbedingungen">Nutzungsbedingungen</a>
|
||||
<a href="/richtlinien">Richtlinien</a>
|
||||
<a href="/datenschutz">Datenschutz</a>
|
||||
<a href="/impressum">Impressum</a>
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -185,7 +185,7 @@
|
||||
const GAMES = [
|
||||
['MindMatch','brain','Trainiere dein Gedächtnis mit IT-Begriffen in einem klassischen Memory-Spiel.','/mindmatch'],
|
||||
['TechTacular','code','Das IT-Quizduell im Jeopardy-Stil – teste dein Wissen in verschiedenen Kategorien.','/techtacular'],
|
||||
['Syntax Saga','terminal','Interaktive IT-Geschichten mit Lückentexten – wie ein Textadventure zum Mitdenken.','/comingsoon'],
|
||||
['Syntax Saga','terminal','Interaktive IT-Geschichten mit Lückentexten – wie ein Textadventure zum Mitdenken.','/syntaxsaga'],
|
||||
['Code Escape Room','lock','Entkomme mit IT-Rätseln aus digitalen Räumen – sei schnell und clever.','/comingsoon'],
|
||||
['Service Clash','sword','Der chaotische Admin-Alltag als Spiel – meistere Anfragen & halte Systeme am Laufen!','/comingsoon'],
|
||||
['Bug Hunter','bug','Finde Fehler im Code, denke wie ein Debugger und löse knifflige Bugs.','/comingsoon'],
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de" data-theme="dark" xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Syntax Saga – Mindflux</title>
|
||||
<link rel="stylesheet" href="/css/theme.css">
|
||||
<script src="/js/theme.js"></script>
|
||||
<link rel="stylesheet" href="/css/syntaxsaga.css?v=1">
|
||||
</head>
|
||||
<body class="ss-body">
|
||||
|
||||
<div class="ss-shell">
|
||||
<header class="ss-header">
|
||||
<a href="/dashboard" class="ss-back">⟵ Dashboard</a>
|
||||
<h1 class="ss-title">Syntax Saga</h1>
|
||||
<div class="ss-header-tools">
|
||||
<button type="button" class="mf-theme"></button>
|
||||
<a href="/logout" class="ss-logout">Logout</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="ss-bar">
|
||||
<div class="ss-bar-group" aria-label="Schwierigkeit wählen">
|
||||
<span class="ss-bar-label">Stufe:</span>
|
||||
<button type="button" class="ss-diff" data-diff="junior">Junior</button>
|
||||
<button type="button" class="ss-diff" data-diff="professional">Professional</button>
|
||||
<button type="button" class="ss-diff" data-diff="senior">Senior</button>
|
||||
</div>
|
||||
<div class="ss-bar-group ss-hud" aria-live="polite">
|
||||
<span class="ss-stat">Punkte <b id="ss-score">0</b></span>
|
||||
<span class="ss-stat">Mission <b id="ss-mission">– / –</b></span>
|
||||
<span class="ss-stat">Zeit <b id="ss-time">0:00</b></span>
|
||||
<span class="ss-stat">Bestwert <b id="ss-best">–</b></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main class="ss-stage">
|
||||
<section id="ss-story" class="ss-story" aria-label="Syntax-Saga-Geschichte"></section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Ergebnis-Overlay -->
|
||||
<div id="ss-result" class="ss-overlay" hidden>
|
||||
<div class="ss-result-card">
|
||||
<h2>Saga beendet 🎉</h2>
|
||||
<p id="ss-result-stars" class="ss-result-stars">★★★</p>
|
||||
<p class="ss-result-score">Score: <strong id="ss-result-score">0</strong> / <span id="ss-result-max">0</span></p>
|
||||
<p id="ss-result-detail" class="ss-result-detail"></p>
|
||||
<p id="ss-result-high" class="ss-result-high" hidden>🏆 Neuer Bestwert!</p>
|
||||
<div class="ss-result-actions">
|
||||
<button type="button" id="ss-again" class="ss-btn">Nochmal</button>
|
||||
<button type="button" id="ss-choose" class="ss-btn ss-btn-secondary">Andere Stufe</button>
|
||||
<a href="/dashboard" class="ss-btn ss-btn-secondary">Dashboard</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/syntaxsaga.js?v=1" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user