diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ccb220..967969f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,26 @@ - NPC dialogue is static (LLM not yet wired to frontend) - Only one map (Eichenhafen) -### Next Milestone -- M2: Refine pathfinding, add map transitions -- M3: Wire LLM dialogue to frontend NPC interactions \ No newline at end of file +## v0.2.0 — M7: LLM Integration Frontend ↔ Backend (2026-07-17) + +### Added +- API bridge (`api.js`) connecting frontend to FastAPI backend +- Backend health check on startup — shows LLM model name in event log +- Dynamic NPC dialogues via Ollama (gemma4:12b) + - Each NPC gets unique dialogue based on personality, mood, and player class/level + - 30-second dialogue cache to reduce LLM calls + - Fallback to static dialogue if LLM unavailable +- Periodic world event generation (every ~10 seconds) + - LLM generates optional events (weather, discoveries, encounters) + - Events validated by backend (no soft-lock possible) + - Events displayed in event log with ✨ prefix and purple color +- NPC interaction now shows "LLM generating..." placeholder while waiting + +### Changed +- NPC interaction is now async (waits for LLM response) +- Event log color coding: purple (llm), red (combat), green (quest), yellow (system) + +### Tested +- Backend health endpoint: ✅ +- Event generation: ✅ (gemma4:12b generates atmospheric events) +- NPC dialogue: ✅ (personalized, in-character responses) \ No newline at end of file diff --git a/backend/gamestate.db b/backend/gamestate.db new file mode 100644 index 0000000..975ba84 Binary files /dev/null and b/backend/gamestate.db differ diff --git a/frontend/index.html b/frontend/index.html index 1c5dd88..39488f4 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -74,6 +74,7 @@ + \ No newline at end of file diff --git a/frontend/js/api.js b/frontend/js/api.js new file mode 100644 index 0000000..cd8abe4 --- /dev/null +++ b/frontend/js/api.js @@ -0,0 +1,89 @@ +// api.js — Frontend ↔ Backend API Bridge + +const API_BASE = 'http://localhost:8000/api'; + +class GameAPI { + constructor() { + this.cache = new Map(); + this.cacheTimeout = 30000; // 30s cache for dialogues + } + + async getPlayer() { + try { + const res = await fetch(`${API_BASE}/player`); + return await res.json(); + } catch (e) { + console.warn('[API] getPlayer failed:', e); + return null; + } + } + + async savePlayer(state) { + try { + await fetch(`${API_BASE}/player/save`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(state) + }); + } catch (e) { + console.warn('[API] savePlayer failed:', e); + } + } + + async getEvents(mapId) { + try { + const res = await fetch(`${API_BASE}/events/${mapId}`); + const data = await res.json(); + return data.events || []; + } catch (e) { + console.warn('[API] getEvents failed:', e); + return []; + } + } + + async generateEvent(mapId, playerLevel) { + try { + const res = await fetch(`${API_BASE}/llm/event?map_id=${mapId}&player_level=${playerLevel}`, { + method: 'POST' + }); + const data = await res.json(); + return data.event; + } catch (e) { + console.warn('[API] generateEvent failed:', e); + return null; + } + } + + async getNPCDialogue(npcId, playerClass, playerLevel, mood) { + const cacheKey = `${npcId}_${mood}`; + if (this.cache.has(cacheKey)) { + const cached = this.cache.get(cacheKey); + if (Date.now() - cached.time < this.cacheTimeout) { + return cached.dialogue; + } + } + + try { + const res = await fetch(`${API_BASE}/llm/dialogue?npc_id=${npcId}&player_class=${playerClass}&player_level=${playerLevel}&mood=${mood}`, { + method: 'POST' + }); + const data = await res.json(); + if (data.dialogue) { + this.cache.set(cacheKey, { dialogue: data.dialogue, time: Date.now() }); + return data.dialogue; + } + } catch (e) { + console.warn('[API] getNPCDialogue failed:', e); + } + return null; + } + + async health() { + try { + const res = await fetch(`${API_BASE}/health`); + return await res.json(); + } catch (e) { + return { status: 'offline' }; + } + } +} \ No newline at end of file diff --git a/frontend/js/game.js b/frontend/js/game.js index e356a4b..ed38ca9 100644 --- a/frontend/js/game.js +++ b/frontend/js/game.js @@ -17,6 +17,14 @@ class Game { 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(); @@ -33,9 +41,51 @@ class Game { 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({ @@ -162,6 +212,13 @@ class Game { 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); @@ -399,8 +456,26 @@ class Game { document.getElementById('char-class').textContent = this.player.class; } - interactNPC(npc) { - this.addEvent(`${npc.name}: "${npc.dialogue}"`, 'quest'); + 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) {