353 lines
12 KiB
JavaScript
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 };
|
|
|
|
})();
|