Aktueller Stand: Editor + Player + Short-Codes + XSS-Härtung

This commit is contained in:
Stefan Franke 2026-07-07 08:36:44 +02:00
commit be7998e864
66 changed files with 9014 additions and 0 deletions

20
.gitignore vendored Normal file
View file

@ -0,0 +1,20 @@
# Backups
backups/
backup_*/
_backup_*/
*.bak
*.bak.*
*.bak?
editor_backup_*
game.html.bak2
# Build & Deps
node_modules/
dist/
.env
.env.*
*.log
# OS
.DS_Store
Thumbs.db

313
codegen.js Normal file
View file

@ -0,0 +1,313 @@
/* codegen.js — Live-Python-Code-Vorschau (EINZIGE Quelle, aus editor.html ausgelagert) */
/*
PYTHON CODE GENERATOR Live-Vorschau (kompakt)
*/
// ── Progressive unlock tracking ─────────────────────────────
const unlocked = new Set();
let lastKey = null; // welcher Bereich wurde zuletzt angefasst → gelbes Highlight
function setCodeFocus(key){
unlocked.add(key);
lastKey = key;
scheduleCodeUpdate(50);
}
// Focus-Listener für Text-Eingaben
document.addEventListener('focusin', e=>{
const id = e.target.id || '';
if(id==='s1devname') setCodeFocus('dev');
if(id==='s1name') setCodeFocus('gamename');
if(id==='s1desc') setCodeFocus('gamedesc');
if(id && id.startsWith('qq')) setCodeFocus('quiz');
});
// Mini-Game IDs → Python-Funktionsnamen
const MG_PY = {
snake:'schlangen_spiel', flappy:'flug_spiel', memory:'memory', quiz:'quiz',
reaction:'reaktionstest', basketball:'basketball', catch:'fangen', maze:'labyrinth',
simon:'simon_says', puzzle:'raetsel', spotdiff:'unterschiede', typing:'tipp_rennen',
snake2p:'snake_duell', flappy2p:'flappy_duell',
};
// ─────────────────────────────────────────────
function buildPythonCode(){
const s = ST;
const fig = FIGURES.find(f=>f.id===s.figure);
const bg = BACKGROUNDS.find(b=>b.id===s.background);
// Leer bis zur ersten Interaktion
if(unlocked.size===0 && !s.devName && !s.name && !s.figure) return [];
const lines = [];
const hi = key => lastKey === key;
// ── Imports ──
lines.push({t:'kw', v:'import', rest:' boardgame_engine as engine'});
lines.push({t:'kw', v:'import', rest:' minigames'});
lines.push({t:'kw', v:'from', rest:' config import GameConfig, Field, StoryText'});
lines.push({t:'bl'});
// ── Grundeinstellungen ──
const anyBasic = s.devName||s.name||s.desc||s.figure||s.background
||unlocked.has('dev')||unlocked.has('gamename')||unlocked.has('gamedesc')
||unlocked.has('figure')||unlocked.has('background')||unlocked.has('movement')
||unlocked.has('failmode')||unlocked.has('lives')||unlocked.has('pts')||unlocked.has('fieldcount');
if(anyBasic){
lines.push({t:'cm', v:'# ── Grundeinstellungen ──'});
if(unlocked.has('dev') || s.devName){
if(s.devName) lines.push({t:'ass', var:'developer_name', val:`"${s.devName}"`, hi:hi('dev')});
else lines.push({t:'ph', var:'developer_name', hint:'← tippe deinen Namen'});
}
if(unlocked.has('gamename') || s.name){
if(s.name) lines.push({t:'ass', var:'game_name', val:`"${s.name}"`, hi:hi('gamename')});
else lines.push({t:'ph', var:'game_name', hint:'← tippe den Spielnamen'});
}
if(unlocked.has('gamedesc') || s.desc){
if(s.desc) lines.push({t:'ass', var:'game_description', val:`"${s.desc}"`, hi:hi('gamedesc')});
else lines.push({t:'ph', var:'game_description', hint:'← tippe eine Beschreibung'});
}
if(unlocked.has('figure') || s.figure){
if(s.figure) lines.push({t:'ass', var:'player_figure',
val:`"${s.figure}" # ${fig?fig.e+' '+fig.n:''}`, hi:hi('figure')});
else lines.push({t:'ph', var:'player_figure', hint:'← klicke eine Figur an'});
}
if(unlocked.has('background') || s.background){
if(s.background) lines.push({t:'ass', var:'world_setting',
val:`"${s.background}" # ${bg?bg.e+' '+bg.n:''}`, hi:hi('background')});
else lines.push({t:'ph', var:'world_setting', hint:'← klicke eine Welt an'});
}
if(unlocked.has('movement')){
lines.push({t:'ass', var:'movement_type',
val: s.rules.movement==='step' ? '"schritt" # 1 Feld pro Runde'
: '"wuerfeln" # 16 Felder',
hi:hi('movement')});
}
if(unlocked.has('failmode')){
if(s.rules.fail==='lives'){
lines.push({t:'ass', var:'verlust_system', val:'"leben"', hi:hi('failmode')});
lines.push({t:'ass', var:'anzahl_leben', val:String(s.rules.lives||3), hi:hi('lives')});
} else {
lines.push({t:'ass', var:'verlust_system', val:'"punkte"', hi:hi('failmode')});
lines.push({t:'ass', var:'punkte_pro_sieg',val:String(s.rules.pts||10), hi:hi('pts')});
}
}
if(unlocked.has('fieldcount')){
lines.push({t:'ass', var:'anzahl_felder', val:String(s.fieldCount), hi:hi('fieldcount')});
}
lines.push({t:'bl'});
}
// ── Konsequenz nach Mini-Game ──
if(unlocked.has('consequences')){
const c = s.consequences || {};
const win = c.win || 'nothing';
const lose = c.lose || 'nothing';
if(win !== 'nothing' || lose !== 'nothing'){
lines.push({t:'cm', v:'# ── Konsequenz nach Mini-Game ──'});
lines.push({t:'kw', v:'if', rest:' ergebnis == "gewonnen":'});
if(win === 'forward') lines.push({t:'ind', v:`spieler.position += ${c.winVal??2} # ${c.winVal??2} Felder vor`, hi:hi('consequences')});
else if(win === 'points') lines.push({t:'ind', v:`spieler.punkte += ${c.winPts??10} # +${c.winPts??10} Punkte`, hi:hi('consequences')});
else if(win === 'again') lines.push({t:'ind', v:'spieler.nochmal_wuerfeln = True', hi:hi('consequences')});
else lines.push({t:'ind', v:'pass', hi:hi('consequences')});
lines.push({t:'kw', v:'else', rest:':'});
if(lose === 'back') lines.push({t:'ind', v:`spieler.position -= ${c.loseVal??1} # ${c.loseVal??1} Felder zurück`, hi:hi('consequences')});
else if(lose === 'life') lines.push({t:'ind', v:'spieler.leben -= 1', hi:hi('consequences')});
else if(lose === 'points') lines.push({t:'ind', v:`spieler.punkte -= ${c.losePts??5} # ${c.losePts??5} Punkte`, hi:hi('consequences')});
else if(lose === 'skip') lines.push({t:'ind', v:'spieler.aussetzen = True', hi:hi('consequences')});
else lines.push({t:'ind', v:'pass', hi:hi('consequences')});
lines.push({t:'bl'});
}
}
// ── Spielfeld aufbauen ──
const hasAnyField = (s.fields||[]).some(Boolean);
if(unlocked.has('fields') || hasAnyField){
lines.push({t:'cm', v:'# ── Spielfeld aufbauen ──'});
lines.push({t:'fn-def', name:'setup_spielfeld', args:''});
lines.push({t:'ind', v:'felder = [Field(index=0, typ="start")]'});
for(let i=1;i<s.fieldCount-1;i++){
const fId=(s.fields||[])[i];
if(fId){
lines.push({t:'ind', v:`felder.append(Field(index=${i}, typ="minigame", spiel="${MG_PY[fId]||fId}"))`,
hi: hi('fields') && (s.fields||[])[i]===fId});
} else {
lines.push({t:'ind', v:`felder.append(Field(index=${i}, typ="leer"))`});
}
}
lines.push({t:'ind', v:`felder.append(Field(index=${s.fieldCount-1}, typ="ziel")) # 🏁`});
lines.push({t:'ind', v:'return felder'});
lines.push({t:'bl'});
}
// ── Mini-Game Funktionen ──
const usedGames=[...new Set((s.fields||[]).filter(Boolean))];
if(usedGames.length>0){
lines.push({t:'cm', v:'# ── Mini-Game Funktionen ──'});
usedGames.forEach(id=>{
const pyName=MG_PY[id]||id;
lines.push({t:'fn-def', name:`spiele_${pyName}`, args:'spieler'});
lines.push({t:'ind', v:`return minigames.${pyName}.starten(spieler).gewonnen`});
});
lines.push({t:'bl'});
}
// ── Quiz-Fragen ──
const quizItems=(s.quizData||[]).filter(q=>q&&q.question);
if(quizItems.length>0 || unlocked.has('quiz')){
lines.push({t:'cm', v:'# ── Quiz-Fragen ──'});
lines.push({t:'ass', var:'fragen', val:'[', hi:false});
quizItems.forEach(q=>{
const clean=q.question.replace(/"/g,"'").slice(0,50);
const ans=q.answers.map(a=>`"${(a||'').replace(/"/g,"'")}"`).join(', ');
const cor=q.correct!=null?`"${(q.answers[q.correct]||'').replace(/"/g,"'")}"`:'None';
lines.push({t:'ind', v:`{"frage": "${clean}", "antworten": [${ans}], "richtig": ${cor}},`});
});
if(quizItems.length===0) lines.push({t:'ind-cm', v:'# noch keine Fragen eingetragen...'});
lines.push({t:'ass-end', v:']'});
lines.push({t:'bl'});
}
// ── Erzähltexte ──
const storyList = (s.storyItems||[]).filter(st=>st&&st.text&&st.text.trim());
if(storyList.length>0){
lines.push({t:'cm', v:'# ── Erzähltexte ──'});
lines.push({t:'ass', var:'erzaehltexte', val:'[', hi:false});
storyList.forEach(st=>{
const txt = st.text.replace(/"/g,"'").slice(0,60);
const pos = st.position==='before' ? `vor Feld ${st.fieldIndex}` : `nach Feld ${st.fieldIndex}`;
lines.push({t:'ind', v:`{"text": "${txt}", "position": "${pos}"},`});
});
lines.push({t:'ass-end', v:']'});
lines.push({t:'bl'});
}
// ── Spiel starten ──
lines.push({t:'cm', v:'# ── Spiel starten ──'});
lines.push({t:'kw', v:'if', rest:" __name__ == '__main__':"});
lines.push({t:'ind', v:'config = GameConfig('});
lines.push({t:'ind2', v:`name=${s.name?`"${s.name}"`:'""'},`});
lines.push({t:'ind2', v:`developer=${s.devName?`"${s.devName}"`:'""'},`});
if(s.figure) lines.push({t:'ind2', v:`figure="${s.figure}",`});
if(s.background) lines.push({t:'ind2', v:`setting="${s.background}",`});
if(hasAnyField) lines.push({t:'ind2', v:'board=setup_spielfeld(),'});
if(quizItems.length) lines.push({t:'ind2', v:'questions=fragen,'});
if(storyList.length) lines.push({t:'ind2', v:'stories=erzaehltexte,'});
lines.push({t:'ind', v:')'});
lines.push({t:'ind', v:'engine.run(config)'});
return lines;
}
function renderToken(line){
const esc = s => String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
let r='';
switch(line.t){
case 'cm': r=`<span class="py-cm">${esc(line.v)}</span>`; break;
case 'bl': return '';
case 'ph': r=`<span class="py-var">${esc(line.var)}</span><span class="py-punc"> = </span><span class="py-cm">"???" <span style="color:#e06c75;font-style:italic">${esc(line.hint||'')}</span></span>`; break;
case 'kw': r=`<span class="py-kw">${esc(line.v)}</span><span class="py-val">${esc(line.rest||'')}</span>`; break;
case 'ass': r=`<span class="py-var">${esc(line.var)}</span><span class="py-punc"> = </span><span class="py-str">${esc(String(line.val))}</span>`; break;
case 'ass-end':r=`<span class="py-punc">${esc(line.v)}</span>`; break;
case 'fn-def': r=`<span class="py-kw">def </span><span class="py-fn">${esc(line.name)}</span><span class="py-punc">(${esc(line.args||'')})</span><span class="py-punc">:</span>`; break;
case 'ind': r=` <span class="py-val">${esc(line.v)}</span>`; break;
case 'ind2': r=` <span class="py-val">${esc(line.v)}</span>`; break;
case 'ind-cm': r=` <span class="py-cm">${esc(line.v)}</span>`; break;
default: return esc(line.v||'');
}
return line.hi ? `<span class="py-hi">${r}</span>` : r;
}
let codeTypingTimer = null;
let lastLineCount = 0;
let lastKey_scroll = null;
function updateCodePane(){
const lines = buildPythonCode();
const codeEl = document.getElementById('codeContent');
const numsEl = document.getElementById('codeLineNums');
const linesEl= document.getElementById('codeLines');
const scroll = document.getElementById('codeScroll');
if(!codeEl) return;
// Dateiname
const dev = ST.devName ? ST.devName.toLowerCase().replace(/[^a-z0-9]/g,'_') : 'dev';
const gn = ST.name ? ST.name.toLowerCase().replace(/[^a-z0-9]/g,'_').slice(0,20) : 'boardgame';
const fnEl = document.getElementById('codeFilename');
if(fnEl) fnEl.textContent = (ST.name||ST.devName) ? `${gn}_von_${dev}.py` : 'boardgame.py';
if(lines.length===0){
codeEl.innerHTML='<span class="py-cm"># Klicke ein Feld an — dein Code erscheint hier ✨</span>';
numsEl.innerHTML='<div>1</div>';
if(linesEl) linesEl.textContent='1 Zeile';
if(scroll) scroll.scrollTop = 0;
lastLineCount=0; return;
}
let html='', lineNum=1;
const lineNums=[];
lines.forEach(line=>{
if(line.t==='bl'){ html+='\n'; }
else { html+=renderToken(line)+'\n'; }
lineNums.push(lineNum++);
});
const newCount = lines.length;
const grew = newCount > lastLineCount;
lastLineCount = newCount;
codeEl.innerHTML = html;
numsEl.innerHTML = lineNums.map(n=>`<div>${n}</div>`).join('');
if(linesEl) linesEl.textContent = lineNum+' Zeilen';
const status = document.getElementById('codeStatus');
if(status){ status.textContent='● Live'; setTimeout(()=>{ status.textContent='● Bereit'; },800); }
// Nach Layout: scrollTop auf gültigen Bereich begrenzen (verhindert "ins Leere gescrollt")
// UND optional zum Highlight scrollen (nur bei grow + neuem Key, damit nichts thrasht)
if(scroll){
requestAnimationFrame(()=>{
const maxScroll = Math.max(0, scroll.scrollHeight - scroll.clientHeight);
if(scroll.scrollTop > maxScroll) scroll.scrollTop = maxScroll;
if(grew && lastKey !== lastKey_scroll){
lastKey_scroll = lastKey;
const hiEl = codeEl.querySelector('.py-hi');
if(hiEl){
const preTop = codeEl.getBoundingClientRect().top;
const hiTop = hiEl.getBoundingClientRect().top;
const offset = hiTop - preTop;
let target = scroll.scrollTop + offset - 80;
target = Math.max(0, Math.min(maxScroll, target));
scroll.scrollTo({top: target, behavior:'smooth'});
}
}
});
}
}
function scheduleCodeUpdate(delay=80){
clearTimeout(codeTypingTimer);
codeTypingTimer = setTimeout(updateCodePane, delay);
}
function downloadPy(){
const codeEl=document.getElementById('codeContent');
if(!codeEl)return;
const text=codeEl.innerText||codeEl.textContent||'';
const dev=(ST.devName||'dev').toLowerCase().replace(/[^a-z0-9]/g,'_');
const gn=(ST.name||'boardgame').toLowerCase().replace(/[^a-z0-9]/g,'_').slice(0,20);
const fname=`${gn}_von_${dev}.py`;
const blob=new Blob([text],{type:'text/plain'});
const a=document.createElement('a');
a.href=URL.createObjectURL(blob);
a.download=fname;
a.click();
URL.revokeObjectURL(a.href);
}
document.addEventListener('input', ()=>scheduleCodeUpdate(50));
document.addEventListener('change', ()=>scheduleCodeUpdate(50));
// Initial render
setTimeout(updateCodePane, 200);

616
editor.css Normal file
View file

@ -0,0 +1,616 @@
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#0f0e17;--surface:#1a1827;--card:#22203a;
--accent:#f5a623;--accent2:#7c3aed;--accent3:#10b981;
--danger:#ef4444;--text:#fffffe;--muted:#a7a3c2;--border:#2e2b4a;--radius:16px;
}
html,body{height:100%;margin:0;padding:0}
body{
color:var(--text);font-family:'Nunito',sans-serif;
background:var(--bg);
background-image:radial-gradient(ellipse at 20% 10%,rgba(124,58,237,0.18) 0%,transparent 50%),radial-gradient(ellipse at 80% 90%,rgba(245,166,35,0.10) 0%,transparent 50%);
background-attachment:fixed;
display:flex;flex-direction:column;height:100vh;overflow:hidden;
}
/* ─── SPLIT LAYOUT ─── */
#splitWrap{
display:flex;flex-direction:row;flex:1 1 0;overflow:hidden;min-height:0;width:100%;
}
#editorPane{
flex:0 0 65%;width:65%;overflow-y:auto;overflow-x:hidden;min-width:0;
border-right:1px solid var(--border);
order:1;
scrollbar-width:thin;scrollbar-color:rgba(124,58,237,0.3) transparent;
}
#editorPane::-webkit-scrollbar{width:5px}
#editorPane::-webkit-scrollbar-thumb{background:rgba(124,58,237,0.25);border-radius:3px}
#editorPane::-webkit-scrollbar-thumb:hover{background:rgba(124,58,237,0.5)}
#resizerBar{
flex:0 0 5px;width:5px;background:var(--border);cursor:col-resize;
transition:background 0.2s;user-select:none;
display:flex;align-items:center;justify-content:center;
position:relative;z-index:10;order:2;
}
#resizerBar:hover,#resizerBar.dragging{background:var(--accent2);}
#resizerBar::after{
content:'⋮';color:rgba(255,255,255,0.25);font-size:1.1rem;letter-spacing:-2px;
pointer-events:none;
}
#codePane{
flex:1 1 0;min-width:0;overflow:hidden;display:flex;flex-direction:column;
background:#0d0c1a;order:3;
}
/* ─── PROGRESS BAR ─── */
#progressBar{position:relative;z-index:200;flex-shrink:0;width:100%;box-sizing:border-box;background:rgba(15,14,23,0.95);backdrop-filter:blur(14px);border-bottom:1px solid rgba(255,255,255,0.07);padding:10px 24px}
.pb-inner{max-width:100%;margin:0 auto}
.pb-top{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px}
.pb-logo{font-family:'Fredoka One',cursive;font-size:0.95rem;color:var(--accent);display:flex;align-items:center;gap:8px}
.pb-phwg{display:flex;align-items:center;gap:10px}
.pb-phwg img{height:32px;width:auto;object-fit:contain;opacity:0.92}
.pb-phwg-text{display:flex;flex-direction:column;gap:1px}
.pb-phwg-studiengaenge{font-size:9px;font-weight:700;color:var(--muted);letter-spacing:0.3px;line-height:1.4}
.pb-phwg-studiengaenge span{color:rgba(245,166,35,0.7)}
@media(max-width:640px){.pb-phwg-text{display:none}}
.pb-steps{display:flex;align-items:center;gap:0}
.pb-step{display:flex;align-items:center;gap:7px;flex:1;cursor:default;opacity:0.38;transition:opacity 0.3s}
.pb-step.done{opacity:0.7;cursor:pointer}
.pb-step.done:hover{opacity:1}
.pb-step.active{opacity:1}
.pb-dot{width:28px;height:28px;border-radius:50%;border:2.5px solid var(--muted);display:flex;align-items:center;justify-content:center;font-family:'Fredoka One',cursive;font-size:0.85rem;flex-shrink:0;transition:all 0.3s}
.pb-step.active .pb-dot{border-color:var(--accent);background:var(--accent);color:#000;box-shadow:0 0 12px rgba(245,166,35,0.5)}
.pb-step.done .pb-dot{border-color:var(--accent3);background:var(--accent3);color:#fff}
.pb-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.5px;white-space:nowrap}
.pb-step.active .pb-label{color:var(--accent)}
.pb-step.done .pb-label{color:var(--accent3)}
.pb-connector{flex:1;height:2px;background:var(--border);margin:0 6px;transition:background 0.4s;min-width:12px}
.pb-connector.done{background:var(--accent3)}
@media(max-width:580px){.pb-label{display:none}}
.pb-track{height:3px;background:var(--border);border-radius:999px;margin-top:10px;overflow:hidden}
.pb-fill{height:100%;background:linear-gradient(90deg,var(--accent3),var(--accent));border-radius:999px;transition:width 0.5s cubic-bezier(0.34,1.56,0.64,1)}
/* ─── SCREENS ─── */
.screen{display:none;max-width:680px;margin:0 auto;padding:32px 16px 80px}
.screen.active{display:block}
.slide-in{animation:slideIn 0.38s cubic-bezier(0.16,1,0.3,1) both}
.slide-out{animation:slideOut 0.22s ease both}
@keyframes slideIn{from{opacity:0;transform:translateX(40px)}to{opacity:1;transform:translateX(0)}}
@keyframes slideOut{from{opacity:1;transform:translateX(0)}to{opacity:0;transform:translateX(-30px)}}
@keyframes fadeUp{from{opacity:0;transform:translateY(16px)}to{opacity:1;transform:translateY(0)}}
@keyframes popIn{from{opacity:0;transform:scale(0.9)}to{opacity:1;transform:scale(1)}}
@keyframes float{0%,100%{transform:translateY(0)}50%{transform:translateY(-10px)}}
/* ─── SCREEN HEADER ─── */
.sh{text-align:center;margin-bottom:28px}
.sh-badge{display:inline-block;background:var(--accent2);color:#fff;font-size:11px;font-weight:700;letter-spacing:2px;text-transform:uppercase;padding:4px 14px;border-radius:999px;margin-bottom:12px}
.sh-title{font-family:'Fredoka One',cursive;font-size:clamp(1.8rem,4vw,2.5rem)}
.sh-title em{color:var(--accent);font-style:normal}
.sh-sub{color:var(--muted);font-size:14px;margin-top:8px;line-height:1.6}
/* ─── STEP SUMMARY BANNER ─── */
.summary-banner{background:linear-gradient(135deg,rgba(16,185,129,0.12),rgba(16,185,129,0.04));border:1.5px solid rgba(16,185,129,0.4);border-radius:14px;padding:18px 22px;margin-bottom:20px;display:none;animation:fadeUp 0.4s ease both}
.summary-banner.visible{display:block}
.sb-title{font-family:'Fredoka One',cursive;font-size:1.1rem;color:var(--accent3);margin-bottom:8px;display:flex;align-items:center;gap:8px}
.sb-chips{display:flex;gap:8px;flex-wrap:wrap}
.sb-chip{background:rgba(16,185,129,0.15);border:1px solid rgba(16,185,129,0.3);border-radius:8px;padding:4px 12px;font-size:12px;font-weight:700;color:var(--accent3)}
/* ─── CARD ─── */
.card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:26px;margin-bottom:18px}
.card-title{font-family:'Fredoka One',cursive;font-size:1.15rem;margin-bottom:5px;display:flex;align-items:center;gap:10px}
.card-sub{color:var(--muted);font-size:13px;margin-bottom:18px;line-height:1.6}
.divider{height:1px;background:var(--border);margin:20px 0}
/* ─── FORM ─── */
label.lbl{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.8px;color:var(--muted);margin-bottom:7px;display:block}
.finput,.ftarea{width:100%;background:var(--surface);border:1.5px solid var(--border);border-radius:10px;color:var(--text);font-family:'Nunito',sans-serif;font-size:15px;font-weight:600;padding:12px 16px;outline:none;transition:border-color 0.2s,box-shadow 0.2s}
.finput:focus,.ftarea:focus{border-color:var(--accent2);box-shadow:0 0 0 3px rgba(124,58,237,0.14)}
.finput::placeholder,.ftarea::placeholder{color:#4a476a}
.ftarea{resize:none}
.char-row{display:flex;justify-content:flex-end;font-size:11px;color:var(--muted);margin-top:4px}
.char-row.warn{color:var(--accent)}
/* ─── NAV BUTTONS ─── */
.btn-row{display:flex;justify-content:space-between;gap:12px;margin-top:28px}
.btn-back{background:var(--surface);color:var(--muted);font-family:'Fredoka One',cursive;font-size:1rem;border:1.5px solid var(--border);border-radius:12px;padding:13px 24px;cursor:pointer;transition:all 0.2s}
.btn-back:hover{border-color:var(--muted);color:var(--text)}
.btn-next{background:linear-gradient(135deg,var(--accent2),var(--accent));color:#fff;font-family:'Fredoka One',cursive;font-size:1rem;border:none;border-radius:12px;padding:14px 32px;cursor:pointer;display:flex;align-items:center;gap:8px;transition:transform 0.15s,box-shadow 0.15s,opacity 0.2s;box-shadow:0 4px 20px rgba(124,58,237,0.35)}
.btn-next:hover:not(:disabled){transform:translateY(-2px);box-shadow:0 8px 28px rgba(124,58,237,0.45)}
.btn-next:disabled{opacity:0.32;cursor:not-allowed;transform:none}
/* ─── PREVIEW CARD ─── */
.preview-card{background:linear-gradient(135deg,rgba(124,58,237,0.15),rgba(245,166,35,0.08));border:1px solid rgba(124,58,237,0.35);border-radius:14px;padding:18px 22px;margin-bottom:18px;animation:fadeUp 0.3s ease both}
.preview-card .pv-label{font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:1px;color:var(--accent2);margin-bottom:6px}
.preview-card .pv-name{font-family:'Fredoka One',cursive;font-size:1.5rem;color:var(--accent)}
.preview-card .pv-desc{font-size:13px;color:var(--muted);margin-top:4px;line-height:1.5}
/* ─── SUGGESTIONS ─── */
.suggestions{display:flex;flex-wrap:wrap;gap:6px;margin-top:10px}
.sug{background:var(--surface);border:1px solid var(--border);border-radius:7px;padding:4px 12px;font-size:12px;color:var(--muted);cursor:pointer;transition:all 0.15s}
.sug:hover{border-color:var(--accent2);color:var(--text)}
/* ─── SELECTION GRID (step 2) ─── */
.sel-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(90px,1fr));gap:9px;margin-bottom:14px}
.sel-card{background:var(--surface);border:2px solid var(--border);border-radius:12px;padding:14px 6px;text-align:center;cursor:pointer;transition:all 0.2s;user-select:none}
.sel-card:hover{border-color:var(--accent2);transform:translateY(-2px)}
.sel-card.selected{border-color:var(--accent);background:rgba(245,166,35,0.07);box-shadow:0 0 14px rgba(245,166,35,0.2)}
.sel-card.disabled{opacity:0.35;cursor:not-allowed;filter:grayscale(0.7)}
.sel-card.disabled:hover{transform:none;border-color:var(--border)}
.sel-card .se{font-size:1.9rem;display:block;margin-bottom:5px}
.sel-card .sl{font-size:11px;font-weight:700;color:var(--muted)}
.sel-card.selected .sl{color:var(--accent)}
.combo-preview{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:18px;text-align:center;margin-top:10px;animation:popIn 0.3s ease both}
.combo-preview .cp-scene{font-size:3rem;display:block;margin-bottom:6px;animation:float 3s ease-in-out infinite}
.combo-preview .cp-label{font-size:12px;color:var(--muted);font-weight:700}
/* ─── RULES (step 3) ─── */
.rules-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:4px}
@media(max-width:560px){.rules-grid{grid-template-columns:1fr}}
.rule-block{background:var(--surface);border:1.5px solid var(--border);border-radius:12px;padding:15px}
.rule-block-title{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.8px;color:var(--muted);margin-bottom:11px}
.toggle-group{display:flex;flex-direction:column;gap:8px}
.toggle-option{display:flex;align-items:center;gap:9px;padding:10px 12px;border-radius:9px;border:2px solid var(--border);cursor:pointer;transition:all 0.2s;user-select:none}
.toggle-option:hover{border-color:var(--accent2)}
.toggle-option.selected{border-color:var(--accent);background:rgba(245,166,35,0.07)}
.tog-emoji{font-size:1.2rem;flex-shrink:0}
.tog-text{font-size:13px;font-weight:700}
.tog-sub{font-size:11px;color:var(--muted)}
.tog-check{margin-left:auto;width:18px;height:18px;border-radius:50%;border:2px solid var(--border);transition:all 0.2s;flex-shrink:0}
.toggle-option.selected .tog-check{background:var(--accent);border-color:var(--accent)}
.sub-opts{display:none;gap:7px;margin-top:9px;flex-wrap:wrap}
.sub-opts.visible{display:flex}
.sub-opt{background:var(--card);border:1.5px solid var(--border);border-radius:8px;padding:6px 13px;font-size:13px;font-weight:700;cursor:pointer;transition:all 0.15s;color:var(--muted)}
.sub-opt:hover{border-color:var(--accent2);color:var(--text)}
.sub-opt.selected{border-color:var(--accent);color:var(--accent);background:rgba(245,166,35,0.07)}
.count-row{display:flex;align-items:center;gap:14px;background:var(--surface);border:1.5px solid var(--border);border-radius:12px;padding:14px 18px;margin-bottom:18px}
.count-row label{font-size:13px;font-weight:800;text-transform:uppercase;letter-spacing:0.5px;color:var(--muted)}
.count-ctrl{display:flex;align-items:center;gap:10px;margin-left:auto}
.cnt-btn{width:36px;height:36px;border-radius:8px;background:var(--card);border:1.5px solid var(--border);color:var(--text);font-size:1.2rem;font-weight:800;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all 0.15s}
.cnt-btn:hover:not(:disabled){border-color:var(--accent);color:var(--accent)}
.cnt-btn:disabled{opacity:0.3;cursor:not-allowed}
.cnt-val{font-family:'Fredoka One',cursive;font-size:1.8rem;color:var(--accent);min-width:36px;text-align:center}
/* ─── PALETTE ─── */
.pal-tabs{display:flex;gap:8px;margin-bottom:12px}
.pal-tab{background:var(--surface);border:1.5px solid var(--border);border-radius:9px;padding:7px 16px;font-size:12px;font-weight:800;cursor:pointer;color:var(--muted);transition:all 0.2s;text-transform:uppercase;letter-spacing:0.5px}
.pal-tab.active{background:var(--accent2);border-color:var(--accent2);color:#fff}
.pal-panel{display:none}
.pal-panel.active{display:grid;grid-template-columns:repeat(auto-fill,minmax(105px,1fr));gap:9px;margin-bottom:18px}
.mg-card{background:var(--surface);border:2px solid var(--border);border-radius:11px;padding:11px 7px 8px;display:flex;flex-direction:column;align-items:center;gap:4px;cursor:grab;transition:all 0.2s;user-select:none;position:relative}
.mg-card:hover:not(.disabled){border-color:var(--accent2);transform:translateY(-2px);box-shadow:0 5px 18px rgba(124,58,237,0.2)}
.mg-card.click-sel{outline:3px solid var(--accent);outline-offset:2px;background:rgba(245,166,35,0.06)}
.mg-card.disabled{opacity:0.28;cursor:not-allowed;filter:grayscale(0.5);pointer-events:none}
.mg-emoji{font-size:1.6rem;pointer-events:none}
.mg-lbl{font-size:11px;font-weight:700;color:var(--muted);text-align:center;line-height:1.3;pointer-events:none}
.mg-tag{font-size:9px;font-weight:800;letter-spacing:0.5px;text-transform:uppercase;padding:2px 7px;border-radius:999px;pointer-events:none}
.mg-tag.once{background:rgba(239,68,68,0.2);color:#f87171}
.mg-tag.multi{background:rgba(16,185,129,0.2);color:var(--accent3)}
.mg-tag.unlim{background:rgba(245,166,35,0.2);color:var(--accent)}
.mg-tag.story-tag{background:rgba(6,182,212,0.2);color:#22d3ee}
.mg-tag.requires2p{background:rgba(124,58,237,0.25);color:#c4b5fd}
.mg-card.disabled .mg-test-btn{pointer-events:none;}
.mg-lock{position:absolute;bottom:4px;left:4px;right:4px;font-size:9px;font-weight:700;color:#a78bfa;background:rgba(124,58,237,0.15);border:1px solid rgba(124,58,237,0.35);border-radius:6px;padding:2px 4px;text-align:center;pointer-events:none;}
.use-count{position:absolute;top:4px;right:4px;background:var(--accent);color:#000;font-size:10px;font-weight:900;width:17px;height:17px;border-radius:50%;display:none;align-items:center;justify-content:center;pointer-events:none}
.mg-card.used .use-count{display:flex}
.story-counter{display:inline-flex;align-items:center;gap:5px;background:rgba(6,182,212,0.12);border:1px solid rgba(6,182,212,0.4);border-radius:999px;padding:3px 10px;font-size:11px;font-weight:800;color:#22d3ee}
.story-counter.maxed{background:rgba(239,68,68,0.12);border-color:var(--danger);color:var(--danger)}
.sec-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:1px;color:var(--muted);margin-bottom:10px;display:flex;align-items:center;justify-content:space-between}
/* ─── BOARD ─── */
.board-builder{display:flex;flex-direction:column;gap:2px}
.drop-zone{height:10px;border-radius:7px;border:2px dashed transparent;transition:all 0.2s;display:flex;align-items:center;justify-content:center;font-size:11px;color:transparent;font-weight:700;cursor:pointer}
.drop-zone.story-hoverable{height:28px;border-color:rgba(6,182,212,0.3);color:rgba(6,182,212,0.4)}
.drop-zone.drag-over{height:38px;background:rgba(6,182,212,0.12);border-color:#22d3ee;color:#22d3ee}
.field-row{display:flex;align-items:center;gap:10px;background:var(--surface);border:1.5px solid var(--border);border-radius:9px;padding:9px 13px;transition:all 0.2s;cursor:pointer}
.field-row:hover:not(.start-f):not(.end-f){border-color:var(--accent2)}
.field-row.filled{border-color:rgba(124,58,237,0.5)}
.field-row.start-f{border-color:var(--accent3);cursor:default}
.field-row.end-f{border-color:var(--accent);cursor:default}
.field-row.drag-target{background:rgba(124,58,237,0.1);border-color:var(--accent)!important;border-style:dashed}
.f-num{font-family:'Fredoka One',cursive;font-size:0.85rem;color:var(--muted);min-width:54px;flex-shrink:0}
.field-row.start-f .f-num{color:var(--accent3)}
.field-row.end-f .f-num{color:var(--accent)}
.f-icon{font-size:1.2rem;min-width:26px;text-align:center;flex-shrink:0}
.f-name{font-size:13px;font-weight:700;flex:1}
.f-empty{color:var(--muted);font-style:italic;font-weight:400}
.f-clear{background:none;border:none;color:var(--muted);cursor:pointer;font-size:0.9rem;padding:4px 6px;border-radius:6px;transition:all 0.15s;display:none}
.field-row.filled .f-clear{display:block}
.f-clear:hover{color:var(--danger);background:rgba(239,68,68,0.1)}
.story-row{display:flex;align-items:flex-start;gap:10px;background:rgba(6,182,212,0.07);border:1.5px solid rgba(6,182,212,0.4);border-radius:9px;padding:11px 13px;animation:fadeUp 0.25s ease both}
.story-icon{font-size:1.3rem;cursor:pointer;transition:transform 0.2s;flex-shrink:0;margin-top:2px}
.story-icon:hover{transform:scale(1.2)}
.story-content{flex:1}
.story-lbl{font-size:9px;font-weight:800;text-transform:uppercase;letter-spacing:1px;color:#22d3ee;margin-bottom:4px}
.story-ta{width:100%;background:rgba(6,182,212,0.06);border:1.5px solid rgba(6,182,212,0.3);border-radius:7px;color:var(--text);font-family:'Nunito',sans-serif;font-size:13px;font-weight:600;padding:7px 10px;resize:none;outline:none;transition:border-color 0.2s}
.story-ta:focus{border-color:#22d3ee}
.story-ta::placeholder{color:#3a6e7a}
.story-meta{display:flex;align-items:center;gap:6px;margin-top:5px}
.story-pos-lbl{font-size:10px;font-weight:700;color:#22d3ee;background:rgba(6,182,212,0.12);padding:2px 8px;border-radius:999px}
.story-chars{font-size:10px;color:var(--muted);margin-left:auto}
.story-clear{background:none;border:none;color:var(--muted);cursor:pointer;font-size:0.85rem;padding:4px;border-radius:5px;transition:all 0.15s;flex-shrink:0}
.story-clear:hover{color:var(--danger);background:rgba(239,68,68,0.1)}
.emoji-picker{position:fixed;background:var(--card);border:1.5px solid var(--border);border-radius:12px;padding:10px;display:grid;grid-template-columns:repeat(6,1fr);gap:4px;z-index:1000;box-shadow:0 8px 30px rgba(0,0,0,0.5);display:none}
.emoji-picker.open{display:grid}
.ep-it{font-size:1.2rem;cursor:pointer;padding:4px;border-radius:6px;text-align:center;transition:background 0.15s}
.ep-it:hover{background:var(--surface)}
.board-stats{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px}
.stat-pill{background:var(--surface);border:1px solid var(--border);border-radius:999px;padding:5px 13px;font-size:12px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:5px}
.stat-pill span{color:var(--text)}
#dragGhost{position:fixed;pointer-events:none;z-index:9999;background:var(--card);border:2px solid var(--accent);border-radius:10px;padding:7px 14px;font-size:1rem;display:flex;align-items:center;gap:7px;box-shadow:0 8px 28px rgba(0,0,0,0.6);transform:translate(-50%,-50%);opacity:0;transition:opacity 0.1s;font-family:'Nunito',sans-serif;font-weight:700;color:var(--text)}
/* ─── QUIZ (step 4) ─── */
.quiz-card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:22px;margin-bottom:14px;position:relative;overflow:hidden}
.quiz-card::before{content:'';position:absolute;left:0;top:0;bottom:0;width:4px;background:linear-gradient(180deg,var(--accent2),var(--accent))}
.quiz-card.complete::before{background:linear-gradient(180deg,var(--accent3),#059669)}
.qc-head{display:flex;align-items:center;gap:10px;margin-bottom:14px}
.qc-num{width:32px;height:32px;border-radius:50%;background:linear-gradient(135deg,var(--accent2),var(--accent));color:#fff;font-family:'Fredoka One',cursive;font-size:0.9rem;display:flex;align-items:center;justify-content:center;flex-shrink:0}
.quiz-card.complete .qc-num{background:linear-gradient(135deg,var(--accent3),#059669)}
.qc-title{font-family:'Fredoka One',cursive;font-size:1rem;flex:1}
.qc-status{font-size:11px;font-weight:800;text-transform:uppercase;padding:3px 9px;border-radius:999px}
.qc-status.done{background:rgba(16,185,129,0.12);color:var(--accent3);border:1px solid var(--accent3)}
.qc-status.todo{background:rgba(245,166,35,0.08);color:var(--accent);border:1px solid var(--accent)}
.q-input{width:100%;background:var(--surface);border:1.5px solid var(--border);border-radius:9px;color:var(--text);font-family:'Nunito',sans-serif;font-size:14px;font-weight:600;padding:11px 13px;outline:none;transition:border-color 0.2s,box-shadow 0.2s;margin-bottom:12px;resize:none}
.q-input:focus{border-color:var(--accent2);box-shadow:0 0 0 3px rgba(124,58,237,0.12)}
.q-input::placeholder{color:#4a476a}
.ans-grid{display:grid;grid-template-columns:1fr 1fr;gap:7px;margin-bottom:12px}
@media(max-width:480px){.ans-grid{grid-template-columns:1fr}}
.ans-wrap{position:relative}
.ans-letter{position:absolute;left:10px;top:50%;transform:translateY(-50%);font-family:'Fredoka One',cursive;font-size:0.85rem;width:22px;height:22px;border-radius:6px;display:flex;align-items:center;justify-content:center;background:var(--border);color:var(--muted);pointer-events:none;z-index:1;transition:all 0.2s}
.ans-wrap.correct .ans-letter{background:var(--accent3);color:#fff}
.ans-input{width:100%;background:var(--surface);border:1.5px solid var(--border);border-radius:9px;color:var(--text);font-family:'Nunito',sans-serif;font-size:13px;font-weight:600;padding:9px 11px 9px 40px;outline:none;transition:border-color 0.2s}
.ans-input:focus{border-color:var(--accent2)}
.ans-wrap.correct .ans-input{border-color:var(--accent3);background:rgba(16,185,129,0.06)}
.correct-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.correct-lbl{font-size:12px;font-weight:800;color:var(--muted);text-transform:uppercase;letter-spacing:0.5px}
.correct-btns{display:flex;gap:7px}
.correct-btn{width:33px;height:33px;border-radius:8px;background:var(--surface);border:1.5px solid var(--border);font-family:'Fredoka One',cursive;font-size:0.85rem;color:var(--muted);cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all 0.2s}
.correct-btn:hover{border-color:var(--accent3);color:var(--accent3)}
.correct-btn.selected{background:var(--accent3);border-color:var(--accent3);color:#fff;box-shadow:0 0 10px rgba(16,185,129,0.35)}
.tips-btn{background:none;border:none;color:var(--accent2);font-size:12px;font-weight:800;cursor:pointer;padding:0;margin-top:10px;display:flex;align-items:center;gap:5px;text-transform:uppercase;letter-spacing:0.5px}
.tips-box{background:rgba(124,58,237,0.07);border:1px solid rgba(124,58,237,0.25);border-radius:9px;padding:11px 13px;margin-top:7px;font-size:13px;color:var(--muted);line-height:1.7;display:none}
.tips-box.open{display:block}
.no-quiz{text-align:center;padding:36px 20px}
.no-quiz .nq-icon{font-size:3rem;margin-bottom:10px}
.no-quiz h3{font-family:'Fredoka One',cursive;font-size:1.2rem;color:var(--muted);margin-bottom:6px}
.af-bar{display:flex;align-items:center;gap:12px;background:var(--surface);border:1px solid var(--border);border-radius:11px;padding:13px 16px;margin-bottom:14px}
.af-icon{font-size:1.4rem;flex-shrink:0}
.af-text .af-title{font-family:'Fredoka One',cursive;font-size:0.95rem}
.af-text .af-sub{font-size:12px;color:var(--muted)}
.btn-sm{background:linear-gradient(135deg,var(--accent2),var(--accent));color:#fff;font-family:'Fredoka One',cursive;font-size:0.85rem;border:none;border-radius:8px;padding:9px 16px;cursor:pointer;transition:transform 0.15s;white-space:nowrap;flex-shrink:0}
.btn-sm:hover{transform:translateY(-1px)}
.ov-bar{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px}
.ov-pill{background:var(--card);border:1px solid var(--border);border-radius:999px;padding:5px 13px;font-size:12px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:5px}
.ov-pill span{color:var(--text)}
.ov-pill.good span{color:var(--accent3)}
.ov-pill.warn span{color:var(--accent)}
/* ─── REVIEW + TEST (step 5) ─── */
.hero-card{background:linear-gradient(135deg,rgba(124,58,237,0.18),rgba(245,166,35,0.08));border:1px solid rgba(124,58,237,0.35);border-radius:var(--radius);padding:26px;margin-bottom:18px;display:flex;align-items:center;gap:18px}
.hero-scene{font-size:3rem;line-height:1;flex-shrink:0;position:relative}
.hero-fig{position:absolute;bottom:-4px;right:-14px;font-size:2rem}
.hero-info .hi-name{font-family:'Fredoka One',cursive;font-size:1.7rem;color:var(--accent)}
.hero-info .hi-desc{color:var(--muted);font-size:13px;margin-top:4px;line-height:1.5}
.hero-tags{display:flex;gap:7px;flex-wrap:wrap;margin-top:9px}
.hero-tag{background:rgba(124,58,237,0.18);border:1px solid rgba(124,58,237,0.3);border-radius:999px;padding:3px 11px;font-size:11px;font-weight:700;color:#c4b5fd}
.info-grid{display:grid;grid-template-columns:1fr 1fr;gap:9px}
@media(max-width:500px){.info-grid{grid-template-columns:1fr}}
.info-block{background:var(--surface);border:1px solid var(--border);border-radius:11px;padding:13px}
.ib-lbl{font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:1px;color:var(--muted);margin-bottom:4px}
.ib-val{font-family:'Fredoka One',cursive;font-size:1.05rem}
.tl{display:flex;flex-direction:column}
.tl-item{display:flex;align-items:flex-start;gap:11px;padding:7px 0;position:relative}
.tl-item:not(:last-child)::after{content:'';position:absolute;left:16px;top:38px;bottom:-7px;width:2px;background:var(--border)}
.tl-item.story-it:not(:last-child)::after{background:rgba(6,182,212,0.3)}
.tl-dot{width:34px;height:34px;border-radius:50%;flex-shrink:0;display:flex;align-items:center;justify-content:center;font-size:0.95rem;border:2px solid var(--border);background:var(--surface)}
.tl-dot.s{background:rgba(16,185,129,0.12);border-color:var(--accent3)}
.tl-dot.e{background:rgba(245,166,35,0.12);border-color:var(--accent)}
.tl-dot.st{background:rgba(6,182,212,0.12);border-color:#22d3ee}
.tl-dot.g{background:rgba(124,58,237,0.12);border-color:var(--accent2)}
.tl-dot.em{opacity:0.35}
.tl-lbl{font-size:13px;font-weight:800;padding-top:6px}
.tl-lbl.s{color:var(--accent3)}.tl-lbl.e{color:var(--accent)}.tl-lbl.st{color:#22d3ee}.tl-lbl.g,.tl-lbl.em{color:var(--muted)}
.tl-sub{font-size:12px;color:var(--muted);margin-top:1px;line-height:1.5}
.tl-badge{display:inline-block;background:rgba(124,58,237,0.12);border:1px solid var(--accent2);border-radius:6px;padding:2px 7px;font-size:10px;font-weight:800;color:var(--accent2);margin-top:2px;text-transform:uppercase;letter-spacing:0.5px}
.tl-badge.st{background:rgba(6,182,212,0.1);border-color:#22d3ee;color:#22d3ee}
.qp-list{display:flex;flex-direction:column;gap:7px}
.qp-item{background:var(--surface);border:1px solid var(--border);border-radius:9px;padding:11px 13px}
.qp-q{font-size:13px;font-weight:700;margin-bottom:6px}
.qp-ans-grid{display:grid;grid-template-columns:1fr 1fr;gap:4px}
.qp-ans{font-size:11px;padding:4px 8px;border-radius:6px;background:var(--card);border:1px solid var(--border);color:var(--muted);display:flex;align-items:center;gap:4px}
.qp-ans.cor{background:rgba(16,185,129,0.1);border-color:var(--accent3);color:var(--accent3);font-weight:700}
.checklist{display:flex;flex-direction:column;gap:6px}
.chk-item{background:var(--surface);border:1px solid var(--border);border-radius:9px;padding:9px 13px;display:flex;align-items:center;gap:8px;font-size:13px}
.chk-item.ok{border-color:rgba(16,185,129,0.3);color:var(--accent3)}
.chk-item.warn{border-color:rgba(245,166,35,0.3);color:var(--accent)}
/* ─── LAUNCH CARD ─── */
.launch-hero{background:linear-gradient(135deg,rgba(124,58,237,0.22),rgba(245,166,35,0.1));border-bottom:1px solid var(--border);padding:30px;text-align:center;position:relative;overflow:hidden}
.launch-hero::before{content:'';position:absolute;inset:0;background:radial-gradient(ellipse at 50% 0%,rgba(124,58,237,0.28),transparent 70%)}
.lh-fig{font-size:3.8rem;display:block;margin-bottom:9px;animation:float 3s ease-in-out infinite;position:relative;z-index:1}
.lh-name{font-family:'Fredoka One',cursive;font-size:1.7rem;color:var(--accent);position:relative;z-index:1}
.lh-desc{color:var(--muted);font-size:13px;margin-top:4px;max-width:360px;margin-left:auto;margin-right:auto;line-height:1.5;position:relative;z-index:1}
.lh-pills{display:flex;gap:7px;justify-content:center;flex-wrap:wrap;margin-top:11px;position:relative;z-index:1}
.lh-pill{background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.13);border-radius:999px;padding:4px 12px;font-size:12px;font-weight:700;color:rgba(255,255,255,0.72)}
.launch-body{padding:22px 26px}
.launch-chk{display:flex;flex-direction:column;gap:6px;margin-bottom:18px}
.lc-item{display:flex;align-items:center;gap:9px;background:var(--surface);border:1px solid var(--border);border-radius:9px;padding:9px 13px;font-size:13px;font-weight:700}
.lc-check{color:var(--accent3);margin-left:auto}
.btn-launch{width:100%;background:linear-gradient(135deg,var(--accent2),var(--accent));color:#fff;font-family:'Fredoka One',cursive;font-size:1.15rem;border:none;border-radius:13px;padding:17px;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:9px;box-shadow:0 5px 26px rgba(124,58,237,0.4);transition:transform 0.15s,box-shadow 0.15s,opacity 0.2s;position:relative;overflow:hidden}
.btn-launch::after{content:'';position:absolute;inset:0;background:linear-gradient(135deg,rgba(255,255,255,0.1),transparent)}
.btn-launch:hover:not(:disabled){transform:translateY(-2px);box-shadow:0 9px 32px rgba(124,58,237,0.5)}
.btn-launch:disabled{opacity:0.5;cursor:not-allowed;transform:none}
.btn-launch-sub{text-align:center;font-size:12px;color:var(--muted);margin-top:7px;font-weight:700}
.played-badge{display:none;background:rgba(16,185,129,0.1);border:1px solid var(--accent3);border-radius:9px;padding:11px 15px;margin-top:12px;font-size:13px;font-weight:700;color:var(--accent3);align-items:center;gap:7px}
.played-badge.visible{display:flex}
/* ─── FEEDBACK ─── */
.fb-prog{height:4px;background:var(--border);border-radius:999px;margin-bottom:22px;overflow:hidden}
.fb-prog-fill{height:100%;background:linear-gradient(90deg,var(--accent2),var(--accent));border-radius:999px;transition:width 0.4s ease}
.fb-qnum{font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:1px;color:var(--accent2);margin-bottom:5px}
.fb-q{font-family:'Fredoka One',cursive;font-size:1.1rem;margin-bottom:11px;line-height:1.4}
.fb-sub{font-size:13px;color:var(--muted);margin-bottom:11px;margin-top:-5px}
.mc-opts{display:flex;flex-direction:column;gap:7px}
.mc-opt{background:var(--surface);border:2px solid var(--border);border-radius:9px;padding:11px 15px;cursor:pointer;transition:all 0.2s;display:flex;align-items:center;gap:10px;font-size:14px;font-weight:600}
.mc-opt:hover{border-color:var(--accent2)}
.mc-opt.sel{border-color:var(--accent);background:rgba(245,166,35,0.07);color:var(--accent)}
.mc-dot{width:19px;height:19px;border-radius:50%;border:2px solid var(--border);flex-shrink:0;transition:all 0.2s}
.mc-opt.sel .mc-dot{background:var(--accent);border-color:var(--accent)}
.scale-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
.scale-btn{width:46px;height:46px;border-radius:9px;border:2px solid var(--border);background:var(--surface);font-family:'Fredoka One',cursive;font-size:1.05rem;cursor:pointer;color:var(--muted);transition:all 0.2s;display:flex;align-items:center;justify-content:center}
.scale-btn:hover{border-color:var(--accent2);color:var(--text)}
.scale-btn.sel{background:var(--accent);border-color:var(--accent);color:#000}
.scale-lbls{display:flex;justify-content:space-between;font-size:11px;color:var(--muted);margin-top:5px;font-weight:700}
.fb-ta{width:100%;background:var(--surface);border:1.5px solid var(--border);border-radius:9px;color:var(--text);font-family:'Nunito',sans-serif;font-size:14px;font-weight:600;padding:11px 13px;outline:none;resize:none;transition:border-color 0.2s}
.fb-ta:focus{border-color:var(--accent2)}
.fb-ta::placeholder{color:#4a476a}
.fb-nav{display:flex;gap:9px;margin-top:18px}
.btn-fb-prev{background:var(--surface);color:var(--muted);font-family:'Fredoka One',cursive;font-size:0.9rem;border:1.5px solid var(--border);border-radius:10px;padding:11px 20px;cursor:pointer;transition:all 0.2s}
.btn-fb-prev:hover{border-color:var(--muted);color:var(--text)}
.btn-fb-next{flex:1;background:linear-gradient(135deg,var(--accent2),var(--accent));color:#fff;font-family:'Fredoka One',cursive;font-size:1rem;border:none;border-radius:10px;padding:12px;cursor:pointer;transition:transform 0.15s,opacity 0.2s;box-shadow:0 4px 14px rgba(124,58,237,0.3)}
.btn-fb-next:hover:not(:disabled){transform:translateY(-1px)}
.btn-fb-next:disabled{opacity:0.32;cursor:not-allowed}
/* ─── SUMMARY ─── */
.sum-hero{text-align:center;padding:30px 22px 18px}
.sum-icon{font-size:3.5rem;display:block;margin-bottom:10px}
.sum-hero h2{font-family:'Fredoka One',cursive;font-size:1.9rem;color:var(--accent3)}
.sum-hero p{color:var(--muted);font-size:13px;margin-top:5px;line-height:1.6}
.sum-scores{display:flex;gap:9px;justify-content:center;flex-wrap:wrap;margin-top:14px}
.sum-sc{background:var(--surface);border:1px solid var(--border);border-radius:11px;padding:11px 16px;text-align:center}
.sum-val{font-family:'Fredoka One',cursive;font-size:1.5rem;color:var(--accent)}
.sum-lbl{font-size:10px;font-weight:800;text-transform:uppercase;color:var(--muted);margin-top:2px}
.sum-notes{display:flex;flex-direction:column;gap:6px;margin-bottom:18px}
.sn-item{display:flex;align-items:flex-start;gap:8px;font-size:13px;padding:8px 0;border-bottom:1px solid var(--border)}
.sn-item:last-child{border-bottom:none}
.final-btns{display:flex;gap:10px;justify-content:center;flex-wrap:wrap}
.btn-revise{background:var(--surface);color:var(--text);font-family:'Fredoka One',cursive;font-size:1rem;border:1.5px solid var(--border);border-radius:11px;padding:13px 22px;cursor:pointer;transition:all 0.2s}
.btn-revise:hover{border-color:var(--accent2)}
.btn-publish{background:linear-gradient(135deg,var(--accent3),#059669);color:#fff;font-family:'Fredoka One',cursive;font-size:1.05rem;border:none;border-radius:11px;padding:14px 28px;cursor:pointer;transition:transform 0.15s,box-shadow 0.15s;box-shadow:0 4px 18px rgba(16,185,129,0.38)}
.btn-publish:hover{transform:translateY(-2px);box-shadow:0 8px 26px rgba(16,185,129,0.48)}
/* ─── TOAST ─── */
#toast{position:fixed;bottom:22px;left:50%;transform:translateX(-50%) translateY(16px);background:rgba(16,185,129,0.96);color:#fff;font-family:'Fredoka One',cursive;font-size:0.95rem;padding:11px 26px;border-radius:11px;z-index:500;opacity:0;transition:all 0.3s;pointer-events:none;white-space:nowrap;box-shadow:0 4px 18px rgba(0,0,0,0.3)}
#toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
/* ─── MINI-GAME TEST POPUP ─── */
.mg-card{position:relative;}
.mg-test-btn{display:block;width:100%;margin-top:6px;background:rgba(124,58,237,0.25);border:1px solid rgba(124,58,237,0.5);border-radius:6px;color:#c4b5fd;font-size:10px;font-weight:800;padding:4px 0;cursor:pointer;text-transform:uppercase;letter-spacing:0.5px;transition:all 0.15s;z-index:2;}
.mg-test-btn:hover{background:rgba(124,58,237,0.55);color:#fff;border-color:#7c3aed;}
.mg-card.disabled .mg-test-btn{display:none;}
/* ── Spielmodus-Auswahl (Schritt 2) ── */
.pmode-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:8px;}
.pmode-card{cursor:pointer;border:2px solid #2e2b4a;background:#13111f;border-radius:14px;padding:18px 16px;text-align:center;transition:all 0.15s;user-select:none;}
.pmode-card:hover{border-color:#5b21b6;background:#1a162e;transform:translateY(-2px);}
.pmode-card.active{border-color:#7c3aed;background:linear-gradient(135deg,rgba(124,58,237,0.20),rgba(124,58,237,0.08));box-shadow:0 6px 22px rgba(124,58,237,0.30);}
.pmode-icon{font-size:2.4rem;margin-bottom:4px;}
.pmode-label{font-family:'Fredoka One',cursive;font-size:1.05rem;color:#e2e0f0;margin-bottom:2px;}
.pmode-card.active .pmode-label{color:#c4b5fd;}
.pmode-desc{font-size:11px;color:var(--muted);}
/* ══════════ TOUR-OVERLAY (Schritt 3) ══════════ */
.tour-root{position:fixed;inset:0;z-index:8800;pointer-events:none;}
.tour-blocker{position:fixed;inset:0;background:rgba(0,0,0,0.05);pointer-events:auto;z-index:8810;cursor:not-allowed;transition:background 0.35s ease;}
.tour-root.no-anchor .tour-blocker{background:rgba(8,6,20,0.78);}
.tour-highlight{
position:fixed;border-radius:14px;pointer-events:none;
box-shadow:0 0 0 9999px rgba(8,6,20,0.78),0 0 0 2px rgba(167,139,250,0.55),0 0 30px rgba(167,139,250,0.45) inset;
transition:top 0.45s cubic-bezier(0.25,1,0.5,1),left 0.45s cubic-bezier(0.25,1,0.5,1),width 0.45s ease,height 0.45s ease;
z-index:8820;
}
.tour-character{
position:fixed;display:flex;align-items:flex-start;gap:14px;
z-index:8830;pointer-events:auto;width:380px;
transition:left 0.5s cubic-bezier(0.34,1.3,0.5,1),top 0.5s cubic-bezier(0.34,1.3,0.5,1);
}
.tour-character[data-side="left"]{flex-direction:row-reverse;}
.tour-figure{
font-size:3.4rem;line-height:1;flex-shrink:0;margin-top:6px;
filter:drop-shadow(0 6px 18px rgba(124,58,237,0.55));
animation:tourBob 1.8s ease-in-out infinite;
}
@keyframes tourBob{
0%,100%{transform:translateY(0) rotate(-2deg);}
50%{transform:translateY(-6px) rotate(2deg);}
}
.tour-bubble{
background:#1a1827;border:2px solid #7c3aed;border-radius:16px;padding:16px 18px 12px;
color:#e2e0f0;font-family:'Nunito',sans-serif;font-size:14px;line-height:1.55;
box-shadow:0 14px 50px rgba(0,0,0,0.7),0 0 0 1px rgba(167,139,250,0.2);
position:relative;flex:1;min-width:0;
animation:tourBubblePop 0.35s cubic-bezier(0.34,1.5,0.5,1);
}
@keyframes tourBubblePop{from{transform:scale(0.92);opacity:0;}to{transform:scale(1);opacity:1;}}
/* Sprechblasen-Schwanz */
.tour-character[data-side="right"] .tour-bubble::before,
.tour-character[data-side="right"] .tour-bubble::after{content:'';position:absolute;top:24px;border:10px solid transparent;}
.tour-character[data-side="right"] .tour-bubble::before{left:-11px;border-right-color:#7c3aed;}
.tour-character[data-side="right"] .tour-bubble::after {left:-8px;border-right-color:#1a1827;border-width:9px;top:25px;}
.tour-character[data-side="left"] .tour-bubble::before,
.tour-character[data-side="left"] .tour-bubble::after{content:'';position:absolute;top:24px;border:10px solid transparent;}
.tour-character[data-side="left"] .tour-bubble::before{right:-11px;border-left-color:#7c3aed;}
.tour-character[data-side="left"] .tour-bubble::after {right:-8px;border-left-color:#1a1827;border-width:9px;top:25px;}
.tour-bubble-text{min-height:60px;cursor:pointer;user-select:none;white-space:pre-line;}
.tour-bubble-text::after{content:'▌';opacity:0.6;animation:tourCaret 0.9s steps(2) infinite;margin-left:1px;}
@keyframes tourCaret{0%,49%{opacity:0.65;}50%,100%{opacity:0;}}
.tour-bubble-progress{font-size:10px;color:#7c7596;text-align:right;margin-top:6px;font-weight:700;letter-spacing:1px;}
.tour-bubble-actions{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-top:10px;}
.tour-btn-next{
background:linear-gradient(135deg,#7c3aed,#a78bfa);color:#fff;
font-family:'Fredoka One',cursive;font-size:14px;border:none;border-radius:10px;
padding:9px 18px;cursor:pointer;transition:transform 0.15s,box-shadow 0.15s;
}
.tour-btn-next:hover{transform:translateY(-2px);box-shadow:0 8px 22px rgba(124,58,237,0.55);}
.tour-btn-skip{
background:transparent;color:#7c7596;border:1px solid #2e2b4a;border-radius:8px;
padding:6px 12px;font-size:12px;cursor:pointer;font-family:'Nunito',sans-serif;
}
.tour-btn-skip:hover{color:#cdc9e6;border-color:#3a3658;background:rgba(255,255,255,0.04);}
.tour-btn-back{
background:transparent;color:#cdc9e6;border:1px solid #2e2b4a;border-radius:8px;
padding:8px 14px;font-size:13px;font-family:'Nunito',sans-serif;font-weight:700;cursor:pointer;
}
.tour-btn-back:hover{background:rgba(255,255,255,0.06);border-color:#3a3658;}
/* Während Scroll/Resize: keine Transitions — sonst hinkt der Anker hinterher */
.tour-character.tour-no-anim,
.tour-highlight.tour-no-anim{transition:none !important;}
/* Diskreter Re-Trigger-Button oben rechts in Schritt 3 */
.tour-restart-btn{
position:absolute;top:14px;right:14px;
background:rgba(124,58,237,0.12);border:1px solid rgba(124,58,237,0.40);color:#c4b5fd;
font-family:'Nunito',sans-serif;font-size:11px;font-weight:700;letter-spacing:0.3px;
border-radius:999px;padding:6px 12px;cursor:pointer;transition:all 0.15s;
user-select:none;
}
.tour-restart-btn:hover{background:rgba(124,58,237,0.26);border-color:rgba(124,58,237,0.65);color:#e2e0f0;transform:translateY(-1px);}
/* ── App-Dialog (ersetzt confirm/alert) ── */
.app-dialog{position:fixed;inset:0;z-index:9000;display:none;align-items:center;justify-content:center;background:rgba(0,0,0,0.78);animation:appDlgFade 0.15s ease;}
.app-dialog.open{display:flex;}
.app-dialog-box{background:#1a1827;border:1.5px solid #2e2b4a;border-radius:18px;padding:26px 30px;width:min(440px,92vw);box-shadow:0 24px 60px rgba(0,0,0,0.7);animation:appDlgIn 0.22s cubic-bezier(0.34,1.56,0.64,1);}
.app-dialog-icon{font-size:2.6rem;text-align:center;margin-bottom:10px;}
.app-dialog-msg{color:#e2e0f0;font-family:'Nunito',sans-serif;font-size:15px;line-height:1.5;text-align:center;margin-bottom:22px;white-space:pre-line;}
.app-dialog-btns{display:flex;gap:10px;justify-content:center;flex-wrap:wrap;}
.app-dialog-btns button{font-family:'Fredoka One',cursive;font-size:0.95rem;border:none;border-radius:10px;padding:11px 24px;cursor:pointer;transition:transform 0.15s,box-shadow 0.15s;}
.app-dialog-cancel{background:#2e2b4a;color:#cdc9e6;}
.app-dialog-cancel:hover{background:#3a3658;}
.app-dialog-ok{background:linear-gradient(135deg,#7c3aed,#a78bfa);color:#fff;}
.app-dialog-ok:hover{transform:translateY(-2px);box-shadow:0 6px 18px rgba(124,58,237,0.5);}
.app-dialog-ok.danger{background:linear-gradient(135deg,#dc2626,#ef4444);}
.app-dialog-ok.danger:hover{box-shadow:0 6px 18px rgba(239,68,68,0.5);}
@keyframes appDlgIn{from{transform:scale(0.92);opacity:0;}to{transform:scale(1);opacity:1;}}
@keyframes appDlgFade{from{opacity:0;}to{opacity:1;}}
/* ── Welt-Editor (Schritt 3) ── */
.world-toolbar{display:flex;gap:8px;align-items:center;margin:8px 0 10px;flex-wrap:wrap;}
.world-toolbar button{background:linear-gradient(135deg,#7c3aed,#a78bfa);color:#fff;border:none;border-radius:10px;padding:9px 16px;font-family:'Fredoka One',cursive;font-size:14px;cursor:pointer;transition:transform 0.15s,box-shadow 0.15s,filter 0.15s;}
.world-toolbar button:hover{transform:translateY(-2px);box-shadow:0 6px 18px rgba(124,58,237,0.4);}
.world-toolbar button.locked{background:linear-gradient(135deg,#059669,#10b981);}
.world-toolbar button.dim{filter:grayscale(0.4) opacity(0.85);}
.world-status{font-size:12px;color:var(--muted);margin-left:6px;}
.world-canvas{display:block;width:100%;height:min(60vh,560px);min-height:380px;border-radius:14px;border:1px solid #2e2b4a;background:#050314;cursor:pointer;}
.story-list{display:flex;flex-direction:column;gap:8px;margin-top:12px;}
#mgTestOverlay{position:fixed;inset:0;background:rgba(0,0,0,0.82);z-index:5000;display:none;align-items:center;justify-content:center;}
#mgTestOverlay.open{display:flex;}
#mgTestBox{background:#1a1827;border:1.5px solid #2e2b4a;border-radius:20px;padding:0;width:min(1040px,95vw);max-height:92vh;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 24px 60px rgba(0,0,0,0.7);}
#mgTestHeader{display:flex;align-items:center;gap:12px;padding:18px 22px;border-bottom:1px solid #2e2b4a;background:#22203a;}
#mgTestEmoji{font-size:2rem;}
#mgTestTitle{font-family:'Fredoka One',cursive;font-size:1.3rem;color:#f5a623;flex:1;}
#mgTestClose{background:none;border:none;color:#a7a3c2;font-size:1.4rem;cursor:pointer;padding:4px 8px;border-radius:8px;transition:all 0.15s;line-height:1;}
#mgTestClose:hover{background:rgba(239,68,68,0.15);color:#ef4444;}
#mgTestBody{padding:20px;overflow-y:auto;}
#mgTestCanvas{display:block;margin:0 auto;border-radius:10px;border:1px solid #2e2b4a;}
#mgTestMsg{text-align:center;font-family:'Fredoka One',cursive;font-size:1.1rem;margin-top:14px;min-height:28px;}
#mgTestMsg.win{color:#10b981;}#mgTestMsg.lose{color:#ef4444;}
#mgTestControls{display:flex;gap:8px;justify-content:center;flex-wrap:wrap;margin-top:14px;}
.mg-ctrl-btn{background:linear-gradient(135deg,#7c3aed,#f5a623);color:#fff;font-family:'Fredoka One',cursive;font-size:0.9rem;border:none;border-radius:9px;padding:10px 20px;cursor:pointer;transition:transform 0.15s;}
.mg-ctrl-btn:hover{transform:translateY(-1px);}
.mg-ctrl-btn.secondary{background:#22203a;border:1.5px solid #2e2b4a;color:#a7a3c2;}
#mgTestDesc{font-size:13px;color:#a7a3c2;text-align:center;line-height:1.6;margin-bottom:14px;}
/* ─── CODE PANE ─── */
#codePane{font-family:'Fira Code','Cascadia Code','Consolas','Monaco',monospace}
#codeHeader{
display:flex;align-items:center;justify-content:space-between;
background:#131221;border-bottom:1px solid rgba(255,255,255,0.07);
padding:9px 16px;flex-shrink:0;
}
#codeHeaderLeft{display:flex;align-items:center;gap:7px}
.code-dot{width:11px;height:11px;border-radius:50%;display:inline-block}
.code-dot.red{background:#ff5f57}.code-dot.yellow{background:#febc2e}.code-dot.green{background:#28c840}
#codeFilename{color:#a7a3c2;font-size:12px;font-weight:600;margin-left:6px;font-family:'Nunito',sans-serif}
#codeHeaderRight{display:flex;align-items:center;gap:12px}
#codeLang{font-size:11px;font-weight:700;color:var(--accent2);font-family:'Nunito',sans-serif;
background:rgba(124,58,237,0.15);border:1px solid rgba(124,58,237,0.3);border-radius:5px;padding:2px 8px}
#codeLines{font-size:10px;color:rgba(255,255,255,0.25);font-family:'Nunito',sans-serif}
#codeScroll{
display:flex;flex:1;overflow-y:auto;overflow-x:hidden;min-height:0;
scrollbar-width:thin;scrollbar-color:rgba(124,58,237,0.3) transparent;
}
#codeScroll::-webkit-scrollbar{width:5px}
#codeScroll::-webkit-scrollbar-thumb{background:rgba(124,58,237,0.3);border-radius:3px}
#codeLineNums{
padding:16px 12px 16px 10px;text-align:right;
color:rgba(255,255,255,0.15);font-size:12px;line-height:1.65;
user-select:none;flex-shrink:0;min-width:38px;border-right:1px solid rgba(255,255,255,0.05);
background:#0a0917;
}
#codeContent{
flex:1;padding:16px 16px 80px 14px;margin:0;white-space:pre;
font-size:12.5px;line-height:1.65;color:#e2e0f0;overflow-x:hidden;
background:transparent;border:none;outline:none;
}
/* ── Syntax colors (applied via spans) */
.py-kw{color:#c678dd} /* keywords: def, class, if, for, return, import */
.py-fn{color:#61afef} /* function names */
.py-str{color:#98c379} /* strings */
.py-num{color:#d19a66} /* numbers */
.py-cm{color:#5c6370;font-style:italic} /* comments */
.py-var{color:#e06c75} /* variables (left of =) */
.py-val{color:#abb2bf} /* plain values */
.py-punc{color:#abb2bf} /* punctuation */
.py-cls{color:#e5c07b} /* class names */
.py-dec{color:#56b6c2} /* decorators / special */
.py-hi{ /* active/focused line highlight */
background:rgba(245,166,35,0.13);display:inline-block;width:100%;
border-left:3px solid var(--accent);padding-left:6px;margin-left:-6px;
animation:codeFlash 0.4s ease both;
}
@keyframes codeFlash{
0% {background:rgba(245,166,35,0.35);border-left-color:var(--accent)}
100%{background:rgba(245,166,35,0.10);border-left-color:var(--accent)}
}
#codeFooter{
display:flex;align-items:center;justify-content:space-between;
background:#0d0c1a;border-top:1px solid rgba(255,255,255,0.05);
padding:5px 14px;flex-shrink:0;
}
#codeStatus{font-size:10px;color:var(--accent3);font-weight:700;font-family:'Nunito',sans-serif;display:flex;align-items:center;gap:5px}
#codeInfo{font-size:10px;color:rgba(255,255,255,0.2);font-family:'Nunito',sans-serif}
/* typing cursor in code */
.py-cursor{display:inline-block;width:2px;height:14px;background:var(--accent2);vertical-align:middle;animation:blink 1s step-end infinite;margin-left:1px}
@keyframes blink{0%,100%{opacity:1}50%{opacity:0}}
/* Responsive: hide code pane on small screens */
@media(max-width:900px){
#resizerBar,#codePane{display:none}
#editorPane{flex:1;border-right:none}
}
/* Save indicator */
#saveIndicator{
position:fixed;top:12px;right:16px;z-index:9000;
background:rgba(16,185,129,0.92);color:#fff;
font-family:'Fredoka One',cursive;font-size:0.85rem;
padding:6px 14px;border-radius:8px;
opacity:0;transition:opacity 0.3s;pointer-events:none;
box-shadow:0 3px 12px rgba(0,0,0,0.3);
}
/* ── Konsequenz-Wert-Buttons ── */
.val-btn{background:rgba(255,255,255,0.1);border:1px solid rgba(255,255,255,0.2);
color:#fff;border-radius:4px;padding:1px 7px;cursor:pointer;font-size:12px;
line-height:1.4;vertical-align:middle;}
.val-btn:hover{background:rgba(255,255,255,0.2);}
/* Ausgeblendete Optionen je nach Spielregel */
/* Konsequenz-Optionen: alle sichtbar ausser wenn explizit ausgeblendet */
.cq-mode-step .cq-dice-only{display:none;}
.cq-mode-pts .cq-lives-only{display:none;}
.cq-mode-lives .cq-pts-only{display:none;}

597
editor.html Normal file
View file

@ -0,0 +1,597 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1.0"/>
<title>Spiel-Generator</title>
<link href="https://fonts.googleapis.com/css2?family=Fredoka+One&family=Nunito:wght@400;600;700;800;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="editor.css">
</head>
<body>
<div id="saveIndicator">Gespeichert ✓</div>
<!-- ═══ PROGRESS BAR ═══ -->
<div id="progressBar">
<div class="pb-inner">
<div class="pb-top">
<div class="pb-logo" style="display:flex;align-items:center;gap:12px">
<span>🎲 Spiel-Generator <span style="color:var(--muted);font-size:0.75rem;font-family:'Nunito',sans-serif;font-weight:700">— Dein Spiel, deine Regeln</span></span>
<button onclick="resetAll()" title="Alles zurücksetzen" style="background:rgba(239,68,68,0.12);border:1px solid rgba(239,68,68,0.3);color:#ef4444;font-size:10px;font-weight:800;font-family:'Nunito',sans-serif;border-radius:6px;padding:3px 9px;cursor:pointer;letter-spacing:0.5px;transition:all 0.2s" onmouseover="this.style.background='rgba(239,68,68,0.25)'" onmouseout="this.style.background='rgba(239,68,68,0.12)'">↺ Reset</button>
</div>
<div class="pb-phwg">
<img src="logo.png" alt="PH Weingarten"/>
<div class="pb-phwg-text">
<div class="pb-phwg-studiengaenge"><span>Medien- und Bildungsmanagement</span></div>
<div class="pb-phwg-studiengaenge"><span>Informatik (Lehramt)</span></div>
</div>
</div>
</div>
<div class="pb-steps">
<div class="pb-step active" id="pb0" onclick="pbClick(0)"><div class="pb-dot">1</div><span class="pb-label">Grundinfos</span></div>
<div class="pb-connector" id="pbc0"></div>
<div class="pb-step" id="pb1" onclick="pbClick(1)"><div class="pb-dot">2</div><span class="pb-label">Gestaltung</span></div>
<div class="pb-connector" id="pbc1"></div>
<div class="pb-step" id="pb2" onclick="pbClick(2)"><div class="pb-dot">3</div><span class="pb-label">Spielfeld</span></div>
<div class="pb-connector" id="pbc2"></div>
<div class="pb-step" id="pb3" onclick="pbClick(3)"><div class="pb-dot">4</div><span class="pb-label">Quiz</span></div>
<div class="pb-connector" id="pbc3"></div>
<div class="pb-step" id="pb4" onclick="pbClick(4)"><div class="pb-dot">5</div><span class="pb-label">Testen</span></div>
</div>
<div class="pb-track"><div class="pb-fill" id="pbFill" style="width:10%"></div></div>
</div>
</div>
</div>
<div id="splitWrap">
<div id="editorPane">
<div id="dragGhost"></div>
<div class="emoji-picker" id="emojiPicker"></div>
<div id="toast">✅ Gespeichert!</div>
<!-- ════════════ SCREEN 1: GRUNDINFOS ════════════ -->
<div class="screen active" id="s0">
<div class="sh">
<div class="sh-badge">Schritt 1 von 5</div>
<h1 class="sh-title">Dein Spiel <em>beginnt hier ✨</em></h1>
<p class="sh-sub">Erstmal wichtig: Wer bist du? Dann gib deinem Spiel einen Namen.</p>
</div>
<div class="card" style="background:linear-gradient(135deg,rgba(124,58,237,0.12),rgba(245,166,35,0.06));border-color:rgba(124,58,237,0.4)">
<div class="card-title">👋 Hallo! Wie heißt du?</div>
<p class="card-sub">Dein Name oder Nickname — er erscheint auf deinem Spiel als Entwickler:in!</p>
<input class="finput" id="s1devname" type="text" onfocus="setCodeFocus('dev')" maxlength="30" placeholder="z.B. Lena, MrCoder, GameMaster42 …" oninput="s1update()" style="border-color:rgba(124,58,237,0.5)"/>
<div id="devGreeting" style="margin-top:10px;min-height:28px;font-family:'Fredoka One',cursive;font-size:1.05rem;color:var(--accent);transition:opacity 0.3s;opacity:0"></div>
</div>
<div class="card">
<label class="lbl">🎮 Spielname</label>
<input class="finput" id="s1name" type="text" onfocus="setCodeFocus('name')" maxlength="40" placeholder="z.B. Abenteuer im Dschungel" oninput="s1update()"/>
<div class="char-row" id="s1nc">0 / 40</div>
<div class="suggestions">
<div class="sug" onclick="suggest('Abenteuer im Dschungel')">🌴 Abenteuer im Dschungel</div>
<div class="sug" onclick="suggest('Mission Weltraum')">🚀 Mission Weltraum</div>
<div class="sug" onclick="suggest('Das große Quiz-Turnier')">❓ Das große Quiz-Turnier</div>
<div class="sug" onclick="suggest('Schatz des Drachen')">🐲 Schatz des Drachen</div>
<div class="sug" onclick="suggest('Flucht aus dem Labyrinth')">🌀 Flucht aus dem Labyrinth</div>
</div>
<div class="divider"></div>
<label class="lbl">📝 Beschreibung <span style="color:var(--muted);font-weight:400;font-size:10px">(optional)</span></label>
<textarea class="ftarea" id="s1desc" rows="3" onfocus="setCodeFocus('desc')" maxlength="120" placeholder="Worum geht es? Was ist das Ziel?" oninput="s1update()"></textarea>
<div class="char-row" id="s1dc">0 / 120</div>
</div>
<div id="s1preview" style="display:none">
<div class="preview-card">
<div class="pv-label">👁️ Vorschau</div>
<div class="pv-name" id="s1pvname"></div>
<div class="pv-desc" id="s1pvdesc"></div>
<div id="s1pvdev" style="margin-top:6px;font-size:11px;font-weight:700;color:var(--accent2)"></div>
</div>
</div>
<div class="btn-row">
<div></div>
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:6px">
<button class="btn-next" id="s1next" onclick="goTo(1)" disabled>Weiter <span></span></button>
<div id="s1hint" style="font-size:12px;color:var(--muted);display:none">Name und Entwickler-Name erforderlich</div>
</div>
</div>
</div>
<!-- ════════════ SCREEN 2: GESTALTUNG ════════════ -->
<div class="screen" id="s1">
<div class="sh">
<div class="sh-badge">Schritt 2 von 5</div>
<h1 class="sh-title">Figur & <em>Setting 🎨</em></h1>
<p class="sh-sub">Wähle deine Spielfigur und die Spielwelt.</p>
</div>
<div class="summary-banner" id="sum0">
<div class="sb-title">✅ Schritt 1 abgeschlossen</div>
<div class="sb-chips" id="sum0chips"></div>
</div>
<div class="card">
<div class="card-title">👥 Spielmodus</div>
<p class="card-sub">Allein oder zu zweit gegeneinander?</p>
<div class="pmode-grid">
<div class="pmode-card" id="pmode1" onclick="setPlayerCount(1)">
<div class="pmode-icon">👤</div>
<div class="pmode-label">1 Spieler</div>
<div class="pmode-desc">Allein spielen</div>
</div>
<div class="pmode-card" id="pmode2" onclick="setPlayerCount(2)">
<div class="pmode-icon">👥</div>
<div class="pmode-label">2 Spieler</div>
<div class="pmode-desc">Wettrennen — wer zuerst am Ziel?</div>
</div>
</div>
</div>
<div class="card">
<div class="card-title" id="figCardTitle">👤 Spielfigur</div>
<p class="card-sub" id="figCardSub">Das bist du im Spiel!</p>
<div class="sel-grid" id="figGrid"></div>
</div>
<div class="card" id="fig2Card" style="display:none">
<div class="card-title">🎮 Spielfigur Spieler 2</div>
<p class="card-sub">Welche Figur soll der zweite Spieler haben?</p>
<div class="sel-grid" id="fig2Grid"></div>
</div>
<div class="card">
<div class="card-title">🌍 Spielwelt</div>
<p class="card-sub">Wo findet das Abenteuer statt?</p>
<div class="sel-grid" id="bgGrid"></div>
<div id="s2combo" style="display:none">
<div class="combo-preview">
<span class="cp-scene" id="s2scene"></span>
<div class="cp-label" id="s2clabel"></div>
</div>
</div>
</div>
<div class="btn-row">
<button class="btn-back" onclick="goTo(0)">← Zurück</button>
<button class="btn-next" id="s2next" onclick="goTo(2)" disabled>Weiter <span></span></button>
</div>
</div>
<!-- ════════════ SCREEN 3: SPIELFELD ════════════ -->
<div class="screen" id="s2">
<div class="sh" style="position:relative">
<div class="sh-badge">Schritt 3 von 5</div>
<h1 class="sh-title">Bau dein <em>Spielfeld 🎲</em></h1>
<p class="sh-sub">Lege Regeln fest und bestücke die Felder mit Mini-Games.</p>
<button class="tour-restart-btn" onclick="tourStart()" title="Tour erneut anzeigen">🎓 Tour starten</button>
</div>
<div class="summary-banner" id="sum1">
<div class="sb-title">✅ Schritt 2 abgeschlossen</div>
<div class="sb-chips" id="sum1chips"></div>
</div>
<div class="card">
<div class="card-title">⚙️ Spielregeln</div>
<p class="card-sub">Wie soll dein Spiel funktionieren?</p>
<div class="rules-grid">
<div class="rule-block" data-tour="bewegung">
<div class="rule-block-title">🎲 Bewegung</div>
<div class="toggle-group">
<div class="toggle-option selected" data-g="movement" data-v="dice" onclick="selRule(this)"><span class="tog-emoji">🎲</span><div><div class="tog-text">Würfeln</div><div class="tog-sub">Zufällig 16 Felder</div></div><div class="tog-check"></div></div>
<div class="toggle-option" data-g="movement" data-v="step" onclick="selRule(this)"><span class="tog-emoji">👣</span><div><div class="tog-text">Ein Feld pro Runde</div><div class="tog-sub">Jedes Feld wird besucht</div></div><div class="tog-check"></div></div>
</div>
</div>
<div class="rule-block">
<div class="rule-block-title" data-tour="spielmodus-header">🎮 Spielmodus</div>
<div class="toggle-group" data-tour="spielmodus">
<div class="toggle-option selected" data-g="fail" data-v="lives" onclick="selRule(this)"><span class="tog-emoji">❤️</span><div><div class="tog-text">Ich spiele mit Leben</div><div class="tog-sub">Bei 0 Leben: Game Over</div></div><div class="tog-check"></div></div>
<div class="toggle-option" data-g="fail" data-v="points" onclick="selRule(this)"><span class="tog-emoji"></span><div><div class="tog-text">Ich spiele mit Punkten</div><div class="tog-sub">Kein Verlieren, nur Score</div></div><div class="tog-check"></div></div>
</div>
<div class="sub-opts visible" id="sub_lives" data-tour="modus-counter" style="display:flex;align-items:center;gap:10px;padding:10px 4px 4px">
<span style="font-size:13px;font-weight:700;color:var(--text)">Anzahl Leben:</span>
<button class="val-btn" onclick="chgLives(-1)"></button>
<span id="livesDisplay" style="font-size:15px;font-weight:800;min-width:18px;text-align:center">3</span>
<button class="val-btn" onclick="chgLives(1)">+</button>
<span id="livesHearts" style="font-size:13px;letter-spacing:1px">❤️❤️❤️</span>
</div>
<div class="sub-opts" id="sub_points" data-tour="modus-counter"></div>
</div>
</div>
<div class=divider></div>
<div class="card-title">Konsequenzen nach Mini-Game</div>
<p class="card-sub">Was passiert wenn ein Mini-Game gewonnen oder verloren wird?</p>
<div class="rules-grid" id="consequenceGrid">
<!-- BEI SIEG -->
<div class="rule-block" data-tour="konsequenz-sieg">
<div class="rule-block-title" style="color:#22c55e">Bei Sieg</div>
<div class="toggle-group" id="cq-win-group">
<div class="toggle-option selected" data-cq="win" data-cv="nothing" onclick="selCQ(this,event)">
<span class="tog-emoji"></span><div><div class="tog-text">Nichts passiert</div><div class="tog-sub">Spiel läuft normal weiter</div></div><div class="tog-check"></div>
</div>
<div class="toggle-option cq-dice-only" data-cq="win" data-cv="forward" onclick="selCQ(this,event)">
<span class="tog-emoji"></span><div><div class="tog-text">Felder vor</div>
<div class="tog-sub"><button class="val-btn" onclick="chgCQVal('winVal',-1,event)"></button> <span id="cqWinVal">2</span> <button class="val-btn" onclick="chgCQVal('winVal',1,event)">+</button> Felder</div></div><div class="tog-check"></div>
</div>
<div class="toggle-option cq-pts-only" data-cq="win" data-cv="points" onclick="selCQ(this,event)">
<span class="tog-emoji"></span><div><div class="tog-text">Punkte +</div>
<div class="tog-sub"><button class="val-btn" onclick="chgCQVal('winPts',-5,event)"></button> <span id="cqWinPts">10</span> <button class="val-btn" onclick="chgCQVal('winPts',5,event)">+</button> Punkte</div></div><div class="tog-check"></div>
</div>
<div class="toggle-option cq-dice-only" data-cq="win" data-cv="again" onclick="selCQ(this,event)">
<span class="tog-emoji">🎲</span><div><div class="tog-text">Nochmal würfeln</div><div class="tog-sub">Sofort ein zweites Mal</div></div><div class="tog-check"></div>
</div>
</div>
</div>
<!-- BEI NIEDERLAGE -->
<div class="rule-block" data-tour="konsequenz-niederlage">
<div class="rule-block-title" style="color:#ef4444">Bei Niederlage</div>
<div class="toggle-group" id="cq-lose-group">
<div class="toggle-option selected" data-cq="lose" data-cv="nothing" onclick="selCQ(this,event)">
<span class="tog-emoji"></span><div><div class="tog-text">Nichts passiert</div><div class="tog-sub">Spiel läuft normal weiter</div></div><div class="tog-check"></div>
</div>
<div class="toggle-option cq-dice-only" data-cq="lose" data-cv="back" onclick="selCQ(this,event)">
<span class="tog-emoji"></span><div><div class="tog-text">Felder zurück</div>
<div class="tog-sub"><button class="val-btn" onclick="chgCQVal('loseVal',-1,event)"></button> <span id="cqLoseVal">1</span> <button class="val-btn" onclick="chgCQVal('loseVal',1,event)">+</button> Felder</div></div><div class="tog-check"></div>
</div>
<div class="toggle-option cq-lives-only" data-cq="lose" data-cv="life" onclick="selCQ(this,event)">
<span class="tog-emoji">❤️</span><div><div class="tog-text">Leben verlieren</div><div class="tog-sub">Zusätzlich zu Spielregel</div></div><div class="tog-check"></div>
</div>
<div class="toggle-option cq-pts-only" data-cq="lose" data-cv="points" onclick="selCQ(this,event)">
<span class="tog-emoji"></span><div><div class="tog-text">Punkte </div>
<div class="tog-sub"><button class="val-btn" onclick="chgCQVal('losePts',-5,event)"></button> <span id="cqLosePts">5</span> <button class="val-btn" onclick="chgCQVal('losePts',5,event)">+</button> Punkte</div></div><div class="tog-check"></div>
</div>
<div class="toggle-option cq-dice-only" data-cq="lose" data-cv="skip" onclick="selCQ(this,event)">
<span class="tog-emoji">⏸️</span><div><div class="tog-text">Runde aussetzen</div><div class="tog-sub">Nächste Runde pausieren</div></div><div class="tog-check"></div>
</div>
</div>
</div>
</div>
<div class="divider"></div>
<div class="count-row" data-tour="fieldcount">
<label>⬛ Anzahl Felder</label>
<div class="count-ctrl">
<button class="cnt-btn" id="cntMinus" onclick="chgCount(-1)"></button>
<div class="cnt-val" id="cntVal">10</div>
<button class="cnt-btn" id="cntPlus" onclick="chgCount(1)">+</button>
<span style="font-size:11px;color:var(--muted)">(650)</span>
</div>
</div>
<div data-tour="palette">
<div class="sec-label"><span>🕹️ Bausteine</span><div class="story-counter" id="storyCtr">📖 0 / 5</div></div>
<div class="pal-tabs">
<div class="pal-tab active" onclick="palTab('games')">🕹️ Mini-Games</div>
<div class="pal-tab" onclick="palTab('story')">📖 Erzähltexte</div>
</div>
<div class="pal-panel active" id="panGames"></div>
<div class="pal-panel" id="panStory">
<div class="mg-card" id="storyCard" draggable="true" onclick="clickStory()" style="border-color:rgba(6,182,212,0.4)">
<div class="use-count" id="storyUC">0</div>
<div class="mg-emoji">📖</div>
<div class="mg-lbl">Erzähl-Text</div>
<div class="mg-tag story-tag">max.5×</div>
<div style="font-size:10px;color:rgba(6,182,212,0.6);margin-top:2px">Vor/nach Feld</div>
</div>
</div>
</div>
<div class="sec-label" style="margin-top:6px">🗺️ Spielwelt <span style="font-size:10px;font-weight:400;text-transform:none;letter-spacing:0">Generieren bis es passt, dann Bausteine auf die Felder ziehen</span></div>
<div class="world-toolbar">
<button id="wbReroll" type="button" onclick="worldReroll()">🎲 Welt neu generieren</button>
<button id="wbLock" type="button" onclick="worldToggleLock()">✓ Spielbrett verwenden</button>
<span id="wbStatus" class="world-status">🎲 Welt frei generierbar</span>
</div>
<canvas id="worldCanvas" class="world-canvas"></canvas>
<div class="board-stats" id="boardStats"></div>
<div id="storyList" class="story-list"></div>
</div>
<div class="btn-row">
<button class="btn-back" onclick="goTo(1)">← Zurück</button>
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:6px">
<button class="btn-next" id="s3next" onclick="goTo(3)" disabled>Weiter <span></span></button>
<div id="s3hint" style="font-size:12px;color:var(--muted);display:none">Mindestens 1 Minigame auf dem Spielfeld platzieren</div>
</div>
</div>
</div>
<!-- ════════════ SCREEN 4: QUIZ ════════════ -->
<div class="screen" id="s3">
<div class="sh">
<div class="sh-badge">Schritt 4 von 5</div>
<h1 class="sh-title">Deine <em>Quiz-Fragen ❓</em></h1>
<p class="sh-sub">Für jedes Quiz-Feld brauchst du eine Frage mit vier Antworten.</p>
</div>
<div class="summary-banner" id="sum2">
<div class="sb-title">✅ Schritt 3 abgeschlossen</div>
<div class="sb-chips" id="sum2chips"></div>
</div>
<div id="quizArea"></div>
<div class="btn-row">
<button class="btn-back" onclick="goTo(2)">← Zurück</button>
<button class="btn-next" id="s4next" onclick="goTo(4)">Weiter <span></span></button>
</div>
</div>
<!-- ════════════ SCREEN 5: TESTEN ════════════ -->
<div class="screen" id="s4">
<div class="sh">
<div class="sh-badge">Schritt 5 von 5</div>
<h1 class="sh-title">Testen & <em>Feedback 🎮</em></h1>
<p class="sh-sub">Überblick über dein Spiel — dann starten und ausprobieren!</p>
</div>
<div class="summary-banner" id="sum3">
<div class="sb-title">✅ Schritt 4 abgeschlossen</div>
<div class="sb-chips" id="sum3chips"></div>
</div>
<!-- Review filled dynamically -->
<div id="reviewArea"></div>
<!-- Launch card -->
<div class="card" id="launchCard" style="padding:0;overflow:hidden;margin-bottom:18px">
<div class="launch-hero">
<span class="lh-fig" id="lhFig">🎮</span>
<div class="lh-name" id="lhName"></div>
<div class="lh-desc" id="lhDesc"></div>
<div class="lh-pills" id="lhPills"></div>
</div>
<div class="launch-body">
<div class="launch-chk" id="launchChk"></div>
<button class="btn-launch" id="btnLaunch" onclick="doLaunch()">🎮 Spiel starten &amp; testen!</button>
<div class="btn-launch-sub">Öffnet das Spiel in einem neuen Fenster</div>
<div class="played-badge" id="playedBadge">✅ Du hast gespielt! Jetzt bitte Feedback geben ↓</div>
</div>
</div>
<!-- Feedback (hidden until played) -->
<div class="card" id="fbCard" style="display:none">
<div class="card-title">📝 Feedback zu deinem Spiel</div>
<p class="card-sub">Beantworte diese Fragen ehrlich — sie helfen dir, das Spiel zu verbessern!</p>
<div class="fb-prog"><div class="fb-prog-fill" id="fbFill" style="width:0%"></div></div>
<div id="fbArea"></div>
<div class="fb-nav">
<button class="btn-fb-prev" id="fbPrev" onclick="fbBack()" style="display:none">← Zurück</button>
<button class="btn-fb-next" id="fbNext" onclick="fbFwd()" disabled>Weiter →</button>
</div>
</div>
<!-- Summary (hidden until feedback done) -->
<div class="card" id="sumCard" style="display:none;padding:0;overflow:hidden">
<div class="sum-hero">
<span class="sum-icon">🎉</span>
<h2>Test abgeschlossen!</h2>
<p id="sumText"></p>
<div class="sum-scores" id="sumScores"></div>
</div>
<div style="padding:0 22px 6px">
<div class="card-title" style="margin-bottom:9px">📋 Auswertung</div>
<div class="sum-notes" id="sumNotes"></div>
</div>
<div style="padding:14px 22px 26px">
<div class="final-btns">
<button class="btn-revise" onclick="goTo(0)">✏️ Überarbeiten</button>
<button class="btn-publish" onclick="doPublish()">🚀 Spiel veröffentlichen →</button>
</div>
</div>
</div>
<div class="btn-row">
<button class="btn-back" onclick="goTo(3)">← Zurück</button>
<div></div>
</div>
</div>
<!-- ════ SHARE MODAL ════ -->
<div id="shareOverlay" onclick="if(event.target===this)closeShare()" style="
display:none;position:fixed;inset:0;background:rgba(0,0,0,0.8);z-index:6000;
align-items:center;justify-content:center;backdrop-filter:blur(6px);">
<div style="background:#1a1827;border:1.5px solid #2e2b4a;border-radius:20px;
width:min(520px,94vw);max-height:90vh;overflow-y:auto;box-shadow:0 24px 60px rgba(0,0,0,0.7);">
<div style="display:flex;align-items:center;justify-content:space-between;
padding:18px 22px 14px;border-bottom:1px solid #2e2b4a;">
<div style="font-family:'Fredoka One',cursive;font-size:1.3rem;color:#a78bfa">
🎉 Dein Spiel ist fertig!
</div>
<button onclick="closeShare()" style="background:none;border:none;color:#a7a3c2;
font-size:1.3rem;cursor:pointer;padding:4px 8px;border-radius:6px">✕</button>
</div>
<div style="padding:22px">
<div id="shareGameInfo" style="background:rgba(124,58,237,0.1);border:1px solid rgba(124,58,237,0.3);
border-radius:12px;padding:14px;margin-bottom:18px;font-family:'Nunito',sans-serif;"></div>
<div style="margin-bottom:18px">
<div style="font-family:'Fredoka One',cursive;color:#a78bfa;font-size:0.95rem;margin-bottom:8px">
🔗 Dein Spiel-Link
</div>
<div style="display:flex;gap:8px;align-items:center">
<input id="shareLinkInput" readonly style="
flex:1;background:#0f0e17;border:1.5px solid #2e2b4a;border-radius:10px;
color:#e2e0f0;font-family:'Nunito',sans-serif;font-size:11px;
padding:10px 12px;outline:none;" />
<button onclick="copyShareLink()" id="shareCopyBtn" style="
background:linear-gradient(135deg,#7c3aed,#5b21b6);color:#fff;
font-family:'Fredoka One',cursive;font-size:0.9rem;border:none;
border-radius:10px;padding:10px 16px;cursor:pointer;white-space:nowrap">
📋 Kopieren
</button>
</div>
<div id="shareLinkLen" style="font-size:11px;color:#64748b;margin-top:4px;font-family:'Nunito',sans-serif;"></div>
</div>
<div style="margin-bottom:18px;text-align:center">
<div style="font-family:'Fredoka One',cursive;color:#a78bfa;font-size:0.95rem;margin-bottom:10px">
📱 QR-Code zum Scannen
</div>
<div id="shareQR" style="display:inline-block;background:#fff;padding:12px;border-radius:12px;min-width:160px;min-height:160px;"></div>
<div style="font-size:11px;color:#64748b;margin-top:6px;font-family:'Nunito',sans-serif">
Handy-Kamera auf den QR-Code richten → öffnet das Spiel direkt
</div>
</div>
<!-- KURZ-CODE für PC/Geräte ohne QR-Scanner -->
<div id="shareCodeBox" style="display:none;margin-bottom:18px;background:rgba(124,58,237,0.06);
border:1px solid rgba(124,58,237,0.25);border-radius:14px;padding:16px;">
<div style="font-family:'Fredoka One',cursive;color:#a78bfa;font-size:0.95rem;margin-bottom:6px">
⌨️ Kein QR-Scanner? Code eingeben:
</div>
<div style="font-size:11px;color:#7c7596;margin-bottom:12px;font-family:'Nunito',sans-serif;line-height:1.45">
Am PC oder Gerät ohne Kamera zu <b style="color:#cdc9e6"><span id="sharePlayUrl">franke-lab.de/edu-boardgame-generator/play/</span></b> gehen und den Code eintippen.
</div>
<div style="display:flex;gap:14px;align-items:center;flex-wrap:wrap">
<div style="flex:1;min-width:160px;text-align:center;background:#0f0e17;border:1.5px solid #2e2b4a;border-radius:10px;padding:14px 8px">
<div style="font-size:10px;color:#7c7596;letter-spacing:1px;font-weight:700;margin-bottom:4px">DEIN SPIEL-CODE</div>
<div id="shareCodeText" style="font-family:'Courier New',monospace;font-size:1.7rem;font-weight:900;letter-spacing:5px;color:#fff">······</div>
<div id="shareCodeStatus" style="font-size:10px;color:#7c7596;margin-top:4px">wird erzeugt …</div>
</div>
<div style="background:#fff;padding:8px;border-radius:10px;flex-shrink:0">
<div id="sharePlayQR" style="width:120px;height:120px"></div>
</div>
</div>
</div>
<div style="font-family:'Fredoka One',cursive;color:#a78bfa;font-size:0.95rem;margin-bottom:10px">
📤 Direkt teilen
</div>
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-bottom:16px">
<button onclick="shareViaEmail()" style="flex:1;min-width:100px;background:rgba(59,130,246,0.15);
border:1.5px solid rgba(59,130,246,0.4);color:#93c5fd;font-family:'Fredoka One',cursive;
font-size:0.85rem;border-radius:10px;padding:10px 14px;cursor:pointer">
📧 E-Mail
</button>
<button onclick="shareViaWhatsApp()" style="flex:1;min-width:100px;background:rgba(34,197,94,0.15);
border:1.5px solid rgba(34,197,94,0.4);color:#86efac;font-family:'Fredoka One',cursive;
font-size:0.85rem;border-radius:10px;padding:10px 14px;cursor:pointer">
💬 WhatsApp
</button>
<button onclick="shareNative()" style="flex:1;min-width:100px;background:rgba(245,158,11,0.15);
border:1.5px solid rgba(245,158,11,0.4);color:#fcd34d;font-family:'Fredoka One',cursive;
font-size:0.85rem;border-radius:10px;padding:10px 14px;cursor:pointer">
📲 Teilen...
</button>
<button onclick="printAsPDF()" style="flex:1;min-width:100px;background:rgba(239,68,68,0.15);border:1.5px solid rgba(239,68,68,0.4);color:#fca5a5;font-family:'Fredoka One',cursive;font-size:0.85rem;border-radius:10px;padding:10px 14px;cursor:pointer">
🖨️ PDF
</button>
</div>
<div style="background:rgba(16,185,129,0.1);border:1px solid rgba(16,185,129,0.3);
border-radius:10px;padding:12px;font-family:'Nunito',sans-serif;font-size:12px;color:#6ee7b7;">
Der Link enthält dein komplettes Spiel. Jeder mit dem Link kann es direkt spielen keine Anmeldung nötig!
</div>
</div>
</div>
</div>
<script src="js/data.js"></script>
<script src="js/state.js"></script>
<script src="js/world.js"></script>
<script src="js/wizard.js"></script>
<script src="js/board.js"></script>
<script src="js/quiz.js"></script>
<script src="js/share.js"></script>
<script src="js/minigame-test.js"></script>
<script src="codegen.js"></script>
<!-- ════ MINI-GAME TEST POPUP ════ -->
<div id="mgTestOverlay" onclick="if(event.target===this)closeMGTest()">
<div id="mgTestBox">
<div id="mgTestHeader">
<span id="mgTestEmoji">🎮</span>
<div id="mgTestTitle">Mini-Game Test</div>
<button id="mgTestClose" onclick="closeMGTest()"></button>
</div>
<div id="mgTestBody">
<div id="mgTestDesc"></div>
<div id="mgTestWrap" style="width:100%;min-height:560px;position:relative;overflow:hidden;border-radius:10px;">
<canvas id="mgTestCanvas" width="400" height="280" style="display:none;margin:0 auto;border-radius:10px;border:1px solid #2e2b4a;"></canvas>
</div>
<div id="mgTestMsg"></div>
<div id="mgTestControls"></div>
</div>
</div>
</div>
</div><!-- /editorPane -->
<div id="resizerBar"></div>
<div id="codePane">
<div id="codeHeader">
<div id="codeHeaderLeft">
<span class="code-dot red"></span>
<span class="code-dot yellow"></span>
<span class="code-dot green"></span>
<span id="codeFilename">boardgame.py</span>
</div>
<div id="codeHeaderRight">
<span id="codeLang">🐍 Python</span>
<span id="codeLines">0 Zeilen</span>
</div>
</div>
<div id="codeScroll">
<div id="codeLineNums"></div>
<pre id="codeContent"></pre>
</div>
<div id="codeFooter">
<span id="codeStatus">● Bereit</span>
<span id="codeInfo">Live-Vorschau deines Spielcodes</span>
<button onclick="downloadPy()" style="background:rgba(124,58,237,0.25);border:1px solid rgba(124,58,237,0.5);color:#c4b5fd;font-family:'Fredoka One',cursive;font-size:0.78rem;border-radius:7px;padding:4px 10px;cursor:pointer;transition:all 0.15s;flex-shrink:0;" onmouseover="this.style.background='rgba(124,58,237,0.45)'" onmouseout="this.style.background='rgba(124,58,237,0.25)'">⬇ .py</button>
</div>
</div><!-- /codePane -->
</div><!-- /splitWrap -->
<!-- CDN Libraries: QR-Code + Kompression -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/pako/2.1.0/pako.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
<!-- ═══ Mini-Game-Module (gemeinsame Quelle mit game.html) ═══ -->
<script src="minigames/_api.js"></script>
<script src="minigames/snake.js"></script>
<script src="minigames/flappy.js"></script>
<script src="minigames/memory.js"></script>
<script src="minigames/quiz.js"></script>
<script src="minigames/reaction.js"></script>
<script src="minigames/basketball.js"></script>
<script src="minigames/catch.js"></script>
<script src="minigames/maze.js"></script>
<script src="minigames/simon.js"></script>
<script src="minigames/typing.js"></script>
<script src="minigames/puzzle.js"></script>
<script src="minigames/spotdiff.js"></script>
<script src="minigames/snake2p.js"></script>
<script src="minigames/flappy2p.js"></script>
<script src="js/tour.js"></script>
<script>
// ── Initialisierung — läuft NACH allen Modulen ──────────────────────────────
s1init();
updatePB();
if (ST.highestStep > 0) {
for (let i = 0; i < ST.highestStep && i < 4; i++) {
document.getElementById('s' + i).classList.remove('active');
document.getElementById('s' + i).style.display = '';
}
cur = ST.highestStep;
document.getElementById('s' + cur).classList.add('active');
updatePB();
showSummary(cur);
if (cur === 1) s2init();
if (cur === 2) s3init();
if (cur === 3) buildQuiz();
if (cur === 4) buildReview();
}
</script>
<!-- ═══ TOUR-OVERLAY (Schritt 3 Erstbesuch) ═══ -->
<div id="tourRoot" class="tour-root" style="display:none">
<div class="tour-blocker"></div>
<div class="tour-highlight" id="tourHighlight" style="display:none"></div>
<div class="tour-character" id="tourCharacter">
<div class="tour-figure" id="tourFigure">🎮</div>
<div class="tour-bubble" id="tourBubble">
<div class="tour-bubble-text" id="tourBubbleText"></div>
<div class="tour-bubble-progress" id="tourBubbleProgress"></div>
<div class="tour-bubble-actions">
<button class="tour-btn-skip" id="tourBtnSkip" onclick="tourSkipConfirm()">Tour beenden</button>
<div style="flex:1"></div>
<button class="tour-btn-back" id="tourBtnBack" onclick="tourPrev()" style="display:none">← Zurück</button>
<button class="tour-btn-next" id="tourBtnNext" onclick="tourNext()">Weiter →</button>
</div>
</div>
</div>
</div>
<!-- App-eigener Dialog (ersetzt natives confirm/alert für einheitliche Optik) -->
<div id="appDialog" class="app-dialog" onclick="if(event.target===this)appDialogCancel()">
<div class="app-dialog-box">
<div class="app-dialog-icon" id="appDialogIcon">⚠️</div>
<div class="app-dialog-msg" id="appDialogMsg"></div>
<div class="app-dialog-btns">
<button class="app-dialog-cancel" id="appDialogNo" onclick="appDialogCancel()">Abbrechen</button>
<button class="app-dialog-ok" id="appDialogYes" onclick="appDialogOk()">Bestätigen</button>
</div>
</div>
</div>
<script src=/chat/chat.js></script>
</body>
</html>

270
game.css Normal file
View file

@ -0,0 +1,270 @@
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#0f0e17;--card:#1e1c30;--border:#2e2b4a;
--accent:#f5a623;--accent2:#7c3aed;--accent3:#10b981;
--danger:#ef4444;--text:#fffffe;--muted:#a7a3c2;
--field-size:72px;
--theme-primary:#f5a623;--theme-bg:#0f0e17;--theme-glow:rgba(245,166,35,0.3);
}
html,body{width:100%;height:100%;overflow:hidden;background:#000;}
canvas#bgCanvas{
position:fixed;inset:0;width:100%;height:100%;z-index:0;
}
/* ══ SCREENS ══ */
.screen{
position:fixed;inset:0;z-index:10;
display:flex;flex-direction:column;align-items:center;justify-content:center;
opacity:0;pointer-events:none;transition:opacity 0.6s ease;
overflow:hidden;
}
.screen.active{opacity:1;pointer-events:all;}
/* ══ INTRO ══ */
#introScreen{background:linear-gradient(180deg,rgba(0,0,0,0.85) 0%,rgba(0,0,0,0.6) 100%);}
.intro-inner{text-align:center;padding:32px;max-width:700px;position:relative;z-index:2;}
.intro-particles{position:absolute;inset:0;z-index:0;overflow:hidden;}
.game-badge{
display:inline-block;background:rgba(124,58,237,0.3);border:1px solid rgba(124,58,237,0.6);
color:#c4b5fd;font-size:11px;font-weight:800;letter-spacing:3px;text-transform:uppercase;
padding:6px 18px;border-radius:999px;margin-bottom:24px;
animation:fadeDown 0.8s 0.2s ease both;
}
.intro-title{
font-family:'Fredoka One',cursive;
font-size:clamp(2.8rem,8vw,5.5rem);
line-height:1.05;color:#fff;
animation:titleReveal 1.2s 0.4s cubic-bezier(0.16,1,0.3,1) both;
text-shadow:0 0 60px var(--theme-glow),0 4px 30px rgba(0,0,0,0.5);
position:relative;z-index:1;
}
.intro-title .highlight{color:var(--theme-primary);}
.intro-desc{
font-size:clamp(14px,2vw,17px);color:rgba(255,255,255,0.75);
line-height:1.8;margin:20px auto;max-width:480px;
animation:fadeUp 0.8s 0.8s ease both;opacity:0;
}
.intro-meta{
display:flex;gap:16px;justify-content:center;flex-wrap:wrap;
margin:24px 0;
animation:fadeUp 0.8s 1s ease both;opacity:0;
}
.intro-meta-pill{
background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.15);
border-radius:999px;padding:7px 18px;font-size:13px;font-weight:700;color:rgba(255,255,255,0.8);
display:flex;align-items:center;gap:6px;
}
.btn-start{
background:linear-gradient(135deg,var(--accent2),var(--accent));
color:#fff;font-family:'Fredoka One',cursive;font-size:1.3rem;
border:none;border-radius:16px;padding:18px 52px;cursor:pointer;
margin-top:8px;box-shadow:0 8px 40px rgba(124,58,237,0.5);
transition:transform 0.2s,box-shadow 0.2s;
animation:fadeUp 0.8s 1.2s ease both;opacity:0;
position:relative;overflow:hidden;
}
.btn-start::after{
content:'';position:absolute;inset:0;
background:linear-gradient(135deg,rgba(255,255,255,0.15),transparent);
}
.btn-start:hover{transform:translateY(-3px) scale(1.02);box-shadow:0 16px 50px rgba(124,58,237,0.6);}
.btn-start:active{transform:translateY(0);}
.intro-figure{font-size:5rem;display:block;margin-bottom:16px;animation:floatFig 3s ease-in-out infinite;}
/* ══ GAME SCREEN ══ */
#gameScreen{
background:transparent;
flex-direction:row;
gap:0;
align-items:stretch;
justify-content:stretch;
padding:0;
}
/* HUD TOP */
.hud{
position:fixed;top:0;left:0;right:0;z-index:50;
background:rgba(10,8,22,0.85);backdrop-filter:blur(12px);
border-bottom:1px solid rgba(255,255,255,0.08);
padding:10px 20px;
display:flex;align-items:center;gap:16px;
}
.hud-title{font-family:'Fredoka One',cursive;font-size:1rem;color:var(--text);flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.hud-pill{
background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.1);
border-radius:999px;padding:5px 14px;font-size:12px;font-weight:700;color:var(--muted);
display:flex;align-items:center;gap:5px;white-space:nowrap;
}
.hud-pill .val{color:var(--text);}
.hud-lives{display:flex;gap:3px;font-size:1rem;}
.hud-2p{display:flex;gap:8px;flex:1;}
.hud-player{display:flex;gap:6px;align-items:center;background:rgba(255,255,255,0.06);border:1.5px solid rgba(255,255,255,0.10);border-radius:999px;padding:5px 12px;font-size:12px;font-weight:700;color:var(--muted);transition:all 0.25s;}
.hud-player.active{border-color:var(--theme-primary,#a78bfa);box-shadow:0 0 16px var(--theme-glow,rgba(167,139,250,0.5));background:rgba(255,255,255,0.10);color:var(--text);}
.hud-player.eliminated{opacity:0.45;}
.hud-player.eliminated .hp-stat{text-decoration:line-through;}
.hp-fig{font-size:1.15rem;line-height:1;}
.hp-pos{color:var(--text);}
.hp-stat{font-size:0.85rem;}
#btnMenu{
background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.12);
color:var(--muted);border-radius:8px;padding:6px 12px;font-size:12px;font-weight:700;
cursor:pointer;transition:all 0.15s;
}
#btnMenu:hover{background:rgba(255,255,255,0.15);color:var(--text);}
/* BOARD AREA */
.board-area{
position:fixed;inset:0;top:56px;
display:flex;align-items:center;justify-content:center;
padding:20px;
}
#boardCanvas{
border-radius:20px;
box-shadow:0 0 80px rgba(0,0,0,0.6),0 0 0 1px rgba(255,255,255,0.05);
}
/* DICE PANEL */
.dice-panel{
position:fixed;bottom:24px;left:50%;transform:translateX(-50%);
background:rgba(10,8,22,0.9);backdrop-filter:blur(16px);
border:1px solid rgba(255,255,255,0.1);border-radius:20px;
padding:16px 28px;display:flex;align-items:center;gap:20px;
z-index:50;box-shadow:0 8px 40px rgba(0,0,0,0.5);
}
#diceEmoji{font-size:2.8rem;cursor:pointer;transition:transform 0.1s;user-select:none;filter:drop-shadow(0 0 12px var(--theme-glow));}
#diceEmoji:hover:not(.locked){transform:scale(1.15);}
#diceEmoji.rolling{animation:diceAnim 0.5s ease;}
.dice-label{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:1px;color:var(--muted);}
#btnRoll{
background:linear-gradient(135deg,var(--accent2),var(--accent));
color:#fff;font-family:'Fredoka One',cursive;font-size:1rem;
border:none;border-radius:12px;padding:12px 28px;cursor:pointer;
box-shadow:0 4px 20px rgba(124,58,237,0.4);
transition:transform 0.15s,box-shadow 0.15s,opacity 0.2s;
}
#btnRoll:hover:not(:disabled){transform:translateY(-2px);box-shadow:0 8px 28px rgba(124,58,237,0.5);}
#btnRoll:disabled{opacity:0.35;cursor:not-allowed;}
/* ══ OVERLAYS ══ */
.overlay{
position:fixed;inset:0;z-index:200;
display:flex;align-items:center;justify-content:center;
padding:20px;
opacity:0;pointer-events:none;transition:opacity 0.35s;
}
.overlay.open{opacity:1;pointer-events:all;}
.overlay-bg{position:absolute;inset:0;background:rgba(0,0,0,0.84);}
.overlay-box{
position:relative;z-index:1;
background:rgba(22,20,38,0.97);border:1px solid rgba(255,255,255,0.1);
border-radius:24px;width:100%;max-width:520px;
box-shadow:0 24px 80px rgba(0,0,0,0.7);
transform:scale(0.92);transition:transform 0.35s cubic-bezier(0.16,1,0.3,1);
overflow:hidden;
}
.overlay.open .overlay-box{transform:scale(1);}
/* STORY OVERLAY */
#storyOverlay .overlay-box{border-color:rgba(6,182,212,0.4);max-width:560px;}
.story-ov-header{
background:linear-gradient(135deg,rgba(14,116,144,0.3),rgba(6,182,212,0.1));
padding:28px 28px 20px;text-align:center;border-bottom:1px solid rgba(6,182,212,0.2);
}
.story-ov-emoji{font-size:3.5rem;display:block;margin-bottom:12px;animation:floatFig 3s ease-in-out infinite;}
.story-ov-title{font-family:'Fredoka One',cursive;font-size:1.2rem;color:#06b6d4;}
.story-ov-body{padding:24px 28px;}
.story-ov-text{font-size:15px;font-weight:600;line-height:1.8;color:rgba(255,255,255,0.9);}
.story-ov-btn{
display:block;width:calc(100% - 56px);margin:0 28px 28px;
background:linear-gradient(135deg,#0e7490,#06b6d4);color:#fff;
font-family:'Fredoka One',cursive;font-size:1rem;border:none;
border-radius:12px;padding:14px;cursor:pointer;transition:transform 0.15s;
}
.story-ov-btn:hover{transform:translateY(-2px);}
/* MINIGAME OVERLAY */
#mgOverlay .overlay-box{max-width:1100px;width:min(1100px,95vw);}
.mg-ov-header{
padding:20px 24px 0;
display:flex;align-items:center;justify-content:space-between;
}
.mg-ov-title{font-family:'Fredoka One',cursive;font-size:1.4rem;display:flex;align-items:center;gap:10px;}
.mg-ov-close{
background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.12);
color:var(--muted);width:32px;height:32px;border-radius:8px;cursor:pointer;
font-size:1rem;display:flex;align-items:center;justify-content:center;transition:all 0.15s;
}
.mg-ov-close:hover{border-color:var(--danger);color:var(--danger);}
.mg-ov-body{padding:16px 24px 24px;}
.mg-ov-desc{color:var(--muted);font-size:13px;margin-bottom:12px;line-height:1.6;}
.mg-canvas-wrap{
background:#000;border-radius:12px;overflow:hidden;
display:flex;align-items:center;justify-content:center;
width:100%;min-height:min(560px,70vh);
}
.mg-ov-controls{
margin-top:10px;background:rgba(255,255,255,0.04);border-radius:8px;
padding:8px 12px;font-size:12px;color:var(--muted);display:flex;align-items:center;gap:6px;
}
.mg-result-bar{
margin-top:12px;border-radius:12px;padding:14px 16px;text-align:center;display:none;
}
.mg-result-bar.win{background:rgba(16,185,129,0.15);border:1px solid var(--accent3);}
.mg-result-bar.lose{background:rgba(239,68,68,0.1);border:1px solid var(--danger);}
.mg-result-bar h3{font-family:'Fredoka One',cursive;font-size:1.2rem;}
.mg-result-bar.win h3{color:var(--accent3);}
.mg-result-bar.lose h3{color:var(--danger);}
.mg-result-bar p{font-size:13px;color:var(--muted);margin-top:4px;}
#btnMgContinue{
display:none;margin-top:10px;width:100%;
background:linear-gradient(135deg,var(--accent2),var(--accent));
color:#fff;font-family:'Fredoka One',cursive;font-size:1rem;border:none;
border-radius:10px;padding:13px;cursor:pointer;transition:transform 0.15s;
}
#btnMgContinue:hover{transform:translateY(-1px);}
/* RESULT OVERLAY */
#resultOverlay .overlay-box{text-align:center;padding:40px 32px;}
.res-emoji{font-size:5rem;display:block;margin-bottom:16px;}
.res-title{font-family:'Fredoka One',cursive;font-size:2.5rem;margin-bottom:8px;}
.res-sub{color:var(--muted);font-size:15px;margin-bottom:24px;line-height:1.6;}
.res-stats{display:flex;gap:12px;justify-content:center;flex-wrap:wrap;margin-bottom:28px;}
.rs-pill{background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.1);border-radius:12px;padding:14px 20px;}
.rs-val{font-family:'Fredoka One',cursive;font-size:1.6rem;color:var(--accent);}
.rs-label{font-size:11px;font-weight:800;text-transform:uppercase;color:var(--muted);margin-top:2px;}
.res-btn{
background:linear-gradient(135deg,var(--accent3),#059669);
color:#fff;font-family:'Fredoka One',cursive;font-size:1.1rem;
border:none;border-radius:14px;padding:16px 40px;cursor:pointer;
box-shadow:0 6px 24px rgba(16,185,129,0.4);transition:transform 0.15s;
}
.res-btn:hover{transform:translateY(-2px);}
/* TRANSITION */
#transOverlay{
position:fixed;inset:0;z-index:500;
background:radial-gradient(ellipse at center,var(--accent2) 0%,#000 100%);
opacity:0;pointer-events:none;transition:opacity 0.4s;
}
#transOverlay.flash{opacity:1;}
/* FLOATING TOAST */
#toast{
position:fixed;top:72px;left:50%;transform:translateX(-50%) translateY(-10px);
background:rgba(22,20,38,0.95);border:1px solid rgba(255,255,255,0.12);
border-radius:12px;padding:10px 20px;font-size:14px;font-weight:700;
color:var(--text);z-index:300;opacity:0;transition:all 0.3s;white-space:nowrap;
backdrop-filter:blur(12px);
}
#toast.show{opacity:1;transform:translateX(-50%) translateY(0);}
@keyframes fadeDown{from{opacity:0;transform:translateY(-20px);}to{opacity:1;transform:translateY(0);}}
@keyframes fadeUp {from{opacity:0;transform:translateY(24px);}to{opacity:1;transform:translateY(0);}}
@keyframes titleReveal{from{opacity:0;transform:scale(0.85) translateY(30px);}to{opacity:1;transform:scale(1) translateY(0);}}
@keyframes floatFig{0%,100%{transform:translateY(0);}50%{transform:translateY(-12px);}}
@keyframes diceAnim{0%{transform:rotate(0) scale(1);}25%{transform:rotate(-30deg) scale(1.3);}75%{transform:rotate(30deg) scale(1.3);}100%{transform:rotate(0) scale(1);}}
@keyframes fieldPop{0%{transform:scale(1);}50%{transform:scale(1.3);}100%{transform:scale(1);}}

140
game.html Normal file
View file

@ -0,0 +1,140 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1.0"/>
<title id="pageTitle">Spiel</title>
<link href="https://fonts.googleapis.com/css2?family=Fredoka+One&family=Nunito:wght@400;600;700;800;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="game.css">
</head>
<body>
<canvas id="bgCanvas"></canvas>
<!-- INTRO -->
<div class="screen active" id="introScreen">
<div class="intro-inner">
<div class="intro-particles" id="introParticles"></div>
<span class="intro-figure" id="introFigure">🎮</span>
<div class="game-badge">Spiel-Generator Workshop</div>
<h1 class="intro-title" id="introTitle">Mein <span class="highlight">Spiel</span></h1>
<p class="intro-desc" id="introDesc">Beschreibung folgt...</p>
<div class="intro-meta" id="introMeta"></div>
<button class="btn-start" onclick="startGame()">🎮 Spiel starten!</button>
</div>
</div>
<!-- GAME -->
<div class="screen" id="gameScreen">
<div class="hud">
<div class="hud-title" id="hudTitle">Spiel</div>
<!-- 1P-Pillen (versteckt bei 2P) -->
<div class="hud-pill" id="hudPosPill">⬛ Feld <span class="val" id="hudPos">1/10</span></div>
<div class="hud-pill" id="hudLifePill">❤️ <span class="val hud-lives" id="hudLives">❤️❤️❤️</span></div>
<div class="hud-pill" id="hudPtPill" style="display:none"><span class="val" id="hudPts">0</span></div>
<!-- 2P-Spieler-Pillen (versteckt bei 1P) -->
<div class="hud-2p" id="hud2p" style="display:none">
<div class="hud-player" id="hudP1">
<span class="hp-fig" id="hp1Fig">🤖</span>
<span class="hp-pos" id="hp1Pos">1/10</span>
<span class="hp-stat" id="hp1Stat">❤️❤️❤️</span>
</div>
<div class="hud-player" id="hudP2">
<span class="hp-fig" id="hp2Fig">🦊</span>
<span class="hp-pos" id="hp2Pos">1/10</span>
<span class="hp-stat" id="hp2Stat">❤️❤️❤️</span>
</div>
</div>
<button id="btnMenu" onclick="showMenu()">☰ Menü</button>
</div>
<div class="board-area">
<canvas id="boardCanvas"></canvas>
</div>
<div class="dice-panel">
<div>
<div class="dice-label" id="diceLabel">Würfeln!</div>
<div id="diceEmoji">🎲</div>
</div>
<button id="btnRoll" onclick="doRoll()">🎲 Würfeln</button>
</div>
</div>
<!-- OVERLAYS -->
<!-- Story -->
<div class="overlay" id="storyOverlay">
<div class="overlay-bg"></div>
<div class="overlay-box">
<div class="story-ov-header">
<span class="story-ov-emoji" id="storyEmoji">📖</span>
<div class="story-ov-title">Erzähl-Text</div>
</div>
<div class="story-ov-body">
<p class="story-ov-text" id="storyText"></p>
</div>
<button class="story-ov-btn" onclick="closeStory()">▶ Weiter</button>
</div>
</div>
<!-- Minigame -->
<div class="overlay" id="mgOverlay">
<div class="overlay-bg"></div>
<div class="overlay-box">
<div class="mg-ov-header">
<div class="mg-ov-title" id="mgTitle">Mini-Game</div>
<button class="mg-ov-close" onclick="skipMinigame()"></button>
</div>
<div class="mg-ov-body">
<p class="mg-ov-desc" id="mgDesc"></p>
<div class="mg-canvas-wrap" id="mgCanvasWrap"></div>
<div class="mg-ov-controls" id="mgControls"></div>
<div class="mg-result-bar" id="mgResultBar">
<h3 id="mgResultTitle"></h3>
<p id="mgResultSub"></p>
</div>
<button id="btnMgContinue" onclick="afterMinigame()">Weiter →</button>
</div>
</div>
</div>
<!-- Result -->
<div class="overlay" id="resultOverlay">
<div class="overlay-bg"></div>
<div class="overlay-box">
<span class="res-emoji" id="resEmoji">🏆</span>
<h2 class="res-title" id="resTitle">Gewonnen!</h2>
<p class="res-sub" id="resSub"></p>
<div class="res-stats" id="resStats"></div>
<div style="display:flex;gap:10px;flex-wrap:wrap;justify-content:center;margin-top:8px">
<button class="res-btn" onclick="window.location.reload()" style="background:linear-gradient(135deg,#7c3aed,#5b21b6)">🔄 Nochmal spielen</button>
<button class="res-btn" onclick="backToEditor()" style="background:linear-gradient(135deg,#0e7490,#06b6d4)">📝 Zum Editor</button>
</div>
</div>
</div>
<!-- Flash -->
<div id="transOverlay"></div>
<div id="toast"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pako/2.1.0/pako.min.js"></script>
<script src="js/world.js"></script>
<script src="js/game.js"></script>
<!-- Mini-Game Module -->
<script src="minigames/_api.js"></script>
<script src="minigames/snake.js"></script>
<script src="minigames/flappy.js"></script>
<script src="minigames/memory.js"></script>
<script src="minigames/quiz.js"></script>
<script src="minigames/reaction.js"></script>
<script src="minigames/basketball.js"></script>
<script src="minigames/catch.js"></script>
<script src="minigames/maze.js"></script>
<script src="minigames/simon.js"></script>
<script src="minigames/typing.js"></script>
<script src="minigames/puzzle.js"></script>
<script src="minigames/spotdiff.js"></script>
<script src="minigames/snake2p.js"></script>
<script src="minigames/flappy2p.js"></script>
<script src=/chat/chat.js></script>
</body>
</html>

14
index.html Normal file
View file

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Edu Boardgame Generator</title>
<meta http-equiv="refresh" content="0; url=editor.html">
<link rel="canonical" href="editor.html">
<script>location.replace('editor.html');</script>
</head>
<body style="font-family:Nunito,sans-serif;background:#0f0e17;color:#e2e0f0;height:100vh;display:flex;align-items:center;justify-content:center">
<p>Weiterleitung zum Editor… <a href="editor.html" style="color:#a78bfa">Hier klicken</a>, falls es nicht automatisch geht.</p>
</body>
</html>

307
js/board.js Normal file
View file

@ -0,0 +1,307 @@
/* board.js — Mini-Game-Palette, Spielfeld-Aufbau, Story Drag&Drop */
function palTab(t){
document.querySelectorAll('.pal-tab').forEach((x,i)=>x.classList.toggle('active',(i===0&&t==='games')||(i===1&&t==='story')));
document.getElementById('panGames').classList.toggle('active',t==='games');
document.getElementById('panStory').classList.toggle('active',t==='story');
}
function getUC(id){return ST.fields.filter(f=>f===id).length;}
function mgDis(mg){
if(mg.requires==='2p' && ST.playerCount!==2) return true; // nur in 2P verfügbar
const c=getUC(mg.id);
return mg.multi===1?c>=1:mg.multi===3?c>=MULTI_MAX:false;
}
function mgLockReason(mg){
if(mg.requires==='2p' && ST.playerCount!==2) return '2-Spieler-Modus nötig';
return '';
}
function sCnt(){return ST.storyItems.length;}
function sMaxed(){return ST.storyItems.length>=STORY_MAX;}
function buildPalette(){
const pan=document.getElementById('panGames');pan.innerHTML='';
MINIGAMES.forEach(mg=>{
const tagCls=mg.requires==='2p'?'requires2p':mg.multi==='∞'?'unlim':mg.multi===3?'multi':'once';
const tagTxt=mg.requires==='2p'?'2P':mg.multi==='∞'?'∞':mg.multi===3?'max.3×':'1×';
const c=document.createElement('div');c.className='mg-card';c.id='mgc_'+mg.id;c.draggable=true;
const lockHint=mg.requires==='2p'?`<div class="mg-lock" id="mglk_${mg.id}">🔒 2-Spieler-Modus</div>`:'';
c.innerHTML=`<div class="use-count" id="uc_${mg.id}">0</div><div class="mg-emoji">${mg.e}</div><div class="mg-lbl">${mg.n}</div><div class="mg-tag ${tagCls}">${tagTxt}</div>${lockHint}<button class="mg-test-btn" onclick="openMGTest('${mg.id}',event)">▶ Test</button>`;
c.addEventListener('click',()=>{if(c.classList.contains('disabled'))return;if(selGame===mg.id){selGame=null;c.classList.remove('click-sel');}else{clearSel();selGame=mg.id;c.classList.add('click-sel');}});
c.addEventListener('dragstart',e=>{
if(c.classList.contains('disabled')){e.preventDefault();return;}
dragging='game'; dragId=mg.id; clearSel();
e.dataTransfer.effectAllowed='copy';
e.dataTransfer.setData('text/plain','game:'+mg.id);
// Use the card itself as drag image — offset so cursor is in center
e.dataTransfer.setDragImage(c, c.offsetWidth/2, c.offsetHeight/2);
ghost(mg.e+' '+mg.n);
});
c.addEventListener('dragend',()=>{dragging=null;dragId=null;unghost();cleanDropTargets();});
pan.appendChild(c);
});
const sc=document.getElementById('storyCard');
sc.ondragstart=e=>{
if(sMaxed()){e.preventDefault();return;}
dragging='story'; dragId='story'; clearSel(); selGame='story';
e.dataTransfer.effectAllowed='copy';
e.dataTransfer.setData('text/plain','story');
e.dataTransfer.setDragImage(sc, sc.offsetWidth/2, sc.offsetHeight/2);
ghost('📖 Erzähl-Text');
};
sc.ondragend=()=>{dragging=null;dragId=null;unghost();cleanDropTargets();};
updPal();
}
function clearSel(){selGame=null;document.querySelectorAll('.mg-card').forEach(c=>c.classList.remove('click-sel'));}
function cleanDropTargets(){
document.querySelectorAll('.drop-zone').forEach(z=>{z.classList.remove('story-hoverable','drag-over');});
document.querySelectorAll('.field-row').forEach(r=>r.classList.remove('drag-target'));
}
function updPal(){
MINIGAMES.forEach(mg=>{
const c=document.getElementById('mgc_'+mg.id);const uc=document.getElementById('uc_'+mg.id);if(!c)return;
const cnt=getUC(mg.id);uc.textContent=cnt;
c.classList.toggle('used',cnt>0);c.classList.toggle('disabled',mgDis(mg));
const lockNeeded = mg.requires==='2p' && ST.playerCount!==2;
const lockEl=document.getElementById('mglk_'+mg.id);
if(lockEl) lockEl.style.display = lockNeeded ? '' : 'none';
if(mgDis(mg)&&selGame===mg.id)clearSel();
});
const sc=document.getElementById('storyCard');if(sc){sc.classList.toggle('disabled',sMaxed());const suc=document.getElementById('storyUC');if(suc)suc.textContent=sCnt();sc.classList.toggle('used',sCnt()>0);}
const badge=document.getElementById('storyCtr');badge.textContent='📖 '+sCnt()+' / '+STORY_MAX;badge.classList.toggle('maxed',sMaxed());
const filled=ST.fields.slice(1,ST.fieldCount-1).filter(Boolean).length;
document.getElementById('s3next').disabled=filled===0;
const s3hint=document.getElementById('s3hint');if(s3hint)s3hint.style.display=filled===0?'block':'none';
}
let dragId=null;
function ghost(txt){const g=document.getElementById('dragGhost');g.textContent=txt;g.style.opacity='1';}
function unghost(){document.getElementById('dragGhost').style.opacity='0';}
// Track mouse position for ghost element
document.addEventListener('dragover',e=>{
e.preventDefault();
const g=document.getElementById('dragGhost');
if(dragging){g.style.left=e.clientX+'px';g.style.top=e.clientY+'px';}
if(dragging==='story'){
document.querySelectorAll('.drop-zone').forEach(z=>z.classList.add('story-hoverable'));
}
});
document.addEventListener('dragend',()=>cleanDropTargets());
/* ── Welt-Editor: Canvas mit rAF-Loop + Drag&Drop direkt auf Pads ── */
let worldEdit=null, worldRaf=null, worldHover=-1, worldCanvasInit=false;
function ensureWorld(){
if(!ST.worldSeed) ST.worldSeed=World.randomSeed();
worldEdit=World.generate(ST.background||'space', ST.worldSeed, ST.fieldCount);
}
function worldReroll(){
const doReroll=()=>{
ST.worldSeed=World.randomSeed();
ensureWorld(); setCodeFocus('fields'); save(); updateWorldUI();
};
if(ST.worldLocked){
appConfirm('Spielbrett ist fixiert. Willst du wirklich eine neue Welt generieren?\n\nFelder und Storys bleiben erhalten.', doReroll, {icon:'🔒', okText:'Neu generieren'});
} else {
doReroll();
}
}
function worldToggleLock(){
ST.worldLocked=!ST.worldLocked;
save();
updateWorldUI();
}
function updateWorldUI(){
const lb=document.getElementById('wbLock'); const st=document.getElementById('wbStatus'); const rb=document.getElementById('wbReroll');
if(lb){lb.textContent=ST.worldLocked?'🔓 Wieder bearbeiten':'✓ Spielbrett verwenden'; lb.classList.toggle('locked',ST.worldLocked);}
if(st){st.textContent=ST.worldLocked?'🔒 Spielbrett ist fixiert':'🎲 Welt frei generierbar';}
if(rb)rb.classList.toggle('dim',ST.worldLocked);
}
function resizeWorldCanvas(canvas){
const r=canvas.getBoundingClientRect();
const dpr=window.devicePixelRatio||1;
const w=Math.round(r.width), h=Math.round(r.height);
if(w<=0||h<=0) return false;
if(canvas.width!==Math.round(w*dpr)){canvas.width=Math.round(w*dpr);canvas.height=Math.round(h*dpr);}
canvas._w=w; canvas._h=h; canvas._dpr=dpr;
return true;
}
function worldLoop(){
const canvas=document.getElementById('worldCanvas');
if(!canvas){worldRaf=null;return;}
// Pause wenn Schritt 3 nicht aktiv (cur!==2)
if(typeof cur==='number' && cur!==2){worldRaf=requestAnimationFrame(worldLoop);return;}
if(!resizeWorldCanvas(canvas)){worldRaf=requestAnimationFrame(worldLoop);return;}
if(!worldEdit || worldEdit.theme!==World.resolveTheme(ST.background||'space') || worldEdit.fieldCount!==ST.fieldCount || worldEdit.seed!==(ST.worldSeed>>>0)){
ensureWorld();
}
const ctx=canvas.getContext('2d');
ctx.setTransform(canvas._dpr,0,0,canvas._dpr,0,0);
ctx.clearRect(0,0,canvas._w,canvas._h);
const fig1=(FIGURES.find(f=>f.id===ST.figure)||{}).e||'🎮';
const fig2=ST.playerCount===2 ? ((FIGURES.find(f=>f.id===ST.figure2)||{}).e||'🎮') : null;
const c0=World.padCenter(worldEdit,0,canvas._w,canvas._h);
const figures = [{ emoji:fig1, x:c0.x, y:c0.y, active:true, dimmed:false }];
if(fig2) figures.push({ emoji:fig2, x:c0.x, y:c0.y, active:false, dimmed:false });
World.render(ctx, worldEdit, {
W:canvas._w, H:canvas._h,
pos:0, visited:new Set([0]),
fields:ST.fields, storyItems:ST.storyItems,
figures,
figEmoji:fig1, figX:c0.x, figY:c0.y,
gameName:ST.name, devName:ST.devName,
hover:worldHover,
}, performance.now());
worldRaf=requestAnimationFrame(worldLoop);
}
function worldEventToPad(e){
const canvas=document.getElementById('worldCanvas');
if(!canvas||!worldEdit) return -1;
const r=canvas.getBoundingClientRect();
return World.hitTestPad(worldEdit, e.clientX-r.left, e.clientY-r.top, canvas._w||r.width, canvas._h||r.height);
}
function initWorldCanvas(){
if(worldCanvasInit) return;
const canvas=document.getElementById('worldCanvas'); if(!canvas) return;
worldCanvasInit=true;
canvas.addEventListener('dragover',e=>{if(!dragging)return; e.preventDefault(); e.dataTransfer.dropEffect='copy'; worldHover=worldEventToPad(e);});
canvas.addEventListener('dragleave',()=>{worldHover=-1;});
canvas.addEventListener('drop',e=>{
if(!dragging) return;
e.preventDefault();
const idx=worldEventToPad(e);
const d=e.dataTransfer.getData('text/plain');
worldHover=-1;
if(idx<0) return;
if(d==='story'){
const r=canvas.getBoundingClientRect();
const pc=World.padCenter(worldEdit,idx,canvas._w,canvas._h);
const above=(e.clientY-r.top)<pc.y;
// before idx>0; after idx<last
const pos = above ? (idx>0?'before':'after') : (idx<ST.fieldCount-1?'after':'before');
const fi = above ? (idx>0?idx:idx) : (idx<ST.fieldCount-1?idx:idx);
dropStory(pos,fi);
} else if(d.startsWith('game:')){
if(idx===0||idx===ST.fieldCount-1){showToast('Start- und Zielfeld sind reserviert.');return;}
dropGame(idx, d.replace('game:',''));
}
});
canvas.addEventListener('click',e=>{
const idx=worldEventToPad(e);
if(idx<0) return;
if(selGame==='story'){
const r=canvas.getBoundingClientRect();
const pc=World.padCenter(worldEdit,idx,canvas._w,canvas._h);
const above=(e.clientY-r.top)<pc.y;
const pos = above ? (idx>0?'before':'after') : (idx<ST.fieldCount-1?'after':'before');
dropStory(pos,idx);
} else if(selGame){
if(idx===0||idx===ST.fieldCount-1) return;
handleFClick(idx);
} else if(ST.fields[idx] && idx>0 && idx<ST.fieldCount-1){
// Klick auf belegtes Pad ohne Auswahl → entfernen?
appConfirm('Mini-Game von diesem Feld entfernen?', ()=>{
ST.fields[idx]=null; buildBoard(); save(); setCodeFocus('fields');
}, {danger:true, okText:'Entfernen', icon:'🗑️'});
}
});
canvas.addEventListener('mousemove',e=>{worldHover=worldEventToPad(e);});
canvas.addEventListener('mouseleave',()=>{worldHover=-1;});
}
function buildStoryList(){
const list=document.getElementById('storyList'); if(!list) return;
list.innerHTML='';
// sortiert: nach fieldIndex, before vor after
const sorted=[...ST.storyItems].sort((a,b)=>(a.fieldIndex-b.fieldIndex)||(a.position==='before'?-1:1));
sorted.forEach(s=>list.appendChild(mkStory(s)));
}
function buildBoard(){
ensureWorld();
initWorldCanvas();
updateWorldUI();
if(!worldRaf) worldRaf=requestAnimationFrame(worldLoop);
buildStoryList();
updPal();
const filled=ST.fields.slice(1,ST.fieldCount-1).filter(Boolean).length;
const stEl=document.getElementById('boardStats');
if(stEl) stEl.innerHTML=`<div class="stat-pill">🎯 Belegt: <span>${filled}/${ST.fieldCount-2}</span></div><div class="stat-pill">🕹️ Games: <span>${new Set(ST.fields.filter(Boolean)).size}</span></div><div class="stat-pill">📖 Texte: <span>${sCnt()}</span></div>`;
}
function mkDrop(pos,fi){
const z=document.createElement('div');z.className='drop-zone';
z.textContent = pos==='before' ? `↗ Vor Feld ${fi}` : `↘ Nach Feld ${fi}`;
z.addEventListener('dragenter',e=>{if(dragging==='story'){e.preventDefault();z.classList.add('drag-over');}});
z.addEventListener('dragover',e=>{if(dragging==='story'){e.preventDefault();e.dataTransfer.dropEffect='copy';z.classList.add('drag-over');}});
z.addEventListener('dragleave',e=>{if(!z.contains(e.relatedTarget))z.classList.remove('drag-over');});
z.addEventListener('drop',e=>{
e.preventDefault();z.classList.remove('drag-over');
if(e.dataTransfer.getData('text/plain')==='story')dropStory(pos,fi);
});
z.addEventListener('click',()=>{if(selGame==='story')dropStory(pos,fi);});
return z;
}
function mkStory(s){
// XSS-sicher: DOM-API + textContent/value statt innerHTML — s.id wird via JS-Closure übergeben,
// also kein HTML-Inject-Vektor mehr.
const row=document.createElement('div'); row.className='story-row'; row.dataset.sid=s.id;
const posLbl = s.position==='before' ? `Vor Feld ${s.fieldIndex|0}` : `Nach Feld ${s.fieldIndex|0}`;
const icon = document.createElement('div'); icon.className='story-icon';
icon.textContent = String(s.emoji||'📖').slice(0,8);
icon.addEventListener('click', () => openEP(s.id, icon));
const content = document.createElement('div'); content.className='story-content';
const lbl = document.createElement('div'); lbl.className='story-lbl'; lbl.textContent='📖 Erzähl-Text';
const ta = document.createElement('textarea'); ta.className='story-ta'; ta.rows=2; ta.maxLength=300;
ta.placeholder='Schreib deine Geschichte...';
ta.value = String(s.text||'');
ta.addEventListener('input', () => updStory(s.id, ta.value));
const meta = document.createElement('div'); meta.className='story-meta';
const posEl = document.createElement('div'); posEl.className='story-pos-lbl'; posEl.textContent=posLbl;
const chars = document.createElement('div'); chars.className='story-chars'; chars.textContent=(s.text||'').length+'/300';
meta.appendChild(posEl); meta.appendChild(chars);
content.appendChild(lbl); content.appendChild(ta); content.appendChild(meta);
const clear = document.createElement('button'); clear.className='story-clear'; clear.textContent='✕';
clear.addEventListener('click', () => rmStory(s.id));
row.appendChild(icon); row.appendChild(content); row.appendChild(clear);
return row;
}
function clickStory(){if(sMaxed())return;if(selGame==='story'){selGame=null;document.getElementById('storyCard').classList.remove('click-sel');}else{clearSel();selGame='story';document.getElementById('storyCard').classList.add('click-sel');}}
function dropStory(pos,fi){
if(sMaxed()){showToast('⚠️ Maximal 5 Erzähltexte!');return;}
const id='st_'+Date.now();
ST.storyItems.push({id,emoji:'📖',text:'',position:pos,fieldIndex:fi});
clearSel();buildBoard();
setTimeout(()=>{const r=document.querySelector(`[data-sid="${id}"] textarea`);if(r)r.focus();},80);
}
function updStory(id,val){const s=ST.storyItems.find(x=>x.id===id);if(!s)return;s.text=val;const row=document.querySelector(`[data-sid="${id}"]`);if(row){const cc=row.querySelector('.story-chars');if(cc)cc.textContent=val.length+'/300';}}
function rmStory(id){appConfirm('Erzähltext wirklich löschen?', ()=>{ST.storyItems=ST.storyItems.filter(x=>x.id!==id);buildBoard();}, {danger:true, okText:'Löschen', icon:'🗑️'});}
function openEP(sid,iconEl){
const picker=document.getElementById('emojiPicker');
if(epFor===sid){picker.classList.remove('open');epFor=null;return;}
epFor=sid;picker.innerHTML='';
STORY_EMOJIS.forEach(em=>{const btn=document.createElement('div');btn.className='ep-it';btn.textContent=em;btn.addEventListener('click',()=>{const s=ST.storyItems.find(x=>x.id===sid);if(s){s.emoji=em;buildBoard();}picker.classList.remove('open');epFor=null;});picker.appendChild(btn);});
const r=iconEl.getBoundingClientRect();
picker.style.left=Math.min(r.left,window.innerWidth-220)+'px';
picker.style.top=(r.bottom+6)+'px';
picker.classList.add('open');
}
document.addEventListener('click',e=>{if(epFor&&!e.target.classList.contains('story-icon')&&!document.getElementById('emojiPicker').contains(e.target)){document.getElementById('emojiPicker').classList.remove('open');epFor=null;}});
function handleFClick(idx){if(!selGame||selGame==='story')return;const mg=MINIGAMES.find(m=>m.id===selGame);if(!mg||mgDis(mg))return;ST.fields[idx]=selGame;setCodeFocus('fields');buildBoard();}
function dropGame(idx,id){
const mg=MINIGAMES.find(m=>m.id===id);if(!mg)return;
const src=dragFromIdx;dragFromIdx=null;
if(src===idx){buildBoard();return;}
const tmp=[...ST.fields];
if(src!==null)tmp[src]=null;
tmp[idx]=null;
const cnt=tmp.filter(f=>f===id).length;
if((mg.multi===1&&cnt>=1)||(mg.multi===3&&cnt>=MULTI_MAX)){showToast('⚠️ Limit erreicht für '+mg.n+'!');return;}
if(src!==null)ST.fields[src]=null;
ST.fields[idx]=id;
setCodeFocus('fields');
buildBoard();
}
function clearF(idx,e){e&&e.stopPropagation();appConfirm('Mini-Game von Feld '+idx+' entfernen?', ()=>{ST.fields[idx]=null;buildBoard();}, {danger:true, okText:'Entfernen', icon:'🗑️'});}

54
js/data.js Normal file
View file

@ -0,0 +1,54 @@
/* data.js — Stammdaten: Figuren, Welten, Mini-Games, Quiz-Beispiele, Feedback-Fragen */
/* ── XSS helper ── */
function esc(s){return String(s??'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');}
/*
DATA
*/
const FIGURES=[
{id:'robot',e:'🤖',n:'Roboter'},{id:'ninja',e:'🥷',n:'Ninja'},{id:'knight',e:'🧙‍♂️',n:'Zauberer'},
{id:'cat',e:'🐱',n:'Katze'},{id:'rocket',e:'🚀',n:'Rakete'},{id:'dino',e:'🦖',n:'Dino'},
{id:'alien',e:'👾',n:'Alien'},{id:'superhero',e:'🦸',n:'Superheld'},{id:'pirate',e:'🏴‍☠️',n:'Pirat'},
{id:'fox',e:'🦊',n:'Fuchs'},{id:'dragon',e:'🐲',n:'Drache'},{id:'astronaut',e:'👨‍🚀',n:'Astronaut'},
];
const BACKGROUNDS=[
{id:'underwater',e:'🌊',scene:'🌊🐠🐙',n:'Unterwasser',color:'#22d3ee',glow:'rgba(34,211,238,0.45)'},
{id:'space', e:'🌌',scene:'🌌🪐⭐',n:'Weltall', color:'#a78bfa',glow:'rgba(167,139,250,0.5)'},
{id:'haunted', e:'👻',scene:'👻🏚️🕸️',n:'Geisterhaus',color:'#86efac',glow:'rgba(134,239,172,0.45)'},
{id:'fantasy', e:'🧙',scene:'🧙🐉✨',n:'Fantasy', color:'#f0abfc',glow:'rgba(240,171,252,0.5)'},
];
const MINIGAMES=[
{id:'quiz',e:'❓',n:'Quiz',multi:'∞'},{id:'reaction',e:'⚡',n:'Reaktion',multi:3},
{id:'puzzle',e:'🧩',n:'Rätsel',multi:3},{id:'spotdiff',e:'🔍',n:'Fehler finden',multi:3},
{id:'typing',e:'⌨️',n:'Tipp-Rennen',multi:3},{id:'snake',e:'🐍',n:'Snake',multi:1},
{id:'flappy',e:'🐦',n:'Flappy Bird',multi:1},{id:'catch',e:'🍎',n:'Äpfel fangen',multi:1},
{id:'basketball',e:'🏀',n:'Basketball',multi:1},{id:'memory',e:'🃏',n:'Memory',multi:1},
{id:'maze',e:'🌀',n:'Labyrinth',multi:1},{id:'simon',e:'🔴',n:'Simon Says',multi:1},
{id:'snake2p',e:'🐍🐍',n:'Snake-Duell',multi:1,requires:'2p'},
{id:'flappy2p',e:'🐦🐦',n:'Flappy-Duell',multi:1,requires:'2p'},
];
const STORY_EMOJIS=['📖','🗺️','⚔️','🧭','💬','🌟','🔥','💡','🎭','🏴‍☠️','🌈','🐉','👁️','🎶','🌙','⚡','🗝️','🌊','🏔️','🎪','🦋','🌺','💎','🎯'];
const LETTERS=['A','B','C','D'];
const QUIZ_EX=[
{q:'Was ist die Hauptstadt von Deutschland?',a:['Paris','London','Berlin','Madrid'],c:2},
{q:'Wie viele Seiten hat ein Hexagon?',a:['5','6','7','8'],c:1},
{q:'Wer schrieb "Romeo und Julia"?',a:['Goethe','Schiller','Shakespeare','Kafka'],c:2},
{q:'Was ist das größte Tier der Welt?',a:['Elefant','Blauwal','Hai','Giraffe'],c:1},
{q:'Wie viele Minuten hat eine Stunde?',a:['50','70','100','60'],c:3},
{q:'Chemische Formel für Wasser?',a:['CO2','H2O','O2','NaCl'],c:1},
{q:'In welchem Kontinent liegt Ägypten?',a:['Asien','Europa','Amerika','Afrika'],c:3},
{q:'Was ist 12 × 12?',a:['132','144','124','148'],c:1},
{q:'Blau + Gelb = ?',a:['Rot','Orange','Grün','Lila'],c:2},
{q:'Höchster Berg der Welt?',a:['K2','Mont Blanc','Everest','Kilimandscharo'],c:2},
];
const FB_QS=[
{id:'fun',t:'scale',q:'Wie viel Spaß hat dein Spiel gemacht?',sub:'1 = gar kein Spaß · 5 = mega viel Spaß',min:'😴 Langweilig',max:'🔥 Super Spaß'},
{id:'difficulty',t:'mc',q:'Wie schwierig war dein Spiel?',opts:['😴 Viel zu einfach','👍 Genau richtig','😅 Etwas zu schwer','💀 Viel zu schwer']},
{id:'flow',t:'mc',q:'Wie gut passt das Spiel zusammen?',sub:'„Flow" — fühlt sich alles stimmig und flüssig an?',opts:['🔀 Wirkt zusammengewürfelt','🤔 Einigermaßen','👌 Passt gut','✨ Perfekt stimmig']},
{id:'clarity',t:'mc',q:'Waren Regeln und Aufgaben klar?',opts:['❓ Oft unklar','🤔 Manchmal unklar','👍 Meistens klar','✅ Immer sofort klar']},
{id:'minigames',t:'scale',q:'Wie gut passen die Mini-Games zum Thema?',sub:'Passen sie zur Geschichte und zum Setting?',min:'🚫 Passen nicht',max:'✨ Passen perfekt'},
{id:'problems',t:'text',q:'Was hat noch nicht so gut funktioniert?',ph:'z.B. "Das Quiz war zu einfach" oder "zu viele leere Felder"...'},
{id:'highlight',t:'text',q:'Was war der beste Teil deines Spiels?',ph:'z.B. "Die Geschichte war spannend" oder "Snake hat Spaß gemacht"...'},
{id:'replay',t:'mc',q:'Würdest du das Spiel nochmal spielen?',opts:['❌ Eher nicht','🤷 Vielleicht','😊 Ja','🚀 Auf jeden Fall!']},
];

1001
js/game.js Normal file

File diff suppressed because it is too large Load diff

155
js/minigame-test.js Normal file
View file

@ -0,0 +1,155 @@
/* minigame-test.js — Test-Popup (preview) der Mini-Game-Module */
/*
INIT
*/
// ── Init wird nach Modulen ausgeführt (siehe unten) ──
/*
MINI-GAME TEST POPUP
*/
let mgTestLoop=null;
let mgTestActive=null;
function openMGTest(id, e){
e&&e.stopPropagation();
const mg=MINIGAMES.find(m=>m.id===id);if(!mg)return;
closeMGTest();
currentMGId=id;
document.getElementById('mgTestEmoji').textContent=mg.e;
document.getElementById('mgTestTitle').textContent=mg.n+' — Probespiel';
// Beschreibung aus Modul oder MINIGAMES
const mod=window['MG_'+id];
document.getElementById('mgTestDesc').textContent=mod?mod.desc:(mg.desc||'');
document.getElementById('mgTestMsg').textContent='';
document.getElementById('mgTestMsg').className='';
const ctrl=document.getElementById('mgTestControls');
ctrl.innerHTML=renderMgSettings(id)+'<div style="display:flex;gap:8px;margin-top:4px"><button class="mg-ctrl-btn" onclick="restartMGTest()">🔄 Neu starten</button><button class="mg-ctrl-btn secondary" onclick="closeMGTest()">Schließen</button></div>';
document.getElementById('mgTestOverlay').classList.add('open');
document.body.style.overflow='hidden';
// Modul-System: preview() aufrufen
const wrap=document.getElementById('mgTestWrap');
wrap.innerHTML='';
if(mod && mod.preview){
// Theme aus aktuellem ST-State
const bg=BACKGROUNDS.find(b=>b.id===ST.background);
const modCfg={
theme: bg?{primary:bg.color||'#7c3aed',glow:bg.glow||'rgba(124,58,237,0.4)'}:{primary:'#7c3aed',glow:'rgba(124,58,237,0.4)'},
quizData: ST.quizData||[],
devName: ST.devName,
gameName: ST.name,
figure: (FIGURES.find(f=>f.id===ST.figure)||{}).e || '🐦',
};
Object.assign(modCfg, getMgSettings(id));
// onResult-Handler
window._mgOnResult=(won)=>{
setMsg(won?'🏆 Gewonnen! 🎉':'💀 Verloren! Versuch es nochmal.', won?'win':'lose');
};
if(window.MGAPI) MGAPI.onResult=window._mgOnResult;
// Bug 4 Fix: requestAnimationFrame stellt sicher dass der Browser das
// Overlay gerendert hat und wrap.offsetWidth korrekt ist
requestAnimationFrame(()=>{
mgTestActive=mod.preview(wrap, Math.max(wrap.offsetWidth,360), Math.max(wrap.offsetHeight||0,560), modCfg);
});
} else {
setMsg('Vorschau für dieses Mini-Game nicht verfügbar.', 'lose');
}
}
function closeMGTest(){
// Modul stoppen falls aktiv
if(mgTestActive&&mgTestActive.stop){mgTestActive.stop();mgTestActive=null;}
if(mgTestLoop){clearInterval(mgTestLoop);cancelAnimationFrame(mgTestLoop);mgTestLoop=null;}
document.removeEventListener('keydown',mgKeyHandler);
document.removeEventListener('mousemove',mgMouseHandler);
document.getElementById('mgTestOverlay').classList.remove('open');
document.body.style.overflow='';
// Wrap leeren
const wrap=document.getElementById('mgTestWrap');
if(wrap)wrap.innerHTML='';
const canvas=document.getElementById('mgTestCanvas');
if(canvas){const ctx=canvas.getContext('2d');ctx.clearRect(0,0,canvas.width,canvas.height);}
currentMGId=null;
}
let currentMGId=null;
function restartMGTest(){
if(currentMGId)openMGTest(currentMGId,null);
}
let mgKeyHandler=null,mgMouseHandler=null;
function setMsg(txt,cls=''){const m=document.getElementById('mgTestMsg');m.textContent=txt;m.className=cls;}
/*
SCHWIERIGKEITS-EINSTELLUNGEN je Mini-Game
Werte landen in ST.mgSettings[id] werden exportiert (Teilen-URL)
und im fertigen Spiel angewandt.
*/
const MG_SETTINGS_DEF={
flappy:[
{key:'win', label:'Hindernisse bis Sieg', min:5, max:30, step:1, def:15},
{key:'speed',label:'Start-Tempo', min:1.8,max:4.5, step:0.1, def:2.7, fmt:v=>(+v).toFixed(1)},
{key:'gap', label:'Start-Größe Durchgang',min:110,max:210, step:5, def:168, fmt:v=>v+' px'},
{key:'rampSpeed',type:'toggle',label:'Tempo steigt pro Durchgang',def:true},
{key:'shrinkGap',type:'toggle',label:'Durchgänge werden enger', def:true},
],
flappy2p:[
{key:'bestOf', label:'Best of', min:2, max:5, step:1, def:3, fmt:v=>'Best of '+v},
{key:'speed', label:'Start-Tempo', min:1.8,max:4.5, step:0.1, def:2.7, fmt:v=>(+v).toFixed(1)},
{key:'gap', label:'Start-Größe Durchgang',min:110,max:210, step:5, def:168, fmt:v=>v+' px'},
{key:'rampSpeed',type:'toggle',label:'Tempo steigt pro Durchgang',def:true},
{key:'shrinkGap',type:'toggle',label:'Durchgänge werden enger', def:true},
],
};
// Liefert (und initialisiert mit Defaults) die Einstellungen eines Mini-Games
function getMgSettings(id){
if(!ST.mgSettings)ST.mgSettings={};
const defs=MG_SETTINGS_DEF[id];
if(!defs)return {};
if(!ST.mgSettings[id])ST.mgSettings[id]={};
defs.forEach(d=>{ if(ST.mgSettings[id][d.key]==null)ST.mgSettings[id][d.key]=d.def; });
return ST.mgSettings[id];
}
// Einstellungs-Panel im Test-Popup (Slider = Startwerte, Toggles = Steigerung obendrauf)
function renderMgSettings(id){
const defs=MG_SETTINGS_DEF[id]; if(!defs)return '';
const s=getMgSettings(id);
const rows=defs.filter(d=>d.type!=='toggle').map(d=>{
const v=s[d.key], disp=d.fmt?d.fmt(v):v;
return `<div style="display:flex;align-items:center;gap:10px;margin:7px 0;font-size:13px;color:#cdc9e6">
<span style="flex:0 0 150px">${d.label}</span>
<input type="range" min="${d.min}" max="${d.max}" step="${d.step}" value="${v}" style="flex:1;accent-color:#7c3aed;cursor:pointer"
oninput="mgSetLabel('${id}','${d.key}',this.value)" onchange="mgSetApply('${id}','${d.key}',this.value)">
<b id="mgset-${id}-${d.key}" style="flex:0 0 56px;text-align:right;color:#a78bfa">${disp}</b>
</div>`;
}).join('');
const toggles=defs.filter(d=>d.type==='toggle');
let tg='';
if(toggles.length){
tg='<div style="display:flex;gap:8px;margin-top:9px">'+toggles.map(d=>{
const on=!!s[d.key];
return `<div onclick="mgToggle('${id}','${d.key}')" style="flex:1;cursor:pointer;user-select:none;border-radius:9px;padding:8px 10px;font-size:12px;line-height:1.25;text-align:center;transition:all 0.12s;border:1.5px solid ${on?'#7c3aed':'#2e2b4a'};background:${on?'rgba(124,58,237,0.22)':'#13111f'};color:${on?'#c4b5fd':'#7c7596'}">
<div style="font-size:15px;margin-bottom:1px">${on?'✓':'○'}</div>${d.label}</div>`;
}).join('')+'</div>';
}
return `<div style="background:#13111f;border:1px solid #2e2b4a;border-radius:10px;padding:8px 12px;margin-bottom:10px">
<div style="font-size:12px;color:#7c7596;margin-bottom:2px"> Schwierigkeit wird im geteilten Spiel übernommen</div>${rows}${tg}</div>`;
}
// Live: nur Anzeige aktualisieren (beim Ziehen)
function mgSetLabel(id,key,val){
const d=(MG_SETTINGS_DEF[id]||[]).find(x=>x.key===key); if(!d)return;
const el=document.getElementById('mgset-'+id+'-'+key);
if(el)el.textContent=d.fmt?d.fmt(val):val;
}
// Übernehmen (beim Loslassen): speichern + Vorschau neu starten
function mgSetApply(id,key,val){
getMgSettings(id)[key]=Number(val);
save();
restartMGTest();
}
// Anklickbares Feld umschalten (Steigerung an/aus)
function mgToggle(id,key){
const s=getMgSettings(id); s[key]=!s[key];
save();
restartMGTest();
}

232
js/quiz.js Normal file
View file

@ -0,0 +1,232 @@
/* quiz.js — Quiz-Builder, Review-Screen, Feedback-Bogen */
/*
STEP 4 QUIZ
*/
function isQComplete(q){return q.question.trim()&&q.answers.every(a=>a.trim())&&q.correct!==null;}
function getQ(fi){return ST.quizData.find(q=>q.fieldIndex===fi);}
function buildQuiz(){
const qidx=ST.fields.map((f,i)=>f==='quiz'?i:-1).filter(i=>i>=0);
qidx.forEach(fi=>{if(!ST.quizData.find(q=>q.fieldIndex===fi))ST.quizData.push({fieldIndex:fi,question:'',answers:['','','',''],correct:null});});
ST.quizData=ST.quizData.filter(q=>qidx.includes(q.fieldIndex));
const area=document.getElementById('quizArea');area.innerHTML='';
if(qidx.length===0){
area.innerHTML=`<div class="card"><div class="no-quiz"><div class="nq-icon">❓</div><h3>Keine Quiz-Felder platziert</h3><p style="color:var(--muted);font-size:13px">In Schritt 3 hast du keine Quiz-Felder hinzugefügt.<br>Du kannst trotzdem weitermachen!</p></div></div>`;
document.getElementById('s4next').disabled=false;return;
}
const done=ST.quizData.filter(isQComplete).length;
const ov=document.createElement('div');ov.className='ov-bar';
ov.innerHTML=`<div class="ov-pill ${done===qidx.length?'good':'warn'}">✅ Fertig: <span>${done}/${qidx.length}</span></div><div class="ov-pill">❓ Quiz-Felder: <span>${qidx.length}</span></div>`;
area.appendChild(ov);
const af=document.createElement('div');af.className='af-bar';
af.innerHTML=`<div class="af-icon">✨</div><div class="af-text"><div class="af-title">Beispielfragen</div><div class="af-sub">Alle leeren Felder automatisch ausfüllen</div></div><button class="btn-sm" onclick="autoFill()">Ausfüllen</button>`;
area.appendChild(af);
ST.quizData.forEach((q,i)=>area.appendChild(mkQCard(q,i)));
updQNext();
}
function updQNext(){
const qidx=ST.fields.map((f,i)=>f==='quiz'?i:-1).filter(i=>i>=0);
document.getElementById('s4next').disabled=qidx.length>0&&ST.quizData.filter(isQComplete).length<qidx.length;
const done=ST.quizData.filter(isQComplete).length;
const p=document.querySelector('.ov-pill');if(p){p.className='ov-pill '+(done===qidx.length?'good':'warn');p.innerHTML=`✅ Fertig: <span>${done}/${qidx.length}</span>`;}
}
function mkQCard(q,idx){
// XSS-sicher: fieldIndex wird zu Integer geclampt, Frage/Antworten via esc() (entschärft <,>,&,",')
const comp=isQComplete(q);
const fi = parseInt(q.fieldIndex)|0; // hart casten
const c=document.createElement('div');c.className='card quiz-card'+(comp?' complete':'');c.id='qc_'+fi;
const fl=fi===0?'Start':fi===ST.fieldCount-1?'Ziel':'Feld '+fi;
const ans = LETTERS.map((L,i)=>{
const v = esc(q.answers[i]||'');
return `<div class="ans-wrap ${q.correct===i?'correct':''}" id="aw_${fi}_${i}"><div class="ans-letter">${L}</div><input type="text" class="ans-input" maxlength="80" placeholder="Antwort ${L}..." value="${v}" oninput="updQ(${fi},'a${i}',this.value)"/></div>`;
}).join('');
const corr = LETTERS.map((L,i)=>`<button class="correct-btn ${q.correct===i?'selected':''}" onclick="setCorr(${fi},${i})">${L}</button>`).join('');
c.innerHTML=`
<div class="qc-head">
<div class="qc-num" id="qnum_${fi}">${idx+1}</div>
<div class="qc-title">Quiz-Frage ${idx+1}</div>
<div class="qc-status ${comp?'done':'todo'}" id="qst_${fi}">${comp?'✓ Fertig':'⏳ Ausfüllen'}</div>
</div>
<label class="lbl">Frage (📍 ${fl})</label>
<textarea class="q-input" rows="2" maxlength="200" placeholder="Schreib deine Frage hier..." oninput="updQ(${fi},'q',this.value)">${esc(q.question||'')}</textarea>
<label class="lbl">Die vier Antworten</label>
<div class="ans-grid" id="ag_${fi}">${ans}</div>
<div class="correct-row">
<span class="correct-lbl"> Richtige Antwort:</span>
<div class="correct-btns">${corr}</div>
</div>
<button class="tips-btn" onclick="this.nextElementSibling.classList.toggle('open')">💡 Tipps</button>
<div class="tips-box"> Stelle eine klare, eindeutige Frage<br> Alle 4 Antworten sollten plausibel klingen<br> Nur eine Antwort ist korrekt<br> Beziehe die Frage auf dein Spielthema!</div>
`;
return c;
}
function updQ(fi,field,val){
const q=getQ(fi);if(!q)return;
if(field==='q')q.question=val;
else q.answers[parseInt(field.slice(1))]=val;
refQCard(fi);updQNext();
}
function setCorr(fi,idx){
const q=getQ(fi);if(!q)return;q.correct=idx;
LETTERS.forEach((_,i)=>{const w=document.getElementById(`aw_${fi}_${i}`);if(w)w.className='ans-wrap '+(i===idx?'correct':'');});
const card=document.getElementById('qc_'+fi);if(card)card.querySelectorAll('.correct-btn').forEach((b,i)=>b.classList.toggle('selected',i===idx));
refQCard(fi);updQNext();
}
function refQCard(fi){
const q=getQ(fi);if(!q)return;
const comp=isQComplete(q);
const c=document.getElementById('qc_'+fi);if(!c)return;
c.classList.toggle('complete',comp);
const st=document.getElementById('qst_'+fi);if(st){st.className='qc-status '+(comp?'done':'todo');st.textContent=comp?'✓ Fertig':'⏳ Ausfüllen';}
const num=document.getElementById('qnum_'+fi);if(num)num.style.background=comp?'linear-gradient(135deg,#10b981,#059669)':'linear-gradient(135deg,#7c3aed,#f5a623)';
}
function autoFill(){
ST.quizData.forEach((q,i)=>{if(!isQComplete(q)){const ex=QUIZ_EX[i%QUIZ_EX.length];q.question=ex.q;q.answers=[...ex.a];q.correct=ex.c;}});
buildQuiz();showToast('✅ Beispielfragen eingefügt!');
}
/*
STEP 5 REVIEW + TEST
*/
function buildReview(){
const fig=FIGURES.find(f=>f.id===ST.figure)||{e:'🎮',n:'Figur'};
const bg=BACKGROUNDS.find(b=>b.id===ST.background)||{scene:'🌍',n:'Welt'};
const area=document.getElementById('reviewArea');area.innerHTML='';
// Hero card
const hero=document.createElement('div');hero.className='hero-card';
hero.innerHTML=`<div class="hero-scene">${esc(bg.scene)}<span class="hero-fig">${esc(fig.e)}</span></div><div class="hero-info"><div class="hi-name">${esc(ST.name||'Mein Spiel')}</div><div class="hi-desc">${esc(ST.desc||'Keine Beschreibung.')}</div><div class="hero-tags"><div class="hero-tag">${esc(fig.e)} ${esc(fig.n)}</div><div class="hero-tag">🌍 ${esc(bg.n)}</div><div class="hero-tag">⬛ ${esc(String(ST.fieldCount))} Felder</div></div></div>`;
area.appendChild(hero);
// Rules
const rules=document.createElement('div');rules.className='card';
const filled=ST.fields.slice(1,ST.fieldCount-1).filter(Boolean).length;
rules.innerHTML=`<div class="card-title" style="margin-bottom:12px">⚙️ Spielregeln</div><div class="info-grid"><div class="info-block"><div class="ib-lbl">🎲 Bewegung</div><div class="ib-val">${ST.rules.movement==='dice'?'🎲 Würfeln':'👣 Ein Feld'}</div></div><div class="info-block"><div class="ib-lbl">${ST.rules.fail==='lives'?'❤️ Leben':'⭐ Punkte'}</div><div class="ib-val">${ST.rules.fail==='lives'?ST.rules.lives+' Leben':'+'+ST.rules.pts+' Pts'}</div></div><div class="info-block"><div class="ib-lbl">🕹️ Mini-Games</div><div class="ib-val">${filled} Felder</div></div><div class="info-block"><div class="ib-lbl">📖 Erzähltexte</div><div class="ib-val">${ST.storyItems.length}</div></div></div>`;
area.appendChild(rules);
// Timeline
const tlCard=document.createElement('div');tlCard.className='card';
tlCard.innerHTML='<div class="card-title" style="margin-bottom:12px">🗺️ Spielablauf</div>';
const tl=document.createElement('div');tl.className='tl';
for(let i=0;i<ST.fieldCount;i++){
const g=ST.fields[i];const mg=g?MINIGAMES.find(m=>m.id===g):null;
const isSt=i===0,isEn=i===ST.fieldCount-1;
ST.storyItems.filter(s=>s.position==='before'&&s.fieldIndex===i).forEach(s=>{
const it=document.createElement('div');it.className='tl-item story-it';
it.innerHTML=`<div class="tl-dot st">${esc(s.emoji||'📖')}</div><div><div class="tl-lbl st">Erzähl-Text</div><div class="tl-sub">${esc(s.text||'(noch leer)')}</div><div class="tl-badge st">Vor Feld ${i}</div></div>`;
tl.appendChild(it);
});
const it=document.createElement('div');it.className='tl-item';
let dCls,lCls,lbl,sub,badge='';
if(isSt){dCls='s';lCls='s';lbl='🟢 Start';sub='Das Abenteuer beginnt!';}
else if(isEn){dCls='e';lCls='e';lbl='🏁 Ziel';sub='Hier gewinnt man!';}
else if(mg){dCls='g';lCls='g';lbl='Feld '+i;sub=mg.n;badge=`<div class="tl-badge">${esc(mg.e)} ${esc(mg.n)}</div>`;}
else{dCls='em';lCls='em';lbl='Feld '+i;sub='Leeres Durchgangsfeld';}
it.innerHTML=`<div class="tl-dot ${esc(dCls)}">${isSt?'🟢':isEn?'🏁':mg?esc(mg.e):'⬜'}</div><div><div class="tl-lbl ${esc(lCls)}">${esc(lbl)}</div><div class="tl-sub">${esc(sub)}</div>${badge}</div>`;
tl.appendChild(it);
ST.storyItems.filter(s=>s.position==='after'&&s.fieldIndex===i).forEach(s=>{
const it2=document.createElement('div');it2.className='tl-item story-it';
it2.innerHTML=`<div class="tl-dot st">${esc(s.emoji||'📖')}</div><div><div class="tl-lbl st">Erzähl-Text</div><div class="tl-sub">${esc(s.text||'(noch leer)')}</div><div class="tl-badge st">Nach Feld ${i}</div></div>`;
tl.appendChild(it2);
});
}
tlCard.appendChild(tl);area.appendChild(tlCard);
// Quiz preview
if(ST.quizData.length>0){
const qc=document.createElement('div');qc.className='card';
qc.innerHTML='<div class="card-title" style="margin-bottom:10px">❓ Quiz-Fragen</div>';
const ql=document.createElement('div');ql.className='qp-list';
ST.quizData.forEach((q,i)=>{
const qi=document.createElement('div');qi.className='qp-item';
qi.innerHTML=`<div class="qp-q">Frage ${i+1}: ${esc(q.question||'(leer)')}</div><div class="qp-ans-grid">${LETTERS.map((L,j)=>`<div class="qp-ans ${q.correct===j?'cor':''}">${esc(L)} ${esc(q.answers[j]||'?')}${q.correct===j?' ✓':''}</div>`).join('')}</div>`;
ql.appendChild(qi);
});
qc.appendChild(ql);area.appendChild(qc);
}
// Checklist
const cc=document.createElement('div');cc.className='card';cc.innerHTML='<div class="card-title" style="margin-bottom:10px">🔍 Checkliste</div>';
const cl=document.createElement('div');cl.className='checklist';
const qDone=ST.quizData.filter(isQComplete).length;
[
{ok:!!ST.name,t:ST.name?`Spielname: "${esc(ST.name)}"`:'Kein Spielname vergeben'},
{ok:!!(ST.figure&&ST.background),t:ST.figure?`Figur & Setting: ${esc(fig.n)} / ${esc(bg.n)}`:'Figur oder Setting fehlt'},
{ok:filled>0,t:`${filled} von ${ST.fieldCount-2} Spielfeldern belegt`},
{ok:ST.quizData.length===0||qDone===ST.quizData.length,t:ST.quizData.length===0?'Keine Quiz-Felder (ok)':`${qDone}/${ST.quizData.length} Quiz-Fragen ausgefüllt`},
].forEach(ch=>{const it=document.createElement('div');it.className='chk-item '+(ch.ok?'ok':'warn');it.innerHTML=`<span>${ch.ok?'✅':'⚠️'}</span>${ch.t}`;cl.appendChild(it);});
cc.appendChild(cl);area.appendChild(cc);
// Fill launch card
document.getElementById('lhFig').textContent=fig.e;
document.getElementById('lhName').textContent=ST.name||'Mein Spiel';
document.getElementById('lhDesc').textContent=ST.desc||'';
document.getElementById('lhPills').innerHTML=[
`<div class="lh-pill">${fig.e} ${fig.n}</div>`,
`<div class="lh-pill">🌍 ${bg.n}</div>`,
`<div class="lh-pill">⬛ ${ST.fieldCount} Felder</div>`,
`<div class="lh-pill">${ST.rules.fail==='lives'?'❤️ '+ST.rules.lives+' Leben':'⭐ Punkte'}</div>`,
].join('');
const uniqueG=[...new Set(ST.fields.filter(Boolean))];
document.getElementById('launchChk').innerHTML=[
{i:'🎲',t:`${ST.fieldCount} Felder · ${filled} Mini-Games`},
{i:'🕹️',t:`Games: ${uniqueG.map(g=>{const m=MINIGAMES.find(x=>x.id===g);return m?m.e:'❓';}).join(' ')||'—'}`},
{i:'📖',t:`${ST.storyItems.length} Erzähltexte`},
{i:'🎵',t:`Musik-Theme: ${bg.n}`},
].map(x=>`<div class="lc-item"><span>${x.i}</span><span style="flex:1">${x.t}</span><span class="lc-check">✓</span></div>`).join('');
}
let gamePlayed=false;
function doLaunch(){
save();
const w=window.open('game.html','Spiel',`width=${Math.min(1100,screen.width-60)},height=${Math.min(800,screen.height-60)},left=40,top=30,resizable=yes,scrollbars=no`);
if(!w){showToast('⚠️ Popup blockiert! Bitte Popups erlauben.');return;}
document.getElementById('btnLaunch').textContent='⏳ Warte auf Spielende...';
document.getElementById('btnLaunch').disabled=true;
const poll=setInterval(()=>{if(w.closed){clearInterval(poll);onGameDone();}},800);
}
function onGameDone(){
gamePlayed=true;
document.getElementById('btnLaunch').textContent='🔄 Nochmal spielen';
document.getElementById('btnLaunch').disabled=false;
document.getElementById('playedBadge').classList.add('visible');
const fc=document.getElementById('fbCard');fc.style.display='block';
fc.style.animation='slideIn 0.4s ease both';
setTimeout(()=>fc.scrollIntoView({behavior:'smooth',block:'start'}),500);
fbCur=0;fbAnswers={};renderFB();
}
/*
FEEDBACK
*/
let fbCur=0,fbAnswers={};
function renderFB(){
const q=FB_QS[fbCur];
document.getElementById('fbFill').style.width=(fbCur/FB_QS.length*100)+'%';
document.getElementById('fbPrev').style.display=fbCur>0?'':'none';
document.getElementById('fbNext').textContent=fbCur<FB_QS.length-1?'Weiter →':'Auswertung ✅';
const area=document.getElementById('fbArea');
area.innerHTML=`<div class="fb-qnum">Frage ${fbCur+1} von ${FB_QS.length}</div><div class="fb-q">${q.q}</div>${q.sub?`<div class="fb-sub">${q.sub}</div>`:''}`;
if(q.t==='mc'){
const opts=document.createElement('div');opts.className='mc-opts';
q.opts.forEach((o,i)=>{const btn=document.createElement('div');btn.className='mc-opt'+(fbAnswers[q.id]===i?' sel':'');btn.innerHTML=`<div class="mc-dot"></div>${o}`;btn.addEventListener('click',()=>{fbAnswers[q.id]=i;opts.querySelectorAll('.mc-opt').forEach(b=>b.classList.remove('sel'));btn.classList.add('sel');document.getElementById('fbNext').disabled=false;});opts.appendChild(btn);});
area.appendChild(opts);
}
if(q.t==='scale'){
const row=document.createElement('div');row.className='scale-row';
for(let i=1;i<=5;i++){const btn=document.createElement('button');btn.className='scale-btn'+(fbAnswers[q.id]===i?' sel':'');btn.textContent=i;btn.addEventListener('click',()=>{fbAnswers[q.id]=i;row.querySelectorAll('.scale-btn').forEach(b=>b.classList.remove('sel'));btn.classList.add('sel');document.getElementById('fbNext').disabled=false;});row.appendChild(btn);}
area.appendChild(row);
const lbl=document.createElement('div');lbl.className='scale-lbls';lbl.innerHTML=`<span>${q.min}</span><span>${q.max}</span>`;area.appendChild(lbl);
}
if(q.t==='text'){
const ta=document.createElement('textarea');ta.className='fb-ta';ta.rows=3;ta.placeholder=q.ph||'';ta.value=fbAnswers[q.id]||'';ta.addEventListener('input',()=>{fbAnswers[q.id]=ta.value;document.getElementById('fbNext').disabled=ta.value.trim().length<3;});area.appendChild(ta);
}
document.getElementById('fbNext').disabled=fbAnswers[q.id]===undefined;
}
function fbFwd(){
fbCur++;
if(fbCur>=FB_QS.length){showSummaryCard();return;}
renderFB();
}
function fbBack(){if(fbCur>0){fbCur--;renderFB();}}

362
js/share.js Normal file
View file

@ -0,0 +1,362 @@
/* share.js — Veröffentlichen: Link/QR/PDF, Teilen */
/*
SUMMARY CARD
*/
function showSummaryCard(){
document.getElementById('fbCard').style.display='none';
const sc=document.getElementById('sumCard');sc.style.display='block';
sc.style.animation='slideIn 0.4s ease both';
setTimeout(()=>sc.scrollIntoView({behavior:'smooth',block:'start'}),400);
const fun=fbAnswers.fun||3;const mg=fbAnswers.minigames||3;
const diff=fbAnswers.difficulty;const replay=fbAnswers.replay;
document.getElementById('sumText').textContent='Du hast dein Spiel getestet und Feedback gegeben. Hier ist deine Auswertung:';
document.getElementById('sumScores').innerHTML=`<div class="sum-sc"><div class="sum-val">${fun}/5</div><div class="sum-lbl">Spaßfaktor</div></div><div class="sum-sc"><div class="sum-val">${mg}/5</div><div class="sum-lbl">Mini-Game Passung</div></div><div class="sum-sc"><div class="sum-val">${ST.fieldCount}</div><div class="sum-lbl">Felder</div></div><div class="sum-sc"><div class="sum-val">${ST.storyItems.length}</div><div class="sum-lbl">Erzähltexte</div></div>`;
const notes=[];
if(fun<=2)notes.push({i:'🎯',t:'Der Spaßfaktor ist noch niedrig — überlege, spannendere Mini-Games oder Erzähltexte hinzuzufügen.'});
else if(fun>=4)notes.push({i:'🔥',t:'Super Spaßfaktor! Dein Spiel macht offensichtlich Freude.'});
if(diff===0)notes.push({i:'⚠️',t:'Das Spiel war zu einfach — probiere schwierigere Mini-Games oder weniger Leben.'});
else if(diff===3)notes.push({i:'⚠️',t:'Das Spiel war zu schwer — erhöhe die Lebensanzahl oder wähle einfachere Mini-Games.'});
if(ST.storyItems.length===0)notes.push({i:'📖',t:'Keine Erzähltexte vorhanden — sie machen das Spiel viel lebendiger!'});
if(ST.fields.slice(1,ST.fieldCount-1).filter(Boolean).length<3)notes.push({i:'🕹️',t:'Nur wenige Felder sind belegt — füge mehr Mini-Games hinzu für mehr Abwechslung.'});
if(replay>=2)notes.push({i:'🚀',t:'Das Spiel hat Wiederspielwert — großartig!'});
if(fbAnswers.problems&&fbAnswers.problems.length>5)notes.push({i:'🔧',t:`Problem: "${esc(fbAnswers.problems)}" — geh zurück und überarbeite das.`});
if(notes.length===0)notes.push({i:'✅',t:'Alles sieht gut aus! Dein Spiel ist bereit zum Teilen.'});
document.getElementById('sumNotes').innerHTML=notes.map(n=>`<div class="sn-item"><span>${n.i}</span><span>${n.t}</span></div>`).join('');
save();localStorage.setItem('testFeedback',JSON.stringify(fbAnswers));
}
// ── URL-Sharing System ─────────────────────────────────────────────────────
// Komprimiert die Spielkonfiguration und kodiert sie als URL-Hash
// JSON → pako.gzip (DEFLATE) → Base64 → URL-Hash
function buildShareURL() {
// Nur die nötigen Felder exportieren (kein highestStep etc.)
const exportCfg = {
devName: ST.devName,
name: ST.name,
desc: ST.desc,
figure: ST.figure,
figure2: ST.figure2,
playerCount: ST.playerCount,
background: ST.background,
fieldCount: ST.fieldCount,
fields: ST.fields,
storyItems: ST.storyItems,
quizData: ST.quizData,
rules: ST.rules,
consequences:ST.consequences,
mgSettings: ST.mgSettings,
worldSeed: ST.worldSeed,
worldLocked: ST.worldLocked,
};
const json = JSON.stringify(exportCfg);
// pako ist über CDN geladen falls nicht verfügbar: direktes Base64
let encoded;
try {
const compressed = pako.deflate(json, { level: 9 });
// Uint8Array → binary string → btoa
const binary = Array.from(compressed).map(b => String.fromCharCode(b)).join('');
encoded = 'z:' + btoa(binary);
} catch(e) {
// Fallback ohne Kompression
encoded = 'j:' + btoa(unescape(encodeURIComponent(json)));
}
const base = window.location.href.replace(/\/editor\.html.*$/, '') + '/game.html';
return base + '#' + encoded;
}
// ── Kurz-Code (6 Zeichen) zum manuellen Eintippen ──
const CODE_ALPHA = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ'; // ohne 0,O,1,I,L (Verwechslungsschutz)
function genShareCode(){
let s = '';
for(let i=0;i<6;i++) s += CODE_ALPHA[Math.floor(Math.random()*CODE_ALPHA.length)];
return s;
}
// URL der /play/-Landing-Page (für den zweiten QR)
function playPageURL(){
return window.location.href.replace(/\/editor\.html.*$/, '') + '/play/';
}
// Spielkonfiguration zum Server hochladen → Kurz-Code reservieren
async function uploadCfgForCode(code, cfg){
const res = await fetch('/api/boardgame-play/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, cfg }),
});
if(!res.ok) throw new Error('Server-Fehler ' + res.status);
return res.json();
}
function doPublish() {
const url = buildShareURL();
// Spiel-Info füllen
const bg = BACKGROUNDS.find(b => b.id === ST.background);
const fig = FIGURES.find(f => f.id === ST.figure);
const infoEl = document.getElementById('shareGameInfo');
infoEl.innerHTML = `
<div style="display:flex;gap:12px;align-items:center">
<div style="font-size:2rem">${fig ? fig.e : '🎮'}${bg ? bg.e : ''}</div>
<div>
<div style="font-family:'Fredoka One',cursive;font-size:1.1rem;color:#e2e0f0">${esc(ST.name || 'Mein Spiel')}</div>
<div style="font-size:12px;color:#a7a3c2">von ${esc(ST.devName || 'Anonym')} · ${ST.fieldCount} Felder · ${ST.fields.filter(Boolean).length} Mini-Games</div>
</div>
</div>
`;
// Link setzen
const input = document.getElementById('shareLinkInput');
input.value = url;
const lenEl = document.getElementById('shareLinkLen');
lenEl.textContent = `Link-Länge: ${url.length} Zeichen${url.length > 4000 ? ' ⚠️ zu lang für QR-Code!' : ' ✅ QR-Code-tauglich'}`;
// Haupt-QR-Code (zum direkten Spiel-Link)
const qrEl = document.getElementById('shareQR');
qrEl.innerHTML = '';
try {
new QRCode(qrEl, {
text: url, width: 180, height: 180,
colorDark: '#000000', colorLight: '#ffffff', correctLevel: QRCode.CorrectLevel.M,
});
} catch(e) {
qrEl.innerHTML = '<div style="color:#ef4444;font-size:12px;padding:20px">QR-Code konnte nicht erstellt werden.<br>Link zum Kopieren nutzen.</div>';
}
// Overlay zeigen
const ov = document.getElementById('shareOverlay');
ov.style.display = 'flex';
document.body.style.overflow = 'hidden';
// ── Kurz-Code-Bereich ──
const codeBox = document.getElementById('shareCodeBox');
const codeEl = document.getElementById('shareCodeText');
const playUrlEl= document.getElementById('sharePlayUrl');
const playQrEl = document.getElementById('sharePlayQR');
const codeStatus = document.getElementById('shareCodeStatus');
if(codeBox){
codeBox.style.display = '';
if(codeEl) codeEl.textContent = '······';
if(codeStatus) codeStatus.textContent = 'wird erzeugt …';
if(playUrlEl) playUrlEl.textContent = playPageURL().replace(/^https?:\/\//,'');
if(playQrEl){
playQrEl.innerHTML = '';
try{
new QRCode(playQrEl, { text: playPageURL(), width:120, height:120,
colorDark:'#000000', colorLight:'#ffffff', correctLevel: QRCode.CorrectLevel.M });
}catch(e){}
}
}
// Code reservieren (re-publish nutzt denselben Code)
const exportCfg = {
devName: ST.devName, name: ST.name, desc: ST.desc, figure: ST.figure, figure2: ST.figure2,
playerCount: ST.playerCount, background: ST.background, fieldCount: ST.fieldCount,
fields: ST.fields, storyItems: ST.storyItems, quizData: ST.quizData, rules: ST.rules,
consequences: ST.consequences,
mgSettings: ST.mgSettings, worldSeed: ST.worldSeed, worldLocked: ST.worldLocked,
};
const useCode = ST.shareCode || genShareCode();
uploadCfgForCode(useCode, exportCfg)
.then(() => {
ST.shareCode = useCode;
save();
if(codeEl) codeEl.textContent = useCode;
if(codeStatus) codeStatus.textContent = '✓ bereit';
})
.catch(err => {
console.warn('Kurz-Code Upload fehlgeschlagen:', err);
if(codeEl) codeEl.textContent = '— —';
if(codeStatus) codeStatus.innerHTML = '<span style="color:#ef4444">offline?</span>';
});
}
function closeShare() {
document.getElementById('shareOverlay').style.display = 'none';
document.body.style.overflow = '';
}
async function printAsPDF() {
// Heutiges Datum DD.MM.YYYY (dynamisch — kein Hardcode)
const _d = new Date();
const todayStr = String(_d.getDate()).padStart(2,'0') + '.' +
String(_d.getMonth()+1).padStart(2,'0') + '.' + _d.getFullYear();
const qrEl = document.getElementById('shareQR');
const canvas = qrEl.querySelector('canvas');
if (!canvas) { appAlert('QR-Code nicht gefunden.'); return; }
const qrDataUrl = canvas.toDataURL('image/png');
const { jsPDF } = window.jspdf;
const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
const pageW = 210, pageH = 297, margin = 14;
await new Promise(resolve => {
const img = new Image();
img.onload = () => {
const c = document.createElement('canvas');
c.width = img.naturalWidth; c.height = img.naturalHeight;
c.getContext('2d').drawImage(img, 0, 0);
// Logo klein, zentriert
const logoW = 72, logoH = logoW * (img.naturalHeight / img.naturalWidth);
doc.addImage(c.toDataURL('image/png'), 'PNG', (pageW - logoW) / 2, margin, logoW, logoH);
let y = margin + logoH + 3;
// Trennlinie grau
doc.setDrawColor(160, 160, 160); doc.setLineWidth(0.3);
doc.line(margin, y, pageW - margin, y); y += 4;
// Fach-Bezeichnung
doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(90, 90, 90);
doc.text('Fach Informatik & Medien- und Bildungsmanagement', pageW / 2, y, { align: 'center' }); y += 5;
// Datum
doc.setFont('helvetica', 'bold'); doc.setFontSize(10); doc.setTextColor(40, 40, 40);
doc.text(todayStr, pageW / 2, y, { align: 'center' }); y += 6;
// Spielname
doc.setFont('helvetica', 'bold'); doc.setFontSize(22); doc.setTextColor(10, 10, 10);
doc.text(ST.name || 'Mein Spiel', pageW / 2, y, { align: 'center' }); y += 5;
// Autor + Info
doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(110, 110, 110);
const miniCount = ST.fields.filter(Boolean).length;
doc.text('von ' + (ST.devName || 'Anonym') + ' · ' + ST.fieldCount + ' Felder · ' + miniCount + ' Mini-Games', pageW / 2, y, { align: 'center' }); y += 9;
// ── Zwei Spalten: Haupt-QR (Direktstart) links, Kurz-Code-Block rechts ──
const colGap = 8;
const qrLarge = 70;
const colW = (pageW - 2*margin - colGap) / 2;
const leftX = margin;
const rightX = margin + colW + colGap;
// ── LINKE SPALTE: QR direkt ──
// Überschrift links
doc.setFont('helvetica', 'bold'); doc.setFontSize(9); doc.setTextColor(40,40,40);
doc.text('📱 Mit Handy scannen', leftX + colW/2, y, { align: 'center' });
const qrX = leftX + (colW - qrLarge) / 2;
const qrY = y + 4;
doc.setDrawColor(180,180,180); doc.setLineWidth(0.25);
doc.rect(qrX - 1, qrY - 1, qrLarge + 2, qrLarge + 2);
doc.addImage(qrDataUrl, 'PNG', qrX, qrY, qrLarge, qrLarge);
doc.setFont('helvetica','italic'); doc.setFontSize(7.5); doc.setTextColor(120,120,120);
doc.text('Kamera → öffnet Spiel direkt', leftX + colW/2, qrY + qrLarge + 4, { align: 'center' });
// ── RECHTE SPALTE: Kurz-Code + Play-Seite-QR ──
const shareCode = (ST.shareCode || '······').toUpperCase();
const playUrl = playPageURL().replace(/^https?:\/\//, '');
doc.setFont('helvetica', 'bold'); doc.setFontSize(9); doc.setTextColor(40,40,40);
doc.text('⌨️ Am PC eintippen', rightX + colW/2, y, { align: 'center' });
// Kleiner QR für /play/ Seite
const playQrEl = document.getElementById('sharePlayQR');
const playQrCanvas = playQrEl ? playQrEl.querySelector('canvas') : null;
const smallQR = 28;
if (playQrCanvas) {
const playQrDataUrl = playQrCanvas.toDataURL('image/png');
const playQrX = rightX + 2;
const playQrY = y + 4;
doc.setDrawColor(200,200,200); doc.setLineWidth(0.2);
doc.rect(playQrX - 0.5, playQrY - 0.5, smallQR + 1, smallQR + 1);
doc.addImage(playQrDataUrl, 'PNG', playQrX, playQrY, smallQR, smallQR);
doc.setFont('helvetica','normal'); doc.setFontSize(6); doc.setTextColor(120,120,120);
doc.text('zur Seite:', playQrX + smallQR/2, playQrY + smallQR + 3, { align: 'center' });
}
// URL-Text + Code
const txtX = rightX + smallQR + 8;
let codeY = y + 9;
doc.setFont('helvetica','normal'); doc.setFontSize(7); doc.setTextColor(80,80,80);
doc.text('1. Im Browser öffnen:', txtX, codeY); codeY += 3.5;
doc.setFont('helvetica','bold'); doc.setFontSize(8); doc.setTextColor(30,30,30);
doc.text(playUrl, txtX, codeY); codeY += 7;
doc.setFont('helvetica','normal'); doc.setFontSize(7); doc.setTextColor(80,80,80);
doc.text('2. Diesen Code eingeben:', txtX, codeY); codeY += 6;
doc.setFont('courier', 'bold'); doc.setFontSize(22); doc.setTextColor(20,20,20);
doc.text(shareCode, txtX, codeY);
y = qrY + qrLarge + 12;
// ---- Schnittlinie ----
const stripW = 43, stripH = 23;
const stripX = (pageW - stripW) / 2;
const stripY = pageH - margin - stripH;
const cutY = stripY - 6;
// Schnittlinie
doc.setDrawColor(150, 150, 150); doc.setLineWidth(0.25);
doc.setLineDashPattern([1.5, 1.5], 0);
doc.line(margin, cutY, pageW - margin, cutY);
doc.setLineDashPattern([], 0);
doc.setFont('helvetica', 'normal'); doc.setFontSize(5.5); doc.setTextColor(170, 170, 170);
doc.text('✂ Hier abschneiden', margin + 1, cutY - 1.2);
// ---- Ausschneidstreifen 41 × 23 mm (QR 23×23 | Text ~18×23) ----
// Rahmen
doc.setDrawColor(100, 100, 100); doc.setLineWidth(0.3);
doc.rect(stripX, stripY, stripW, stripH);
// Trennlinie bei 23mm (QR-Breite)
doc.setLineWidth(0.15); doc.setDrawColor(180,180,180);
doc.line(stripX + 23, stripY, stripX + 23, stripY + stripH);
// QR links 23×23
doc.addImage(qrDataUrl, 'PNG', stripX, stripY, 23, 23);
// Text rechts — linksbündig mit 1.5mm Einzug, vertikal zentriert
const tx = stripX + 23 + 1.5;
doc.setFont('helvetica', 'bold'); doc.setFontSize(5.5); doc.setTextColor(20, 20, 20);
const gameName = (ST.name || 'Mein Spiel').substring(0, 18);
doc.text(gameName, tx, stripY + 5);
doc.setFont('helvetica', 'normal'); doc.setFontSize(4); doc.setTextColor(110,110,110);
doc.text(todayStr + ' · PH Weingarten', tx, stripY + 9);
doc.setFont('helvetica', 'normal'); doc.setFontSize(4); doc.setTextColor(120,120,120);
doc.text('Code:', tx, stripY + 14);
doc.setFont('courier', 'bold'); doc.setFontSize(7); doc.setTextColor(20,20,20);
doc.text((ST.shareCode || '······').toUpperCase(), tx, stripY + 18.5);
resolve();
};
img.onerror = () => { resolve(); };
img.src = 'phw_logo.png';
});
const filename = (ST.name || 'spiel').replace(/[^a-zA-Z0-9_\-]/g, '_') + '_qr.pdf';
doc.save(filename);
}
function copyShareLink() {
const input = document.getElementById('shareLinkInput');
input.select();
navigator.clipboard.writeText(input.value).then(() => {
const btn = document.getElementById('shareCopyBtn');
btn.textContent = '✅ Kopiert!';
setTimeout(() => btn.textContent = '📋 Kopieren', 2000);
}).catch(() => {
document.execCommand('copy');
showToast('📋 Link kopiert!');
});
}
function shareViaEmail() {
const url = document.getElementById('shareLinkInput').value;
const subject = encodeURIComponent('🎲 Mein Brettspiel: ' + (ST.name || 'Mein Spiel'));
const body = encodeURIComponent(
'Hallo!\n\nIch habe ein Brettspiel erstellt und möchte es mit dir teilen:\n\n' +
(ST.name || 'Mein Spiel') + '\n' + url + '\n\nViel Spaß beim Spielen!\n' + (ST.devName || '')
);
window.open('mailto:?subject=' + subject + '&body=' + body);
}
function shareViaWhatsApp() {
const url = document.getElementById('shareLinkInput').value;
const text = encodeURIComponent('🎲 Mein Brettspiel "' + (ST.name || 'Mein Spiel') + '" einfach anklicken und spielen! ' + url);
window.open('https://wa.me/?text=' + text, '_blank', 'noopener,noreferrer');
}
function shareNative() {
const url = document.getElementById('shareLinkInput').value;
if (navigator.share) {
navigator.share({
title: ST.name || 'Mein Brettspiel',
text: 'Ich habe ein Brettspiel erstellt!',
url: url,
}).catch(() => {});
} else {
copyShareLink();
showToast('📋 Link in die Zwischenablage kopiert!');
}
}

98
js/state.js Normal file
View file

@ -0,0 +1,98 @@
/* state.js — Zustand ST + localStorage + Reset */
/*
STATE
*/
const ST={
devName:'',name:'',desc:'',figure:'',figure2:'',background:'',
playerCount:1,
fieldCount:10,fields:Array(10).fill(null),
storyItems:[],quizData:[],
rules:{movement:'dice',fail:'lives',lives:'3',pts:'10'},
consequences:{win:'nothing',winVal:2,lose:'nothing',loseVal:1,winPts:10,losePts:5},
mgSettings:{},
worldSeed:0,
worldLocked:false,
shareCode:'',
highestStep:0,
};
try{
const sv=JSON.parse(localStorage.getItem('gameConfig')||'{}');
if(sv.devName)ST.devName=sv.devName;
if(sv.name)ST.name=sv.name;if(sv.desc)ST.desc=sv.desc;
if(sv.figure)ST.figure=sv.figure;if(sv.figure2)ST.figure2=sv.figure2;if(sv.background)ST.background=sv.background;
if(sv.playerCount===1||sv.playerCount===2)ST.playerCount=sv.playerCount;
if(sv.fieldCount)ST.fieldCount=sv.fieldCount;
if(sv.fields){ST.fields=sv.fields;while(ST.fields.length<ST.fieldCount)ST.fields.push(null);ST.fields=ST.fields.slice(0,ST.fieldCount);}
if(sv.storyItems)ST.storyItems=sv.storyItems;
if(sv.quizData)ST.quizData=sv.quizData;
if(sv.rules)Object.assign(ST.rules,sv.rules);
if(sv.consequences)Object.assign(ST.consequences,sv.consequences);
if(sv.mgSettings)ST.mgSettings=sv.mgSettings;
if(sv.worldSeed)ST.worldSeed=sv.worldSeed>>>0;
if(sv.worldLocked)ST.worldLocked=!!sv.worldLocked;
if(sv.shareCode)ST.shareCode=String(sv.shareCode);
if(sv.highestStep)ST.highestStep=sv.highestStep;
}catch(e){}
let cur=0;
function save(){
try {
localStorage.setItem('gameConfig',JSON.stringify({
devName:ST.devName,name:ST.name,desc:ST.desc,figure:ST.figure,figure2:ST.figure2,playerCount:ST.playerCount,background:ST.background,
fieldCount:ST.fieldCount,fields:ST.fields,storyItems:ST.storyItems,
quizData:ST.quizData,rules:ST.rules,consequences:ST.consequences,mgSettings:ST.mgSettings,worldSeed:ST.worldSeed,worldLocked:ST.worldLocked,shareCode:ST.shareCode,highestStep:ST.highestStep,
}));
} catch(e) {
if(e.name==='QuotaExceededError'){
appAlert('Speicher voll. Bitte altes Spiel löschen.');
}
}
showSaveIndicator();
// Note: code update is triggered by input/change listeners, not here
}
function showSaveIndicator(){
const el=document.getElementById('saveIndicator');
if(!el)return;
el.style.opacity='1';
clearTimeout(el._hideTimer);
el._hideTimer=setTimeout(()=>{el.style.opacity='0';},2000);
}
/* ── App-Dialog (ersetzt natives confirm/alert) ── */
let _appOk=null;
function appConfirm(message, onOk, opts){
opts=opts||{};
const dlg=document.getElementById('appDialog'); if(!dlg){ if(confirm(message)&&onOk)onOk(); return; }
document.getElementById('appDialogIcon').textContent = opts.icon || (opts.danger?'⚠️':'❓');
document.getElementById('appDialogMsg').textContent = message;
const yes=document.getElementById('appDialogYes'); const no=document.getElementById('appDialogNo');
yes.textContent = opts.okText || 'Ja';
no.textContent = opts.cancelText || 'Abbrechen';
yes.classList.toggle('danger', !!opts.danger);
no.style.display = opts.noCancel ? 'none' : '';
_appOk = onOk || null;
dlg.classList.add('open');
}
function appAlert(message, onOk, opts){
appConfirm(message, onOk, Object.assign({noCancel:true, okText:'OK', icon:''}, opts||{}));
}
function appDialogOk(){
document.getElementById('appDialog').classList.remove('open');
const fn=_appOk; _appOk=null;
if(fn){ try{ fn(); }catch(e){ console.error(e); } }
}
function appDialogCancel(){
document.getElementById('appDialog').classList.remove('open');
_appOk=null;
}
function resetAll(){
appConfirm('Wirklich alles zurücksetzen?\n\nAlle Eingaben gehen verloren!', ()=>{
localStorage.removeItem('gameConfig');
localStorage.removeItem('testFeedback');
location.reload();
}, {danger:true, okText:'Zurücksetzen', icon:'🗑️'});
}

278
js/tour.js Normal file
View file

@ -0,0 +1,278 @@
/**
* js/tour.js Tour durch Schritt 3 (Spielfeld bauen)
* Triggert beim ersten Aufruf von goTo(2). Figur (cfg.figure) führt durch alle Bereiche.
* Während der Tour ist die Seite VOLLSTÄNDIG GESPERRT nur die Buttons in der
* Sprechblase und ein Klick auf den Text (für Skip-Tippvorgang) sind aktiv.
*/
(function () {
// ── Storyboard ──
// selector: CSS-Selector des Anker-Elements (null = kein Anker, Figur in Mitte)
// text: Sprechblasen-Text (\n für Absatz)
// side: 'left'|'right'|'top'|'bottom'|'auto' — wo die Figur am Anker landet
const TOUR_STEPS = [
{
id: 'welcome', selector: null,
text: "Hi! Ich bin {fig} und führe dich kurz durch dein Spielfeld. Los geht's!\n\nDer Board Game Emulator befindet sich übrigens noch in der Entwicklung. Wenn dir auffällt, dass was nicht funktioniert, gib das kurz deinem Lehrer oder deiner Lehrerin weiter — vielleicht kann er/sie dir direkt helfen.",
},
{
id: 'bewegung', selector: '[data-tour="bewegung"]', side: 'bottom',
text: "Hier wählst du, wie sich deine Figur bewegt — würfeln für Spannung, oder genau ein Feld pro Runde für mehr Kontrolle.",
},
{
id: 'spielmodus', selector: '[data-tour="spielmodus"]', side: 'bottom',
text: "Willst du lieber mit Leben spielen, die du verlieren kannst? Oder Punkte sammeln beim Gewinnen?",
},
{
id: 'modus-counter', selector: '[data-tour="modus-counter"]:not([style*="display: none"])', side: 'bottom',
text: "Wie viele Leben jeder bekommt — oder mit welcher Punktzahl gestartet wird — stellst du hier ein.",
},
{
id: 'code-pane', selector: '#codePane', side: 'left',
text: "Hier rechts siehst du den Programmcode deines Spiels — und zwar live! Während du dein Spiel baust, kannst du direkt mitverfolgen, wie der Code im Hintergrund entsteht. Das ist übrigens Python — eine sehr häufig benutzte Programmiersprache.",
},
{
id: 'konsequenz-sieg', selector: '[data-tour="konsequenz-sieg"]', side: 'bottom',
text: "Wenn ein Mini-Game gewonnen wird, darf was Tolles passieren: ein paar Felder vor, Bonuspunkte oder nochmal würfeln.",
},
{
id: 'konsequenz-niederlage', selector: '[data-tour="konsequenz-niederlage"]', side: 'bottom',
text: "Und wenn jemand verliert? Vielleicht ein paar Felder zurück, ein Leben weniger oder eine Runde aussetzen.",
},
{
id: 'fieldcount', selector: '[data-tour="fieldcount"]', side: 'right',
text: "Wie groß soll dein Spielfeld sein? Zwischen 6 und 30 Feldern — je mehr, desto länger das Spiel.",
},
{
id: 'palette', selector: '[data-tour="palette"]', side: 'right',
text: "Das hier sind deine Bausteine: 12 Mini-Games und Erzähltexte. Die ziehst du gleich aufs Spielfeld.",
},
{
id: 'world-reroll', selector: '#wbReroll', side: 'right',
text: "Das ist dein Spielfeld! Mit '🎲 Welt neu generieren' würfelst du eine neue Welt aus — so lange, bis dir der Pfad gefällt.",
},
{
id: 'world-canvas', selector: '#worldCanvas', side: 'right',
text: "Zieh die Bausteine einfach auf die runden Felder hier. Klick auf ein belegtes Feld, wenn du es wieder leeren willst.",
},
{
id: 'world-lock', selector: '#wbLock', side: 'right',
text: "Wenn dein Spielbrett perfekt ist, klick auf '✓ Spielbrett verwenden' — dann bleibt es so.",
},
{
id: 'next', selector: '#s3next', side: 'left',
text: "Fertig? Dann geht's mit 'Weiter' zum Quiz. Viel Spaß beim Bauen!",
},
];
// ── State ──
let stepIdx = 0;
let typingTimer = null;
let typingFull = '';
let typingPos = 0;
let active = false;
let layoutHandler = null;
let suppressAnim = false; // Scroll/Resize → keine Position-Transition (sonst hinkt's hinterher)
// ── Public API ──
window.tourStart = function () {
if (active) return;
active = true;
stepIdx = 0;
const root = document.getElementById('tourRoot');
if (!root) return;
root.style.display = '';
// Figur-Emoji aus ST.figure
const figId = typeof ST !== 'undefined' && ST.figure;
const fig = (typeof FIGURES !== 'undefined' && FIGURES.find(f => f.id === figId)) || null;
const figEmoji = fig ? fig.e : '🎮';
document.getElementById('tourFigure').textContent = figEmoji;
// Layout neu-bauen bei Resize/Scroll (capture: fängt auch Scrolls auf inneren Containern wie #editorPane)
layoutHandler = () => { suppressAnim = true; layoutCurrent(); };
window.addEventListener('resize', layoutHandler);
document.addEventListener('scroll', layoutHandler, { passive: true, capture: true });
showStep(0);
};
window.tourNext = function () {
if (!active) return;
if (typingTimer) { skipTypewriter(); return; }
if (stepIdx >= TOUR_STEPS.length - 1) { tourFinish(); return; }
showStep(stepIdx + 1);
};
window.tourPrev = function () {
if (!active) return;
if (stepIdx > 0) showStep(stepIdx - 1);
};
window.tourSkipConfirm = function () {
if (!active) return;
appConfirm('Tour wirklich beenden?\n\nDu kannst sie später jederzeit nochmal starten.',
() => tourFinish(),
{ okText: 'Ja, beenden', cancelText: 'Weiter ansehen', icon: '🚪' }
);
};
function tourFinish() {
if (!active) return;
active = false;
if (typingTimer) { clearInterval(typingTimer); typingTimer = null; }
const root = document.getElementById('tourRoot');
if (root) root.style.display = 'none';
if (layoutHandler) {
window.removeEventListener('resize', layoutHandler);
document.removeEventListener('scroll', layoutHandler, { capture: true });
layoutHandler = null;
}
try { localStorage.setItem('tourSchritt3Done', '1'); } catch (e) {}
}
// ── Steps ──
function showStep(idx) {
stepIdx = idx;
const step = TOUR_STEPS[idx];
// Progress-Indikator
const prog = document.getElementById('tourBubbleProgress');
if (prog) prog.textContent = `${idx + 1} / ${TOUR_STEPS.length}`;
// Buttons aktualisieren
const btnNext = document.getElementById('tourBtnNext');
if (btnNext) btnNext.textContent = (idx >= TOUR_STEPS.length - 1) ? '✓ Fertig' : 'Weiter →';
const btnBack = document.getElementById('tourBtnBack');
if (btnBack) btnBack.style.display = idx > 0 ? '' : 'none';
// Bei echtem Step-Wechsel: Position animieren (suppressAnim = false)
suppressAnim = false;
layoutCurrent();
// Nach kurzem Delay zurück in den Scroll-Follow-Modus (no anim)
setTimeout(() => { suppressAnim = true; }, 80);
// Text mit Typewriter starten (Figur-Token ersetzen)
const figEmoji = document.getElementById('tourFigure').textContent;
const text = step.text.replace(/\{fig\}/g, figEmoji);
startTypewriter(text);
}
function layoutCurrent() {
if (!active) return;
const step = TOUR_STEPS[stepIdx];
const root = document.getElementById('tourRoot');
const highlight = document.getElementById('tourHighlight');
const character = document.getElementById('tourCharacter');
if (!step) return;
// Transition-Klassen für scroll-follow vs. step-change
if (suppressAnim) {
character.classList.add('tour-no-anim');
highlight.classList.add('tour-no-anim');
} else {
character.classList.remove('tour-no-anim');
highlight.classList.remove('tour-no-anim');
}
let targetEl = null;
if (step.selector) {
const cands = document.querySelectorAll(step.selector);
for (const c of cands) {
if (c.offsetWidth > 0 && c.offsetHeight > 0) { targetEl = c; break; }
}
}
if (!targetEl) {
// Kein Anker → Hintergrund dunkel (no-anchor-Modus), Figur mittig, kein Highlight
root.classList.add('no-anchor');
highlight.style.display = 'none';
character.style.left = (window.innerWidth/2 - 200) + 'px';
character.style.top = (window.innerHeight/2 - 120) + 'px';
character.dataset.side = 'center';
return;
}
root.classList.remove('no-anchor');
// Element ins Viewport scrollen, falls außerhalb
const r = targetEl.getBoundingClientRect();
const PAD = 10;
const inView = r.top >= 0 && r.bottom <= window.innerHeight;
if (!inView) targetEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
// Highlight positionieren
highlight.style.display = '';
highlight.style.top = (r.top - PAD) + 'px';
highlight.style.left = (r.left - PAD) + 'px';
highlight.style.width = (r.width + PAD * 2) + 'px';
highlight.style.height = (r.height + PAD * 2) + 'px';
// Figur+Bubble platzieren — Seite je nach Anker
const charW = 380, charH = 200;
let side = step.side || 'right';
let cx, cy;
if (side === 'right') {
cx = r.right + 24;
cy = r.top + r.height/2 - charH/2;
if (cx + charW > window.innerWidth - 12) side = 'left';
}
if (side === 'left') {
cx = r.left - charW - 24;
cy = r.top + r.height/2 - charH/2;
if (cx < 12) side = 'bottom';
}
if (side === 'top') {
cx = r.left + r.width/2 - charW/2;
cy = r.top - charH - 16;
if (cy < 12) side = 'bottom';
}
if (side === 'bottom') {
cx = r.left + r.width/2 - charW/2;
cy = r.bottom + 16;
}
// Clamp ins Viewport
cx = Math.max(12, Math.min(window.innerWidth - charW - 12, cx));
cy = Math.max(12, Math.min(window.innerHeight - charH - 12, cy));
character.style.left = cx + 'px';
character.style.top = cy + 'px';
character.dataset.side = side;
}
// ── Typewriter ──
function startTypewriter(text) {
if (typingTimer) clearInterval(typingTimer);
typingFull = text;
typingPos = 0;
const el = document.getElementById('tourBubbleText');
el.textContent = '';
typingTimer = setInterval(() => {
typingPos++;
el.textContent = typingFull.slice(0, typingPos);
if (typingPos >= typingFull.length) {
clearInterval(typingTimer);
typingTimer = null;
}
}, 38);
}
function skipTypewriter() {
if (!typingTimer) return;
clearInterval(typingTimer);
typingTimer = null;
document.getElementById('tourBubbleText').textContent = typingFull;
}
// Klick auf den Text-Bereich → Tippen überspringen
document.addEventListener('click', (e) => {
if (!active) return;
const text = document.getElementById('tourBubbleText');
if (text && text.contains(e.target) && typingTimer) {
e.stopPropagation();
skipTypewriter();
}
});
// ── Auto-Trigger beim ersten Aufruf von Schritt 3 ──
window.maybeStartTour = function () {
try {
if (localStorage.getItem('tourSchritt3Done') === '1') return;
} catch (e) {}
// Tour erst nach kurzem Delay starten, damit Layout/Render fertig sind
setTimeout(() => tourStart(), 350);
};
})();

340
js/wizard.js Normal file
View file

@ -0,0 +1,340 @@
/* wizard.js — Schritt-Navigation + Screens 1-3 (Grundinfos, Gestaltung, Regeln) */
/*
NAVIGATION
*/
function goTo(step){
if(step>cur&&!canLeave(cur))return;
save();
const from=document.getElementById('s'+cur);
from.classList.remove('active');
from.style.display='none';
setTimeout(()=>{from.style.display='';},50);
if(step>cur)ST.highestStep=Math.max(ST.highestStep,step);
cur=step;
const to=document.getElementById('s'+cur);
to.style.display='';
to.classList.add('active','slide-in');
to.addEventListener('animationend',()=>to.classList.remove('slide-in'),{once:true});
const ep=document.getElementById('editorPane');if(ep)ep.scrollTo({top:0,behavior:'smooth'});else window.scrollTo({top:0,behavior:'smooth'});
updatePB();
showSummary(step);
if(step===1)s2init();
if(step===2){ s3init(); if(typeof maybeStartTour==='function') maybeStartTour(); }
if(step===3)buildQuiz();
if(step===4)buildReview();
showToast('✅ Schritt '+(step+1)+' geöffnet!');
}
function pbClick(step){if(step<cur&&step<=ST.highestStep){goTo(step);}}
function canLeave(step){
if(step===0)return ST.name.trim().length>0;
if(step===1)return !!(ST.figure&&ST.background) && (ST.playerCount!==2 || (ST.figure2 && ST.figure2!==ST.figure));
if(step===2)return ST.fields.slice(1,ST.fieldCount-1).filter(Boolean).length>0;
return true;
}
function updatePB(){
for(let i=0;i<5;i++){
const d=document.getElementById('pb'+i);
d.className='pb-step'+(i===cur?' active':i<cur?' done':'');
const c=document.getElementById('pbc'+i);
if(c)c.className='pb-connector'+(i<cur?' done':'');
}
document.getElementById('pbFill').style.width=((cur+1)/5*100)+'%';
}
function showSummary(step){
const idx=step-1;
if(idx<0)return;
const banner=document.getElementById('sum'+idx);
if(!banner)return;
banner.classList.add('visible');
const chips=document.getElementById('sum'+idx+'chips');
if(!chips)return;
let html='';
if(idx===0){if(ST.devName)html+=`<div class="sb-chip">👋 ${esc(ST.devName)}</div>`;html+=`<div class="sb-chip">🎮 ${esc(ST.name)}</div>`;if(ST.desc)html+=`<div class="sb-chip">"${esc(ST.desc.slice(0,40))}${ST.desc.length>40?'…':''}"</div>`;}
if(idx===1){const f=FIGURES.find(x=>x.id===ST.figure);const b=BACKGROUNDS.find(x=>x.id===ST.background);if(f)html+=`<div class="sb-chip">${f.e} ${f.n}</div>`;if(b)html+=`<div class="sb-chip">🌍 ${b.n}</div>`;}
if(idx===2){const filled=ST.fields.slice(1,ST.fieldCount-1).filter(Boolean).length;html+=`<div class="sb-chip">⬛ ${ST.fieldCount} Felder</div><div class="sb-chip">🕹️ ${filled} Mini-Games</div><div class="sb-chip">📖 ${ST.storyItems.length} Erzähltexte</div>`;}
if(idx===3){const done=ST.quizData.filter(isQComplete).length;html+=`<div class="sb-chip">❓ ${done}/${ST.quizData.length||0} Quiz-Fragen</div>`;}
chips.innerHTML=html;
}
function showToast(msg){
const t=document.getElementById('toast');t.textContent=msg;t.classList.add('show');
setTimeout(()=>t.classList.remove('show'),2000);
}
/*
STEP 1 GRUNDINFOS
*/
function s1init(){
document.getElementById('s1devname').value=ST.devName;
document.getElementById('s1name').value=ST.name;
document.getElementById('s1desc').value=ST.desc;
s1update();
}
function s1update(){
ST.devName=document.getElementById('s1devname').value.trim();
ST.name=document.getElementById('s1name').value.trim();
ST.desc=document.getElementById('s1desc').value;
const nl=document.getElementById('s1name').value.length;
const dl=ST.desc.length;
document.getElementById('s1nc').textContent=nl+' / 40';
document.getElementById('s1dc').textContent=dl+' / 120';
document.getElementById('s1dc').className='char-row'+(dl>100?' warn':'');
const s1dis=!ST.name||!ST.devName;
document.getElementById('s1next').disabled=s1dis;
const s1hint=document.getElementById('s1hint');if(s1hint)s1hint.style.display=s1dis?'block':'none';
// Persönliche Begrüßung
const grEl=document.getElementById('devGreeting');
if(ST.devName){
const greetings=[
'Hey '+ST.devName+' 👋 Schön, dass du dabei bist!',
'Los geht\'s, '+ST.devName+'! Dein Spiel wartet 🎮',
'Hi '+ST.devName+'! Du wirst ein tolles Spiel bauen ✨',
'Willkommen, '+ST.devName+'! Game on! 🚀',
];
grEl.textContent=greetings[ST.devName.length%greetings.length];
grEl.style.opacity='1';
} else {grEl.style.opacity='0';}
// Vorschau
const show=ST.name.length>0&&ST.devName.length>0;
document.getElementById('s1preview').style.display=show?'':'none';
if(show){
document.getElementById('s1pvname').textContent=ST.name;
document.getElementById('s1pvdesc').textContent=ST.desc||'Keine Beschreibung.';
document.getElementById('s1pvdev').textContent='👨‍💻 Entwickelt von '+ST.devName;
}
}
function suggest(txt){document.getElementById('s1name').value=txt;s1update();}
/*
STEP 2 GESTALTUNG
*/
function buildFigureGrid(gridId, current, blocked, onPick){
const g=document.getElementById(gridId); if(!g) return;
g.innerHTML='';
FIGURES.forEach(f=>{
const isBlocked = blocked && blocked===f.id;
const c=document.createElement('div');
c.className='sel-card'+(current===f.id?' selected':'')+(isBlocked?' disabled':'');
c.innerHTML=`<span class="se">${f.e}</span><div class="sl">${f.n}</div>`;
if(isBlocked){ c.title='Bereits vom anderen Spieler gewählt'; }
else { c.onclick=()=>onPick(f.id); }
g.appendChild(c);
});
}
function setPlayerCount(n){
ST.playerCount = n;
if(n===1){ ST.figure2=''; }
setCodeFocus('figure');
save();
s2update();
}
function s2init(){
buildFigureGrid('figGrid', ST.figure, ST.figure2, id => {
ST.figure=id; setCodeFocus('figure');
if(ST.figure2===id) ST.figure2=''; // Konflikt löst sich automatisch
s2update();
});
const bg=document.getElementById('bgGrid');
bg.innerHTML='';
BACKGROUNDS.forEach(b=>{
const c=document.createElement('div');c.className='sel-card'+(ST.background===b.id?' selected':'');
c.innerHTML=`<span class="se">${b.e}</span><div class="sl">${b.n}</div>`;
c.onclick=()=>{ST.background=b.id;setCodeFocus('bg');s2update();bg.querySelectorAll('.sel-card').forEach(x=>x.classList.remove('selected'));c.classList.add('selected');};
bg.appendChild(c);
});
s2update();
}
function s2update(){
// Spielmodus-Karten markieren
document.getElementById('pmode1')?.classList.toggle('active', ST.playerCount!==2);
document.getElementById('pmode2')?.classList.toggle('active', ST.playerCount===2);
// Figur-Karten-Titel anpassen
const t = document.getElementById('figCardTitle');
const s = document.getElementById('figCardSub');
if(t){ t.textContent = ST.playerCount===2 ? '👤 Spielfigur Spieler 1' : '👤 Spielfigur'; }
if(s){ s.textContent = ST.playerCount===2 ? 'Wer bist du im Spiel?' : 'Das bist du im Spiel!'; }
// P2-Karte ein-/ausblenden + Grid neu bauen (für blocked-State)
const fig2Card = document.getElementById('fig2Card');
if(fig2Card){
fig2Card.style.display = ST.playerCount===2 ? '' : 'none';
if(ST.playerCount===2){
buildFigureGrid('fig2Grid', ST.figure2, ST.figure, id => {
ST.figure2=id; setCodeFocus('figure'); s2update();
});
}
}
// P1-Grid neu bauen (damit Block-State der gegenüberliegenden Figur stimmt)
buildFigureGrid('figGrid', ST.figure, ST.playerCount===2 ? ST.figure2 : '', id => {
ST.figure=id; setCodeFocus('figure');
if(ST.figure2===id) ST.figure2='';
s2update();
});
// Combo-Preview
const ok = !!(ST.figure&&ST.background) && (ST.playerCount!==2 || (ST.figure2 && ST.figure2!==ST.figure));
document.getElementById('s2next').disabled=!ok;
const combo=document.getElementById('s2combo');
if(ST.figure && ST.background){
const f=FIGURES.find(x=>x.id===ST.figure);const b=BACKGROUNDS.find(x=>x.id===ST.background);
combo.style.display='';
const f2 = ST.playerCount===2 && ST.figure2 ? FIGURES.find(x=>x.id===ST.figure2) : null;
document.getElementById('s2scene').textContent = b.scene + ' ' + f.e + (f2?' vs '+f2.e:'');
document.getElementById('s2clabel').textContent = f.n + (f2?' & '+f2.n:'') + ' im '+ b.n;
} else { combo.style.display='none'; }
}
/*
STEP 3 SPIELFELD
*/
let selGame=null,dragging=null,epFor=null,dragFromIdx=null;
const STORY_MAX=5,MULTI_MAX=3;
// unlockSection: Markiert eine Sektion als "gesehen" für den Live-Code-Preview
function unlockSection(key){
if(typeof unlocked !== 'undefined') unlocked.add(key);
if(typeof setCodeFocus === 'function') setCodeFocus(key);
}
function s3init(){
unlockSection('movement'); unlockSection('failmode'); unlockSection('fieldcount'); unlockSection('fields'); unlockSection('consequences');
document.getElementById('cntVal').textContent=ST.fieldCount;
document.getElementById('cntMinus').disabled=ST.fieldCount<=6;
document.getElementById('cntPlus').disabled=ST.fieldCount>=50;
applyRuleUI();
buildPalette();
buildBoard();
updateConsequenceUI();
}
function selRule(el){
const g=el.dataset.g;
document.querySelectorAll(`[data-g="${g}"]`).forEach(x=>x.classList.remove('selected'));
el.classList.add('selected');ST.rules[g]=el.dataset.v;
// Map data-g to focus keys
const fkMap={'movement':'movement','fail':'failmode'};
setCodeFocus(fkMap[g]||g);
if(g==='fail'){
const sl = document.getElementById('sub_lives');
const sp = document.getElementById('sub_points');
if(ST.rules.fail==='lives'){
sl.classList.add('visible'); sl.style.display='flex';
sp.classList.remove('visible'); sp.style.display='';
} else {
sl.classList.remove('visible'); sl.style.display='';
sp.classList.add('visible'); sp.style.display='';
}
}
updateConsequenceUI();
}
function selSub(el){
const g=el.dataset.g;
document.querySelectorAll(`[data-g="${g}"]`).forEach(x=>x.classList.remove('selected'));
el.classList.add('selected');ST.rules[g]=el.dataset.v;
// lives/pts sub-options
setCodeFocus(g==='lives'?'lives':g==='pts'?'pts':'failmode');
}
// ── Konsequenz-Funktionen ──────────────────────────────────────────────────
function chgLives(delta){
const cur = parseInt(ST.rules.lives) || 3;
const next = Math.min(5, Math.max(1, cur + delta));
ST.rules.lives = String(next);
document.getElementById('livesDisplay').textContent = next;
document.getElementById('livesHearts').textContent = '❤️'.repeat(next);
setCodeFocus('lives');
save();
}
function selCQ(el,e){
e&&e.stopPropagation&&e.stopPropagation();
const side = el.dataset.cq;
const val = el.dataset.cv;
document.querySelectorAll(`[data-cq="${side}"]`)
.forEach(x=>x.classList.remove('selected'));
el.classList.add('selected');
ST.consequences[side] = val;
setCodeFocus('consequences');
save();
}
function chgCQVal(key, delta, e){
e && e.stopPropagation();
const mins = {winVal:0,loseVal:0,winPts:0,losePts:0};
const maxs = {winVal:6,loseVal:6,winPts:100,losePts:100};
if(!ST.consequences[key]) ST.consequences[key] = key.includes('Pts') ? 10 : 1;
let v = (ST.consequences[key]||0) + delta;
v = Math.max(mins[key]||0, Math.min(maxs[key]||99, v));
ST.consequences[key] = v;
const elId = {winVal:'cqWinVal',loseVal:'cqLoseVal',winPts:'cqWinPts',losePts:'cqLosePts'}[key];
if(elId) document.getElementById(elId).textContent = v;
setCodeFocus('consequences');
save();
}
function updateConsequenceUI(){
const grid = document.getElementById('consequenceGrid');
if(!grid) return;
const isDice = ST.rules.movement === 'dice';
const isLives = ST.rules.fail === 'lives';
const isPts = ST.rules.fail === 'points';
// CSS-Klassen für sichtbare Optionen
// Alle Modus-Klassen zuerst entfernen
grid.classList.remove('cq-mode-step','cq-mode-lives','cq-mode-pts');
// Dann die passenden setzen
if(!isDice) grid.classList.add('cq-mode-step');
if(isLives) grid.classList.add('cq-mode-lives');
if(isPts) grid.classList.add('cq-mode-pts');
// Falls aktuelle Auswahl nicht mehr sichtbar → auf 'nothing' zurücksetzen
['win','lose'].forEach(side=>{
const cur = ST.consequences[side];
const diceOnly = ['forward','back','again','skip'];
const livesOnly = ['life'];
const ptsOnly = ['points'];
let invalid = false;
if(diceOnly.includes(cur) && !isDice) invalid = true;
if(livesOnly.includes(cur) && !isLives) invalid = true;
if(ptsOnly.includes(cur) && !isPts) invalid = true;
if(invalid){
ST.consequences[side] = 'nothing';
}
// UI aktualisieren
document.querySelectorAll(`[data-cq="${side}"]`)
.forEach(x=>x.classList.toggle('selected', x.dataset.cv === ST.consequences[side]));
});
// Werte anzeigen
const c = ST.consequences;
const wv = document.getElementById('cqWinVal');
const lv = document.getElementById('cqLoseVal');
const wp = document.getElementById('cqWinPts');
const lp = document.getElementById('cqLosePts');
if(wv) wv.textContent = c.winVal ?? 2;
if(lv) lv.textContent = c.loseVal ?? 1;
const ld = document.getElementById('livesDisplay');
const lh = document.getElementById('livesHearts');
const ln = parseInt(ST.rules.lives) || 3;
if(ld) ld.textContent = ln;
if(lh) lh.textContent = '❤️'.repeat(ln);
if(wp) wp.textContent = c.winPts ?? 10;
if(lp) lp.textContent = c.losePts ?? 5;
}
function applyRuleUI(){
['movement','fail'].forEach(g=>document.querySelectorAll(`[data-g="${g}"]`).forEach(x=>x.classList.toggle('selected',x.dataset.v===ST.rules[g])));
document.getElementById('sub_lives').classList.toggle('visible',ST.rules.fail==='lives');
document.getElementById('sub_points').classList.toggle('visible',ST.rules.fail==='points');
['lives','pts'].forEach(g=>document.querySelectorAll(`[data-g="${g}"]`).forEach(x=>x.classList.toggle('selected',x.dataset.v===ST.rules[g])));
updateConsequenceUI();
}
function chgCount(d){
const n=ST.fieldCount+d;if(n<6||n>50)return;
ST.fieldCount=n;setCodeFocus('fieldcount');document.getElementById('cntVal').textContent=n;
document.getElementById('cntMinus').disabled=n<=6;document.getElementById('cntPlus').disabled=n>=50;
while(ST.fields.length<n)ST.fields.push(null);
ST.fields=ST.fields.slice(0,n);
ST.storyItems=ST.storyItems.filter(s=>s.fieldIndex<n);
buildBoard();
}

723
js/world.js Normal file
View file

@ -0,0 +1,723 @@
/**
* js/world.js Gemeinsame Welt-Engine für Editor (Vorschau/Generator) und Spieler.
*
* API:
* World.generate(themeId, seed, fieldCount) world (normalized, deterministisch)
* World.render(ctx, world, state, t) zeichnet ein Frame
* World.hitTestPad(world, x, y, W, H) fieldIndex | -1
* World.padCenter(world, i, W, H) {x,y}
* World.resolveTheme(id) mapped theme id (Legacy)
* World.randomSeed() uint32
* World.hashSeed(str) uint32
*
* state = { W, H, pos, visited, fields, storyItems, figEmoji, figX, figY,
* gameName?, devName?, hover? (fieldIndex), locked? }
*/
window.World = (function () {
// ── Seedable PRNG (mulberry32) ─────────────────────────────
function mulberry32(seed) {
let s = (seed >>> 0) || 1;
return function () {
s = (s + 0x6D2B79F5) | 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function hashSeed(str) {
let h = 2166136261;
const s = String(str || '');
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return (h >>> 0) || 1;
}
function randomSeed() { return (Math.floor(Math.random() * 0x100000000)) >>> 0; }
// ── Theme registry (4 Welten) ───────────────────────────────
const THEMES = {
underwater: {
id: 'underwater', name: 'Unterwasser', emoji: '🌊',
primary: '#22d3ee', accent: '#0ea5e9',
bgGrad: ['#052236', '#083b56', '#0e567a'],
pad: { fill: '#0891b2', edge: '#155e75', glow: 'rgba(34,211,238,0.5)' },
},
space: {
id: 'space', name: 'Weltall', emoji: '🌌',
primary: '#a78bfa', accent: '#7c3aed',
bgGrad: ['#050314', '#0f0a2e', '#1e1b4b'],
pad: { fill: '#7c3aed', edge: '#4c1d95', glow: 'rgba(167,139,250,0.55)' },
},
haunted: {
id: 'haunted', name: 'Geisterhaus', emoji: '👻',
primary: '#86efac', accent: '#22d3ee',
bgGrad: ['#0a0a0e', '#16162a', '#1a1a2e'],
pad: { fill: '#475569', edge: '#1e293b', glow: 'rgba(134,239,172,0.45)' },
},
fantasy: {
id: 'fantasy', name: 'Fantasy', emoji: '🧙',
primary: '#f0abfc', accent: '#c084fc',
bgGrad: ['#1a0a2e', '#3b0764', '#581c87'],
pad: { fill: '#a855f7', edge: '#581c87', glow: 'rgba(240,171,252,0.55)' },
},
};
// Mapping für entfernte Welten (Migration alter Saves/URLs)
const LEGACY_MAP = {
ocean: 'underwater', jungle: 'fantasy', volcano: 'space', snow: 'space',
city: 'space', candy: 'fantasy', desert: 'fantasy', school: 'fantasy', future: 'space',
};
function resolveTheme(id) { return THEMES[id] ? id : (LEGACY_MAP[id] || 'space'); }
// ── Pad-Platzierung (Snake-Grid + Jitter, normalisierte Koords) ─────
function placePads(rng, fieldCount) {
const aspect = 16 / 9;
const cols = Math.max(2, Math.min(fieldCount, Math.round(Math.sqrt(fieldCount * aspect))));
const rows = Math.ceil(fieldCount / cols);
const marginX = 0.07, marginY = 0.13; // Platz oben für Header
const usableW = 1 - 2 * marginX;
const usableH = 1 - marginY - 0.10;
const cellW = usableW / cols;
const cellH = usableH / rows;
const pads = [];
for (let i = 0; i < fieldCount; i++) {
const r = Math.floor(i / cols);
const c = r % 2 === 0 ? i % cols : (cols - 1) - (i % cols);
const cx = marginX + (c + 0.5) * cellW;
const cy = marginY + (r + 0.5) * cellH;
const jx = (rng() - 0.5) * cellW * 0.55;
const jy = (rng() - 0.5) * cellH * 0.45;
const ux = Math.max(marginX, Math.min(1 - marginX, cx + jx));
const uy = Math.max(marginY, Math.min(1 - 0.06, cy + jy));
pads.push({ ux, uy });
}
return pads;
}
// Catmull-Rom-Spline durch Pads (normalized space)
function spline(pads, samplesPerSeg) {
const out = [];
const n = pads.length;
if (n < 2) return pads.slice();
for (let i = 0; i < n - 1; i++) {
const p0 = pads[Math.max(0, i - 1)];
const p1 = pads[i];
const p2 = pads[i + 1];
const p3 = pads[Math.min(n - 1, i + 2)];
for (let s = 0; s < samplesPerSeg; s++) {
const t = s / samplesPerSeg, t2 = t * t, t3 = t2 * t;
const ux = 0.5 * ((2 * p1.ux) + (-p0.ux + p2.ux) * t + (2 * p0.ux - 5 * p1.ux + 4 * p2.ux - p3.ux) * t2 + (-p0.ux + 3 * p1.ux - 3 * p2.ux + p3.ux) * t3);
const uy = 0.5 * ((2 * p1.uy) + (-p0.uy + p2.uy) * t + (2 * p0.uy - 5 * p1.uy + 4 * p2.uy - p3.uy) * t2 + (-p0.uy + 3 * p1.uy - 3 * p2.uy + p3.uy) * t3);
out.push({ ux, uy });
}
}
out.push({ ux: pads[n - 1].ux, uy: pads[n - 1].uy });
return out;
}
// ── Theme-spezifische Dekoration deterministisch generieren ────
function makeDecor(rng, themeId, fieldCount) {
const d = {};
if (themeId === 'underwater') {
d.bubbles = []; for (let i = 0; i < 30; i++) d.bubbles.push({ ux: rng(), uy: rng(), r: 2 + rng() * 5, sp: 0.04 + rng() * 0.10, ph: rng() * 6.28 });
d.grass = []; for (let i = 0; i < 14; i++) d.grass.push({ ux: rng(), h: 0.10 + rng() * 0.22, sw: 1.5 + rng() * 2.5, ph: rng() * 6.28 });
d.rocks = []; for (let i = 0; i < 8; i++) d.rocks.push({ ux: rng(), w: 0.04 + rng() * 0.08, h: 0.05 + rng() * 0.08, col: rng() });
d.rays = []; for (let i = 0; i < 5; i++) d.rays.push({ x: rng(), w: 60 + rng() * 80, sk: rng(), ph: rng() * 6.28 });
}
else if (themeId === 'space') {
d.stars = []; for (let i = 0; i < 90; i++) d.stars.push({ ux: rng(), uy: rng(), r: 0.5 + rng() * 1.8, sp: 0.5 + rng() * 1.8, ph: rng() * 6.28 });
d.planets = []; for (let i = 0; i < 3; i++) d.planets.push({ ux: 0.1 + rng() * 0.8, uy: rng() * 0.5, r: 0.03 + rng() * 0.045, hue: rng() * 360 });
d.nebula = []; for (let i = 0; i < 2; i++) d.nebula.push({ ux: rng(), uy: rng() * 0.6, r: 0.18 + rng() * 0.12, hue: 250 + rng() * 80 });
}
else if (themeId === 'haunted') {
d.trees = []; for (let i = 0; i < 7; i++) d.trees.push({ ux: rng(), h: 0.30 + rng() * 0.30, lean: (rng() - 0.5) * 0.4 });
d.graves = []; for (let i = 0; i < 8; i++) d.graves.push({ ux: rng(), w: 0.03 + rng() * 0.04, h: 0.04 + rng() * 0.05 });
d.wisps = []; for (let i = 0; i < 14; i++) d.wisps.push({ ux: rng(), uy: rng(), r: 4 + rng() * 5, sp: 0.02 + rng() * 0.05, ph: rng() * 6.28 });
d.moonX = 0.78 + rng() * 0.12;
}
else if (themeId === 'fantasy') {
d.trees = []; for (let i = 0; i < 9; i++) d.trees.push({ ux: rng(), h: 0.25 + rng() * 0.30 });
d.flies = []; for (let i = 0; i < 40; i++) d.flies.push({ ux: rng(), uy: rng(), r: 1.5 + rng() * 2, sp: 0.6 + rng() * 1.2, ph: rng() * 6.28 });
d.runes = []; for (let i = 0; i < 5; i++) d.runes.push({ ux: 0.05 + rng() * 0.9, uy: 0.1 + rng() * 0.5, r: 0.018 + rng() * 0.020, ph: rng() * 6.28 });
}
return d;
}
// ── generate ────────────────────────────────────────────────
function generate(themeIdRaw, seed, fieldCount) {
const themeId = resolveTheme(themeIdRaw);
const s = ((seed >>> 0) || 1) >>> 0;
const rng = mulberry32(s);
const pads = placePads(rng, fieldCount);
const path = spline(pads, 22);
const decor = makeDecor(rng, themeId, fieldCount);
return { theme: themeId, seed: s, fieldCount, pads, path, decor };
}
// ── Hilfen ──────────────────────────────────────────────────
function padR(W, H) { return Math.min(W, H) * 0.052; }
function padCenter(world, i, W, H) { const p = world.pads[i]; return { x: p.ux * W, y: p.uy * H }; }
function hitTestPad(world, x, y, W, H) {
const r = padR(W, H) * 1.25; // etwas größer für Touch
for (let i = 0; i < world.pads.length; i++) {
const p = world.pads[i], px = p.ux * W, py = p.uy * H;
if ((x - px) * (x - px) + (y - py) * (y - py) < r * r) return i;
}
return -1;
}
// ── Background-Dekoration zeichnen ──────────────────────────
function drawBgDecor(ctx, world, theme, state, t) {
const W = state.W, H = state.H, tt = t * 0.001;
if (theme.id === 'underwater') {
// Caustic-Lichtstrahlen
ctx.save(); ctx.globalCompositeOperation = 'lighter';
world.decor.rays.forEach(ry => {
const x = ((ry.x + Math.sin(tt * 0.2 + ry.ph) * 0.03) % 1 + 1) % 1 * W;
const g = ctx.createLinearGradient(x, 0, x + ry.w * 0.6, H);
g.addColorStop(0, 'rgba(125,211,252,0.10)');
g.addColorStop(1, 'rgba(125,211,252,0)');
ctx.fillStyle = g;
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x + ry.w, 0); ctx.lineTo(x + ry.w * 1.2 + 60, H); ctx.lineTo(x + 40, H); ctx.closePath(); ctx.fill();
});
ctx.restore();
// Felsen
world.decor.rocks.forEach(r => {
const x = r.ux * W, w = r.w * W, h = r.h * H;
ctx.fillStyle = r.col > 0.5 ? '#0e7490' : '#155e75';
ctx.beginPath(); ctx.ellipse(x, H - h * 0.3, w, h * 0.7, 0, Math.PI, 2 * Math.PI); ctx.fill();
});
// Seetang
world.decor.grass.forEach(gr => {
const x = gr.ux * W, sway = Math.sin(tt * 1.4 + gr.ph) * 14;
ctx.strokeStyle = 'rgba(20,184,166,0.65)'; ctx.lineWidth = gr.sw; ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(x, H);
ctx.quadraticCurveTo(x + sway * 0.3, H - gr.h * H * 0.5, x + sway, H - gr.h * H);
ctx.stroke();
});
// Blasen
world.decor.bubbles.forEach(b => {
const x = b.ux * W + Math.sin(tt + b.ph) * 12;
const y = ((b.uy - tt * b.sp) % 1 + 1) % 1 * H;
ctx.globalAlpha = 0.35 + 0.35 * Math.sin(tt * 2 + b.ph);
ctx.fillStyle = 'rgba(186,230,253,0.6)';
ctx.beginPath(); ctx.arc(x, y, b.r, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.4)'; ctx.lineWidth = 0.5; ctx.stroke();
});
ctx.globalAlpha = 1;
}
else if (theme.id === 'space') {
// Nebel
world.decor.nebula.forEach(n => {
const x = n.ux * W, y = n.uy * H, r = n.r * Math.min(W, H);
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, `hsla(${n.hue},80%,60%,0.20)`);
g.addColorStop(1, `hsla(${n.hue},80%,40%,0)`);
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill();
});
// Sterne
world.decor.stars.forEach(s => {
const x = s.ux * W, y = s.uy * H;
ctx.globalAlpha = 0.4 + 0.5 * Math.sin(tt * s.sp + s.ph);
ctx.fillStyle = '#cdd6ff';
ctx.fillRect(x, y, s.r, s.r);
});
ctx.globalAlpha = 1;
// Planeten
world.decor.planets.forEach(p => {
const x = p.ux * W, y = p.uy * H, r = p.r * Math.min(W, H);
const g = ctx.createRadialGradient(x - r * 0.3, y - r * 0.3, 0, x, y, r);
g.addColorStop(0, `hsla(${p.hue},70%,65%,0.55)`);
g.addColorStop(1, `hsla(${p.hue},70%,30%,0.05)`);
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill();
});
}
else if (theme.id === 'haunted') {
// Mond
const mx = world.decor.moonX * W, my = H * 0.16, mr = Math.min(W, H) * 0.06;
ctx.fillStyle = 'rgba(220,230,200,0.18)';
ctx.beginPath(); ctx.arc(mx, my, mr * 2.2, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = 'rgba(220,230,200,0.92)';
ctx.beginPath(); ctx.arc(mx, my, mr, 0, Math.PI * 2); ctx.fill();
// Bäume
ctx.fillStyle = 'rgba(5,5,12,0.85)';
world.decor.trees.forEach(tr => {
const x = tr.ux * W, h = tr.h * H;
ctx.beginPath(); ctx.moveTo(x - 8, H);
ctx.quadraticCurveTo(x + tr.lean * 30, H - h * 0.55, x + tr.lean * 50, H - h);
ctx.lineTo(x + tr.lean * 50 + 4, H - h * 0.97);
ctx.lineTo(x + 8, H); ctx.closePath(); ctx.fill();
});
// Grabsteine
world.decor.graves.forEach(g => {
const x = g.ux * W, w = g.w * W, h = g.h * H;
ctx.fillStyle = 'rgba(60,60,75,0.85)'; ctx.fillRect(x - w / 2, H - h, w, h);
ctx.beginPath(); ctx.arc(x, H - h, w / 2, Math.PI, 2 * Math.PI); ctx.fill();
});
// Wisps
world.decor.wisps.forEach(b => {
const x = b.ux * W + Math.sin(tt + b.ph) * 30;
const y = ((b.uy - tt * b.sp) % 1 + 1) % 1 * H;
const a = 0.3 + 0.35 * Math.sin(tt * 2 + b.ph);
const g2 = ctx.createRadialGradient(x, y, 0, x, y, b.r * 3.5);
g2.addColorStop(0, `rgba(134,239,172,${a * 0.7})`);
g2.addColorStop(1, 'rgba(134,239,172,0)');
ctx.fillStyle = g2;
ctx.beginPath(); ctx.arc(x, y, b.r * 3.5, 0, Math.PI * 2); ctx.fill();
});
}
else if (theme.id === 'fantasy') {
// Doppelmond
ctx.fillStyle = 'rgba(240,171,252,0.85)';
ctx.beginPath(); ctx.arc(W * 0.78, H * 0.14, Math.min(W, H) * 0.045, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = 'rgba(240,171,252,0.6)';
ctx.beginPath(); ctx.arc(W * 0.87, H * 0.22, Math.min(W, H) * 0.025, 0, Math.PI * 2); ctx.fill();
// Wald
ctx.fillStyle = 'rgba(15,5,30,0.85)';
world.decor.trees.forEach(tr => {
const x = tr.ux * W, h = tr.h * H, w = h * 0.45;
ctx.beginPath(); ctx.moveTo(x - w / 2, H); ctx.lineTo(x, H - h); ctx.lineTo(x + w / 2, H); ctx.closePath(); ctx.fill();
});
// Glühwürmchen
world.decor.flies.forEach(b => {
const x = b.ux * W + Math.cos(tt * b.sp + b.ph) * 20;
const y = b.uy * H + Math.sin(tt * b.sp * 1.2 + b.ph) * 12;
const a = 0.4 + 0.55 * Math.sin(tt * 3 + b.ph);
const g = ctx.createRadialGradient(x, y, 0, x, y, b.r * 4);
g.addColorStop(0, `rgba(253,224,71,${a})`);
g.addColorStop(1, 'rgba(253,224,71,0)');
ctx.fillStyle = g;
ctx.beginPath(); ctx.arc(x, y, b.r * 4, 0, Math.PI * 2); ctx.fill();
});
// Runen
world.decor.runes.forEach(r => {
const x = r.ux * W, y = r.uy * H + Math.sin(tt + r.ph) * 8;
const rr = r.r * Math.min(W, H);
ctx.save(); ctx.translate(x, y); ctx.rotate(tt * 0.4 + r.ph);
ctx.strokeStyle = 'rgba(240,171,252,0.65)'; ctx.lineWidth = 1.5;
ctx.strokeRect(-rr, -rr, rr * 2, rr * 2);
ctx.beginPath(); ctx.moveTo(-rr * 0.5, 0); ctx.lineTo(rr * 0.5, 0); ctx.moveTo(0, -rr * 0.5); ctx.lineTo(0, rr * 0.5); ctx.stroke();
ctx.restore();
});
}
}
// ── Pfad zeichnen ───────────────────────────────────────────
function drawPath(ctx, world, theme, state, t) {
if (world.path.length < 2) return;
const W = state.W, H = state.H;
// Outer glow
ctx.lineCap = 'round'; ctx.lineJoin = 'round';
ctx.strokeStyle = theme.pad.glow;
ctx.lineWidth = 16;
ctx.beginPath();
ctx.moveTo(world.path[0].ux * W, world.path[0].uy * H);
for (let i = 1; i < world.path.length; i++) ctx.lineTo(world.path[i].ux * W, world.path[i].uy * H);
ctx.stroke();
// Inner line
ctx.strokeStyle = theme.primary; ctx.lineWidth = 5;
ctx.beginPath();
ctx.moveTo(world.path[0].ux * W, world.path[0].uy * H);
for (let i = 1; i < world.path.length; i++) ctx.lineTo(world.path[i].ux * W, world.path[i].uy * H);
ctx.stroke();
// Animierte fließende Lichtpunkte
const tt = t * 0.0007;
for (let d = 0; d < 8; d++) {
const frac = ((tt + d / 8) % 1 + 1) % 1;
const idx = Math.min(world.path.length - 1, Math.floor(frac * (world.path.length - 1)));
const p = world.path[idx];
ctx.fillStyle = '#fff'; ctx.globalAlpha = 0.55;
ctx.beginPath(); ctx.arc(p.ux * W, p.uy * H, 2.6, 0, Math.PI * 2); ctx.fill();
}
ctx.globalAlpha = 1;
}
// ── Pads zeichnen ───────────────────────────────────────────
function drawPads(ctx, world, theme, state, t) {
const W = state.W, H = state.H, R = padR(W, H), tt = t * 0.001;
const MG_ICONS = { snake: '🐍', flappy: '🐦', memory: '🃏', quiz: '❓', reaction: '⚡', basketball: '🏀', catch: '🍎', maze: '🌀', simon: '🔴', puzzle: '🧩', spotdiff: '🔍', typing: '⌨️' };
for (let i = 0; i < world.pads.length; i++) {
const p = world.pads[i], x = p.ux * W, y = p.uy * H;
const isStart = i === 0, isEnd = i === world.pads.length - 1;
const isActive = state.pos === i;
const isVisited = state.visited && state.visited.has(i);
const isHover = state.hover === i;
const gameId = state.fields ? state.fields[i] : null;
ctx.save();
// Glow ring
if (isActive) {
const pulse = 0.6 + 0.4 * Math.sin(tt * 4);
const g = ctx.createRadialGradient(x, y, R * 0.9, x, y, R * 1.9);
g.addColorStop(0, theme.pad.glow); g.addColorStop(1, 'transparent');
ctx.fillStyle = g;
ctx.beginPath(); ctx.arc(x, y, R * 1.9 * pulse, 0, Math.PI * 2); ctx.fill();
} else if (isHover) {
const g = ctx.createRadialGradient(x, y, R * 0.9, x, y, R * 1.6);
g.addColorStop(0, 'rgba(255,255,255,0.25)'); g.addColorStop(1, 'transparent');
ctx.fillStyle = g;
ctx.beginPath(); ctx.arc(x, y, R * 1.6, 0, Math.PI * 2); ctx.fill();
}
// Pad fill
const grd = ctx.createRadialGradient(x - R * 0.35, y - R * 0.35, 0, x, y, R);
if (isStart) { grd.addColorStop(0, '#6ee7b7'); grd.addColorStop(1, '#065f46'); }
else if (isEnd) { grd.addColorStop(0, '#fde68a'); grd.addColorStop(1, '#78350f'); }
else if (gameId) { grd.addColorStop(0, theme.primary); grd.addColorStop(1, theme.pad.edge); }
else { grd.addColorStop(0, theme.pad.fill); grd.addColorStop(1, theme.pad.edge); }
ctx.fillStyle = grd;
if (theme.id === 'haunted' && !isStart && !isEnd) {
// Grabstein-Form
ctx.beginPath();
ctx.moveTo(x - R * 0.85, y + R * 0.95);
ctx.lineTo(x - R * 0.85, y - R * 0.35);
ctx.quadraticCurveTo(x - R * 0.85, y - R * 1.0, x, y - R * 1.0);
ctx.quadraticCurveTo(x + R * 0.85, y - R * 1.0, x + R * 0.85, y - R * 0.35);
ctx.lineTo(x + R * 0.85, y + R * 0.95);
ctx.closePath(); ctx.fill();
} else {
ctx.beginPath(); ctx.arc(x, y, R, 0, Math.PI * 2); ctx.fill();
}
// Rim
ctx.strokeStyle = isActive ? '#fff' : 'rgba(255,255,255,0.35)';
ctx.lineWidth = isActive ? 2.5 : 1.2;
ctx.beginPath(); ctx.arc(x, y, R, 0, Math.PI * 2); ctx.stroke();
// Glas-Highlight
ctx.beginPath();
ctx.arc(x - R * 0.32, y - R * 0.32, R * 0.45, Math.PI * 1.05, Math.PI * 1.9);
ctx.lineWidth = 2.5; ctx.strokeStyle = 'rgba(255,255,255,0.35)';
ctx.stroke();
ctx.restore();
// Icon + Nummer
const icon = isStart ? '▶' : isEnd ? '🏁' : gameId ? (MG_ICONS[gameId] || '🎮') : '';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
if (icon) {
ctx.font = `bold ${Math.round(R * 0.9)}px ${gameId ? 'serif' : 'Nunito,sans-serif'}`;
ctx.fillStyle = '#fff';
ctx.fillText(icon, x, y);
} else if (!isStart && !isEnd) {
ctx.font = `bold ${Math.round(R * 0.7)}px Nunito,sans-serif`;
ctx.fillStyle = 'rgba(255,255,255,0.85)';
ctx.fillText(String(i), x, y);
}
// Visited check
if (isVisited && !isActive && i > 0) {
ctx.font = `bold ${Math.round(R * 0.5)}px Nunito,sans-serif`;
ctx.fillStyle = '#86efac';
ctx.fillText('✓', x + R * 0.78, y - R * 0.78);
}
// Story-Badge
const hasStory = state.storyItems && state.storyItems.some(s => s.fieldIndex === i);
if (hasStory) {
ctx.font = `${Math.round(R * 0.5)}px serif`;
ctx.fillText('📖', x - R * 0.8, y - R * 0.8);
}
}
}
function drawFigure(ctx, world, theme, state, t) {
const W = state.W, H = state.H, R = padR(W, H);
// Neue Multi-Figuren-API: state.figures [{emoji,x,y,active,dimmed}]
// Backwards-Compat: alte Felder state.figEmoji/figX/figY → in 1-Element-Array umwandeln
let figs = state.figures;
if (!figs || !figs.length) {
if (state.figEmoji == null || state.figX == null) return;
figs = [{ emoji: state.figEmoji, x: state.figX, y: state.figY, active: true, dimmed: false }];
}
// Same-Pad-Offset: wenn zwei Figuren ~auf gleicher Pixel-Position sind, leicht versetzen
const fsBase = R * 1.5;
const pts = figs.map(f => ({ ...f, dx: 0, dy: 0 }));
if (pts.length >= 2) {
for (let i = 0; i < pts.length; i++) {
for (let j = i+1; j < pts.length; j++) {
const dx = pts[j].x - pts[i].x, dy = pts[j].y - pts[i].y;
if (dx*dx + dy*dy < (R*0.6)*(R*0.6)) {
pts[i].dx -= R * 0.55; pts[j].dx += R * 0.55;
}
}
}
}
// Inaktive zuerst zeichnen (damit aktiver oben liegt)
pts.sort((a, b) => (a.active === b.active ? 0 : (a.active ? 1 : -1)));
pts.forEach(p => {
const fx = p.x + p.dx, fy = p.y + p.dy;
const active = !!p.active && !p.dimmed;
const fs = active ? fsBase : fsBase * 0.88;
const bounce = active ? Math.sin(t * 0.005) * 3 : 0;
// Aura
const g = ctx.createRadialGradient(fx, fy, 0, fx, fy, fs * 1.1);
g.addColorStop(0, p.dimmed ? 'rgba(100,100,100,0.25)' : theme.pad.glow); g.addColorStop(1, 'transparent');
ctx.fillStyle = g;
ctx.beginPath(); ctx.arc(fx, fy, fs * 1.1, 0, Math.PI * 2); ctx.fill();
// Emoji
ctx.font = `${Math.round(fs)}px serif`;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
if (active) { ctx.shadowColor = theme.primary; ctx.shadowBlur = 15; }
ctx.globalAlpha = p.dimmed ? 0.45 : 1;
ctx.fillText(p.emoji || '🎮', fx, fy - R * 0.05 + bounce);
ctx.shadowBlur = 0; ctx.globalAlpha = 1;
});
}
function drawHeader(ctx, state) {
if (!state.gameName) return;
const W = state.W, H = state.H;
ctx.textAlign = 'center';
ctx.font = `bold ${Math.round(Math.min(W, H) * 0.038)}px 'Fredoka One',cursive`;
ctx.fillStyle = 'rgba(255,255,255,0.95)';
ctx.shadowColor = 'rgba(0,0,0,0.8)'; ctx.shadowBlur = 14;
ctx.fillText(String(state.gameName).toUpperCase(), W / 2, H * 0.055);
if (state.devName) {
ctx.font = `${Math.round(Math.min(W, H) * 0.018)}px Nunito,sans-serif`;
ctx.fillStyle = 'rgba(255,255,255,0.65)';
ctx.fillText('von ' + state.devName, W / 2, H * 0.085);
}
ctx.shadowBlur = 0;
}
// ── Vordergrund: passierende Bewohner (Schwärme, Wal, Komet, Drache, Fledermaus, Blitz) ──
function drawFish(ctx, x, y, s, color) {
ctx.save(); ctx.translate(x, y); ctx.fillStyle = color;
ctx.beginPath(); ctx.ellipse(0, 0, s, s * 0.55, 0, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.moveTo(-s * 0.7, 0); ctx.lineTo(-s * 1.5, -s * 0.6); ctx.lineTo(-s * 1.5, s * 0.6); ctx.closePath(); ctx.fill();
ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.arc(s * 0.4, -s * 0.1, s * 0.16, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#000'; ctx.beginPath(); ctx.arc(s * 0.42, -s * 0.1, s * 0.08, 0, Math.PI * 2); ctx.fill();
ctx.restore();
}
function drawFishLayer(ctx, tt, W, H, color, count, yFrac, speed, size, bob) {
const cycle = W + 240;
for (let i = 0; i < count; i++) {
const x = ((tt * speed * 60 + i * (cycle / count)) % cycle) - 120;
const y = yFrac * H + Math.sin(tt * 1.4 + i * 0.7) * bob;
drawFish(ctx, x, y, size, color);
}
}
function drawWhale(ctx, W, H, c) {
const x = -260 + c * (W + 520);
const y = H * 0.42 + Math.sin(c * Math.PI * 2) * 22;
ctx.save(); ctx.translate(x, y); ctx.fillStyle = 'rgba(20,40,68,0.55)';
ctx.beginPath(); ctx.ellipse(0, 0, 110, 36, 0, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.moveTo(-100, 0); ctx.lineTo(-140, -30); ctx.lineTo(-140, 30); ctx.closePath(); ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,0.35)'; ctx.beginPath(); ctx.arc(55, -6, 4, 0, Math.PI * 2); ctx.fill();
ctx.restore();
}
function drawShootingStar(ctx, W, H, c, idx) {
// Position bewegt sich von oben-rechts nach unten-links → Kopf führt, Schweif trailt
const sx = W * (1.15 - c * 1.4);
const sy = H * (0.08 + idx * 0.08 + c * 0.55);
const len = 70 + 50 * Math.sin(c * Math.PI);
ctx.save(); ctx.translate(sx, sy); ctx.rotate(-0.45 - idx * 0.12);
// Kopf (hell) am Ursprung = aktuelle Position, Schweif erstreckt sich in +x = entgegen Flugrichtung
const g = ctx.createLinearGradient(0, 0, len, 0);
g.addColorStop(0, 'rgba(255,255,255,0.95)');
g.addColorStop(1, 'rgba(255,255,255,0)');
ctx.strokeStyle = g; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(len, 0); ctx.stroke();
ctx.fillStyle = '#fff'; ctx.shadowColor = '#fff'; ctx.shadowBlur = 8;
ctx.beginPath(); ctx.arc(0, 0, 3, 0, Math.PI * 2); ctx.fill();
ctx.shadowBlur = 0;
ctx.restore();
}
function drawUFO(ctx, W, H, c, theme) {
// Sanft auf-und-ab fliegend, von links nach rechts
const x = -60 + c * (W + 120);
const y = H * 0.28 + Math.sin(c * Math.PI * 5) * 18;
ctx.save(); ctx.translate(x, y);
// Beam unter dem UFO
const beamA = 0.10 + 0.10 * Math.sin(c * Math.PI * 12);
ctx.fillStyle = `rgba(160,255,255,${beamA})`;
ctx.beginPath(); ctx.moveTo(-6, 5); ctx.lineTo(-20, 38); ctx.lineTo(20, 38); ctx.lineTo(6, 5); ctx.closePath(); ctx.fill();
// Untertasse
const dish = ctx.createLinearGradient(0, -2, 0, 8);
dish.addColorStop(0, '#666c80'); dish.addColorStop(1, '#1f2433');
ctx.fillStyle = dish;
ctx.beginPath(); ctx.ellipse(0, 2, 26, 8, 0, 0, Math.PI * 2); ctx.fill();
// Glas-Dome
const dome = ctx.createRadialGradient(-3, -7, 1, 0, -3, 14);
dome.addColorStop(0, 'rgba(180,235,255,0.95)'); dome.addColorStop(1, 'rgba(80,150,200,0.7)');
ctx.fillStyle = dome;
ctx.beginPath(); ctx.ellipse(0, -3, 13, 9, 0, Math.PI, 2 * Math.PI); ctx.fill();
// Highlight im Dome
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.beginPath(); ctx.ellipse(-4, -6, 3, 2, 0, 0, Math.PI * 2); ctx.fill();
// Blinkende Lichter unten
for (let i = -2; i <= 2; i++) {
const on = Math.sin(c * Math.PI * 22 + i * 1.2) > 0;
ctx.fillStyle = on ? '#ffd166' : 'rgba(255,209,102,0.35)';
ctx.shadowColor = '#ffd166'; ctx.shadowBlur = on ? 6 : 0;
ctx.beginPath(); ctx.arc(i * 7, 8, 1.8, 0, Math.PI * 2); ctx.fill();
}
ctx.shadowBlur = 0;
ctx.restore();
}
function drawGhost(ctx, W, H, c, tt) {
// Schwebt in Wellenform durchs Bild
const x = -50 + c * (W + 100);
const y = H * 0.32 + Math.sin(c * Math.PI * 3) * 30;
ctx.save(); ctx.translate(x, y);
ctx.shadowColor = 'rgba(220,230,245,0.7)'; ctx.shadowBlur = 22;
ctx.fillStyle = 'rgba(230,235,245,0.88)';
// Körper: runde Oberseite, geschwungene Unterkante
ctx.beginPath();
ctx.arc(0, -8, 22, Math.PI, 2 * Math.PI);
ctx.lineTo(22, 16);
for (let i = 4; i >= -4; i--) {
const wx = (i / 4) * 22;
const wy = 16 + Math.sin(tt * 4 + i * 0.7 + c * 6) * 5;
ctx.lineTo(wx, wy);
}
ctx.lineTo(-22, 16);
ctx.closePath(); ctx.fill();
ctx.shadowBlur = 0;
// Augen
ctx.fillStyle = '#0c0c14';
ctx.beginPath(); ctx.ellipse(-7, -8, 3, 4, 0, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.ellipse(7, -8, 3, 4, 0, 0, Math.PI * 2); ctx.fill();
// Mund (Oval)
ctx.beginPath(); ctx.ellipse(0, 2, 3.5, 5, 0, 0, Math.PI * 2); ctx.fill();
ctx.restore();
}
function drawComet(ctx, W, H, c, theme) {
const x = W * (1.1 - c * 1.3), y = H * (0.12 + c * 0.5);
ctx.save(); ctx.translate(x, y);
const g = ctx.createLinearGradient(0, 0, 70, 0);
g.addColorStop(0, theme.primary); g.addColorStop(1, 'transparent');
ctx.fillStyle = g;
ctx.beginPath(); ctx.moveTo(0, -7); ctx.lineTo(70, 0); ctx.lineTo(0, 7); ctx.closePath(); ctx.fill();
ctx.shadowColor = theme.primary; ctx.shadowBlur = 14;
ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.arc(0, 0, 5, 0, Math.PI * 2); ctx.fill();
ctx.shadowBlur = 0; ctx.restore();
}
function drawBat(ctx, W, H, c, idx, tt) {
const x = c * (W + 120) - 60;
const y = H * (0.18 + idx * 0.08) + Math.sin(tt * 5 + idx) * 14;
const flap = Math.sin(tt * 14) * 0.4 + 0.7;
ctx.save(); ctx.translate(x, y); ctx.fillStyle = 'rgba(10,10,18,0.92)';
ctx.beginPath(); ctx.ellipse(0, 0, 5, 4, 0, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.moveTo(0, 0); ctx.quadraticCurveTo(-12, -9 * flap, -20, -2); ctx.quadraticCurveTo(-10, 4, 0, 2); ctx.fill();
ctx.beginPath(); ctx.moveTo(0, 0); ctx.quadraticCurveTo(12, -9 * flap, 20, -2); ctx.quadraticCurveTo(10, 4, 0, 2); ctx.fill();
ctx.restore();
}
function drawMist(ctx, W, H, tt) {
for (let i = 0; i < 3; i++) {
const x = ((tt * (10 + i * 6) + i * 240) % (W + 480)) - 240;
const y = H - 50 - i * 10;
const g = ctx.createRadialGradient(x, y, 0, x, y, 220);
g.addColorStop(0, 'rgba(200,200,225,0.15)'); g.addColorStop(1, 'transparent');
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(x, y, 220, 0, Math.PI * 2); ctx.fill();
}
}
function drawLeaves(ctx, W, H, tt) {
for (let i = 0; i < 7; i++) {
const x = ((tt * 22 + i * 240) % (W + 200)) - 100;
const y = ((tt * 14 + i * 130) % (H + 80)) - 40;
const rot = tt * 1.4 + i;
ctx.save(); ctx.translate(x, y); ctx.rotate(rot);
ctx.fillStyle = `hsla(${280 + i * 8},65%,55%,0.55)`;
ctx.beginPath(); ctx.ellipse(0, 0, 7, 3.5, 0, 0, Math.PI * 2); ctx.fill();
ctx.restore();
}
}
function drawDragon(ctx, W, H, c, theme) {
const x = -140 + c * (W + 320);
const y = H * 0.16 + Math.sin(c * Math.PI * 3) * 30;
ctx.save(); ctx.translate(x, y);
ctx.shadowColor = theme.primary; ctx.shadowBlur = 18;
ctx.fillStyle = 'rgba(30,10,40,0.92)';
// S-Body
ctx.beginPath();
ctx.moveTo(0, 0); ctx.quadraticCurveTo(-30, -16, -60, 0);
ctx.quadraticCurveTo(-90, 16, -120, 4); ctx.lineTo(-120, 12);
ctx.quadraticCurveTo(-90, 24, -60, 14); ctx.quadraticCurveTo(-30, 10, 0, 14);
ctx.closePath(); ctx.fill();
// Kopf
ctx.beginPath(); ctx.ellipse(10, 8, 13, 9, 0, 0, Math.PI * 2); ctx.fill();
// Flügel
const wing = Math.sin(c * Math.PI * 16) * 14;
ctx.beginPath(); ctx.moveTo(-22, 6); ctx.quadraticCurveTo(-42, -22 - wing, -60, -10); ctx.lineTo(-30, 4); ctx.fill();
ctx.beginPath(); ctx.moveTo(-50, 6); ctx.quadraticCurveTo(-72, -28 - wing, -86, -8); ctx.lineTo(-58, 4); ctx.fill();
ctx.shadowBlur = 0;
ctx.fillStyle = theme.primary;
ctx.beginPath(); ctx.arc(17, 6, 2.4, 0, Math.PI * 2); ctx.fill();
ctx.restore();
}
function drawForeground(ctx, world, theme, state, t) {
const W = state.W, H = state.H, tt = t * 0.001;
if (theme.id === 'underwater') {
drawFishLayer(ctx, tt, W, H, 'rgba(252,211,77,0.85)', 6, 0.22, 0.5, 11, 7);
drawFishLayer(ctx, tt, W, H, 'rgba(125,211,252,0.7)', 5, 0.42, 0.7, 9, 5);
drawFishLayer(ctx, tt, W, H, 'rgba(74,222,128,0.7)', 7, 0.62, 0.4, 8, 6);
const wc = (tt % 22) / 14; if (wc <= 1) drawWhale(ctx, W, H, wc);
}
else if (theme.id === 'space') {
for (let i = 0; i < 3; i++) {
const c = ((tt + i * 4.3) % 11) / 3.5;
if (c <= 1) drawShootingStar(ctx, W, H, c, i);
}
const cc = (tt % 28) / 18; if (cc <= 1) drawComet(ctx, W, H, cc, theme);
// UFO sporadisch (alle ~36 s, fliegt ~14 s)
const uc = (tt % 36) / 14; if (uc <= 1) drawUFO(ctx, W, H, uc, theme);
}
else if (theme.id === 'haunted') {
// Blitz-Flicker: kurz, alle ~9 s
const flickerPhase = (tt * 0.5) % 9;
if (flickerPhase < 0.15) {
ctx.fillStyle = `rgba(200,210,230,${(0.15 - flickerPhase) * 1.8})`;
ctx.fillRect(0, 0, W, H);
}
drawMist(ctx, W, H, tt);
for (let i = 0; i < 2; i++) {
const c = ((tt + i * 6.5) % 12) / 7.5;
if (c <= 1) drawBat(ctx, W, H, c, i, tt);
}
// Gespenst sporadisch (alle ~28 s, schwebt ~16 s durchs Bild)
const gc = (tt % 28) / 16; if (gc <= 1) drawGhost(ctx, W, H, gc, tt);
}
else if (theme.id === 'fantasy') {
drawLeaves(ctx, W, H, tt);
// Drache sporadisch (alle ~38 s, fliegt ~17 s) — wirkt seltener/zufälliger
const dc = (tt % 38) / 17; if (dc <= 1) drawDragon(ctx, W, H, dc, theme);
}
}
// ── render() ────────────────────────────────────────────────
function render(ctx, world, state, t) {
const W = state.W, H = state.H;
const theme = THEMES[world.theme];
// Background gradient
const bg = ctx.createLinearGradient(0, 0, 0, H);
bg.addColorStop(0, theme.bgGrad[0]); bg.addColorStop(0.55, theme.bgGrad[1]); bg.addColorStop(1, theme.bgGrad[2]);
ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H);
// Atmosphäre
drawBgDecor(ctx, world, theme, state, t);
// Pfad + Pads + Vordergrund-Bewohner + Figur + Header
drawPath(ctx, world, theme, state, t);
drawPads(ctx, world, theme, state, t);
drawForeground(ctx, world, theme, state, t);
drawFigure(ctx, world, theme, state, t);
drawHeader(ctx, state);
}
return { THEMES, generate, render, hitTestPad, padCenter, resolveTheme, randomSeed, hashSeed };
})();

BIN
logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

98
minigames/_api.js Normal file
View file

@ -0,0 +1,98 @@
/**
* minigames/_api.js
* Gemeinsame API und Hilfsfunktionen für alle Mini-Games
*
* Jedes Mini-Game muss folgendes exportieren (als globale Variable window.MG_<NAME>):
*
* window.MG_snake = {
* id: 'snake',
* emoji: '🐍',
* name: 'Snake',
* desc: 'Steuere die Schlange...',
* controls: 'Pfeiltasten oder WASD',
* multi: 1, // 1 = einmalig, 3 = max 3x, 99 = unbegrenzt
* launch: function(wrap, W, H, cfg) { ... return { stop() {} }; },
* preview: function(wrap, W, H, cfg) { ... return { stop() {} }; },
* };
*
* launch() vollständiges Spiel, wird in game.html verwendet
* preview() kompakte Demo, wird im Editor-Test-Popup verwendet
* Beide geben ein Objekt { stop() } zurück zum Aufräumen.
*
* cfg = { quizData, theme, rules, devName, gameName }
*/
window.MGAPI = (function() {
// ── Canvas-Setup-Helfer ──────────────────────────────────────────────────
function makeCanvas(wrap, W, H) {
const dpr = window.devicePixelRatio || 1;
const canvas = document.createElement('canvas');
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
canvas.style.width = W + 'px';
canvas.style.height = H + 'px';
canvas.style.display = 'block';
canvas.style.margin = '0 auto';
canvas.style.borderRadius = '12px';
canvas.style.background = '#0f0e17';
wrap.appendChild(canvas);
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
return { canvas, ctx };
}
// ── Ergebnis-Anzeige (wird von game.html überschrieben) ──────────────────
// game.html setzt window.MGAPI.onResult = finishMinigame
// editor.html setzt window.MGAPI.onResult = previewResult
function onResult(won) {
if (typeof window._mgOnResult === 'function') {
window._mgOnResult(won);
}
}
// ── Schrift-Helfer ───────────────────────────────────────────────────────
function text(ctx, str, x, y, opts = {}) {
ctx.save();
ctx.font = `${opts.weight || 'bold'} ${opts.size || 16}px ${opts.family || 'Nunito,sans-serif'}`;
ctx.fillStyle = opts.color || '#fff';
ctx.textAlign = opts.align || 'center';
ctx.textBaseline = opts.baseline || 'middle';
if (opts.shadow) {
ctx.shadowColor = opts.shadow;
ctx.shadowBlur = opts.shadowBlur || 10;
}
ctx.fillText(str, x, y);
ctx.restore();
}
// ── Runde Rechtecke ──────────────────────────────────────────────────────
function roundRect(ctx, x, y, w, h, r, fill, stroke) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.arcTo(x + w, y, x + w, y + r, r);
ctx.lineTo(x + w, y + h - r);
ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
ctx.lineTo(x + r, y + h);
ctx.arcTo(x, y + h, x, y + h - r, r);
ctx.lineTo(x, y + r);
ctx.arcTo(x, y, x + r, y, r);
ctx.closePath();
if (fill) { ctx.fillStyle = fill; ctx.fill(); }
if (stroke) { ctx.strokeStyle = stroke; ctx.stroke(); }
}
// ── Game-Over / Win Screen ───────────────────────────────────────────────
function resultScreen(ctx, W, H, won, msg) {
ctx.fillStyle = won ? 'rgba(16,185,129,0.85)' : 'rgba(239,68,68,0.85)';
roundRect(ctx, W/2-120, H/2-50, 240, 100, 16, ctx.fillStyle, null);
text(ctx, won ? '🎉 Gewonnen!' : '💀 Verloren!', W/2, H/2-16,
{ size: 22, weight: 'bold', family: "'Fredoka One',cursive", color: '#fff' });
if (msg) text(ctx, msg, W/2, H/2+16, { size: 13, color: 'rgba(255,255,255,0.85)' });
}
// ── Öffentliche API ──────────────────────────────────────────────────────
return { makeCanvas, onResult, text, roundRect, resultScreen };
})();

176
minigames/basketball.js Normal file
View file

@ -0,0 +1,176 @@
/**
* minigames/basketball.js
* 🏀 Basketball Wirf den Ball zum richtigen Zeitpunkt!
*/
window.MG_basketball = (function() {
const ID = 'basketball';
const EMOJI = '🏀';
const NAME = 'Basketball';
const DESC = 'Drücke zum richtigen Zeitpunkt, um den Ball zu werfen!';
const CONTROLS = 'Klick / Leertaste';
const MULTI = 3;
function run(wrap, W, H, cfg, onDone) {
const { canvas, ctx } = MGAPI.makeCanvas(wrap, W, H);
const theme = cfg.theme || { primary: '#f97316' };
const WIN_BASKETS = cfg.winBaskets || 1;
const HOOP_X = W * 0.72;
const HOOP_Y = H * 0.28;
const HOOP_W = 54;
const BALL_R = 18;
const BALL_START = { x: W * 0.18, y: H * 0.72 };
let ballX = BALL_START.x;
let ballY = BALL_START.y;
let vx = 0, vy = 0;
let flying = false;
let score = 0;
let misses = 0;
let stopped = false;
let raf, endTimer;
let result = null;
let power = 0;
let powerDir = 1;
const powerMax = 100;
function shoot() {
if (flying || result) return;
const dx = HOOP_X - BALL_START.x;
const dy = HOOP_Y - BALL_START.y - 20;
const speed = 8.5 + power * 0.12; // schneller: war 4 + 0.09
const angle = Math.atan2(dy, dx) - 0.18 - (power - 50) * 0.005;
vx = Math.cos(angle) * speed;
vy = Math.sin(angle) * speed;
flying = true;
ballX = BALL_START.x;
ballY = BALL_START.y;
}
const onAction = e => {
if (e.type === 'keydown' && e.code !== 'Space') return;
if (e.type === 'keydown') e.preventDefault();
shoot();
};
document.addEventListener('keydown', onAction);
canvas.addEventListener('click', onAction);
canvas.addEventListener('touchstart', e => { e.preventDefault(); shoot(); }, { passive: false });
function reset() {
flying = false;
ballX = BALL_START.x;
ballY = BALL_START.y;
}
function drawHoop() {
ctx.fillStyle = '#e5e7eb';
ctx.fillRect(HOOP_X + HOOP_W * 0.4, HOOP_Y - 50, 8, 50);
ctx.strokeStyle = '#ef4444';
ctx.lineWidth = 5;
ctx.beginPath();
ctx.ellipse(HOOP_X + HOOP_W / 2, HOOP_Y, HOOP_W / 2, 8, 0, 0, Math.PI * 2);
ctx.stroke();
ctx.strokeStyle = 'rgba(255,255,255,0.5)';
ctx.lineWidth = 1.5;
for (let i = 0; i <= 4; i++) {
const nx = HOOP_X + (HOOP_W / 4) * i;
ctx.beginPath();
ctx.moveTo(nx, HOOP_Y + 8);
ctx.lineTo(HOOP_X + HOOP_W / 2 + (i - 2) * 3, HOOP_Y + 36);
ctx.stroke();
}
}
function drawPower() {
if (flying || result) return;
const bx = 12, by = H - 28, bw = W * 0.45, bh = 14;
MGAPI.roundRect(ctx, bx, by, bw, bh, 6, 'rgba(255,255,255,0.1)', 'rgba(255,255,255,0.2)');
const fill = Math.max(0, Math.min(1, power / powerMax));
const color = fill < 0.4 ? '#22c55e' : fill < 0.7 ? '#f59e0b' : '#ef4444';
MGAPI.roundRect(ctx, bx + 2, by + 2, (bw - 4) * fill, bh - 4, 4, color, null);
MGAPI.text(ctx, 'Kraft', bx + bw / 2, by + bh / 2, { size: 10, color: '#fff' });
}
function loop() {
if (stopped) return;
raf = requestAnimationFrame(loop);
ctx.fillStyle = '#050508';
ctx.fillRect(0, 0, W, H);
MGAPI.roundRect(ctx, 0, H - 10, W, 10, 0, '#1e293b', null);
drawHoop();
if (!flying && !result) {
power += powerDir * 2.2; // Powerbar etwas schneller (war 1.8)
if (power >= powerMax) powerDir = -1;
if (power <= 0) powerDir = 1;
}
if (flying) {
vy += 0.38;
ballX += vx;
ballY += vy;
const inHoopX = ballX > HOOP_X && ballX < HOOP_X + HOOP_W;
const inHoopY = Math.abs(ballY - HOOP_Y) < 18;
if (inHoopX && inHoopY && vy > 0) {
score++;
if (score >= WIN_BASKETS && !endTimer)
endTimer = setTimeout(() => { result = 'win'; setTimeout(() => onDone(true), 900); }, 300);
reset();
}
if (ballY > H + 20 || ballX > W + 20 || ballX < -20) {
misses++;
if (misses >= 6 && score < WIN_BASKETS && !endTimer)
endTimer = setTimeout(() => { result = 'lose'; setTimeout(() => onDone(false), 900); }, 300);
reset();
}
}
// Ball
ctx.shadowColor = theme.primary;
ctx.shadowBlur = 15;
ctx.fillStyle = theme.primary;
ctx.beginPath();
ctx.arc(ballX, ballY, BALL_R, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
ctx.strokeStyle = 'rgba(0,0,0,0.35)';
ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(ballX, ballY, BALL_R, 0, Math.PI * 2); ctx.stroke();
ctx.beginPath(); ctx.moveTo(ballX - BALL_R, ballY); ctx.lineTo(ballX + BALL_R, ballY); ctx.stroke();
drawPower();
MGAPI.text(ctx, `🏀 ${score} / ${WIN_BASKETS}`, W / 2, 18, { size: 14, color: theme.primary });
if (!flying && !result)
MGAPI.text(ctx, 'Klick oder Leertaste zum Werfen', W / 2, H - 48,
{ size: 11, color: 'rgba(255,255,255,0.45)' });
if (result)
MGAPI.resultScreen(ctx, W, H, result === 'win',
result === 'win' ? `${score} Körbe getroffen!` : `Nur ${score} — weiter üben!`);
}
raf = requestAnimationFrame(loop);
return {
stop() {
stopped = true;
cancelAnimationFrame(raf);
clearTimeout(endTimer);
document.removeEventListener('keydown', onAction);
canvas.removeEventListener('click', onAction);
},
};
}
function launch(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
function preview(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
return { id: ID, emoji: EMOJI, name: NAME, desc: DESC, controls: CONTROLS, multi: MULTI, launch, preview };
})();

142
minigames/catch.js Normal file
View file

@ -0,0 +1,142 @@
/**
* minigames/catch.js
* 🍎 Äpfel fangen Bewege den Korb und fange fallende Früchte!
*/
window.MG_catch = (function() {
const ID = 'catch';
const EMOJI = '🍎';
const NAME = 'Äpfel fangen';
const DESC = 'Bewege den Korb mit ← → oder der Maus und fange 5 Äpfel!';
const CONTROLS = '← → / A D / Maus';
const MULTI = 3;
function run(wrap, W, H, cfg, onDone) {
const { canvas, ctx } = MGAPI.makeCanvas(wrap, W, H);
const theme = cfg.theme || { primary: '#22c55e' };
const WIN = cfg.winCatch || 20;
const FRUITS = ['🍎','🍊','🍋','🍇','🍓','🍑'];
const BASKET_W = 70, BASKET_H = 24;
let basket = { x: W / 2 - BASKET_W / 2 };
let items = [];
let score = 0;
let missed = 0;
let frame = 0;
let stopped = false;
let raf, endTimer, result = null;
const keys = {};
let mouseX = null;
const onKey = e => { keys[e.code] = e.type === 'keydown'; };
const onMouse = e => {
const rect = canvas.getBoundingClientRect();
mouseX = (e.clientX - rect.left) * (canvas.width / rect.width / (window.devicePixelRatio || 1)) - BASKET_W / 2;
};
const onTouch = e => {
e.preventDefault();
const rect = canvas.getBoundingClientRect();
mouseX = (e.touches[0].clientX - rect.left) * (canvas.width / rect.width / (window.devicePixelRatio || 1)) - BASKET_W / 2;
};
document.addEventListener('keydown', onKey);
document.addEventListener('keyup', onKey);
canvas.addEventListener('mousemove', onMouse);
canvas.addEventListener('touchmove', onTouch, { passive: false });
function spawnItem() {
items.push({
x: Math.random() * (W - 40) + 20,
y: -30,
vy: 3.2 + Math.random() * 1.2 + score * 0.22, // schneller + mehr Zuwachs
emoji: FRUITS[Math.floor(Math.random() * FRUITS.length)],
size: 26 + Math.random() * 10,
});
}
function loop() {
if (stopped) return;
raf = requestAnimationFrame(loop);
frame++;
if (mouseX !== null) {
basket.x += (mouseX - basket.x) * 0.18;
} else {
if (keys['ArrowLeft'] || keys['KeyA']) basket.x -= 5.5;
if (keys['ArrowRight'] || keys['KeyD']) basket.x += 5.5;
}
basket.x = Math.max(0, Math.min(W - BASKET_W, basket.x));
const spawnRate = Math.max(22, 55 - score * 3); // häufiger spawnen
if (frame % spawnRate === 0 && !result) spawnItem();
items.forEach(it => { it.y += it.vy; });
items = items.filter(it => {
const basketTop = H - 50 - BASKET_H;
if (it.y + it.size / 2 > basketTop &&
it.x > basket.x && it.x < basket.x + BASKET_W) {
score++;
if (score >= WIN && !endTimer)
endTimer = setTimeout(() => { result = 'win'; setTimeout(() => onDone(true), 900); }, 200);
return false;
}
if (it.y > H + 20) {
missed++;
if (missed >= 4 && score < WIN && !endTimer)
endTimer = setTimeout(() => { result = 'lose'; setTimeout(() => onDone(false), 900); }, 200);
return false;
}
return true;
});
ctx.fillStyle = '#050508';
ctx.fillRect(0, 0, W, H);
ctx.strokeStyle = 'rgba(255,255,255,0.08)';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(0, H - 50); ctx.lineTo(W, H - 50); ctx.stroke();
ctx.font = '28px serif'; ctx.textAlign = 'center';
items.forEach(it => ctx.fillText(it.emoji, it.x, it.y + it.size));
const bx = basket.x, by = H - 50 - BASKET_H;
MGAPI.roundRect(ctx, bx, by, BASKET_W, BASKET_H, 6, `${theme.primary}33`, theme.primary);
for (let i = 1; i < 4; i++) {
ctx.strokeStyle = `${theme.primary}66`; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(bx+(BASKET_W/4)*i, by); ctx.lineTo(bx+(BASKET_W/4)*i, by+BASKET_H); ctx.stroke();
}
ctx.font = '22px serif';
ctx.fillText('🧺', bx + BASKET_W / 2, by + BASKET_H + 20);
MGAPI.text(ctx, `🍎 ${score} / ${WIN} 💔 ${missed} / 4`, W / 2, 18, { size: 13, color: theme.primary });
if (!result)
MGAPI.text(ctx, '← → oder Maus bewegen', W / 2, H - 10, { size: 10, color: 'rgba(255,255,255,0.3)' });
if (result)
MGAPI.resultScreen(ctx, W, H, result === 'win',
result === 'win' ? `${score} Früchte gefangen!` : `${missed} verpasst — nächstes Mal!`);
}
raf = requestAnimationFrame(loop);
return {
stop() {
stopped = true;
cancelAnimationFrame(raf);
clearTimeout(endTimer);
document.removeEventListener('keydown', onKey);
document.removeEventListener('keyup', onKey);
canvas.removeEventListener('mousemove', onMouse);
canvas.removeEventListener('touchmove', onTouch);
},
};
}
function launch(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
function preview(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
return { id: ID, emoji: EMOJI, name: NAME, desc: DESC, controls: CONTROLS, multi: MULTI, launch, preview };
})();

268
minigames/flappy.js Normal file
View file

@ -0,0 +1,268 @@
/**
* minigames/flappy.js
* 🐦 Flappy Dark-Neon-Look, flüssig (Fixed-Timestep + Interpolation).
* Die Spielfigur ist die im Editor gewählte Welt-Figur (als leuchtender Komet mit Schweif).
* Eigene Vektor-/Canvas-Grafik, keine externen Assets.
*/
window.MG_flappy = (function () {
const ID = 'flappy', EMOJI = '🐦', NAME = 'Flappy Bird';
const DESC = '3 Leben, 15 Hindernisse. Klick oder Leertaste zum Fliegen!';
const CONTROLS = 'Klick / Leertaste';
const MULTI = 1;
const lerp = (a, b, t) => a + (b - a) * t;
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
const hx = (h, i) => parseInt(h.replace('#', '').substr(i, 2), 16);
function shade(hex, amt) {
let r = clamp(hx(hex, 0) + amt, 0, 255), g = clamp(hx(hex, 2) + amt, 0, 255), b = clamp(hx(hex, 4) + amt, 0, 255);
return '#' + [r, g, b].map(v => Math.round(v).toString(16).padStart(2, '0')).join('');
}
function rgba(hex, a) { return `rgba(${hx(hex, 0)},${hx(hex, 2)},${hx(hex, 4)},${a})`; }
function run(wrap, W, H, cfg, onDone) {
const { canvas, ctx } = MGAPI.makeCanvas(wrap, W, H);
let accent = (cfg && cfg.theme && cfg.theme.primary) || '#5ec5b8';
if (!/^#[0-9a-f]{6}$/i.test(accent)) accent = '#5ec5b8';
const accentLight = shade(accent, 70);
const accentDeep = shade(accent, -120);
const figEmoji = (cfg && cfg.figure) || '🐦';
const WIN_PIPES = (cfg && (cfg.win || cfg.winPipes)) || 15;
const MAX_LIVES = (cfg && cfg.lives) || 3;
// ── Physik (pro festem 1/60-Schritt) — einstellbar via cfg.speed / cfg.gap ──
const STEP = 1000 / 60;
const GRAV = 0.42, FLAP = -7.2, PW = 46;
const BASE_SPEED = (cfg && cfg.speed) || 2.7;
const SPEED_INC = (cfg && cfg.rampSpeed === false) ? 0 : 0.07; // Tempo steigt pro Durchgang (Toggle)
const BASE_GAP = (cfg && cfg.gap) || 168;
const GAP_DEC = (cfg && cfg.shrinkGap === false) ? 0 : 1.6; // Durchgänge werden enger (Toggle)
const MIN_GAP = Math.max(90, BASE_GAP - 36), minTop = 36;
const GROUND_H = Math.min(54, Math.round(H * 0.13));
const PLAY_H = H - GROUND_H;
const BIRD_X = Math.round(W * 0.28);
const BR = 16;
// ── Zustand ──
let state = 'ready';
let bird = { y: PLAY_H / 2, py: PLAY_H / 2, vy: 0, angle: 0 };
let pipes = [], trail = [];
let score = 0, lives = MAX_LIVES, speed = BASE_SPEED;
let worldScroll = 0, pWorldScroll = 0;
let simTime = 0, flapAnim = 0, shake = 0, flash = 0;
let stopped = false, raf = null, endTimer = null, respawnTimer = null;
let lastTime = 0, acc = 0;
let puffs = [], pops = [], confetti = [];
const stars = [];
for (let i = 0; i < 34; i++)
stars.push({ x: Math.random() * W, y: Math.random() * PLAY_H, r: Math.random() < 0.3 ? 2 : 1, a: 0.3 + Math.random() * 0.5, p: Math.random() * 6.28 });
// Dunkler Himmel in Welt-Farbe (einmalig)
const skyGrad = ctx.createLinearGradient(0, 0, 0, H);
skyGrad.addColorStop(0, '#080810');
skyGrad.addColorStop(0.6, shade(accentDeep, -40));
skyGrad.addColorStop(1, accentDeep);
// Figur einmal mit Neon-Glow als Sprite vorrendern → danach nur noch blitten
const SPR = 72, dpr = window.devicePixelRatio || 1;
const spr = document.createElement('canvas');
spr.width = SPR * dpr; spr.height = SPR * dpr;
const sctx = spr.getContext('2d');
sctx.scale(dpr, dpr);
sctx.textAlign = 'center'; sctx.textBaseline = 'middle';
sctx.font = `${Math.round(SPR * 0.55)}px serif`;
sctx.shadowColor = accentLight; sctx.shadowBlur = 18;
sctx.fillText(figEmoji, SPR / 2, SPR / 2);
sctx.shadowBlur = 10;
sctx.fillText(figEmoji, SPR / 2, SPR / 2);
function curGAP(sc) { return Math.max(MIN_GAP, BASE_GAP - sc * GAP_DEC); }
function rndGapY(sc) {
const g = curGAP(sc), maxTop = PLAY_H - g - 40;
return minTop + Math.random() * Math.max(10, maxTop - minTop);
}
function spawnPipe() { pipes.push({ x: W + PW, px: W + PW, gap: rndGapY(score), g: curGAP(score), passed: false }); }
function flap() {
if (stopped || state === 'dying' || state === 'won') return;
if (state === 'ready') { state = 'play'; spawnPipe(); }
bird.vy = FLAP; flapAnim = 1;
for (let i = 0; i < 4; i++) puffs.push({ x: BIRD_X - BR, y: bird.y + (Math.random() - 0.5) * 14, r: 2, a: 0.6 });
}
const onKey = e => { if (e.code === 'Space' || e.code === 'ArrowUp') { e.preventDefault(); flap(); } };
document.addEventListener('keydown', onKey);
canvas.addEventListener('mousedown', flap);
canvas.addEventListener('touchstart', e => { e.preventDefault(); flap(); }, { passive: false });
function hitPipe(p) {
const r = BR - 4;
if (BIRD_X + r > p.x && BIRD_X - r < p.x + PW)
return bird.y - r < p.gap || bird.y + r > p.gap + p.g;
return false;
}
function loseLife() {
if (state === 'dying' || state === 'won') return;
shake = 14; flash = 0.55; lives--; state = 'dying';
bird.vy = Math.min(bird.vy, 1.5);
if (lives <= 0) endTimer = setTimeout(() => onDone(false), 1400);
else respawnTimer = setTimeout(() => {
bird = { y: PLAY_H / 2, py: PLAY_H / 2, vy: 0, angle: 0 };
pipes = pipes.filter(p => p.x > BIRD_X + 150); trail = []; state = 'play';
}, 1100);
}
function win() {
if (state === 'won') return;
state = 'won';
const cols = [accent, accentLight, '#ffffff', shade(accent, 40)];
for (let i = 0; i < 70; i++)
confetti.push({ x: W / 2, y: PLAY_H * 0.4, vx: (Math.random() - 0.5) * 9, vy: -3 - Math.random() * 6, c: cols[i % cols.length], r: 3 + Math.random() * 3, a: 1 });
endTimer = setTimeout(() => onDone(true), 1800);
}
function step() {
pWorldScroll = worldScroll;
simTime += STEP;
flapAnim = Math.max(0, flapAnim - 0.08);
if (shake > 0) shake = Math.max(0, shake - 0.7);
if (flash > 0) flash = Math.max(0, flash - 0.04);
stars.forEach(s => { s.x -= speed * 0.12; if (s.x < -2) { s.x = W + 2; s.y = Math.random() * PLAY_H; } });
if (state === 'ready') { bird.py = bird.y; bird.y = PLAY_H / 2 + Math.sin(simTime * 0.005) * 8; return; }
if (state === 'won') {
bird.py = bird.y;
confetti.forEach(p => { p.x += p.vx; p.y += p.vy; p.vy += 0.25; p.a -= 0.009; });
confetti = confetti.filter(p => p.a > 0); return;
}
bird.py = bird.y;
bird.vy += GRAV; bird.y += bird.vy;
bird.angle = lerp(bird.angle, clamp(bird.vy * 0.06, -0.5, 1.4), 0.18);
trail.unshift(bird.y); if (trail.length > 8) trail.pop();
puffs.forEach(p => { p.r += 1.1; p.a -= 0.06; p.x -= speed; });
puffs = puffs.filter(p => p.a > 0);
pops.forEach(p => { p.y -= 0.8; p.a -= 0.02; p.scale = Math.min(1.3, p.scale + 0.06); });
pops = pops.filter(p => p.a > 0);
if (state === 'dying') { bird.angle = Math.min(1.7, bird.angle + 0.12); return; }
worldScroll += speed;
pipes.forEach(p => { p.px = p.x; p.x -= speed; });
if (!pipes.length || pipes[pipes.length - 1].x < W - 200) spawnPipe();
pipes = pipes.filter(p => p.x > -PW - 10);
pipes.forEach(p => {
if (!p.passed && p.x + PW < BIRD_X) {
p.passed = true; score++; speed = BASE_SPEED + score * SPEED_INC;
pops.push({ x: BIRD_X + 26, y: bird.y - 22, a: 1, scale: 0.6 });
if (score >= WIN_PIPES) win();
}
});
if (bird.y + (BR - 4) > PLAY_H) { bird.y = PLAY_H - (BR - 4); loseLife(); }
else if (bird.y - (BR - 4) < 0) { bird.y = BR - 4; bird.vy = 0; }
if (state === 'play') for (const p of pipes) if (hitPipe(p)) { loseLife(); break; }
}
function drawPipe(x, p) {
const topH = p.gap, botY = p.gap + p.g, glow = rgba(accent, 0.16);
const body = (yy, hh) => {
if (hh <= 0) return;
ctx.fillStyle = glow; ctx.fillRect(x - 6, yy, PW + 12, hh);
ctx.fillStyle = 'rgba(10,12,24,0.80)'; MGAPI.roundRect(ctx, x, yy, PW, hh, 6, 'rgba(10,12,24,0.80)', null);
ctx.fillStyle = accent; ctx.fillRect(x + 3, yy, 3, hh); ctx.fillRect(x + PW - 6, yy, 3, hh);
ctx.fillStyle = accentLight; ctx.fillRect(x + PW / 2 - 1, yy, 2, hh);
};
const cap = yy => {
ctx.fillStyle = glow; ctx.fillRect(x - 9, yy - 2, PW + 18, 20);
MGAPI.roundRect(ctx, x - 5, yy, PW + 10, 16, 5, accent, null);
ctx.fillStyle = accentLight; ctx.fillRect(x - 2, yy + 3, PW + 4, 3);
};
body(0, topH - 18); body(botY + 18, PLAY_H - botY - 18);
cap(topH - 18); cap(botY + 2);
}
function drawFloor(scroll) {
const gy = PLAY_H;
ctx.fillStyle = rgba(accent, 0.20); ctx.fillRect(0, gy - 3, W, 6);
ctx.fillStyle = accentLight; ctx.fillRect(0, gy - 1, W, 2);
ctx.fillStyle = '#070710'; ctx.fillRect(0, gy, W, GROUND_H);
ctx.fillStyle = rgba(accent, 0.18);
const tile = 34, off = -(((scroll % tile) + tile) % tile);
for (let x = off; x < W; x += tile) ctx.fillRect(x, gy + 6, 16, 2);
}
function render(alpha) {
let sx = 0, sy = 0;
if (shake > 0.2) { sx = (Math.random() - 0.5) * shake; sy = (Math.random() - 0.5) * shake; }
ctx.save(); ctx.translate(sx, sy);
ctx.fillStyle = skyGrad; ctx.fillRect(-12, -12, W + 24, H + 24);
// Sterne
stars.forEach(s => { ctx.globalAlpha = s.a * (0.5 + 0.5 * Math.sin(simTime * 0.004 + s.p)); ctx.fillStyle = '#cdd6ff'; ctx.fillRect(s.x, s.y, s.r, s.r); });
ctx.globalAlpha = 1;
pipes.forEach(p => drawPipe(lerp(p.px, p.x, alpha), p));
drawFloor(lerp(pWorldScroll, worldScroll, alpha));
// Flap-Püffchen
puffs.forEach(p => { ctx.globalAlpha = p.a; ctx.fillStyle = accentLight; ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); ctx.fill(); });
ctx.globalAlpha = 1;
const headY = lerp(bird.py, bird.y, alpha);
// Komet-Schweif
for (let i = trail.length - 1; i >= 0; i--) {
const t = 1 - i / trail.length;
ctx.globalAlpha = 0.28 * t;
ctx.fillStyle = accentLight;
ctx.beginPath(); ctx.arc(BIRD_X - i * 7, trail[i], (BR * 0.55) * t + 1.5, 0, Math.PI * 2); ctx.fill();
}
ctx.globalAlpha = 1;
// Figur (Sprite-Blit, rotiert)
ctx.save(); ctx.translate(BIRD_X, headY); ctx.rotate(bird.angle);
ctx.drawImage(spr, -SPR / 2, -SPR / 2, SPR, SPR); ctx.restore();
pops.forEach(p => { ctx.save(); ctx.globalAlpha = Math.max(0, p.a); MGAPI.text(ctx, '+1', p.x, p.y, { size: 18 * p.scale, color: accentLight, weight: 'bold' }); ctx.restore(); });
confetti.forEach(p => { ctx.globalAlpha = Math.max(0, p.a); ctx.fillStyle = p.c; ctx.fillRect(p.x, p.y, p.r, p.r); });
ctx.globalAlpha = 1;
ctx.restore();
// HUD
MGAPI.text(ctx, `${score} / ${WIN_PIPES}`, W / 2, 28, { size: 22, color: '#fff', weight: 'bold', family: "'Fredoka One',cursive" });
const hearts = '❤️'.repeat(Math.max(0, lives)) + '🤍'.repeat(Math.max(0, MAX_LIVES - lives));
MGAPI.text(ctx, hearts, 10, 16, { align: 'left', size: 13 });
if (state === 'ready')
MGAPI.text(ctx, '👆 Tippen oder Leertaste', W / 2, PLAY_H * 0.62, { size: 15, color: accentLight, weight: 'bold' });
if (flash > 0) { ctx.fillStyle = `rgba(255,255,255,${flash})`; ctx.fillRect(0, 0, W, H); }
if (state === 'dying' && lives <= 0) MGAPI.resultScreen(ctx, W, H, false, `${score} Hindernisse — nochmal!`);
if (state === 'won') MGAPI.resultScreen(ctx, W, H, true, `Alle ${WIN_PIPES} geschafft! 🎉`);
}
function loop(ts) {
if (stopped) return;
raf = requestAnimationFrame(loop);
if (!lastTime) lastTime = ts;
acc += Math.min(ts - lastTime, STEP * 5);
lastTime = ts;
while (acc >= STEP) { step(); acc -= STEP; }
render(acc / STEP);
}
raf = requestAnimationFrame(loop);
return {
stop() {
stopped = true; cancelAnimationFrame(raf);
clearTimeout(endTimer); clearTimeout(respawnTimer);
document.removeEventListener('keydown', onKey);
canvas.removeEventListener('mousedown', flap);
},
};
}
function launch(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
function preview(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
return { id: ID, emoji: EMOJI, name: NAME, desc: DESC, controls: CONTROLS, multi: MULTI, launch, preview };
})();

View file

@ -0,0 +1,174 @@
/**
* minigames/flappy.js
* 🐦 Flappy Bird — Klicke oder Leertaste, um durch die Röhren zu fliegen!
*/
window.MG_flappy = (function() {
const ID = 'flappy';
const EMOJI = '🐦';
const NAME = 'Flappy Bird';
const DESC = '3 Leben, 15 Hindernisse. Klick oder Leertaste zum Fliegen!';
const CONTROLS = 'Klick / Leertaste';
const MULTI = 1;
function run(wrap, W, H, cfg, onDone) {
const { canvas, ctx } = MGAPI.makeCanvas(wrap, W, H);
const theme = cfg.theme || { primary: '#06b6d4' };
const WIN_PIPES = cfg.winPipes || 15;
const MAX_LIVES = cfg.lives || 3;
const GRAV = 0.35;
const JUMP = -7;
const PW = 38;
const BASE_SPEED = 4.5; // spürbar schneller von Anfang
const SPEED_INC = 0.16;
const BASE_GAP = 175; // großzügige Lücke zu Beginn
const MIN_GAP = 135; // bleibt auch am Ende gut spielbar
const GAP_DEC = 2.7; // sanfte Verkleinerung pro Hindernis
const minTop = 35;
let bird = { y: H / 2, vy: 0 };
let pipes = [{ x: W, gap: rndGap(0) }];
let score = 0;
let speed = BASE_SPEED;
let lives = MAX_LIVES;
let dead = false;
let dying = false;
let started = false;
let stopped = false;
let raf, endTimer, respawnTimer;
function curGAP(sc) {
return Math.max(MIN_GAP, BASE_GAP - sc * GAP_DEC);
}
function rndGap(sc) {
const gap = curGAP(sc);
const maxTop = H - gap - 35;
return minTop + Math.random() * Math.max(0, maxTop - minTop);
}
const jump = () => { if (!dead) { bird.vy = JUMP; started = true; } };
const onKey = e => { if (e.code === 'Space') { e.preventDefault(); jump(); } };
document.addEventListener('keydown', onKey);
canvas.addEventListener('click', jump);
canvas.addEventListener('touchstart', e => { e.preventDefault(); jump(); }, { passive: false });
function die() {
if (dying) return;
dying = true;
dead = true;
lives--;
if (lives <= 0) {
endTimer = setTimeout(() => onDone(false), 1100);
} else {
respawnTimer = setTimeout(() => {
bird = { y: H / 2, vy: 0 };
pipes = pipes.filter(p => p.x > 120);
if (!pipes.length) pipes = [{ x: W, gap: rndGap(score) }];
dead = false;
dying = false;
}, 900);
}
}
function loop() {
if (stopped) return;
raf = requestAnimationFrame(loop);
const grad = ctx.createLinearGradient(0, 0, 0, H);
grad.addColorStop(0, '#0a0a1a');
grad.addColorStop(1, '#050508');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, W, H);
if (started && !dead) {
bird.vy += GRAV;
bird.y += bird.vy;
pipes.forEach(p => p.x -= speed);
if (pipes[pipes.length - 1].x < W - 210)
pipes.push({ x: W, gap: rndGap(score) });
pipes = pipes.filter(p => p.x > -PW);
pipes.forEach(p => {
if (p.x + PW < 50 && !p.passed) {
p.passed = true;
score++;
speed = BASE_SPEED + score * SPEED_INC;
}
});
if (bird.y < 0 || bird.y + 20 > H) die();
pipes.forEach(p => {
const g = curGAP(score);
if (50 < p.x + PW && 70 > p.x &&
(bird.y < p.gap || bird.y + 20 > p.gap + g)) die();
});
if (score >= WIN_PIPES && !endTimer)
endTimer = setTimeout(() => onDone(true), 400);
}
// Röhren
pipes.forEach(p => {
const g = curGAP(score);
MGAPI.roundRect(ctx, p.x, 0, PW, p.gap - 8, 4, `${theme.primary}cc`, null);
MGAPI.roundRect(ctx, p.x - 3, p.gap - 12, PW + 6, 12, 4, `${theme.primary}ee`, null);
MGAPI.roundRect(ctx, p.x, p.gap + g + 8, PW, H - p.gap - g - 8, 4, `${theme.primary}cc`, null);
MGAPI.roundRect(ctx, p.x - 3, p.gap + g, PW + 6, 12, 4, `${theme.primary}ee`, null);
});
// Vogel
ctx.shadowColor = dead ? '#ef4444' : theme.primary;
ctx.shadowBlur = 12;
ctx.fillStyle = dead ? '#ef4444' : theme.primary;
ctx.beginPath();
ctx.ellipse(50 + 14, bird.y + 10, 14, 10, bird.vy * 0.04, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
ctx.fillStyle = '#fff';
ctx.beginPath(); ctx.arc(50 + 20, bird.y + 7, 4, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#000';
ctx.beginPath(); ctx.arc(50 + 21, bird.y + 7, 2, 0, Math.PI * 2); ctx.fill();
// HUD
MGAPI.text(ctx, `🐦 ${score} / ${WIN_PIPES}`, 8, 14,
{ align: 'left', size: 13, color: theme.primary });
const hearts = '❤️'.repeat(lives) + '🖤'.repeat(Math.max(0, MAX_LIVES - lives));
MGAPI.text(ctx, hearts, W / 2, 14, { align: 'center', size: 12 });
MGAPI.text(ctx, `⚡ ${speed.toFixed(1)}`, W - 8, 14,
{ align: 'right', size: 11, color: 'rgba(255,255,255,0.4)' });
if (!started)
MGAPI.text(ctx, 'Klicken oder Leertaste', W / 2, H / 2 + 40,
{ size: 13, color: 'rgba(255,255,255,0.7)' });
if (dead && lives <= 0)
MGAPI.resultScreen(ctx, W, H, false, `Nur ${score} Hindernisse — nächstes Mal!`);
else if (dead && dying)
MGAPI.text(ctx, `${lives} ${lives === 1 ? 'Leben' : 'Leben'} übrig`, W / 2, H / 2,
{ size: 16, color: '#ef4444' });
if (score >= WIN_PIPES && !dead)
MGAPI.resultScreen(ctx, W, H, true, `Alle ${WIN_PIPES} Hindernisse geschafft!`);
}
raf = requestAnimationFrame(loop);
return {
stop() {
stopped = true;
cancelAnimationFrame(raf);
clearTimeout(endTimer);
clearTimeout(respawnTimer);
document.removeEventListener('keydown', onKey);
canvas.removeEventListener('click', jump);
},
};
}
function launch(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
function preview(wrap, W, H, cfg) { return run(wrap, W, H, { ...cfg, winPipes: 15, lives: 3 }, won => MGAPI.onResult(won)); }
return { id: ID, emoji: EMOJI, name: NAME, desc: DESC, controls: CONTROLS, multi: MULTI, launch, preview };
})();

457
minigames/flappy2p.js Normal file
View file

@ -0,0 +1,457 @@
/**
* minigames/flappy2p.js
* 🐦🐦 Flappy-Duell 2-Spieler "Best of 2-5", basierend auf flappy.js
* 🔵 P1 = Pfeil · 🔴 P2 = W. Beide fliegen durch dieselben Röhren.
* Stirbt einer (Wand/Röhre/Boden), gibt es einen Punkt für den anderen neue Runde.
* Spielfiguren kollidieren NICHT miteinander.
*/
window.MG_flappy2p = (function () {
const ID = 'flappy2p';
const EMOJI = '🐦🐦';
const NAME = 'Flappy-Duell';
const DESC = 'Best of 2-5 · 🔵 ↑ Pfeil oben gegen 🔴 W. Wer abstürzt, gibt einen Punkt ab.';
const CONTROLS = '↑ / W';
const MULTI = 1;
const REQUIRES = '2p';
const lerp = (a, b, t) => a + (b - a) * t;
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
const hx = (h, i) => parseInt(h.replace('#', '').substr(i, 2), 16);
function shade(hex, amt) {
const r = clamp(hx(hex, 0) + amt, 0, 255), g = clamp(hx(hex, 2) + amt, 0, 255), b = clamp(hx(hex, 4) + amt, 0, 255);
return '#' + [r, g, b].map(v => Math.round(v).toString(16).padStart(2, '0')).join('');
}
const P1_COL = '#3b82f6', P1_LIGHT = '#93c5fd';
const P2_COL = '#ef4444', P2_LIGHT = '#fca5a5';
function run(wrap, W, H, cfg, onDone) {
const { canvas, ctx } = MGAPI.makeCanvas(wrap, W, H);
// ── Einstellungen (vom mgSettings durchgereicht) ──
const BEST_OF = clamp(parseInt(cfg.bestOf) || 3, 2, 5); // 2..5
const TARGET = Math.ceil(BEST_OF / 2); // Runden bis Sieg
const BASE_SPEED = (cfg && cfg.speed) || 2.7;
const SPEED_INC = (cfg && cfg.rampSpeed === false) ? 0 : 0.07;
const BASE_GAP = (cfg && cfg.gap) || 168;
const GAP_DEC = (cfg && cfg.shrinkGap === false) ? 0 : 1.6;
const MIN_GAP = Math.max(90, BASE_GAP - 36);
const minTop = 36;
// ── Physik (pro festem 1/60-Schritt) ──
const STEP = 1000 / 60;
const GRAV = 0.42, FLAP = -7.2, PW = 46;
const GROUND_H = Math.min(54, Math.round(H * 0.13));
const PLAY_H = H - GROUND_H;
const BIRD_X = Math.round(W * 0.28);
const BR = 16;
// ── Theme-Hintergrund ──
let accent = (cfg && cfg.theme && cfg.theme.primary) || '#5ec5b8';
if (!/^#[0-9a-f]{6}$/i.test(accent)) accent = '#5ec5b8';
const accentLight = shade(accent, 70);
const accentDeep = shade(accent, -120);
const fig1Emoji = (cfg && cfg.figure) || '🐦';
const fig2Emoji = (cfg && cfg.figure2) || '🐤';
// ── Match-State ──
const scores = [0, 0];
let roundIdx = 0;
let state = 'intro'; // intro | playing | roundEnd | duelEnd
let stateTimer = 0;
let roundResult = null;
let startBtnRect = null;
let stopped = false, raf = null;
let lastTime = 0, acc = 0;
// Pre-rendered Glow-Sprites pro Spieler (einmalig)
const SPR = 72, dpr = window.devicePixelRatio || 1;
function makeSprite(emoji, glow) {
const s = document.createElement('canvas');
s.width = SPR * dpr; s.height = SPR * dpr;
const sx = s.getContext('2d'); sx.scale(dpr, dpr);
sx.textAlign = 'center'; sx.textBaseline = 'middle';
sx.font = `${Math.round(SPR * 0.55)}px serif`;
sx.shadowColor = glow; sx.shadowBlur = 18; sx.fillText(emoji, SPR/2, SPR/2);
sx.shadowBlur = 10; sx.fillText(emoji, SPR/2, SPR/2);
return s;
}
const sprP1 = makeSprite(fig1Emoji, P1_LIGHT);
const sprP2 = makeSprite(fig2Emoji, P2_LIGHT);
// Sky einmal vorrendern
const skyGrad = ctx.createLinearGradient(0, 0, 0, H);
skyGrad.addColorStop(0, '#080810');
skyGrad.addColorStop(0.6, shade(accentDeep, -40));
skyGrad.addColorStop(1, accentDeep);
// Deterministische Sterne im Hintergrund
const stars = [];
for (let i = 0; i < 34; i++)
stars.push({ x: Math.random()*W, y: Math.random()*PLAY_H, r: Math.random()<0.3?2:1, a: 0.3+Math.random()*0.5, p: Math.random()*6.28 });
// ── Runden-State ──
let birds, pipes, score, speed, worldScroll, pWorldScroll, simTime, shake, flash, puffs;
function curGAP(sc) { return Math.max(MIN_GAP, BASE_GAP - sc * GAP_DEC); }
function rndGapY(sc) {
const g = curGAP(sc), maxTop = PLAY_H - g - 40;
return minTop + Math.random() * Math.max(10, maxTop - minTop);
}
function spawnPipe() { pipes.push({ x: W + PW, px: W + PW, gap: rndGapY(score), g: curGAP(score), passed: false }); }
function makeBird(yFrac) {
const y0 = PLAY_H * yFrac;
return { y: y0, py: y0, vy: 0, angle: 0, alive: true, flapAnim: 0, trail: [] };
}
function newRound() {
birds = [ makeBird(0.4), makeBird(0.6) ];
pipes = [];
spawnPipe();
score = 0;
speed = BASE_SPEED;
worldScroll = 0; pWorldScroll = 0;
simTime = 0; shake = 0; flash = 0;
puffs = [];
acc = 0;
lastTime = 0;
state = 'playing';
stateTimer = 0;
roundResult = null;
}
function endRound(winnerIdx) {
if (winnerIdx === 0 || winnerIdx === 1) scores[winnerIdx]++;
roundResult = winnerIdx;
shake = 14; flash = 0.5;
state = 'roundEnd';
stateTimer = 0;
}
function advanceRound() {
roundIdx++;
if (scores[0] >= TARGET || scores[1] >= TARGET || roundIdx >= BEST_OF) {
state = 'duelEnd'; stateTimer = 0;
} else {
newRound();
}
}
function finalWinner() {
if (scores[0] > scores[1]) return 0;
if (scores[1] > scores[0]) return 1;
return -1;
}
function finishUp() {
if (stopped) return;
stopped = true;
cancelAnimationFrame(raf);
document.removeEventListener('keydown', onKey);
canvas.removeEventListener('mousedown', onCanvasDown);
canvas.removeEventListener('touchstart', onCanvasTouch);
onDone(finalWinner());
}
// ── Eingabe ──
function flap(i) {
if (state !== 'playing') return;
const b = birds[i]; if (!b || !b.alive) return;
b.vy = FLAP; b.flapAnim = 1;
for (let k = 0; k < 3; k++)
puffs.push({ x: BIRD_X - BR + (Math.random()-0.5)*6, y: b.y + (Math.random()-0.5)*14, r: 2, a: 0.6, col: i===0 ? P1_LIGHT : P2_LIGHT });
}
function onKey(e) {
if (state === 'intro') return; // Start nur per Klick
if (state !== 'playing') return;
const k = e.key;
if (k === 'ArrowUp') { flap(0); e.preventDefault(); }
else if (k === 'w' || k === 'W') { flap(1); e.preventDefault(); }
}
function canvasCoords(clientX, clientY) {
const r = canvas.getBoundingClientRect();
const d = window.devicePixelRatio || 1;
const x = (clientX - r.left) * (canvas.width / r.width / d);
const y = (clientY - r.top) * (canvas.height / r.height / d);
return { x, y };
}
function pointInBtn(x, y) {
return startBtnRect && x >= startBtnRect.x && x <= startBtnRect.x + startBtnRect.w
&& y >= startBtnRect.y && y <= startBtnRect.y + startBtnRect.h;
}
function onCanvasDown(e) {
const p = canvasCoords(e.clientX, e.clientY);
if (state === 'intro') {
if (pointInBtn(p.x, p.y)) newRound();
return;
}
if (state === 'playing') {
// Tap-Steuerung als Bonus (für Touch/Tablets): linke Hälfte = P2, rechte Hälfte = P1
flap(p.x < W / 2 ? 1 : 0);
}
}
function onCanvasTouch(e) {
const t = e.changedTouches && e.changedTouches[0];
if (!t) return;
const p = canvasCoords(t.clientX, t.clientY);
if (state === 'intro') {
if (pointInBtn(p.x, p.y)) { e.preventDefault(); newRound(); }
return;
}
if (state === 'playing') {
e.preventDefault();
flap(p.x < W / 2 ? 1 : 0);
}
}
document.addEventListener('keydown', onKey);
canvas.addEventListener('mousedown', onCanvasDown);
canvas.addEventListener('touchstart', onCanvasTouch, { passive: false });
function hitPipe(b, p) {
const r = BR - 4;
if (BIRD_X + r > p.x && BIRD_X - r < p.x + PW)
return b.y - r < p.gap || b.y + r > p.gap + p.g;
return false;
}
// ── Physik-Schritt (1/60) ──
function step() {
pWorldScroll = worldScroll;
simTime += STEP;
if (shake > 0) shake = Math.max(0, shake - 0.7);
if (flash > 0) flash = Math.max(0, flash - 0.04);
// Birds bewegen
for (const b of birds) {
if (!b.alive) continue;
b.py = b.y;
b.vy += GRAV;
b.y += b.vy;
b.angle = lerp(b.angle, clamp(b.vy * 0.06, -0.5, 1.4), 0.18);
b.flapAnim = Math.max(0, b.flapAnim - 0.08);
b.trail.unshift(b.y); if (b.trail.length > 8) b.trail.pop();
}
// Puff-Partikel
puffs.forEach(p => { p.r += 1.1; p.a -= 0.06; p.x -= speed; });
puffs = puffs.filter(p => p.a > 0);
// Röhren scrollen
worldScroll += speed;
pipes.forEach(p => { p.px = p.x; p.x -= speed; });
if (!pipes.length || pipes[pipes.length - 1].x < W - 210) spawnPipe();
pipes = pipes.filter(p => p.x > -PW - 10);
pipes.forEach(p => {
if (!p.passed && p.x + PW < BIRD_X) {
p.passed = true; score++;
speed = BASE_SPEED + score * SPEED_INC;
}
});
// Kollisionen pro Vogel (Vögel untereinander NICHT)
for (const b of birds) {
if (!b.alive) continue;
if (b.y + (BR - 4) > PLAY_H) { b.y = PLAY_H - (BR - 4); b.alive = false; continue; }
if (b.y - (BR - 4) < 0) { b.y = BR - 4; b.alive = false; continue; }
for (const p of pipes) { if (hitPipe(b, p)) { b.alive = false; break; } }
}
// Round-Ende?
if (!birds[0].alive || !birds[1].alive) {
let winner;
if (!birds[0].alive && !birds[1].alive) winner = -1;
else if (!birds[0].alive) winner = 1;
else winner = 0;
endRound(winner);
}
}
// ── Render ──
function drawPipe(x, p) {
const topH = p.gap, botY = p.gap + p.g, glow = `${accent}28`;
const body = (yy, hh) => {
if (hh <= 0) return;
ctx.fillStyle = glow; ctx.fillRect(x - 6, yy, PW + 12, hh);
MGAPI.roundRect(ctx, x, yy, PW, hh, 6, 'rgba(10,12,24,0.80)', null);
ctx.fillStyle = accent; ctx.fillRect(x + 3, yy, 3, hh); ctx.fillRect(x + PW - 6, yy, 3, hh);
ctx.fillStyle = accentLight; ctx.fillRect(x + PW / 2 - 1, yy, 2, hh);
};
const cap = yy => {
ctx.fillStyle = glow; ctx.fillRect(x - 9, yy - 2, PW + 18, 20);
MGAPI.roundRect(ctx, x - 5, yy, PW + 10, 16, 5, accent, null);
ctx.fillStyle = accentLight; ctx.fillRect(x - 2, yy + 3, PW + 4, 3);
};
body(0, topH - 18); body(botY + 18, PLAY_H - botY - 18);
cap(topH - 18); cap(botY + 2);
}
function drawFloor(scroll) {
const gy = PLAY_H;
ctx.fillStyle = `${accent}33`; ctx.fillRect(0, gy - 3, W, 6);
ctx.fillStyle = accentLight; ctx.fillRect(0, gy - 1, W, 2);
ctx.fillStyle = '#070710'; ctx.fillRect(0, gy, W, GROUND_H);
ctx.fillStyle = `${accent}2e`;
const tile = 34, off = -(((scroll % tile) + tile) % tile);
for (let x = off; x < W; x += tile) ctx.fillRect(x, gy + 6, 16, 2);
}
function drawStartButton() {
const bw = Math.min(320, W * 0.62), bh = 56;
const bx = (W - bw) / 2, by = H / 2 + 10;
startBtnRect = { x: bx, y: by, w: bw, h: bh };
ctx.fillStyle = 'rgba(0,0,0,0.45)';
MGAPI.roundRect(ctx, bx, by + 4, bw, bh, 14, 'rgba(0,0,0,0.45)', null);
const grd = ctx.createLinearGradient(0, by, 0, by + bh);
grd.addColorStop(0, '#7c3aed'); grd.addColorStop(1, '#5b21b6');
MGAPI.roundRect(ctx, bx, by, bw, bh, 14, grd, null);
MGAPI.roundRect(ctx, bx + 4, by + 3, bw - 8, bh / 2 - 4, 12, 'rgba(255,255,255,0.18)', null);
MGAPI.text(ctx, '▶ Duell starten', bx + bw / 2, by + bh / 2, {
size: 22, color: '#fff', weight: 'bold', family: "'Fredoka One',cursive"
});
}
function drawOverlayText(title, sub, color) {
ctx.fillStyle = 'rgba(0,0,0,0.6)'; ctx.fillRect(0, 0, W, H);
MGAPI.text(ctx, title, W/2, H/2 - 22, {
size: 28, weight:'bold', color: color || '#fff',
family: "'Fredoka One',cursive", shadow:'rgba(0,0,0,0.7)', shadowBlur: 12
});
if (sub) MGAPI.text(ctx, sub, W/2, H/2 + 18, { size: 15, color:'rgba(255,255,255,0.88)' });
}
function render(alpha) {
// Shake
let sx = 0, sy = 0;
if (shake > 0.2) { sx = (Math.random()-0.5)*shake; sy = (Math.random()-0.5)*shake; }
ctx.save(); ctx.translate(sx, sy);
// Himmel
ctx.fillStyle = skyGrad; ctx.fillRect(-12, -12, W + 24, H + 24);
// Sterne (twinkern via simTime)
stars.forEach(s => {
ctx.globalAlpha = s.a * (0.5 + 0.5*Math.sin((simTime||0)*0.004 + s.p));
ctx.fillStyle = '#cdd6ff';
ctx.fillRect(s.x, s.y, s.r, s.r);
});
ctx.globalAlpha = 1;
// Pipes + Boden
if (state !== 'intro') {
pipes.forEach(p => drawPipe(lerp(p.px, p.x, alpha), p));
drawFloor(lerp(pWorldScroll || 0, worldScroll || 0, alpha));
// Puffs
puffs.forEach(p => {
ctx.globalAlpha = p.a; ctx.fillStyle = p.col || '#fff';
ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI*2); ctx.fill();
});
ctx.globalAlpha = 1;
// Birds (mit Komet-Schweif pro Spieler)
const drawBird = (b, col, sprite, dimmed) => {
const headY = lerp(b.py, b.y, alpha);
// Schweif
for (let i = b.trail.length - 1; i >= 0; i--) {
const t = 1 - i / b.trail.length;
ctx.globalAlpha = 0.28 * t;
ctx.fillStyle = col;
ctx.beginPath(); ctx.arc(BIRD_X - i*7, b.trail[i], (BR * 0.55) * t + 1.5, 0, Math.PI*2); ctx.fill();
}
ctx.globalAlpha = dimmed ? 0.45 : 1;
ctx.save(); ctx.translate(BIRD_X, headY); ctx.rotate(b.angle);
ctx.drawImage(sprite, -SPR/2, -SPR/2, SPR, SPR);
ctx.restore();
ctx.globalAlpha = 1;
};
if (birds) {
drawBird(birds[0], P1_LIGHT, sprP1, !birds[0].alive);
drawBird(birds[1], P2_LIGHT, sprP2, !birds[1].alive);
}
} else {
// Im Intro: Boden andeuten (statisch)
drawFloor(0);
}
ctx.restore();
// HUD oben (Stil von 1P-Flappy: kompakt)
MGAPI.text(ctx, `🔵 ${scores[0]}`, 8, 14, { align:'left', size:13, color:P1_COL });
const roundLabel = state === 'duelEnd' ? 'Best of ' + BEST_OF
: `Runde ${Math.min(roundIdx+1, BEST_OF)} / ${BEST_OF}`;
MGAPI.text(ctx, roundLabel, W/2, 14, { align:'center', size:12, color:'rgba(255,255,255,0.85)' });
MGAPI.text(ctx, `${scores[1]} 🔴`, W-8, 14, { align:'right', size:13, color:P2_COL });
// Flash beim Crash
if (flash > 0) { ctx.fillStyle = `rgba(255,255,255,${flash})`; ctx.fillRect(0, 0, W, H); }
// Overlays
if (state === 'intro') {
MGAPI.text(ctx, `Flappy-Duell · Best of ${BEST_OF}`, W/2, H/2 - 56, {
size: 26, weight:'bold', color:'#fff', family:"'Fredoka One',cursive",
shadow:'rgba(0,0,0,0.5)', shadowBlur: 10
});
MGAPI.text(ctx, '🔵 ↑ Pfeil oben · 🔴 W',
W/2, H/2 - 24, { size: 14, color:'rgba(255,255,255,0.88)' });
drawStartButton();
} else if (state === 'roundEnd') {
let t, c;
if (roundResult === 0) { t = '🔵 Runde für Spieler 1!'; c = P1_LIGHT; }
else if (roundResult === 1) { t = '🔴 Runde für Spieler 2!'; c = P2_LIGHT; }
else { t = '⚖️ Beide abgestürzt!'; c = '#fde68a'; }
drawOverlayText(t, `Stand: 🔵 ${scores[0]} : ${scores[1]} 🔴`, c);
} else if (state === 'duelEnd') {
const w = finalWinner();
let t, c;
if (w === 0) { t = '🏆 Spieler 1 gewinnt!'; c = P1_LIGHT; }
else if (w === 1) { t = '🏆 Spieler 2 gewinnt!'; c = P2_LIGHT; }
else { t = '🤝 Unentschieden!'; c = '#fde68a'; }
drawOverlayText(t, `Endstand: 🔵 ${scores[0]} : ${scores[1]} 🔴`, c);
}
}
function loop(ts) {
if (stopped) return;
raf = requestAnimationFrame(loop);
if (state === 'playing') {
if (!lastTime) lastTime = ts;
acc += Math.min(ts - lastTime, STEP * 5);
lastTime = ts;
while (acc >= STEP) { step(); acc -= STEP; }
render(acc / STEP);
return;
}
// Sonstige States: stateTimer mit dt zählen, render normal
if (!lastTime) lastTime = ts;
const dt = Math.min(50, ts - lastTime);
lastTime = ts;
stateTimer += dt;
if (state === 'roundEnd' && stateTimer >= 1800) advanceRound();
else if (state === 'duelEnd' && stateTimer >= 2200) finishUp();
render(0);
}
raf = requestAnimationFrame(loop);
return {
stop() {
if (stopped) return;
stopped = true;
cancelAnimationFrame(raf);
document.removeEventListener('keydown', onKey);
canvas.removeEventListener('mousedown', onCanvasDown);
canvas.removeEventListener('touchstart', onCanvasTouch);
},
};
}
function launch(wrap, W, H, cfg) { return run(wrap, W, H, cfg, idx => MGAPI.onResult(idx)); }
function preview(wrap, W, H, cfg) { return run(wrap, W, H, cfg, idx => MGAPI.onResult(idx)); }
return { id: ID, emoji: EMOJI, name: NAME, desc: DESC, controls: CONTROLS, multi: MULTI, requires: REQUIRES, launch, preview };
})();

194
minigames/maze.js Normal file
View file

@ -0,0 +1,194 @@
/**
* minigames/maze.js
* 🌀 Labyrinth Finde den Ausgang in der Zeit!
*/
window.MG_maze = (function() {
const ID = 'maze';
const EMOJI = '🌀';
const NAME = 'Labyrinth';
const DESC = '2 Leben, 15 Sekunden pro Versuch. Pfeiltasten zum Steuern!';
const CONTROLS = 'Pfeiltasten / WASD';
const MULTI = 3;
function generateMaze(COLS, ROWS) {
const cells = Array.from({ length: ROWS }, () =>
Array.from({ length: COLS }, () => ({ n: true, s: true, e: true, w: true, visited: false }))
);
const stack = [];
let cur = { c: 0, r: 0 };
cells[0][0].visited = true;
stack.push(cur);
while (stack.length) {
const { c, r } = stack[stack.length - 1];
const neighbors = [];
if (r > 0 && !cells[r-1][c].visited) neighbors.push({ c, r: r-1, dir: 'n' });
if (r < ROWS-1 && !cells[r+1][c].visited) neighbors.push({ c, r: r+1, dir: 's' });
if (c < COLS-1 && !cells[r][c+1].visited) neighbors.push({ c: c+1, r, dir: 'e' });
if (c > 0 && !cells[r][c-1].visited) neighbors.push({ c: c-1, r, dir: 'w' });
if (!neighbors.length) { stack.pop(); continue; }
const next = neighbors[Math.floor(Math.random() * neighbors.length)];
cells[r][c][next.dir] = false;
cells[next.r][next.c][{ n:'s', s:'n', e:'w', w:'e' }[next.dir]] = false;
cells[next.r][next.c].visited = true;
stack.push({ c: next.c, r: next.r });
}
return cells;
}
function run(wrap, W, H, cfg, onDone) {
const { canvas, ctx } = MGAPI.makeCanvas(wrap, W, H);
const theme = cfg.theme || { primary: '#6366f1' };
const MAX_LIVES = 2;
const TIME_LIMIT = 15;
const COLS = 9, ROWS = 7;
const cellW = Math.floor((W - 16) / COLS);
const cellH = Math.floor((H - 48) / ROWS);
const OX = (W - COLS * cellW) / 2;
const OY = 38;
const WALL = 2;
let maze = generateMaze(COLS, ROWS);
let px = 0, py = 0;
let lives = MAX_LIVES;
let timeLeft = TIME_LIMIT;
let startTs = null;
let won = false;
let gameOver = false;
let stopped = false;
let raf, endTimer;
function resetRound(ts) {
maze = generateMaze(COLS, ROWS);
px = 0; py = 0;
startTs = ts;
timeLeft = TIME_LIMIT;
}
function tryMove(dc, dr) {
if (won || gameOver) return;
const cell = maze[py][px];
const dir = dc === 1 ? 'e' : dc === -1 ? 'w' : dr === 1 ? 's' : 'n';
if (cell[dir]) return;
px += dc; py += dr;
if (px === COLS - 1 && py === ROWS - 1 && !endTimer) {
won = true;
endTimer = setTimeout(() => onDone(true), 900);
}
}
const held = {};
const onKey = e => {
const map = {
ArrowUp:[0,-1], ArrowDown:[0,1], ArrowLeft:[-1,0], ArrowRight:[1,0],
w:[0,-1], s:[0,1], a:[-1,0], d:[1,0],
};
const d = map[e.key];
if (!d) return;
e.preventDefault();
if (e.type === 'keydown') { if (!held[e.key]) tryMove(d[0], d[1]); held[e.key] = true; }
else held[e.key] = false;
};
let touchStart = null;
canvas.addEventListener('touchstart', e => {
touchStart = { x: e.touches[0].clientX, y: e.touches[0].clientY };
}, { passive: true });
canvas.addEventListener('touchend', e => {
if (!touchStart) return;
const dx = e.changedTouches[0].clientX - touchStart.x;
const dy = e.changedTouches[0].clientY - touchStart.y;
Math.abs(dx) > Math.abs(dy) ? tryMove(dx > 0 ? 1 : -1, 0) : tryMove(0, dy > 0 ? 1 : -1);
}, { passive: true });
document.addEventListener('keydown', onKey);
document.addEventListener('keyup', onKey);
function drawMaze() {
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const x = OX + c * cellW, y = OY + r * cellH;
const cell = maze[r][c];
ctx.strokeStyle = `${theme.primary}88`;
ctx.lineWidth = WALL;
if (cell.n && r === 0) { ctx.beginPath(); ctx.moveTo(x,y); ctx.lineTo(x+cellW,y); ctx.stroke(); }
if (cell.w && c === 0) { ctx.beginPath(); ctx.moveTo(x,y); ctx.lineTo(x,y+cellH); ctx.stroke(); }
if (cell.s) { ctx.beginPath(); ctx.moveTo(x,y+cellH); ctx.lineTo(x+cellW,y+cellH); ctx.stroke(); }
if (cell.e) { ctx.beginPath(); ctx.moveTo(x+cellW,y); ctx.lineTo(x+cellW,y+cellH); ctx.stroke(); }
}
}
}
function loop(ts) {
if (stopped) return;
raf = requestAnimationFrame(loop);
if (startTs === null) startTs = ts;
// Timer
if (!won && !gameOver) {
timeLeft = Math.max(0, TIME_LIMIT - (ts - startTs) / 1000);
if (timeLeft <= 0) {
lives--;
if (lives <= 0) {
gameOver = true;
if (!endTimer) endTimer = setTimeout(() => onDone(false), 1100);
} else {
resetRound(ts);
}
}
}
ctx.fillStyle = '#050508';
ctx.fillRect(0, 0, W, H);
MGAPI.roundRect(ctx, OX, OY, cellW, cellH, 4, 'rgba(16,185,129,0.2)', null);
MGAPI.roundRect(ctx, OX+(COLS-1)*cellW, OY+(ROWS-1)*cellH, cellW, cellH, 4, 'rgba(245,166,35,0.25)', null);
drawMaze();
ctx.font = '14px serif'; ctx.textAlign = 'center';
ctx.fillText('🟢', OX + cellW/2, OY + cellH/2 + 5);
ctx.fillText('🏁', OX + (COLS-0.5)*cellW, OY + (ROWS-0.5)*cellH + 5);
// Spieler
const plX = OX + px * cellW + cellW / 2;
const plY = OY + py * cellH + cellH / 2;
ctx.shadowColor = theme.primary; ctx.shadowBlur = 14;
ctx.fillStyle = theme.primary;
ctx.beginPath();
ctx.arc(plX, plY, Math.min(cellW, cellH) * 0.32, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
// HUD: Timer + Herzen
const tSec = Math.ceil(timeLeft);
const tColor = timeLeft < 5 ? '#ef4444' : timeLeft < 8 ? '#f59e0b' : theme.primary;
MGAPI.text(ctx, `${tSec}s`, W / 2, 16, { size: 13, color: tColor });
const hearts = '❤️'.repeat(lives) + '🖤'.repeat(Math.max(0, MAX_LIVES - lives));
MGAPI.text(ctx, hearts, W - 10, 16, { align: 'right', size: 12 });
MGAPI.text(ctx, '← ↑ → ↓', 10, 16, { align: 'left', size: 10, color: 'rgba(255,255,255,0.3)' });
if (won) MGAPI.resultScreen(ctx, W, H, true, 'Ausgang gefunden! ⏱ ' + (TIME_LIMIT - timeLeft).toFixed(1) + 's');
if (gameOver) MGAPI.resultScreen(ctx, W, H, false, 'Zeit abgelaufen — nächstes Mal!');
}
raf = requestAnimationFrame(loop);
return {
stop() {
stopped = true;
cancelAnimationFrame(raf);
clearTimeout(endTimer);
document.removeEventListener('keydown', onKey);
document.removeEventListener('keyup', onKey);
},
};
}
function launch(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
function preview(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
return { id: ID, emoji: EMOJI, name: NAME, desc: DESC, controls: CONTROLS, multi: MULTI, launch, preview };
})();

142
minigames/memory.js Normal file
View file

@ -0,0 +1,142 @@
/**
* minigames/memory.js
* 🃏 Memory Finde alle Paare, so schnell wie möglich!
*/
window.MG_memory = (function() {
const ID = 'memory';
const EMOJI = '🃏';
const NAME = 'Memory';
const DESC = 'Finde alle Paare — so schnell wie möglich!';
const CONTROLS = 'Mausklick / Tippen';
const MULTI = 1;
function run(wrap, W, H, cfg, onDone) {
const { canvas, ctx } = MGAPI.makeCanvas(wrap, W, H);
const theme = cfg.theme || { primary: '#8b5cf6' };
const EMOJIS = cfg.emojis || ['🐱','🐶','🦊','🐸','🦋','🐠'];
const all = [...EMOJIS, ...EMOJIS].sort(() => Math.random() - 0.5);
let cards = all.map((e, i) => ({ e, i, open: false }));
let flipped = [], matched = [], checking = false, moves = 0;
let stopped = false, raf, startTime = null, elapsed = 0;
// Layout: 4×3
const COLS = 4, ROWS = Math.ceil(all.length / 4);
const PAD = 8;
const cw = Math.floor((W - PAD * (COLS + 1)) / COLS);
const ch = Math.floor((H - PAD * (ROWS + 2) - 24) / ROWS);
const ox = (W - COLS * cw - PAD * (COLS - 1)) / 2;
const oy = 30;
function cardAt(mx, my) {
for (let ri = 0; ri < ROWS; ri++) {
for (let ci = 0; ci < COLS; ci++) {
const idx = ri * COLS + ci;
if (idx >= cards.length) continue;
const x = ox + ci * (cw + PAD);
const y = oy + ri * (ch + PAD);
if (mx >= x && mx < x + cw && my >= y && my < y + ch) return cards[idx];
}
}
return null;
}
function onClick(e) {
if (checking) return;
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width / (window.devicePixelRatio || 1);
const scaleY = canvas.height / rect.height / (window.devicePixelRatio || 1);
const mx = (e.clientX - rect.left) * scaleX;
const my = (e.clientY - rect.top) * scaleY;
const card = cardAt(mx, my);
if (!card || card.open || matched.includes(card.i)) return;
if (!startTime) startTime = performance.now();
card.open = true;
flipped.push(card);
if (flipped.length === 2) {
moves++;
checking = true;
setTimeout(() => {
if (flipped[0].e === flipped[1].e) {
matched.push(flipped[0].i, flipped[1].i);
if (matched.length === cards.length) {
setTimeout(() => onDone(true), 600);
}
} else {
flipped.forEach(c => c.open = false);
}
flipped = [];
checking = false;
}, 700);
}
}
canvas.addEventListener('click', onClick);
// Karten-Flip-Animation (progress 01)
const flipAnim = new Map(); // card.i → { prog, dir }
function loop(ts) {
if (stopped) return;
raf = requestAnimationFrame(loop);
if (startTime) elapsed = (performance.now() - startTime) / 1000;
ctx.fillStyle = '#050508';
ctx.fillRect(0, 0, W, H);
// HUD
MGAPI.text(ctx, `🃏 ${matched.length / 2} / ${EMOJIS.length} | Züge: ${moves}`, W / 2, 16,
{ size: 12, color: theme.primary });
// Karten zeichnen
for (let ri = 0; ri < ROWS; ri++) {
for (let ci = 0; ci < COLS; ci++) {
const idx = ri * COLS + ci;
if (idx >= cards.length) continue;
const card = cards[idx];
const x = ox + ci * (cw + PAD);
const y = oy + ri * (ch + PAD);
const isM = matched.includes(card.i);
const isOpen = card.open || isM;
if (isM) {
MGAPI.roundRect(ctx, x, y, cw, ch, 8,
'rgba(16,185,129,0.15)', '#10b981');
} else if (isOpen) {
MGAPI.roundRect(ctx, x, y, cw, ch, 8,
`${theme.primary}22`, theme.primary);
} else {
MGAPI.roundRect(ctx, x, y, cw, ch, 8,
'rgba(255,255,255,0.04)', 'rgba(255,255,255,0.1)');
}
if (isOpen) {
ctx.font = `${Math.min(cw, ch) * 0.55}px serif`;
ctx.textAlign = 'center';
ctx.fillText(card.e, x + cw / 2, y + ch / 2 + ch * 0.15);
} else {
MGAPI.text(ctx, '?', x + cw / 2, y + ch / 2,
{ size: Math.floor(ch * 0.35), color: 'rgba(255,255,255,0.2)' });
}
}
}
}
raf = requestAnimationFrame(loop);
return {
stop() {
stopped = true;
cancelAnimationFrame(raf);
canvas.removeEventListener('click', onClick);
},
};
}
function launch(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
function preview(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
return { id: ID, emoji: EMOJI, name: NAME, desc: DESC, controls: CONTROLS, multi: MULTI, launch, preview };
})();

137
minigames/puzzle.js Normal file
View file

@ -0,0 +1,137 @@
/**
* minigames/puzzle.js
* 🧩 Zahlen-Puzzle Schiebe die Kacheln in die richtige Reihenfolge! (15-Puzzle)
*/
window.MG_puzzle = (function() {
const ID = 'puzzle';
const EMOJI = '🧩';
const NAME = 'Zahlen-Puzzle';
const DESC = 'Schiebe die Kacheln in die richtige Reihenfolge (18)!';
const CONTROLS = 'Mausklick / Pfeiltasten';
const MULTI = 1;
function run(wrap, W, H, cfg, onDone) {
const { canvas, ctx } = MGAPI.makeCanvas(wrap, W, H);
const theme = cfg.theme || { primary: '#14b8a6' };
const SIZE = 3; // 3×3
const PAD = 20;
const cellW = Math.floor((W - PAD * 2) / SIZE);
const cellH = Math.floor((H - PAD * 2 - 30) / SIZE);
const OX = (W - SIZE * cellW) / 2;
const OY = 30 + (H - 30 - SIZE * cellH) / 2;
// Ziel: 1 2 3 / 4 5 6 / 7 8 _
const GOAL = [1,2,3,4,5,6,7,8,0];
let board = [...GOAL];
let moves = 0;
let stopped = false;
let raf, endTimer, solved = false;
// Mischeln (100 zufällige Züge)
function shuffle() {
for (let i = 0; i < 100; i++) {
const ei = board.indexOf(0);
const er = Math.floor(ei / SIZE), ec = ei % SIZE;
const nbr = [];
if (er > 0) nbr.push(ei - SIZE);
if (er < SIZE-1) nbr.push(ei + SIZE);
if (ec > 0) nbr.push(ei - 1);
if (ec < SIZE-1) nbr.push(ei + 1);
const ni = nbr[Math.floor(Math.random() * nbr.length)];
[board[ei], board[ni]] = [board[ni], board[ei]];
}
}
shuffle();
function slideAt(idx) {
if (solved) return;
const ei = board.indexOf(0);
const er = Math.floor(ei / SIZE), ec = ei % SIZE;
const tr = Math.floor(idx / SIZE), tc = idx % SIZE;
const adj = (er === tr && Math.abs(ec - tc) === 1) ||
(ec === tc && Math.abs(er - tr) === 1);
if (!adj) return;
[board[ei], board[idx]] = [board[idx], board[ei]];
moves++;
if (board.join() === GOAL.join()) {
solved = true;
endTimer = setTimeout(() => onDone(true), 900);
}
}
canvas.addEventListener('click', e => {
const rect = canvas.getBoundingClientRect();
const mx = (e.clientX - rect.left) * (canvas.width / rect.width / (window.devicePixelRatio || 1));
const my = (e.clientY - rect.top) * (canvas.height / rect.height);
for (let i = 0; i < SIZE * SIZE; i++) {
const c = i % SIZE, r = Math.floor(i / SIZE);
const x = OX + c * cellW, y = OY + r * cellH;
if (mx >= x && mx < x + cellW - 4 && my >= y && my < y + cellH - 4)
slideAt(i);
}
});
const onKey = e => {
const ei = board.indexOf(0);
const map = { ArrowUp: SIZE, ArrowDown: -SIZE, ArrowLeft: 1, ArrowRight: -1 };
const di = map[e.key];
if (di && ei + di >= 0 && ei + di < SIZE * SIZE) {
e.preventDefault();
slideAt(ei + di);
}
};
document.addEventListener('keydown', onKey);
function loop() {
if (stopped) return;
raf = requestAnimationFrame(loop);
ctx.fillStyle = '#050508';
ctx.fillRect(0, 0, W, H);
MGAPI.text(ctx, `🧩 Züge: ${moves}`, W / 2, 18, { size: 12, color: theme.primary });
for (let i = 0; i < SIZE * SIZE; i++) {
const val = board[i];
const c = i % SIZE, r = Math.floor(i / SIZE);
const x = OX + c * cellW, y = OY + r * cellH;
if (val === 0) {
// Leerfeld
MGAPI.roundRect(ctx, x+2, y+2, cellW-8, cellH-8, 8, 'rgba(255,255,255,0.03)', null);
continue;
}
const isCorrect = val === GOAL[i];
MGAPI.roundRect(ctx, x+2, y+2, cellW-8, cellH-8, 10,
isCorrect ? `${theme.primary}33` : 'rgba(255,255,255,0.07)',
isCorrect ? theme.primary : 'rgba(255,255,255,0.15)');
MGAPI.text(ctx, String(val), x + cellW/2 - 2, y + cellH/2 + 2,
{ size: Math.floor(cellH * 0.38), family: "'Fredoka One',cursive",
color: isCorrect ? theme.primary : '#fff' });
}
if (solved)
MGAPI.resultScreen(ctx, W, H, true, `In ${moves} Zügen gelöst!`);
}
raf = requestAnimationFrame(loop);
return {
stop() {
stopped = true;
clearTimeout(endTimer);
cancelAnimationFrame(raf);
document.removeEventListener('keydown', onKey);
},
};
}
function launch(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
function preview(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
return { id: ID, emoji: EMOJI, name: NAME, desc: DESC, controls: CONTROLS, multi: MULTI, launch, preview };
})();

116
minigames/quiz.js Normal file
View file

@ -0,0 +1,116 @@
/**
* minigames/quiz.js
* Quiz Beantworte die Frage richtig!
*/
window.MG_quiz = (function() {
const ID = 'quiz';
const EMOJI = '❓';
const NAME = 'Quiz';
const DESC = 'Beantworte die Frage richtig!';
const CONTROLS = 'Mausklick / Tippen';
const MULTI = 99; // unbegrenzt, je Quiz-Feld eine Frage
function run(wrap, W, H, cfg, onDone) {
const theme = cfg.theme || { primary: '#f59e0b' };
const quizData = cfg.quizData || [];
const fieldIdx = cfg.fieldIndex ?? null;
// Passende Frage suchen
let q = null;
if (fieldIdx !== null) q = quizData.find(d => d.fieldIndex === fieldIdx);
if (!q && quizData.length > 0) q = quizData[Math.floor(Math.random() * quizData.length)];
// Fallback wenn keine Frage hinterlegt
if (!q || !q.question) {
wrap.innerHTML = `
<div style="text-align:center;padding:40px 20px;font-family:'Nunito',sans-serif">
<div style="font-size:3rem;margin-bottom:12px"></div>
<div style="color:#a7a3c2;font-size:14px;margin-bottom:20px">Keine Quiz-Frage für dieses Feld hinterlegt.</div>
<button onclick="MGAPI.onResult(true)"
style="background:linear-gradient(135deg,#7c3aed,#f5a623);color:#fff;font-family:'Fredoka One',cursive;
font-size:1rem;border:none;border-radius:10px;padding:12px 28px;cursor:pointer">
Trotzdem bestanden!
</button>
</div>`;
return { stop() {} };
}
// HTML-Quiz (kein Canvas — besser lesbar)
const answers = q.answers || [];
const correct = q.correct ?? 0;
const COLORS = ['#7c3aed','#2563eb','#059669','#d97706'];
const LABELS = ['A','B','C','D'];
const container = document.createElement('div');
container.style.cssText = `
display:flex;flex-direction:column;gap:10px;padding:16px;
font-family:'Nunito',sans-serif;width:100%;box-sizing:border-box;
`;
// Frage
const qEl = document.createElement('div');
qEl.style.cssText = `
background:rgba(255,255,255,0.05);border-radius:12px;padding:16px;
color:#fff;font-size:15px;font-weight:700;line-height:1.5;text-align:center;
`;
qEl.textContent = q.question;
container.appendChild(qEl);
// Antworten
let answered = false;
answers.forEach((a, i) => {
if (!a) return;
const btn = document.createElement('button');
btn.style.cssText = `
background:${COLORS[i] || '#333'}22;border:2px solid ${COLORS[i] || '#333'}88;
color:#fff;border-radius:10px;padding:12px 16px;cursor:pointer;
font-family:'Nunito',sans-serif;font-size:13px;font-weight:700;
text-align:left;transition:all 0.2s;display:flex;gap:10px;align-items:center;
`;
// XSS-sicher: Label per DOM, Antwort als reiner Textknoten
const lbl = document.createElement('span');
lbl.style.cssText = `background:${COLORS[i]};border-radius:6px;padding:2px 8px;font-size:12px`;
lbl.textContent = LABELS[i];
btn.appendChild(lbl);
btn.appendChild(document.createTextNode(' ' + a));
btn.addEventListener('click', () => {
if (answered) return;
answered = true;
const won = (i === correct);
// Feedback-Farben
answers.forEach((_, j) => {
const b = container.querySelectorAll('button')[j];
if (!b) return;
if (j === correct) {
b.style.background = 'rgba(16,185,129,0.3)';
b.style.borderColor = '#10b981';
} else if (j === i && !won) {
b.style.background = 'rgba(239,68,68,0.3)';
b.style.borderColor = '#ef4444';
}
b.style.cursor = 'default';
});
// Ergebnis-Text
const result = document.createElement('div');
result.style.cssText = `
text-align:center;font-family:'Fredoka One',cursive;font-size:1.2rem;
padding:10px;color:${won ? '#10b981' : '#ef4444'};
`;
result.textContent = won ? '🎉 Richtig!' : `❌ Falsch! Richtig wäre: ${answers[correct]}`;
container.appendChild(result);
setTimeout(() => onDone(won), 1400);
});
container.appendChild(btn);
});
wrap.appendChild(container);
return { stop() { wrap.innerHTML = ''; } };
}
function launch(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
function preview(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
return { id: ID, emoji: EMOJI, name: NAME, desc: DESC, controls: CONTROLS, multi: MULTI, launch, preview };
})();

144
minigames/reaction.js Normal file
View file

@ -0,0 +1,144 @@
/**
* minigames/reaction.js
* Reaktionstest Drück den Knopf so schnell wie möglich!
*/
window.MG_reaction = (function() {
const ID = 'reaction';
const EMOJI = '⚡';
const NAME = 'Reaktionstest';
const DESC = 'Wenn der Kreis GRÜN wird — so schnell wie möglich klicken!';
const CONTROLS = 'Klick / Leertaste';
const MULTI = 3;
function run(wrap, W, H, cfg, onDone) {
const { canvas, ctx } = MGAPI.makeCanvas(wrap, W, H);
const theme = cfg.theme || { primary: '#ef4444' };
const ROUNDS = 3;
const WIN_MS = 600;
let phase = 'wait';
let waitEnd = 0;
let reStart = 0;
let times = [];
let best = Infinity;
let stopped = false;
let raf, endTimer;
function nextWait() {
phase = 'wait';
waitEnd = performance.now() + (2 + Math.random() * 3) * 1000;
}
nextWait();
const react = () => {
if (phase === 'ready') {
const t = performance.now() - reStart;
times.push(t);
best = Math.min(best, t);
if (times.length >= ROUNDS) {
clearTimeout(endTimer);
endTimer = setTimeout(() => onDone(best < WIN_MS), 800);
phase = 'done';
} else {
nextWait();
}
} else if (phase === 'wait') {
phase = 'toosoon';
setTimeout(nextWait, 1000);
}
};
const onKey = e => { if (e.code === 'Space') { e.preventDefault(); react(); } };
document.addEventListener('keydown', onKey);
canvas.addEventListener('click', react);
function loop(ts) {
if (stopped) return;
raf = requestAnimationFrame(loop);
if (phase === 'wait' && ts > waitEnd) {
phase = 'ready';
reStart = performance.now();
}
// Hintergrund
const bg = phase === 'ready' ? '#14532d'
: phase === 'toosoon' ? '#7f1d1d'
: phase === 'done' ? '#0f0e17'
: '#050508';
ctx.fillStyle = bg;
ctx.fillRect(0, 0, W, H);
const cx = W / 2, cy = H / 2;
if (phase === 'wait') {
// Pulsierender Kreis (rot = warten)
const pulse = 0.85 + 0.15 * Math.sin(ts * 0.003);
ctx.beginPath();
ctx.arc(cx, cy, 60 * pulse, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(239,68,68,0.2)';
ctx.fill();
ctx.strokeStyle = '#ef444488';
ctx.lineWidth = 3;
ctx.stroke();
MGAPI.text(ctx, '⏳', cx, cy - 6, { size: 36 });
MGAPI.text(ctx, 'Warte...', cx, cy + 44, { size: 14, color: '#a7a3c2' });
MGAPI.text(ctx, `${times.length} / ${ROUNDS} Runden`, cx, H - 20, { size: 12, color: '#64748b' });
}
if (phase === 'ready') {
// Leuchtender grüner Kreis
ctx.shadowColor = '#22c55e';
ctx.shadowBlur = 40;
ctx.beginPath();
ctx.arc(cx, cy, 65, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(22,163,74,0.4)';
ctx.fill();
ctx.strokeStyle = '#22c55e';
ctx.lineWidth = 4;
ctx.stroke();
ctx.shadowBlur = 0;
MGAPI.text(ctx, '⚡', cx, cy - 8, { size: 44 });
MGAPI.text(ctx, 'JETZT!', cx, cy + 48,
{ size: 22, family: "'Fredoka One',cursive", color: '#fff', shadow: '#22c55e', shadowBlur: 15 });
MGAPI.text(ctx, 'Klick oder Leertaste!', cx, cy + 78, { size: 13, color: 'rgba(255,255,255,0.6)' });
}
if (phase === 'toosoon') {
MGAPI.text(ctx, '😅', cx, cy - 10, { size: 48 });
MGAPI.text(ctx, 'Zu früh!', cx, cy + 46,
{ size: 22, family: "'Fredoka One',cursive", color: '#fca5a5' });
}
if (phase === 'done') {
MGAPI.resultScreen(ctx, W, H, best < WIN_MS,
best < WIN_MS ? `${Math.round(best)} ms — super schnell!` : `${Math.round(best)} ms — noch etwas langsam`);
}
// Statistik
if (times.length > 0 && phase !== 'done') {
const last = times[times.length - 1];
MGAPI.text(ctx, `Letzte: ${Math.round(last)} ms | Beste: ${Math.round(best)} ms`,
cx, H - 14, { size: 11, color: 'rgba(255,255,255,0.4)' });
}
}
raf = requestAnimationFrame(loop);
return {
stop() {
stopped = true;
cancelAnimationFrame(raf);
clearTimeout(endTimer);
document.removeEventListener('keydown', onKey);
canvas.removeEventListener('click', react);
},
};
}
function launch(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
function preview(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
return { id: ID, emoji: EMOJI, name: NAME, desc: DESC, controls: CONTROLS, multi: MULTI, launch, preview };
})();

172
minigames/simon.js Normal file
View file

@ -0,0 +1,172 @@
/**
* minigames/simon.js
* 🔴 Simon Says Merke und wiederhole die Farb-Sequenz!
*/
window.MG_simon = (function() {
const ID = 'simon';
const EMOJI = '🔴';
const NAME = 'Simon Says';
const DESC = 'Merke die Farb-Reihenfolge und tippe sie nach!';
const CONTROLS = 'Mausklick / Tippen';
const MULTI = 3;
const COLORS = [
{ id: 0, fill: '#22c55e', glow: 'rgba(34,197,94,0.6)', label: '🟢' },
{ id: 1, fill: '#ef4444', glow: 'rgba(239,68,68,0.6)', label: '🔴' },
{ id: 2, fill: '#3b82f6', glow: 'rgba(59,130,246,0.6)', label: '🔵' },
{ id: 3, fill: '#f59e0b', glow: 'rgba(245,158,11,0.6)', label: '🟡' },
];
function run(wrap, W, H, cfg, onDone) {
const { canvas, ctx } = MGAPI.makeCanvas(wrap, W, H);
const WIN_ROUNDS = cfg.winRounds || 4;
// Layout: 2×2 Grid
const PAD = 16;
const GW = (W - PAD * 3) / 2;
const GH = (H - PAD * 3 - 32) / 2;
const POS = [
{ x: PAD, y: 32 + PAD },
{ x: PAD*2+GW, y: 32 + PAD },
{ x: PAD, y: 32 + PAD*2 + GH },
{ x: PAD*2+GW, y: 32 + PAD*2 + GH },
];
let sequence = [];
let userSeq = [];
let pressedIdx = -1;
let showIdx = -1; // welcher Schritt der Anzeige läuft
let phase = 'show'; // 'show' | 'input' | 'result'
let litIdx = -1;
let stopped = false;
let raf, stepTimer, endTimer, result = null;
function addStep() { sequence.push(Math.floor(Math.random() * 4)); }
function startShow() {
phase = 'show';
litIdx = -1;
showIdx = 0;
userSeq = [];
stepTimer = setInterval(() => {
litIdx = (litIdx === -1) ? sequence[showIdx] : -1;
if (litIdx === -1) {
showIdx++;
if (showIdx >= sequence.length) {
clearInterval(stepTimer);
litIdx = -1;
phase = 'input';
}
}
}, 500);
}
function checkInput(colorId) {
if (phase !== 'input') return;
pressedIdx = colorId;
setTimeout(() => { pressedIdx = -1; }, 200);
userSeq.push(colorId);
const idx = userSeq.length - 1;
if (userSeq[idx] !== sequence[idx]) {
// Falsch
clearTimeout(endTimer);
result = 'lose';
endTimer = setTimeout(() => onDone(false), 1000);
phase = 'result';
return;
}
if (userSeq.length === sequence.length) {
if (sequence.length >= WIN_ROUNDS) {
clearTimeout(endTimer);
result = 'win';
endTimer = setTimeout(() => onDone(true), 900);
phase = 'result';
} else {
// Nächste Runde
phase = 'wait';
setTimeout(() => {
addStep();
startShow();
}, 800);
}
}
}
// Klick auf Farb-Button
canvas.addEventListener('click', e => {
if (phase !== 'input') return;
const rect = canvas.getBoundingClientRect();
const mx = (e.clientX - rect.left) * (canvas.width / rect.width / (window.devicePixelRatio || 1));
const my = (e.clientY - rect.top) * (canvas.height / rect.height / (window.devicePixelRatio || 1));
POS.forEach((p, i) => {
if (mx >= p.x && mx < p.x + GW && my >= p.y && my < p.y + GH)
checkInput(i);
});
});
// Starten
addStep();
startShow();
function loop() {
if (stopped) return;
raf = requestAnimationFrame(loop);
ctx.fillStyle = '#050508';
ctx.fillRect(0, 0, W, H);
// Felder zeichnen
POS.forEach((p, i) => {
const col = COLORS[i];
const isLit = litIdx === i;
const inUser = pressedIdx === i;
ctx.shadowColor = isLit || inUser ? col.glow : 'transparent';
ctx.shadowBlur = isLit || inUser ? 30 : 0;
MGAPI.roundRect(ctx, p.x, p.y, GW, GH, 12,
isLit || inUser ? col.fill : `${col.fill}44`,
isLit || inUser ? col.fill : `${col.fill}88`);
ctx.shadowBlur = 0;
// Emoji mittig
ctx.font = `${Math.min(GW, GH) * 0.4}px serif`;
ctx.textAlign = 'center';
ctx.fillText(col.label, p.x + GW / 2, p.y + GH / 2 + GH * 0.14);
});
// HUD
const phaseText = phase === 'show' ? '👀 Schau zu...'
: phase === 'input' ? '👆 Deine Runde!'
: phase === 'wait' ? '✅ Richtig!'
: '';
MGAPI.text(ctx, phaseText, W / 2, 18,
{ size: 13, color: phase === 'input' ? '#f59e0b' : '#a7a3c2' });
MGAPI.text(ctx, `Runde ${sequence.length} / ${WIN_ROUNDS}`, W / 2, H - 10,
{ size: 11, color: 'rgba(255,255,255,0.35)' });
if (result)
MGAPI.resultScreen(ctx, W, H, result === 'win',
result === 'win' ? `${sequence.length} Runden gemeistert!` : 'Falsche Farbe!');
}
raf = requestAnimationFrame(loop);
return {
stop() {
stopped = true;
clearInterval(stepTimer);
clearTimeout(endTimer);
cancelAnimationFrame(raf);
},
};
}
function launch(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
function preview(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
return { id: ID, emoji: EMOJI, name: NAME, desc: DESC, controls: CONTROLS, multi: MULTI, launch, preview };
})();

204
minigames/snake.js Normal file
View file

@ -0,0 +1,204 @@
/**
* minigames/snake.js
* 🐍 Snake Steuere die Schlange, friss 10 Äpfel ohne gegen die Wand zu fahren
*/
window.MG_snake = (function() {
const ID = 'snake';
const EMOJI = '🐍';
const NAME = 'Snake';
const DESC = '3 Leben, 15 Äpfel sammeln. Pfeiltasten oder WASD.';
const CONTROLS = 'Pfeiltasten / WASD';
const MULTI = 1;
function run(wrap, W, H, cfg, onDone) {
const { canvas, ctx } = MGAPI.makeCanvas(wrap, W, H);
const SZ = 20;
const COLS = Math.floor(W / SZ);
const ROWS = Math.floor(H / SZ);
const WIN_SCORE = cfg.winScore || 15;
const MAX_LIVES = cfg.lives || 3;
const theme = cfg.theme || { primary: '#10b981', glow: 'rgba(16,185,129,0.4)' };
// Startgeschwindigkeit 160ms, pro Apfel -12ms, Minimum 55ms
const BASE_INTERVAL = 160;
const SPEED_INC = 12;
const MIN_INTERVAL = 55;
function interval(sc) {
return Math.max(MIN_INTERVAL, BASE_INTERVAL - sc * SPEED_INC);
}
function rndFood(snk) {
let f;
do { f = { x: Math.floor(Math.random() * COLS), y: Math.floor(Math.random() * ROWS) }; }
while (snk.some(s => s.x === f.x && s.y === f.y));
return f;
}
function startSnake() {
return [{ x: 5, y: 5 }, { x: 4, y: 5 }, { x: 3, y: 5 }];
}
let snake = startSnake();
let dir = { x: 1, y: 0 };
let nextDir = { x: 1, y: 0 };
let food = rndFood(snake);
let score = 0;
let lives = MAX_LIVES;
let dead = false;
let dying = false;
let stopped = false;
let raf, endTimer, respawnTimer, last = 0;
function onKey(e) {
const map = {
ArrowUp: { x: 0, y: -1 }, w: { x: 0, y: -1 },
ArrowDown: { x: 0, y: 1 }, s: { x: 0, y: 1 },
ArrowLeft: { x: -1, y: 0 }, a: { x: -1, y: 0 },
ArrowRight: { x: 1, y: 0 }, d: { x: 1, y: 0 },
};
const d = map[e.key];
if (d && (d.x !== -dir.x || d.y !== -dir.y)) {
e.preventDefault();
nextDir = d;
}
}
document.addEventListener('keydown', onKey);
let touchStart = null;
canvas.addEventListener('touchstart', e => {
touchStart = { x: e.touches[0].clientX, y: e.touches[0].clientY };
}, { passive: true });
canvas.addEventListener('touchend', e => {
if (!touchStart) return;
const dx = e.changedTouches[0].clientX - touchStart.x;
const dy = e.changedTouches[0].clientY - touchStart.y;
if (Math.abs(dx) > Math.abs(dy))
nextDir = dx > 0 ? { x: 1, y: 0 } : { x: -1, y: 0 };
else
nextDir = dy > 0 ? { x: 0, y: 1 } : { x: 0, y: -1 };
}, { passive: true });
function die() {
if (dying) return;
dying = true;
dead = true;
lives--;
if (lives <= 0) {
if (!endTimer) endTimer = setTimeout(() => onDone(false), 1300);
} else {
respawnTimer = setTimeout(() => {
snake = startSnake();
dir = { x: 1, y: 0 };
nextDir = { x: 1, y: 0 };
food = rndFood(snake);
dead = false;
dying = false;
last = 0;
}, 1000);
}
}
function drawGrid() {
ctx.strokeStyle = 'rgba(255,255,255,0.04)';
ctx.lineWidth = 0.5;
for (let x = 0; x < COLS; x++)
for (let y = 0; y < ROWS; y++)
ctx.strokeRect(x * SZ, y * SZ, SZ, SZ);
}
function loop(ts) {
if (stopped) return;
raf = requestAnimationFrame(loop);
// Zeichnen immer, Spiellogik nur im Takt
const tick = ts - last >= interval(score);
if (tick && !dead) {
last = ts;
dir = nextDir;
const head = { x: snake[0].x + dir.x, y: snake[0].y + dir.y };
if (
head.x < 0 || head.x >= COLS ||
head.y < 0 || head.y >= ROWS ||
snake.some(s => s.x === head.x && s.y === head.y)
) {
die();
} else {
snake.unshift(head);
if (head.x === food.x && head.y === food.y) {
score++;
food = rndFood(snake);
if (score >= WIN_SCORE && !endTimer)
endTimer = setTimeout(() => onDone(true), 800);
// Schlange wächst automatisch (kein pop)
} else {
snake.pop();
}
}
}
// --- Zeichnen ---
ctx.fillStyle = '#050508';
ctx.fillRect(0, 0, W, H);
drawGrid();
// Futter
ctx.shadowColor = theme.primary;
ctx.shadowBlur = 12;
ctx.font = `${SZ - 2}px serif`;
ctx.textAlign = 'center';
ctx.fillText('🍎', food.x * SZ + SZ / 2, food.y * SZ + SZ / 1.2);
ctx.shadowBlur = 0;
// Schlange
snake.forEach((s, i) => {
const alpha = i === 0 ? 'ff' : Math.max(0x44, 0x88 - i * 2).toString(16).padStart(2, '0');
ctx.fillStyle = i === 0 ? theme.primary : `${theme.primary}${alpha}`;
if (i === 0) { ctx.shadowColor = theme.primary; ctx.shadowBlur = 8; }
MGAPI.roundRect(ctx, s.x * SZ + 2, s.y * SZ + 2, SZ - 4, SZ - 4, 4, ctx.fillStyle, null);
ctx.shadowBlur = 0;
});
// HUD
MGAPI.text(ctx, `🍎 ${score} / ${WIN_SCORE}`, 8, 14,
{ align: 'left', size: 13, color: theme.primary });
const hearts = '❤️'.repeat(lives) + '🖤'.repeat(Math.max(0, MAX_LIVES - lives));
MGAPI.text(ctx, hearts, W / 2, 14, { align: 'center', size: 12 });
// Geschwindigkeitsanzeige
const spd = Math.round((BASE_INTERVAL / Math.max(MIN_INTERVAL, interval(score))) * 10) / 10;
MGAPI.text(ctx, `${spd.toFixed(1)}x`, W - 8, 14,
{ align: 'right', size: 11, color: 'rgba(255,255,255,0.4)' });
if (dead && lives <= 0) {
MGAPI.resultScreen(ctx, W, H, false,
`Nur ${score} Äpfel — nächstes Mal!`);
} else if (dead && dying) {
MGAPI.text(ctx, `${lives} ${lives === 1 ? 'Leben' : 'Leben'} übrig`, W / 2, H / 2,
{ size: 16, color: '#ef4444' });
}
if (score >= WIN_SCORE && !dead)
MGAPI.resultScreen(ctx, W, H, true, `${score} Äpfel gefressen — geschafft!`);
}
raf = requestAnimationFrame(loop);
return {
stop() {
stopped = true;
cancelAnimationFrame(raf);
clearTimeout(endTimer);
clearTimeout(respawnTimer);
document.removeEventListener('keydown', onKey);
},
};
}
function launch(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
function preview(wrap, W, H, cfg) { return run(wrap, W, H, { ...cfg, winScore: 15, lives: 3 }, won => MGAPI.onResult(won)); }
return { id: ID, emoji: EMOJI, name: NAME, desc: DESC, controls: CONTROLS, multi: MULTI, launch, preview };
})();

353
minigames/snake2p.js Normal file
View file

@ -0,0 +1,353 @@
/**
* minigames/snake2p.js
* 🐍🐍 Snake-Duell 2-Spieler Best-of-3 im Stil von snake.js
* Blau (Pfeiltasten) startet rechts · Rot (WASD) startet links.
* Ruft MGAPI.onResult(winnerIdx) mit 0 (P1), 1 (P2) oder -1 (Unentschieden).
*/
window.MG_snake2p = (function () {
const ID = 'snake2p';
const EMOJI = '🐍🐍';
const NAME = 'Snake-Duell';
const DESC = 'Best-of-3 · 🔵 Pfeile (rechts) gegen 🔴 WASD (links). 2 Äpfel auf dem Feld.';
const CONTROLS = '← ↑ → ↓ / W A S D';
const MULTI = 1;
const REQUIRES = '2p';
const TOTAL_ROUNDS = 3;
const STEP_INTERVAL = 160; // wie snake.js Start-Tempo, konstant (fair fürs Duell)
const P1_COL = '#3b82f6'; // Pfeiltasten, startet rechts
const P2_COL = '#ef4444'; // WASD, startet links
const APPLE_COL = '#f59e0b';
function run(wrap, W, H, cfg, onDone) {
const { canvas, ctx } = MGAPI.makeCanvas(wrap, W, H);
const SZ = 20;
const COLS = Math.floor(W / SZ);
const ROWS = Math.floor(H / SZ);
const scores = [0, 0]; // [P1 (Pfeile), P2 (WASD)]
let roundIdx = 0;
let state = 'intro'; // intro | playing | roundEnd | duelEnd
let stateTimer = 0;
let lastTs = 0;
let lastStepTs = 0;
let stopped = false, raf = null;
let roundResult = null;
let startBtnRect = null;
let s1 = null, s2 = null;
let apples = [];
// ── Snake-Init ──
function startSnakeRight() {
// P1 (Pfeiltasten), Blau, startet rechts und zieht nach links
const my = Math.floor(ROWS / 2);
const x0 = Math.max(5, COLS - 6);
return { body: [{x:x0, y:my}, {x:x0+1, y:my}, {x:x0+2, y:my}], dir:{x:-1,y:0}, nextDir:{x:-1,y:0}, alive:true };
}
function startSnakeLeft() {
// P2 (WASD), Rot, startet links und zieht nach rechts
const my = Math.floor(ROWS / 2);
const x0 = Math.min(COLS - 6, 5);
return { body: [{x:x0, y:my}, {x:x0-1, y:my}, {x:x0-2, y:my}], dir:{x:1,y:0}, nextDir:{x:1,y:0}, alive:true };
}
function rndApple() {
let safety = 200;
while (safety-- > 0) {
const f = { x: Math.floor(Math.random()*COLS), y: Math.floor(Math.random()*ROWS) };
if (s1.body.some(c=>c.x===f.x&&c.y===f.y)) continue;
if (s2.body.some(c=>c.x===f.x&&c.y===f.y)) continue;
if (apples.some(a=>a.x===f.x&&a.y===f.y)) continue;
return f;
}
return null;
}
function spawnApples() {
while (apples.length < 2) {
const a = rndApple();
if (!a) break;
apples.push(a);
}
}
function newRound() {
s1 = startSnakeRight();
s2 = startSnakeLeft();
apples = [];
spawnApples();
lastStepTs = 0;
roundResult = null;
state = 'playing';
stateTimer = 0;
}
function endRound(winner) {
if (winner === 0 || winner === 1) scores[winner]++;
roundResult = winner;
state = 'roundEnd';
stateTimer = 0;
}
function advanceRound() {
roundIdx++;
if (scores[0] >= 2 || scores[1] >= 2 || roundIdx >= TOTAL_ROUNDS) {
state = 'duelEnd';
stateTimer = 0;
} else {
newRound();
}
}
function finalWinner() {
if (scores[0] > scores[1]) return 0;
if (scores[1] > scores[0]) return 1;
return -1;
}
function finishUp() {
if (stopped) return;
stopped = true;
cancelAnimationFrame(raf);
document.removeEventListener('keydown', onKey);
canvas.removeEventListener('click', onClick);
canvas.removeEventListener('touchend', onTouch);
onDone(finalWinner());
}
// ── Steuerung ──
function setDir(s, dx, dy) {
if (s.dir.x === -dx && s.dir.y === -dy) return; // kein 180°
s.nextDir = { x: dx, y: dy };
}
function onKey(e) {
if (state !== 'playing') return;
const k = e.key;
let handled = true;
if (k === 'ArrowUp') setDir(s1, 0, -1);
else if (k === 'ArrowDown') setDir(s1, 0, 1);
else if (k === 'ArrowLeft') setDir(s1, -1, 0);
else if (k === 'ArrowRight') setDir(s1, 1, 0);
else if (k === 'w' || k === 'W') setDir(s2, 0, -1);
else if (k === 's' || k === 'S') setDir(s2, 0, 1);
else if (k === 'a' || k === 'A') setDir(s2, -1, 0);
else if (k === 'd' || k === 'D') setDir(s2, 1, 0);
else handled = false;
if (handled) e.preventDefault();
}
function pointInBtn(x, y) {
return startBtnRect && x >= startBtnRect.x && x <= startBtnRect.x + startBtnRect.w
&& y >= startBtnRect.y && y <= startBtnRect.y + startBtnRect.h;
}
function canvasCoords(clientX, clientY) {
const r = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const x = (clientX - r.left) * (canvas.width / r.width / dpr);
const y = (clientY - r.top) * (canvas.height / r.height / dpr);
return { x, y };
}
function onClick(e) {
if (state !== 'intro') return;
const p = canvasCoords(e.clientX, e.clientY);
if (pointInBtn(p.x, p.y)) newRound();
}
function onTouch(e) {
if (state !== 'intro') return;
const t = e.changedTouches && e.changedTouches[0];
if (!t) return;
const p = canvasCoords(t.clientX, t.clientY);
if (pointInBtn(p.x, p.y)) { e.preventDefault(); newRound(); }
}
document.addEventListener('keydown', onKey);
canvas.addEventListener('click', onClick);
canvas.addEventListener('touchend', onTouch, { passive: false });
// ── Spielschritt ──
function stepGame() {
s1.dir = s1.nextDir;
s2.dir = s2.nextDir;
const h1 = { x: s1.body[0].x + s1.dir.x, y: s1.body[0].y + s1.dir.y };
const h2 = { x: s2.body[0].x + s2.dir.x, y: s2.body[0].y + s2.dir.y };
const out1 = h1.x<0||h1.x>=COLS||h1.y<0||h1.y>=ROWS;
const out2 = h2.x<0||h2.x>=COLS||h2.y<0||h2.y>=ROWS;
const self1 = s1.body.some(c=>c.x===h1.x&&c.y===h1.y);
const self2 = s2.body.some(c=>c.x===h2.x&&c.y===h2.y);
const cross1 = s2.body.some(c=>c.x===h1.x&&c.y===h1.y);
const cross2 = s1.body.some(c=>c.x===h2.x&&c.y===h2.y);
const headOn = h1.x===h2.x && h1.y===h2.y;
let die1 = out1||self1||cross1;
let die2 = out2||self2||cross2;
if (headOn) { die1 = true; die2 = true; }
if (die1) s1.alive = false;
if (die2) s2.alive = false;
if (s1.alive) {
s1.body.unshift(h1);
const ai = apples.findIndex(a=>a.x===h1.x&&a.y===h1.y);
if (ai >= 0) apples.splice(ai,1); else s1.body.pop();
}
if (s2.alive) {
s2.body.unshift(h2);
const ai = apples.findIndex(a=>a.x===h2.x&&a.y===h2.y);
if (ai >= 0) apples.splice(ai,1); else s2.body.pop();
}
spawnApples();
if (!s1.alive || !s2.alive) {
let w;
if (!s1.alive && !s2.alive) w = -1;
else if (!s1.alive) w = 1;
else w = 0;
endRound(w);
}
}
// ── Render (gleicher Stil wie snake.js) ──
function drawGrid() {
ctx.strokeStyle = 'rgba(255,255,255,0.04)';
ctx.lineWidth = 0.5;
for (let x = 0; x < COLS; x++)
for (let y = 0; y < ROWS; y++)
ctx.strokeRect(x*SZ, y*SZ, SZ, SZ);
}
function drawSnake(s, col) {
s.body.forEach((c, i) => {
const alpha = i === 0 ? 'ff' : Math.max(0x44, 0x88 - i*2).toString(16).padStart(2, '0');
const fill = i === 0 ? col : `${col}${alpha}`;
if (i === 0) { ctx.shadowColor = col; ctx.shadowBlur = 8; }
MGAPI.roundRect(ctx, c.x*SZ + 2, c.y*SZ + 2, SZ-4, SZ-4, 4, fill, null);
ctx.shadowBlur = 0;
});
}
function drawApples() {
ctx.shadowColor = APPLE_COL;
ctx.shadowBlur = 12;
ctx.font = `${SZ-2}px serif`;
ctx.textAlign = 'center';
apples.forEach(a => ctx.fillText('🍎', a.x*SZ + SZ/2, a.y*SZ + SZ/1.2));
ctx.shadowBlur = 0;
}
function drawHUD() {
MGAPI.text(ctx, `🔵 ${scores[0]}`, 8, 14, { align:'left', size:13, color:P1_COL });
MGAPI.text(ctx, `Runde ${Math.min(roundIdx+1, TOTAL_ROUNDS)} / ${TOTAL_ROUNDS}`,
W/2, 14, { align:'center', size:12, color:'rgba(255,255,255,0.85)' });
MGAPI.text(ctx, `${scores[1]} 🔴`, W-8, 14, { align:'right', size:13, color:P2_COL });
}
function drawStartButton() {
const bw = Math.min(320, W*0.62), bh = 56;
const bx = (W - bw)/2, by = H/2 + 8;
startBtnRect = { x: bx, y: by, w: bw, h: bh };
// Schatten
ctx.fillStyle = 'rgba(0,0,0,0.45)';
MGAPI.roundRect(ctx, bx, by+4, bw, bh, 14, 'rgba(0,0,0,0.45)', null);
// Button-Gradient
const grd = ctx.createLinearGradient(0, by, 0, by+bh);
grd.addColorStop(0, '#7c3aed'); grd.addColorStop(1, '#5b21b6');
MGAPI.roundRect(ctx, bx, by, bw, bh, 14, grd, null);
// Glanz oben
MGAPI.roundRect(ctx, bx+4, by+3, bw-8, bh/2 - 4, 12, 'rgba(255,255,255,0.18)', null);
// Text
MGAPI.text(ctx, '▶ Duell starten', bx + bw/2, by + bh/2, {
size: 22, color:'#fff', weight:'bold', family: "'Fredoka One',cursive"
});
}
function drawOverlayText(title, sub, color) {
ctx.fillStyle = 'rgba(0,0,0,0.62)';
ctx.fillRect(0, 0, W, H);
MGAPI.text(ctx, title, W/2, H/2 - 20, {
size: 28, weight:'bold', color: color||'#fff',
family: "'Fredoka One',cursive", shadow:'rgba(0,0,0,0.7)', shadowBlur: 12
});
if (sub) MGAPI.text(ctx, sub, W/2, H/2 + 18, { size: 15, color:'rgba(255,255,255,0.88)' });
}
function render() {
// Hintergrund + Grid (wie snake.js)
ctx.fillStyle = '#050508';
ctx.fillRect(0, 0, W, H);
drawGrid();
if (state !== 'intro') {
drawApples();
if (s1) drawSnake(s1, s1.alive ? P1_COL : 'rgba(80,80,120,0.5)');
if (s2) drawSnake(s2, s2.alive ? P2_COL : 'rgba(80,80,120,0.5)');
}
drawHUD();
if (state === 'intro') {
MGAPI.text(ctx, 'Snake-Duell · Best of 3', W/2, H/2 - 56, {
size: 26, weight:'bold', color:'#fff', family:"'Fredoka One',cursive",
shadow:'rgba(0,0,0,0.5)', shadowBlur: 10
});
MGAPI.text(ctx, '🔵 Pfeiltasten (Start rechts) · 🔴 WASD (Start links)',
W/2, H/2 - 24, { size: 13, color:'rgba(255,255,255,0.85)' });
drawStartButton();
} else if (state === 'roundEnd') {
let t, c;
if (roundResult === 0) { t = '🔵 Runde für Spieler 1!'; c = '#93c5fd'; }
else if (roundResult === 1) { t = '🔴 Runde für Spieler 2!'; c = '#fca5a5'; }
else { t = '⚖️ Unentschieden!'; c = '#fde68a'; }
drawOverlayText(t, `Stand: 🔵 ${scores[0]} : ${scores[1]} 🔴`, c);
} else if (state === 'duelEnd') {
const w = finalWinner();
let t, c;
if (w === 0) { t = '🏆 Spieler 1 gewinnt!'; c = '#93c5fd'; }
else if (w === 1) { t = '🏆 Spieler 2 gewinnt!'; c = '#fca5a5'; }
else { t = '🤝 Unentschieden!'; c = '#fde68a'; }
drawOverlayText(t, `Endstand: 🔵 ${scores[0]} : ${scores[1]} 🔴`, c);
}
}
function loop(ts) {
if (stopped) return;
raf = requestAnimationFrame(loop);
if (!lastTs) lastTs = ts;
const dt = ts - lastTs;
lastTs = ts;
if (state === 'playing') {
if (!lastStepTs) lastStepTs = ts;
if (ts - lastStepTs >= STEP_INTERVAL) {
lastStepTs = ts;
stepGame();
}
} else {
stateTimer += dt;
if (state === 'roundEnd' && stateTimer >= 1800) advanceRound();
else if (state === 'duelEnd' && stateTimer >= 2200) finishUp();
// intro: wartet auf Klick auf den Start-Button
}
render();
}
raf = requestAnimationFrame(loop);
return {
stop() {
if (stopped) return;
stopped = true;
cancelAnimationFrame(raf);
document.removeEventListener('keydown', onKey);
canvas.removeEventListener('click', onClick);
canvas.removeEventListener('touchend', onTouch);
},
};
}
function launch(wrap, W, H, cfg) { return run(wrap, W, H, cfg, idx => MGAPI.onResult(idx)); }
function preview(wrap, W, H, cfg) { return run(wrap, W, H, cfg, idx => MGAPI.onResult(idx)); }
return { id: ID, emoji: EMOJI, name: NAME, desc: DESC, controls: CONTROLS, multi: MULTI, requires: REQUIRES, launch, preview };
})();

392
minigames/spotdiff.js Normal file
View file

@ -0,0 +1,392 @@
/**
* minigames/spotdiff.js
* 🔍 Fehler finden Finde alle Unterschiede zwischen den zwei Bildern!
*/
window.MG_spotdiff = (function() {
const ID = 'spotdiff';
const EMOJI = '🔍';
const NAME = 'Fehler finden';
const DESC = 'Finde alle Unterschiede zwischen den zwei Bildern!';
const CONTROLS = 'Mausklick';
const MULTI = 1;
// ── Bild-Sets (canvas-gezeichnete Szenen) ────────────────────────────────
// Jede Szene besteht aus drawA(ctx,W,H) und drawB(ctx,W,H),
// plus einer Liste von Unterschied-Hotspots { x, y, r } (relativ, 01)
const SCENES = [
{
name: 'Bauernhof',
drawA(ctx, W, H) {
// Himmel
ctx.fillStyle = '#7dd3fc'; ctx.fillRect(0, 0, W, H * 0.55);
// Gras
ctx.fillStyle = '#4ade80'; ctx.fillRect(0, H * 0.55, W, H * 0.45);
// Sonne
ctx.fillStyle = '#fbbf24'; ctx.beginPath(); ctx.arc(W*0.15, H*0.18, H*0.1, 0, Math.PI*2); ctx.fill();
// Haus
ctx.fillStyle = '#f87171'; ctx.fillRect(W*0.3, H*0.3, W*0.25, H*0.28);
ctx.fillStyle = '#7f1d1d'; ctx.beginPath(); ctx.moveTo(W*0.27,H*0.3); ctx.lineTo(W*0.425,H*0.12); ctx.lineTo(W*0.58,H*0.3); ctx.fill();
// Fenster (2)
ctx.fillStyle = '#bae6fd'; ctx.fillRect(W*0.34, H*0.38, W*0.06, H*0.07);
ctx.fillStyle = '#bae6fd'; ctx.fillRect(W*0.45, H*0.38, W*0.06, H*0.07);
// Tür
ctx.fillStyle = '#78350f'; ctx.fillRect(W*0.39, H*0.46, W*0.05, H*0.12);
// Baum (3 Kreise)
ctx.fillStyle = '#16a34a'; ctx.beginPath(); ctx.arc(W*0.75, H*0.38, W*0.06, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = '#15803d'; ctx.beginPath(); ctx.arc(W*0.72, H*0.46, W*0.05, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = '#14532d'; ctx.beginPath(); ctx.arc(W*0.79, H*0.44, W*0.05, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = '#78350f'; ctx.fillRect(W*0.74, H*0.5, W*0.02, H*0.08);
// Wolke
ctx.fillStyle = '#fff';
ctx.beginPath(); ctx.arc(W*0.55, H*0.14, W*0.05, 0, Math.PI*2); ctx.fill();
ctx.beginPath(); ctx.arc(W*0.62, H*0.11, W*0.06, 0, Math.PI*2); ctx.fill();
ctx.beginPath(); ctx.arc(W*0.69, H*0.14, W*0.05, 0, Math.PI*2); ctx.fill();
// Vogel (klein, V-Form im Himmel)
ctx.strokeStyle = '#1f2937'; ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(W*0.85, H*0.23); ctx.lineTo(W*0.88, H*0.20); ctx.lineTo(W*0.91, H*0.23);
ctx.stroke();
},
drawB(ctx, W, H) {
// Himmel — IDENTISCH zu A
ctx.fillStyle = '#7dd3fc'; ctx.fillRect(0, 0, W, H * 0.55);
// Gras
ctx.fillStyle = '#4ade80'; ctx.fillRect(0, H * 0.55, W, H * 0.45);
// Sonne (UNTERSCHIED 1: weiter oben)
ctx.fillStyle = '#fbbf24'; ctx.beginPath(); ctx.arc(W*0.15, H*0.10, H*0.1, 0, Math.PI*2); ctx.fill();
// Haus
ctx.fillStyle = '#f87171'; ctx.fillRect(W*0.3, H*0.3, W*0.25, H*0.28);
ctx.fillStyle = '#7f1d1d'; ctx.beginPath(); ctx.moveTo(W*0.27,H*0.3); ctx.lineTo(W*0.425,H*0.12); ctx.lineTo(W*0.58,H*0.3); ctx.fill();
// Fenster (UNTERSCHIED 2: zweites Fenster ist ROT statt blau)
ctx.fillStyle = '#bae6fd'; ctx.fillRect(W*0.34, H*0.38, W*0.06, H*0.07);
ctx.fillStyle = '#f87171'; ctx.fillRect(W*0.45, H*0.38, W*0.06, H*0.07);
// Tür (UNTERSCHIED 3: fehlt — Wand bleibt durchgehend rot)
// Baum (UNTERSCHIED 4: kleiner)
ctx.fillStyle = '#16a34a'; ctx.beginPath(); ctx.arc(W*0.75, H*0.42, W*0.04, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = '#15803d'; ctx.beginPath(); ctx.arc(W*0.72, H*0.48, W*0.035, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = '#14532d'; ctx.beginPath(); ctx.arc(W*0.79, H*0.46, W*0.035, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = '#78350f'; ctx.fillRect(W*0.74, H*0.5, W*0.02, H*0.08);
// Wolke (UNTERSCHIED 5: fehlt)
// Vogel — bleibt
ctx.strokeStyle = '#1f2937'; ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(W*0.85, H*0.23); ctx.lineTo(W*0.88, H*0.20); ctx.lineTo(W*0.91, H*0.23);
ctx.stroke();
},
spots: [
{ x: 0.15, y: 0.10, r: 0.10, label: 'Sonne (weiter oben)' },
{ x: 0.48, y: 0.41, r: 0.06, label: 'Rotes Fenster' },
{ x: 0.42, y: 0.52, r: 0.05, label: 'Fehlende Tür' },
{ x: 0.75, y: 0.44, r: 0.08, label: 'Kleinerer Baum' },
{ x: 0.62, y: 0.13, r: 0.10, label: 'Fehlende Wolke' },
],
},
// ── Szene 2: STRAND ──────────────────────────────────────────────────
{
name: 'Strand',
drawA(ctx, W, H) {
// Himmel
ctx.fillStyle = '#7dd3fc'; ctx.fillRect(0, 0, W, H * 0.6);
// Meer
ctx.fillStyle = '#0e7490'; ctx.fillRect(0, H * 0.55, W, H * 0.12);
// Sand
ctx.fillStyle = '#fde68a'; ctx.fillRect(0, H * 0.67, W, H * 0.33);
// Sonne (oben rechts)
ctx.fillStyle = '#fbbf24'; ctx.beginPath(); ctx.arc(W*0.8, H*0.18, H*0.08, 0, Math.PI*2); ctx.fill();
// Palme (links)
ctx.fillStyle = '#78350f'; ctx.fillRect(W*0.13, H*0.4, W*0.022, H*0.4);
ctx.fillStyle = '#15803d';
for (let i = 0; i < 5; i++) {
ctx.save();
ctx.translate(W*0.14, H*0.4);
ctx.rotate((i - 2) * 0.35);
ctx.beginPath(); ctx.ellipse(0, -H*0.04, W*0.09, H*0.025, 0, 0, Math.PI*2); ctx.fill();
ctx.restore();
}
// Kokosnüsse (2)
ctx.fillStyle = '#451a03';
ctx.beginPath(); ctx.arc(W*0.155, H*0.41, W*0.012, 0, Math.PI*2); ctx.fill();
ctx.beginPath(); ctx.arc(W*0.13, H*0.43, W*0.012, 0, Math.PI*2); ctx.fill();
// Sandburg (Mitte unten)
ctx.fillStyle = '#d97706';
ctx.fillRect(W*0.42, H*0.74, W*0.18, H*0.14);
// Türme links + rechts + Mitte
ctx.fillRect(W*0.41, H*0.7, W*0.04, H*0.04);
ctx.fillRect(W*0.49, H*0.66, W*0.04, H*0.08);
ctx.fillRect(W*0.57, H*0.7, W*0.04, H*0.04);
// Flagge am mittleren Turm
ctx.fillStyle = '#dc2626';
ctx.beginPath(); ctx.moveTo(W*0.51, H*0.62); ctx.lineTo(W*0.55, H*0.64); ctx.lineTo(W*0.51, H*0.66); ctx.fill();
ctx.strokeStyle = '#1f2937'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(W*0.51, H*0.62); ctx.lineTo(W*0.51, H*0.7); ctx.stroke();
// Schiff am Horizont (rechts)
ctx.fillStyle = '#1f2937'; ctx.fillRect(W*0.78, H*0.58, W*0.08, H*0.025);
ctx.fillStyle = '#fff'; ctx.beginPath();
ctx.moveTo(W*0.82, H*0.58); ctx.lineTo(W*0.82, H*0.5); ctx.lineTo(W*0.85, H*0.58); ctx.fill();
// Möwe (V)
ctx.strokeStyle = '#1f2937'; ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(W*0.36, H*0.22); ctx.lineTo(W*0.4, H*0.24); ctx.lineTo(W*0.44, H*0.22);
ctx.stroke();
},
drawB(ctx, W, H) {
// Himmel — IDENTISCH zu A
ctx.fillStyle = '#7dd3fc'; ctx.fillRect(0, 0, W, H * 0.6);
// Meer
ctx.fillStyle = '#0e7490'; ctx.fillRect(0, H * 0.55, W, H * 0.12);
// Sand
ctx.fillStyle = '#fde68a'; ctx.fillRect(0, H * 0.67, W, H * 0.33);
// Sonne (UNTERSCHIED 1: weiter mittig)
ctx.fillStyle = '#fbbf24'; ctx.beginPath(); ctx.arc(W*0.5, H*0.18, H*0.08, 0, Math.PI*2); ctx.fill();
// Palme (links)
ctx.fillStyle = '#78350f'; ctx.fillRect(W*0.13, H*0.4, W*0.022, H*0.4);
ctx.fillStyle = '#15803d';
for (let i = 0; i < 5; i++) {
ctx.save();
ctx.translate(W*0.14, H*0.4);
ctx.rotate((i - 2) * 0.35);
ctx.beginPath(); ctx.ellipse(0, -H*0.04, W*0.09, H*0.025, 0, 0, Math.PI*2); ctx.fill();
ctx.restore();
}
// Kokosnüsse (UNTERSCHIED 2: nur EINE statt zwei)
ctx.fillStyle = '#451a03';
ctx.beginPath(); ctx.arc(W*0.155, H*0.41, W*0.012, 0, Math.PI*2); ctx.fill();
// Sandburg
ctx.fillStyle = '#d97706';
ctx.fillRect(W*0.42, H*0.74, W*0.18, H*0.14);
ctx.fillRect(W*0.41, H*0.7, W*0.04, H*0.04);
ctx.fillRect(W*0.49, H*0.66, W*0.04, H*0.08);
ctx.fillRect(W*0.57, H*0.7, W*0.04, H*0.04);
// Flagge fehlt (UNTERSCHIED 3)
ctx.strokeStyle = '#1f2937'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(W*0.51, H*0.62); ctx.lineTo(W*0.51, H*0.7); ctx.stroke();
// Schiff fehlt (UNTERSCHIED 4)
// Möwe fehlt (UNTERSCHIED 5)
},
spots: [
{ x: 0.50, y: 0.18, r: 0.10, label: 'Sonne (weiter mittig)' },
{ x: 0.155, y: 0.43, r: 0.04, label: 'Fehlende Kokosnuss' },
{ x: 0.52, y: 0.64, r: 0.05, label: 'Fehlende Flagge' },
{ x: 0.82, y: 0.55, r: 0.08, label: 'Fehlendes Schiff' },
{ x: 0.40, y: 0.23, r: 0.07, label: 'Fehlende Möwe' },
],
},
// ── Szene 3: WELTRAUM ─────────────────────────────────────────────────
{
name: 'Weltraum',
drawA(ctx, W, H) {
// Hintergrund: tiefes Nachtblau
ctx.fillStyle = '#0c0a2e'; ctx.fillRect(0, 0, W, H);
// Sterne (10 Stück)
ctx.fillStyle = '#fff';
const stars = [[0.10,0.15],[0.22,0.32],[0.35,0.10],[0.48,0.25],[0.60,0.12],
[0.72,0.30],[0.85,0.18],[0.18,0.55],[0.90,0.50],[0.05,0.75]];
stars.forEach(([sx,sy])=>{ ctx.beginPath(); ctx.arc(sx*W, sy*H, 2, 0, Math.PI*2); ctx.fill(); });
// Großer Planet rechts mit Ringen (Saturn-Style)
const px = W*0.78, py = H*0.45, pr = H*0.13;
ctx.fillStyle = '#fb923c'; ctx.beginPath(); ctx.arc(px, py, pr, 0, Math.PI*2); ctx.fill();
// Ring
ctx.strokeStyle = '#fde68a'; ctx.lineWidth = 4;
ctx.beginPath(); ctx.ellipse(px, py, pr*1.7, pr*0.35, 0.3, 0, Math.PI*2); ctx.stroke();
// Rakete (links, schräg)
ctx.save(); ctx.translate(W*0.2, H*0.6); ctx.rotate(-0.5);
ctx.fillStyle = '#e5e7eb';
ctx.fillRect(-W*0.025, -H*0.08, W*0.05, H*0.16);
// Spitze
ctx.fillStyle = '#dc2626'; ctx.beginPath();
ctx.moveTo(-W*0.025, -H*0.08); ctx.lineTo(0, -H*0.13); ctx.lineTo(W*0.025, -H*0.08); ctx.fill();
// Fenster
ctx.fillStyle = '#3b82f6'; ctx.beginPath(); ctx.arc(0, -H*0.03, W*0.012, 0, Math.PI*2); ctx.fill();
// Flossen
ctx.fillStyle = '#dc2626';
ctx.beginPath(); ctx.moveTo(-W*0.025, H*0.04); ctx.lineTo(-W*0.05, H*0.08); ctx.lineTo(-W*0.025, H*0.08); ctx.fill();
ctx.beginPath(); ctx.moveTo(W*0.025, H*0.04); ctx.lineTo(W*0.05, H*0.08); ctx.lineTo(W*0.025, H*0.08); ctx.fill();
// Flamme
ctx.fillStyle = '#fbbf24'; ctx.beginPath();
ctx.moveTo(-W*0.015, H*0.08); ctx.lineTo(0, H*0.16); ctx.lineTo(W*0.015, H*0.08); ctx.fill();
ctx.restore();
// Astronaut (Mitte unten)
ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.arc(W*0.48, H*0.75, H*0.05, 0, Math.PI*2); ctx.fill(); // Helm
ctx.fillStyle = '#1f2937'; ctx.beginPath(); ctx.ellipse(W*0.48, H*0.75, H*0.03, H*0.025, 0, 0, Math.PI*2); ctx.fill(); // Visier
ctx.fillStyle = '#e5e7eb'; ctx.fillRect(W*0.45, H*0.78, W*0.06, H*0.1); // Körper
// UFO (oben links)
ctx.fillStyle = '#9ca3af'; ctx.beginPath(); ctx.ellipse(W*0.15, H*0.25, W*0.05, H*0.018, 0, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = '#67e8f9'; ctx.beginPath(); ctx.ellipse(W*0.15, H*0.225, W*0.025, H*0.018, 0, Math.PI, 2*Math.PI); ctx.fill();
},
drawB(ctx, W, H) {
// Hintergrund: GLEICH wie A (kein Hintergrund-Unterschied)
ctx.fillStyle = '#0c0a2e'; ctx.fillRect(0, 0, W, H);
// Sterne — eine fehlt (UNTERSCHIED 1: nur 9 statt 10, der bei 0.48/0.25 fehlt)
ctx.fillStyle = '#fff';
const stars = [[0.10,0.15],[0.22,0.32],[0.35,0.10],[0.60,0.12],
[0.72,0.30],[0.85,0.18],[0.18,0.55],[0.90,0.50],[0.05,0.75]];
stars.forEach(([sx,sy])=>{ ctx.beginPath(); ctx.arc(sx*W, sy*H, 2, 0, Math.PI*2); ctx.fill(); });
// Planet (UNTERSCHIED 2: kein Ring)
const px = W*0.78, py = H*0.45, pr = H*0.13;
ctx.fillStyle = '#fb923c'; ctx.beginPath(); ctx.arc(px, py, pr, 0, Math.PI*2); ctx.fill();
// Rakete (UNTERSCHIED 3: keine Flamme)
ctx.save(); ctx.translate(W*0.2, H*0.6); ctx.rotate(-0.5);
ctx.fillStyle = '#e5e7eb';
ctx.fillRect(-W*0.025, -H*0.08, W*0.05, H*0.16);
ctx.fillStyle = '#dc2626'; ctx.beginPath();
ctx.moveTo(-W*0.025, -H*0.08); ctx.lineTo(0, -H*0.13); ctx.lineTo(W*0.025, -H*0.08); ctx.fill();
ctx.fillStyle = '#3b82f6'; ctx.beginPath(); ctx.arc(0, -H*0.03, W*0.012, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = '#dc2626';
ctx.beginPath(); ctx.moveTo(-W*0.025, H*0.04); ctx.lineTo(-W*0.05, H*0.08); ctx.lineTo(-W*0.025, H*0.08); ctx.fill();
ctx.beginPath(); ctx.moveTo(W*0.025, H*0.04); ctx.lineTo(W*0.05, H*0.08); ctx.lineTo(W*0.025, H*0.08); ctx.fill();
ctx.restore();
// Astronaut (UNTERSCHIED 4: ROTER Anzug statt grau)
ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.arc(W*0.48, H*0.75, H*0.05, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = '#1f2937'; ctx.beginPath(); ctx.ellipse(W*0.48, H*0.75, H*0.03, H*0.025, 0, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = '#dc2626'; ctx.fillRect(W*0.45, H*0.78, W*0.06, H*0.1);
// UFO (UNTERSCHIED 5: an anderer Position - weiter rechts oben)
ctx.fillStyle = '#9ca3af'; ctx.beginPath(); ctx.ellipse(W*0.42, H*0.15, W*0.05, H*0.018, 0, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = '#67e8f9'; ctx.beginPath(); ctx.ellipse(W*0.42, H*0.125, W*0.025, H*0.018, 0, Math.PI, 2*Math.PI); ctx.fill();
},
spots: [
{ x: 0.48, y: 0.25, r: 0.05, label: 'Fehlender Stern' },
{ x: 0.78, y: 0.45, r: 0.13, label: 'Fehlender Ring' },
{ x: 0.21, y: 0.74, r: 0.06, label: 'Fehlende Flamme' },
{ x: 0.48, y: 0.83, r: 0.06, label: 'Anderer Anzug (rot)' },
{ x: 0.42, y: 0.15, r: 0.07, label: 'UFO an anderer Stelle' },
],
},
];
// Pro Aufruf andere Szene (rotiert) — so bekommt jeder Spielzug bei multi:3 eine neue Szene
let _sceneCounter = 0;
function run(wrap, W, H, cfg, onDone) {
const scene = SCENES[_sceneCounter % SCENES.length];
_sceneCounter++;
const theme = cfg.theme || { primary: '#84cc16' };
// Zwei Canvas nebeneinander
const halfW = Math.floor((W - 8) / 2);
const imgH = H - 64;
const container = document.createElement('div');
container.style.cssText = 'position:relative;width:100%;';
wrap.appendChild(container);
const canvasA = document.createElement('canvas');
canvasA.width = halfW; canvasA.height = imgH;
canvasA.style.cssText = `display:inline-block;border-radius:8px;cursor:default;`;
const canvasB = document.createElement('canvas');
canvasB.width = halfW; canvasB.height = imgH;
canvasB.style.cssText = `display:inline-block;border-radius:8px;cursor:crosshair;margin-left:8px;`;
container.appendChild(canvasA);
container.appendChild(canvasB);
// HUD-Canvas
const hud = document.createElement('canvas');
hud.width = W; hud.height = 48;
hud.style.display = 'block';
container.appendChild(hud);
const ctxA = canvasA.getContext('2d');
const ctxB = canvasB.getContext('2d');
const ctxH = hud.getContext('2d');
scene.drawA(ctxA, halfW, imgH);
scene.drawB(ctxB, halfW, imgH);
let found = new Set();
let marks = []; // { x, y, ok }
let stopped = false;
let endTimer;
function drawMarks() {
scene.drawB(ctxB, halfW, imgH); // neu zeichnen
marks.forEach(m => {
ctxB.strokeStyle = m.ok ? '#22c55e' : '#ef4444';
ctxB.lineWidth = 3;
ctxB.beginPath();
ctxB.arc(m.x, m.y, 18, 0, Math.PI * 2);
ctxB.stroke();
if (m.ok) {
ctxB.fillStyle = 'rgba(34,197,94,0.25)';
ctxB.fill();
}
});
}
function drawHUD() {
ctxH.clearRect(0, 0, W, 48);
MGAPI.text(ctxH, `🔍 ${found.size} / ${scene.spots.length} Unterschiede gefunden`, W/2, 16,
{ size: 13, color: theme.primary });
MGAPI.text(ctxH, 'Klicke auf die Unterschiede im rechten Bild', W/2, 36,
{ size: 11, color: 'rgba(255,255,255,0.4)' });
}
function hitsSpot(s, mx, my) {
if (s.shape === 'rect') {
const x = s.x * halfW, y = s.y * imgH;
const w = s.w * halfW, h = s.h * imgH;
return mx >= x && mx < x + w && my >= y && my < y + h;
}
const sx = s.x * halfW, sy = s.y * imgH;
const d = Math.sqrt((mx - sx) ** 2 + (my - sy) ** 2);
return d < s.r * Math.min(halfW, imgH);
}
canvasB.addEventListener('click', e => {
if (found.size >= scene.spots.length) return;
const rect = canvasB.getBoundingClientRect();
const mx = (e.clientX - rect.left) * (canvasB.width / rect.width / (window.devicePixelRatio || 1));
const my = (e.clientY - rect.top) * (canvasB.height / rect.height);
// Reihenfolge = Priorität: erste passende Spot gewinnt (radial-Spots vor rect-Catchall)
let hit = null;
for (let i = 0; i < scene.spots.length; i++) {
if (found.has(i)) continue;
if (hitsSpot(scene.spots[i], mx, my)) { hit = i; break; }
}
if (hit !== null) {
found.add(hit);
// Treffer-Marker an Klick-Position (statt Spot-Mitte) — funktioniert für Kreis UND Rechteck
marks.push({ x: mx, y: my, ok: true });
// Auch in Bild A markieren (gleiche Klick-Position)
ctxA.strokeStyle = '#22c55e';
ctxA.lineWidth = 3;
ctxA.beginPath(); ctxA.arc(mx, my, 18, 0, Math.PI * 2); ctxA.stroke();
ctxA.fillStyle = 'rgba(34,197,94,0.2)'; ctxA.fill();
} else {
marks.push({ x: mx, y: my, ok: false });
setTimeout(() => {
marks = marks.filter(m => m.ok);
drawMarks(); drawHUD();
}, 600);
}
drawMarks();
drawHUD();
if (found.size >= scene.spots.length && !endTimer) {
endTimer = setTimeout(() => onDone(true), 900);
// Overlay
ctxH.fillStyle = 'rgba(16,185,129,0.9)';
MGAPI.roundRect(ctxH, W/2-120, 4, 240, 38, 8, 'rgba(16,185,129,0.9)', null);
MGAPI.text(ctxH, '🎉 Alle Unterschiede gefunden!', W/2, 24,
{ size: 14, family: "'Fredoka One',cursive", color: '#fff' });
}
});
drawHUD();
return {
stop() {
stopped = true;
clearTimeout(endTimer);
},
};
}
function launch(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
function preview(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
return { id: ID, emoji: EMOJI, name: NAME, desc: DESC, controls: CONTROLS, multi: MULTI, launch, preview };
})();

170
minigames/typing.js Normal file
View file

@ -0,0 +1,170 @@
/**
* minigames/typing.js
* Tipp-Rennen Tippe den Text so schnell wie möglich!
*/
window.MG_typing = (function() {
const ID = 'typing';
const EMOJI = '⌨️';
const NAME = 'Tipp-Rennen';
const DESC = 'Tippe das angezeigte Wort so schnell wie möglich!';
const CONTROLS = 'Tastatur';
const MULTI = 3;
const WORD_POOLS = {
easy: ['Katze','Hund','Baum','Haus','Ball','Buch','Schule','Spiel','Kind','Mond'],
medium: ['Abenteuer','Computer','Programm','Zauber','Kristall','Phantom','Roboter','Galaxie'],
hard: ['Dinosaurier','Wissenschaft','Programmierung','Abenteuerland','Weltentdecker'],
};
function run(wrap, W, H, cfg, onDone) {
const { canvas, ctx } = MGAPI.makeCanvas(wrap, W, H);
const theme = cfg.theme || { primary: '#f43f5e' };
const WIN_WPM = cfg.winWpm || 30; // mind. 30 WPM zum Gewinnen
const ROUNDS = 3;
const allWords = [...WORD_POOLS.easy, ...WORD_POOLS.medium];
let words = [];
for (let i = 0; i < ROUNDS; i++) {
const pool = i < 2 ? WORD_POOLS.easy : WORD_POOLS.medium;
words.push(pool[Math.floor(Math.random() * pool.length)]);
}
let round = 0;
let typed = '';
let startTs = null;
let times = [];
let stopped = false;
let raf, endTimer, result = null;
// HTML-Input über dem Canvas
const input = document.createElement('input');
input.type = 'text';
input.autocomplete = 'off';
input.autocorrect = 'off';
input.autocapitalize = 'none';
input.spellcheck = false;
input.style.cssText = `
position:absolute;left:-9999px;top:0;opacity:0;width:1px;height:1px;
`;
wrap.style.position = 'relative';
wrap.appendChild(input);
setTimeout(() => input.focus(), 100);
canvas.addEventListener('click', () => input.focus());
input.addEventListener('input', () => {
typed = input.value;
if (!startTs && typed.length > 0) startTs = performance.now();
const word = words[round];
if (typed.toLowerCase() === word.toLowerCase()) {
const elapsed = (performance.now() - startTs) / 1000 / 60; // Minuten
const wpm = Math.round(word.length / 5 / elapsed); // Standard: 5 Zeichen = 1 Wort
times.push({ word, wpm });
typed = '';
input.value = '';
startTs = null;
round++;
if (round >= ROUNDS) {
const avgWpm = Math.round(times.reduce((a, b) => a + b.wpm, 0) / times.length);
result = avgWpm >= WIN_WPM ? 'win' : 'lose';
endTimer = setTimeout(() => onDone(result === 'win'), 1200);
}
}
});
function loop(ts) {
if (stopped) return;
raf = requestAnimationFrame(loop);
ctx.fillStyle = '#050508';
ctx.fillRect(0, 0, W, H);
if (round >= ROUNDS) {
const avgWpm = times.length
? Math.round(times.reduce((a,b) => a+b.wpm, 0) / times.length)
: 0;
MGAPI.resultScreen(ctx, W, H, result === 'win',
result === 'win' ? `${avgWpm} WPM — richtig schnell!` : `${avgWpm} WPM — weiter üben!`);
return;
}
const word = words[round];
const elapsed = startTs ? (ts - startTs) / 1000 : 0;
// Fortschrittsbalken (Timing-Druck)
const timeLimit = 10;
const prog = Math.min(1, elapsed / timeLimit);
if (prog >= 1 && !endTimer && !result) {
result = 'lose';
endTimer = setTimeout(() => onDone(false), 800);
}
const barW = W - 40;
MGAPI.roundRect(ctx, 20, H - 28, barW, 10, 4, 'rgba(255,255,255,0.07)', null);
const barColor = prog < 0.6 ? theme.primary : prog < 0.8 ? '#f59e0b' : '#ef4444';
MGAPI.roundRect(ctx, 20, H - 28, barW * (1 - prog), 10, 4, barColor, null);
// Rundenanzeige
MGAPI.text(ctx, `⌨️ Runde ${round + 1} / ${ROUNDS}`, W / 2, 20, { size: 12, color: theme.primary });
// Zu tippendes Wort
MGAPI.roundRect(ctx, W/2-140, H*0.2, 280, 60, 12,
'rgba(255,255,255,0.05)', `${theme.primary}44`);
MGAPI.text(ctx, word, W / 2, H * 0.2 + 34,
{ size: 28, family: "'Fredoka One',cursive", color: '#fff' });
// Eingabe-Anzeige (zeichenweiser Vergleich)
const charW = 26;
const startX = W / 2 - (word.length * charW) / 2;
const charY = H * 0.55;
MGAPI.text(ctx, 'Tippe:', W / 2, charY - 24, { size: 11, color: 'rgba(255,255,255,0.4)' });
for (let i = 0; i < word.length; i++) {
const cx = startX + i * charW + charW / 2;
const tc = (typed[i] || '').toLowerCase();
const wc = word[i].toLowerCase();
let color;
if (!typed[i]) color = 'rgba(255,255,255,0.2)';
else if (tc === wc) color = '#22c55e';
else color = '#ef4444';
MGAPI.roundRect(ctx, startX + i * charW, charY - 4, charW - 2, 32, 4,
'rgba(255,255,255,0.04)', `${color}66`);
MGAPI.text(ctx, typed[i] || word[i], cx, charY + 12, { size: 18, color });
}
// Cursor-Blinken
if (startTs || typed.length === 0) {
const cx = startX + Math.min(typed.length, word.length) * charW + 2;
if (Math.floor(ts / 500) % 2 === 0) {
ctx.fillStyle = theme.primary;
ctx.fillRect(cx, charY, 3, 32);
}
}
// letzten WPM anzeigen
if (times.length > 0) {
const last = times[times.length - 1];
MGAPI.text(ctx, `Letztes: ${last.wpm} WPM`, W / 2, H - 42, { size: 11, color: 'rgba(255,255,255,0.35)' });
}
}
raf = requestAnimationFrame(loop);
return {
stop() {
stopped = true;
clearTimeout(endTimer);
cancelAnimationFrame(raf);
if (input.parentNode) input.parentNode.removeChild(input);
},
};
}
function launch(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
function preview(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
return { id: ID, emoji: EMOJI, name: NAME, desc: DESC, controls: CONTROLS, multi: MULTI, launch, preview };
})();

BIN
phw_logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

1
play/codes/4KVF7E.json Normal file
View file

@ -0,0 +1 @@
{"devName":"No_like","name":"der fluch der karibik","desc":"piraten bret spiel","figure":"pirate","figure2":"ninja","playerCount":2,"background":"underwater","fieldCount":30,"fields":[null,"typing","spotdiff","reaction","quiz","puzzle","quiz","basketball","reaction","memory","quiz","snake2p","typing","spotdiff","simon","puzzle","quiz","flappy2p","quiz","puzzle","reaction","typing","maze","quiz","catch","spotdiff","quiz","snake","flappy",null],"storyItems":[],"quizData":[{"fieldIndex":4,"question":"WER IST DER HAUPT KARAKTER IN ASSASIN CREED 2","answers":["edwart kenway","ezio","ezio Auditore ","heywen kenwey"],"correct":2},{"fieldIndex":6,"question":"was liebt edwart kenway","answers":["rum","sein schiff","geld","family"],"correct":2},{"fieldIndex":10,"question":"wie heiß DER GAME entwickler","answers":["FRANZ","no_LIKE","NO LIKE","ROBIN"],"correct":1},{"fieldIndex":16,"question":"WENN HAST DER GAME ENTWICKLE ","answers":["LEANDER ","MARKUS","AUGUSTUS","NICKITA"],"correct":3},{"fieldIndex":18,"question":"WER SIND DIE HAUPT CARAKTERE IN GTA","answers":["ALLE ","MICHAEL","TREVOR","FRANKLIN"],"correct":0},{"fieldIndex":23,"question":"HATT EZIO ","answers":["EIN BRUDER ","EINE SCHWESTER","EIN HAUSTIER","NICHTS"],"correct":1},{"fieldIndex":26,"question":"WELCHE FIRMA GEHÖRT ASSASINE CREED","answers":["UBISOF","ABSTERGO","VETER","NIEMANDEN"],"correct":0}],"rules":{"movement":"dice","fail":"lives","lives":"5","pts":"10"},"mgSettings":{"flappy":{"win":30,"speed":4.5,"gap":175,"rampSpeed":true,"shrinkGap":true}},"worldSeed":2214350745,"worldLocked":false}

1
play/codes/4PN6WA.json Normal file
View file

@ -0,0 +1 @@
{"devName":"Jason","name":"JacksonTv","desc":"Spielspass und viel Freude ","figure":"astronaut","figure2":"","playerCount":1,"background":"haunted","fieldCount":10,"fields":[null,"reaction","typing","maze","basketball","snake","catch","flappy","puzzle",null],"storyItems":[],"quizData":[],"rules":{"movement":"step","fail":"lives","lives":"1","pts":"10"},"mgSettings":{"flappy":{"win":30,"speed":4.5,"gap":110,"rampSpeed":true,"shrinkGap":true}},"worldSeed":2734155088,"worldLocked":true}

1
play/codes/4QMP7Z.json Normal file
View file

@ -0,0 +1 @@
{"devName":"Steini und GKBraun","name":"Fantasyrush","desc":"Ein cooles Duo Abenteuerspiel.","figure":"dino","figure2":"dragon","playerCount":2,"background":"fantasy","fieldCount":13,"fields":[null,"typing","reaction","spotdiff","snake2p","spotdiff","memory","flappy2p","basketball","maze","typing","spotdiff",null],"storyItems":[],"quizData":[],"rules":{"movement":"dice","fail":"lives","lives":"5","pts":"10"},"mgSettings":{"flappy":{"win":15,"speed":2.7,"gap":168,"rampSpeed":true,"shrinkGap":true}},"worldSeed":3718887036,"worldLocked":false}

1
play/codes/58LPMQ.json Normal file
View file

@ -0,0 +1 @@
{"devName":"xminister","name":"lebron james","desc":"","figure":"superhero","figure2":"ninja","playerCount":2,"background":"space","fieldCount":30,"fields":[null,"basketball","flappy2p",null,"memory",null,"puzzle",null,"spotdiff",null,"maze",null,"simon","quiz",null,"catch",null,"typing",null,null,"reaction","snake",null,"snake2p",null,null,"flappy",null,null,null],"storyItems":[],"quizData":[{"fieldIndex":13,"question":"Was bedeutet Layup auf Deutsch","answers":["hochlegen","hoch schmeißen","Korbleger","dribbeln"],"correct":2}],"rules":{"movement":"dice","fail":"lives","lives":"3","pts":"10"},"mgSettings":{},"worldSeed":675013668,"worldLocked":false}

1
play/codes/5VL3YY.json Normal file
View file

@ -0,0 +1 @@
{"devName":"gefährlich","name":"UNTERWASSER","desc":"man lauft durch ein labyrinth (unterwasserwelt) und muss verschiedene fragen beantworten,sachen einsammeln und kämpfen.\n","figure":"ninja","figure2":"pirate","playerCount":2,"background":"underwater","fieldCount":20,"fields":[null,"maze","reaction","snake2p","puzzle","catch","reaction","puzzle","spotdiff","snake","reaction","flappy2p","spotdiff","basketball","flappy","puzzle","spotdiff","simon","memory",null],"storyItems":[],"quizData":[],"rules":{"movement":"dice","fail":"lives","lives":"5","pts":"10"},"mgSettings":{},"worldSeed":3490040687,"worldLocked":false}

1
play/codes/7DR474.json Normal file
View file

@ -0,0 +1 @@
{"devName":"game master","name":"game of death","desc":"gewinne oder stirb","figure":"ninja","figure2":"","playerCount":1,"background":"fantasy","fieldCount":37,"fields":[null,null,"simon",null,"catch",null,"flappy","quiz",null,"typing",null,"reaction",null,null,null,"spotdiff",null,"reaction",null,"maze",null,null,"memory","spotdiff",null,"quiz",null,"puzzle",null,"spotdiff","snake","reaction",null,"basketball",null,null,null],"storyItems":[],"quizData":[{"fieldIndex":7,"question":"welche schule ist die beste","answers":["real","Gymnasium","werk real","keine"],"correct":0},{"fieldIndex":25,"question":"wie viele Menschen gibt es","answers":["8 Millionen","9 Milliarden","8 Milliarden","7 Millionen"],"correct":2}],"rules":{"movement":"dice","fail":"lives","lives":"5","pts":"10"},"mgSettings":{"flappy":{"win":15,"speed":2.7,"gap":168,"rampSpeed":true,"shrinkGap":true}},"worldSeed":3470773478,"worldLocked":false}

1
play/codes/7N5VM4.json Normal file
View file

@ -0,0 +1 @@
{"devName":"Moto-King","name":"FAST-RUN","desc":"Der als erstes im Ziel ankommt gewinnt, desto höher die Zahl auf dem Würfel desto besser ist die Chance","figure":"cat","figure2":"fox","playerCount":2,"background":"underwater","fieldCount":18,"fields":[null,null,null,"reaction",null,null,"typing",null,"catch",null,"memory","reaction",null,"reaction",null,"basketball",null,null],"storyItems":[],"quizData":[],"rules":{"movement":"dice","fail":"lives","lives":"4","pts":"10"},"mgSettings":{},"worldSeed":226427537,"worldLocked":false}

1
play/codes/9BXACW.json Normal file
View file

@ -0,0 +1 @@
{"devName":"ghaith67","name":"191919beta","desc":"","figure":"dragon","figure2":"","playerCount":1,"background":"space","fieldCount":20,"fields":[null,"reaction",null,null,"typing",null,"reaction","puzzle","memory","typing",null,"flappy","simon","spotdiff","maze","snake","basketball","reaction","catch",null],"storyItems":[],"quizData":[],"rules":{"movement":"step","fail":"lives","lives":"5","pts":"10"},"mgSettings":{"flappy":{"win":22,"speed":3.5,"gap":200,"rampSpeed":true,"shrinkGap":true}},"worldSeed":2225447356,"worldLocked":false}

1
play/codes/AA7BB5.json Normal file
View file

@ -0,0 +1 @@
{"devName":"cubecraft","name":"Flucht aus dem Labyrinth","desc":"man soll das spiel gewinnen","figure":"alien","figure2":"ninja","playerCount":2,"background":"haunted","fieldCount":35,"fields":[null,"quiz","reaction","spotdiff","typing","puzzle","quiz","flappy2p","simon",null,null,"snake2p","quiz","reaction","maze","puzzle","quiz","catch","spotdiff",null,"puzzle","typing","flappy","spotdiff","basketball","quiz","typing","reaction","memory","quiz",null,"snake",null,null,null],"storyItems":[],"quizData":[{"fieldIndex":1,"question":"wann wurde minecraft erfunden","answers":["2009 ","2010","2013","2020"],"correct":0},{"fieldIndex":6,"question":"wie vielle texturepacks hat cubecraft","answers":["9","21","3","10"],"correct":3},{"fieldIndex":12,"question":"wie viele spieler spielen minecraft täglich","answers":["40,3m","13,7m","31,9m","500,1m"],"correct":2},{"fieldIndex":16,"question":"welches item macht den meisten schaden","answers":["axt","mase","speer","schwert"],"correct":1},{"fieldIndex":25,"question":"welcher spieler ist der beste in unstable smp","answers":["wemmbu","Clown Pierce","farah mc","parrotx2"],"correct":0},{"fieldIndex":29,"question":"was ist die stärkste machine","answers":["tnt launcher","orbital strike canon","schwert mit schärfe 10","mase mit die beste verzauberungen"],"correct":1}],"rules":{"movement":"dice","fail":"points","lives":"3","pts":"10"},"mgSettings":{},"worldSeed":2111293881,"worldLocked":false}

1
play/codes/B56Q2K.json Normal file
View file

@ -0,0 +1 @@
{"devName":"Andi","name":"Zanninja","desc":"es soll schwirig sein und spaß machen","figure":"ninja","figure2":"","playerCount":1,"background":"fantasy","fieldCount":10,"fields":[null,"reaction","catch","basketball","typing","maze","flappy","puzzle","snake",null],"storyItems":[],"quizData":[],"rules":{"movement":"step","fail":"lives","lives":"5","pts":"10"},"mgSettings":{},"worldSeed":382314214,"worldLocked":false}

1
play/codes/CFTWPL.json Normal file
View file

@ -0,0 +1 @@
{"devName":"ahimet gaming","name":"gaming is good","desc":"","figure":"dino","figure2":"","playerCount":1,"background":"haunted","fieldCount":20,"fields":[null,"spotdiff","puzzle","typing","typing","reaction","basketball","simon",null,"typing","flappy","memory","catch","reaction",null,"puzzle",null,"reaction",null,null],"storyItems":[],"quizData":[],"rules":{"movement":"dice","fail":"lives","lives":"5","pts":"10"},"mgSettings":{"flappy":{"win":10,"speed":1.8,"gap":110,"rampSpeed":true,"shrinkGap":true}},"worldSeed":267994743,"worldLocked":false}

1
play/codes/D2FVAZ.json Normal file
View file

@ -0,0 +1 @@
{"devName":"Adem","name":"GoonerTV","desc":"Spielspaß ","figure":"ninja","figure2":"","playerCount":1,"background":"space","fieldCount":10,"fields":[null,"typing","snake","reaction","catch","flappy","basketball","spotdiff","maze",null],"storyItems":[],"quizData":[],"rules":{"movement":"step","fail":"lives","lives":"5","pts":"10"},"mgSettings":{"flappy":{"win":15,"speed":2.7,"gap":168,"rampSpeed":true,"shrinkGap":true}},"worldSeed":2606163505,"worldLocked":false}

1
play/codes/G8X49H.json Normal file
View file

@ -0,0 +1 @@
{"devName":"vanille","name":"runnery","desc":"","figure":"fox","figure2":"","playerCount":1,"background":"fantasy","fieldCount":13,"fields":[null,"memory","spotdiff","simon","typing","flappy","typing","basketball","reaction","typing","snake","maze",null],"storyItems":[],"quizData":[],"rules":{"movement":"step","fail":"lives","lives":"3","pts":"10"},"mgSettings":{"flappy":{"win":17,"speed":2.3,"gap":210,"rampSpeed":true,"shrinkGap":true}},"worldSeed":2868277306,"worldLocked":false}

1
play/codes/HGPBEE.json Normal file
View file

@ -0,0 +1 @@
{"devName":"s1nd3yy-smn","name":"brawlnite","desc":"supper spiele","figure":"dragon","figure2":"","playerCount":1,"background":"underwater","fieldCount":20,"fields":[null,"snake","quiz",null,"reaction","catch","puzzle","basketball","spotdiff","memory","simon","maze","typing","spotdiff","puzzle","spotdiff","reaction","reaction","puzzle",null],"storyItems":[],"quizData":[{"fieldIndex":2,"question":"wie alt bin ich ","answers":["19","55","13","12"],"correct":3}],"rules":{"movement":"step","fail":"points","lives":"3","pts":"10"},"mgSettings":{"flappy":{"win":30,"speed":1.8,"gap":110,"rampSpeed":true,"shrinkGap":true}},"worldSeed":1743976196,"worldLocked":false}

1
play/codes/J732HQ.json Normal file
View file

@ -0,0 +1 @@
{"devName":"RUMI","name":"Das KPOP Quiz","desc":"","figure":"ninja","figure2":"","playerCount":1,"background":"underwater","fieldCount":15,"fields":[null,"memory","quiz","spotdiff","typing","reaction","quiz","quiz","maze","quiz","puzzle","quiz","spotdiff","quiz",null],"storyItems":[],"quizData":[{"fieldIndex":2,"question":"WIE VIELE MEMBER HAT BTS","answers":["7","8","6","10"],"correct":0},{"fieldIndex":6,"question":"AUS WELCHEM LAND KOMMT DIE KPOP MUSIK","answers":["JAPAN","AMERIKA","KOREA","DEUTSCHLAND"],"correct":2},{"fieldIndex":9,"question":"WIE HEIST DER ÄLTESTE VON STRAYKIDS","answers":["FELIX","HAN","CHANGBIN","BANGCHANG"],"correct":3},{"fieldIndex":11,"question":"WIE HEISEN DIE MEMBER VON BLACKPINK","answers":["LISA; MELLY; ROSE; JISOO","LISA; JENNY; ROSA;JISOO","JENNY; ROSE; LISA; JISOO","ROSE; LISA; MISOO; JENNY"],"correct":2},{"fieldIndex":13,"question":"IN WELCHER FARBE LEUCHTET DER BLACKPINK LIGHTSTICK","answers":["LILA","PINK","ROT","BLAU"],"correct":1},{"fieldIndex":7,"question":"WIE HEIST DER KPOP FILM FÜR KINDER","answers":["STRAYKISD STEHT KOPF","BLACKPINK IM BÄLLEBAD","BTS IN DER SCHULE","KPOP DEMON HUNTERS"],"correct":3}],"rules":{"movement":"step","fail":"lives","lives":"3","pts":"10"},"mgSettings":{},"worldSeed":787496475,"worldLocked":false}

1
play/codes/K8ENHR.json Normal file
View file

@ -0,0 +1 @@
{"devName":"hamster563","name":"kiselstein389","desc":"ein offliene game\n","figure":"dino","figure2":"ninja","playerCount":2,"background":"underwater","fieldCount":20,"fields":[null,"snake","basketball","catch","snake2p","spotdiff","flappy","flappy2p","puzzle","memory","simon","maze","reaction","quiz","spotdiff","puzzle","spotdiff","quiz","typing",null],"storyItems":[],"quizData":[{"fieldIndex":13,"question":"wer hat die wm 2014 gewonnen ","answers":["frankreich","argentinen","deutschland ","afrika"],"correct":2},{"fieldIndex":17,"question":"wer wurde in der wm 7 zu1 von deutschland besigt ","answers":["brasilien ","argentinien ","schweitz ","frankreich"],"correct":0}],"rules":{"movement":"dice","fail":"points","lives":"5","pts":"10"},"mgSettings":{},"worldSeed":445616089,"worldLocked":false}

1
play/codes/K8RZCV.json Normal file
View file

@ -0,0 +1 @@
{"devName":"ABC","name":"DEF","desc":"","figure":"dino","figure2":"fox","playerCount":2,"background":"haunted","fieldCount":6,"fields":[null,"snake2p","flappy2p","catch","spotdiff","basketball"],"storyItems":[],"quizData":[],"rules":{"movement":"step","fail":"points","lives":"3","pts":"10"},"mgSettings":{"flappy2p":{"bestOf":3,"speed":2.7,"gap":168,"rampSpeed":true,"shrinkGap":true}},"worldSeed":772068203,"worldLocked":false}

1
play/codes/KQZJMW.json Normal file
View file

@ -0,0 +1 @@
{"devName":"Stefan","name":"Aulendorfer Adventure","desc":"","figure":"knight","figure2":"cat","playerCount":2,"background":"fantasy","fieldCount":10,"fields":[null,"snake2p","flappy2p",null,null,null,null,null,null,null],"storyItems":[{"id":"st_1781598703137","emoji":"📖","text":"","position":"before","fieldIndex":1}],"quizData":[],"rules":{"movement":"dice","fail":"lives","lives":"3","pts":"10"},"mgSettings":{},"worldSeed":1950088632,"worldLocked":false}

1
play/codes/M62QKS.json Normal file
View file

@ -0,0 +1 @@
{"devName":"khalemimi","name":"MEISTER ELIEN","desc":"MANN MUS VERSUCHEN DEN ELIEN ZU FANGEN","figure":"alien","figure2":"astronaut","playerCount":2,"background":"space","fieldCount":13,"fields":[null,"flappy","snake","catch","simon","reaction","basketball","memory","maze","typing","snake2p","flappy2p",null],"storyItems":[],"quizData":[],"rules":{"movement":"dice","fail":"lives","lives":"3","pts":"10"},"mgSettings":{"flappy2p":{"bestOf":3,"speed":2.7,"gap":168,"rampSpeed":true,"shrinkGap":true}},"worldSeed":160670247,"worldLocked":false}

1
play/codes/NJMCNF.json Normal file
View file

@ -0,0 +1 @@
{"devName":"Jaroslav","name":"1000mini spile","desc":"","figure":"fox","figure2":"","playerCount":1,"background":"underwater","fieldCount":20,"fields":[null,"snake","simon","maze","basketball","flappy","spotdiff","reaction","memory","catch","spotdiff","typing","puzzle","spotdiff","puzzle","reaction","reaction","puzzle","typing","typing"],"storyItems":[],"quizData":[],"rules":{"movement":"step","fail":"lives","lives":"5","pts":"10"},"mgSettings":{"flappy":{"win":30,"speed":4.5,"gap":168,"rampSpeed":true,"shrinkGap":true}},"worldSeed":3114780664,"worldLocked":false}

1
play/codes/PGJWC7.json Normal file
View file

@ -0,0 +1 @@
{"devName":"Adnan","name":"ninja dream","desc":"","figure":"ninja","figure2":"","playerCount":1,"background":"space","fieldCount":20,"fields":[null,"flappy","snake","basketball","reaction","spotdiff","typing","memory","spotdiff","puzzle","typing","maze","catch","simon","spotdiff","reaction","typing","puzzle",null,"puzzle"],"storyItems":[],"quizData":[],"rules":{"movement":"step","fail":"lives","lives":"4","pts":"10"},"mgSettings":{"flappy":{"win":15,"speed":2.7,"gap":168,"rampSpeed":true,"shrinkGap":true}},"worldSeed":2954399299,"worldLocked":true}

1
play/codes/QCE29W.json Normal file
View file

@ -0,0 +1 @@
{"devName":"derdcmodgamer","name":"Flucht aus dem Labyrinth","desc":"","figure":"rocket","figure2":"","playerCount":1,"background":"haunted","fieldCount":10,"fields":[null,"basketball","reaction","memory","maze","spotdiff","snake","puzzle","catch",null],"storyItems":[],"quizData":[],"rules":{"movement":"step","fail":"lives","lives":"1","pts":"10"},"mgSettings":{"flappy":{"win":15,"speed":2.7,"gap":168,"rampSpeed":true,"shrinkGap":true}},"worldSeed":3792878537,"worldLocked":false}

1
play/codes/QEE88J.json Normal file
View file

@ -0,0 +1 @@
{"devName":"adrian123626","name":"beta version","desc":"","figure":"ninja","figure2":"","playerCount":1,"background":"space","fieldCount":20,"fields":[null,"flappy","basketball","reaction","maze","snake","simon","catch","memory","reaction","puzzle","spotdiff","typing","quiz","reaction","puzzle","spotdiff","typing","typing",null],"storyItems":[],"quizData":[{"fieldIndex":13,"question":"wie geht es dir\n","answers":["gut","ne","geht","deprission"],"correct":3}],"rules":{"movement":"step","fail":"lives","lives":"3","pts":"10"},"mgSettings":{"flappy":{"win":15,"speed":2.7,"gap":168,"rampSpeed":true,"shrinkGap":true}},"worldSeed":2237941060,"worldLocked":true}

1
play/codes/SMK7DD.json Normal file
View file

@ -0,0 +1 @@
{"devName":"Meister Proppa","name":"ling Ling","desc":"Banghenigong","figure":"astronaut","figure2":"","playerCount":1,"background":"space","fieldCount":20,"fields":[null,"basketball","snake","spotdiff","memory","flappy","simon","maze","reaction","puzzle","reaction","puzzle","spotdiff","quiz","puzzle","quiz","reaction","quiz","spotdiff",null],"storyItems":[],"quizData":[{"fieldIndex":13,"question":"Was meint ihr wie alt ist der Spielentwikler","answers":["67","13","12","15"],"correct":2},{"fieldIndex":15,"question":"Wie alt ist Ling ling","answers":["67","32","56","leider tot "],"correct":1},{"fieldIndex":17,"question":"wie dumm ist Marco_SCM der beste streamer\n","answers":["dumm","DUMM","dumm","67"],"correct":0}],"rules":{"movement":"step","fail":"lives","lives":"3","pts":"10"},"mgSettings":{},"worldSeed":2820088432,"worldLocked":false}

1
play/codes/T9B7XH.json Normal file
View file

@ -0,0 +1 @@
{"devName":"no_abo","name":"star wars","desc":"","figure":"ninja","figure2":"knight","playerCount":2,"background":"fantasy","fieldCount":30,"fields":[null,null,"spotdiff","simon","quiz",null,null,"quiz","reaction",null,"maze","quiz","flappy",null,"basketball","quiz",null,"memory","flappy2p",null,"snake","typing",null,"quiz","snake2p",null,"spotdiff",null,null,null],"storyItems":[],"quizData":[{"fieldIndex":4,"question":"Auf welchem Planeten Wurde Aniken von obi-wan besiegt","answers":["Nabu","exegol","mustarfar","corusant"],"correct":2},{"fieldIndex":7,"question":"wer wird ein Macht Geist","answers":["Mace windu","Kit Fisto","Plo Koon","Yoda"],"correct":3},{"fieldIndex":11,"question":"wer ist Anakin Skywalkers Frau","answers":["Leia Organa","Ahsoka Tano","Padmé Amidala","Rey"],"correct":2},{"fieldIndex":15,"question":"Wer ist Edward Kenways Sohn","answers":["Haythen","conner","Ezio","Altair"],"correct":0},{"fieldIndex":23,"question":"wann kam das erste Assassine creed","answers":["2007","2016","2000","2005"],"correct":0}],"rules":{"movement":"dice","fail":"points","lives":"3","pts":"10"},"mgSettings":{"flappy":{"win":20,"speed":2.7,"gap":168,"rampSpeed":true,"shrinkGap":true}},"worldSeed":198389643,"worldLocked":false}

1
play/codes/UU6CZ4.json Normal file
View file

@ -0,0 +1 @@
{"devName":"LaraK","name":"1,2,3 RAUS","desc":"Wen du auf die zahlen Reihe komst must du ein Spiel spielen ob du raus fliegst oder nicht","figure":"knight","figure2":"astronaut","playerCount":2,"background":"underwater","fieldCount":50,"fields":[null,null,null,"snake",null,null,"flappy",null,null,"basketball",null,null,"maze",null,null,"simon",null,null,"catch",null,null,"reaction",null,null,"puzzle",null,null,"spotdiff",null,null,"typing",null,null,"spotdiff",null,null,"memory",null,null,"spotdiff",null,null,"typing",null,null,"reaction",null,null,"puzzle",null],"storyItems":[],"quizData":[],"rules":{"movement":"dice","fail":"lives","lives":"2","pts":"10"},"mgSettings":{"flappy":{"win":15,"speed":2.7,"gap":168,"rampSpeed":true,"shrinkGap":true}},"worldSeed":2188213609,"worldLocked":true}

1
play/codes/VZUA7B.json Normal file
View file

@ -0,0 +1 @@
{"devName":"GambleMASTER","name":"GostRUN","desc":"","figure":"ninja","figure2":"dragon","playerCount":2,"background":"haunted","fieldCount":50,"fields":[null,null,"memory",null,null,"spotdiff",null,"puzzle","flappy2p",null,"spotdiff",null,"flappy",null,"puzzle",null,"snake2p",null,"reaction",null,"puzzle",null,"maze","simon",null,"quiz","typing",null,null,"spotdiff",null,null,"reaction",null,"typing",null,"basketball",null,"quiz","reaction",null,null,null,"snake",null,"typing","quiz",null,"catch",null],"storyItems":[],"quizData":[{"fieldIndex":25,"question":"Wie viele Weltwunder gibt es","answers":["6","7","19","27"],"correct":1},{"fieldIndex":38,"question":"Wann endete der 2. Weltkrieg","answers":["1933","2026","1820","1945"],"correct":3},{"fieldIndex":46,"question":"Wer hat Minecraft erfunden","answers":["Notch","Wotch","Lotch","Noch"],"correct":0}],"rules":{"movement":"dice","fail":"lives","lives":"3","pts":"10"},"mgSettings":{"flappy":{"win":15,"speed":2.7,"gap":168,"rampSpeed":true,"shrinkGap":true}},"worldSeed":3312271192,"worldLocked":false}

1
play/codes/W53XAM.json Normal file
View file

@ -0,0 +1 @@
{"devName":"Stefan","name":"Aulendorfer Adventure","desc":"","figure":"knight","figure2":"cat","playerCount":2,"background":"space","fieldCount":10,"fields":[null,"snake2p","snake",null,null,null,null,null,null,null],"storyItems":[],"quizData":[],"rules":{"movement":"dice","fail":"lives","lives":"3","pts":"10"},"mgSettings":{},"worldSeed":1959313746,"worldLocked":false}

1
play/codes/WYSG2Q.json Normal file
View file

@ -0,0 +1 @@
{"devName":"Lohen und Varka","name":"Tempel des Adeps","desc":"Varka und Lohen sind in sumeru angekommen und müssen jetzt vom adep fliehen ","figure":"ninja","figure2":"superhero","playerCount":2,"background":"fantasy","fieldCount":20,"fields":[null,"basketball","puzzle",null,"maze","quiz",null,null,"catch","reaction","quiz",null,"puzzle",null,"reaction",null,null,"quiz","flappy2p",null],"storyItems":[],"quizData":[{"fieldIndex":5,"question":"wie heiß ittos vorname ","answers":["paki ","arata","arataki","kikata"],"correct":2},{"fieldIndex":10,"question":"wie heißt die göttin der wälder","answers":["diah","hida","navie","nahida"],"correct":3},{"fieldIndex":17,"question":"welcher supporter ist der beste","answers":["citlali","BENNET ","escoffie","Furina "],"correct":0}],"rules":{"movement":"dice","fail":"lives","lives":"3","pts":"10"},"mgSettings":{"flappy":{"win":30,"speed":3.3,"gap":155,"rampSpeed":false,"shrinkGap":true}},"worldSeed":2721658814,"worldLocked":false}

1
play/codes/XSCYZN.json Normal file
View file

@ -0,0 +1 @@
{"devName":"ORCAPIPER","name":"Goast Run","desc":"bezwinge das geister haus","figure":"alien","figure2":"ninja","playerCount":2,"background":"haunted","fieldCount":12,"fields":[null,"reaction","snake","flappy","simon","maze","catch","basketball","typing","flappy2p","snake2p",null],"storyItems":[],"quizData":[],"rules":{"movement":"step","fail":"points","lives":"3","pts":"10"},"mgSettings":{"flappy":{"win":15,"speed":2.7,"gap":168,"rampSpeed":true,"shrinkGap":true}},"worldSeed":4170920395,"worldLocked":true}

125
play/index.html Normal file
View file

@ -0,0 +1,125 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Spiel laden — Edu Boardgame Generator</title>
<link href="https://fonts.googleapis.com/css2?family=Fredoka+One&family=Nunito:wght@400;700;800;900&display=swap" rel="stylesheet">
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;}
body{
background:radial-gradient(ellipse at top,#1e1b4b 0%,#050314 70%);
color:#e2e0f0;font-family:'Nunito',sans-serif;
min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px;
}
.card{
background:#1a1827;border:1px solid #2e2b4a;border-radius:22px;
padding:36px 32px;max-width:460px;width:100%;text-align:center;
box-shadow:0 24px 70px rgba(0,0,0,0.7);
}
h1{font-family:'Fredoka One',cursive;color:#a78bfa;margin-bottom:6px;font-size:1.8rem;}
.sub{color:#a7a3c2;margin-bottom:26px;font-size:14px;line-height:1.5;}
.code-input{
background:#0f0e17;border:2px solid #2e2b4a;border-radius:14px;
color:#fff;font-size:34px;font-weight:900;letter-spacing:10px;
font-family:'Courier New',monospace;padding:18px 14px;width:100%;
text-align:center;text-transform:uppercase;
transition:border-color 0.2s, box-shadow 0.2s;
}
.code-input::placeholder{color:#3a3658;letter-spacing:10px;}
.code-input:focus{outline:none;border-color:#7c3aed;box-shadow:0 0 0 4px rgba(124,58,237,0.18);}
.go-btn{
background:linear-gradient(135deg,#7c3aed,#a78bfa);color:#fff;
font-family:'Fredoka One',cursive;font-size:1.15rem;border:none;border-radius:12px;
padding:14px 36px;margin-top:18px;cursor:pointer;
transition:transform 0.15s,box-shadow 0.15s;
}
.go-btn:hover{transform:translateY(-2px);box-shadow:0 10px 30px rgba(124,58,237,0.55);}
.go-btn:disabled{opacity:0.5;cursor:not-allowed;transform:none;box-shadow:none;}
.err{color:#ef4444;margin-top:14px;min-height:20px;font-size:13px;font-weight:700;}
.ok{color:#a78bfa;}
.hint{margin-top:24px;color:#5b5680;font-size:11px;line-height:1.5;}
.logo{font-size:2.4rem;margin-bottom:10px;}
</style>
</head>
<body>
<div class="card">
<div class="logo">🎲</div>
<h1>Spiel laden</h1>
<p class="sub">Gib den 6-stelligen Code ein, den du auf deinem Ausdruck findest.</p>
<input id="code" class="code-input" maxlength="9" placeholder="——————" autofocus
autocapitalize="characters" autocomplete="off" spellcheck="false" inputmode="text">
<br>
<button id="goBtn" class="go-btn" onclick="loadGame()">Spiel starten →</button>
<div class="err" id="err"></div>
<div class="hint">Edu Boardgame Generator · PH Weingarten</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pako/2.1.0/pako.min.js"></script>
<script>
const ALPHA_RE = /[23456789A-HJ-NP-Z]/g;
function cleanCode(v) {
return (v || '').toUpperCase().match(ALPHA_RE)?.join('') || '';
}
async function loadGame() {
const inp = document.getElementById('code');
const err = document.getElementById('err');
const btn = document.getElementById('goBtn');
const raw = cleanCode(inp.value);
if (raw.length !== 6) {
err.className = 'err';
err.textContent = 'Bitte 6 Zeichen eingeben (Buchstaben + Zahlen).';
return;
}
err.className = 'err ok';
err.textContent = 'Lade Spiel …';
btn.disabled = true;
try {
const res = await fetch('codes/' + raw + '.json', { cache: 'no-store' });
if (!res.ok) {
err.className = 'err';
err.textContent = res.status === 404
? '⚠️ Code unbekannt. Schau nochmal auf deinen Ausdruck.'
: 'Fehler ' + res.status + ' beim Laden.';
btn.disabled = false;
return;
}
const cfg = await res.json();
// Hash bauen wie share.js es macht (pako deflate + base64)
const json = JSON.stringify(cfg);
const compressed = pako.deflate(json, { level: 9 });
const binary = Array.from(compressed).map(b => String.fromCharCode(b)).join('');
const encoded = 'z:' + btoa(binary);
location.href = '../game.html#' + encoded;
} catch (e) {
err.className = 'err';
err.textContent = 'Verbindungsfehler. Bist du online?';
btn.disabled = false;
console.error(e);
}
}
// Auto-format input (Großbuchstaben, nur erlaubte Zeichen, max 6)
const inp = document.getElementById('code');
inp.addEventListener('input', () => {
const clean = cleanCode(inp.value).slice(0, 6);
if (inp.value !== clean) inp.value = clean;
});
inp.addEventListener('keypress', e => {
if (e.key === 'Enter') loadGame();
});
// Auto-load via URL hash (z.B. /play/#ABC234)
if (location.hash.length > 1) {
const fromHash = cleanCode(location.hash.slice(1));
if (fromHash.length === 6) {
inp.value = fromHash;
setTimeout(loadGame, 100);
}
}
</script>
</body>
</html>