managarten/games/arcade/apps/web/static/games/snake_game.html
Till JS 9e82e40e16 rename(mana-games): rebrand to Arcade
Rename games/mana-games/ to games/arcade/, update all package names
(@mana-games/* → @arcade/*), appIds, display names, docker-compose
service, root scripts, and documentation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 18:31:37 +02:00

662 lines
No EOL
27 KiB
HTML

<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Snake Spiel</title>
<style>
body {
margin: 0;
padding: 0;
background: #000;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
font-family: 'Courier New', monospace;
color: #00ffff;
user-select: none;
}
.game-container {
text-align: center;
}
.score {
font-size: 16px;
margin-bottom: 5px;
letter-spacing: 2px;
}
canvas {
border: 2px solid #00ffff;
background: #000;
display: block;
image-rendering: pixelated;
image-rendering: -moz-crisp-edges;
image-rendering: crisp-edges;
}
.game-over {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(0, 0, 0, 0.95);
border: 2px solid #00ffff;
padding: 20px;
display: none;
}
.restart-btn {
background: #000;
color: #00ffff;
border: 1px solid #00ffff;
padding: 8px 16px;
font-family: 'Courier New', monospace;
font-size: 14px;
cursor: pointer;
margin-top: 10px;
}
.restart-btn:hover {
background: #00ffff;
color: #000;
}
</style>
</head>
<body>
<div class="game-container">
<div class="score">SCORE: <span id="score">0</span></div>
<canvas id="gameCanvas" width="400" height="400"></canvas>
<div class="game-over" id="gameOver">
<div>GAME OVER</div>
<div>SCORE: <span id="finalScore">0</span></div>
<button class="restart-btn" onclick="restartGame()">RESTART</button>
</div>
</div>
<script>
// ======================== CANVAS UND UI ELEMENTE ========================
// Hole die Canvas und 2D Kontext für das Zeichnen des Spiels
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
// UI Elemente für Score-Anzeige und Game Over Screen
const scoreElement = document.getElementById('score');
const gameOverElement = document.getElementById('gameOver');
const finalScoreElement = document.getElementById('finalScore');
// ======================== SPIEL-KONSTANTEN ========================
// Größe eines einzelnen Feldes/Kachel in Pixeln
const gridSize = 20;
// Anzahl der Kacheln in jeder Richtung (20x20 Grid bei 400px Canvas)
const tileCount = canvas.width / gridSize;
// ======================== SPIEL-ZUSTAND ========================
// Snake Array - jedes Element ist ein Objekt mit x,y Koordinaten
// Index 0 ist der Kopf der Schlange
let snake = [{x: 10, y: 10}];
// Position des Essens als Objekt mit x,y Koordinaten
let food = {};
// Bewegungsrichtung der Schlange (-1, 0, 1 für jede Achse)
let dx = 0; // Horizontale Bewegung: -1 = links, 0 = keine, 1 = rechts
let dy = 0; // Vertikale Bewegung: -1 = oben, 0 = keine, 1 = unten
// Aktueller Punktestand
let score = 0;
// Game ID für Statistiken
const GAME_ID = 'snake';
// Flag ob das Spiel läuft oder pausiert/beendet ist
let gameRunning = true;
// Zeit-Management für konstante Bewegungsgeschwindigkeit
let lastMoveTime = 0; // Zeitstempel der letzten Bewegung
let moveInterval = 120; // Millisekunden zwischen Bewegungen (Start-Geschwindigkeit)
// ======================== INPUT STEUERUNG ========================
// Queue für Tasteneingaben - ermöglicht schnelle Richtungswechsel ohne Verlust
// Maximal 3 Eingaben werden gespeichert
let inputQueue = [];
// Letzte tatsächliche Bewegungsrichtung (verhindert 180° Drehungen)
let lastDirection = { dx: 0, dy: 0 };
// ======================== GAME OVER ANIMATION ========================
// Flag ob die Explosions-Animation läuft
let gameOverAnimation = false;
// Array mit Partikeln für die Explosion
let explosionParticles = [];
// Startzeit der Animation für Timing
let animationStartTime = 0;
// ======================== BESUCHTE FELDER TRACKING ========================
// 2D Array das speichert, wie oft jedes Feld besucht wurde
// 0 = unbesucht (schwarz)
// 1 = 1x besucht (blau)
// 2 = 2x besucht (rot mit Streifen)
// 3 = 3x besucht (magenta mit Kreuz) - tödlich!
let visitedGrid = Array(tileCount).fill().map(() => Array(tileCount).fill(0));
// ======================== FARB-PALETTE ========================
// Zentrale Definition aller Farben für konsistentes Design
// und bessere Performance (weniger String-Allokationen)
const COLORS = {
background: '#000', // Schwarzer Hintergrund
snakeHead: '#00ffff', // Cyan für Schlangenkopf
snakeBody: '#0088aa', // Dunkleres Cyan für Körper
food: '#ffff00', // Gelb für Essen
border: '#ffffff', // Weiße Ränder
visited1: '#4444aa', // Blau für 1x besuchte Felder
visited2: '#aa4444', // Rot für 2x besuchte Felder
visited3: '#aa44aa', // Magenta für 3x besuchte (tödliche) Felder
pattern: '#ffffff' // Weiß für Muster auf besuchten Feldern
};
// ======================== ESSEN GENERATION ========================
/**
* Generiert eine neue zufällige Position für das Essen.
* Stellt sicher, dass das Essen nicht auf der Schlange erscheint.
*/
function generateFood() {
// Wiederhole bis eine freie Position gefunden wird
do {
food = {
x: Math.floor(Math.random() * tileCount),
y: Math.floor(Math.random() * tileCount)
};
// Prüfe ob irgendein Schlangen-Segment auf dieser Position ist
} while (snake.some(segment => segment.x === food.x && segment.y === food.y));
}
// ======================== HAUPT-GAME-LOOP ========================
/**
* Die zentrale Game Loop die kontinuierlich läuft.
* Wird von requestAnimationFrame aufgerufen für 60 FPS.
*
* @param {number} currentTime - Aktuelle Zeit in Millisekunden
*/
function gameLoop(currentTime) {
// Spezialbehandlung während der Game Over Animation
if (gameOverAnimation) {
updateExplosion(currentTime); // Bewege Explosions-Partikel
drawGame(); // Zeichne normales Spielfeld
drawExplosion(currentTime); // Zeichne Explosion darüber
requestAnimationFrame(gameLoop);
return;
}
// Beende Loop wenn Spiel nicht läuft
if (!gameRunning) return;
// Verarbeite gespeicherte Tasteneingaben
processInputQueue();
// Bewegung nur in festgelegten Intervallen (nicht jeden Frame)
// Dies erzeugt die klassische "ruckartige" Snake-Bewegung
if (currentTime - lastMoveTime >= moveInterval) {
moveSnake();
lastMoveTime = currentTime;
}
// Zeichne jeden Frame für flüssige Darstellung
// (auch wenn Bewegung nur alle 120ms erfolgt)
drawGame();
// Nächsten Frame anfordern
requestAnimationFrame(gameLoop);
}
// ======================== INPUT VERARBEITUNG ========================
/**
* Verarbeitet die nächste Eingabe aus der Input-Queue.
* Verhindert 180° Drehungen (Rückwärtsbewegung in sich selbst).
*/
function processInputQueue() {
// Keine Eingaben vorhanden
if (inputQueue.length === 0) return;
// Hole und entferne erste Eingabe aus Queue
const nextMove = inputQueue.shift();
// Prüfe ob die Bewegung gültig ist (keine 180° Drehung)
// Beispiel: Wenn Schlange nach rechts (dx=1) läuft,
// ist links (dx=-1) nicht erlaubt
if ((nextMove.dx !== 0 && lastDirection.dx !== -nextMove.dx) ||
(nextMove.dy !== 0 && lastDirection.dy !== -nextMove.dy)) {
// Setze neue Bewegungsrichtung
dx = nextMove.dx;
dy = nextMove.dy;
// Speichere als letzte Richtung für nächste Prüfung
lastDirection = { dx, dy };
}
}
// ======================== HAUPT-ZEICHENFUNKTION ========================
/**
* Zeichnet das gesamte Spiel.
* Reihenfolge ist wichtig für korrekte Überlagerung.
*/
function drawGame() {
clearCanvas(); // 1. Lösche alles und zeichne Hintergrund + besuchte Felder
drawFood(); // 2. Zeichne Essen
drawSnake(); // 3. Zeichne Schlange (über allem anderen)
checkCollision(); // 4. Prüfe Kollisionen
}
// ======================== CANVAS LÖSCHEN UND HINTERGRUND ========================
/**
* Löscht das Canvas und zeichnet den Hintergrund inklusive besuchter Felder.
* Optimiert durch Batch-Rendering gleicher Farben.
*/
function clearCanvas() {
// Lösche gesamtes Canvas mit schwarzem Hintergrund
ctx.fillStyle = COLORS.background;
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Zeichne alle besuchten Felder nach Level gruppiert
// Dies minimiert ctx.fillStyle Änderungen für bessere Performance
for (let level = 1; level <= 3; level++) {
// Setze Farbe für aktuelles Level
ctx.fillStyle = level === 1 ? COLORS.visited1 :
level === 2 ? COLORS.visited2 : COLORS.visited3;
// Durchlaufe gesamtes Grid
for (let x = 0; x < tileCount; x++) {
for (let y = 0; y < tileCount; y++) {
// Zeichne nur wenn Feld dieses Level hat
if (visitedGrid[x][y] === level) {
// Prüfe ob Schlange auf diesem Feld ist
const isSnakeField = snake.some(segment => segment.x === x && segment.y === y);
// Zeichne nur wenn Schlange NICHT auf dem Feld ist
if (!isSnakeField) {
ctx.fillRect(x * gridSize, y * gridSize, gridSize, gridSize);
}
}
}
}
}
// Zeichne Muster auf besuchten Feldern (Level 2 und 3)
// Alle Muster verwenden die gleiche Farbe für Performance
ctx.fillStyle = COLORS.pattern;
for (let x = 0; x < tileCount; x++) {
for (let y = 0; y < tileCount; y++) {
const level = visitedGrid[x][y];
const isSnakeField = snake.some(segment => segment.x === x && segment.y === y);
// Nur Muster für Level 2 und 3, nicht wo Schlange ist
if (!isSnakeField && level > 1) {
// Berechne Pixel-Position des Feldes
const baseX = x * gridSize;
const baseY = y * gridSize;
if (level === 2) {
// Vertikale Streifen für Level 2
ctx.fillRect(baseX + 5, baseY, 2, gridSize); // Linker Streifen
ctx.fillRect(baseX + 13, baseY, 2, gridSize); // Rechter Streifen
} else if (level === 3) {
// Kreuz-Muster für Level 3 (tödliche Felder)
ctx.fillRect(baseX + 8, baseY, 4, gridSize); // Vertikaler Balken
ctx.fillRect(baseX, baseY + 8, gridSize, 4); // Horizontaler Balken
}
}
}
}
}
// ======================== SCHLANGE ZEICHNEN ========================
/**
* Zeichnet die Schlange mit unterschiedlichen Farben für Kopf und Körper.
* Fügt weiße Ränder für bessere Sichtbarkeit hinzu.
*/
function drawSnake() {
// Verstecke Schlange während Explosions-Animation
if (gameOverAnimation) return;
// Zeichne alle Segmente der Schlange
snake.forEach((segment, index) => {
// Kopf (index 0) ist heller als Körper
ctx.fillStyle = index === 0 ? COLORS.snakeHead : COLORS.snakeBody;
ctx.fillRect(segment.x * gridSize, segment.y * gridSize, gridSize, gridSize);
});
// Zeichne Ränder für alle Segmente in einem Durchgang
// (Performance-Optimierung: nur einmal Stil setzen)
ctx.strokeStyle = COLORS.border;
ctx.lineWidth = 1;
snake.forEach(segment => {
ctx.strokeRect(segment.x * gridSize, segment.y * gridSize, gridSize, gridSize);
});
}
// ======================== ESSEN ZEICHNEN ========================
/**
* Zeichnet das Essen als gelbes Quadrat mit weißem Rand.
*/
function drawFood() {
// Gelbes Quadrat für das Essen
ctx.fillStyle = COLORS.food;
ctx.fillRect(food.x * gridSize, food.y * gridSize, gridSize, gridSize);
// Weißer Rand für bessere Sichtbarkeit
ctx.strokeStyle = COLORS.border;
ctx.lineWidth = 1;
ctx.strokeRect(food.x * gridSize, food.y * gridSize, gridSize, gridSize);
}
// ======================== SCHLANGEN-BEWEGUNG ========================
/**
* Bewegt die Schlange in die aktuelle Richtung.
* Behandelt Wrap-Around an den Rändern, Essen-Aufnahme und Kollisionen.
*/
function moveSnake() {
// Keine Bewegung wenn Schlange stillsteht (Spielstart)
if (dx === 0 && dy === 0) return;
// Berechne neue Kopfposition
let head = {x: snake[0].x + dx, y: snake[0].y + dy};
// Wrap-Around: Schlange erscheint auf der anderen Seite
if (head.x < 0) head.x = tileCount - 1; // Links raus -> rechts rein
if (head.x >= tileCount) head.x = 0; // Rechts raus -> links rein
if (head.y < 0) head.y = tileCount - 1; // Oben raus -> unten rein
if (head.y >= tileCount) head.y = 0; // Unten raus -> oben rein
// Prüfe ob neues Feld tödlich ist (3x besucht = rot mit Kreuz)
if (visitedGrid[head.x][head.y] === 3) {
gameOver();
return;
}
// Füge neuen Kopf am Anfang des Arrays hinzu
snake.unshift(head);
// Prüfe ob Essen gegessen wurde
if (head.x === food.x && head.y === food.y) {
// Essen aufgenommen: Score erhöhen, neues Essen generieren
score += 10;
scoreElement.textContent = score;
// Sende Score Update für Statistiken
window.parent.postMessage({
type: 'GAME_EVENT',
gameId: GAME_ID,
event: 'SCORE_UPDATE',
data: { score: score }
}, '*');
generateFood();
// Spiel wird schneller (min. 80ms zwischen Bewegungen)
moveInterval = Math.max(80, moveInterval - 1);
// Schwanz wird NICHT entfernt -> Schlange wächst
} else {
// Kein Essen: Entferne Schwanz (Schlange bleibt gleich lang)
const tail = snake.pop();
// Erhöhe Besuchszähler für verlassenes Feld (max. 3)
visitedGrid[tail.x][tail.y] = Math.min(3, visitedGrid[tail.x][tail.y] + 1);
}
}
// ======================== KOLLISIONSPRÜFUNG ========================
/**
* Prüft ob die Schlange mit sich selbst kollidiert.
* Wandkollisionen gibt es nicht (Wrap-Around).
*/
function checkCollision() {
const head = snake[0];
// Prüfe Kollision mit jedem Körpersegment (nicht mit Kopf selbst)
for (let i = 1; i < snake.length; i++) {
if (head.x === snake[i].x && head.y === snake[i].y) {
// Schlange hat sich selbst gebissen
gameOver();
return;
}
}
}
// ======================== GAME OVER BEHANDLUNG ========================
/**
* Beendet das Spiel und startet die Explosions-Animation.
* Erstellt Partikel für jeden Teil der Schlange.
*/
function gameOver() {
// Stoppe Spiellogik
gameRunning = false;
// Sende Game Over Event mit Score für Statistiken
window.parent.postMessage({
type: 'GAME_EVENT',
gameId: GAME_ID,
event: 'GAME_OVER',
data: { score: score }
}, '*');
// Achievement prüfen
if (score >= 500) {
window.parent.postMessage({
type: 'GAME_EVENT',
gameId: GAME_ID,
event: 'ACHIEVEMENT_UNLOCKED',
data: {
achievement: {
id: 'snake-master',
name: 'Snake Meister',
description: '500 Punkte in einem Spiel erreicht!'
}
}
}, '*');
}
if (score >= 1000) {
window.parent.postMessage({
type: 'GAME_EVENT',
gameId: GAME_ID,
event: 'ACHIEVEMENT_UNLOCKED',
data: {
achievement: {
id: 'snake-legend',
name: 'Snake Legende',
description: '1000 Punkte in einem Spiel erreicht!'
}
}
}, '*');
}
// Starte Explosions-Animation
gameOverAnimation = true;
animationStartTime = performance.now();
// Erstelle Explosions-Partikel für jedes Schlangen-Segment
explosionParticles = [];
snake.forEach((segment, index) => {
// Berechne Zentrum des Segments in Pixeln
const centerX = segment.x * gridSize + gridSize / 2;
const centerY = segment.y * gridSize + gridSize / 2;
const isHead = index === 0;
// Erstelle 4 Partikel pro Segment (in 4 Richtungen)
for (let i = 0; i < 4; i++) {
// Berechne Richtung mit leichter Zufälligkeit
const angle = (Math.PI * 2 * i) / 4 + Math.random() * 0.3;
const speed = Math.random() * 2 + 2;
// Erstelle Partikel-Objekt
explosionParticles.push({
x: centerX, // Start-Position X
y: centerY, // Start-Position Y
vx: Math.cos(angle) * speed, // Geschwindigkeit X
vy: Math.sin(angle) * speed, // Geschwindigkeit Y
size: gridSize / 4, // Größe des Partikels
life: 1.0, // Lebenszeit (1.0 = 100%)
color: isHead ? COLORS.snakeHead : COLORS.snakeBody // Farbe
});
}
});
// Setze finalen Score
finalScoreElement.textContent = score;
// Zeige Game Over Dialog nach 1 Sekunde Animation
setTimeout(() => {
gameOverElement.style.display = 'block';
gameOverAnimation = false;
}, 1000);
}
// ======================== EXPLOSIONS-UPDATE ========================
/**
* Aktualisiert die Positionen und Eigenschaften der Explosions-Partikel.
* Wird jeden Frame während der Game Over Animation aufgerufen.
*
* @param {number} currentTime - Aktuelle Zeit (wird hier nicht verwendet)
*/
function updateExplosion(currentTime) {
// Aktualisiere jeden Partikel
explosionParticles.forEach(particle => {
// Bewege Partikel basierend auf Geschwindigkeit
particle.x += particle.vx;
particle.y += particle.vy;
// Reibung: Verlangsame Partikel (5% pro Frame)
particle.vx *= 0.95;
particle.vy *= 0.95;
// Reduziere Lebenszeit (2% pro Frame)
particle.life -= 0.02;
});
// Entferne "tote" Partikel (life <= 0) aus dem Array
explosionParticles = explosionParticles.filter(particle => particle.life > 0);
}
// ======================== EXPLOSIONS-ZEICHNUNG ========================
/**
* Zeichnet alle Explosions-Partikel.
* Verwendet Alpha-Transparenz für Fade-Out Effekt.
*
* @param {number} currentTime - Aktuelle Zeit (wird hier nicht verwendet)
*/
function drawExplosion(currentTime) {
// Zeichne jeden Partikel
explosionParticles.forEach(particle => {
// Setze Transparenz basierend auf Lebenszeit (fade out)
ctx.globalAlpha = Math.max(0, particle.life);
ctx.fillStyle = particle.color;
// Zeichne Partikel als Quadrat (zentriert um Position)
ctx.fillRect(
particle.x - particle.size/2, // X-Position (zentriert)
particle.y - particle.size/2, // Y-Position (zentriert)
particle.size, // Breite
particle.size // Höhe
);
});
// Setze Alpha auf Standard zurück für nächste Zeichenoperationen
ctx.globalAlpha = 1.0;
}
// ======================== SPIEL NEUSTART ========================
/**
* Setzt alle Spielvariablen zurück und startet ein neues Spiel.
* Wird vom Restart-Button aufgerufen.
*/
function restartGame() {
// Setze Schlange auf Startposition zurück
snake = [{x: 10, y: 10}];
// Keine Bewegung zu Beginn
dx = 0;
dy = 0;
// Reset Score und Geschwindigkeit
score = 0;
moveInterval = 120;
// Leere Input-Queue und Richtungs-Tracking
inputQueue = [];
lastDirection = { dx: 0, dy: 0 };
// Beende Animationen
gameOverAnimation = false;
explosionParticles = [];
// Update UI
scoreElement.textContent = score;
gameRunning = true;
gameOverElement.style.display = 'none';
// Reset besuchte Felder (alle auf 0)
visitedGrid = Array(tileCount).fill().map(() => Array(tileCount).fill(0));
// Generiere neues Essen und starte Game Loop
generateFood();
requestAnimationFrame(gameLoop);
}
// ======================== TASTATUR-STEUERUNG ========================
/**
* Event-Listener für Tastatureingaben.
* Unterstützt Pfeiltasten und WASD.
* Verwendet eine Queue für responsive Steuerung bei schnellen Eingaben.
*/
document.addEventListener('keydown', (e) => {
// Ignoriere Eingaben wenn Spiel nicht läuft
if (!gameRunning) return;
let newMove = null;
// Mappe Tasten zu Bewegungsrichtungen
switch(e.key) {
case 'ArrowUp': // Pfeil nach oben
case 'w': // W-Taste
case 'W':
newMove = {dx: 0, dy: -1}; // Nach oben
break;
case 'ArrowDown': // Pfeil nach unten
case 's': // S-Taste
case 'S':
newMove = {dx: 0, dy: 1}; // Nach unten
break;
case 'ArrowLeft': // Pfeil nach links
case 'a': // A-Taste
case 'A':
newMove = {dx: -1, dy: 0}; // Nach links
break;
case 'ArrowRight': // Pfeil nach rechts
case 'd': // D-Taste
case 'D':
newMove = {dx: 1, dy: 0}; // Nach rechts
break;
}
// Füge gültige Bewegung zur Queue hinzu
if (newMove && inputQueue.length < 3) { // Max. 3 Eingaben speichern
// Verhindere identische aufeinanderfolgende Eingaben
const lastInQueue = inputQueue[inputQueue.length - 1];
if (!lastInQueue || lastInQueue.dx !== newMove.dx || lastInQueue.dy !== newMove.dy) {
inputQueue.push(newMove);
}
// Verhindere Standard-Scrolling bei Pfeiltasten
e.preventDefault();
}
});
// ======================== SPIEL INITIALISIERUNG ========================
// Sende Game Loaded Event für Statistiken
window.parent.postMessage({
type: 'GAME_LOADED',
gameId: GAME_ID
}, '*');
// Generiere erstes Essen
generateFood();
// Starte Game Loop
requestAnimationFrame(gameLoop);
</script>
</body>
</html>