/** * minigames/snake.js * 🐍 Snake — Steuere die Schlange, friss 10 Äpfel ohne gegen die Wand zu fahren */ window.MG_snake = (function() { const ID = 'snake'; const EMOJI = '🐍'; const NAME = 'Snake'; const DESC = '3 Leben, 15 Äpfel sammeln. Pfeiltasten oder WASD.'; const CONTROLS = 'Pfeiltasten / WASD'; const MULTI = 1; function run(wrap, W, H, cfg, onDone) { const { canvas, ctx } = MGAPI.makeCanvas(wrap, W, H); const SZ = 20; const COLS = Math.floor(W / SZ); const ROWS = Math.floor(H / SZ); const WIN_SCORE = cfg.winScore || 15; const MAX_LIVES = cfg.lives || 3; const theme = cfg.theme || { primary: '#10b981', glow: 'rgba(16,185,129,0.4)' }; // Startgeschwindigkeit 160ms, pro Apfel -12ms, Minimum 55ms const BASE_INTERVAL = 160; const SPEED_INC = 12; const MIN_INTERVAL = 55; function interval(sc) { return Math.max(MIN_INTERVAL, BASE_INTERVAL - sc * SPEED_INC); } function rndFood(snk) { let f; do { f = { x: Math.floor(Math.random() * COLS), y: Math.floor(Math.random() * ROWS) }; } while (snk.some(s => s.x === f.x && s.y === f.y)); return f; } function startSnake() { return [{ x: 5, y: 5 }, { x: 4, y: 5 }, { x: 3, y: 5 }]; } let snake = startSnake(); let dir = { x: 1, y: 0 }; let nextDir = { x: 1, y: 0 }; let food = rndFood(snake); let score = 0; let lives = MAX_LIVES; let dead = false; let dying = false; let stopped = false; let raf, endTimer, respawnTimer, last = 0; function onKey(e) { const map = { ArrowUp: { x: 0, y: -1 }, w: { x: 0, y: -1 }, ArrowDown: { x: 0, y: 1 }, s: { x: 0, y: 1 }, ArrowLeft: { x: -1, y: 0 }, a: { x: -1, y: 0 }, ArrowRight: { x: 1, y: 0 }, d: { x: 1, y: 0 }, }; const d = map[e.key]; if (d && (d.x !== -dir.x || d.y !== -dir.y)) { e.preventDefault(); nextDir = d; } } document.addEventListener('keydown', onKey); let touchStart = null; canvas.addEventListener('touchstart', e => { touchStart = { x: e.touches[0].clientX, y: e.touches[0].clientY }; }, { passive: true }); canvas.addEventListener('touchend', e => { if (!touchStart) return; const dx = e.changedTouches[0].clientX - touchStart.x; const dy = e.changedTouches[0].clientY - touchStart.y; if (Math.abs(dx) > Math.abs(dy)) nextDir = dx > 0 ? { x: 1, y: 0 } : { x: -1, y: 0 }; else nextDir = dy > 0 ? { x: 0, y: 1 } : { x: 0, y: -1 }; }, { passive: true }); function die() { if (dying) return; dying = true; dead = true; lives--; if (lives <= 0) { if (!endTimer) endTimer = setTimeout(() => onDone(false), 1300); } else { respawnTimer = setTimeout(() => { snake = startSnake(); dir = { x: 1, y: 0 }; nextDir = { x: 1, y: 0 }; food = rndFood(snake); dead = false; dying = false; last = 0; }, 1000); } } function drawGrid() { ctx.strokeStyle = 'rgba(255,255,255,0.04)'; ctx.lineWidth = 0.5; for (let x = 0; x < COLS; x++) for (let y = 0; y < ROWS; y++) ctx.strokeRect(x * SZ, y * SZ, SZ, SZ); } function loop(ts) { if (stopped) return; raf = requestAnimationFrame(loop); // Zeichnen immer, Spiellogik nur im Takt const tick = ts - last >= interval(score); if (tick && !dead) { last = ts; dir = nextDir; const head = { x: snake[0].x + dir.x, y: snake[0].y + dir.y }; if ( head.x < 0 || head.x >= COLS || head.y < 0 || head.y >= ROWS || snake.some(s => s.x === head.x && s.y === head.y) ) { die(); } else { snake.unshift(head); if (head.x === food.x && head.y === food.y) { score++; food = rndFood(snake); if (score >= WIN_SCORE && !endTimer) endTimer = setTimeout(() => onDone(true), 800); // Schlange wächst automatisch (kein pop) } else { snake.pop(); } } } // --- Zeichnen --- ctx.fillStyle = '#050508'; ctx.fillRect(0, 0, W, H); drawGrid(); // Futter ctx.shadowColor = theme.primary; ctx.shadowBlur = 12; ctx.font = `${SZ - 2}px serif`; ctx.textAlign = 'center'; ctx.fillText('🍎', food.x * SZ + SZ / 2, food.y * SZ + SZ / 1.2); ctx.shadowBlur = 0; // Schlange snake.forEach((s, i) => { const alpha = i === 0 ? 'ff' : Math.max(0x44, 0x88 - i * 2).toString(16).padStart(2, '0'); ctx.fillStyle = i === 0 ? theme.primary : `${theme.primary}${alpha}`; if (i === 0) { ctx.shadowColor = theme.primary; ctx.shadowBlur = 8; } MGAPI.roundRect(ctx, s.x * SZ + 2, s.y * SZ + 2, SZ - 4, SZ - 4, 4, ctx.fillStyle, null); ctx.shadowBlur = 0; }); // HUD MGAPI.text(ctx, `🍎 ${score} / ${WIN_SCORE}`, 8, 14, { align: 'left', size: 13, color: theme.primary }); const hearts = '❤️'.repeat(lives) + '🖤'.repeat(Math.max(0, MAX_LIVES - lives)); MGAPI.text(ctx, hearts, W / 2, 14, { align: 'center', size: 12 }); // Geschwindigkeitsanzeige const spd = Math.round((BASE_INTERVAL / Math.max(MIN_INTERVAL, interval(score))) * 10) / 10; MGAPI.text(ctx, `⚡ ${spd.toFixed(1)}x`, W - 8, 14, { align: 'right', size: 11, color: 'rgba(255,255,255,0.4)' }); if (dead && lives <= 0) { MGAPI.resultScreen(ctx, W, H, false, `Nur ${score} Äpfel — nächstes Mal!`); } else if (dead && dying) { MGAPI.text(ctx, `${lives} ${lives === 1 ? 'Leben' : 'Leben'} übrig`, W / 2, H / 2, { size: 16, color: '#ef4444' }); } if (score >= WIN_SCORE && !dead) MGAPI.resultScreen(ctx, W, H, true, `${score} Äpfel gefressen — geschafft!`); } raf = requestAnimationFrame(loop); return { stop() { stopped = true; cancelAnimationFrame(raf); clearTimeout(endTimer); clearTimeout(respawnTimer); document.removeEventListener('keydown', onKey); }, }; } 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, winScore: 15, lives: 3 }, won => MGAPI.onResult(won)); } return { id: ID, emoji: EMOJI, name: NAME, desc: DESC, controls: CONTROLS, multi: MULTI, launch, preview }; })();