edu-boardgame-generator/minigames/flappy.js

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 };
})();