Fortschritts-Quest v1.0 — Automatisches RPG mit lokalem LLM
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,162 @@
|
||||
"""Character-System: Rassen, Klassen, Stats, Leveling."""
|
||||
|
||||
import random
|
||||
import math
|
||||
|
||||
RACES = [
|
||||
"Kleinwüchsiger Goblin", "Waldelf", "Höhlentroll", "Stadtmensch",
|
||||
"Wüstenork", "Sumpfzwerg", "Eisbarbar", "Schattenkatze",
|
||||
"Steingolem", "Feuerechse", "Nachtalb", "Wolfsjäger",
|
||||
]
|
||||
|
||||
CLASSES = [
|
||||
"Schlachtenbraten", "Kartoffelmagier", "Panzerschmied", "Bratpfannenpaladin",
|
||||
"Würstchenkrieger", "Kaffeepriester", "Bierbrauer-Mönch", "Käsekönigin",
|
||||
"Dosenöffner-Assassin", "Fahrradschurke", "Schneeballhexer", "Teppichkampfkünstler",
|
||||
]
|
||||
|
||||
# Class → stat weights (STR, DEX, INT, VIT, LCK)
|
||||
CLASS_STATS = {
|
||||
"Schlachtenbraten": (8, 3, 1, 7, 2),
|
||||
"Kartoffelmagier": (2, 3, 8, 3, 5),
|
||||
"Panzerschmied": (7, 2, 3, 8, 1),
|
||||
"Bratpfannenpaladin": (6, 4, 4, 6, 3),
|
||||
"Würstchenkrieger": (7, 5, 2, 5, 4),
|
||||
"Kaffeepriester": (3, 4, 7, 3, 6),
|
||||
"Bierbrauer-Mönch": (5, 3, 5, 7, 3),
|
||||
"Käsekönigin": (4, 5, 6, 4, 5),
|
||||
"Dosenöffner-Assassin": (4, 8, 3, 3, 7),
|
||||
"Fahrradschurke": (3, 7, 4, 4, 8),
|
||||
"Schneeballhexer": (2, 4, 8, 3, 5),
|
||||
"Teppichkampfkünstler": (5, 7, 3, 5, 4),
|
||||
}
|
||||
|
||||
# XP curve: xp needed for next level = base * level^1.5
|
||||
def xp_for_level(level: int) -> int:
|
||||
return int(50 * (level ** 1.5))
|
||||
|
||||
class Character:
|
||||
def __init__(self, name: str, race: str, char_class: str):
|
||||
self.name = name
|
||||
self.race = race
|
||||
self.char_class = char_class
|
||||
self.level = 1
|
||||
self.xp = 0
|
||||
self.gold = 10
|
||||
|
||||
stats = CLASS_STATS.get(char_class, (5, 5, 5, 5, 5))
|
||||
self.str = stats[0] + random.randint(0, 2)
|
||||
self.dex = stats[1] + random.randint(0, 2)
|
||||
self.int = stats[2] + random.randint(0, 2)
|
||||
self.vit = stats[3] + random.randint(0, 2)
|
||||
self.lck = stats[4] + random.randint(0, 2)
|
||||
|
||||
self.hp = self.max_hp
|
||||
self.mp = self.max_mp
|
||||
|
||||
self.inventory = []
|
||||
self.equipped = {"weapon": None, "armor": None, "helmet": None, "boots": None, "accessory": None}
|
||||
|
||||
self.kills = 0
|
||||
self.deaths = 0
|
||||
self.quests_done = 0
|
||||
self.steps = 0
|
||||
|
||||
@property
|
||||
def max_hp(self) -> int:
|
||||
return 20 + self.vit * 5 + self.level * 3
|
||||
|
||||
@property
|
||||
def max_mp(self) -> int:
|
||||
return 10 + self.int * 3 + self.level * 2
|
||||
|
||||
@property
|
||||
def attack(self) -> int:
|
||||
weapon_bonus = self.equipped["weapon"].power if self.equipped["weapon"] else 0
|
||||
return self.str * 2 + self.level + weapon_bonus
|
||||
|
||||
@property
|
||||
def defense(self) -> int:
|
||||
armor_bonus = sum(e.power for e in self.equipped.values() if e)
|
||||
return self.vit + armor_bonus
|
||||
|
||||
@property
|
||||
def xp_to_next(self) -> int:
|
||||
return xp_for_level(self.level)
|
||||
|
||||
def gain_xp(self, amount: int) -> bool:
|
||||
"""Gibt XP. Return True bei Level-Up."""
|
||||
self.xp += amount
|
||||
if self.xp >= self.xp_to_next:
|
||||
self.xp -= self.xp_to_next
|
||||
self.level += 1
|
||||
self.hp = self.max_hp
|
||||
self.mp = self.max_mp
|
||||
# Stat growth
|
||||
self.str += random.randint(0, 1)
|
||||
self.dex += random.randint(0, 1)
|
||||
self.int += random.randint(0, 1)
|
||||
self.vit += random.randint(0, 1)
|
||||
self.lck += random.randint(0, 1)
|
||||
return True
|
||||
return False
|
||||
|
||||
def take_damage(self, dmg: int):
|
||||
actual = max(1, dmg - self.defense)
|
||||
self.hp -= actual
|
||||
return actual
|
||||
|
||||
def heal(self):
|
||||
self.hp = self.max_hp
|
||||
self.mp = self.max_mp
|
||||
|
||||
def is_alive(self) -> bool:
|
||||
return self.hp > 0
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"name": self.name, "race": self.race, "char_class": self.char_class,
|
||||
"level": self.level, "xp": self.xp, "gold": self.gold,
|
||||
"str": self.str, "dex": self.dex, "int": self.int, "vit": self.vit, "lck": self.lck,
|
||||
"hp": self.hp, "mp": self.mp,
|
||||
"kills": self.kills, "deaths": self.deaths, "quests_done": self.quests_done, "steps": self.steps,
|
||||
"inventory": [i.to_dict() for i in self.inventory],
|
||||
"equipped": {k: v.to_dict() if v else None for k, v in self.equipped.items()},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "Character":
|
||||
c = cls(d["name"], d["race"], d["char_class"])
|
||||
c.level = d["level"]; c.xp = d["xp"]; c.gold = d["gold"]
|
||||
c.str = d["str"]; c.dex = d["dex"]; c.int = d["int"]; c.vit = d["vit"]; c.lck = d["lck"]
|
||||
c.hp = d["hp"]; c.mp = d["mp"]
|
||||
c.kills = d["kills"]; c.deaths = d["deaths"]; c.quests_done = d["quests_done"]; c.steps = d["steps"]
|
||||
c.inventory = [Item.from_dict(i) for i in d.get("inventory", [])]
|
||||
c.equipped = {
|
||||
k: Item.from_dict(v) if v else None
|
||||
for k, v in d.get("equipped", {}).items()
|
||||
}
|
||||
return c
|
||||
|
||||
|
||||
class Item:
|
||||
def __init__(self, name: str, item_type: str, power: int, value: int, rarity: str = "gewöhnlich"):
|
||||
self.name = name
|
||||
self.item_type = item_type # weapon, armor, helmet, boots, accessory, junk, potion
|
||||
self.power = power
|
||||
self.value = value
|
||||
self.rarity = rarity
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {"name": self.name, "item_type": self.item_type, "power": self.power, "value": self.value, "rarity": self.rarity}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "Item":
|
||||
return cls(d["name"], d["item_type"], d["power"], d["value"], d.get("rarity", "gewöhnlich"))
|
||||
|
||||
def __str__(self):
|
||||
r = {"gewöhnlich": "", "selten": "[cyan]", "episch": "[magenta]", "legendär": "[bold yellow]"}
|
||||
color = r.get(self.rarity, "")
|
||||
prefix = f"{color}" if color else ""
|
||||
suffix = "[/]" if color else ""
|
||||
return f"{prefix}{self.name} (Stärke {self.power}, {self.value} Gold){suffix}"
|
||||
+241
@@ -0,0 +1,241 @@
|
||||
"""Game-Engine: Auto-Progress, Kämpfe, Loot, Quests, Shop."""
|
||||
|
||||
import random
|
||||
import time
|
||||
from character import Character, Item, RACES, CLASSES
|
||||
from llm import NameCache
|
||||
|
||||
class Monster:
|
||||
def __init__(self, name: str, level: int, hp: int, attack: int, defense: int, xp: int, gold: int):
|
||||
self.name = name
|
||||
self.level = level
|
||||
self.hp = hp
|
||||
self.attack = attack
|
||||
self.defense = defense
|
||||
self.xp = xp
|
||||
self.gold = gold
|
||||
|
||||
def is_alive(self):
|
||||
return self.hp > 0
|
||||
|
||||
class GameEngine:
|
||||
def __init__(self, character: Character):
|
||||
self.character = character
|
||||
self.cache = NameCache()
|
||||
self.cache.refill_async()
|
||||
self.current_location = self.cache.get_location()
|
||||
self.current_quest = self.cache.get_quest(character.level)
|
||||
self.quest_progress = 0
|
||||
self.quest_target = random.randint(3, 8)
|
||||
self.log = []
|
||||
self.max_log = 15
|
||||
|
||||
def _add_log(self, msg: str):
|
||||
self.log.insert(0, msg)
|
||||
if len(self.log) > self.max_log:
|
||||
self.log = self.log[:self.max_log]
|
||||
|
||||
def _spawn_monster(self) -> Monster:
|
||||
name = self.cache.get_monster(self.character.level, self.current_location)
|
||||
level = max(1, self.character.level + random.randint(-1, 1))
|
||||
hp = 10 + level * 5 + random.randint(0, 10)
|
||||
attack = level * 2 + random.randint(0, 3)
|
||||
defense = level + random.randint(0, 2)
|
||||
xp = 5 + level * 3 + random.randint(0, 5)
|
||||
gold = 1 + level + random.randint(0, 3)
|
||||
return Monster(name, level, hp, attack, defense, xp, gold)
|
||||
|
||||
def _generate_loot(self, monster: Monster) -> Item:
|
||||
item_types = ["weapon", "armor", "helmet", "boots", "accessory", "junk"]
|
||||
item_type = random.choices(item_types, weights=[15, 15, 10, 10, 8, 42])[0]
|
||||
|
||||
# Rarity based on luck and level
|
||||
roll = random.randint(1, 100) + self.character.lck
|
||||
if roll > 95:
|
||||
rarity = "legendär"
|
||||
elif roll > 80:
|
||||
rarity = "episch"
|
||||
elif roll > 60:
|
||||
rarity = "selten"
|
||||
else:
|
||||
rarity = "gewöhnlich"
|
||||
|
||||
name = self.cache.get_item(item_type, self.character.level, rarity)
|
||||
|
||||
rarity_mult = {"gewöhnlich": 1, "selten": 1.5, "episch": 2.5, "legendär": 4}
|
||||
mult = rarity_mult.get(rarity, 1)
|
||||
|
||||
base_power = self.character.level + random.randint(0, 3)
|
||||
power = max(1, int(base_power * mult))
|
||||
value = max(1, int((monster.gold + 2) * mult * random.uniform(0.8, 1.2)))
|
||||
|
||||
return Item(name, item_type, power, value, rarity)
|
||||
|
||||
def _combat(self, monster: Monster) -> dict:
|
||||
"""Führt einen automatischen Kampf durch. Gibt Ergebnis-Dict zurück."""
|
||||
rounds = 0
|
||||
while monster.is_alive() and self.character.is_alive():
|
||||
rounds += 1
|
||||
# Character attacks
|
||||
char_dmg = max(1, self.character.attack - monster.defense + random.randint(-2, 2))
|
||||
char_dmg = min(char_dmg, monster.hp)
|
||||
monster.hp -= char_dmg
|
||||
|
||||
if not monster.is_alive():
|
||||
break
|
||||
|
||||
# Monster attacks
|
||||
mon_dmg = max(1, monster.attack - self.character.defense + random.randint(-2, 2))
|
||||
actual = self.character.take_damage(mon_dmg)
|
||||
|
||||
if not self.character.is_alive():
|
||||
return {"result": "death", "rounds": rounds, "monster": monster.name}
|
||||
|
||||
return {"result": "victory", "rounds": rounds, "monster": monster.name}
|
||||
|
||||
def _complete_quest(self):
|
||||
self.character.quests_done += 1
|
||||
quest_gold = 5 + self.character.level * 3 + random.randint(0, 10)
|
||||
quest_xp = 10 + self.character.level * 5 + random.randint(0, 10)
|
||||
self.character.gold += quest_gold
|
||||
leveled = self.character.gain_xp(quest_xp)
|
||||
self._add_log(f"[bold green]✅ Quest abgeschlossen: {self.current_quest} (+{quest_gold} Gold, +{quest_xp} XP)[/]")
|
||||
if leveled:
|
||||
self._add_log(f"[bold yellow]⬆️ Stufe {self.character.level} erreicht![/]")
|
||||
self.character.heal()
|
||||
# New quest
|
||||
self.current_quest = self.cache.get_quest(self.character.level)
|
||||
self.quest_progress = 0
|
||||
self.quest_target = random.randint(3, 8)
|
||||
self._add_log(f"[cyan]📜 Neue Quest: {self.current_quest} ({self.quest_target} Gegner)[/]")
|
||||
|
||||
def step(self) -> dict:
|
||||
"""Ein Schritt des Auto-Progress. Gibt Event-Info zurück."""
|
||||
self.character.steps += 1
|
||||
events = []
|
||||
|
||||
# Random: sometimes travel
|
||||
if random.randint(1, 50) == 1:
|
||||
self.current_location = self.cache.get_location()
|
||||
events.append(("travel", f"[blue]🌍 Du reist nach: {self.current_location}[/]"))
|
||||
self._add_log(events[-1][1])
|
||||
|
||||
# Spawn monster
|
||||
monster = self._spawn_monster()
|
||||
events.append(("encounter", f"[red]⚔️ {monster.name} (Stufe {monster.level}) erscheint![/]"))
|
||||
self._add_log(events[-1][1])
|
||||
|
||||
# Fight
|
||||
result = self._combat(monster)
|
||||
|
||||
if result["result"] == "victory":
|
||||
self.character.kills += 1
|
||||
events.append(("victory", f"[green]⚔️ Besiegst {monster.name} ({result['rounds']} Runden)[/]"))
|
||||
self._add_log(events[-1][1])
|
||||
|
||||
# XP + Gold
|
||||
self.character.gold += monster.gold
|
||||
leveled = self.character.gain_xp(monster.xp)
|
||||
events.append(("loot_xp", f"[dim]+{monster.xp} XP, +{monster.gold} Gold[/]"))
|
||||
self._add_log(events[-1][1])
|
||||
|
||||
if leveled:
|
||||
self._add_log(f"[bold yellow]⬆️ Stufe {self.character.level} erreicht![/]")
|
||||
self.character.heal()
|
||||
events.append(("levelup", f"[bold yellow]⬆️ STUFE {self.character.level}![/]"))
|
||||
|
||||
# Loot drop
|
||||
if random.randint(1, 100) <= 70: # 70% drop chance
|
||||
item = self._generate_loot(monster)
|
||||
self._add_log(f"[magenta]📦 Loot: {item}[/]")
|
||||
events.append(("loot", f"[magenta]📦 {item}[/]", item))
|
||||
self._auto_equip(item)
|
||||
else:
|
||||
# Always some gold
|
||||
extra_gold = random.randint(1, 3)
|
||||
self.character.gold += extra_gold
|
||||
|
||||
# Quest progress
|
||||
self.quest_progress += 1
|
||||
if self.quest_progress >= self.quest_target:
|
||||
self._complete_quest()
|
||||
events.append(("quest_done", f"[green]✅ Quest erfüllt![/]"))
|
||||
|
||||
elif result["result"] == "death":
|
||||
self.character.deaths += 1
|
||||
events.append(("death", f"[bold red]💀 Von {monster.name} besiegt! Respawn...[/]"))
|
||||
self._add_log(events[-1][1])
|
||||
# Respawn: lose 10% gold, heal
|
||||
lost_gold = max(1, self.character.gold // 10)
|
||||
self.character.gold -= lost_gold
|
||||
self.character.heal()
|
||||
self._add_log(f"[red]Verlierst {lost_gold} Gold beim Sterben.[/]")
|
||||
|
||||
return {"events": events}
|
||||
|
||||
def _auto_equip(self, item: Item):
|
||||
"""Prüft ob Item besser ist und rüstet es automatisch aus."""
|
||||
slot = None
|
||||
if item.item_type in ("weapon", "armor", "helmet", "boots", "accessory"):
|
||||
slot = item.item_type
|
||||
|
||||
if slot:
|
||||
current = self.character.equipped[slot]
|
||||
if current is None or item.power > current.power:
|
||||
if current:
|
||||
self.character.inventory.append(current)
|
||||
self.character.equipped[slot] = item
|
||||
self._add_log(f"[cyan]🎽 Ausgerüstet: {item}[/]")
|
||||
else:
|
||||
self.character.inventory.append(item)
|
||||
else:
|
||||
self.character.inventory.append(item)
|
||||
|
||||
# Auto-sell junk to keep inventory clean
|
||||
if item.item_type == "junk":
|
||||
self.character.gold += item.value
|
||||
if item in self.character.inventory:
|
||||
self.character.inventory.remove(item)
|
||||
self._add_log(f"[dim]💰 Verkauft: {item.name} (+{item.value} Gold)[/]")
|
||||
|
||||
def _sell_junk(self):
|
||||
"""Verkauft alle Schrott-Items."""
|
||||
sold = 0
|
||||
total_gold = 0
|
||||
for item in self.character.inventory[:]:
|
||||
if item.item_type == "junk":
|
||||
self.character.inventory.remove(item)
|
||||
self.character.gold += item.value
|
||||
total_gold += item.value
|
||||
sold += 1
|
||||
if sold > 0:
|
||||
self._add_log(f"[dim]💰 {sold} Schrott verkauft (+{total_gold} Gold)[/]")
|
||||
|
||||
def get_state(self) -> dict:
|
||||
return {
|
||||
"character": self.character.to_dict(),
|
||||
"current_location": self.current_location,
|
||||
"current_quest": self.current_quest,
|
||||
"quest_progress": self.quest_progress,
|
||||
"quest_target": self.quest_target,
|
||||
"log": self.log,
|
||||
}
|
||||
|
||||
def save(self, path: str):
|
||||
import json
|
||||
with open(path, "w") as f:
|
||||
json.dump(self.get_state(), f, ensure_ascii=False, indent=2)
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: str) -> "GameEngine":
|
||||
import json
|
||||
with open(path) as f:
|
||||
d = json.load(f)
|
||||
char = Character.from_dict(d["character"])
|
||||
engine = cls(char)
|
||||
engine.current_location = d.get("current_location", engine.current_location)
|
||||
engine.current_quest = d.get("current_quest", engine.current_quest)
|
||||
engine.quest_progress = d.get("quest_progress", 0)
|
||||
engine.quest_target = d.get("quest_target", 5)
|
||||
engine.log = d.get("log", [])
|
||||
return engine
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
"""Ollama LLM-Integration für dynamische Namen und Ereignisse."""
|
||||
|
||||
import httpx
|
||||
import json
|
||||
import random
|
||||
import os
|
||||
|
||||
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434")
|
||||
DEFAULT_MODEL = os.environ.get("OLLAMA_MODEL", "gemma4:12b")
|
||||
|
||||
# Fallback-Listen falls Ollama nicht erreichbar
|
||||
FALLBACK_MONSTERS = [
|
||||
"Wütender Bratwurst-Golem", "Verirrter Bürokrat", "Sabbernder Höhlentroll",
|
||||
"Betrunkener Kartoffelgeist", "Kaffeeriese", "Einsamer Würstchenkobold",
|
||||
"Müder Bäcker-Ghoul", "Brüllender Dosenöffner", "Schlafender Schneeball",
|
||||
"Verzweifelter Parkplatzwächter", "Tanzen Milchgeist", "Rostiger Fahrraddämon",
|
||||
"Papiertiger aus Pankow", "Neuköllner Einkaufswagen", "Flüchtiger Socken-Golem",
|
||||
"Grammatik-Nazi aus Kreuzberg", "Mitternachts-Brötchen", "Heimtückischer Toaster",
|
||||
]
|
||||
|
||||
FALLBACK_ITEMS = {
|
||||
"weapon": ["Rostige Bratpfanne", "Verbeulter Dosenöffner", "Alte Wurstzange", "Verbogenes Fahrrad-Pedal"],
|
||||
"armor": ["Kaffeefleck-Kettenhemd", "Pappkarton-Rüstung", "Mülltonnenschild", "Regenmantel des Grauens"],
|
||||
"helmet": ["Kochtopf mit Dellen", "Eimer mit Loch", "Tasse als Helm", "Plastik-Eimer"],
|
||||
"boots": ["Badeschlappen des Schreckens", "Holzschuhe mit Nagel", "Gummistiefel ohne Sohle"],
|
||||
"accessory": ["Rubberband der Macht", "Kaugummi-Kristall", "Wollmütze +2", "Fahrradklingel"],
|
||||
"junk": ["Alte Zeitung", "Leerer Kaffeebecher", "Heruntergefallenes Brötchen", "Verbogener Löffel"],
|
||||
}
|
||||
|
||||
FALLBACK_QUESTS = [
|
||||
"Bringe 3 Würstchen zum Bürgermeister", "Finde den verlorenen Kaffeelöffel",
|
||||
"Befreie das Dorf von der Bratwurst-Plage", "Begleite die Schneeballhexe zum Markt",
|
||||
"Sammle 10 leere Pfandflaschen", "Besiege den Riesen-Gartenzwerg",
|
||||
"Befreie die gefangene Wurstware", "Finde den verschwundenen Dosenöffner",
|
||||
"Reinige die Küche der ewigen Fettflecken", "Begleite den alten Bierbrauer nach Hause",
|
||||
]
|
||||
|
||||
FALLBACK_LOCATIONS = [
|
||||
"Neukölln bei Nacht", "Dem Schornstein hinterher", "Das ewige Baustellen-Nirwana",
|
||||
"Der Tresen der Verzweiflung", "Die große Kartoffel-Höhle", "Berlin Mitte im Stau",
|
||||
"Die Döner-Insel", "Die verlassene Wäscherei", "Der S-Bahn-Ring des Grauens",
|
||||
]
|
||||
|
||||
def _clean_name(result: str) -> str:
|
||||
"""Säubert LLM-Output: entfernt Metadaten, Anführungszeichen, überflüssige Wörter."""
|
||||
if not result:
|
||||
return ""
|
||||
# Remove metadata-like suffixes: _Wütend_Stufe5, _betrunken, etc.
|
||||
result = result.split("_")[0] if "_" in result and not result.startswith("_") else result
|
||||
# Remove common LLM preambles
|
||||
for prefix in ["Der Name lautet: ", "Name: ", "Monster: ", "Quest: ", "Ort: ", "Hier ist", "Das ist"]:
|
||||
if result.lower().startswith(prefix.lower()):
|
||||
result = result[len(prefix):]
|
||||
# Strip quotes, punctuation, whitespace
|
||||
result = result.strip('"\'.!?*#\n\r ')
|
||||
# Capitalize first letter
|
||||
if result:
|
||||
result = result[0].upper() + result[1:]
|
||||
return result
|
||||
|
||||
def _call_ollama(prompt: str, model: str = None, temperature: float = 0.9) -> str:
|
||||
"""Ruft Ollama auf und gibt die Antwort als String zurück."""
|
||||
model = model or DEFAULT_MODEL
|
||||
try:
|
||||
with httpx.Client(timeout=30) as client:
|
||||
resp = client.post(f"{OLLAMA_URL}/api/generate", json={
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"stream": False,
|
||||
"think": False,
|
||||
"options": {"temperature": temperature, "num_predict": 60},
|
||||
})
|
||||
data = resp.json()
|
||||
return data.get("response", "").strip()
|
||||
except Exception as e:
|
||||
return ""
|
||||
|
||||
|
||||
def generate_monster_name(player_level: int, location: str = "") -> str:
|
||||
"""Generiert einen absurden Monsternamen auf Deutsch."""
|
||||
threat = random.choice(["lächerlich", "gefährlich", "müde", "wütend", "verwirrt", "betrunken", "verliebt", "niedergeschlagen"])
|
||||
prompt = f"""Du bist ein Generator für absurde deutsche RPG-Monster. Generiere EINEN kurzen, lustigen Monsternamen auf Deutsch.
|
||||
Der Name soll absurd und komisch sein, wie ein Satire-RPG (Progress Quest).
|
||||
Kombinationen wie "Bratwurst-Golem", "Bürokraten-Spinne", "Kartoffel-Drache" etc.
|
||||
Das Monster ist {threat} und auf Stufe {player_level}.
|
||||
|
||||
Antworte NUR mit dem Namen, kein Satz, keine Erklärung, keine Anführungszeichen."""
|
||||
|
||||
result = _call_ollama(prompt, temperature=1.0)
|
||||
result = _clean_name(result)
|
||||
if result and len(result) < 50:
|
||||
return result
|
||||
return random.choice(FALLBACK_MONSTERS)
|
||||
|
||||
|
||||
def generate_item_name(item_type: str, player_level: int, rarity: str = "gewöhnlich") -> str:
|
||||
"""Generiert einen absurden Item-Namen auf Deutsch."""
|
||||
prompt = f"""Du bist ein Generator für absurde deutsche RPG-Gegenstände. Generiere EINEN kurzen, lustigen Gegenstandsnamen auf Deutsch.
|
||||
Typ: {item_type} (Waffe, Rüstung, Helm, Stiefel, Accessoire, Schrott)
|
||||
Seltenheit: {rarity}
|
||||
Stufe: {player_level}
|
||||
|
||||
Beispiele: "Rostige Bratpfanne", "Verbeulter Dosenöffner", "Kaffeefleck-Kettenhemd", "Badeschlappen des Schreckens".
|
||||
|
||||
Antworte NUR mit dem Namen, kein Satz, keine Erklärung, keine Anführungszeichen."""
|
||||
|
||||
result = _call_ollama(prompt, temperature=1.0)
|
||||
result = _clean_name(result)
|
||||
if result and len(result) < 50:
|
||||
return result
|
||||
return random.choice(FALLBACK_ITEMS.get(item_type, FALLBACK_ITEMS["junk"]))
|
||||
|
||||
|
||||
def generate_quest_name(player_level: int) -> str:
|
||||
"""Generiert eine absurde Quest-Beschreibung auf Deutsch."""
|
||||
prompt = f"""Du bist ein Generator für absurde deutsche RPG-Quests (Progress Quest Stil).
|
||||
Generiere EINE kurze, lustige Quest-Beschreibung auf Deutsch.
|
||||
Stufe: {player_level}
|
||||
|
||||
Beispiele: "Bringe 3 Würstchen zum Bürgermeister", "Finde den verlorenen Kaffee", "Sammle 10 Pfandflaschen", "Besiege den Riesen-Gartenzwerg".
|
||||
|
||||
Antworte NUR mit der Quest, kein Satz, keine Erklärung, keine Anführungszeichen."""
|
||||
|
||||
result = _call_ollama(prompt, temperature=1.0)
|
||||
result = _clean_name(result)
|
||||
if result and len(result) < 80:
|
||||
return result
|
||||
return random.choice(FALLBACK_QUESTS)
|
||||
|
||||
|
||||
def generate_location_name() -> str:
|
||||
"""Generiert einen absurden Ortsnamen auf Deutsch."""
|
||||
prompt = f"""Du bist ein Generator für absurde deutsche RPG-Orte (Progress Quest Satire).
|
||||
Generiere EINEN kurzen, lustigen Ortsnamen auf Deutsch.
|
||||
|
||||
Beispiele: "Neukölln bei Nacht", "Die große Kartoffel-Höhle", "Berlin Mitte im Stau", "Die Döner-Insel".
|
||||
|
||||
Antworte NUR mit dem Ortsnamen, kein Satz, keine Erklärung, keine Anführungszeichen."""
|
||||
|
||||
result = _call_ollama(prompt, temperature=1.0)
|
||||
result = _clean_name(result)
|
||||
if result and len(result) < 50:
|
||||
return result
|
||||
return random.choice(FALLBACK_LOCATIONS)
|
||||
|
||||
|
||||
def generate_combat_text(monster: str, action: str = "attack") -> str:
|
||||
"""Generiert eine kurze Kampfbeschreibung."""
|
||||
if action == "kill":
|
||||
return f"Besiegst {monster}!"
|
||||
elif action == "loot":
|
||||
return f"Durchwühlst die Überreste von {monster}..."
|
||||
else:
|
||||
return f"Greifst {monster} an..."
|
||||
|
||||
|
||||
# Pre-generate cache to avoid calling LLM too often
|
||||
class NameCache:
|
||||
def __init__(self):
|
||||
self.monsters = []
|
||||
self.items = {}
|
||||
self.quests = []
|
||||
self.locations = []
|
||||
self._initial_fill()
|
||||
|
||||
def _initial_fill(self):
|
||||
"""Füllt den Cache initial mit Fallback-Werten."""
|
||||
self.monsters = FALLBACK_MONSTERS.copy()
|
||||
self.quests = FALLBACK_QUESTS.copy()
|
||||
self.locations = FALLBACK_LOCATIONS.copy()
|
||||
for itype, names in FALLBACK_ITEMS.items():
|
||||
self.items[itype] = names.copy()
|
||||
|
||||
def get_monster(self, level: int, location: str = "") -> str:
|
||||
if not self.monsters:
|
||||
self._initial_fill()
|
||||
name = self.monsters.pop(random.randint(0, len(self.monsters) - 1))
|
||||
return name
|
||||
|
||||
def get_item(self, item_type: str, level: int, rarity: str = "gewöhnlich") -> str:
|
||||
names = self.items.get(item_type, FALLBACK_ITEMS.get(item_type, FALLBACK_ITEMS["junk"]))
|
||||
if not names:
|
||||
names = FALLBACK_ITEMS.get(item_type, FALLBACK_ITEMS["junk"])
|
||||
return names[random.randint(0, len(names) - 1)]
|
||||
|
||||
def get_quest(self, level: int) -> str:
|
||||
if not self.quests:
|
||||
self._initial_fill()
|
||||
return self.quests.pop(random.randint(0, len(self.quests) - 1))
|
||||
|
||||
def get_location(self) -> str:
|
||||
if not self.locations:
|
||||
self._initial_fill()
|
||||
return self.locations.pop(random.randint(0, len(self.locations) - 1))
|
||||
|
||||
def refill_async(self):
|
||||
"""Füllt den Cache im Hintergrund mit LLM-generierten Namen auf."""
|
||||
try:
|
||||
for _ in range(3):
|
||||
name = generate_monster_name(1)
|
||||
if name and name not in self.monsters:
|
||||
self.monsters.append(name)
|
||||
for _ in range(2):
|
||||
name = generate_quest_name(1)
|
||||
if name and name not in self.quests:
|
||||
self.quests.append(name)
|
||||
for _ in range(2):
|
||||
name = generate_location_name()
|
||||
if name and name not in self.locations:
|
||||
self.locations.append(name)
|
||||
for itype in ["weapon", "armor", "helmet", "boots"]:
|
||||
name = generate_item_name(itype, 1)
|
||||
if name:
|
||||
self.items.setdefault(itype, []).append(name)
|
||||
except:
|
||||
pass
|
||||
+243
@@ -0,0 +1,243 @@
|
||||
"""Fortschritts-Quest — Automatisches RPG mit lokalem LLM."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import random
|
||||
import signal
|
||||
|
||||
# Add src to path
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from rich.console import Console
|
||||
from rich.live import Live
|
||||
from rich.layout import Layout
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
from rich.progress import Progress, BarColumn, TextColumn
|
||||
from rich.align import Align
|
||||
from rich import box
|
||||
|
||||
from character import Character, RACES, CLASSES
|
||||
from engine import GameEngine
|
||||
from llm import NameCache
|
||||
|
||||
console = Console()
|
||||
SAVE_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "saves")
|
||||
os.makedirs(SAVE_DIR, exist_ok=True)
|
||||
|
||||
BANNER = """
|
||||
[bold magenta]╔═══════════════════════════════════════════════════════════╗
|
||||
║ FORTSCHRITTS-QUEST — Das automatische RPG ║
|
||||
║ "Wo du nichts tust und alles passiert" ║
|
||||
╚═══════════════════════════════════════════════════════════╝[/]
|
||||
"""
|
||||
|
||||
def create_character():
|
||||
"""Character-Erstellung mit Zufallsoption."""
|
||||
console.clear()
|
||||
console.print(BANNER)
|
||||
console.print("[bold]Character-Erstellung[/]\n")
|
||||
|
||||
# Name
|
||||
name = console.input("[cyan]Name deines Helden (oder Enter für Zufall): [/]").strip()
|
||||
if not name:
|
||||
name = random.choice(["Günther", "Brigitte", "Klaus-Dieter", "Waltraud", "Herbert", "Sieglinde", "Wolfgang", "Margot"])
|
||||
console.print(f"[dim]Zufälliger Name: {name}[/]")
|
||||
|
||||
# Race
|
||||
console.print("\n[bold]Verfügbare Rassen:[/]")
|
||||
for i, r in enumerate(RACES):
|
||||
console.print(f" [cyan]{i+1}[/] — {r}")
|
||||
race_choice = console.input(f"\n[cyan]Rasse wählen (1-{len(RACES)}, Enter=Zufall): [/]").strip()
|
||||
if race_choice.isdigit() and 1 <= int(race_choice) <= len(RACES):
|
||||
race = RACES[int(race_choice) - 1]
|
||||
else:
|
||||
race = random.choice(RACES)
|
||||
console.print(f"[dim]Zufällige Rasse: {race}[/]")
|
||||
|
||||
# Class
|
||||
console.print("\n[bold]Verfügbare Klassen:[/]")
|
||||
for i, c in enumerate(CLASSES):
|
||||
console.print(f" [cyan]{i+1}[/] — {c}")
|
||||
class_choice = console.input(f"\n[cyan]Klasse wählen (1-{len(CLASSES)}, Enter=Zufall): [/]").strip()
|
||||
if class_choice.isdigit() and 1 <= int(class_choice) <= len(CLASSES):
|
||||
char_class = CLASSES[int(class_choice) - 1]
|
||||
else:
|
||||
char_class = random.choice(CLASSES)
|
||||
console.print(f"[dim]Zufällige Klasse: {char_class}[/]")
|
||||
|
||||
char = Character(name, race, char_class)
|
||||
|
||||
# Show summary
|
||||
console.print(f"\n[bold green]✅ Character erstellt![/]")
|
||||
console.print(f" Name: [bold]{char.name}[/]")
|
||||
console.print(f" Rasse: {char.race}")
|
||||
console.print(f" Klasse: {char.char_class}")
|
||||
console.print(f" Stufe: {char.level}")
|
||||
console.print(f" HP: {char.hp}/{char.max_hp} | MP: {char.mp}/{char.max_mp}")
|
||||
console.print(f" STR: {char.str} | DEX: {char.dex} | INT: {char.int} | VIT: {char.vit} | LCK: {char.lck}")
|
||||
console.print(f" Gold: {char.gold}")
|
||||
console.input("\n[dim]Enter drücken um zu starten...[/]")
|
||||
|
||||
return char
|
||||
|
||||
|
||||
def render_game(engine: GameEngine) -> Panel:
|
||||
"""Rendert das komplette Spiel-UI."""
|
||||
c = engine.character
|
||||
|
||||
# === Character Panel ===
|
||||
char_table = Table(show_header=False, box=box.SIMPLE, padding=(0, 1))
|
||||
char_table.add_column("Stat", style="cyan", width=8)
|
||||
char_table.add_column("Value", style="bold")
|
||||
|
||||
char_table.add_row("Name", f"{c.name}")
|
||||
char_table.add_row("Rasse", c.race)
|
||||
char_table.add_row("Klasse", c.char_class)
|
||||
char_table.add_row("Stufe", str(c.level))
|
||||
char_table.add_row("XP", f"{c.xp}/{c.xp_to_next}")
|
||||
char_table.add_row("HP", f"{c.hp}/{c.max_hp}")
|
||||
char_table.add_row("MP", f"{c.mp}/{c.max_mp}")
|
||||
char_table.add_row("Angriff", str(c.attack))
|
||||
char_table.add_row("Vert.", str(c.defense))
|
||||
char_table.add_row("Gold", f"{c.gold} 💰")
|
||||
char_table.add_row("Kills", str(c.kills))
|
||||
char_table.add_row("Tode", str(c.deaths))
|
||||
char_table.add_row("Quests", str(c.quests_done))
|
||||
|
||||
# === Equipment Panel ===
|
||||
equip_table = Table(show_header=False, box=box.SIMPLE, padding=(0, 1), title="[bold]Ausrüstung[/]")
|
||||
equip_table.add_column("Slot", style="cyan", width=10)
|
||||
equip_table.add_column("Item")
|
||||
|
||||
slot_names = {"weapon": "Waffe", "armor": "Rüstung", "helmet": "Helm", "boots": "Stiefel", "accessory": "Accessoire"}
|
||||
for slot, name in slot_names.items():
|
||||
item = c.equipped[slot]
|
||||
equip_table.add_row(name, str(item) if item else "[dim]leer[/]")
|
||||
|
||||
# === Quest Panel ===
|
||||
quest_text = Text()
|
||||
quest_text.append(f"📜 {engine.current_quest}\n", style="cyan")
|
||||
quest_text.append(f"Fortschritt: {engine.quest_progress}/{engine.quest_target}", style="yellow")
|
||||
|
||||
# === XP Bar ===
|
||||
xp_pct = c.xp / c.xp_to_next
|
||||
xp_bar_len = 20
|
||||
xp_filled = int(xp_pct * xp_bar_len)
|
||||
xp_bar = "█" * xp_filled + "░" * (xp_bar_len - xp_filled)
|
||||
|
||||
# HP Bar
|
||||
hp_pct = c.hp / c.max_hp
|
||||
hp_filled = int(hp_pct * xp_bar_len)
|
||||
hp_bar = "█" * hp_filled + "░" * (xp_bar_len - hp_filled)
|
||||
|
||||
# === Log Panel ===
|
||||
log_text = Text()
|
||||
for entry in engine.log[-12:]:
|
||||
if isinstance(entry, str):
|
||||
log_text.append(entry + "\n")
|
||||
|
||||
# === Location ===
|
||||
loc_text = f"🌍 {engine.current_location}"
|
||||
|
||||
# === Layout ===
|
||||
layout = Layout()
|
||||
|
||||
# Top: Title bar
|
||||
layout.split_column(
|
||||
Layout(Panel(f"[bold magenta]FORTSCHRITTS-QUEST[/] — {c.name} der {c.race} {c.char_class}",
|
||||
style="magenta"), size=3),
|
||||
Layout(name="main"),
|
||||
Layout(name="bottom", size=8),
|
||||
)
|
||||
|
||||
# Main: left=char/equip, center=log, right=quest/stats
|
||||
layout["main"].split_row(
|
||||
Layout(Panel(char_table, title="[bold]Character[/]"), name="left", size=30),
|
||||
Layout(Panel(log_text, title="[bold]Ereignis-Log[/]"), name="center"),
|
||||
Layout(name="right", size=30),
|
||||
)
|
||||
|
||||
layout["right"].split_column(
|
||||
Layout(Panel(equip_table), name="equip"),
|
||||
Layout(Panel(quest_text, title="[bold]Aktuelle Quest[/]"), name="quest"),
|
||||
)
|
||||
|
||||
# Bottom: bars + location
|
||||
bars = Text()
|
||||
bars.append(f"HP [{hp_bar}] {c.hp}/{c.max_hp}\n", style="red")
|
||||
bars.append(f"XP [{xp_bar}] {c.xp}/{c.xp_to_next}\n", style="green")
|
||||
bars.append(f"{loc_text} | Schritte: {c.steps} | STR {c.str} DEX {c.dex} INT {c.int} VIT {c.vit} LCK {c.lck}")
|
||||
|
||||
layout["bottom"].update(Panel(bars, style="dim"))
|
||||
|
||||
return layout
|
||||
|
||||
|
||||
def run_game(engine: GameEngine, save_path: str):
|
||||
"""Haupt-Game-Loop mit Live-Rendering."""
|
||||
auto_save_counter = 0
|
||||
|
||||
with Live(render_game(engine), console=console, refresh_per_second=2, screen=True) as live:
|
||||
try:
|
||||
while True:
|
||||
# Game step
|
||||
result = engine.step()
|
||||
|
||||
# Refill cache occasionally
|
||||
if engine.character.steps % 5 == 0:
|
||||
engine.cache.refill_async()
|
||||
|
||||
# Sell junk periodically
|
||||
if engine.character.steps % 10 == 0:
|
||||
engine._sell_junk()
|
||||
|
||||
# Auto-save every 50 steps
|
||||
auto_save_counter += 1
|
||||
if auto_save_counter >= 50:
|
||||
engine.save(save_path)
|
||||
auto_save_counter = 0
|
||||
|
||||
# Render
|
||||
live.update(render_game(engine))
|
||||
|
||||
# Speed — slower for readability, faster at higher levels
|
||||
speed = max(0.3, 1.5 - engine.character.level * 0.02)
|
||||
time.sleep(speed)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
live.update(render_game(engine))
|
||||
console.print("\n[bold yellow]Spiel pausiert.[/]")
|
||||
engine.save(save_path)
|
||||
console.print(f"[green]Spielstand gespeichert: {save_path}[/]")
|
||||
console.print("[dim]Drücke Enter zum Beenden...[/]")
|
||||
input()
|
||||
|
||||
|
||||
def main():
|
||||
console.clear()
|
||||
console.print(BANNER)
|
||||
|
||||
save_path = os.path.join(SAVE_DIR, "savegame.json")
|
||||
|
||||
# Check for existing save
|
||||
if os.path.exists(save_path):
|
||||
choice = console.input("[cyan]Spielstand gefunden. Fortsetzen? (j/n): [/]").strip().lower()
|
||||
if choice in ("j", "ja", "y", "yes", ""):
|
||||
console.print("[green]Lade Spielstand...[/]")
|
||||
engine = GameEngine.load(save_path)
|
||||
console.print(f"[dim]Willkommen zurück, {engine.character.name} (Stufe {engine.character.level})![/]")
|
||||
time.sleep(1)
|
||||
run_game(engine, save_path)
|
||||
return
|
||||
|
||||
# New game
|
||||
char = create_character()
|
||||
engine = GameEngine(char)
|
||||
run_game(engine, save_path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user