edu-boardgame-generator/minigames/maze.js

194 lines
6.9 KiB
JavaScript

/**
* minigames/maze.js
* 🌀 Labyrinth — Finde den Ausgang in der Zeit!
*/
window.MG_maze = (function() {
const ID = 'maze';
const EMOJI = '🌀';
const NAME = 'Labyrinth';
const DESC = '2 Leben, 15 Sekunden pro Versuch. Pfeiltasten zum Steuern!';
const CONTROLS = 'Pfeiltasten / WASD';
const MULTI = 3;
function generateMaze(COLS, ROWS) {
const cells = Array.from({ length: ROWS }, () =>
Array.from({ length: COLS }, () => ({ n: true, s: true, e: true, w: true, visited: false }))
);
const stack = [];
let cur = { c: 0, r: 0 };
cells[0][0].visited = true;
stack.push(cur);
while (stack.length) {
const { c, r } = stack[stack.length - 1];
const neighbors = [];
if (r > 0 && !cells[r-1][c].visited) neighbors.push({ c, r: r-1, dir: 'n' });
if (r < ROWS-1 && !cells[r+1][c].visited) neighbors.push({ c, r: r+1, dir: 's' });
if (c < COLS-1 && !cells[r][c+1].visited) neighbors.push({ c: c+1, r, dir: 'e' });
if (c > 0 && !cells[r][c-1].visited) neighbors.push({ c: c-1, r, dir: 'w' });
if (!neighbors.length) { stack.pop(); continue; }
const next = neighbors[Math.floor(Math.random() * neighbors.length)];
cells[r][c][next.dir] = false;
cells[next.r][next.c][{ n:'s', s:'n', e:'w', w:'e' }[next.dir]] = false;
cells[next.r][next.c].visited = true;
stack.push({ c: next.c, r: next.r });
}
return cells;
}
function run(wrap, W, H, cfg, onDone) {
const { canvas, ctx } = MGAPI.makeCanvas(wrap, W, H);
const theme = cfg.theme || { primary: '#6366f1' };
const MAX_LIVES = 2;
const TIME_LIMIT = 15;
const COLS = 9, ROWS = 7;
const cellW = Math.floor((W - 16) / COLS);
const cellH = Math.floor((H - 48) / ROWS);
const OX = (W - COLS * cellW) / 2;
const OY = 38;
const WALL = 2;
let maze = generateMaze(COLS, ROWS);
let px = 0, py = 0;
let lives = MAX_LIVES;
let timeLeft = TIME_LIMIT;
let startTs = null;
let won = false;
let gameOver = false;
let stopped = false;
let raf, endTimer;
function resetRound(ts) {
maze = generateMaze(COLS, ROWS);
px = 0; py = 0;
startTs = ts;
timeLeft = TIME_LIMIT;
}
function tryMove(dc, dr) {
if (won || gameOver) return;
const cell = maze[py][px];
const dir = dc === 1 ? 'e' : dc === -1 ? 'w' : dr === 1 ? 's' : 'n';
if (cell[dir]) return;
px += dc; py += dr;
if (px === COLS - 1 && py === ROWS - 1 && !endTimer) {
won = true;
endTimer = setTimeout(() => onDone(true), 900);
}
}
const held = {};
const onKey = e => {
const map = {
ArrowUp:[0,-1], ArrowDown:[0,1], ArrowLeft:[-1,0], ArrowRight:[1,0],
w:[0,-1], s:[0,1], a:[-1,0], d:[1,0],
};
const d = map[e.key];
if (!d) return;
e.preventDefault();
if (e.type === 'keydown') { if (!held[e.key]) tryMove(d[0], d[1]); held[e.key] = true; }
else held[e.key] = false;
};
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;
Math.abs(dx) > Math.abs(dy) ? tryMove(dx > 0 ? 1 : -1, 0) : tryMove(0, dy > 0 ? 1 : -1);
}, { passive: true });
document.addEventListener('keydown', onKey);
document.addEventListener('keyup', onKey);
function drawMaze() {
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const x = OX + c * cellW, y = OY + r * cellH;
const cell = maze[r][c];
ctx.strokeStyle = `${theme.primary}88`;
ctx.lineWidth = WALL;
if (cell.n && r === 0) { ctx.beginPath(); ctx.moveTo(x,y); ctx.lineTo(x+cellW,y); ctx.stroke(); }
if (cell.w && c === 0) { ctx.beginPath(); ctx.moveTo(x,y); ctx.lineTo(x,y+cellH); ctx.stroke(); }
if (cell.s) { ctx.beginPath(); ctx.moveTo(x,y+cellH); ctx.lineTo(x+cellW,y+cellH); ctx.stroke(); }
if (cell.e) { ctx.beginPath(); ctx.moveTo(x+cellW,y); ctx.lineTo(x+cellW,y+cellH); ctx.stroke(); }
}
}
}
function loop(ts) {
if (stopped) return;
raf = requestAnimationFrame(loop);
if (startTs === null) startTs = ts;
// Timer
if (!won && !gameOver) {
timeLeft = Math.max(0, TIME_LIMIT - (ts - startTs) / 1000);
if (timeLeft <= 0) {
lives--;
if (lives <= 0) {
gameOver = true;
if (!endTimer) endTimer = setTimeout(() => onDone(false), 1100);
} else {
resetRound(ts);
}
}
}
ctx.fillStyle = '#050508';
ctx.fillRect(0, 0, W, H);
MGAPI.roundRect(ctx, OX, OY, cellW, cellH, 4, 'rgba(16,185,129,0.2)', null);
MGAPI.roundRect(ctx, OX+(COLS-1)*cellW, OY+(ROWS-1)*cellH, cellW, cellH, 4, 'rgba(245,166,35,0.25)', null);
drawMaze();
ctx.font = '14px serif'; ctx.textAlign = 'center';
ctx.fillText('🟢', OX + cellW/2, OY + cellH/2 + 5);
ctx.fillText('🏁', OX + (COLS-0.5)*cellW, OY + (ROWS-0.5)*cellH + 5);
// Spieler
const plX = OX + px * cellW + cellW / 2;
const plY = OY + py * cellH + cellH / 2;
ctx.shadowColor = theme.primary; ctx.shadowBlur = 14;
ctx.fillStyle = theme.primary;
ctx.beginPath();
ctx.arc(plX, plY, Math.min(cellW, cellH) * 0.32, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
// HUD: Timer + Herzen
const tSec = Math.ceil(timeLeft);
const tColor = timeLeft < 5 ? '#ef4444' : timeLeft < 8 ? '#f59e0b' : theme.primary;
MGAPI.text(ctx, `${tSec}s`, W / 2, 16, { size: 13, color: tColor });
const hearts = '❤️'.repeat(lives) + '🖤'.repeat(Math.max(0, MAX_LIVES - lives));
MGAPI.text(ctx, hearts, W - 10, 16, { align: 'right', size: 12 });
MGAPI.text(ctx, '← ↑ → ↓', 10, 16, { align: 'left', size: 10, color: 'rgba(255,255,255,0.3)' });
if (won) MGAPI.resultScreen(ctx, W, H, true, 'Ausgang gefunden! ⏱ ' + (TIME_LIMIT - timeLeft).toFixed(1) + 's');
if (gameOver) MGAPI.resultScreen(ctx, W, H, false, 'Zeit abgelaufen — nächstes Mal!');
}
raf = requestAnimationFrame(loop);
return {
stop() {
stopped = true;
cancelAnimationFrame(raf);
clearTimeout(endTimer);
document.removeEventListener('keydown', onKey);
document.removeEventListener('keyup', 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, won => MGAPI.onResult(won)); }
return { id: ID, emoji: EMOJI, name: NAME, desc: DESC, controls: CONTROLS, multi: MULTI, launch, preview };
})();