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