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