/* game.js — Spiel-Runtime: Config laden, Board, Würfel, Mini-Game-Ablauf */
/* ── XSS helper ── */
function esc(s){return String(s??'').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,''');}
/* roundRect polyfill for older browsers */
if(!CanvasRenderingContext2D.prototype.roundRect){
CanvasRenderingContext2D.prototype.roundRect=function(x,y,w,h,r){
r=Math.min(r,w/2,h/2);
this.moveTo(x+r,y);this.lineTo(x+w-r,y);this.arcTo(x+w,y,x+w,y+r,r);
this.lineTo(x+w,y+h-r);this.arcTo(x+w,y+h,x+w-r,y+h,r);
this.lineTo(x+r,y+h);this.arcTo(x,y+h,x,y+h-r,r);
this.lineTo(x,y+r);this.arcTo(x,y,x+r,y,r);this.closePath();
return this;
};
}
/* ══════════ CONFIG ══════════ */
// 1. Versuche URL-Hash (geteilter Link), 2. Fallback localStorage, 3. Demo-Config
function loadGameConfig() {
const hash = window.location.hash.slice(1); // '#' entfernen
if (hash) {
try {
if (hash.startsWith('z:')) {
// Komprimiert: Base64 → binary → pako.inflate → JSON
const b64 = hash.slice(2);
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
const json = pako.inflate(bytes, { to: 'string' });
return JSON.parse(json);
} else if (hash.startsWith('j:')) {
// Unkomprimiert Base64
return JSON.parse(decodeURIComponent(escape(atob(hash.slice(2)))));
}
} catch(e) {
console.warn('URL-Hash konnte nicht gelesen werden:', e);
}
}
// Fallback: localStorage (vom Editor)
try {
const stored = localStorage.getItem('gameConfig');
if (stored) return JSON.parse(stored);
} catch(e) {
document.body.innerHTML = '
⚠️
Spielkonfiguration fehlerhaft Die gespeicherten Spieldaten konnten nicht geladen werden. Bitte gehe zurück zum Editor.
Fenster schließen ';
return null;
}
// Demo-Config
return {name:'Mein Spiel',desc:'Ein tolles Brettspiel!',figure:'robot',
background:'space',fieldCount:10,
fields:['snake','flappy','quiz','memory','reaction',null,'snake','quiz',null,''],
rules:{movement:'dice',fail:'lives',lives:'3',pts:'10'}};
}
const cfg = loadGameConfig();
if (!cfg) throw new Error('No config');
// ── Defense-in-Depth: Konfig aus URL/Hash strikt validieren (verhindert DOM-XSS) ──
(function sanitizeCfg(){
const OK_FIG = ['robot','ninja','knight','cat','rocket','dino','alien','superhero','pirate','fox','dragon','astronaut'];
const OK_BG = ['underwater','space','haunted','fantasy','ocean','jungle','volcano','snow','city','candy','desert','school','future'];
const OK_MG = ['snake','flappy','memory','quiz','reaction','basketball','catch','maze','simon','puzzle','spotdiff','typing','snake2p','flappy2p'];
const clamp = (v,lo,hi,def)=>{const n=parseInt(v); return isFinite(n)?Math.max(lo,Math.min(hi,n)):def;};
const str = (v,max)=>String(v??'').slice(0,max);
cfg.devName = str(cfg.devName, 60);
cfg.name = str(cfg.name, 80);
cfg.desc = str(cfg.desc, 200);
if(cfg.figure && !OK_FIG.includes(cfg.figure)) cfg.figure = '';
if(cfg.figure2 && !OK_FIG.includes(cfg.figure2)) cfg.figure2 = '';
if(cfg.background && !OK_BG.includes(cfg.background)) cfg.background = 'space';
cfg.playerCount = (cfg.playerCount===2)?2:1;
cfg.fieldCount = clamp(cfg.fieldCount, 6, 50, 10);
cfg.fields = (Array.isArray(cfg.fields)?cfg.fields:[]).map(f => (typeof f==='string' && OK_MG.includes(f))?f:null);
while(cfg.fields.length < cfg.fieldCount) cfg.fields.push(null);
cfg.fields.length = cfg.fieldCount;
cfg.rules = cfg.rules || {};
cfg.rules.movement = (cfg.rules.movement==='step')?'step':'dice';
cfg.rules.fail = (cfg.rules.fail==='points')?'points':'lives';
cfg.rules.lives = String(clamp(cfg.rules.lives, 1, 99, 3));
cfg.rules.pts = String(clamp(cfg.rules.pts, 1, 999, 10));
cfg.storyItems = (Array.isArray(cfg.storyItems)?cfg.storyItems:[]).filter(s=>s&&typeof s==='object').map(s=>({
id: String(s.id||'').replace(/[^a-zA-Z0-9_]/g,'').slice(0,30) || ('s'+Math.random().toString(36).slice(2,8)),
emoji: str(s.emoji||'📖', 8),
text: str(s.text, 300),
position: (s.position==='after')?'after':'before',
fieldIndex: clamp(s.fieldIndex, 0, cfg.fieldCount-1, 0),
}));
cfg.quizData = (Array.isArray(cfg.quizData)?cfg.quizData:[]).filter(q=>q&&typeof q==='object').map(q=>({
fieldIndex: clamp(q.fieldIndex, 0, cfg.fieldCount-1, 0),
question: str(q.question, 200),
answers: (Array.isArray(q.answers)?q.answers:[]).slice(0,4).map(a=>str(a,80)),
correct: (q.correct!=null && q.correct>=0 && q.correct<=3) ? (parseInt(q.correct)||0) : null,
}));
const cq = cfg.consequences || {};
const OK_WIN = ['nothing','forward','points','again'];
const OK_LOSE = ['nothing','back','life','points','skip'];
cfg.consequences = {
win: OK_WIN.includes(cq.win)?cq.win:'nothing',
lose: OK_LOSE.includes(cq.lose)?cq.lose:'nothing',
winVal: clamp(cq.winVal, 1, 10, 2),
loseVal: clamp(cq.loseVal, 1, 10, 1),
winPts: clamp(cq.winPts, 1, 999, 10),
losePts: clamp(cq.losePts, 1, 999, 5),
};
cfg.worldSeed = (parseInt(cfg.worldSeed)||0) >>> 0;
cfg.worldLocked = !!cfg.worldLocked;
cfg.mgSettings = (cfg.mgSettings && typeof cfg.mgSettings==='object') ? cfg.mgSettings : {};
})();
const fields = (cfg.fields||[]).map(f=>f||null);
const fieldCount = cfg.fieldCount || fields.length || 10;
// Sanitize: Start- und Zielfeld dürfen NIE ein Minigame haben (sonst Sieg-Check feuert vorher → MG unerreichbar)
if (fields.length > 0) fields[0] = null;
while (fields.length < fieldCount) fields.push(null);
fields[fieldCount - 1] = null;
const rules = cfg.rules || {movement:'dice',fail:'lives',lives:'3',pts:'10'};
const storyItems = cfg.storyItems || [];
const quizData = cfg.quizData || [];
const FIGURES={robot:'🤖',ninja:'🥷',knight:'🧙♂️',cat:'🐱',rocket:'🚀',dino:'🦖',alien:'👾',superhero:'🦸',pirate:'🏴☠️',fox:'🦊',dragon:'🐲',astronaut:'👨🚀'};
const BG_THEMES={
underwater:{primary:'#22d3ee',bg:'#082f49',glow:'rgba(34,211,238,0.4)',sky:'#0c4a6e',particle:'🫧'},
space: {primary:'#a78bfa',bg:'#050314',glow:'rgba(167,139,250,0.45)',sky:'#1e1b4b',particle:'⭐'},
haunted: {primary:'#86efac',bg:'#0a0a14',glow:'rgba(134,239,172,0.4)',sky:'#1a1a2e',particle:'👻'},
fantasy: {primary:'#f0abfc',bg:'#1a0a2e',glow:'rgba(240,171,252,0.45)',sky:'#3b0764',particle:'✨'},
};
// Migration alter Welten-IDs auf die 4 verbliebenen (vor THEME-Lookup!)
const LEGACY_BG={ocean:'underwater',jungle:'fantasy',volcano:'space',snow:'space',city:'space',candy:'fantasy',desert:'fantasy',school:'fantasy',future:'space'};
if(cfg.background && !BG_THEMES[cfg.background] && LEGACY_BG[cfg.background]) cfg.background=LEGACY_BG[cfg.background];
const THEME = BG_THEMES[cfg.background] || BG_THEMES.space;
const MG_INFO={
snake: {emoji:'🐍',name:'Snake', desc:'Steuere die Schlange! Iss das Essen ohne gegen die Wand zu fahren.',controls:'Pfeiltasten oder WASD'},
flappy: {emoji:'🐦',name:'Flappy Bird', desc:'Klick oder Leertaste, um durch die Röhren zu fliegen!', controls:'Klick / Leertaste'},
memory: {emoji:'🃏',name:'Memory', desc:'Finde alle Paare — so schnell wie möglich!', controls:'Mausklick'},
quiz: {emoji:'❓',name:'Quiz', desc:'Beantworte die Frage richtig!', controls:'Mausklick'},
reaction: {emoji:'⚡',name:'Reaktion', desc:'Drück den Knopf so schnell du kannst wenn er erscheint!', controls:'Klick / Leertaste'},
basketball:{emoji:'🏀',name:'Basketball', desc:'Ziehe den Ball und lass ihn los um zu werfen!', controls:'Maus ziehen'},
catch: {emoji:'🍎',name:'Äpfel fangen',desc:'Bewege den Korb mit den Pfeiltasten!', controls:'← → oder A/D'},
maze: {emoji:'🌀',name:'Labyrinth', desc:'Finde den Ausgang!', controls:'Pfeiltasten / WASD'},
simon: {emoji:'🔴',name:'Simon Says', desc:'Merke und wiederhole die Farb-Sequenz!', controls:'Mausklick'},
puzzle: {emoji:'🧩',name:'Rätsel', desc:'Löse die Aufgabe!', controls:'Mausklick'},
spotdiff: {emoji:'🔍',name:'Unterschiede',desc:'Finde alle Unterschiede!', controls:'Mausklick'},
typing: {emoji:'⌨️',name:'Tipp-Rennen', desc:'Tippe das Wort so schnell wie möglich!', controls:'Tastatur'},
};
const figEmoji = FIGURES[cfg.figure] || '🎮';
const LETTERS = ['A','B','C','D'];
/* ══════════ APPLY THEME ══════════ */
document.documentElement.style.setProperty('--theme-primary', THEME.primary);
document.documentElement.style.setProperty('--theme-bg', THEME.bg);
document.documentElement.style.setProperty('--theme-glow', THEME.glow);
document.title = cfg.name || 'Spiel';
document.getElementById('pageTitle').textContent = cfg.name || 'Spiel';
/* ══════════ INTRO SETUP ══════════ */
document.getElementById('introFigure').textContent = figEmoji;
document.getElementById('introTitle').innerHTML =
esc(cfg.name||'Mein Spiel').replace(/(\S+)\s*$/, '$1 ');
document.getElementById('introDesc').textContent = cfg.desc || '';
document.getElementById('hudTitle').textContent = cfg.name || 'Spiel';
const meta = document.getElementById('introMeta');
const BGNAMES={underwater:'Unterwasser',space:'Weltall',haunted:'Geisterhaus',fantasy:'Fantasy'};
const FIGNAMES={robot:'Roboter',ninja:'Ninja',knight:'Zauberer',cat:'Katze',rocket:'Rakete',dino:'Dino',alien:'Alien',superhero:'Superheld',pirate:'Pirat',fox:'Fuchs',dragon:'Drache',astronaut:'Astronaut'};
meta.innerHTML = [
`${figEmoji} ${FIGNAMES[cfg.figure]||'Figur'}
`,
`🌍 ${BGNAMES[cfg.background]||'Setting'}
`,
`⬛ ${fieldCount} Felder
`,
`${rules.fail==='lives'?'❤️ '+rules.lives+' Leben':'⭐ Punkte'}
`,
].join('');
/* ══════════ BG CANVAS (particle/ambient) ══════════ */
const bgCanvas = document.getElementById('bgCanvas');
const bgCtx = bgCanvas.getContext('2d');
let bgW, bgH, bgParticles=[];
function resizeBg() {
bgCanvas.width = bgW = window.innerWidth;
bgCanvas.height = bgH = window.innerHeight;
}
resizeBg(); window.addEventListener('resize', resizeBg);
function initParticles(count=60) {
bgParticles=[];
for(let i=0;i{
bgCtx.globalAlpha = p.alpha * (0.5+0.5*Math.sin(Date.now()*0.001+p.x));
bgCtx.font = p.size+'px serif';
bgCtx.fillText(p.emoji, p.x, p.y);
p.x += p.vx; p.y += p.vy;
if(p.y < -30) { p.y=bgH+20; p.x=Math.random()*bgW; }
if(p.x < -30 || p.x > bgW+30) p.vx *= -1;
});
bgCtx.globalAlpha=1;
bgCtx.restore();
requestAnimationFrame(drawBg);
}
drawBg();
/* ══════════ AUDIO (Web Audio API) ══════════ */
let audioCtx = null;
let musicNodes = [];
function startMusic() {
try {
audioCtx = new (window.AudioContext||window.webkitAudioContext)();
const master = audioCtx.createGain();
master.gain.value = 0.08;
master.connect(audioCtx.destination);
// Theme-specific chord progressions
const THEMES_MUSIC = {
jungle: [[220,277,330],[196,247,294],[165,208,247],[185,233,277]],
space: [[174,220,261],[155,196,233],[138,174,207],[130,164,196]],
ocean: [[261,329,392],[220,277,329],[196,247,294],[174,220,261]],
fantasy: [[220,277,370],[196,247,330],[174,220,294],[155,196,261]],
school: [[261,329,392],[294,370,440],[330,415,494],[261,329,392]],
volcano: [[164,207,247],[146,185,220],[130,164,196],[155,196,233]],
snow: [[293,370,440],[261,329,392],[220,277,329],[246,311,370]],
city: [[220,277,330],[196,247,294],[174,220,261],[185,233,294]],
candy: [[329,415,494],[294,370,440],[261,329,392],[277,349,415]],
desert: [[196,247,294],[174,220,261],[164,207,247],[155,196,233]],
haunted: [[174,207,247],[155,185,220],[138,164,196],[130,155,185]],
future: [[261,329,440],[220,277,370],[196,247,330],[174,220,294]],
};
const chords = THEMES_MUSIC[cfg.background] || THEMES_MUSIC.space;
let chord = 0;
function playChord() {
const now = audioCtx.currentTime;
const notes = chords[chord % chords.length];
notes.forEach(freq => {
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = cfg.background === 'haunted' ? 'sawtooth' : cfg.background === 'space' ? 'sine' : 'triangle';
osc.frequency.value = freq;
gain.gain.setValueAtTime(0, now);
gain.gain.linearRampToValueAtTime(0.15, now+0.3);
gain.gain.linearRampToValueAtTime(0.05, now+1.5);
gain.gain.linearRampToValueAtTime(0, now+2.5);
osc.connect(gain); gain.connect(master);
osc.start(now); osc.stop(now+2.6);
musicNodes.push(osc);
});
chord++;
}
// Add subtle beat
function playBeat() {
const now = audioCtx.currentTime;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type='sine'; osc.frequency.value=80;
gain.gain.setValueAtTime(0.4,now);
gain.gain.linearRampToValueAtTime(0,now+0.1);
osc.connect(gain);gain.connect(master);
osc.start(now);osc.stop(now+0.12);
}
playChord();
const chordInterval = setInterval(playChord, 2800);
const beatInterval = setInterval(playBeat, 700);
musicNodes.push({ stop:()=>{ clearInterval(chordInterval); clearInterval(beatInterval); }});
} catch(e) { console.log('Audio not available:', e); }
}
function playSfx(type) {
if (!audioCtx) return;
try {
const now = audioCtx.currentTime;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
const sfx = {
roll: {freq:440, type:'sine', dur:0.15, vol:0.2},
land: {freq:330, type:'triangle',dur:0.2, vol:0.15},
win: {freq:660, type:'sine', dur:0.4, vol:0.25},
lose: {freq:110, type:'sawtooth',dur:0.5, vol:0.2},
story: {freq:528, type:'sine', dur:0.3, vol:0.15},
mg: {freq:550, type:'triangle',dur:0.25, vol:0.2},
};
const s = sfx[type] || sfx.land;
osc.type = s.type; osc.frequency.value = s.freq;
gain.gain.setValueAtTime(s.vol, now);
if(type==='win'){osc.frequency.setValueAtTime(660,now);osc.frequency.linearRampToValueAtTime(880,now+0.2);}
gain.gain.linearRampToValueAtTime(0, now+s.dur);
osc.connect(gain); gain.connect(audioCtx.destination);
osc.start(now); osc.stop(now+s.dur+0.05);
} catch(e){}
}
/* ══════════ BOARD CANVAS (World-Engine) ══════════ */
const bc = document.getElementById('boardCanvas');
const bctx = bc.getContext('2d');
// DPR-aware sizing — Canvas füllt die .board-area
let bW = 0, bH = 0, bDpr = 1;
function resizeBoard() {
const ba = document.querySelector('.board-area');
if (!ba) return;
const dpr = window.devicePixelRatio || 1;
const w = Math.max(640, ba.clientWidth - 24);
const h = Math.max(420, ba.clientHeight - 24);
bc.width = Math.round(w * dpr);
bc.height = Math.round(h * dpr);
bc.style.width = w + 'px';
bc.style.height = h + 'px';
bctx.setTransform(dpr, 0, 0, dpr, 0, 0);
bW = w; bH = h; bDpr = dpr;
}
resizeBoard();
window.addEventListener('resize', resizeBoard);
// Welt aus cfg.worldSeed (Fallback: deterministischer Hash aus Name+Dev)
const worldSeed = (cfg.worldSeed >>> 0) || World.hashSeed((cfg.name||'') + '|' + (cfg.devName||'') + '|' + (cfg.background||''));
const world = World.generate(cfg.background, worldSeed, fieldCount);
// ── Game state (Multiplayer-ready: players[]-Array + cur-Index) ──
let cur = 0; // aktiver Spieler-Index
function _makePlayer(idx, emoji){
return {
id: idx,
figEmoji: emoji,
pos: 0,
lives: parseInt(rules.lives)||3,
maxLives: parseInt(rules.lives)||3,
points: 0,
visited: new Set([0]),
gamesPlayed: [],
figX: 0, figY: 0, figTargX: 0, figTargY: 0,
skipNext: false,
eliminated: false,
};
}
const players = [_makePlayer(0, figEmoji)];
if(cfg.playerCount === 2){
const fig2Emoji = FIGURES[cfg.figure2] || '🦊';
players.push(_makePlayer(1, fig2Emoji));
}
function me() { return players[cur]; }
function nextPlayer(){
if(players.length < 2) return;
for(let i=1; i<=players.length; i++){
const next=(cur+i)%players.length;
if(!players[next].eliminated){ cur=next; return; }
}
// alle eliminiert: cur bleibt
}
function turnEnd(){
if(gameEnded) return;
if(players.length < 2){ enableRoll(); return; }
nextPlayer();
handleTurnStart();
}
function handleTurnStart(){
if(gameEnded) return;
// Aussetz-Flag prüfen (2P)
if(players.length >= 2 && me().skipNext){
me().skipNext = false;
showToast(`⏭️ Spieler ${cur+1} ${me().figEmoji} setzt aus!`, 1500);
setTimeout(()=>{ nextPlayer(); handleTurnStart(); }, 1500);
return;
}
enableRoll();
updateHUD();
if(players.length >= 2){
showToast(`▶ Spieler ${cur+1} ${me().figEmoji} ist dran!`, 1600);
}
}
let quizIdx=0;
let rolling=false, waitingForAction=false;
let pendingStories=[], pendingStoryIdx=0;
let currentMgId=null, mgActive=null, mgResult=null;
let gameEnded=false;
let pendingExtraRoll=false, pendingSkipRoll=false;
function fieldCenter(i) { return World.padCenter(world, i, bW, bH); }
function updateFig() {
const p = me();
const c = fieldCenter(p.pos);
p.figTargX = c.x; p.figTargY = c.y;
}
updateFig();
players.forEach(p => { p.figX = p.figTargX; p.figY = p.figTargY; });
function drawBoard() {
// Pause während ein Mini-Game offen ist (Board liegt verdeckt hinter dem Overlay)
if (document.getElementById('mgOverlay')?.classList.contains('open')) { requestAnimationFrame(drawBoard); return; }
bctx.clearRect(0, 0, bW, bH);
// Jede Spielfigur sanft Richtung Zielpad bewegen
for (const p of players) {
const tc = fieldCenter(p.pos);
p.figTargX = tc.x; p.figTargY = tc.y;
p.figX += (p.figTargX - p.figX) * 0.14;
p.figY += (p.figTargY - p.figY) * 0.14;
}
const meP = me();
World.render(bctx, world, {
W: bW, H: bH,
pos: meP.pos, visited: meP.visited, fields, storyItems,
// Neue Multi-Figuren-API
figures: players.map((p, i) => ({ emoji: p.figEmoji, x: p.figX, y: p.figY, active: i===cur, dimmed: p.eliminated })),
// Backwards-Compat (alter Single-Figure-Pfad in world.js)
figEmoji: meP.figEmoji, figX: meP.figX, figY: meP.figY,
gameName: cfg.name, devName: cfg.devName,
}, performance.now());
requestAnimationFrame(drawBoard);
}
// Alte Render-Implementierung (durch World-Engine ersetzt) entfernt — Ende drawBoard.
/* ══════════ SCREEN TRANSITION ══════════ */
function switchScreen(from, to, cb) {
const t=document.getElementById('transOverlay');
t.classList.add('flash');
setTimeout(()=>{
document.getElementById(from).classList.remove('active');
document.getElementById(to).classList.add('active');
t.classList.remove('flash');
cb && cb();
}, 400);
}
/* ══════════ START ══════════ */
function startGame() {
startMusic();
switchScreen('introScreen','gameScreen',()=>{
updateHUD();
requestAnimationFrame(drawBoard);
if(players.length >= 2){
setTimeout(()=>showToast(`▶ Spieler 1 ${players[0].figEmoji} fängt an!`, 2200), 800);
} else {
setTimeout(()=>showToast(`${figEmoji} Viel Spaß, ${cfg.devName||'Spieler'}! Würfle zum Starten!`), 800);
}
});
}
/* ══════════ HUD ══════════ */
function updateHUD() {
const is2P = players.length >= 2;
// 1P-Pillen ein/aus
document.getElementById('hudPosPill').style.display = is2P ? 'none' : '';
document.getElementById('hudLifePill').style.display = is2P ? 'none' : (rules.fail==='lives' ? '' : 'none');
document.getElementById('hudPtPill').style.display = is2P ? 'none' : (rules.fail==='points' ? '' : 'none');
document.getElementById('hud2p').style.display = is2P ? '' : 'none';
if(is2P){
// Beide Spieler-Pillen befüllen
for(let i=0; i<2; i++){
const p = players[i];
const fig = document.getElementById('hp'+(i+1)+'Fig');
const pos = document.getElementById('hp'+(i+1)+'Pos');
const stat= document.getElementById('hp'+(i+1)+'Stat');
const box = document.getElementById('hudP'+(i+1));
if(fig) fig.textContent = p.figEmoji;
if(pos) pos.textContent = `${p.pos+1}/${fieldCount}`;
if(stat){
if(rules.fail==='lives'){
stat.textContent = '❤️'.repeat(p.lives)+'🖤'.repeat(Math.max(0,p.maxLives-p.lives));
} else {
stat.textContent = '⭐ '+p.points;
}
}
if(box){
box.classList.toggle('active', i===cur && !p.eliminated);
box.classList.toggle('eliminated', !!p.eliminated);
}
}
} else {
const p = me();
document.getElementById('hudPos').textContent=`${p.pos+1}/${fieldCount}`;
if(rules.fail==='lives'){
document.getElementById('hudLives').textContent='❤️'.repeat(p.lives)+'🖤'.repeat(Math.max(0,p.maxLives-p.lives));
} else {
document.getElementById('hudPts').textContent=p.points;
}
}
}
/* ══════════ TOAST ══════════ */
let toastTimer=null;
function showToast(msg, dur=2500) {
const t=document.getElementById('toast');
t.textContent=msg; t.classList.add('show');
clearTimeout(toastTimer);
toastTimer=setTimeout(()=>t.classList.remove('show'), dur);
}
/* ══════════ DICE & MOVEMENT ══════════ */
const isStepMode = rules.movement === 'step';
// UI an Bewegungs-Modus anpassen (Schritt-Modus: kein Würfel, sondern "Nächster Zug")
(function setupMovementUI(){
const lbl = document.getElementById('diceLabel');
const em = document.getElementById('diceEmoji');
const btn = document.getElementById('btnRoll');
if(isStepMode){
if(lbl) lbl.textContent = 'Nächster Zug';
if(em) em.textContent = '👣';
if(btn) btn.innerHTML = '👣 Weiter';
}
})();
function doRoll() {
if(rolling||waitingForAction||gameEnded) return;
rolling=true;
document.getElementById('btnRoll').disabled=true;
playSfx('roll');
const de=document.getElementById('diceEmoji');
de.classList.add('rolling');
if(isStepMode){
// Schritt-Modus: kurze Animation, dann 1 Feld weiter
setTimeout(()=>{
de.classList.remove('rolling');
showToast('👣 1 Feld weiter!');
setTimeout(()=>movePlayer(1), 300);
}, 400);
return;
}
// Würfel-Modus: Faces rotieren, dann Zufallszahl
const faces=['⚀','⚁','⚂','⚃','⚄','⚅'];
let t=0;
const iv=setInterval(()=>{
de.textContent=faces[Math.floor(Math.random()*6)];
t++;
if(t>10){
clearInterval(iv);
de.classList.remove('rolling');
const steps=Math.floor(Math.random()*6)+1;
de.textContent=faces[steps-1];
showToast(`🎲 ${steps} ${steps===1?'Feld':'Felder'} vor!`);
setTimeout(()=>movePlayer(steps), 500);
}
}, 70);
}
function movePlayer(steps) {
const p = me();
const newPos=Math.min(p.pos+steps,fieldCount-1);
// Animate step by step
let walkPos=p.pos;
function doStep(){
if(walkPos>=newPos){
p.pos=newPos; p.visited.add(newPos); rolling=false;
playSfx('land');
updateHUD();
updateFig();
processLanding();
return;
}
walkPos++;
p.visited.add(walkPos);
p.figTargX=fieldCenter(walkPos).x;
p.figTargY=fieldCenter(walkPos).y;
setTimeout(doStep, 220);
}
doStep();
}
/* ══════════ LANDING LOGIC ══════════ */
function processLanding() {
const p = me();
// Check stories BEFORE
const sBefore=storyItems.filter(s=>s.position==='before'&&s.fieldIndex===p.pos);
const sAfter =storyItems.filter(s=>s.position==='after' &&s.fieldIndex===p.pos);
// Win check: letztes Feld — erst Erzähltexte zeigen, dann gewinnen
if(p.pos===fieldCount-1){
if(sBefore.length>0){
pendingStories=[...sBefore, {_win:true}, ...sAfter];
pendingStoryIdx=0;
processNextStoryOrField();
} else {
setTimeout(showWin, 400);
}
return;
}
if(sBefore.length>0){
pendingStories=[...sBefore, {_field:true}, ...sAfter];
pendingStoryIdx=0;
processNextStoryOrField();
} else {
triggerFieldEvent(sAfter);
}
}
function processNextStoryOrField(){
const item=pendingStories[pendingStoryIdx];
if(!item){ turnEnd(); return; }
if(item._win){
setTimeout(showWin, 400);
return;
}
if(item._field){
pendingStoryIdx++;
const remaining=pendingStories.slice(pendingStoryIdx);
triggerFieldEvent(remaining);
return;
}
pendingStoryIdx++;
openStory(item);
}
function openStory(s){
playSfx('story');
document.getElementById('storyEmoji').textContent=s.emoji||'📖';
document.getElementById('storyText').textContent=s.text||'...';
document.getElementById('storyOverlay').classList.add('open');
}
function closeStory(){
document.getElementById('storyOverlay').classList.remove('open');
processNextStoryOrField();
}
function triggerFieldEvent(afterStories){
const p = me();
const gameId=fields[p.pos];
if(!gameId||p.pos===0){
// Empty field — check after stories
if(afterStories&&afterStories.length>0){
pendingStories=afterStories; pendingStoryIdx=0; processNextStoryOrField();
} else { showToast('⬜ Leeres Feld — kein Mini-Game.'); turnEnd(); }
return;
}
// Store after stories for post-mg
pendingStories=afterStories||[];
pendingStoryIdx=0;
openMinigame(gameId);
}
function enableRoll(){
waitingForAction=false;
document.getElementById('btnRoll').disabled=false;
}
/* ══════════ MINIGAME SYSTEM ══════════ */
function openMinigame(id){
const info=MG_INFO[id]||{emoji:'🎮',name:id,desc:'Mini-Game!',controls:'Variiert'};
currentMgId=id;
document.getElementById('mgTitle').innerHTML=`${info.emoji} ${info.name}`;
document.getElementById('mgDesc').textContent=info.desc;
document.getElementById('mgControls').innerHTML=`🎮 ${info.controls}`;
document.getElementById('mgResultBar').style.display='none';
document.getElementById('btnMgContinue').style.display='none';
const wrap=document.getElementById('mgCanvasWrap');
wrap.innerHTML='';
if(mgActive&&mgActive.stop) mgActive.stop();
mgActive=null; mgResult=null;
playSfx('mg');
document.getElementById('mgOverlay').classList.add('open');
// Größe aus dem mg-canvas-wrap ableiten (sobald das Overlay sichtbar ist), so groß wie der Test im Editor
let W = 1040, H = 560;
// Overlay ist gerade erst sichtbar — Browser muss Layout berechnen
// Im nächsten Frame lesen wir die echten Maße des Wraps und übergeben sie ans Modul
// (rAF-Verzögerung wird unten beim Aufruf abgewickelt)
// Wire external MG modules to finishMinigame (oder finishDuelMinigame bei 2P-Duell)
const isDuel = (id === 'snake2p' || id === 'flappy2p');
window._mgOnResult = isDuel ? finishDuelMinigame : finishMinigame;
const mgCfg = {
theme: THEME, quizData, rules,
devName: cfg.devName, gameName: cfg.name,
figure: FIGURES[cfg.figure] || '🐦',
figure2: cfg.figure2 ? (FIGURES[cfg.figure2] || '🦊') : null,
fieldIndex: me().pos, // Quiz nutzt das, um die feldspezifische Frage zu finden
};
if (cfg.mgSettings && cfg.mgSettings[id]) Object.assign(mgCfg, cfg.mgSettings[id]); // eingestellte Schwierigkeit übernehmen
const launchers={
snake: (w,W,H)=>window.MG_snake&&window.MG_snake.launch(w,W,H,mgCfg),
flappy: (w,W,H)=>window.MG_flappy&&window.MG_flappy.launch(w,W,H,mgCfg),
memory: (w,W,H)=>window.MG_memory&&window.MG_memory.launch(w,W,H,mgCfg),
quiz: (w,W,H)=>window.MG_quiz&&window.MG_quiz.launch(w,W,H,mgCfg),
reaction: (w,W,H)=>window.MG_reaction&&window.MG_reaction.launch(w,W,H,mgCfg),
basketball: (w,W,H)=>window.MG_basketball&&window.MG_basketball.launch(w,W,H,mgCfg),
catch: (w,W,H)=>window.MG_catch&&window.MG_catch.launch(w,W,H,mgCfg),
maze: (w,W,H)=>window.MG_maze&&window.MG_maze.launch(w,W,H,mgCfg),
simon: (w,W,H)=>window.MG_simon&&window.MG_simon.launch(w,W,H,mgCfg),
puzzle: (w,W,H)=>window.MG_puzzle&&window.MG_puzzle.launch(w,W,H,mgCfg),
spotdiff: (w,W,H)=>window.MG_spotdiff&&window.MG_spotdiff.launch(w,W,H,mgCfg),
typing: (w,W,H)=>window.MG_typing&&window.MG_typing.launch(w,W,H,mgCfg),
snake2p: (w,W,H)=>window.MG_snake2p&&window.MG_snake2p.launch(w,W,H,mgCfg),
flappy2p: (w,W,H)=>window.MG_flappy2p&&window.MG_flappy2p.launch(w,W,H,mgCfg),
};
// Layout des Wraps abwarten und ECHTE Maße verwenden (so wie's das Editor-Test-Popup macht)
requestAnimationFrame(()=>{
const rect = wrap.getBoundingClientRect();
const Wv = Math.max(360, Math.round(rect.width)) || W;
const Hv = Math.max(280, Math.round(rect.height)) || H;
if(launchers[id]){
const result=launchers[id](wrap,Wv,Hv);
mgActive=result||{stop:()=>{}};
}
else {
// Fallback placeholder
wrap.style.minHeight=Hv+'px';
wrap.innerHTML=`
${esc(info.emoji)}
${esc(info.name)}
Dieses Mini-Game wird bald verfügbar sein!
✅ Bestanden!
`;
}
});
}
function finishMinigame(won){
const p = me();
if(mgActive&&mgActive.stop) mgActive.stop();
mgActive=null;
p.gamesPlayed.push(currentMgId);
const rb=document.getElementById('mgResultBar');
rb.className='mg-result-bar '+(won?'win':'lose');
rb.style.display='block';
document.getElementById('mgResultTitle').textContent=won?'🎉 Gewonnen!':'💀 Verloren!';
// ── Basis-Spielregel-Konsequenz ──
let subMsg = won?'Super gemacht!':'Nicht schlimm — weiter gehts!';
if(!won && rules.fail==='lives'){
p.lives=Math.max(0,p.lives-1);
subMsg=`Du verlierst ein Leben. Noch ${p.lives} übrig.`;
playSfx('lose');
} else if(won && rules.fail==='points'){
const pts=parseInt(rules.pts)||10;
p.points+=pts;
subMsg=`+${pts} Punkte! Insgesamt: ${p.points}`;
playSfx('win');
} else {
if(won) playSfx('win'); else playSfx('lose');
}
// ── Zusatz-Konsequenz (if/else aus Editor Schritt 3) ──
const cq = cfg.consequences || {};
const action = won ? (cq.win||'nothing') : (cq.lose||'nothing');
let cqMsg = '';
if(action === 'forward'){
const v = parseInt(won ? cq.winVal : cq.loseVal) || (won?2:1);
p.pos = Math.min(fieldCount-1, p.pos+v);
p.visited.add(p.pos);
updateFig();
cqMsg = `+${v} Felder vor!`;
// Bug 3 Fix: Sieg prüfen wenn Konsequenz ans Ziel schiebt
if(p.pos === fieldCount-1){ setTimeout(showWin, 1600); }
} else if(action === 'back'){
const v = parseInt(cq.loseVal) || 1;
p.pos = Math.max(0, p.pos-v);
p.visited.add(p.pos);
updateFig();
cqMsg = `-${v} Felder zurück!`;
} else if(action === 'life' && !won && rules.fail !== 'lives'){
p.lives = Math.max(0, p.lives-1);
cqMsg = `Noch ${p.lives} Leben.`;
} else if(action === 'points'){
const v = parseInt(won ? cq.winPts : cq.losePts) || (won?10:5);
if(won){ p.points+=v; cqMsg=`+${v} Punkte!`; }
else { p.points=Math.max(0,p.points-v); cqMsg=`-${v} Punkte!`; }
} else if(action === 'again' && won){
pendingExtraRoll = true;
cqMsg = 'Nochmal würfeln!';
} else if(action === 'skip' && !won){
pendingSkipRoll = true;
cqMsg = 'Nächste Runde aussetzen!';
}
document.getElementById('mgResultSub').textContent =
cqMsg ? `${subMsg} ${cqMsg}` : subMsg;
updateHUD();
document.getElementById('btnMgContinue').style.display='block';
if(rules.fail==='lives' && p.lives<=0){ setTimeout(showGameOver,1200); }
}
/* ══════════ DUEL-MINIGAME (snake2p) ══════════ */
function finishDuelMinigame(winnerIdx){
if(mgActive&&mgActive.stop) mgActive.stop();
mgActive=null;
const rb=document.getElementById('mgResultBar');
rb.style.display='block';
// Beide Spieler protokollieren das gespielte Minispiel
players.forEach(p => p.gamesPlayed.push(currentMgId));
if(winnerIdx === -1){
// Unentschieden — keine Konsequenzen
rb.className='mg-result-bar';
document.getElementById('mgResultTitle').textContent = '🤝 Unentschieden!';
document.getElementById('mgResultSub').textContent = 'Niemand gewinnt — der Zug geht normal weiter.';
playSfx('mg');
updateHUD();
document.getElementById('btnMgContinue').style.display='block';
return;
}
const winner = players[winnerIdx];
const loser = players[1 - winnerIdx];
rb.className='mg-result-bar win';
document.getElementById('mgResultTitle').textContent =
`🏆 Spieler ${winnerIdx+1} ${winner.figEmoji} gewinnt das Duell!`;
const subParts = [];
// ── Basis-Spielregel ──
if(rules.fail==='lives'){
loser.lives = Math.max(0, loser.lives-1);
subParts.push(`${loser.figEmoji} −1 Leben (${loser.lives})`);
playSfx('lose');
} else if(rules.fail==='points'){
const pts = parseInt(rules.pts)||10;
winner.points += pts;
subParts.push(`+${pts} Punkte für ${winner.figEmoji}`);
playSfx('win');
} else {
playSfx('win');
}
// ── Sieg-Konsequenz für Sieger ──
const cq = cfg.consequences || {};
const winAction = cq.win || 'nothing';
const loseAction = cq.lose || 'nothing';
if(winAction === 'forward'){
const v = parseInt(cq.winVal)||2;
winner.pos = Math.min(fieldCount-1, winner.pos+v);
winner.visited.add(winner.pos);
subParts.push(`${winner.figEmoji} +${v} Felder`);
if(winner.pos === fieldCount-1){ setTimeout(()=>showWin(winnerIdx), 1800); }
} else if(winAction === 'points'){
const v = parseInt(cq.winPts)||10;
winner.points += v;
subParts.push(`+${v} Punkte für ${winner.figEmoji}`);
} else if(winAction === 'again'){
// Nur sinnvoll, wenn Sieger der aktive Spieler ist (er hat gewürfelt)
if(winnerIdx === cur){ pendingExtraRoll = true; subParts.push(`${winner.figEmoji} darf nochmal würfeln!`); }
}
// ── Niederlage-Konsequenz für Verlierer ──
if(loseAction === 'back'){
const v = parseInt(cq.loseVal)||1;
loser.pos = Math.max(0, loser.pos-v);
loser.visited.add(loser.pos);
subParts.push(`${loser.figEmoji} −${v} Felder`);
} else if(loseAction === 'points'){
const v = parseInt(cq.losePts)||5;
loser.points = Math.max(0, loser.points-v);
subParts.push(`${loser.figEmoji} −${v} Punkte`);
} else if(loseAction === 'life' && rules.fail !== 'lives'){
loser.lives = Math.max(0, loser.lives-1);
subParts.push(`${loser.figEmoji} −1 Leben (${loser.lives})`);
} else if(loseAction === 'skip'){
loser.skipNext = true;
subParts.push(`${loser.figEmoji} setzt aus`);
}
document.getElementById('mgResultSub').textContent = subParts.join(' · ') || 'Weiter geht\'s!';
updateHUD();
document.getElementById('btnMgContinue').style.display='block';
// Eliminierungs-Check (Race-Sieg bei Lebensverlust)
if(rules.fail==='lives' && loser.lives<=0){
setTimeout(()=>{
loser.eliminated = true;
const other = players.find(p => !p.eliminated);
if(other) showWin(other.id);
}, 1500);
}
}
function afterMinigame(){
document.getElementById('mgOverlay').classList.remove('open');
updateHUD();
// Bug 2 Fix: pendingExtraRoll / pendingSkipRoll auswerten
if(pendingExtraRoll){
pendingExtraRoll=false;
if(pendingStories.length>0){ processNextStoryOrField(); }
else { enableRoll(); showToast('🎲 Nochmal würfeln!'); }
return;
}
if(pendingSkipRoll){
pendingSkipRoll=false;
if(pendingStories.length>0){ processNextStoryOrField(); return; }
if(players.length >= 2){
// 2P: skip-Flag setzen + sofort Turn-Switch
me().skipNext = true;
showToast('⏭️ Du setzt eine Runde aus!');
setTimeout(turnEnd, 1200);
} else {
// 1P: alter 3s-Block
enableRoll(); showToast('⏭️ Nächste Runde aussetzen — warte auf deinen Zug!');
waitingForAction=true;
setTimeout(()=>{ waitingForAction=false; showToast('▶️ Du darfst wieder würfeln!'); }, 3000);
}
return;
}
// Process after-stories
if(pendingStories.length>0){ processNextStoryOrField(); }
else { turnEnd(); }
}
function skipMinigame(){
if(mgActive&&mgActive.stop) mgActive.stop();
mgActive=null;
document.getElementById('mgOverlay').classList.remove('open');
enableRoll();
}
/* ══ SNAKE ══ */
function showWin(winnerIdx){
if(gameEnded) return;
gameEnded=true;
playSfx('win');
const winner = (winnerIdx != null) ? players[winnerIdx] : me();
const ov=document.getElementById('resultOverlay');
document.getElementById('resEmoji').textContent = winner.figEmoji + '🏆';
document.getElementById('resTitle').style.color = '';
if(players.length >= 2){
document.getElementById('resTitle').textContent = `Spieler ${winner.id+1} gewinnt!`;
document.getElementById('resSub').textContent = `${winner.figEmoji} hat das Ziel als Erster erreicht!`;
} else {
document.getElementById('resTitle').textContent='Du hast gewonnen!';
document.getElementById('resSub').textContent=`Super gemacht! Du hast alle ${fieldCount} Felder durchgespielt!`;
}
document.getElementById('resStats').innerHTML=`
${winner.gamesPlayed.length}
Mini-Games
${rules.fail==='points'?winner.points:winner.lives+'❤️'}
${rules.fail==='points'?'Punkte':'Leben übrig'}
${winner.visited.size}
Felder besucht
`;
ov.classList.add('open');
}
function showGameOver(){
if(gameEnded) return;
// 2P: Aktiver Spieler verliert alle Leben → eliminiert; wenn anderer noch lebt → Race-Sieg
if(players.length >= 2){
me().eliminated = true;
const other = players.find(p => !p.eliminated);
if(other){
showWin(other.id);
return;
}
}
gameEnded=true;
playSfx('lose');
const p = me();
const ov=document.getElementById('resultOverlay');
document.getElementById('resEmoji').textContent='💀';
document.getElementById('resTitle').textContent='Game Over!';
document.getElementById('resSub').textContent='Alle Leben aufgebraucht. Aber du kannst trotzdem Feedback geben!';
document.getElementById('resTitle').style.color='var(--danger)';
document.getElementById('resStats').innerHTML=`
${p.gamesPlayed.length}
Mini-Games gespielt
${p.visited.size}/${fieldCount}
Felder besucht
`;
ov.classList.add('open');
}
function showMenu(){ showToast('☰ Menü kommt in der finalen Version!'); }
function backToEditor(){ if(window.opener&&!window.opener.closed) window.close(); else window.location.href='editor.html'; }