v0.1.0 — M1: Project Setup + Tilemap + Combat + Backend

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
This commit is contained in:
arch_agent
2026-07-17 20:14:16 +02:00
commit 5cf42a5e5d
12 changed files with 2038 additions and 0 deletions
+33
View File
@@ -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
+260
View File
@@ -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
+116
View File
@@ -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
+20
View File
@@ -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
+350
View File
@@ -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)
+79
View File
@@ -0,0 +1,79 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Aether Chronicles</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="game-container">
<canvas id="game-canvas" width="960" height="640"></canvas>
<!-- UI Overlay -->
<div id="ui-overlay">
<!-- HP/SP Bars -->
<div id="status-bars">
<div class="bar-row">
<span class="bar-label">HP</span>
<div class="bar-container">
<div id="hp-bar" class="bar-fill hp-fill"></div>
<span id="hp-text" class="bar-text">100/100</span>
</div>
</div>
<div class="bar-row">
<span class="bar-label">SP</span>
<div class="bar-container">
<div id="sp-bar" class="bar-fill sp-fill"></div>
<span id="sp-text" class="bar-text">50/50</span>
</div>
</div>
<div class="bar-row">
<span class="bar-label">EXP</span>
<div class="bar-container">
<div id="exp-bar" class="bar-fill exp-fill"></div>
<span id="exp-text" class="bar-text">0/100</span>
</div>
</div>
</div>
<!-- Skill Bar -->
<div id="skill-bar">
<div class="skill-slot" data-slot="1">1</div>
<div class="skill-slot" data-slot="2">2</div>
<div class="skill-slot" data-slot="3">3</div>
<div class="skill-slot" data-slot="4">4</div>
<div class="skill-slot" data-slot="5">5</div>
<div class="skill-slot" data-slot="6">6</div>
<div class="skill-slot" data-slot="7">7</div>
<div class="skill-slot" data-slot="8">8</div>
<div class="skill-slot" data-slot="9">9</div>
<div class="skill-slot" data-slot="0">0</div>
</div>
<!-- Minimap -->
<div id="minimap-container">
<canvas id="minimap" width="120" height="120"></canvas>
</div>
<!-- Chat / Event Log -->
<div id="event-log">
<div id="event-messages"></div>
</div>
<!-- Character Info -->
<div id="char-info">
<div id="char-name">Wanderer</div>
<div id="char-level">Lv. 1</div>
<div id="char-class">Wanderer</div>
</div>
</div>
</div>
<script src="js/tilemap.js"></script>
<script src="js/camera.js"></script>
<script src="js/player.js"></script>
<script src="js/input.js"></script>
<script src="js/game.js"></script>
</body>
</html>
+21
View File
@@ -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;
}
}
+444
View File
@@ -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();
});
+85
View File
@@ -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;
}
}
}
+196
View File
@@ -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;
+261
View File
@@ -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();
}
}
+173
View File
@@ -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; }