3890395885
- Remove duplicate const TILE_HEIGHT/TILE_WIDTH in player.js and game.js - Use var TILE_HEIGHT_PLAYER in player.js to avoid collision - Fix bare ctx → this.ctx in monster render - Fix ctx = this.ctx → const ctx = this.ctx in NPC render This was causing a SyntaxError that prevented the entire game from loading.
516 lines
18 KiB
JavaScript
516 lines
18 KiB
JavaScript
// game.js — Main Game Loop
|
|
|
|
class Game {
|
|
constructor() {
|
|
this.canvas = document.getElementById('game-canvas');
|
|
this.ctx = this.canvas.getContext('2d');
|
|
this.minimapCanvas = document.getElementById('minimap');
|
|
this.minimapCtx = this.minimapCanvas.getContext('2d');
|
|
|
|
this.camera = new Camera();
|
|
this.map = TileMap.generateEichenhafen();
|
|
this.player = new Player(20, 20);
|
|
this.monsters = [];
|
|
this.npcs = [];
|
|
this.damageNumbers = [];
|
|
this.events = [];
|
|
this.activeWorldEvents = [];
|
|
|
|
// API
|
|
this.api = new GameAPI();
|
|
this.currentMap = 'eichenhafen';
|
|
this.eventCheckTimer = 0;
|
|
this.eventCheckInterval = 600; // Check every 10 seconds (at 60fps)
|
|
this.llmBusy = false;
|
|
|
|
// Spawn some NPCs
|
|
this.spawnNPCs();
|
|
|
|
// Spawn some monsters outside the city
|
|
this.spawnMonsters();
|
|
|
|
this.input = new InputHandler(this.canvas, this);
|
|
|
|
this.running = true;
|
|
this.frameCount = 0;
|
|
|
|
this.updateUI();
|
|
this.addEvent('Willkommen in Eichenhafen!', 'system');
|
|
this.addEvent('Klicke um dich zu bewegen. Klicke auf Monster zum angreifen.', 'system');
|
|
|
|
// Check backend health
|
|
this.checkBackend();
|
|
|
|
// Load existing events
|
|
this.loadWorldEvents();
|
|
|
|
this.loop();
|
|
}
|
|
|
|
async checkBackend() {
|
|
const health = await this.api.health();
|
|
if (health.status === 'ok') {
|
|
this.addEvent(`LLM verbunden: ${health.llm_model}`, 'llm');
|
|
} else {
|
|
this.addEvent('Backend offline — LLM-Features deaktiviert', 'system');
|
|
}
|
|
}
|
|
|
|
async loadWorldEvents() {
|
|
const events = await this.api.getEvents(this.currentMap);
|
|
this.activeWorldEvents = events;
|
|
for (const ev of events) {
|
|
this.addEvent(`[Event] ${ev.description}`, 'llm');
|
|
}
|
|
}
|
|
|
|
async checkForNewEvents() {
|
|
if (this.llmBusy) return;
|
|
this.llmBusy = true;
|
|
|
|
const event = await this.api.generateEvent(this.currentMap, this.player.level);
|
|
if (event) {
|
|
this.activeWorldEvents.push(event);
|
|
this.addEvent(`✨ ${event.description}`, 'llm');
|
|
|
|
// Apply event effects
|
|
if (event.effect === 'spawn') {
|
|
this.addEvent('Ein neues Monster ist aufgetaucht!', 'combat');
|
|
// Could spawn a special monster based on event data
|
|
}
|
|
}
|
|
|
|
this.llmBusy = false;
|
|
}
|
|
|
|
spawnNPCs() {
|
|
// NPC near the first building
|
|
this.npcs.push({
|
|
id: 'merchant_eichenhafen',
|
|
name: 'Händler Bruno',
|
|
tileX: 22,
|
|
tileY: 19,
|
|
type: 'merchant',
|
|
dialogue: 'Willkommen! Schau dich in meinem Laden um.'
|
|
});
|
|
// NPC near second building
|
|
this.npcs.push({
|
|
id: 'class_master',
|
|
name: 'Meisterin Vera',
|
|
tileX: 15,
|
|
tileY: 19,
|
|
type: 'class_master',
|
|
dialogue: 'Wenn du Level 10 erreichst, kann ich dir eine Klasse zuweisen.'
|
|
});
|
|
// NPC near third building
|
|
this.npcs.push({
|
|
id: 'innkeeper',
|
|
name: 'Wirtin Helga',
|
|
tileX: 15,
|
|
tileY: 29,
|
|
type: 'innkeeper',
|
|
dialogue: 'Ruhe dich aus. Deine Wunden heilen in der Schenke.'
|
|
});
|
|
}
|
|
|
|
spawnMonsters() {
|
|
// Spawn wolves outside the city area
|
|
const spawnArea = [
|
|
{x: 30, y: 5}, {x: 32, y: 8}, {x: 28, y: 10},
|
|
{x: 35, y: 15}, {x: 33, y: 25}, {x: 30, y: 30}
|
|
];
|
|
|
|
for (const pos of spawnArea) {
|
|
this.monsters.push({
|
|
id: Math.random().toString(36).substr(2, 9),
|
|
name: 'Junger Wolf',
|
|
tileX: pos.x,
|
|
tileY: pos.y,
|
|
x: pos.x,
|
|
y: pos.y,
|
|
hp: 30,
|
|
maxHp: 30,
|
|
atk: 8,
|
|
def: 2,
|
|
level: 1,
|
|
exp: 15,
|
|
gold: 5,
|
|
aggroRange: 4,
|
|
attackCooldown: 0,
|
|
attackSpeed: 45,
|
|
alive: true,
|
|
respawnTimer: 0
|
|
});
|
|
}
|
|
}
|
|
|
|
findPath(start, end) {
|
|
// Simple A* pathfinding
|
|
const openSet = [{x: start.x, y: start.y, g: 0, h: 0, f: 0, parent: null}];
|
|
const closedSet = [];
|
|
const gridSize = this.map.width * this.map.height;
|
|
|
|
while (openSet.length > 0) {
|
|
// Find lowest f
|
|
openSet.sort((a, b) => a.f - b.f);
|
|
const current = openSet.shift();
|
|
|
|
if (current.x === end.x && current.y === end.y) {
|
|
// Reconstruct path
|
|
const path = [];
|
|
let node = current;
|
|
while (node.parent) {
|
|
path.unshift({x: node.x, y: node.y});
|
|
node = node.parent;
|
|
}
|
|
return path;
|
|
}
|
|
|
|
closedSet.push(current);
|
|
|
|
// Check neighbors
|
|
const neighbors = [
|
|
{x: current.x + 1, y: current.y},
|
|
{x: current.x - 1, y: current.y},
|
|
{x: current.x, y: current.y + 1},
|
|
{x: current.x, y: current.y - 1},
|
|
// Diagonal
|
|
{x: current.x + 1, y: current.y + 1},
|
|
{x: current.x - 1, y: current.y - 1},
|
|
{x: current.x + 1, y: current.y - 1},
|
|
{x: current.x - 1, y: current.y + 1}
|
|
];
|
|
|
|
for (const n of neighbors) {
|
|
if (n.x < 0 || n.x >= this.map.width || n.y < 0 || n.y >= this.map.height) continue;
|
|
if (this.map.isSolid(n.x, n.y)) continue;
|
|
|
|
// Skip if in closed set
|
|
if (closedSet.find(c => c.x === n.x && c.y === n.y)) continue;
|
|
|
|
const g = current.g + 1;
|
|
const h = Math.abs(n.x - end.x) + Math.abs(n.y - end.y);
|
|
const f = g + h;
|
|
|
|
// Skip if already in open set with lower f
|
|
const existing = openSet.find(o => o.x === n.x && o.y === n.y);
|
|
if (existing && existing.f <= f) continue;
|
|
|
|
openSet.push({x: n.x, y: n.y, g, h, f, parent: current});
|
|
}
|
|
|
|
// Prevent infinite loop
|
|
if (closedSet.length > 500) break;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
update() {
|
|
this.frameCount++;
|
|
|
|
// Periodic event check (every ~10 seconds)
|
|
this.eventCheckTimer++;
|
|
if (this.eventCheckTimer >= this.eventCheckInterval) {
|
|
this.eventCheckTimer = 0;
|
|
this.checkForNewEvents();
|
|
}
|
|
|
|
// Update player
|
|
this.player.update(this.map);
|
|
|
|
// Camera follows player
|
|
const playerScreen = this.map.tileToScreen(this.player.x, this.player.y);
|
|
this.camera.follow(playerScreen.x - TILE_WIDTH/2, playerScreen.y - TILE_HEIGHT/2);
|
|
this.camera.update();
|
|
|
|
// Update monsters
|
|
for (const monster of this.monsters) {
|
|
if (!monster.alive) {
|
|
monster.respawnTimer--;
|
|
if (monster.respawnTimer <= 0) {
|
|
monster.alive = true;
|
|
monster.hp = monster.maxHp;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Aggro check
|
|
const dx = this.player.x - monster.x;
|
|
const dy = this.player.y - monster.y;
|
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
|
|
if (dist < monster.aggroRange) {
|
|
// Chase player
|
|
if (dist > 1.2) {
|
|
monster.x += (dx / dist) * 0.05;
|
|
monster.y += (dy / dist) * 0.05;
|
|
monster.tileX = Math.floor(monster.x);
|
|
monster.tileY = Math.floor(monster.y);
|
|
} else {
|
|
// Attack player
|
|
if (monster.attackCooldown <= 0) {
|
|
const damage = Math.max(1, monster.atk - this.player.def);
|
|
this.player.takeDamage(damage);
|
|
this.addDamageNumber(this.player.x, this.player.y, damage, '#ff4444');
|
|
this.addEvent(`Wolf greift an: -${damage} HP`, 'combat');
|
|
monster.attackCooldown = monster.attackSpeed;
|
|
|
|
if (this.player.hp <= 0) {
|
|
this.onPlayerDeath();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (monster.attackCooldown > 0) monster.attackCooldown--;
|
|
}
|
|
|
|
// Player auto-attack
|
|
if (this.player.attackTarget && this.player.attackTarget.alive) {
|
|
const target = this.player.attackTarget;
|
|
const dx = target.x - this.player.x;
|
|
const dy = target.y - this.player.y;
|
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
|
|
if (dist <= this.player.attackRange) {
|
|
// In range — attack
|
|
if (this.player.attackCooldown <= 0) {
|
|
const damage = Math.max(1, this.player.atk - target.def);
|
|
target.hp -= damage;
|
|
this.addDamageNumber(target.x, target.y, damage, '#ffff44');
|
|
this.addEvent(`Du trifst ${target.name}: -${damage} HP`, 'combat');
|
|
this.player.attackCooldown = this.player.attackSpeed;
|
|
|
|
if (target.hp <= 0) {
|
|
target.alive = false;
|
|
target.respawnTimer = 300; // 5 seconds at 60fps
|
|
this.player.gainExp(target.exp);
|
|
this.player.gold += target.gold;
|
|
this.addEvent(`${target.name} besiegt! +${target.exp} EXP, +${target.gold} Gold`, 'combat');
|
|
this.player.attackTarget = null;
|
|
}
|
|
}
|
|
} else {
|
|
// Move towards target
|
|
const tile = {x: Math.floor(target.x), y: Math.floor(target.y)};
|
|
const playerTile = {x: Math.floor(this.player.x), y: Math.floor(this.player.y)};
|
|
const path = this.findPath(playerTile, tile);
|
|
if (path) this.player.setPath(path);
|
|
}
|
|
}
|
|
|
|
// Update damage numbers
|
|
this.damageNumbers = this.damageNumbers.filter(d => {
|
|
d.y -= 0.5;
|
|
d.life--;
|
|
return d.life > 0;
|
|
});
|
|
|
|
// Update UI
|
|
if (this.frameCount % 10 === 0) {
|
|
this.updateUI();
|
|
}
|
|
}
|
|
|
|
render() {
|
|
// Clear
|
|
this.ctx.fillStyle = '#1a1a2e';
|
|
this.ctx.fillRect(0, 0, 960, 640);
|
|
|
|
// Render map
|
|
this.map.render(this.ctx, this.camera);
|
|
|
|
// Render NPCs
|
|
for (const npc of this.npcs) {
|
|
const screen = this.map.tileToScreen(npc.tileX, npc.tileY);
|
|
const drawX = screen.x - this.camera.x + 480;
|
|
const drawY = screen.y - this.camera.y + 320;
|
|
|
|
// NPC body (different colors by type)
|
|
const colors = {
|
|
merchant: '#4a8a4a',
|
|
class_master: '#8a4a8a',
|
|
innkeeper: '#8a8a4a'
|
|
};
|
|
const ctx = this.ctx;
|
|
ctx.fillStyle = colors[npc.type] || '#666';
|
|
ctx.fillRect(drawX - 5, drawY + TILE_HEIGHT/2 - 15, 10, 12);
|
|
ctx.fillStyle = '#e0b890';
|
|
ctx.beginPath();
|
|
ctx.arc(drawX, drawY + TILE_HEIGHT/2 - 18, 6, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
// Name
|
|
ctx.fillStyle = '#88ff88';
|
|
ctx.font = '10px sans-serif';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText(npc.name, drawX, drawY + TILE_HEIGHT/2 - 26);
|
|
|
|
// "!" indicator for interactable
|
|
ctx.fillStyle = '#ffcc44';
|
|
ctx.font = '14px sans-serif';
|
|
ctx.fillText('!', drawX + 8, drawY + TILE_HEIGHT/2 - 22);
|
|
}
|
|
|
|
// Render monsters
|
|
for (const monster of this.monsters) {
|
|
if (!monster.alive) continue;
|
|
|
|
const screen = this.map.tileToScreen(monster.x, monster.y);
|
|
const drawX = screen.x - this.camera.x + 480;
|
|
const drawY = screen.y - this.camera.y + 320;
|
|
|
|
// Shadow
|
|
this.ctx.fillStyle = 'rgba(0,0,0,0.3)';
|
|
this.ctx.beginPath();
|
|
this.ctx.ellipse(drawX, drawY + TILE_HEIGHT/2 + 2, 8, 3, 0, 0, Math.PI * 2);
|
|
this.ctx.fill();
|
|
|
|
// Wolf body (gray)
|
|
this.ctx.fillStyle = '#666';
|
|
this.ctx.fillRect(drawX - 6, drawY + TILE_HEIGHT/2 - 10, 12, 8);
|
|
this.ctx.fillStyle = '#555';
|
|
this.ctx.beginPath();
|
|
this.ctx.arc(drawX, drawY + TILE_HEIGHT/2 - 12, 5, 0, Math.PI * 2);
|
|
this.ctx.fill();
|
|
|
|
// Eyes (red)
|
|
this.ctx.fillStyle = '#ff3333';
|
|
this.ctx.fillRect(drawX - 3, drawY + TILE_HEIGHT/2 - 13, 2, 2);
|
|
this.ctx.fillRect(drawX + 1, drawY + TILE_HEIGHT/2 - 13, 2, 2);
|
|
|
|
// Name + HP bar
|
|
this.ctx.fillStyle = '#ff8888';
|
|
this.ctx.font = '9px sans-serif';
|
|
this.ctx.textAlign = 'center';
|
|
this.ctx.fillText(monster.name, drawX, drawY + TILE_HEIGHT/2 - 22);
|
|
|
|
const hpPercent = monster.hp / monster.maxHp;
|
|
this.ctx.fillStyle = '#333';
|
|
this.ctx.fillRect(drawX - 12, drawY + TILE_HEIGHT/2 - 20, 24, 2);
|
|
this.ctx.fillStyle = '#dd4444';
|
|
this.ctx.fillRect(drawX - 12, drawY + TILE_HEIGHT/2 - 20, 24 * hpPercent, 2);
|
|
}
|
|
|
|
// Render player
|
|
this.player.render(this.ctx, this.map, this.camera);
|
|
|
|
// Render damage numbers
|
|
for (const dmg of this.damageNumbers) {
|
|
const screen = this.map.tileToScreen(dmg.x, dmg.y);
|
|
const drawX = screen.x - this.camera.x + 480;
|
|
const drawY = screen.y - this.camera.y + 320 - (30 - dmg.life);
|
|
this.ctx.fillStyle = dmg.color;
|
|
this.ctx.font = 'bold 14px sans-serif';
|
|
this.ctx.textAlign = 'center';
|
|
this.ctx.globalAlpha = dmg.life / 30;
|
|
this.ctx.fillText(dmg.value, drawX, drawY);
|
|
this.ctx.globalAlpha = 1;
|
|
}
|
|
|
|
// Render minimap
|
|
this.map.renderMinimap(this.minimapCtx, this.player);
|
|
}
|
|
|
|
addDamageNumber(x, y, value, color) {
|
|
this.damageNumbers.push({x, y, value: Math.floor(value), color, life: 30});
|
|
}
|
|
|
|
addEvent(text, type = 'normal') {
|
|
const messages = document.getElementById('event-messages');
|
|
const div = document.createElement('div');
|
|
div.className = `event-msg ${type}`;
|
|
div.textContent = text;
|
|
messages.appendChild(div);
|
|
messages.scrollTop = messages.scrollHeight;
|
|
|
|
// Keep only last 20 messages
|
|
while (messages.children.length > 20) {
|
|
messages.removeChild(messages.firstChild);
|
|
}
|
|
}
|
|
|
|
updateUI() {
|
|
// HP
|
|
const hpPercent = (this.player.hp / this.player.maxHp) * 100;
|
|
document.getElementById('hp-bar').style.width = hpPercent + '%';
|
|
document.getElementById('hp-text').textContent = `${this.player.hp}/${this.player.maxHp}`;
|
|
|
|
// SP
|
|
const spPercent = (this.player.sp / this.player.maxSp) * 100;
|
|
document.getElementById('sp-bar').style.width = spPercent + '%';
|
|
document.getElementById('sp-text').textContent = `${this.player.sp}/${this.player.maxSp}`;
|
|
|
|
// EXP
|
|
const expPercent = (this.player.exp / this.player.expNeeded) * 100;
|
|
document.getElementById('exp-bar').style.width = expPercent + '%';
|
|
document.getElementById('exp-text').textContent = `${this.player.exp}/${this.player.expNeeded}`;
|
|
|
|
// Char info
|
|
document.getElementById('char-name').textContent = this.player.name;
|
|
document.getElementById('char-level').textContent = `Lv. ${this.player.level}`;
|
|
document.getElementById('char-class').textContent = this.player.class;
|
|
}
|
|
|
|
async interactNPC(npc) {
|
|
this.addEvent(`${npc.name}: "..." (LLM generiert...)`, 'llm');
|
|
|
|
// Generate mood based on current events
|
|
const moods = ['neutral', 'glücklich', 'müde', 'besorgt', 'aufgeregt'];
|
|
const mood = moods[Math.floor(Math.random() * moods.length)];
|
|
|
|
const dialogue = await this.api.getNPCDialogue(
|
|
npc.id,
|
|
this.player.class,
|
|
this.player.level,
|
|
mood
|
|
);
|
|
|
|
if (dialogue) {
|
|
this.addEvent(`${npc.name}: ${dialogue}`, 'llm');
|
|
} else {
|
|
// Fallback to static dialogue
|
|
this.addEvent(`${npc.name}: "${npc.dialogue}"`, 'quest');
|
|
}
|
|
}
|
|
|
|
useSkill(slot) {
|
|
this.addEvent(`Skill ${slot} (noch nicht implementiert)`, 'system');
|
|
}
|
|
|
|
toggleInventory() {
|
|
this.addEvent('Inventar (noch nicht implementiert)', 'system');
|
|
}
|
|
|
|
toggleQuestLog() {
|
|
this.addEvent('Quest-Log (noch nicht implementiert)', 'system');
|
|
}
|
|
|
|
closeMenus() {
|
|
// Close any open menus
|
|
}
|
|
|
|
onPlayerDeath() {
|
|
this.addEvent('Du bist gestorben! Respawne in Eichenhafen...', 'system');
|
|
this.player.hp = this.player.maxHp;
|
|
this.player.sp = this.player.maxSp;
|
|
this.player.x = 20;
|
|
this.player.y = 20;
|
|
this.player.path = [];
|
|
this.player.attackTarget = null;
|
|
this.player.exp = Math.max(0, this.player.exp - this.player.expNeeded * 0.1);
|
|
}
|
|
|
|
loop() {
|
|
if (!this.running) return;
|
|
this.update();
|
|
this.render();
|
|
requestAnimationFrame(() => this.loop());
|
|
}
|
|
}
|
|
|
|
// Start the game when page loads
|
|
window.addEventListener('load', () => {
|
|
new Game();
|
|
}); |