edu-boardgame-generator/minigames/snake.js

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