/** * minigames/flappy.js * 🐦 Flappy — Dark-Neon-Look, flüssig (Fixed-Timestep + Interpolation). * Die Spielfigur ist die im Editor gewählte Welt-Figur (als leuchtender Komet mit Schweif). * Eigene Vektor-/Canvas-Grafik, keine externen Assets. */ window.MG_flappy = (function () { const ID = 'flappy', EMOJI = '🐦', NAME = 'Flappy Bird'; const DESC = '3 Leben, 15 Hindernisse. Klick oder Leertaste zum Fliegen!'; const CONTROLS = 'Klick / Leertaste'; const MULTI = 1; 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) { let 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(''); } function rgba(hex, a) { return `rgba(${hx(hex, 0)},${hx(hex, 2)},${hx(hex, 4)},${a})`; } function run(wrap, W, H, cfg, onDone) { const { canvas, ctx } = MGAPI.makeCanvas(wrap, W, H); 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 figEmoji = (cfg && cfg.figure) || '🐦'; const WIN_PIPES = (cfg && (cfg.win || cfg.winPipes)) || 15; const MAX_LIVES = (cfg && cfg.lives) || 3; // ── Physik (pro festem 1/60-Schritt) — einstellbar via cfg.speed / cfg.gap ── const STEP = 1000 / 60; const GRAV = 0.42, FLAP = -7.2, PW = 46; const BASE_SPEED = (cfg && cfg.speed) || 2.7; const SPEED_INC = (cfg && cfg.rampSpeed === false) ? 0 : 0.07; // Tempo steigt pro Durchgang (Toggle) const BASE_GAP = (cfg && cfg.gap) || 168; const GAP_DEC = (cfg && cfg.shrinkGap === false) ? 0 : 1.6; // Durchgänge werden enger (Toggle) const MIN_GAP = Math.max(90, BASE_GAP - 36), minTop = 36; 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; // ── Zustand ── let state = 'ready'; let bird = { y: PLAY_H / 2, py: PLAY_H / 2, vy: 0, angle: 0 }; let pipes = [], trail = []; let score = 0, lives = MAX_LIVES, speed = BASE_SPEED; let worldScroll = 0, pWorldScroll = 0; let simTime = 0, flapAnim = 0, shake = 0, flash = 0; let stopped = false, raf = null, endTimer = null, respawnTimer = null; let lastTime = 0, acc = 0; let puffs = [], pops = [], confetti = []; 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 }); // Dunkler Himmel in Welt-Farbe (einmalig) const skyGrad = ctx.createLinearGradient(0, 0, 0, H); skyGrad.addColorStop(0, '#080810'); skyGrad.addColorStop(0.6, shade(accentDeep, -40)); skyGrad.addColorStop(1, accentDeep); // Figur einmal mit Neon-Glow als Sprite vorrendern → danach nur noch blitten const SPR = 72, dpr = window.devicePixelRatio || 1; const spr = document.createElement('canvas'); spr.width = SPR * dpr; spr.height = SPR * dpr; const sctx = spr.getContext('2d'); sctx.scale(dpr, dpr); sctx.textAlign = 'center'; sctx.textBaseline = 'middle'; sctx.font = `${Math.round(SPR * 0.55)}px serif`; sctx.shadowColor = accentLight; sctx.shadowBlur = 18; sctx.fillText(figEmoji, SPR / 2, SPR / 2); sctx.shadowBlur = 10; sctx.fillText(figEmoji, SPR / 2, SPR / 2); 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 flap() { if (stopped || state === 'dying' || state === 'won') return; if (state === 'ready') { state = 'play'; spawnPipe(); } bird.vy = FLAP; flapAnim = 1; for (let i = 0; i < 4; i++) puffs.push({ x: BIRD_X - BR, y: bird.y + (Math.random() - 0.5) * 14, r: 2, a: 0.6 }); } const onKey = e => { if (e.code === 'Space' || e.code === 'ArrowUp') { e.preventDefault(); flap(); } }; document.addEventListener('keydown', onKey); canvas.addEventListener('mousedown', flap); canvas.addEventListener('touchstart', e => { e.preventDefault(); flap(); }, { passive: false }); function hitPipe(p) { const r = BR - 4; if (BIRD_X + r > p.x && BIRD_X - r < p.x + PW) return bird.y - r < p.gap || bird.y + r > p.gap + p.g; return false; } function loseLife() { if (state === 'dying' || state === 'won') return; shake = 14; flash = 0.55; lives--; state = 'dying'; bird.vy = Math.min(bird.vy, 1.5); if (lives <= 0) endTimer = setTimeout(() => onDone(false), 1400); else respawnTimer = setTimeout(() => { bird = { y: PLAY_H / 2, py: PLAY_H / 2, vy: 0, angle: 0 }; pipes = pipes.filter(p => p.x > BIRD_X + 150); trail = []; state = 'play'; }, 1100); } function win() { if (state === 'won') return; state = 'won'; const cols = [accent, accentLight, '#ffffff', shade(accent, 40)]; for (let i = 0; i < 70; i++) confetti.push({ x: W / 2, y: PLAY_H * 0.4, vx: (Math.random() - 0.5) * 9, vy: -3 - Math.random() * 6, c: cols[i % cols.length], r: 3 + Math.random() * 3, a: 1 }); endTimer = setTimeout(() => onDone(true), 1800); } function step() { pWorldScroll = worldScroll; simTime += STEP; flapAnim = Math.max(0, flapAnim - 0.08); if (shake > 0) shake = Math.max(0, shake - 0.7); if (flash > 0) flash = Math.max(0, flash - 0.04); stars.forEach(s => { s.x -= speed * 0.12; if (s.x < -2) { s.x = W + 2; s.y = Math.random() * PLAY_H; } }); if (state === 'ready') { bird.py = bird.y; bird.y = PLAY_H / 2 + Math.sin(simTime * 0.005) * 8; return; } if (state === 'won') { bird.py = bird.y; confetti.forEach(p => { p.x += p.vx; p.y += p.vy; p.vy += 0.25; p.a -= 0.009; }); confetti = confetti.filter(p => p.a > 0); return; } bird.py = bird.y; bird.vy += GRAV; bird.y += bird.vy; bird.angle = lerp(bird.angle, clamp(bird.vy * 0.06, -0.5, 1.4), 0.18); trail.unshift(bird.y); if (trail.length > 8) trail.pop(); puffs.forEach(p => { p.r += 1.1; p.a -= 0.06; p.x -= speed; }); puffs = puffs.filter(p => p.a > 0); pops.forEach(p => { p.y -= 0.8; p.a -= 0.02; p.scale = Math.min(1.3, p.scale + 0.06); }); pops = pops.filter(p => p.a > 0); if (state === 'dying') { bird.angle = Math.min(1.7, bird.angle + 0.12); return; } worldScroll += speed; pipes.forEach(p => { p.px = p.x; p.x -= speed; }); if (!pipes.length || pipes[pipes.length - 1].x < W - 200) 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; pops.push({ x: BIRD_X + 26, y: bird.y - 22, a: 1, scale: 0.6 }); if (score >= WIN_PIPES) win(); } }); if (bird.y + (BR - 4) > PLAY_H) { bird.y = PLAY_H - (BR - 4); loseLife(); } else if (bird.y - (BR - 4) < 0) { bird.y = BR - 4; bird.vy = 0; } if (state === 'play') for (const p of pipes) if (hitPipe(p)) { loseLife(); break; } } function drawPipe(x, p) { const topH = p.gap, botY = p.gap + p.g, glow = rgba(accent, 0.16); const body = (yy, hh) => { if (hh <= 0) return; ctx.fillStyle = glow; ctx.fillRect(x - 6, yy, PW + 12, hh); ctx.fillStyle = 'rgba(10,12,24,0.80)'; 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 = rgba(accent, 0.20); 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 = rgba(accent, 0.18); const tile = 34, off = -(((scroll % tile) + tile) % tile); for (let x = off; x < W; x += tile) ctx.fillRect(x, gy + 6, 16, 2); } function render(alpha) { 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); ctx.fillStyle = skyGrad; ctx.fillRect(-12, -12, W + 24, H + 24); // Sterne stars.forEach(s => { ctx.globalAlpha = s.a * (0.5 + 0.5 * Math.sin(simTime * 0.004 + s.p)); ctx.fillStyle = '#cdd6ff'; ctx.fillRect(s.x, s.y, s.r, s.r); }); ctx.globalAlpha = 1; pipes.forEach(p => drawPipe(lerp(p.px, p.x, alpha), p)); drawFloor(lerp(pWorldScroll, worldScroll, alpha)); // Flap-Püffchen puffs.forEach(p => { ctx.globalAlpha = p.a; ctx.fillStyle = accentLight; ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); ctx.fill(); }); ctx.globalAlpha = 1; const headY = lerp(bird.py, bird.y, alpha); // Komet-Schweif for (let i = trail.length - 1; i >= 0; i--) { const t = 1 - i / trail.length; ctx.globalAlpha = 0.28 * t; ctx.fillStyle = accentLight; ctx.beginPath(); ctx.arc(BIRD_X - i * 7, trail[i], (BR * 0.55) * t + 1.5, 0, Math.PI * 2); ctx.fill(); } ctx.globalAlpha = 1; // Figur (Sprite-Blit, rotiert) ctx.save(); ctx.translate(BIRD_X, headY); ctx.rotate(bird.angle); ctx.drawImage(spr, -SPR / 2, -SPR / 2, SPR, SPR); ctx.restore(); pops.forEach(p => { ctx.save(); ctx.globalAlpha = Math.max(0, p.a); MGAPI.text(ctx, '+1', p.x, p.y, { size: 18 * p.scale, color: accentLight, weight: 'bold' }); ctx.restore(); }); confetti.forEach(p => { ctx.globalAlpha = Math.max(0, p.a); ctx.fillStyle = p.c; ctx.fillRect(p.x, p.y, p.r, p.r); }); ctx.globalAlpha = 1; ctx.restore(); // HUD MGAPI.text(ctx, `${score} / ${WIN_PIPES}`, W / 2, 28, { size: 22, color: '#fff', weight: 'bold', family: "'Fredoka One',cursive" }); const hearts = '❤️'.repeat(Math.max(0, lives)) + '🤍'.repeat(Math.max(0, MAX_LIVES - lives)); MGAPI.text(ctx, hearts, 10, 16, { align: 'left', size: 13 }); if (state === 'ready') MGAPI.text(ctx, '👆 Tippen oder Leertaste', W / 2, PLAY_H * 0.62, { size: 15, color: accentLight, weight: 'bold' }); if (flash > 0) { ctx.fillStyle = `rgba(255,255,255,${flash})`; ctx.fillRect(0, 0, W, H); } if (state === 'dying' && lives <= 0) MGAPI.resultScreen(ctx, W, H, false, `${score} Hindernisse — nochmal!`); if (state === 'won') MGAPI.resultScreen(ctx, W, H, true, `Alle ${WIN_PIPES} geschafft! 🎉`); } function loop(ts) { if (stopped) return; raf = requestAnimationFrame(loop); if (!lastTime) lastTime = ts; acc += Math.min(ts - lastTime, STEP * 5); lastTime = ts; while (acc >= STEP) { step(); acc -= STEP; } render(acc / STEP); } raf = requestAnimationFrame(loop); return { stop() { stopped = true; cancelAnimationFrame(raf); clearTimeout(endTimer); clearTimeout(respawnTimer); document.removeEventListener('keydown', onKey); canvas.removeEventListener('mousedown', flap); }, }; } function launch(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); } function preview(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); } return { id: ID, emoji: EMOJI, name: NAME, desc: DESC, controls: CONTROLS, multi: MULTI, launch, preview }; })();