Files
aether-chronicles/frontend/js/game.js
T
arch_agent 3890395885 fix: const redeclaration bug + bare ctx references
- 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.
2026-07-17 20:42:39 +02:00

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();
});