/* 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.

'; 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!
`; } }); } 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'; }