1607 lines
53 KiB
JavaScript
1607 lines
53 KiB
JavaScript
// ===== PIXEL-ÜBERLEBENDER - 2D SURVIVAL GAME =====
|
|
// Eine Schritt-für-Schritt Einführung in die 2D-Spieleentwicklung
|
|
|
|
// 1. CANVAS-GRUNDLAGEN
|
|
// Das Canvas-Element ist wie eine digitale Leinwand, auf die wir mit JavaScript zeichnen können.
|
|
// Es bietet uns eine 2D-Zeichenfläche mit Pixeln, die wir einzeln kontrollieren können.
|
|
|
|
// Canvas-Element aus dem HTML-Dokument holen
|
|
const canvas = document.getElementById('gameCanvas');
|
|
|
|
// Den 2D-Rendering-Kontext erhalten
|
|
// Der Kontext (ctx) ist unser "Pinsel" - damit zeichnen wir auf das Canvas
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
// Canvas-Dimensionen für spätere Berechnungen speichern
|
|
const CANVAS_WIDTH = canvas.width; // 800 Pixel
|
|
const CANVAS_HEIGHT = canvas.height; // 600 Pixel
|
|
|
|
// ===== ASSET-SYSTEM =====
|
|
|
|
/**
|
|
* Asset Manager für das Laden und Verwalten von Sprites
|
|
*/
|
|
class AssetManager {
|
|
constructor() {
|
|
this.images = new Map();
|
|
this.loadedCount = 0;
|
|
this.totalCount = 0;
|
|
this.isLoaded = false;
|
|
}
|
|
|
|
/**
|
|
* Lädt ein Bild und speichert es im Asset Manager
|
|
* @param {string} key - Eindeutiger Schlüssel für das Bild
|
|
* @param {string} path - Pfad zur Bilddatei
|
|
*/
|
|
loadImage(key, path) {
|
|
return new Promise((resolve, reject) => {
|
|
const img = new Image();
|
|
img.onload = () => {
|
|
this.images.set(key, img);
|
|
this.loadedCount++;
|
|
console.log(`✅ Bild geladen: ${key} (${this.loadedCount}/${this.totalCount})`);
|
|
resolve(img);
|
|
};
|
|
img.onerror = () => {
|
|
console.error(`❌ Fehler beim Laden: ${key} von ${path}`);
|
|
reject(new Error(`Failed to load ${path}`));
|
|
};
|
|
img.src = path;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Lädt alle benötigten Assets
|
|
*/
|
|
async loadAllAssets() {
|
|
const assetsToLoad = [
|
|
// Boden-Tiles
|
|
{ key: 'grass', path: 'assets/1 Tiles/gras.png' },
|
|
{ key: 'path', path: 'assets/1 Tiles/weg.png' },
|
|
{ key: 'swamp1', path: 'assets/1 Tiles/SwampTile_01.png' },
|
|
{ key: 'swamp2', path: 'assets/1 Tiles/SwampTile_02.png' },
|
|
{ key: 'swamp3', path: 'assets/1 Tiles/SwampTile_03.png' },
|
|
|
|
// Bäume
|
|
{ key: 'tree1', path: 'assets/2 Objects/2 Tree/1.png' },
|
|
{ key: 'tree2', path: 'assets/2 Objects/2 Tree/2.png' },
|
|
{ key: 'tree3', path: 'assets/2 Objects/2 Tree/3.png' },
|
|
{ key: 'tree4', path: 'assets/2 Objects/2 Tree/4.png' },
|
|
|
|
// Steine
|
|
{ key: 'stone1', path: 'assets/2 Objects/4 Stone/1.png' },
|
|
{ key: 'stone2', path: 'assets/2 Objects/4 Stone/2.png' },
|
|
{ key: 'stone3', path: 'assets/2 Objects/4 Stone/3.png' },
|
|
|
|
// Gras/Stöcker
|
|
{ key: 'stick1', path: 'assets/2 Objects/3 Grass/1.png' },
|
|
{ key: 'stick2', path: 'assets/2 Objects/3 Grass/2.png' },
|
|
{ key: 'stick3', path: 'assets/2 Objects/3 Grass/3.png' },
|
|
|
|
// Schatten
|
|
{ key: 'shadow1', path: 'assets/2 Objects/1 Shadow/1.png' },
|
|
{ key: 'shadow2', path: 'assets/2 Objects/1 Shadow/2.png' },
|
|
|
|
// Spieler-Sprite
|
|
{ key: 'pointer1', path: 'assets/2 Objects/5 Pointer/1.png' },
|
|
|
|
// Animierte Objekte (Lagerfeuer)
|
|
{ key: 'torch1', path: 'assets/3 Animated Objects/2 Torch/1.png' },
|
|
{ key: 'torch2', path: 'assets/3 Animated Objects/2 Torch/2.png' },
|
|
{ key: 'torch3', path: 'assets/3 Animated Objects/2 Torch/3.png' },
|
|
{ key: 'torch4', path: 'assets/3 Animated Objects/2 Torch/4.png' }
|
|
];
|
|
|
|
this.totalCount = assetsToLoad.length;
|
|
console.log(`🎨 Lade ${this.totalCount} Assets...`);
|
|
|
|
try {
|
|
await Promise.all(assetsToLoad.map(asset =>
|
|
this.loadImage(asset.key, asset.path)
|
|
));
|
|
|
|
this.isLoaded = true;
|
|
console.log('🎉 Alle Assets erfolgreich geladen!');
|
|
return true;
|
|
} catch (error) {
|
|
console.error('❌ Fehler beim Laden der Assets:', error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gibt ein geladenes Bild zurück
|
|
* @param {string} key - Schlüssel des Bildes
|
|
* @returns {Image|null} Das Bild oder null falls nicht gefunden
|
|
*/
|
|
getImage(key) {
|
|
return this.images.get(key) || null;
|
|
}
|
|
|
|
/**
|
|
* Zeichnet ein Sprite auf das Canvas
|
|
* @param {CanvasRenderingContext2D} ctx - Canvas-Kontext
|
|
* @param {string} key - Sprite-Schlüssel
|
|
* @param {number} x - X-Position
|
|
* @param {number} y - Y-Position
|
|
* @param {number} width - Breite (optional)
|
|
* @param {number} height - Höhe (optional)
|
|
*/
|
|
drawSprite(ctx, key, x, y, width = null, height = null) {
|
|
const img = this.getImage(key);
|
|
if (!img) {
|
|
// Fallback: Zeichne ein farbiges Rechteck
|
|
ctx.fillStyle = '#FF0000';
|
|
ctx.fillRect(x, y, width || 32, height || 32);
|
|
return;
|
|
}
|
|
|
|
if (width && height) {
|
|
ctx.drawImage(img, x, y, width, height);
|
|
} else {
|
|
ctx.drawImage(img, x, y);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Globaler Asset Manager
|
|
let assetManager = new AssetManager();
|
|
|
|
// ===== WELT-SYSTEM =====
|
|
|
|
// Welt-Konfiguration
|
|
const WORLD_CONFIG = {
|
|
CHUNK_SIZE: 400, // Größe eines Chunks in Pixeln
|
|
RENDER_DISTANCE: 2, // Wie viele Chunks um den Spieler geladen werden
|
|
WORLD_SEED: 12345, // Seed für prozedurale Generierung
|
|
RESOURCE_DENSITY: {
|
|
stick: 0.8, // Stöcker pro 100x100 Pixel Bereich
|
|
tree: 0.4, // Bäume pro 100x100 Pixel Bereich
|
|
rock: 0.3 // Felsen pro 100x100 Pixel Bereich
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Kamera-System für die scrollende Welt
|
|
*/
|
|
class Camera {
|
|
constructor() {
|
|
this.x = 0; // Kamera-Position in Weltkoordinaten
|
|
this.y = 0;
|
|
this.targetX = 0; // Ziel-Position (für smooth following)
|
|
this.targetY = 0;
|
|
this.smoothing = 0.1; // Wie sanft die Kamera folgt (0-1)
|
|
}
|
|
|
|
/**
|
|
* Aktualisiert die Kamera-Position um dem Spieler zu folgen
|
|
* @param {Player} player - Der Spieler
|
|
*/
|
|
update(player) {
|
|
// Kamera soll auf den Spieler zentriert sein
|
|
this.targetX = player.x - CANVAS_WIDTH / 2;
|
|
this.targetY = player.y - CANVAS_HEIGHT / 2;
|
|
|
|
// Sanfte Kamera-Bewegung (Lerp)
|
|
this.x += (this.targetX - this.x) * this.smoothing;
|
|
this.y += (this.targetY - this.y) * this.smoothing;
|
|
}
|
|
|
|
/**
|
|
* Konvertiert Weltkoordinaten zu Bildschirmkoordinaten
|
|
* @param {number} worldX - X in Weltkoordinaten
|
|
* @param {number} worldY - Y in Weltkoordinaten
|
|
* @returns {Object} - {x, y} in Bildschirmkoordinaten
|
|
*/
|
|
worldToScreen(worldX, worldY) {
|
|
return {
|
|
x: worldX - this.x,
|
|
y: worldY - this.y
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Konvertiert Bildschirmkoordinaten zu Weltkoordinaten
|
|
* @param {number} screenX - X in Bildschirmkoordinaten
|
|
* @param {number} screenY - Y in Bildschirmkoordinaten
|
|
* @returns {Object} - {x, y} in Weltkoordinaten
|
|
*/
|
|
screenToWorld(screenX, screenY) {
|
|
return {
|
|
x: screenX + this.x,
|
|
y: screenY + this.y
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Chunk-System für Performance und prozedurale Generierung
|
|
*/
|
|
class Chunk {
|
|
constructor(chunkX, chunkY) {
|
|
this.chunkX = chunkX; // Chunk-Koordinaten
|
|
this.chunkY = chunkY;
|
|
this.worldX = chunkX * WORLD_CONFIG.CHUNK_SIZE; // Welt-Position
|
|
this.worldY = chunkY * WORLD_CONFIG.CHUNK_SIZE;
|
|
this.resources = []; // Ressourcen in diesem Chunk
|
|
this.tiles = []; // Boden-Tiles für diesen Chunk
|
|
this.generated = false; // Ob der Chunk bereits generiert wurde
|
|
|
|
// Perlin Noise Permutation Table initialisieren
|
|
this.initPerlinNoise();
|
|
}
|
|
|
|
/**
|
|
* Generiert Ressourcen für diesen Chunk prozedural
|
|
*/
|
|
generate() {
|
|
if (this.generated) return;
|
|
|
|
this.resources = [];
|
|
this.tiles = [];
|
|
|
|
// Pseudo-Zufallsgenerator basierend auf Chunk-Position und Seed
|
|
const random = this.seededRandom(this.chunkX, this.chunkY, WORLD_CONFIG.WORLD_SEED);
|
|
|
|
// Berechne wie viele Ressourcen basierend auf Chunk-Größe
|
|
const area = (WORLD_CONFIG.CHUNK_SIZE / 100) ** 2; // Normalisiert auf 100x100 Bereiche
|
|
const tileSize = 32; // Größe eines Tiles in Pixeln
|
|
|
|
// Boden-Tiles generieren mit Perlin Noise
|
|
for (let x = 0; x < WORLD_CONFIG.CHUNK_SIZE; x += tileSize) {
|
|
for (let y = 0; y < WORLD_CONFIG.CHUNK_SIZE; y += tileSize) {
|
|
const worldX = this.worldX + x;
|
|
const worldY = this.worldY + y;
|
|
|
|
// Perlin Noise für natürlichere Boden-Verteilung
|
|
const noiseScale = 0.05; // Skalierung des Noise
|
|
const noiseValue = this.noise(worldX * noiseScale, worldY * noiseScale);
|
|
|
|
// Normalisiere Noise-Wert von [-1,1] zu [0,1]
|
|
const normalizedNoise = (noiseValue + 1) / 2;
|
|
|
|
let tileType;
|
|
if (normalizedNoise < 0.3) {
|
|
// Sumpfgebiete in niedrigen Bereichen
|
|
const swampNoise = this.noise(worldX * 0.1, worldY * 0.1);
|
|
const swampVariant = Math.floor(((swampNoise + 1) / 2) * 64) + 1;
|
|
tileType = `SwampTile_${swampVariant.toString().padStart(2, '0')}`;
|
|
} else if (normalizedNoise < 0.6) {
|
|
// Übergangsbereich - gemischte Tiles
|
|
const mixNoise = this.noise(worldX * 0.08, worldY * 0.08);
|
|
if (mixNoise > 0) {
|
|
tileType = 'grass';
|
|
} else {
|
|
const swampVariant = Math.floor(((mixNoise + 1) / 2) * 20) + 1;
|
|
tileType = `SwampTile_${swampVariant.toString().padStart(2, '0')}`;
|
|
}
|
|
} else {
|
|
// Höhere Bereiche - hauptsächlich Gras
|
|
tileType = 'grass';
|
|
}
|
|
|
|
this.tiles.push({
|
|
x: worldX,
|
|
y: worldY,
|
|
type: tileType,
|
|
size: tileSize
|
|
});
|
|
}
|
|
}
|
|
|
|
// Ressourcen mit Perlin Noise-basierten Clustern generieren
|
|
const resourceDensity = 8; // Anzahl Versuche pro 64x64 Bereich
|
|
|
|
for (let attempts = 0; attempts < resourceDensity * area; attempts++) {
|
|
const x = this.worldX + random() * WORLD_CONFIG.CHUNK_SIZE;
|
|
const y = this.worldY + random() * WORLD_CONFIG.CHUNK_SIZE;
|
|
|
|
// Verschiedene Noise-Frequenzen für verschiedene Ressourcen
|
|
const stickNoise = this.noise(x * 0.02, y * 0.02);
|
|
const treeNoise = this.noise(x * 0.015, y * 0.015 + 100); // Offset für Variation
|
|
const rockNoise = this.noise(x * 0.01, y * 0.01 + 200);
|
|
|
|
// Normalisiere zu [0,1]
|
|
const stickDensity = (stickNoise + 1) / 2;
|
|
const treeDensity = (treeNoise + 1) / 2;
|
|
const rockDensity = (rockNoise + 1) / 2;
|
|
|
|
// Stöcker in Bereichen mit hoher Stick-Dichte
|
|
if (stickDensity > 0.6 && random() < WORLD_CONFIG.RESOURCE_DENSITY.stick * stickDensity) {
|
|
const stickVariant = Math.floor(random() * 3) + 1;
|
|
this.resources.push(new Resource(x, y, 'stick', `stick${stickVariant}`));
|
|
}
|
|
|
|
// Bäume in Clustern
|
|
if (treeDensity > 0.5 && random() < WORLD_CONFIG.RESOURCE_DENSITY.tree * treeDensity) {
|
|
const treeVariant = Math.floor(random() * 4) + 1;
|
|
this.resources.push(new Resource(x, y, 'tree', `tree${treeVariant}`));
|
|
}
|
|
|
|
// Felsen in spezifischen Gebieten
|
|
if (rockDensity > 0.7 && random() < WORLD_CONFIG.RESOURCE_DENSITY.rock * rockDensity) {
|
|
const stoneVariant = Math.floor(random() * 3) + 1;
|
|
this.resources.push(new Resource(x, y, 'rock', `stone${stoneVariant}`));
|
|
}
|
|
}
|
|
|
|
this.generated = true;
|
|
console.log(`🗺️ Chunk (${this.chunkX}, ${this.chunkY}) generiert: ${this.resources.length} Ressourcen`);
|
|
}
|
|
|
|
/**
|
|
* Seeded Random Number Generator für konsistente prozedurale Generierung
|
|
*/
|
|
seededRandom(x, y, seed) {
|
|
let counter = 0;
|
|
const hash = (x * 374761393 + y * 668265263 + seed) % 2147483647;
|
|
|
|
return function() {
|
|
counter++;
|
|
const x = Math.sin(hash + counter) * 43758.5453;
|
|
return x - Math.floor(x);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Initialisiert Perlin Noise Permutation Table
|
|
*/
|
|
initPerlinNoise() {
|
|
this.p = new Array(512);
|
|
const permutation = [];
|
|
|
|
// Generiere Permutation basierend auf Chunk-Position und Seed
|
|
for (let i = 0; i < 256; i++) {
|
|
permutation[i] = i;
|
|
}
|
|
|
|
// Shuffle basierend auf Seed und Chunk-Position
|
|
for (let i = 255; i > 0; i--) {
|
|
const seed = this.chunkX * 73856093 + this.chunkY * 19349663 + WORLD_CONFIG.WORLD_SEED;
|
|
const hash = Math.sin(seed + i) * 43758.5453;
|
|
const j = Math.floor((hash - Math.floor(hash)) * (i + 1));
|
|
[permutation[i], permutation[j]] = [permutation[j], permutation[i]];
|
|
}
|
|
|
|
for (let i = 0; i < 512; i++) {
|
|
this.p[i] = permutation[i & 255];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Perlin Noise Implementation für natürlichere Generierung
|
|
*/
|
|
noise(x, y) {
|
|
const X = Math.floor(x) & 255;
|
|
const Y = Math.floor(y) & 255;
|
|
|
|
x -= Math.floor(x);
|
|
y -= Math.floor(y);
|
|
|
|
const u = this.fade(x);
|
|
const v = this.fade(y);
|
|
|
|
const A = this.p[X] + Y;
|
|
const B = this.p[X + 1] + Y;
|
|
|
|
return this.lerp(v,
|
|
this.lerp(u, this.grad(this.p[A], x, y), this.grad(this.p[B], x - 1, y)),
|
|
this.lerp(u, this.grad(this.p[A + 1], x, y - 1), this.grad(this.p[B + 1], x - 1, y - 1))
|
|
);
|
|
}
|
|
|
|
fade(t) {
|
|
return t * t * t * (t * (t * 6 - 15) + 10);
|
|
}
|
|
|
|
lerp(t, a, b) {
|
|
return a + t * (b - a);
|
|
}
|
|
|
|
grad(hash, x, y) {
|
|
const h = hash & 15;
|
|
const u = h < 8 ? x : y;
|
|
const v = h < 4 ? y : h === 12 || h === 14 ? x : 0;
|
|
return ((h & 1) === 0 ? u : -u) + ((h & 2) === 0 ? v : -v);
|
|
}
|
|
|
|
/**
|
|
* Prüft ob dieser Chunk im sichtbaren Bereich ist
|
|
* @param {Camera} camera - Die Kamera
|
|
* @returns {boolean} - True wenn sichtbar
|
|
*/
|
|
isVisible(camera) {
|
|
const screenPos = camera.worldToScreen(this.worldX, this.worldY);
|
|
return screenPos.x < CANVAS_WIDTH + WORLD_CONFIG.CHUNK_SIZE &&
|
|
screenPos.x + WORLD_CONFIG.CHUNK_SIZE > 0 &&
|
|
screenPos.y < CANVAS_HEIGHT + WORLD_CONFIG.CHUNK_SIZE &&
|
|
screenPos.y + WORLD_CONFIG.CHUNK_SIZE > 0;
|
|
}
|
|
|
|
/**
|
|
* Rendert alle Boden-Tiles dieses Chunks
|
|
* @param {CanvasRenderingContext2D} ctx - Canvas-Kontext
|
|
* @param {Camera} camera - Kamera für Koordinaten-Transformation
|
|
*/
|
|
renderTiles(ctx, camera) {
|
|
if (!assetManager.isLoaded) return;
|
|
|
|
this.tiles.forEach(tile => {
|
|
const screenPos = camera.worldToScreen(tile.x, tile.y);
|
|
|
|
// Nur zeichnen wenn im sichtbaren Bereich
|
|
if (screenPos.x + tile.size < 0 || screenPos.x > CANVAS_WIDTH ||
|
|
screenPos.y + tile.size < 0 || screenPos.y > CANVAS_HEIGHT) {
|
|
return;
|
|
}
|
|
|
|
assetManager.drawSprite(ctx, tile.type, screenPos.x, screenPos.y, tile.size, tile.size);
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Welt-Manager für Chunk-Loading und -Unloading
|
|
*/
|
|
class World {
|
|
constructor() {
|
|
this.chunks = new Map(); // Geladene Chunks (key: "x,y")
|
|
this.camera = new Camera();
|
|
}
|
|
|
|
/**
|
|
* Aktualisiert die Welt (lädt/entlädt Chunks basierend auf Spielerposition)
|
|
* @param {Player} player - Der Spieler
|
|
*/
|
|
update(player) {
|
|
// Kamera aktualisieren
|
|
this.camera.update(player);
|
|
|
|
// Berechne welche Chunks geladen werden müssen
|
|
const playerChunkX = Math.floor(player.x / WORLD_CONFIG.CHUNK_SIZE);
|
|
const playerChunkY = Math.floor(player.y / WORLD_CONFIG.CHUNK_SIZE);
|
|
|
|
const neededChunks = new Set();
|
|
|
|
// Chunks um den Spieler laden
|
|
for (let dx = -WORLD_CONFIG.RENDER_DISTANCE; dx <= WORLD_CONFIG.RENDER_DISTANCE; dx++) {
|
|
for (let dy = -WORLD_CONFIG.RENDER_DISTANCE; dy <= WORLD_CONFIG.RENDER_DISTANCE; dy++) {
|
|
const chunkX = playerChunkX + dx;
|
|
const chunkY = playerChunkY + dy;
|
|
const key = `${chunkX},${chunkY}`;
|
|
neededChunks.add(key);
|
|
|
|
// Chunk laden falls nicht vorhanden
|
|
if (!this.chunks.has(key)) {
|
|
const chunk = new Chunk(chunkX, chunkY);
|
|
chunk.generate();
|
|
this.chunks.set(key, chunk);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Entfernte Chunks entladen (Performance)
|
|
for (const [key, chunk] of this.chunks) {
|
|
if (!neededChunks.has(key)) {
|
|
this.chunks.delete(key);
|
|
console.log(`🗑️ Chunk (${chunk.chunkX}, ${chunk.chunkY}) entladen`);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gibt alle Ressourcen in geladenen Chunks zurück
|
|
* @returns {Array} - Array aller Ressourcen
|
|
*/
|
|
getAllResources() {
|
|
const allResources = [];
|
|
for (const chunk of this.chunks.values()) {
|
|
allResources.push(...chunk.resources);
|
|
}
|
|
return allResources;
|
|
}
|
|
|
|
/**
|
|
* Zeichnet Chunk-Grenzen für Debugging
|
|
* @param {CanvasRenderingContext2D} ctx - Canvas-Kontext
|
|
*/
|
|
renderChunkBorders(ctx) {
|
|
ctx.strokeStyle = 'rgba(255, 255, 0, 0.3)';
|
|
ctx.lineWidth = 1;
|
|
|
|
for (const chunk of this.chunks.values()) {
|
|
if (chunk.isVisible(this.camera)) {
|
|
const screenPos = this.camera.worldToScreen(chunk.worldX, chunk.worldY);
|
|
ctx.strokeRect(screenPos.x, screenPos.y, WORLD_CONFIG.CHUNK_SIZE, WORLD_CONFIG.CHUNK_SIZE);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ===== SPIELER-KLASSE =====
|
|
|
|
/**
|
|
* Die Player-Klasse repräsentiert unseren Spielcharakter
|
|
*
|
|
* Warum eine Klasse?
|
|
* - Organisiert alle spielerbezogenen Daten und Funktionen
|
|
* - Macht den Code wiederverwendbar und erweiterbar
|
|
* - Trennt Spielerlogik vom Rest des Spiels (Separation of Concerns)
|
|
*/
|
|
class Player {
|
|
/**
|
|
* Konstruktor - wird beim Erstellen eines neuen Spielers aufgerufen
|
|
* @param {number} x - Startposition X
|
|
* @param {number} y - Startposition Y
|
|
*/
|
|
constructor(x, y) {
|
|
// Position des Spielers (Mittelpunkt)
|
|
this.x = x;
|
|
this.y = y;
|
|
|
|
// Größe des Spielers (Quadrat)
|
|
this.width = 20;
|
|
this.height = 20;
|
|
|
|
// Bewegungsgeschwindigkeit (Pixel pro Frame)
|
|
this.speed = 3;
|
|
|
|
// Farbe des Spielers
|
|
this.color = '#FF6B6B'; // Rot
|
|
|
|
// Gesundheit (Survival-Aspekt)
|
|
this.health = 100;
|
|
this.maxHealth = 100;
|
|
|
|
// Gesundheits-Timer (für langsame Abnahme)
|
|
this.healthTimer = 0;
|
|
this.healthDecayRate = 300; // Alle 5 Sekunden (300 Frames bei 60 FPS)
|
|
|
|
// Inventar
|
|
this.inventory = {
|
|
stick: 0, // Stöcker (für Werkzeuge)
|
|
wood: 0, // Holz (von Bäumen mit Axt)
|
|
stone: 0 // Stein (von Felsen mit Pickaxe)
|
|
};
|
|
|
|
// Werkzeug-System
|
|
this.tools = {
|
|
axe: false, // Kann Bäume abbauen
|
|
pickaxe: false // Kann Steine abbauen
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Update-Methode - wird jeden Frame aufgerufen
|
|
* Hier wird die Spielerbewegung basierend auf Eingaben berechnet
|
|
*/
|
|
update() {
|
|
// Bewegungsvektor berechnen (Richtung der Bewegung)
|
|
let moveX = 0;
|
|
let moveY = 0;
|
|
|
|
// Eingaben in Bewegungsvektor umwandeln
|
|
if (keys.left || keys.a) {
|
|
moveX -= 1;
|
|
}
|
|
if (keys.right || keys.d) {
|
|
moveX += 1;
|
|
}
|
|
if (keys.up || keys.w) {
|
|
moveY -= 1;
|
|
}
|
|
if (keys.down || keys.s) {
|
|
moveY += 1;
|
|
}
|
|
|
|
// Vektornormalisierung für gleichmäßige Geschwindigkeit
|
|
// Problem: Bei diagonaler Bewegung ist die Geschwindigkeit √2 ≈ 1.41x schneller
|
|
// Lösung: Vektor normalisieren (Länge = 1) und dann mit Geschwindigkeit multiplizieren
|
|
if (moveX !== 0 || moveY !== 0) {
|
|
// Länge des Vektors berechnen (Pythagoras: √(x² + y²))
|
|
const length = Math.sqrt(moveX * moveX + moveY * moveY);
|
|
|
|
// Vektor normalisieren (durch Länge teilen)
|
|
moveX = moveX / length;
|
|
moveY = moveY / length;
|
|
|
|
// Mit gewünschter Geschwindigkeit multiplizieren
|
|
this.x += moveX * this.speed;
|
|
this.y += moveY * this.speed;
|
|
}
|
|
|
|
// In der unendlichen Welt gibt es keine Grenzen!
|
|
// Der Spieler kann sich frei bewegen, die Kamera folgt ihm
|
|
|
|
// Gesundheits-System aktualisieren
|
|
this.updateHealth();
|
|
}
|
|
|
|
/**
|
|
* Aktualisiert das Gesundheitssystem
|
|
* Gesundheit nimmt langsam ab, regeneriert sich am Lagerfeuer
|
|
*/
|
|
updateHealth() {
|
|
this.healthTimer++;
|
|
|
|
// Prüfen ob Spieler in der Nähe eines Lagerfeuers ist
|
|
let nearCampfire = false;
|
|
campfires.forEach(campfire => {
|
|
const distance = Math.sqrt(
|
|
(this.x - campfire.x) ** 2 + (this.y - campfire.y) ** 2
|
|
);
|
|
if (distance < 50) { // 50 Pixel Radius
|
|
nearCampfire = true;
|
|
}
|
|
});
|
|
|
|
if (nearCampfire) {
|
|
// Gesundheit regenerieren am Lagerfeuer
|
|
if (this.healthTimer >= 60) { // Jede Sekunde
|
|
this.health = Math.min(this.maxHealth, this.health + 2);
|
|
this.healthTimer = 0;
|
|
}
|
|
} else {
|
|
// Gesundheit nimmt langsam ab
|
|
if (this.healthTimer >= this.healthDecayRate) {
|
|
this.health = Math.max(0, this.health - 5);
|
|
this.healthTimer = 0;
|
|
|
|
if (this.health <= 0) {
|
|
console.log('💀 Game Over! Deine Gesundheit ist auf 0 gefallen.');
|
|
// Hier könnte später ein Game Over Screen kommen
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render-Methode - zeichnet den Spieler auf das Canvas
|
|
* @param {CanvasRenderingContext2D} ctx - Der Canvas-Kontext
|
|
* @param {Camera} camera - Die Kamera für Koordinaten-Transformation
|
|
*/
|
|
render(ctx, camera) {
|
|
// Weltkoordinaten zu Bildschirmkoordinaten konvertieren
|
|
const screenPos = camera.worldToScreen(this.x, this.y);
|
|
|
|
// Spieler zeichnen
|
|
if (assetManager.isLoaded) {
|
|
// Verwende ein Pointer-Sprite für den Spieler
|
|
assetManager.drawSprite(
|
|
ctx,
|
|
'pointer1', // Verwende das erste Pointer-Sprite
|
|
screenPos.x - this.width / 2,
|
|
screenPos.y - this.height / 2,
|
|
this.width,
|
|
this.height
|
|
);
|
|
} else {
|
|
// Fallback: Einfaches Rechteck
|
|
ctx.fillStyle = this.color;
|
|
ctx.fillRect(
|
|
screenPos.x - this.width/2, // X-Position (links)
|
|
screenPos.y - this.height/2, // Y-Position (oben)
|
|
this.width, // Breite
|
|
this.height // Höhe
|
|
);
|
|
|
|
// Schwarzer Rand für bessere Sichtbarkeit
|
|
ctx.strokeStyle = '#000000';
|
|
ctx.lineWidth = 2;
|
|
ctx.strokeRect(
|
|
screenPos.x - this.width/2,
|
|
screenPos.y - this.height/2,
|
|
this.width,
|
|
this.height
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ===== EINGABE-SYSTEM =====
|
|
|
|
/**
|
|
* Das keys-Objekt speichert den Zustand aller Tasten
|
|
*
|
|
* Warum so?
|
|
* - Ermöglicht gleichzeitiges Drücken mehrerer Tasten (z.B. diagonal laufen)
|
|
* - Saubere Trennung zwischen Eingabe-Erkennung und Spiellogik
|
|
* - Einfach erweiterbar für neue Tasten
|
|
*/
|
|
const keys = {
|
|
left: false,
|
|
right: false,
|
|
up: false,
|
|
down: false,
|
|
w: false,
|
|
a: false,
|
|
s: false,
|
|
d: false,
|
|
e: false, // Lagerfeuer
|
|
q: false, // Axt
|
|
r: false, // Pickaxe
|
|
tab: false // Inventar toggle
|
|
};
|
|
|
|
// UI-Zustand
|
|
let showInventory = false; // Inventar standardmäßig ausgeblendet
|
|
|
|
/**
|
|
* Event-Listener für Tastendruck (keydown)
|
|
* Wird aufgerufen, wenn eine Taste gedrückt wird
|
|
*/
|
|
window.addEventListener('keydown', (event) => {
|
|
// event.code gibt uns den physischen Tastencode
|
|
switch(event.code) {
|
|
case 'ArrowLeft':
|
|
keys.left = true;
|
|
break;
|
|
case 'ArrowRight':
|
|
keys.right = true;
|
|
break;
|
|
case 'ArrowUp':
|
|
keys.up = true;
|
|
break;
|
|
case 'ArrowDown':
|
|
keys.down = true;
|
|
break;
|
|
case 'KeyW':
|
|
keys.w = true;
|
|
break;
|
|
case 'KeyA':
|
|
keys.a = true;
|
|
break;
|
|
case 'KeyS':
|
|
keys.s = true;
|
|
break;
|
|
case 'KeyD':
|
|
keys.d = true;
|
|
break;
|
|
case 'KeyE':
|
|
keys.e = true;
|
|
break;
|
|
case 'KeyQ':
|
|
keys.q = true;
|
|
break;
|
|
case 'KeyR':
|
|
keys.r = true;
|
|
break;
|
|
case 'Tab':
|
|
keys.tab = true;
|
|
break;
|
|
}
|
|
|
|
// Verhindert Standard-Browser-Verhalten (z.B. Scrollen mit Pfeiltasten)
|
|
event.preventDefault();
|
|
});
|
|
|
|
/**
|
|
* Event-Listener für Taste loslassen (keyup)
|
|
* Wird aufgerufen, wenn eine Taste losgelassen wird
|
|
*/
|
|
window.addEventListener('keyup', (event) => {
|
|
switch(event.code) {
|
|
case 'ArrowLeft':
|
|
keys.left = false;
|
|
break;
|
|
case 'ArrowRight':
|
|
keys.right = false;
|
|
break;
|
|
case 'ArrowUp':
|
|
keys.up = false;
|
|
break;
|
|
case 'ArrowDown':
|
|
keys.down = false;
|
|
break;
|
|
case 'KeyW':
|
|
keys.w = false;
|
|
break;
|
|
case 'KeyA':
|
|
keys.a = false;
|
|
break;
|
|
case 'KeyS':
|
|
keys.s = false;
|
|
break;
|
|
case 'KeyD':
|
|
keys.d = false;
|
|
break;
|
|
case 'KeyE':
|
|
keys.e = false;
|
|
break;
|
|
case 'KeyQ':
|
|
keys.q = false;
|
|
break;
|
|
case 'KeyR':
|
|
keys.r = false;
|
|
break;
|
|
case 'Tab':
|
|
keys.tab = false;
|
|
break;
|
|
}
|
|
});
|
|
|
|
// ===== RESSOURCEN-KLASSE =====
|
|
|
|
/**
|
|
* Die Resource-Klasse repräsentiert sammelbare Objekte in der Spielwelt
|
|
*
|
|
* Verschiedene Ressourcentypen:
|
|
* - 'tree': Bäume für Holz
|
|
* - 'rock': Felsen für Stein
|
|
*/
|
|
class Resource {
|
|
/**
|
|
* Konstruktor für eine neue Ressource
|
|
* @param {number} x - X-Position
|
|
* @param {number} y - Y-Position
|
|
* @param {string} type - Typ der Ressource ('tree', 'rock', 'stick')
|
|
* @param {string} spriteKey - Schlüssel für das Sprite im AssetManager
|
|
*/
|
|
constructor(x, y, type, spriteKey = null) {
|
|
this.x = x;
|
|
this.y = y;
|
|
this.type = type;
|
|
this.spriteKey = spriteKey;
|
|
|
|
// Eigenschaften basierend auf Typ setzen
|
|
if (type === 'tree') {
|
|
this.width = 64;
|
|
this.height = 64;
|
|
this.color = '#228B22'; // Waldgrün (Fallback)
|
|
this.resource = 'wood'; // Was der Spieler erhält
|
|
this.amount = 3; // Wie viel er erhält
|
|
this.spriteKey = spriteKey || 'tree1';
|
|
} else if (type === 'rock') {
|
|
this.width = 48;
|
|
this.height = 48;
|
|
this.color = '#696969'; // Dunkelgrau (Fallback)
|
|
this.resource = 'stone'; // Was der Spieler erhält
|
|
this.amount = 2; // Wie viel er erhält
|
|
this.spriteKey = spriteKey || 'stone1';
|
|
} else if (type === 'stick') {
|
|
this.width = 32;
|
|
this.height = 32;
|
|
this.color = '#8B4513'; // Braun (Fallback)
|
|
this.resource = 'stick'; // Was der Spieler erhält
|
|
this.amount = 1; // Wie viel er erhält
|
|
this.spriteKey = spriteKey || 'stick1';
|
|
}
|
|
|
|
// Ob die Ressource noch sammelbar ist
|
|
this.collected = false;
|
|
}
|
|
|
|
/**
|
|
* Prüft, ob der Spieler diese Ressource berührt (Kollisionserkennung)
|
|
* @param {Player} player - Der Spieler
|
|
* @returns {boolean} - True wenn Kollision
|
|
*/
|
|
checkCollision(player) {
|
|
// AABB (Axis-Aligned Bounding Box) Kollisionserkennung
|
|
// Zwei Rechtecke überlappen sich, wenn sie sich in beiden Achsen überlappen
|
|
|
|
// Grenzen der Ressource
|
|
const resLeft = this.x - this.width / 2;
|
|
const resRight = this.x + this.width / 2;
|
|
const resTop = this.y - this.height / 2;
|
|
const resBottom = this.y + this.height / 2;
|
|
|
|
// Grenzen des Spielers
|
|
const playerLeft = player.x - player.width / 2;
|
|
const playerRight = player.x + player.width / 2;
|
|
const playerTop = player.y - player.height / 2;
|
|
const playerBottom = player.y + player.height / 2;
|
|
|
|
// Kollision prüfen
|
|
return resLeft < playerRight &&
|
|
resRight > playerLeft &&
|
|
resTop < playerBottom &&
|
|
resBottom > playerTop;
|
|
}
|
|
|
|
/**
|
|
* Sammelt die Ressource und gibt sie an das Inventar weiter
|
|
* @param {Object} inventory - Das Spieler-Inventar
|
|
* @param {Player} player - Der Spieler (für Werkzeug-Überprüfung)
|
|
*/
|
|
collect(player) {
|
|
if (!this.collected) {
|
|
// Prüfen ob das richtige Werkzeug vorhanden ist
|
|
if (this.type === 'tree' && !player.tools.axe) {
|
|
console.log('🪓 Du brauchst eine Axt um Bäume zu fällen!');
|
|
return false;
|
|
}
|
|
if (this.type === 'rock' && !player.tools.pickaxe) {
|
|
console.log('⛏️ Du brauchst eine Spitzhacke um Steine abzubauen!');
|
|
return false;
|
|
}
|
|
|
|
this.collected = true;
|
|
|
|
// Ressource zum Spieler-Inventar hinzufügen
|
|
if (!player.inventory[this.resource]) {
|
|
player.inventory[this.resource] = 0;
|
|
}
|
|
player.inventory[this.resource] += this.amount;
|
|
|
|
console.log(`📦 ${this.amount} ${this.resource} gesammelt! Gesamt: ${player.inventory[this.resource]}`);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Zeichnet die Ressource auf das Canvas
|
|
* @param {CanvasRenderingContext2D} ctx - Der Canvas-Kontext
|
|
* @param {Camera} camera - Die Kamera für Koordinaten-Transformation
|
|
*/
|
|
render(ctx, camera) {
|
|
if (!this.collected) {
|
|
// Weltkoordinaten zu Bildschirmkoordinaten konvertieren
|
|
const screenPos = camera.worldToScreen(this.x, this.y);
|
|
|
|
// Nur zeichnen wenn im sichtbaren Bereich
|
|
if (screenPos.x + this.width/2 < 0 || screenPos.x - this.width/2 > CANVAS_WIDTH ||
|
|
screenPos.y + this.height/2 < 0 || screenPos.y - this.height/2 > CANVAS_HEIGHT) {
|
|
return; // Außerhalb des Bildschirms
|
|
}
|
|
|
|
// Schatten zeichnen (für 3D-Effekt)
|
|
if (assetManager.isLoaded && (this.type === 'tree' || this.type === 'rock')) {
|
|
const shadowKey = this.type === 'tree' ? 'shadow2' : 'shadow1';
|
|
assetManager.drawSprite(
|
|
ctx,
|
|
shadowKey,
|
|
screenPos.x - this.width/2 + 5,
|
|
screenPos.y - this.height/2 + this.height - 10,
|
|
this.width,
|
|
16
|
|
);
|
|
}
|
|
|
|
// Sprite zeichnen falls Assets geladen sind
|
|
if (assetManager.isLoaded && this.spriteKey) {
|
|
assetManager.drawSprite(
|
|
ctx,
|
|
this.spriteKey,
|
|
screenPos.x - this.width/2,
|
|
screenPos.y - this.height/2,
|
|
this.width,
|
|
this.height
|
|
);
|
|
} else {
|
|
// Fallback: Einfache Rechtecke
|
|
ctx.fillStyle = this.color;
|
|
ctx.fillRect(
|
|
screenPos.x - this.width / 2,
|
|
screenPos.y - this.height / 2,
|
|
this.width,
|
|
this.height
|
|
);
|
|
|
|
// Schwarzer Rand
|
|
ctx.strokeStyle = '#000000';
|
|
ctx.lineWidth = 1;
|
|
ctx.strokeRect(
|
|
screenPos.x - this.width / 2,
|
|
screenPos.y - this.height / 2,
|
|
this.width,
|
|
this.height
|
|
);
|
|
|
|
// Spezielle Darstellung für Bäume (Krone)
|
|
if (this.type === 'tree') {
|
|
// Baumkrone (Kreis oben)
|
|
ctx.fillStyle = '#006400'; // Dunkelgrün
|
|
ctx.beginPath();
|
|
ctx.arc(screenPos.x, screenPos.y - this.height/3, this.width/2, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ===== LAGERFEUER-KLASSE =====
|
|
|
|
/**
|
|
* Die Campfire-Klasse repräsentiert ein gebautes Lagerfeuer
|
|
*
|
|
* Funktionen:
|
|
* - Regeneriert Spieler-Gesundheit in der Nähe
|
|
* - Kann mit Holz gebaut werden
|
|
* - Visueller Indikator für sichere Zonen
|
|
*/
|
|
class Campfire {
|
|
/**
|
|
* Konstruktor für ein neues Lagerfeuer
|
|
* @param {number} x - X-Position
|
|
* @param {number} y - Y-Position
|
|
*/
|
|
constructor(x, y) {
|
|
this.x = x;
|
|
this.y = y;
|
|
this.width = 48;
|
|
this.height = 48;
|
|
|
|
// Animation für Torch-Sprites
|
|
this.animationTimer = 0;
|
|
this.animationSpeed = 0.15; // Geschwindigkeit der Animation
|
|
this.currentFrame = 0;
|
|
this.totalFrames = 4; // torch1, torch2, torch3, torch4
|
|
}
|
|
|
|
/**
|
|
* Aktualisiert die Lagerfeuer-Animation
|
|
*/
|
|
update() {
|
|
// Torch-Sprite Animation
|
|
this.animationTimer += this.animationSpeed;
|
|
|
|
if (this.animationTimer >= 1) {
|
|
this.currentFrame = (this.currentFrame + 1) % this.totalFrames;
|
|
this.animationTimer = 0;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Zeichnet das Lagerfeuer
|
|
* @param {CanvasRenderingContext2D} ctx - Der Canvas-Kontext
|
|
* @param {Camera} camera - Die Kamera für Koordinaten-Transformation
|
|
*/
|
|
render(ctx, camera) {
|
|
// Weltkoordinaten zu Bildschirmkoordinaten konvertieren
|
|
const screenPos = camera.worldToScreen(this.x, this.y);
|
|
|
|
// Nur zeichnen wenn im sichtbaren Bereich
|
|
if (screenPos.x + 50 < 0 || screenPos.x - 50 > CANVAS_WIDTH ||
|
|
screenPos.y + 50 < 0 || screenPos.y - 50 > CANVAS_HEIGHT) {
|
|
return; // Außerhalb des Bildschirms
|
|
}
|
|
|
|
// Heilungsradius anzeigen (halbtransparent)
|
|
ctx.fillStyle = 'rgba(0, 255, 0, 0.1)';
|
|
ctx.beginPath();
|
|
ctx.arc(screenPos.x, screenPos.y, 50, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
// Rand um Heilungsradius
|
|
ctx.strokeStyle = 'rgba(0, 255, 0, 0.3)';
|
|
ctx.lineWidth = 2;
|
|
ctx.stroke();
|
|
|
|
// Animiertes Torch-Sprite zeichnen
|
|
if (assetManager.isLoaded) {
|
|
const torchKey = `torch${this.currentFrame + 1}`;
|
|
assetManager.drawSprite(
|
|
ctx,
|
|
torchKey,
|
|
screenPos.x - this.width/2,
|
|
screenPos.y - this.height/2,
|
|
this.width,
|
|
this.height
|
|
);
|
|
} else {
|
|
// Fallback: Einfaches Lagerfeuer
|
|
ctx.fillStyle = '#8B4513'; // Braun
|
|
ctx.fillRect(
|
|
screenPos.x - this.width/2,
|
|
screenPos.y - this.height/2,
|
|
this.width,
|
|
this.height
|
|
);
|
|
|
|
// Flammen (animiert)
|
|
ctx.fillStyle = '#FF4500'; // Orange-Rot
|
|
ctx.fillRect(
|
|
screenPos.x - this.width/3,
|
|
screenPos.y - this.height/2 - 15,
|
|
this.width/1.5,
|
|
15
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ===== SPIEL-OBJEKTE =====
|
|
|
|
// Welt-Instanz erstellen
|
|
let world;
|
|
|
|
// Spieler-Instanz erstellen (Startposition in der Weltmitte)
|
|
let player;
|
|
|
|
// Array für alle Lagerfeuer (bleiben global, da sie vom Spieler gebaut werden)
|
|
let campfires = [];
|
|
|
|
// Crafting-Rezepte
|
|
const recipes = {
|
|
axe: {
|
|
name: 'Axt',
|
|
cost: { stick: 2 },
|
|
description: 'Ermöglicht das Fällen von Bäumen',
|
|
tool: true
|
|
},
|
|
pickaxe: {
|
|
name: 'Spitzhacke',
|
|
cost: { stick: 1, wood: 2 },
|
|
description: 'Ermöglicht das Abbauen von Steinen',
|
|
tool: true
|
|
},
|
|
campfire: {
|
|
name: 'Lagerfeuer',
|
|
cost: { wood: 5 },
|
|
description: 'Regeneriert Gesundheit in der Nähe'
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Versucht ein Item zu craften
|
|
* @param {string} itemType - Der Typ des Items (z.B. 'axe', 'pickaxe', 'campfire')
|
|
*/
|
|
function craftItem(itemType) {
|
|
const recipe = recipes[itemType];
|
|
if (!recipe) {
|
|
console.log(`❌ Unbekanntes Rezept: ${itemType}`);
|
|
return false;
|
|
}
|
|
|
|
// Prüfen ob genügend Ressourcen vorhanden sind
|
|
for (const [resource, amount] of Object.entries(recipe.cost)) {
|
|
if (!player.inventory[resource] || player.inventory[resource] < amount) {
|
|
console.log(`❌ Nicht genügend ${resource}! Benötigt: ${amount}, Vorhanden: ${player.inventory[resource] || 0}`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Spezielle Überprüfungen für Werkzeuge
|
|
if (itemType === 'axe' && player.tools.axe) {
|
|
console.log('🪓 Du hast bereits eine Axt!');
|
|
return false;
|
|
}
|
|
if (itemType === 'pickaxe' && player.tools.pickaxe) {
|
|
console.log('⛏️ Du hast bereits eine Spitzhacke!');
|
|
return false;
|
|
}
|
|
|
|
// Ressourcen abziehen
|
|
for (const [resource, amount] of Object.entries(recipe.cost)) {
|
|
player.inventory[resource] -= amount;
|
|
}
|
|
|
|
// Item erstellen
|
|
if (itemType === 'axe') {
|
|
player.tools.axe = true;
|
|
console.log(`🪓 ${recipe.name} erfolgreich hergestellt!`);
|
|
} else if (itemType === 'pickaxe') {
|
|
player.tools.pickaxe = true;
|
|
console.log(`⛏️ ${recipe.name} erfolgreich hergestellt!`);
|
|
} else if (itemType === 'campfire') {
|
|
// Lagerfeuer an Spielerposition erstellen
|
|
campfires.push(new Campfire(player.x, player.y));
|
|
console.log(`🔥 ${recipe.name} erfolgreich gebaut!`);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
// Die Ressourcen-Generierung wird jetzt von den Chunks übernommen!
|
|
// Jeder Chunk generiert automatisch Ressourcen basierend auf seiner Position
|
|
|
|
// ===== GRUNDLEGENDE ZEICHENFUNKTIONEN =====
|
|
|
|
/**
|
|
* Diese Funktion löscht das gesamte Canvas und zeichnet einen blauen Hintergrund
|
|
*
|
|
* Warum brauchen wir das?
|
|
* - In jedem Frame müssen wir das Canvas "sauber machen"
|
|
* - Sonst würden sich die gezeichneten Objekte überlagern
|
|
* - Der blaue Hintergrund simuliert einen Himmel oder Wasser
|
|
*/
|
|
function clearCanvas() {
|
|
// Schritt 1: Das gesamte Canvas löschen (transparent machen)
|
|
ctx.clearRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
|
|
|
|
// Schritt 2: Einen blauen Hintergrund zeichnen
|
|
ctx.fillStyle = '#87CEEB'; // Himmelblau (Sky Blue)
|
|
ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
|
|
}
|
|
|
|
/**
|
|
* Die Render-Funktion - hier wird alles gezeichnet
|
|
*
|
|
* Das ist das Herzstück unseres Spiels:
|
|
* - Wird in jedem Frame aufgerufen
|
|
* - Zeichnet alle Spielobjekte in der richtigen Reihenfolge
|
|
* - Reihenfolge: Hintergrund → Boden-Tiles → Schatten → Objekte (Y-sortiert) → UI
|
|
*/
|
|
function render() {
|
|
// Canvas leeren und Hintergrund zeichnen
|
|
clearCanvas();
|
|
|
|
if (!world || !player) return;
|
|
|
|
// 1. Boden-Tiles zeichnen (unterste Ebene)
|
|
for (const chunk of world.chunks.values()) {
|
|
if (chunk.isVisible(world.camera)) {
|
|
chunk.renderTiles(ctx, world.camera);
|
|
}
|
|
}
|
|
|
|
// 2. Alle sichtbaren Objekte sammeln und nach Y-Position sortieren
|
|
const allObjects = [];
|
|
|
|
// Ressourcen hinzufügen (nur sichtbare)
|
|
const allResources = world.getAllResources();
|
|
allResources.forEach(resource => {
|
|
if (!resource.collected) {
|
|
const screenPos = world.camera.worldToScreen(resource.x, resource.y);
|
|
// Nur hinzufügen wenn im sichtbaren Bereich
|
|
if (screenPos.x + resource.width >= -50 && screenPos.x <= CANVAS_WIDTH + 50 &&
|
|
screenPos.y + resource.height >= -50 && screenPos.y <= CANVAS_HEIGHT + 50) {
|
|
allObjects.push({
|
|
type: 'resource',
|
|
object: resource,
|
|
// Sortierung nach Fußpunkt für korrekte Tiefe
|
|
y: resource.y + resource.height,
|
|
zIndex: resource.type === 'stick' ? 1 : (resource.type === 'tree' ? 3 : 2)
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
// Lagerfeuer hinzufügen (nur sichtbare)
|
|
campfires.forEach(campfire => {
|
|
const screenPos = world.camera.worldToScreen(campfire.x, campfire.y);
|
|
if (screenPos.x + 50 >= -50 && screenPos.x <= CANVAS_WIDTH + 50 &&
|
|
screenPos.y + 50 >= -50 && screenPos.y <= CANVAS_HEIGHT + 50) {
|
|
allObjects.push({
|
|
type: 'campfire',
|
|
object: campfire,
|
|
y: campfire.y + campfire.height,
|
|
zIndex: 2
|
|
});
|
|
}
|
|
});
|
|
|
|
// Spieler hinzufügen
|
|
allObjects.push({
|
|
type: 'player',
|
|
object: player,
|
|
y: player.y + player.height,
|
|
zIndex: 4 // Spieler immer über allem anderen
|
|
});
|
|
|
|
// 3. Nach Z-Index und dann Y-Position sortieren
|
|
allObjects.sort((a, b) => {
|
|
// Erst nach Z-Index sortieren
|
|
if (a.zIndex !== b.zIndex) {
|
|
return a.zIndex - b.zIndex;
|
|
}
|
|
// Dann nach Y-Position (Tiefe)
|
|
return a.y - b.y;
|
|
});
|
|
|
|
// 4. Alle Objekte in der sortierten Reihenfolge zeichnen
|
|
allObjects.forEach(item => {
|
|
switch(item.type) {
|
|
case 'resource':
|
|
item.object.render(ctx, world.camera);
|
|
break;
|
|
case 'campfire':
|
|
item.object.render(ctx, world.camera);
|
|
break;
|
|
case 'player':
|
|
item.object.render(ctx, world.camera);
|
|
break;
|
|
}
|
|
});
|
|
|
|
// 5. UI zeichnen (immer ganz oben)
|
|
renderUI();
|
|
|
|
// Hier werden später weitere Spielobjekte gezeichnet:
|
|
// - Feinde
|
|
// - Partikeleffekte
|
|
// - Weitere Gebäude
|
|
}
|
|
|
|
/**
|
|
* Zeichnet die Benutzeroberfläche (UI)
|
|
* Zeigt Gesundheit immer an, Inventar nur bei Tab-Taste
|
|
*/
|
|
function renderUI() {
|
|
// Gesundheitsbalken (immer sichtbar, oben links)
|
|
const healthBarWidth = 200;
|
|
const healthBarHeight = 20;
|
|
const healthBarX = 10;
|
|
const healthBarY = 10;
|
|
|
|
// Hintergrund der Gesundheitsleiste
|
|
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
|
|
ctx.fillRect(healthBarX, healthBarY, healthBarWidth, healthBarHeight + 30);
|
|
|
|
ctx.strokeStyle = '#FFFFFF';
|
|
ctx.lineWidth = 2;
|
|
ctx.strokeRect(healthBarX, healthBarY, healthBarWidth, healthBarHeight + 30);
|
|
|
|
// Gesundheits-Text
|
|
ctx.fillStyle = '#FFFFFF';
|
|
ctx.font = '16px Arial';
|
|
ctx.fillText('❤️ Gesundheit:', healthBarX + 10, healthBarY + 20);
|
|
|
|
// Gesundheitsbalken-Hintergrund
|
|
ctx.fillStyle = '#333333';
|
|
ctx.fillRect(healthBarX + 10, healthBarY + 25, healthBarWidth - 20, 15);
|
|
|
|
// Gesundheitsbalken (rot bis grün je nach Gesundheit)
|
|
const healthPercent = player ? player.health / player.maxHealth : 0;
|
|
const healthColor = healthPercent > 0.6 ? '#00FF00' :
|
|
healthPercent > 0.3 ? '#FFFF00' : '#FF0000';
|
|
|
|
ctx.fillStyle = healthColor;
|
|
ctx.fillRect(healthBarX + 10, healthBarY + 25, (healthBarWidth - 20) * healthPercent, 15);
|
|
|
|
// Gesundheits-Zahlen
|
|
ctx.fillStyle = '#FFFFFF';
|
|
ctx.font = '12px Arial';
|
|
const healthText = player ? `${Math.round(player.health)}/${player.maxHealth}` : '0/100';
|
|
ctx.fillText(healthText, healthBarX + healthBarWidth/2 - 15, healthBarY + 36);
|
|
|
|
// Tab-Hinweis (klein, unten rechts)
|
|
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
|
|
ctx.fillRect(CANVAS_WIDTH - 120, CANVAS_HEIGHT - 30, 110, 20);
|
|
|
|
ctx.strokeStyle = '#FFFFFF';
|
|
ctx.lineWidth = 1;
|
|
ctx.strokeRect(CANVAS_WIDTH - 120, CANVAS_HEIGHT - 30, 110, 20);
|
|
|
|
ctx.fillStyle = '#FFFFFF';
|
|
ctx.font = '12px Arial';
|
|
ctx.fillText('Tab = Inventar', CANVAS_WIDTH - 115, CANVAS_HEIGHT - 15);
|
|
|
|
// Inventar nur anzeigen wenn Tab gedrückt wurde
|
|
if (showInventory) {
|
|
renderInventoryPanel();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Zeichnet das Inventar-Panel (nur wenn sichtbar)
|
|
*/
|
|
function renderInventoryPanel() {
|
|
// Inventar-Panel (zentriert)
|
|
const panelWidth = 500;
|
|
const panelHeight = 300;
|
|
const panelX = (CANVAS_WIDTH - panelWidth) / 2;
|
|
const panelY = (CANVAS_HEIGHT - panelHeight) / 2;
|
|
|
|
// Halbtransparenter Hintergrund über dem ganzen Bildschirm
|
|
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
|
|
ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
|
|
|
|
// Inventar-Panel
|
|
ctx.fillStyle = 'rgba(20, 20, 20, 0.95)';
|
|
ctx.fillRect(panelX, panelY, panelWidth, panelHeight);
|
|
|
|
ctx.strokeStyle = '#FFFFFF';
|
|
ctx.lineWidth = 3;
|
|
ctx.strokeRect(panelX, panelY, panelWidth, panelHeight);
|
|
|
|
// Titel
|
|
ctx.fillStyle = '#FFFFFF';
|
|
ctx.font = 'bold 24px Arial';
|
|
ctx.fillText('📦 INVENTAR', panelX + 20, panelY + 40);
|
|
|
|
// Schließen-Hinweis
|
|
ctx.font = '14px Arial';
|
|
ctx.fillText('Tab drücken zum Schließen', panelX + panelWidth - 180, panelY + 40);
|
|
|
|
// Ressourcen (linke Spalte)
|
|
ctx.font = '18px Arial';
|
|
ctx.fillText('🎒 Ressourcen:', panelX + 30, panelY + 80);
|
|
|
|
ctx.font = '16px Arial';
|
|
ctx.fillText(`🪵 Stöcker: ${player.inventory.stick}`, panelX + 50, panelY + 110);
|
|
ctx.fillText(`🌳 Holz: ${player.inventory.wood}`, panelX + 50, panelY + 140);
|
|
ctx.fillText(`🪨 Stein: ${player.inventory.stone}`, panelX + 50, panelY + 170);
|
|
|
|
// Werkzeuge (rechte Spalte)
|
|
ctx.font = '18px Arial';
|
|
ctx.fillText('🔧 Werkzeuge:', panelX + 280, panelY + 80);
|
|
|
|
ctx.font = '16px Arial';
|
|
ctx.fillText(`🪓 Axt: ${player.tools.axe ? '✅ Vorhanden' : '❌ Nicht vorhanden'}`, panelX + 300, panelY + 110);
|
|
ctx.fillText(`⛏️ Pickaxe: ${player.tools.pickaxe ? '✅ Vorhanden' : '❌ Nicht vorhanden'}`, panelX + 300, panelY + 140);
|
|
|
|
// Crafting-Rezepte (unten)
|
|
ctx.font = '18px Arial';
|
|
ctx.fillText('🔨 Crafting-Rezepte:', panelX + 30, panelY + 220);
|
|
|
|
ctx.font = '14px Arial';
|
|
ctx.fillText('Q = Axt (2 Stöcker)', panelX + 50, panelY + 250);
|
|
ctx.fillText('R = Pickaxe (1 Stock + 2 Holz)', panelX + 220, panelY + 250);
|
|
ctx.fillText('E = Lagerfeuer (5 Holz)', panelX + 50, panelY + 275);
|
|
}
|
|
|
|
/**
|
|
* Die Update-Funktion - hier wird die Spiellogik berechnet
|
|
*
|
|
* Trennung von Logik und Darstellung:
|
|
* - update() = Was passiert (Bewegung, Kollisionen, Spielregeln)
|
|
* - render() = Wie es aussieht (Zeichnen auf das Canvas)
|
|
*/
|
|
function update() {
|
|
// Spieler aktualisieren (Bewegung basierend auf Eingaben)
|
|
if (player) {
|
|
player.update();
|
|
}
|
|
|
|
// Welt aktualisieren (Chunks laden/entladen basierend auf Spielerposition)
|
|
if (world) {
|
|
world.update(player);
|
|
}
|
|
|
|
// Lagerfeuer aktualisieren (Animation)
|
|
campfires.forEach(campfire => {
|
|
campfire.update();
|
|
});
|
|
|
|
// Kollisionserkennung: Spieler mit Ressourcen aus allen geladenen Chunks
|
|
if (world) {
|
|
const allResources = world.getAllResources();
|
|
allResources.forEach(resource => {
|
|
if (!resource.collected && resource.checkCollision(player)) {
|
|
resource.collect(player);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Tab-Taste für Inventar-Toggle
|
|
if (keys.tab) {
|
|
showInventory = !showInventory;
|
|
keys.tab = false; // Verhindert mehrfaches Umschalten
|
|
}
|
|
|
|
// Crafting-Eingaben prüfen
|
|
if (keys.q) {
|
|
craftItem('axe');
|
|
keys.q = false; // Verhindert mehrfaches Crafting
|
|
}
|
|
if (keys.r) {
|
|
craftItem('pickaxe');
|
|
keys.r = false;
|
|
}
|
|
if (keys.e) {
|
|
craftItem('campfire');
|
|
keys.e = false;
|
|
}
|
|
|
|
// Hier wird später weitere Spiellogik stehen:
|
|
// - Feinde bewegen
|
|
// - Weitere Crafting-Rezepte
|
|
// - Wetter-System
|
|
}
|
|
|
|
/**
|
|
* Der Game Loop - das Herz jedes Spiels
|
|
*
|
|
* Was ist ein Game Loop?
|
|
* - Eine endlose Schleife, die das Spiel am Leben hält
|
|
* - Läuft idealerweise 60 mal pro Sekunde (60 FPS)
|
|
* - In jedem Durchlauf: Update → Render → Warten → Wiederholen
|
|
*
|
|
* requestAnimationFrame():
|
|
* - Browser-optimierte Funktion für Animationen
|
|
* - Synchronisiert sich automatisch mit der Bildschirmfrequenz
|
|
* - Pausiert automatisch, wenn der Tab nicht sichtbar ist (Energiesparen)
|
|
*/
|
|
function gameLoop() {
|
|
// 1. Spiellogik aktualisieren
|
|
update();
|
|
|
|
// 2. Alles neu zeichnen
|
|
render();
|
|
|
|
// 3. Nächsten Frame anfordern (rekursiver Aufruf)
|
|
requestAnimationFrame(gameLoop);
|
|
}
|
|
|
|
// ===== SPIEL STARTEN =====
|
|
|
|
/**
|
|
* Initialisierungsfunktion - wird einmal beim Laden der Seite aufgerufen
|
|
*/
|
|
async function init() {
|
|
console.log('🎮 Pixel-Überlebender wird gestartet...');
|
|
console.log(`📐 Canvas-Größe: ${CANVAS_WIDTH}x${CANVAS_HEIGHT} Pixel`);
|
|
|
|
// Assets laden
|
|
console.log('🎨 Lade Grafiken...');
|
|
const assetsLoaded = await assetManager.loadAllAssets();
|
|
|
|
if (!assetsLoaded) {
|
|
console.error('❌ Fehler beim Laden der Assets! Spiel startet mit Fallback-Grafiken.');
|
|
}
|
|
|
|
// Welt erstellen
|
|
world = new World();
|
|
console.log('🌍 Unendliche prozedurale Welt erstellt!');
|
|
|
|
// Spieler in der Weltmitte erstellen (0, 0 in Weltkoordinaten)
|
|
player = new Player(0, 0);
|
|
console.log(`👤 Spieler erstellt an Position (${player.x}, ${player.y})`);
|
|
|
|
// Ersten Frame zeichnen
|
|
render();
|
|
|
|
// Game Loop starten
|
|
gameLoop();
|
|
|
|
console.log('✅ Spiel erfolgreich gestartet!');
|
|
console.log('🎯 Verwende Pfeiltasten oder WASD zum Bewegen!');
|
|
console.log('📦 Berühre Bäume und Felsen zum Sammeln!');
|
|
console.log('📋 Tab = Inventar öffnen/schließen');
|
|
console.log('🔨 Crafting: Q=Axt, R=Pickaxe, E=Lagerfeuer');
|
|
console.log('🪵 Sammle zuerst Stöcker für eine Axt!');
|
|
console.log('🪓 Mit Axt kannst du Bäume fällen');
|
|
console.log('⛏️ Mit Pickaxe kannst du Steine abbauen');
|
|
console.log('❤️ Deine Gesundheit nimmt langsam ab - bleibe am Lagerfeuer!');
|
|
console.log('🗺️ Erkunde die unendliche Welt - neue Chunks werden automatisch geladen!');
|
|
|
|
if (assetsLoaded) {
|
|
console.log('✨ Alle Grafiken geladen! Genieße das verbesserte Spielerlebnis!');
|
|
}
|
|
}
|
|
|
|
// Warten bis die HTML-Seite vollständig geladen ist, dann das Spiel starten
|
|
// Das ist wichtig, damit das Canvas-Element bereits existiert
|
|
window.addEventListener('load', init);
|
|
|
|
// ===== ERKLÄRUNG DER CANVAS-GRUNDLAGEN =====
|
|
/*
|
|
|
|
WAS IST DAS CANVAS-ELEMENT?
|
|
============================
|
|
|
|
Das <canvas>-Element ist ein HTML5-Element, das uns eine programmierbare
|
|
Zeichenfläche zur Verfügung stellt. Stell dir vor, es ist wie ein digitales
|
|
Blatt Papier, auf das wir mit JavaScript zeichnen können.
|
|
|
|
Wichtige Eigenschaften:
|
|
- Pixelbasiert: Jeder Punkt auf dem Canvas ist ein Pixel
|
|
- Koordinatensystem: (0,0) ist oben links, X geht nach rechts, Y nach unten
|
|
- Sofortiger Modus: Was gezeichnet wird, bleibt bis zum nächsten Löschen
|
|
|
|
WIE GREIFEN WIR DARAUF ZU?
|
|
==========================
|
|
|
|
1. Element holen: document.getElementById('gameCanvas')
|
|
2. Kontext erhalten: canvas.getContext('2d')
|
|
3. Mit dem Kontext zeichnen: ctx.fillRect(), ctx.drawImage(), etc.
|
|
|
|
DER 2D-KONTEXT:
|
|
===============
|
|
|
|
Der Kontext ist unser "Werkzeugkasten" zum Zeichnen:
|
|
- fillRect(): Gefüllte Rechtecke
|
|
- strokeRect(): Rechteck-Umrisse
|
|
- fillStyle: Füllfarbe setzen
|
|
- clearRect(): Bereich löschen
|
|
- drawImage(): Bilder zeichnen
|
|
|
|
KOORDINATENSYSTEM:
|
|
==================
|
|
|
|
(0,0) -----> X (800)
|
|
|
|
|
|
|
|
|
|
|
v
|
|
Y (600)
|
|
|
|
Das ist anders als in der Mathematik - hier ist Y=0 oben!
|
|
|
|
*/ |