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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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)
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
Reference in New Issue
Block a user