edu-boardgame-generator/js/game.js

1001 lines
41 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* game.js — Spiel-Runtime: Config laden, Board, Würfel, Mini-Game-Ablauf */
/* ── XSS helper ── */
function esc(s){return String(s??'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');}
/* 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 = '<div style="font-family:sans-serif;color:#fff;background:#0f0e17;height:100vh;display:flex;align-items:center;justify-content:center;text-align:center;padding:20px"><div><div style="font-size:3rem;margin-bottom:16px">⚠️</div><h2 style="color:#f5a623">Spielkonfiguration fehlerhaft</h2><p style="color:#a7a3c2;margin-top:8px">Die gespeicherten Spieldaten konnten nicht geladen werden.<br>Bitte gehe zurück zum Editor.</p><button onclick="window.close()" style="margin-top:20px;background:#7c3aed;color:#fff;border:none;border-radius:10px;padding:12px 24px;font-size:1rem;cursor:pointer">Fenster schließen</button></div></div>';
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*$/, '<span class="highlight">$1</span>');
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 = [
`<div class="intro-meta-pill">${figEmoji} ${FIGNAMES[cfg.figure]||'Figur'}</div>`,
`<div class="intro-meta-pill">🌍 ${BGNAMES[cfg.background]||'Setting'}</div>`,
`<div class="intro-meta-pill">⬛ ${fieldCount} Felder</div>`,
`<div class="intro-meta-pill">${rules.fail==='lives'?'❤️ '+rules.lives+' Leben':'⭐ Punkte'}</div>`,
].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<count;i++) bgParticles.push({
x:Math.random()*bgW, y:Math.random()*bgH,
vx:(Math.random()-0.5)*0.3, vy:-Math.random()*0.4-0.1,
size:Math.random()*14+8, alpha:Math.random()*0.6+0.2,
emoji: THEME.particle
});
}
initParticles();
function drawBg() {
// Pause während ein Mini-Game offen ist → spart Frame-Budget, Mini-Game läuft flüssig
if (document.getElementById('mgOverlay')?.classList.contains('open')) { requestAnimationFrame(drawBg); return; }
// Sky gradient
const grad = bgCtx.createLinearGradient(0,0,0,bgH);
grad.addColorStop(0, THEME.sky);
grad.addColorStop(1, THEME.bg);
bgCtx.fillStyle = grad;
bgCtx.fillRect(0,0,bgW,bgH);
// Ambient glow
const glow = bgCtx.createRadialGradient(bgW*0.5,bgH*0.3,0, bgW*0.5,bgH*0.3,bgW*0.5);
glow.addColorStop(0, THEME.glow);
glow.addColorStop(1, 'transparent');
bgCtx.fillStyle = glow;
bgCtx.fillRect(0,0,bgW,bgH);
// Particles
bgCtx.save();
bgParticles.forEach(p=>{
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=`<div style="text-align:center;padding:40px 20px">
<div style="font-size:3rem;margin-bottom:12px">${esc(info.emoji)}</div>
<div style="font-family:'Fredoka One',cursive;font-size:1.2rem;color:${esc(THEME.primary)};margin-bottom:8px">${esc(info.name)}</div>
<div style="color:#a7a3c2;font-size:13px;margin-bottom:20px">Dieses Mini-Game wird bald verfügbar sein!</div>
<button onclick="finishMinigame(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;">✅ Bestanden!</button>
</div>`;
}
});
}
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=`
<div class="rs-pill"><div class="rs-val">${winner.gamesPlayed.length}</div><div class="rs-label">Mini-Games</div></div>
<div class="rs-pill"><div class="rs-val">${rules.fail==='points'?winner.points:winner.lives+'❤️'}</div><div class="rs-label">${rules.fail==='points'?'Punkte':'Leben übrig'}</div></div>
<div class="rs-pill"><div class="rs-val">${winner.visited.size}</div><div class="rs-label">Felder besucht</div></div>
`;
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=`
<div class="rs-pill"><div class="rs-val">${p.gamesPlayed.length}</div><div class="rs-label">Mini-Games gespielt</div></div>
<div class="rs-pill"><div class="rs-val">${p.visited.size}/${fieldCount}</div><div class="rs-label">Felder besucht</div></div>
`;
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'; }