edu-boardgame-generator/minigames/snake2p.js

353 lines
12 KiB
JavaScript

/**
* minigames/snake2p.js
* 🐍🐍 Snake-Duell — 2-Spieler Best-of-3 im Stil von snake.js
* Blau (Pfeiltasten) startet rechts · Rot (WASD) startet links.
* Ruft MGAPI.onResult(winnerIdx) mit 0 (P1), 1 (P2) oder -1 (Unentschieden).
*/
window.MG_snake2p = (function () {
const ID = 'snake2p';
const EMOJI = '🐍🐍';
const NAME = 'Snake-Duell';
const DESC = 'Best-of-3 · 🔵 Pfeile (rechts) gegen 🔴 WASD (links). 2 Äpfel auf dem Feld.';
const CONTROLS = '← ↑ → ↓ / W A S D';
const MULTI = 1;
const REQUIRES = '2p';
const TOTAL_ROUNDS = 3;
const STEP_INTERVAL = 160; // wie snake.js Start-Tempo, konstant (fair fürs Duell)
const P1_COL = '#3b82f6'; // Pfeiltasten, startet rechts
const P2_COL = '#ef4444'; // WASD, startet links
const APPLE_COL = '#f59e0b';
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 scores = [0, 0]; // [P1 (Pfeile), P2 (WASD)]
let roundIdx = 0;
let state = 'intro'; // intro | playing | roundEnd | duelEnd
let stateTimer = 0;
let lastTs = 0;
let lastStepTs = 0;
let stopped = false, raf = null;
let roundResult = null;
let startBtnRect = null;
let s1 = null, s2 = null;
let apples = [];
// ── Snake-Init ──
function startSnakeRight() {
// P1 (Pfeiltasten), Blau, startet rechts und zieht nach links
const my = Math.floor(ROWS / 2);
const x0 = Math.max(5, COLS - 6);
return { body: [{x:x0, y:my}, {x:x0+1, y:my}, {x:x0+2, y:my}], dir:{x:-1,y:0}, nextDir:{x:-1,y:0}, alive:true };
}
function startSnakeLeft() {
// P2 (WASD), Rot, startet links und zieht nach rechts
const my = Math.floor(ROWS / 2);
const x0 = Math.min(COLS - 6, 5);
return { body: [{x:x0, y:my}, {x:x0-1, y:my}, {x:x0-2, y:my}], dir:{x:1,y:0}, nextDir:{x:1,y:0}, alive:true };
}
function rndApple() {
let safety = 200;
while (safety-- > 0) {
const f = { x: Math.floor(Math.random()*COLS), y: Math.floor(Math.random()*ROWS) };
if (s1.body.some(c=>c.x===f.x&&c.y===f.y)) continue;
if (s2.body.some(c=>c.x===f.x&&c.y===f.y)) continue;
if (apples.some(a=>a.x===f.x&&a.y===f.y)) continue;
return f;
}
return null;
}
function spawnApples() {
while (apples.length < 2) {
const a = rndApple();
if (!a) break;
apples.push(a);
}
}
function newRound() {
s1 = startSnakeRight();
s2 = startSnakeLeft();
apples = [];
spawnApples();
lastStepTs = 0;
roundResult = null;
state = 'playing';
stateTimer = 0;
}
function endRound(winner) {
if (winner === 0 || winner === 1) scores[winner]++;
roundResult = winner;
state = 'roundEnd';
stateTimer = 0;
}
function advanceRound() {
roundIdx++;
if (scores[0] >= 2 || scores[1] >= 2 || roundIdx >= TOTAL_ROUNDS) {
state = 'duelEnd';
stateTimer = 0;
} else {
newRound();
}
}
function finalWinner() {
if (scores[0] > scores[1]) return 0;
if (scores[1] > scores[0]) return 1;
return -1;
}
function finishUp() {
if (stopped) return;
stopped = true;
cancelAnimationFrame(raf);
document.removeEventListener('keydown', onKey);
canvas.removeEventListener('click', onClick);
canvas.removeEventListener('touchend', onTouch);
onDone(finalWinner());
}
// ── Steuerung ──
function setDir(s, dx, dy) {
if (s.dir.x === -dx && s.dir.y === -dy) return; // kein 180°
s.nextDir = { x: dx, y: dy };
}
function onKey(e) {
if (state !== 'playing') return;
const k = e.key;
let handled = true;
if (k === 'ArrowUp') setDir(s1, 0, -1);
else if (k === 'ArrowDown') setDir(s1, 0, 1);
else if (k === 'ArrowLeft') setDir(s1, -1, 0);
else if (k === 'ArrowRight') setDir(s1, 1, 0);
else if (k === 'w' || k === 'W') setDir(s2, 0, -1);
else if (k === 's' || k === 'S') setDir(s2, 0, 1);
else if (k === 'a' || k === 'A') setDir(s2, -1, 0);
else if (k === 'd' || k === 'D') setDir(s2, 1, 0);
else handled = false;
if (handled) e.preventDefault();
}
function pointInBtn(x, y) {
return startBtnRect && x >= startBtnRect.x && x <= startBtnRect.x + startBtnRect.w
&& y >= startBtnRect.y && y <= startBtnRect.y + startBtnRect.h;
}
function canvasCoords(clientX, clientY) {
const r = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const x = (clientX - r.left) * (canvas.width / r.width / dpr);
const y = (clientY - r.top) * (canvas.height / r.height / dpr);
return { x, y };
}
function onClick(e) {
if (state !== 'intro') return;
const p = canvasCoords(e.clientX, e.clientY);
if (pointInBtn(p.x, p.y)) newRound();
}
function onTouch(e) {
if (state !== 'intro') return;
const t = e.changedTouches && e.changedTouches[0];
if (!t) return;
const p = canvasCoords(t.clientX, t.clientY);
if (pointInBtn(p.x, p.y)) { e.preventDefault(); newRound(); }
}
document.addEventListener('keydown', onKey);
canvas.addEventListener('click', onClick);
canvas.addEventListener('touchend', onTouch, { passive: false });
// ── Spielschritt ──
function stepGame() {
s1.dir = s1.nextDir;
s2.dir = s2.nextDir;
const h1 = { x: s1.body[0].x + s1.dir.x, y: s1.body[0].y + s1.dir.y };
const h2 = { x: s2.body[0].x + s2.dir.x, y: s2.body[0].y + s2.dir.y };
const out1 = h1.x<0||h1.x>=COLS||h1.y<0||h1.y>=ROWS;
const out2 = h2.x<0||h2.x>=COLS||h2.y<0||h2.y>=ROWS;
const self1 = s1.body.some(c=>c.x===h1.x&&c.y===h1.y);
const self2 = s2.body.some(c=>c.x===h2.x&&c.y===h2.y);
const cross1 = s2.body.some(c=>c.x===h1.x&&c.y===h1.y);
const cross2 = s1.body.some(c=>c.x===h2.x&&c.y===h2.y);
const headOn = h1.x===h2.x && h1.y===h2.y;
let die1 = out1||self1||cross1;
let die2 = out2||self2||cross2;
if (headOn) { die1 = true; die2 = true; }
if (die1) s1.alive = false;
if (die2) s2.alive = false;
if (s1.alive) {
s1.body.unshift(h1);
const ai = apples.findIndex(a=>a.x===h1.x&&a.y===h1.y);
if (ai >= 0) apples.splice(ai,1); else s1.body.pop();
}
if (s2.alive) {
s2.body.unshift(h2);
const ai = apples.findIndex(a=>a.x===h2.x&&a.y===h2.y);
if (ai >= 0) apples.splice(ai,1); else s2.body.pop();
}
spawnApples();
if (!s1.alive || !s2.alive) {
let w;
if (!s1.alive && !s2.alive) w = -1;
else if (!s1.alive) w = 1;
else w = 0;
endRound(w);
}
}
// ── Render (gleicher Stil wie snake.js) ──
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 drawSnake(s, col) {
s.body.forEach((c, i) => {
const alpha = i === 0 ? 'ff' : Math.max(0x44, 0x88 - i*2).toString(16).padStart(2, '0');
const fill = i === 0 ? col : `${col}${alpha}`;
if (i === 0) { ctx.shadowColor = col; ctx.shadowBlur = 8; }
MGAPI.roundRect(ctx, c.x*SZ + 2, c.y*SZ + 2, SZ-4, SZ-4, 4, fill, null);
ctx.shadowBlur = 0;
});
}
function drawApples() {
ctx.shadowColor = APPLE_COL;
ctx.shadowBlur = 12;
ctx.font = `${SZ-2}px serif`;
ctx.textAlign = 'center';
apples.forEach(a => ctx.fillText('🍎', a.x*SZ + SZ/2, a.y*SZ + SZ/1.2));
ctx.shadowBlur = 0;
}
function drawHUD() {
MGAPI.text(ctx, `🔵 ${scores[0]}`, 8, 14, { align:'left', size:13, color:P1_COL });
MGAPI.text(ctx, `Runde ${Math.min(roundIdx+1, TOTAL_ROUNDS)} / ${TOTAL_ROUNDS}`,
W/2, 14, { align:'center', size:12, color:'rgba(255,255,255,0.85)' });
MGAPI.text(ctx, `${scores[1]} 🔴`, W-8, 14, { align:'right', size:13, color:P2_COL });
}
function drawStartButton() {
const bw = Math.min(320, W*0.62), bh = 56;
const bx = (W - bw)/2, by = H/2 + 8;
startBtnRect = { x: bx, y: by, w: bw, h: bh };
// Schatten
ctx.fillStyle = 'rgba(0,0,0,0.45)';
MGAPI.roundRect(ctx, bx, by+4, bw, bh, 14, 'rgba(0,0,0,0.45)', null);
// Button-Gradient
const grd = ctx.createLinearGradient(0, by, 0, by+bh);
grd.addColorStop(0, '#7c3aed'); grd.addColorStop(1, '#5b21b6');
MGAPI.roundRect(ctx, bx, by, bw, bh, 14, grd, null);
// Glanz oben
MGAPI.roundRect(ctx, bx+4, by+3, bw-8, bh/2 - 4, 12, 'rgba(255,255,255,0.18)', null);
// Text
MGAPI.text(ctx, '▶ Duell starten', bx + bw/2, by + bh/2, {
size: 22, color:'#fff', weight:'bold', family: "'Fredoka One',cursive"
});
}
function drawOverlayText(title, sub, color) {
ctx.fillStyle = 'rgba(0,0,0,0.62)';
ctx.fillRect(0, 0, W, H);
MGAPI.text(ctx, title, W/2, H/2 - 20, {
size: 28, weight:'bold', color: color||'#fff',
family: "'Fredoka One',cursive", shadow:'rgba(0,0,0,0.7)', shadowBlur: 12
});
if (sub) MGAPI.text(ctx, sub, W/2, H/2 + 18, { size: 15, color:'rgba(255,255,255,0.88)' });
}
function render() {
// Hintergrund + Grid (wie snake.js)
ctx.fillStyle = '#050508';
ctx.fillRect(0, 0, W, H);
drawGrid();
if (state !== 'intro') {
drawApples();
if (s1) drawSnake(s1, s1.alive ? P1_COL : 'rgba(80,80,120,0.5)');
if (s2) drawSnake(s2, s2.alive ? P2_COL : 'rgba(80,80,120,0.5)');
}
drawHUD();
if (state === 'intro') {
MGAPI.text(ctx, 'Snake-Duell · Best of 3', W/2, H/2 - 56, {
size: 26, weight:'bold', color:'#fff', family:"'Fredoka One',cursive",
shadow:'rgba(0,0,0,0.5)', shadowBlur: 10
});
MGAPI.text(ctx, '🔵 Pfeiltasten (Start rechts) · 🔴 WASD (Start links)',
W/2, H/2 - 24, { size: 13, color:'rgba(255,255,255,0.85)' });
drawStartButton();
} else if (state === 'roundEnd') {
let t, c;
if (roundResult === 0) { t = '🔵 Runde für Spieler 1!'; c = '#93c5fd'; }
else if (roundResult === 1) { t = '🔴 Runde für Spieler 2!'; c = '#fca5a5'; }
else { t = '⚖️ Unentschieden!'; c = '#fde68a'; }
drawOverlayText(t, `Stand: 🔵 ${scores[0]} : ${scores[1]} 🔴`, c);
} else if (state === 'duelEnd') {
const w = finalWinner();
let t, c;
if (w === 0) { t = '🏆 Spieler 1 gewinnt!'; c = '#93c5fd'; }
else if (w === 1) { t = '🏆 Spieler 2 gewinnt!'; c = '#fca5a5'; }
else { t = '🤝 Unentschieden!'; c = '#fde68a'; }
drawOverlayText(t, `Endstand: 🔵 ${scores[0]} : ${scores[1]} 🔴`, c);
}
}
function loop(ts) {
if (stopped) return;
raf = requestAnimationFrame(loop);
if (!lastTs) lastTs = ts;
const dt = ts - lastTs;
lastTs = ts;
if (state === 'playing') {
if (!lastStepTs) lastStepTs = ts;
if (ts - lastStepTs >= STEP_INTERVAL) {
lastStepTs = ts;
stepGame();
}
} else {
stateTimer += dt;
if (state === 'roundEnd' && stateTimer >= 1800) advanceRound();
else if (state === 'duelEnd' && stateTimer >= 2200) finishUp();
// intro: wartet auf Klick auf den Start-Button
}
render();
}
raf = requestAnimationFrame(loop);
return {
stop() {
if (stopped) return;
stopped = true;
cancelAnimationFrame(raf);
document.removeEventListener('keydown', onKey);
canvas.removeEventListener('click', onClick);
canvas.removeEventListener('touchend', onTouch);
},
};
}
function launch(wrap, W, H, cfg) { return run(wrap, W, H, cfg, idx => MGAPI.onResult(idx)); }
function preview(wrap, W, H, cfg) { return run(wrap, W, H, cfg, idx => MGAPI.onResult(idx)); }
return { id: ID, emoji: EMOJI, name: NAME, desc: DESC, controls: CONTROLS, multi: MULTI, requires: REQUIRES, launch, preview };
})();