From 5cf42a5e5d7757a43fc83cf76ef144a632e0e4bb Mon Sep 17 00:00:00 2001 From: arch_agent Date: Fri, 17 Jul 2026 20:14:16 +0200 Subject: [PATCH] =?UTF-8?q?v0.1.0=20=E2=80=94=20M1:=20Project=20Setup=20+?= =?UTF-8?q?=20Tilemap=20+=20Combat=20+=20Backend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend: - HTML5 Canvas isometric tilemap renderer - Eichenhafen starting city (procedural) - Click-to-move A* pathfinding - Player stats, HP/SP/EXP bars, skill bar, minimap - 3 NPCs (merchant, class master, innkeeper) - 6 monster spawns with aggro AI - Combat: auto-attack, damage numbers, EXP/gold - Player death + respawn Backend: - FastAPI server with SQLite - Ollama LLM bridge for events + dialogue - Event validation (no soft-lock) - NPC dialogue generation Design: - Full DESIGN.md with architecture, formulas, milestones - 6 base classes + 6 advanced classes - 3 fixed cities, dynamic events --- CHANGELOG.md | 33 +++ DESIGN.md | 260 ++++++++++++++++++++++++ README.md | 116 +++++++++++ backend/README.md | 20 ++ backend/main.py | 350 ++++++++++++++++++++++++++++++++ frontend/index.html | 79 ++++++++ frontend/js/camera.js | 21 ++ frontend/js/game.js | 444 +++++++++++++++++++++++++++++++++++++++++ frontend/js/input.js | 85 ++++++++ frontend/js/player.js | 196 ++++++++++++++++++ frontend/js/tilemap.js | 261 ++++++++++++++++++++++++ frontend/style.css | 173 ++++++++++++++++ 12 files changed, 2038 insertions(+) create mode 100644 CHANGELOG.md create mode 100644 DESIGN.md create mode 100644 README.md create mode 100644 backend/README.md create mode 100644 backend/main.py create mode 100644 frontend/index.html create mode 100644 frontend/js/camera.js create mode 100644 frontend/js/game.js create mode 100644 frontend/js/input.js create mode 100644 frontend/js/player.js create mode 100644 frontend/js/tilemap.js create mode 100644 frontend/style.css diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4ccb220 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,33 @@ +# Changelog + +## v0.1.0 — M1: Project Setup + Tilemap (2026-07-16) + +### Added +- HTML5 Canvas game with isometric tilemap renderer +- Procedural tilemap for Eichenhafen (starting city) +- Tile types: grass, path, water, wall, sand, flowers, trees, buildings, bridge +- Camera system with smooth follow +- Player character with movement (click-to-move via A* pathfinding) +- Player stats: HP, SP, EXP, Level, STR, AGI, INT, VIT, DEX, LUK +- HP/SP/EXP bars UI overlay +- Skill bar (1-0 keys, visual only) +- Minimap with player position +- Event log with color-coded message types +- 3 NPCs: Händler Bruno (merchant), Meisterin Vera (class master), Wirtin Helga (innkeeper) +- 6 monster spawns (Junger Wolf) with aggro/chase/attack AI +- Combat: click enemy → auto-attack → damage numbers → EXP/gold gain +- Player death → respawn in Eichenhafen +- FastAPI backend with SQLite game state +- LLM bridge (Ollama) for dynamic events and NPC dialogue +- Event validation system (rejects events that block paths, move NPCs, etc.) +- Gitea repository: https://gitea.die-heimatlosen.eu/arch_agent/aether-chronicles + +### Known Issues +- Skill bar has no functionality yet +- Inventory not implemented +- 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 diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..edc8ec8 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,260 @@ +# Aether Chronicles — Design Document + +## 1. Architecture + +``` +┌─────────────────────────────────────────────────┐ +│ Browser (JS) │ +│ ┌─────────────┐ ┌──────────┐ ┌─────────────┐ │ +│ │ Renderer │ │ Input │ │ UI Manager │ │ +│ │ (Canvas) │ │ Handler │ │ (DOM) │ │ +│ └──────┬──────┘ └────┬─────┘ └──────┬──────┘ │ +│ └───────────────┴───────────────┘ │ +│ │ │ +│ Game Client (JS) │ +└────────────────────────┬─────────────────────────┘ + │ REST + WebSocket +┌────────────────────────┴─────────────────────────┐ +│ Python Backend (FastAPI) │ +│ ┌──────────┐ ┌──────────┐ ┌───────────────┐ │ +│ │ Game │ │ LLM │ │ World State │ │ +│ │ Engine │ │ Bridge │ │ (SQLite) │ │ +│ └────┬─────┘ └────┬─────┘ └───────┬───────┘ │ +│ │ │ │ │ +│ └─────────────┴─────────────────┘ │ +│ │ │ +│ ┌──────┴──────┐ │ +│ │ Ollama │ │ +│ │ (gemma4) │ │ +│ └─────────────┘ │ +└──────────────────────────────────────────────────┘ +``` + +## 2. Game State (SQLite Schema) + +```sql +-- Player +CREATE TABLE player ( + id INTEGER PRIMARY KEY, + name TEXT, + class TEXT DEFAULT 'wanderer', + level INTEGER DEFAULT 1, + exp INTEGER DEFAULT 0, + hp INTEGER DEFAULT 100, + max_hp INTEGER DEFAULT 100, + sp INTEGER DEFAULT 50, + max_sp INTEGER DEFAULT 50, + str INTEGER DEFAULT 10, + agi INTEGER DEFAULT 10, + int INTEGER DEFAULT 10, + vit INTEGER DEFAULT 10, + dex INTEGER DEFAULT 10, + luk INTEGER DEFAULT 5, + x REAL DEFAULT 400, + y REAL DEFAULT 300, + map_id TEXT DEFAULT 'eichenhafen', + gold INTEGER DEFAULT 100 +); + +-- Inventory +CREATE TABLE inventory ( + id INTEGER PRIMARY KEY, + player_id INTEGER, + item_id TEXT, + quantity INTEGER DEFAULT 1, + equipped INTEGER DEFAULT 0 +); + +-- World Events (LLM-generated) +CREATE TABLE world_events ( + id INTEGER PRIMARY KEY, + map_id TEXT, + event_type TEXT, + description TEXT, + data_json TEXT, + active INTEGER DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- NPC Dialogues (LLM-generated, cached) +CREATE TABLE npc_dialogues ( + id INTEGER PRIMARY KEY, + npc_id TEXT, + mood TEXT, + dialogue TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Quests +CREATE TABLE quests ( + id INTEGER PRIMARY KEY, + quest_type TEXT, + name TEXT, + description TEXT, + npc_id TEXT, + map_id TEXT, + min_level INTEGER, + reward_gold INTEGER, + reward_exp INTEGER, + status TEXT DEFAULT 'available' +); +``` + +## 3. LLM Integration + +### Prompt Templates + +```python +# Event Generation +EVENT_PROMPT = """You are the Dungeon Master for Aether Chronicles. +Current map: {map_name} +Current time: {time_of_day} +Player level: {player_level} +Recent events: {recent_events} + +Generate ONE optional event for this map. Rules: +- Must be optional (player can ignore it) +- Must not block any paths +- Must not move any NPCs +- Must not be required for progression +- Keep it short (2-3 sentences) + +Respond in JSON: +{ + "type": "weather|encounter|discovery|npc_mood", + "description": "...", + "effect": "buff|debuff|spawn|dialogue", + "data": { ... } +} +""" + +# NPC Dialogue +DIALOGUE_PROMPT = """You are {npc_name}, a {npc_role} in {city_name}. +Your personality: {personality} +Current mood: {mood} +The player ({player_class}, level {player_level}) approaches you. + +Say something in character (2-3 sentences). Be brief. +""" +``` + +### Validation Rules + +```python +def validate_event(event: dict, map_id: str) -> bool: + # Never block paths + if event.get("effect") == "block_path": return False + # Never move NPCs + if event.get("effect") == "move_npc": return False + # Never required for main quest + if event.get("required") == True: return False + # Must be optional + if event.get("optional") == False: return False + return True +``` + +## 4. Map System + +### Fixed Maps +``` +eichenhafen — Starting city (harbor) +eichenhafen_field — Field outside starting city (main road) +sonnenfeld — Central trade city +sonnenfeld_field — Field outside trade city +nebelgipfel — Mountain fortress city +nebelgipfel_path — Mountain path (dangerous) +``` + +### Dynamic Maps (LLM-generated layouts) +``` +side_dungeon_1 — Generated dungeon layout +side_dungeon_2 — Generated dungeon layout +cave_random — Generated cave layout +``` + +## 5. Combat Formulas + +```python +# Physical damage +damage = (atk * (1 + str/100)) - (target_def * 0.5) +damage = max(1, damage) # minimum 1 damage + +# Magic damage +magic_damage = (matk * (1 + int/100)) * element_multiplier + +# Hit rate +hit_rate = min(95, 80 + (dex - target_agi) * 2) + +# Critical rate +crit_rate = min(50, 5 + luk * 2) + +# EXP needed for next level +exp_needed = int(100 * (level ** 1.5)) +``` + +## 6. Milestone Details + +### M1: Project Setup + Tilemap (1-2 hours) +- HTML5 Canvas setup +- Isometric tilemap renderer +- Tile loading from JSON +- Camera with mouse pan/zoom +- Basic tileset (grass, water, path, building) + +### M2: Click-to-Move (1-2 hours) +- A* pathfinding implementation +- Click destination → character walks path +- Collision detection (walls, water) +- Smooth movement animation + +### M3: Player Stats + UI (1-2 hours) +- HP/SP bars (DOM overlay) +- Skill bar (1-0 keys) +- Character info panel +- EXP bar + +### M4: Combat (2-3 hours) +- Click enemy → auto-attack +- Damage numbers floating +- Death animation +- EXP gain on kill +- Aggro range for monsters + +### M5: Monster AI (2-3 hours) +- Spawn system +- Patrol behavior +- Aggro + chase +- Respawn timer +- Drop table + +### M6: City + NPCs (3-4 hours) +- Eichenhafen map layout +- NPC sprites + interaction +- Shop interface +- Storage interface +- Class master NPC + +### M7: LLM Integration (3-4 hours) +- Ollama API bridge +- Event generation (periodic) +- NPC dialogue generation +- Event validation +- Caching to reduce LLM calls + +### M8: Quest System (2-3 hours) +- Fixed main quests +- LLM side quests (validated) +- Quest log UI +- Turn-in mechanics + +### M9: Inventory + Equipment (2-3 hours) +- Item system +- Equipment slots +- Stats from equipment +- Drop/pickup + +### M10: Class System (3-4 hours) +- Class change at level 10 +- Skill trees +- Skill effects in combat +- 2nd class at level 40 \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..db22a59 --- /dev/null +++ b/README.md @@ -0,0 +1,116 @@ +# 🎮 Aether Chronicles — LLM-Powered Browser RPG + +A browser-based RPG inspired by classic isometric MMORPGs, with a unique twist: an LLM acts as a "Dungeon Master" that generates dynamic events, NPC dialogues, and world variations — while core gameplay remains stable and never soft-locks the player. + +## Concept + +``` +Browser (HTML5 Canvas + JS) ←→ Python Backend (FastAPI) ←→ Ollama LLM +``` + +- **Frontend:** HTML5 Canvas, JavaScript, isometric tilemap +- **Backend:** Python FastAPI, SQLite for game state +- **LLM:** Local Ollama (gemma4:12b) for dynamic content generation + +## Design Pillars + +1. **Fix Core, Dynamic Periphery** — Main cities, NPCs, and story quests are fixed. LLM only modifies optional content. +2. **Never Soft-Lock** — LLM events are validated by the backend. If invalid → silently discarded. Player always progresses. +3. **RO-Style Combat** — Click-to-move, click enemy to auto-attack, skill bar, HP/SP bars. +4. **Progression** — Base class → 1st class → 2nd class with branching skills. + +## Class System + +### Base Class +- **Wanderer** (Novice equivalent) + +### 1st Class (Level 10+) +| Class | Role | Weapon | +|---|---|---| +| Klingenwächter | Tank/Melee | Sword+Shield | +| Pirschjäger | Ranged DPS | Bow/Crossbow | +| Runenweber | Magic DPS | Staff/Talisman | +| Lebensfaden | Healer/Support | Holy Symbol | +| Schattengänger | Assassin/Burst | Daggers | +| Handelsreisender | Utility/Merchant | All | + +### 2nd Class (Level 40+) +| 1st → 2nd | New Abilities | +|---|---| +| Klingenwächter → Schildwache | AoE Tank, Taunt | +| Pirschjäger → Sturmpfeil | Multi-Shot, Pet | +| Runenweber → Elementarist | Chain Spells | +| Lebensfaden → Seelenbinde | AoE Heal, Revive | +| Schattengänger → Nachtzahn | Shadow Combo | +| Handelsreisender → Gildenmeister | Trade Posts, NPC Hire | + +## World + +### Fixed Cities (LLM cannot modify) +| City | Theme | Function | +|---|---|---| +| Eichenhafen | Harbor/Start | Tutorial, Base Classes | +| Sonnenfeld | Trade Hub (Central) | Market, Guild Master | +| Nebelgipfel | Mountain Fortress | Advanced Classes, Endgame | + +### Dynamic Content (LLM-generated, validated) +- Weather effects (rain → fire magic -10%) +- Optional side quests ("Caravan under attack") +- NPC dialogue variations (personality-based) +- Monster variants ("Cursed Emerald Wolf" vs normal Wolf) +- Side-dungeon layouts +- Rare events (world boss, meteor, merchant caravan) + +### LLM Safety Rules +``` +1. NPC locations are NEVER moved +2. Main roads are NEVER blocked +3. Quest NPCs are ALWAYS accessible +4. Dynamic events are ALWAYS optional +5. Side-dungeon can change → main dungeon stays fixed +6. Monster spawns dynamic but minimum 3 normal monsters per map +7. If LLM output fails validation → silently discarded, map stays normal +``` + +## Milestones + +| # | Milestone | Status | +|---|---|---| +| M1 | Project Setup + HTML5 Canvas + Tilemap | Pending | +| M2 | Click-to-Move Pathfinding (A*) | Pending | +| M3 | Player Stats + UI (HP/SP, Skill Bar) | Pending | +| M4 | Combat System (Click → Auto-Attack) | Pending | +| M5 | Monster Spawning + Basic AI | Pending | +| M6 | First City + NPCs + Merchant | Pending | +| M7 | LLM Integration (Ollama) | Pending | +| M8 | Quest System | Pending | +| M9 | Inventory + Equipment | Pending | +| M10 | Class System + Skills | Pending | + +## Tech Stack + +| Component | Technology | +|---|---| +| Frontend | HTML5 Canvas, vanilla JS, CSS | +| Backend | Python 3.11, FastAPI, uvicorn | +| Database | SQLite | +| LLM | Ollama (gemma4:12b local) | +| Assets | OpenGameArt, ComfyUI-generated sprites | + +## Running + +```bash +# Backend +cd backend +python3 -m venv venv +source venv/bin/activate +pip install fastapi uvicorn ollama +uvicorn main:app --reload --port 8000 + +# Frontend +# Open frontend/index.html in browser +``` + +## License + +MIT \ No newline at end of file diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..d5c8feb --- /dev/null +++ b/backend/README.md @@ -0,0 +1,20 @@ +# Aether Chronicles — Backend + +FastAPI server for LLM integration, game state persistence, and world event generation. + +## Setup + +```bash +python3 -m venv venv +source venv/bin/activate +pip install fastapi uvicorn ollama +uvicorn main:app --reload --port 8000 +``` + +## Endpoints + +- `GET /api/player` — Get player state +- `POST /api/player/move` — Update player position +- `GET /api/events/{map_id}` — Get active events for a map +- `POST /api/llm/event` — Generate a new event via LLM +- `POST /api/llm/dialogue` — Generate NPC dialogue via LLM \ No newline at end of file diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..a776f53 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,350 @@ +""" +Aether Chronicles — Backend Server +FastAPI + Ollama LLM integration for dynamic world events +""" + +import json +import sqlite3 +import time +import random +from datetime import datetime +from typing import Optional +from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel + +app = FastAPI(title="Aether Chronicles API", version="0.1.0") + +# CORS for frontend +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + +DB_PATH = "/home/natiris/Dokumente/aether-chronicles/backend/gamestate.db" + +# ============================================================ +# Database +# ============================================================ + +def init_db(): + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + c.executescript(""" + CREATE TABLE IF NOT EXISTS player ( + id INTEGER PRIMARY KEY DEFAULT 1, + name TEXT DEFAULT 'Wanderer', + class TEXT DEFAULT 'wanderer', + level INTEGER DEFAULT 1, + exp INTEGER DEFAULT 0, + hp INTEGER DEFAULT 100, + max_hp INTEGER DEFAULT 100, + sp INTEGER DEFAULT 50, + max_sp INTEGER DEFAULT 50, + x REAL DEFAULT 20, + y REAL DEFAULT 20, + map_id TEXT DEFAULT 'eichenhafen', + gold INTEGER DEFAULT 100 + ); + + CREATE TABLE IF NOT EXISTS world_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + map_id TEXT NOT NULL, + event_type TEXT, + description TEXT, + data_json TEXT, + active INTEGER DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS npc_dialogues ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + npc_id TEXT NOT NULL, + mood TEXT, + dialogue TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + INSERT OR IGNORE INTO player (id) VALUES (1); + """) + conn.commit() + conn.close() + +init_db() + +# ============================================================ +# LLM Bridge (Ollama) +# ============================================================ + +OLLAMA_URL = "http://127.0.0.1:11434" +LLM_MODEL = "gemma4:12b" + +# Event templates by map type +MAP_CONTEXTS = { + "eichenhafen": { + "name": "Eichenhafen", + "type": "harbor_city", + "moods": ["friedlich", "geschäftig", "neblig", "stürmisch"], + "event_types": ["weather", "discovery", "npc_mood", "visitor"] + }, + "eichenhafen_field": { + "name": "Felder von Eichenhafen", + "type": "grassland", + "moods": ["sonnig", "windig", "regnerisch", "neblig"], + "event_types": ["weather", "encounter", "discovery", "caravan"] + }, + "sonnenfeld": { + "name": "Sonnenfeld", + "type": "trade_city", + "moods": ["geschäftig", "festlich", "regnerisch", "heiß"], + "event_types": ["market_event", "visitor", "weather", "discovery"] + }, + "nebelgipfel": { + "name": "Nebelgipfel", + "type": "mountain_fortress", + "moods": ["kalt", "stürmisch", "schneiend", "neblig"], + "event_types": ["weather", "encounter", "discovery", "danger"] + } +} + +# NPC personalities +NPC_PERSONALITIES = { + "merchant_eichenhafen": { + "name": "Händler Bruno", + "role": "Händler", + "personality": "freundlich, geschwätzig, immer auf der Suche nach Profit", + "city": "Eichenhafen" + }, + "class_master": { + "name": "Meisterin Vera", + "role": "Klassenmeisterin", + "personality": "weise, streng aber gerecht, respektvoll", + "city": "Eichenhafen" + }, + "innkeeper": { + "name": "Wirtin Helga", + "role": "Schenkenwirtin", + "personality": "warmherzig, mütterlich, kennt alle Gerüchte", + "city": "Eichenhafen" + } +} + + +def call_ollama(prompt: str, system: str = "") -> Optional[str]: + """Call local Ollama LLM.""" + try: + import ollama + response = ollama.chat( + model=LLM_MODEL, + messages=[ + {"role": "system", "content": system}, + {"role": "user", "content": prompt} + ], + options={"temperature": 0.8, "num_ctx": 4096} + ) + return response["message"]["content"].strip() + except Exception as e: + print(f"[LLM] Error: {e}") + return None + + +def validate_event(event: dict, map_id: str) -> bool: + """ + Validate LLM-generated event against safety rules. + Returns True if event is safe to apply. + """ + # Never block paths + if event.get("effect") in ["block_path", "move_npc", "required"]: + return False + + # Must be optional + if event.get("required") is True: + return False + + # Type must be known + if event.get("type") not in ["weather", "encounter", "discovery", "npc_mood", + "market_event", "visitor", "caravan", "danger"]: + return False + + return True + + +def generate_event(map_id: str, player_level: int) -> Optional[dict]: + """Generate a dynamic event for a map via LLM.""" + ctx = MAP_CONTEXTS.get(map_id, MAP_CONTEXTS["eichenhafen"]) + + prompt = f"""Du bist der Dungeon Master für Aether Chronicles. +Aktuelle Karte: {ctx['name']} ({ctx['type']}) +Spieler Level: {player_level} +Tageszeit: {datetime.now().strftime('%H:%M')} + +Generiere EIN optionales Event für diese Karte. Regeln: +- Muss optional sein (Spieler kann es ignorieren) +- Darf keine Wege blockieren +- Darf keine NPCs verschieben +- Kurz halten (2-3 Sätze Beschreibung) + +Antworte NUR als JSON: +{{ + "type": "weather|encounter|discovery|npc_mood", + "description": "...", + "effect": "buff|debuff|spawn|dialogue|atmosphere", + "data": {{}} +}} +""" + + system = "Du bist ein kreativer Dungeon Master für ein Fantasy-RPG. Antworte NUR mit gültigem JSON." + + result = call_ollama(prompt, system) + if not result: + # Fallback: no event + return None + + try: + # Extract JSON from response + json_str = result + if "```json" in json_str: + json_str = json_str.split("```json")[1].split("```")[0] + elif "```" in json_str: + json_str = json_str.split("```")[1].split("```")[0] + + event = json.loads(json_str.strip()) + + if validate_event(event, map_id): + return event + else: + print(f"[LLM] Event rejected by validation: {event}") + return None + except (json.JSONDecodeError, KeyError) as e: + print(f"[LLM] Failed to parse event: {e}") + return None + + +def generate_dialogue(npc_id: str, player_class: str, player_level: int, mood: str = "neutral") -> str: + """Generate NPC dialogue via LLM.""" + npc = NPC_PERSONALITIES.get(npc_id) + if not npc: + return "Hallo, Reisender." + + prompt = f"""Du bist {npc['name']}, ein {npc['role']} in {npc['city']}. +Deine Persönlichkeit: {npc['personality']} +Aktuelle Stimmung: {mood} +Der Spieler ({player_class}, Level {player_level}) spricht dich an. + +Sage etwas passendes (2-3 Sätze). Sei kurz und in Charakter. +""" + + result = call_ollama(prompt) + if result: + # Cache the dialogue + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + c.execute("INSERT INTO npc_dialogues (npc_id, mood, dialogue) VALUES (?, ?, ?)", + (npc_id, mood, result)) + conn.commit() + conn.close() + return result + + # Fallback + return f"{npc['name']}: Sei gegrüßt, Reisender." + + +# ============================================================ +# API Endpoints +# ============================================================ + +class PlayerState(BaseModel): + name: str = "Wanderer" + class_name: str = "wanderer" + level: int = 1 + exp: int = 0 + hp: int = 100 + max_hp: int = 100 + sp: int = 50 + max_sp: int = 50 + x: float = 20.0 + y: float = 20.0 + map_id: str = "eichenhafen" + gold: int = 100 + + +@app.get("/api/player") +def get_player(): + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + c.execute("SELECT * FROM player WHERE id = 1") + row = c.fetchone() + conn.close() + if row: + return { + "id": row[0], "name": row[1], "class": row[2], "level": row[3], + "exp": row[4], "hp": row[5], "max_hp": row[6], "sp": row[7], + "max_sp": row[8], "x": row[9], "y": row[10], "map_id": row[11], + "gold": row[12] + } + return {"error": "No player found"} + + +@app.post("/api/player/save") +def save_player(state: PlayerState): + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + c.execute("""INSERT OR REPLACE INTO player + (id, name, class, level, exp, hp, max_hp, sp, max_sp, x, y, map_id, gold) + VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (state.name, state.class_name, state.level, state.exp, + state.hp, state.max_hp, state.sp, state.max_sp, + state.x, state.y, state.map_id, state.gold)) + conn.commit() + conn.close() + return {"status": "saved"} + + +@app.get("/api/events/{map_id}") +def get_events(map_id: str): + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + c.execute("SELECT * FROM world_events WHERE map_id = ? AND active = 1", (map_id,)) + rows = c.fetchall() + conn.close() + + events = [] + for row in rows: + events.append({ + "id": row[0], "map_id": row[1], "type": row[2], + "description": row[3], "data": json.loads(row[4]) if row[4] else {}, + "active": row[5] + }) + return {"events": events} + + +@app.post("/api/llm/event") +def create_event(map_id: str, player_level: int = 1): + event = generate_event(map_id, player_level) + if event: + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + c.execute("INSERT INTO world_events (map_id, event_type, description, data_json) VALUES (?, ?, ?, ?)", + (map_id, event.get("type"), event.get("description", ""), json.dumps(event.get("data", {})))) + conn.commit() + conn.close() + return {"event": event} + return {"event": None, "message": "No event generated (validation failed or LLM unavailable)"} + + +@app.post("/api/llm/dialogue") +def create_dialogue(npc_id: str, player_class: str = "wanderer", player_level: int = 1, mood: str = "neutral"): + dialogue = generate_dialogue(npc_id, player_class, player_level, mood) + return {"dialogue": dialogue} + + +@app.get("/api/health") +def health(): + return {"status": "ok", "llm_model": LLM_MODEL, "time": datetime.now().isoformat()} + + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..1c5dd88 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,79 @@ + + + + + + Aether Chronicles + + + +
+ + + +
+ +
+
+ HP +
+
+ 100/100 +
+
+
+ SP +
+
+ 50/50 +
+
+
+ EXP +
+
+ 0/100 +
+
+
+ + +
+
1
+
2
+
3
+
4
+
5
+
6
+
7
+
8
+
9
+
0
+
+ + +
+ +
+ + +
+
+
+ + +
+
Wanderer
+
Lv. 1
+
Wanderer
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/frontend/js/camera.js b/frontend/js/camera.js new file mode 100644 index 0000000..e42207e --- /dev/null +++ b/frontend/js/camera.js @@ -0,0 +1,21 @@ +// camera.js — Camera follows player + +class Camera { + constructor() { + this.x = 0; + this.y = 0; + this.targetX = 0; + this.targetY = 0; + this.smoothing = 0.1; + } + + follow(targetX, targetY) { + this.targetX = targetX; + this.targetY = targetY; + } + + update() { + this.x += (this.targetX - this.x) * this.smoothing; + this.y += (this.targetY - this.y) * this.smoothing; + } +} \ No newline at end of file diff --git a/frontend/js/game.js b/frontend/js/game.js new file mode 100644 index 0000000..e356a4b --- /dev/null +++ b/frontend/js/game.js @@ -0,0 +1,444 @@ +// game.js — Main Game Loop + +const TILE_WIDTH = 64; +const TILE_HEIGHT = 32; + +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 = []; + + // 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'); + + this.loop(); + } + + 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++; + + // 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' + }; + 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'; + ctx.beginPath(); + ctx.arc(drawX, drawY + TILE_HEIGHT/2 - 12, 5, 0, Math.PI * 2); + 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; + } + + interactNPC(npc) { + 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(); +}); \ No newline at end of file diff --git a/frontend/js/input.js b/frontend/js/input.js new file mode 100644 index 0000000..22ed000 --- /dev/null +++ b/frontend/js/input.js @@ -0,0 +1,85 @@ +// input.js — Mouse and keyboard input handling + +class InputHandler { + constructor(canvas, game) { + this.canvas = canvas; + this.game = game; + this.mouseX = 0; + this.mouseY = 0; + + canvas.addEventListener('click', (e) => this.handleClick(e)); + canvas.addEventListener('mousemove', (e) => this.handleMouseMove(e)); + canvas.addEventListener('contextmenu', (e) => e.preventDefault()); + + document.addEventListener('keydown', (e) => this.handleKey(e)); + } + + handleClick(e) { + const rect = this.canvas.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + + // Convert screen to world coords + const worldX = mx + this.game.camera.x - 480; + const worldY = my + this.game.camera.y - 320; + + // Convert to tile coords + const tile = this.game.map.screenToTile(worldX, worldY); + + // Check if clicking a monster + for (const monster of this.game.monsters) { + if (monster.tileX === tile.x && monster.tileY === tile.y) { + this.game.player.attackTarget = monster; + this.game.addEvent(`Targeting ${monster.name}`, 'combat'); + return; + } + } + + // Check if clicking an NPC + for (const npc of this.game.npcs) { + if (npc.tileX === tile.x && npc.tileY === tile.y) { + this.game.interactNPC(npc); + return; + } + } + + // Otherwise, move to clicked tile + if (!this.game.map.isSolid(tile.x, tile.y)) { + const playerTile = { x: Math.floor(this.game.player.x), y: Math.floor(this.game.player.y) }; + const path = this.game.findPath(playerTile, tile); + if (path) { + this.game.player.setPath(path); + this.game.player.attackTarget = null; + } + } + } + + handleMouseMove(e) { + const rect = this.canvas.getBoundingClientRect(); + this.mouseX = e.clientX - rect.left; + this.mouseY = e.clientY - rect.top; + } + + handleKey(e) { + // Skill bar (1-0) + if (e.key >= '0' && e.key <= '9') { + const slot = e.key === '0' ? 10 : parseInt(e.key); + this.game.useSkill(slot); + } + + // Other keys + switch(e.key) { + case 'i': + case 'I': + this.game.toggleInventory(); + break; + case 'q': + case 'Q': + this.game.toggleQuestLog(); + break; + case 'Escape': + this.game.closeMenus(); + break; + } + } +} \ No newline at end of file diff --git a/frontend/js/player.js b/frontend/js/player.js new file mode 100644 index 0000000..0f04f8a --- /dev/null +++ b/frontend/js/player.js @@ -0,0 +1,196 @@ +// player.js — Player character with stats + +class Player { + constructor(x, y) { + this.x = x; + this.y = y; + this.tileX = 0; + this.tileY = 0; + this.speed = 2; + + // Stats + this.name = 'Wanderer'; + this.class = 'wanderer'; + this.level = 1; + this.exp = 0; + this.expNeeded = 100; + + this.maxHp = 100; + this.hp = 100; + this.maxSp = 50; + this.sp = 50; + + this.str = 10; + this.agi = 10; + this.int = 10; + this.vit = 10; + this.dex = 10; + this.luk = 5; + + this.atk = 15; + this.def = 5; + this.gold = 100; + + // Movement + this.path = []; + this.targetX = null; + this.targetY = null; + this.moving = false; + + // Combat + this.attackTarget = null; + this.attackRange = 1.5; + this.attackCooldown = 0; + this.attackSpeed = 30; // frames between attacks + + // Visual + this.facing = 'down'; + this.animFrame = 0; + this.animTimer = 0; + } + + getScreenPos(map) { + const screen = map.tileToScreen(Math.floor(this.x), Math.floor(this.y)); + return screen; + } + + update(map) { + // Update tile position + this.tileX = Math.floor(this.x); + this.tileY = Math.floor(this.y); + + // Movement along path + if (this.path.length > 0) { + const next = this.path[0]; + const dx = next.x - this.x; + const dy = next.y - this.y; + const dist = Math.sqrt(dx * dx + dy * dy); + + if (dist < 0.1) { + this.path.shift(); + if (this.path.length === 0) { + this.moving = false; + } + } else { + this.x += (dx / dist) * this.speed * 0.1; + this.y += (dy / dist) * this.speed * 0.1; + + if (Math.abs(dx) > Math.abs(dy)) { + this.facing = dx > 0 ? 'right' : 'left'; + } else { + this.facing = dy > 0 ? 'down' : 'up'; + } + } + } + + // Animation + if (this.moving) { + this.animTimer++; + if (this.animTimer > 10) { + this.animTimer = 0; + this.animFrame = (this.animFrame + 1) % 4; + } + } + + // Combat + if (this.attackCooldown > 0) this.attackCooldown--; + } + + setPath(path) { + this.path = path; + this.moving = path.length > 0; + } + + takeDamage(amount) { + this.hp -= amount; + if (this.hp < 0) this.hp = 0; + return this.hp <= 0; + } + + heal(amount) { + this.hp = Math.min(this.maxHp, this.hp + amount); + } + + gainExp(amount) { + this.exp += amount; + if (this.exp >= this.expNeeded) { + this.levelUp(); + return true; + } + return false; + } + + levelUp() { + this.exp -= this.expNeeded; + this.level++; + this.expNeeded = Math.floor(100 * Math.pow(this.level, 1.5)); + this.maxHp += 20; + this.maxSp += 10; + this.hp = this.maxHp; + this.sp = this.maxSp; + this.str += 2; + this.agi += 2; + this.int += 1; + this.vit += 2; + this.dex += 1; + this.atk += 3; + this.def += 1; + } + + render(ctx, map, camera) { + const screen = map.tileToScreen(this.x, this.y); + const drawX = screen.x - camera.x + 480; + const drawY = screen.y - camera.y + 320; + + // Shadow + ctx.fillStyle = 'rgba(0,0,0,0.3)'; + ctx.beginPath(); + ctx.ellipse(drawX, drawY + TILE_HEIGHT/2 + 4, 12, 4, 0, 0, Math.PI * 2); + ctx.fill(); + + // Body (simple character) + const bobOffset = this.moving ? Math.sin(this.animFrame * Math.PI / 2) * 2 : 0; + + // Legs + ctx.fillStyle = '#4a4a6a'; + ctx.fillRect(drawX - 5, drawY + TILE_HEIGHT/2 - 8 + bobOffset, 4, 8); + ctx.fillRect(drawX + 1, drawY + TILE_HEIGHT/2 - 8 + bobOffset, 4, 8); + + // Body + ctx.fillStyle = '#5566aa'; + ctx.fillRect(drawX - 7, drawY + TILE_HEIGHT/2 - 18 + bobOffset, 14, 12); + + // Head + ctx.fillStyle = '#e0b890'; + ctx.beginPath(); + ctx.arc(drawX, drawY + TILE_HEIGHT/2 - 22 + bobOffset, 7, 0, Math.PI * 2); + ctx.fill(); + + // Hair (black) + ctx.fillStyle = '#1a1a1a'; + ctx.beginPath(); + ctx.arc(drawX, drawY + TILE_HEIGHT/2 - 24 + bobOffset, 7, Math.PI, Math.PI * 2); + ctx.fill(); + ctx.fillRect(drawX - 7, drawY + TILE_HEIGHT/2 - 22 + bobOffset, 14, 3); + + // Name label + ctx.fillStyle = '#ffcc44'; + ctx.font = '11px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText(this.name, drawX, drawY + TILE_HEIGHT/2 - 32); + + // HP bar above character + const hpPercent = this.hp / this.maxHp; + const barWidth = 24; + const barX = drawX - barWidth / 2; + const barY = drawY + TILE_HEIGHT/2 - 38; + + ctx.fillStyle = '#333'; + ctx.fillRect(barX, barY, barWidth, 3); + ctx.fillStyle = hpPercent > 0.5 ? '#44dd44' : hpPercent > 0.25 ? '#ddaa44' : '#dd4444'; + ctx.fillRect(barX, barY, barWidth * hpPercent, 3); + } +} + +// TILE_WIDTH constant for player rendering +const TILE_HEIGHT = 32; \ No newline at end of file diff --git a/frontend/js/tilemap.js b/frontend/js/tilemap.js new file mode 100644 index 0000000..058d2f1 --- /dev/null +++ b/frontend/js/tilemap.js @@ -0,0 +1,261 @@ +// tilemap.js — Isometric Tilemap System + +const TILE_SIZE = 32; +const TILE_WIDTH = 64; // iso tile width +const TILE_HEIGHT = 32; // iso tile height + +// Tile types +const TILES = { + GRASS: 0, + PATH: 1, + WATER: 2, + WALL: 3, + SAND: 4, + FLOWER: 5, + TREE: 6, + BUILDING: 7, + BRIDGE: 8, + DARK_GRASS: 9, +}; + +// Tile colors (procedural, no sprites needed for prototype) +const TILE_COLORS = { + [TILES.GRASS]: '#3a6b2a', + [TILES.PATH]: '#8a7a5a', + [TILES.WATER]: '#2a5a8a', + [TILES.WALL]: '#555555', + [TILES.SAND]: '#c4b07a', + [TILES.FLOWER]: '#3a6b2a', + [TILES.TREE]: '#2a4a1a', + [TILES.BUILDING]: '#6a5a4a', + [TILES.BRIDGE]: '#8a6a4a', + [TILES.DARK_GRASS]: '#2a5b1a', +}; + +// Tile properties +const TILE_SOLID = { + [TILES.GRASS]: false, + [TILES.PATH]: false, + [TILES.WATER]: true, + [TILES.WALL]: true, + [TILES.SAND]: false, + [TILES.FLOWER]: false, + [TILES.TREE]: true, + [TILES.BUILDING]: true, + [TILES.BRIDGE]: false, + [TILES.DARK_GRASS]: false, +}; + +class TileMap { + constructor(width, height) { + this.width = width; + this.height = height; + this.data = new Array(width * height).fill(TILES.GRASS); + } + + getTile(x, y) { + if (x < 0 || x >= this.width || y < 0 || y >= this.height) return TILES.WALL; + return this.data[y * this.width + x]; + } + + setTile(x, y, tile) { + if (x >= 0 && x < this.width && y >= 0 && y < this.height) { + this.data[y * this.width + x] = tile; + } + } + + isSolid(x, y) { + return TILE_SOLID[this.getTile(x, y)] || false; + } + + // Convert tile coords to screen coords (isometric) + tileToScreen(tx, ty) { + return { + x: (tx - ty) * (TILE_WIDTH / 2), + y: (tx + ty) * (TILE_HEIGHT / 2) + }; + } + + // Convert screen coords to tile coords + screenToTile(sx, sy) { + return { + x: Math.floor((sx / (TILE_WIDTH / 2) + sy / (TILE_HEIGHT / 2)) / 2), + y: Math.floor((sy / (TILE_HEIGHT / 2) - sx / (TILE_WIDTH / 2)) / 2) + }; + } + + // Generate Eichenhafen (starting city) + static generateEichenhafen() { + const map = new TileMap(40, 40); + + // Fill with grass + for (let i = 0; i < map.data.length; i++) map.data[i] = TILES.GRASS; + + // Water on the left (harbor) + for (let y = 0; y < 40; y++) { + for (let x = 0; x < 8; x++) { + map.setTile(x, y, TILES.WATER); + } + } + + // Sand beach + for (let y = 0; y < 40; y++) { + map.setTile(8, y, TILES.SAND); + map.setTile(9, y, TILES.SAND); + } + + // Main path (horizontal) + for (let x = 10; x < 35; x++) { + map.setTile(x, 20, TILES.PATH); + map.setTile(x, 21, TILES.PATH); + } + + // Main path (vertical) + for (let y = 5; y < 35; y++) { + map.setTile(20, y, TILES.PATH); + map.setTile(21, y, TILES.PATH); + } + + // Buildings (NPC areas) + for (let x = 14; x < 18; x++) { + for (let y = 14; y < 18; y++) { + map.setTile(x, y, TILES.BUILDING); + } + } + for (let x = 24; x < 28; x++) { + for (let y = 14; y < 18; y++) { + map.setTile(x, y, TILES.BUILDING); + } + } + for (let x = 14; x < 18; x++) { + for (let y = 24; y < 28; y++) { + map.setTile(x, y, TILES.BUILDING); + } + } + + // Trees scattered + const treePositions = [ + [12,5],[13,6],[11,7],[30,8],[31,9],[33,10], + [5,30],[33,30],[34,35],[12,35],[13,36],[30,35], + [25,30],[26,31],[15,32],[16,33] + ]; + treePositions.forEach(([x,y]) => map.setTile(x, y, TILES.TREE)); + + // Flowers + for (let i = 0; i < 30; i++) { + const x = 10 + Math.floor(Math.random() * 28); + const y = 5 + Math.floor(Math.random() * 30); + if (map.getTile(x, y) === TILES.GRASS) { + map.setTile(x, y, TILES.FLOWER); + } + } + + // Dark grass patches + for (let i = 0; i < 15; i++) { + const x = 10 + Math.floor(Math.random() * 28); + const y = 5 + Math.floor(Math.random() * 30); + if (map.getTile(x, y) === TILES.GRASS) { + map.setTile(x, y, TILES.DARK_GRASS); + } + } + + return map; + } + + // Render the tilemap + render(ctx, camera) { + const startX = Math.max(0, Math.floor((camera.x - 480) / (TILE_WIDTH / 2)) - 2); + const startY = Math.max(0, Math.floor((camera.y - 320) / TILE_HEIGHT) - 2); + const endX = Math.min(this.width, Math.ceil((camera.x + 480) / (TILE_WIDTH / 2)) + 2); + const endY = Math.min(this.height, Math.ceil((camera.y + 320) / TILE_HEIGHT) + 2); + + for (let y = startY; y < endY; y++) { + for (let x = startX; x < endX; x++) { + const tile = this.getTile(x, y); + const screen = this.tileToScreen(x, y); + const drawX = screen.x - camera.x + 480; + const drawY = screen.y - camera.y + 320; + + // Draw isometric diamond + ctx.fillStyle = TILE_COLORS[tile]; + ctx.beginPath(); + ctx.moveTo(drawX, drawY); + ctx.lineTo(drawX + TILE_WIDTH / 2, drawY + TILE_HEIGHT / 2); + ctx.lineTo(drawX, drawY + TILE_HEIGHT); + ctx.lineTo(drawX - TILE_WIDTH / 2, drawY + TILE_HEIGHT / 2); + ctx.closePath(); + ctx.fill(); + + // Tile details + if (tile === TILES.WATER) { + ctx.strokeStyle = '#3a7a9a'; + ctx.lineWidth = 1; + ctx.stroke(); + } else if (tile === TILES.TREE) { + // Draw tree on top of grass base + ctx.fillStyle = TILE_COLORS[TILES.GRASS]; + ctx.beginPath(); + ctx.moveTo(drawX, drawY); + ctx.lineTo(drawX + TILE_WIDTH / 2, drawY + TILE_HEIGHT / 2); + ctx.lineTo(drawX, drawY + TILE_HEIGHT); + ctx.lineTo(drawX - TILE_WIDTH / 2, drawY + TILE_HEIGHT / 2); + ctx.closePath(); + ctx.fill(); + + // Tree trunk + canopy + ctx.fillStyle = '#4a3a2a'; + ctx.fillRect(drawX - 3, drawY + TILE_HEIGHT/2 - 5, 6, 10); + ctx.fillStyle = '#1a3a0a'; + ctx.beginPath(); + ctx.arc(drawX, drawY + TILE_HEIGHT/2 - 8, 12, 0, Math.PI * 2); + ctx.fill(); + } else if (tile === TILES.FLOWER) { + // Grass base + flower dot + ctx.fillStyle = TILE_COLORS[TILES.GRASS]; + ctx.beginPath(); + ctx.moveTo(drawX, drawY); + ctx.lineTo(drawX + TILE_WIDTH / 2, drawY + TILE_HEIGHT / 2); + ctx.lineTo(drawX, drawY + TILE_HEIGHT); + ctx.lineTo(drawX - TILE_WIDTH / 2, drawY + TILE_HEIGHT / 2); + ctx.closePath(); + ctx.fill(); + + const colors = ['#ff6b6b', '#ffd93d', '#6bcf7f', '#a78bfa']; + ctx.fillStyle = colors[(x + y) % colors.length]; + ctx.beginPath(); + ctx.arc(drawX, drawY + TILE_HEIGHT/2, 3, 0, Math.PI * 2); + ctx.fill(); + } else if (tile === TILES.BUILDING) { + // Building with roof + ctx.fillStyle = '#5a4a3a'; + ctx.fillRect(drawX - TILE_WIDTH/2 + 2, drawY + 4, TILE_WIDTH - 4, TILE_HEIGHT); + ctx.fillStyle = '#8a3a2a'; + ctx.beginPath(); + ctx.moveTo(drawX - TILE_WIDTH/2 + 2, drawY + 8); + ctx.lineTo(drawX, drawY); + ctx.lineTo(drawX + TILE_WIDTH/2 - 2, drawY + 8); + ctx.lineTo(drawX, drawY + 16); + ctx.closePath(); + ctx.fill(); + } + } + } + } + + // Render minimap + renderMinimap(ctx, player) { + const scale = 3; + for (let y = 0; y < this.height; y++) { + for (let x = 0; x < this.width; x++) { + const tile = this.getTile(x, y); + ctx.fillStyle = TILE_COLORS[tile]; + ctx.fillRect(x * scale, y * scale, scale, scale); + } + } + // Player dot + ctx.fillStyle = '#ffcc44'; + ctx.beginPath(); + ctx.arc(player.tileX * scale, player.tileY * scale, 2, 0, Math.PI * 2); + ctx.fill(); + } +} \ No newline at end of file diff --git a/frontend/style.css b/frontend/style.css new file mode 100644 index 0000000..127901c --- /dev/null +++ b/frontend/style.css @@ -0,0 +1,173 @@ +/* Aether Chronicles — Stylesheet */ +* { margin: 0; padding: 0; box-sizing: border-box; } + +body { + background: #0a0a0a; + color: #e0e0e0; + font-family: 'Segoe UI', Tahoma, sans-serif; + overflow: hidden; + user-select: none; +} + +#game-container { + position: relative; + width: 960px; + height: 640px; + margin: 0 auto; + border: 2px solid #333; +} + +#game-canvas { + display: block; + cursor: crosshair; + background: #1a1a2e; +} + +#ui-overlay { + position: absolute; + top: 0; left: 0; + width: 100%; height: 100%; + pointer-events: none; +} + +/* Status Bars */ +#status-bars { + position: absolute; + top: 10px; left: 10px; + width: 200px; + pointer-events: auto; +} + +.bar-row { + display: flex; + align-items: center; + margin-bottom: 4px; +} + +.bar-label { + width: 30px; + font-size: 11px; + font-weight: bold; + color: #aaa; + text-align: right; + margin-right: 5px; +} + +.bar-container { + flex: 1; + height: 14px; + background: #1a1a1a; + border: 1px solid #444; + border-radius: 2px; + position: relative; + overflow: hidden; +} + +.bar-fill { + height: 100%; + transition: width 0.3s ease; +} + +.hp-fill { background: linear-gradient(to right, #c44, #e55); } +.sp-fill { background: linear-gradient(to right, #44c, #66e); } +.exp-fill { background: linear-gradient(to right, #cc4, #ee5); } + +.bar-text { + position: absolute; + top: 0; left: 0; right: 0; + text-align: center; + font-size: 10px; + line-height: 14px; + color: #fff; + text-shadow: 1px 1px 1px #000; +} + +/* Skill Bar */ +#skill-bar { + position: absolute; + bottom: 10px; left: 50%; + transform: translateX(-50%); + display: flex; + gap: 4px; + pointer-events: auto; +} + +.skill-slot { + width: 40px; height: 40px; + background: rgba(20,20,30,0.85); + border: 1px solid #555; + border-radius: 3px; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + color: #888; + cursor: pointer; + transition: all 0.2s; +} + +.skill-slot:hover { + border-color: #88aaff; + background: rgba(40,40,60,0.9); +} + +.skill-slot.active { + border-color: #ffcc44; + background: rgba(60,50,20,0.9); +} + +.skill-slot.on-cooldown { + opacity: 0.4; + cursor: not-allowed; +} + +/* Minimap */ +#minimap-container { + position: absolute; + top: 10px; right: 10px; + width: 120px; height: 120px; + border: 1px solid #555; + background: rgba(0,0,0,0.7); + border-radius: 3px; +} + +#minimap { display: block; } + +/* Event Log */ +#event-log { + position: absolute; + bottom: 60px; left: 10px; + width: 300px; height: 100px; + background: rgba(0,0,0,0.6); + border: 1px solid #333; + border-radius: 3px; + overflow: hidden; + pointer-events: auto; +} + +#event-messages { + padding: 5px; + font-size: 12px; + max-height: 100%; + overflow-y: auto; +} + +.event-msg { + margin-bottom: 2px; + color: #ccc; +} +.event-msg.system { color: #ffcc44; } +.event-msg.combat { color: #ff6666; } +.event-msg.quest { color: #66ff66; } +.event-msg.llm { color: #aa88ff; } + +/* Character Info */ +#char-info { + position: absolute; + top: 10px; left: 220px; + pointer-events: none; +} + +#char-name { font-size: 14px; font-weight: bold; color: #ffcc44; } +#char-level { font-size: 12px; color: #aaa; } +#char-class { font-size: 11px; color: #88aaff; } \ No newline at end of file