268 lines
12 KiB
JavaScript
268 lines
12 KiB
JavaScript
/**
|
|
* 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 };
|
|
|
|
})();
|