1001 lines
41 KiB
JavaScript
1001 lines
41 KiB
JavaScript
/* 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,'"').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 = '<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'; }
|