v2.0 — D&D 5e Regelwerk mit Würfel-Fenster
Vollständiges D&D 5e Combat-System: - 6 Ability Scores (STR/DEX/CON/INT/WIS/CHA), 4d6 drop lowest - 10 D&D Klassen (Barde bis Hexenmeister), 10 Rassen mit Boni - d20 Attack Rolls vs AC, Damage Dice, Critical Hit (nat 20), Critical Miss (nat 1) - Saving Throws (CON bei Tod), Proficiency Bonus (+2/+3/+4) - D&D 5e XP-Tabelle (300/900/2700/...) - Hit Dice, Level-Up mit HP gain + ASI alle 4 Level - AC = 10 + DEX + Armor + Shield - Spell Slots (vereinfacht) UI: - Würfel-Fenster Panel zeigt jeden d20-Wurf - Fantasy-Theme: Schattengoblin, Höhlentroll, Knochendrache etc. - Quest-Abschluss mit Reise + 3s Pause - Save/Load mit dice_log
This commit is contained in:
@@ -1,162 +0,0 @@
|
||||
"""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}"
|
||||
+288
@@ -0,0 +1,288 @@
|
||||
"""D&D 5e Regelwerk: Stats, Würfel, Combat, Classes."""
|
||||
|
||||
import random
|
||||
|
||||
# === D&D 5e dice ===
|
||||
def roll(sides: int, count: int = 1, modifier: int = 0, drop_lowest: int = 0) -> list:
|
||||
"""Wirft Würfel und gibt [einzelwürfe, gesamt] zurück."""
|
||||
rolls = [random.randint(1, sides) for _ in range(count)]
|
||||
if drop_lowest > 0 and drop_lowest < count:
|
||||
sorted_rolls = sorted(rolls, reverse=True)
|
||||
rolls = sorted_rolls[:count - drop_lowest]
|
||||
total = sum(rolls) + modifier
|
||||
return {"rolls": rolls, "modifier": modifier, "total": total}
|
||||
|
||||
def roll_d20(modifier: int = 0, advantage: bool = False, disadvantage: bool = False) -> dict:
|
||||
"""d20-Wurf mit optional Advantage/Disadvantage."""
|
||||
if advantage:
|
||||
r1 = random.randint(1, 20)
|
||||
r2 = random.randint(1, 20)
|
||||
result = max(r1, r2)
|
||||
return {"rolls": [r1, r2], "modifier": modifier, "total": result + modifier, "advantage": True, "raw": result}
|
||||
elif disadvantage:
|
||||
r1 = random.randint(1, 20)
|
||||
r2 = random.randint(1, 20)
|
||||
result = min(r1, r2)
|
||||
return {"rolls": [r1, r2], "modifier": modifier, "total": result + modifier, "disadvantage": True, "raw": result}
|
||||
else:
|
||||
raw = random.randint(1, 20)
|
||||
return {"rolls": [raw], "modifier": modifier, "total": raw + modifier, "raw": raw}
|
||||
|
||||
# === D&D 5e Ability Scores ===
|
||||
def roll_stats() -> dict:
|
||||
"""4d6 drop lowest, 6 mal für STR/DEX/CON/INT/WIS/CHA."""
|
||||
stats = {}
|
||||
for ability in ["STR", "DEX", "CON", "INT", "WIS", "CHA"]:
|
||||
result = roll(6, 4, drop_lowest=1)
|
||||
stats[ability] = result["total"]
|
||||
return stats
|
||||
|
||||
def modifier(score: int) -> int:
|
||||
"""D&D 5e modifier: (score - 10) // 2."""
|
||||
return (score - 10) // 2
|
||||
|
||||
# === D&D 5e Classes ===
|
||||
DND_CLASSES = {
|
||||
"Barde": {
|
||||
"hit_die": 8, "primary": "CHA", "saves": ["DEX", "CHA"],
|
||||
"weapon_dice": "1d8", "armor_prof": "leicht",
|
||||
},
|
||||
"Kleriker": {
|
||||
"hit_die": 8, "primary": "WIS", "saves": ["WIS", "CHA"],
|
||||
"weapon_dice": "1d8", "armor_prof": "mittel",
|
||||
},
|
||||
"Druide": {
|
||||
"hit_die": 8, "primary": "WIS", "saves": ["INT", "WIS"],
|
||||
"weapon_dice": "1d6", "armor_prof": "mittel",
|
||||
},
|
||||
"Kämpfer": {
|
||||
"hit_die": 10, "primary": "STR", "saves": ["STR", "CON"],
|
||||
"weapon_dice": "1d8", "armor_prof": "schwer",
|
||||
},
|
||||
"Magier": {
|
||||
"hit_die": 6, "primary": "INT", "saves": ["INT", "WIS"],
|
||||
"weapon_dice": "1d6", "armor_prof": "keine",
|
||||
},
|
||||
"Mönch": {
|
||||
"hit_die": 8, "primary": "DEX", "saves": ["STR", "DEX"],
|
||||
"weapon_dice": "1d6", "armor_prof": "keine",
|
||||
},
|
||||
"Paladin": {
|
||||
"hit_die": 10, "primary": "CHA", "saves": ["WIS", "CHA"],
|
||||
"weapon_dice": "1d8", "armor_prof": "schwer",
|
||||
},
|
||||
"Schurke": {
|
||||
"hit_die": 8, "primary": "DEX", "saves": ["DEX", "INT"],
|
||||
"weapon_dice": "1d6", "armor_prof": "leicht",
|
||||
},
|
||||
"Waldläufer": {
|
||||
"hit_die": 10, "primary": "DEX", "saves": ["STR", "DEX"],
|
||||
"weapon_dice": "1d8", "armor_prof": "mittel",
|
||||
},
|
||||
"Hexenmeister": {
|
||||
"hit_die": 8, "primary": "CHA", "saves": ["WIS", "CHA"],
|
||||
"weapon_dice": "1d6", "armor_prof": "leicht",
|
||||
},
|
||||
}
|
||||
|
||||
DND_RACES = {
|
||||
"Mensch": {"boni": {"STR": 1, "DEX": 1, "CON": 1, "INT": 1, "WIS": 1, "CHA": 1}, "speed": 30},
|
||||
"Elf": {"boni": {"DEX": 2}, "speed": 30},
|
||||
"Zwerg": {"boni": {"CON": 2}, "speed": 25},
|
||||
"Halbling": {"boni": {"DEX": 2}, "speed": 25},
|
||||
"Gnom": {"boni": {"INT": 2}, "speed": 25},
|
||||
"Halb-Elf": {"boni": {"CHA": 2}, "speed": 30},
|
||||
"Halb-Ork": {"boni": {"STR": 2, "CON": 1}, "speed": 30},
|
||||
"Tiefling": {"boni": {"INT": 1, "CHA": 2}, "speed": 30},
|
||||
"Drachenblütiger": {"boni": {"STR": 2, "CHA": 1}, "speed": 30},
|
||||
"Dunkelelf": {"boni": {"DEX": 2, "CHA": 1}, "speed": 30},
|
||||
}
|
||||
|
||||
# === D&D 5e Character Stats ===
|
||||
class DnDCharacter:
|
||||
def __init__(self, name: str, race: str, dnd_class: str):
|
||||
self.name = name
|
||||
self.race = race
|
||||
self.dnd_class = dnd_class
|
||||
|
||||
# Roll base stats
|
||||
base = roll_stats()
|
||||
race_boni = DND_RACES.get(race, {}).get("boni", {})
|
||||
self.stats = {k: v + race_boni.get(k, 0) for k, v in base.items()}
|
||||
|
||||
self.level = 1
|
||||
self.xp = 0
|
||||
|
||||
class_data = DND_CLASSES.get(dnd_class, DND_CLASSES["Kämpfer"])
|
||||
self.hit_die = class_data["hit_die"]
|
||||
self.save_prof = class_data["saves"]
|
||||
self.weapon_dice = class_data["weapon_dice"]
|
||||
|
||||
# HP = max(hit_die) + CON mod at level 1, + hit_die/2 + CON mod per level
|
||||
con_mod = modifier(self.stats["CON"])
|
||||
self.max_hp = self.hit_die + con_mod
|
||||
self.hp = self.max_hp
|
||||
|
||||
# AC = 10 + DEX mod (light armor / no armor)
|
||||
dex_mod = modifier(self.stats["DEX"])
|
||||
self.base_ac = 10 + dex_mod
|
||||
self.armor_bonus = 0 # from equipment
|
||||
self.shield_bonus = 0
|
||||
|
||||
# Proficiency bonus (D&D 5e: +2 at 1-4, +3 at 5-8, etc.)
|
||||
self.prof_bonus = self._calc_prof_bonus()
|
||||
|
||||
# Spell slots (simplified)
|
||||
self.spell_slots = self._calc_spell_slots()
|
||||
|
||||
# Equipment
|
||||
self.equipped = {"weapon": None, "armor": None, "shield": None, "helmet": None, "boots": None, "accessory": None}
|
||||
self.inventory = []
|
||||
self.gold = roll(6, 3)["total"] * 10 # Starting gold 3d6 * 10
|
||||
|
||||
# Stats tracking
|
||||
self.kills = 0
|
||||
self.deaths = 0
|
||||
self.quests_done = 0
|
||||
self.steps = 0
|
||||
self.crit_hits = 0
|
||||
self.crit_misses = 0
|
||||
self.nat_20s = 0
|
||||
|
||||
@property
|
||||
def ac(self) -> int:
|
||||
return self.base_ac + self.armor_bonus + self.shield_bonus
|
||||
|
||||
def _calc_prof_bonus(self) -> int:
|
||||
return 2 + ((self.level - 1) // 4)
|
||||
|
||||
def _calc_spell_slots(self) -> int:
|
||||
if self.dnd_class in ("Magier", "Hexenmeister", "Barde", "Kleriker", "Druide"):
|
||||
return max(0, self.level) # simplified: 1 slot per level
|
||||
return 0
|
||||
|
||||
@property
|
||||
def attack_bonus(self) -> int:
|
||||
"""Attack roll modifier: STR/DEX mod + proficiency."""
|
||||
class_data = DND_CLASSES.get(self.dnd_class, {})
|
||||
primary = class_data.get("primary", "STR")
|
||||
if primary in ("DEX",):
|
||||
ability_mod = modifier(self.stats["DEX"])
|
||||
else:
|
||||
ability_mod = modifier(self.stats["STR"])
|
||||
weapon_bonus = self.equipped["weapon"].power if self.equipped["weapon"] else 0
|
||||
return ability_mod + self.prof_bonus + weapon_bonus
|
||||
|
||||
@property
|
||||
def damage_bonus(self) -> int:
|
||||
"""Damage modifier: STR/DEX mod + weapon bonus."""
|
||||
class_data = DND_CLASSES.get(self.dnd_class, {})
|
||||
primary = class_data.get("primary", "STR")
|
||||
if primary == "DEX":
|
||||
ability_mod = modifier(self.stats["DEX"])
|
||||
else:
|
||||
ability_mod = modifier(self.stats["STR"])
|
||||
return ability_mod
|
||||
|
||||
@property
|
||||
def xp_to_next(self) -> int:
|
||||
"""D&D 5e XP thresholds."""
|
||||
thresholds = [0, 300, 900, 2700, 6500, 14000, 23000, 34000, 48000, 64000, 85000, 100000, 120000, 140000, 165000, 195000, 225000, 265000, 305000, 355000]
|
||||
if self.level < len(thresholds):
|
||||
return thresholds[self.level]
|
||||
return 999999
|
||||
|
||||
def saving_throw(self, ability: str) -> dict:
|
||||
"""Saving Throw: d20 + ability mod + prof (if proficient)."""
|
||||
mod = modifier(self.stats[ability])
|
||||
prof = self.prof_bonus if ability in self.save_prof else 0
|
||||
total_mod = mod + prof
|
||||
result = roll_d20(total_mod)
|
||||
result["ability"] = ability
|
||||
result["modifier"] = total_mod
|
||||
result["success"] = result["total"] >= 10 # DC 10 default
|
||||
return result
|
||||
|
||||
def level_up(self):
|
||||
self.level += 1
|
||||
con_mod = modifier(self.stats["CON"])
|
||||
hp_gain = (self.hit_die // 2) + 1 + con_mod
|
||||
self.max_hp += hp_gain
|
||||
self.hp = self.max_hp
|
||||
self.prof_bonus = self._calc_prof_bonus()
|
||||
self.spell_slots = self._calc_spell_slots()
|
||||
# ASI: +2 to one stat every 4 levels
|
||||
if self.level % 4 == 0:
|
||||
best_stat = max(self.stats, key=lambda k: self.stats[k])
|
||||
self.stats[best_stat] += 2
|
||||
return hp_gain
|
||||
|
||||
def gain_xp(self, amount: int) -> bool:
|
||||
self.xp += amount
|
||||
if self.xp >= self.xp_to_next:
|
||||
self.xp -= self.xp_to_next
|
||||
self.level_up()
|
||||
return True
|
||||
return False
|
||||
|
||||
def is_alive(self) -> bool:
|
||||
return self.hp > 0
|
||||
|
||||
def heal(self):
|
||||
self.hp = self.max_hp
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"name": self.name, "race": self.race, "dnd_class": self.dnd_class,
|
||||
"stats": self.stats, "level": self.level, "xp": self.xp,
|
||||
"hit_die": self.hit_die, "save_prof": self.save_prof,
|
||||
"weapon_dice": self.weapon_dice,
|
||||
"max_hp": self.max_hp, "hp": self.hp,
|
||||
"base_ac": self.base_ac, "armor_bonus": self.armor_bonus, "shield_bonus": self.shield_bonus,
|
||||
"prof_bonus": self.prof_bonus, "spell_slots": self.spell_slots,
|
||||
"equipped": {k: v.to_dict() if v else None for k, v in self.equipped.items()},
|
||||
"inventory": [i.to_dict() for i in self.inventory],
|
||||
"gold": self.gold,
|
||||
"kills": self.kills, "deaths": self.deaths, "quests_done": self.quests_done,
|
||||
"steps": self.steps, "crit_hits": self.crit_hits, "crit_misses": self.crit_misses, "nat_20s": self.nat_20s,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "DnDCharacter":
|
||||
c = cls(d["name"], d["race"], d["dnd_class"])
|
||||
c.stats = d["stats"]
|
||||
c.level = d["level"]; c.xp = d["xp"]
|
||||
c.hit_die = d["hit_die"]; c.save_prof = d["save_prof"]
|
||||
c.weapon_dice = d["weapon_dice"]
|
||||
c.max_hp = d["max_hp"]; c.hp = d["hp"]
|
||||
c.base_ac = d["base_ac"]; c.armor_bonus = d["armor_bonus"]; c.shield_bonus = d["shield_bonus"]
|
||||
c.prof_bonus = d["prof_bonus"]; c.spell_slots = d["spell_slots"]
|
||||
c.equipped = {k: Item.from_dict(v) if v else None for k, v in d.get("equipped", {}).items()}
|
||||
c.inventory = [Item.from_dict(i) for i in d.get("inventory", [])]
|
||||
c.gold = d["gold"]
|
||||
c.kills = d["kills"]; c.deaths = d["deaths"]; c.quests_done = d["quests_done"]
|
||||
c.steps = d["steps"]; c.crit_hits = d["crit_hits"]; c.crit_misses = d["crit_misses"]; c.nat_20s = d["nat_20s"]
|
||||
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
|
||||
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} (+{self.power}, {self.value}G){suffix}"
|
||||
+319
-133
@@ -1,102 +1,275 @@
|
||||
"""Game-Engine: Auto-Progress, Kämpfe, Loot, Quests, Shop."""
|
||||
"""Game-Engine mit D&D 5e Combat-System."""
|
||||
|
||||
import random
|
||||
import time
|
||||
from character import Character, Item, RACES, CLASSES
|
||||
from llm import NameCache
|
||||
from dnd import DnDCharacter, Item, DND_RACES, DND_CLASSES, roll, roll_d20, modifier
|
||||
|
||||
class Monster:
|
||||
def __init__(self, name: str, level: int, hp: int, attack: int, defense: int, xp: int, gold: int):
|
||||
# Fantasy Fallbacks
|
||||
FALLBACK_MONSTERS = [
|
||||
"Schattengoblin", "Höhlentroll", "Knochendrache", "Verfluchter Ritter",
|
||||
"Sumpfhexe", "Frostwolf", "Steingolem", "Untoter Magier",
|
||||
"Riesenspinne", "Düsterschurke", "Feuerdämon", "Eisriese",
|
||||
"Waldgnomenräuber", "Dunkler Schamane", "Skelettkrieger", "Vergifter-Naga",
|
||||
"Höllenjagd-Hund", "Sturmschlange", "Ruinengolem", "Schattenassassine",
|
||||
]
|
||||
|
||||
FALLBACK_ITEMS = {
|
||||
"weapon": ["Verrostetes Kurzschwert", "Eiche-Kampfstab", "Zwergenaxt", "Elfenbogen"],
|
||||
"armor": ["Lederrüstung", "Kettenhemd", "Plattenpanzer", "Magierrobe"],
|
||||
"shield": ["Holzschild", "Eisenschild", "Drachenschuppenschild"],
|
||||
"helmet": ["Ledermütze", "Sturmmhelm", "Eisenhelm", "Helm der Weisheit"],
|
||||
"boots": ["Lederstiefel", "Wanderstiefel", "Stiefel der Geschwindigkeit"],
|
||||
"accessory": ["Ring des Schutzes", "Amulett der Macht", "Zauberhandschuh"],
|
||||
"junk": ["Verrosteter Schlüssel", "Alte Schriftrolle", "Knochenstück", "Glasflasche"],
|
||||
}
|
||||
|
||||
FALLBACK_QUESTS = [
|
||||
"Befreie das Dorf von der Schattengoblin-Plage",
|
||||
"Finde das verlorene Schwert des Königs",
|
||||
"Besiege den Drachen der Dunklen Höhle",
|
||||
"Sammle 10 magische Kristalle",
|
||||
"Begleite den Händler sicher durch den Wald",
|
||||
"Reinige den alten Tempel von Untoten",
|
||||
"Befreie die gefangene Prinzessin",
|
||||
"Finde den verschwundenen Magier",
|
||||
]
|
||||
|
||||
FALLBACK_LOCATIONS = [
|
||||
"Die Knochenkavernen von Düstermoor",
|
||||
"Der Verfluchte Wald von Schattental",
|
||||
"Die Eisfeste von Nordwind",
|
||||
"Die Drachenhöhle am Feuerberg",
|
||||
"Die Ruinen von Alt-Eisenberg",
|
||||
"Der Sumpf der Verlorenen Seelen",
|
||||
"Das Labyrinth der Schatten",
|
||||
"Die Unterwelt von Tiefenhall",
|
||||
]
|
||||
|
||||
|
||||
class DnDMonster:
|
||||
"""D&D 5e Monster mit echten Stats."""
|
||||
def __init__(self, name: str, level: int, char_level: int):
|
||||
self.name = name
|
||||
self.level = level
|
||||
self.hp = hp
|
||||
self.attack = attack
|
||||
self.defense = defense
|
||||
self.xp = xp
|
||||
self.gold = gold
|
||||
self.challenge_rating = max(0.25, level / 4)
|
||||
|
||||
# Stats basierend auf Challenge Rating
|
||||
self.ac = 10 + level + random.randint(0, 3)
|
||||
self.hp = max(1, int((level * 8) + random.randint(0, 10)))
|
||||
self.max_hp = self.hp
|
||||
|
||||
# Attack: d20 + prof + STR/DEX
|
||||
self.attack_mod = level // 2 + 2 + random.randint(0, 2)
|
||||
|
||||
# Damage: weapon dice + mod
|
||||
self.damage_dice = random.choice(["1d6", "1d8", "1d10", "2d6", "1d12"])
|
||||
self.damage_mod = max(0, level // 3)
|
||||
|
||||
# XP and Gold (D&D 5e CR table, simplified)
|
||||
cr_xp = {0.25: 50, 0.5: 100, 1: 200, 2: 450, 3: 700, 4: 1100, 5: 1800,
|
||||
6: 2300, 7: 2900, 8: 3900, 9: 5000, 10: 5900}
|
||||
cr_key = min(self.challenge_rating, 10) if self.challenge_rating >= 1 else 0.25
|
||||
self.xp = cr_xp.get(cr_key, int(100 * self.challenge_rating))
|
||||
self.gold = max(1, int(level * random.uniform(1, 5)))
|
||||
|
||||
# Saving throws
|
||||
self.saves = {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}
|
||||
|
||||
def is_alive(self):
|
||||
@property
|
||||
def is_alive(self) -> bool:
|
||||
return self.hp > 0
|
||||
|
||||
def attack(self, target_ac: int, dice_log: list) -> dict:
|
||||
"""Monster greift an mit d20."""
|
||||
atk_roll = roll_d20(self.attack_mod)
|
||||
dice_log.append(f"[red]🎲 {self.name} greift an: d20{atk_roll['rolls']}{'+' if self.attack_mod >= 0 else ''}{self.attack_mod} = {atk_roll['total']} vs AC {target_ac}[/]")
|
||||
|
||||
hit = atk_roll["total"] >= target_ac
|
||||
crit = atk_roll["raw"] == 20
|
||||
fumble = atk_roll["raw"] == 1
|
||||
|
||||
if fumble:
|
||||
dice_log.append(f"[red]💀 Kritischer Fehlschlag! {self.name} stolpert![/]")
|
||||
return {"hit": False, "damage": 0, "crit": False, "fumble": True}
|
||||
|
||||
if not hit:
|
||||
dice_log.append(f"[yellow]💨 {self.name} verfehlt! (AC {target_ac})[/]")
|
||||
return {"hit": False, "damage": 0, "crit": False, "fumble": False}
|
||||
|
||||
# Damage roll
|
||||
dice_str = self.damage_dice
|
||||
count, sides = map(int, dice_str.split("d"))
|
||||
dmg_roll = roll(sides, count, self.damage_mod)
|
||||
|
||||
if crit:
|
||||
# Double dice on crit
|
||||
extra = roll(sides, count, 0)
|
||||
total_dmg = dmg_roll["total"] + extra["total"] - self.damage_mod
|
||||
dice_log.append(f"[bold red]💥 KRITISCH! {dice_str}+{self.damage_mod} = {dmg_roll['total']} + {extra['total']} (crit) = {total_dmg} Schaden![/]")
|
||||
else:
|
||||
total_dmg = dmg_roll["total"]
|
||||
dice_log.append(f"[red]⚔️ {self.name} trifft: {dice_str}+{self.damage_mod} = {total_dmg} Schaden[/]")
|
||||
|
||||
return {"hit": True, "damage": total_dmg, "crit": crit, "fumble": False}
|
||||
|
||||
|
||||
class DiceLog:
|
||||
"""Verwaltet den Würfel-Log für die UI."""
|
||||
def __init__(self, max_entries: int = 25):
|
||||
self.entries = []
|
||||
self.max_entries = max_entries
|
||||
|
||||
def add(self, msg: str):
|
||||
self.entries.insert(0, msg)
|
||||
if len(self.entries) > self.max_entries:
|
||||
self.entries = self.entries[:self.max_entries]
|
||||
|
||||
def clear(self):
|
||||
self.entries = []
|
||||
|
||||
def get_all(self) -> list:
|
||||
return self.entries
|
||||
|
||||
|
||||
class GameEngine:
|
||||
def __init__(self, character: Character):
|
||||
def __init__(self, character: DnDCharacter):
|
||||
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.dice_log = DiceLog(max_entries=25)
|
||||
self.event_log = []
|
||||
self.max_event_log = 20
|
||||
self.loot_log = []
|
||||
self.max_loot_log = 20
|
||||
|
||||
self.current_location = random.choice(FALLBACK_LOCATIONS)
|
||||
self.current_quest = random.choice(FALLBACK_QUESTS)
|
||||
self.quest_progress = 0
|
||||
self.quest_target = random.randint(3, 8)
|
||||
self.log = []
|
||||
self.max_log = 30
|
||||
self.loot_log = []
|
||||
self.max_loot_log = 30
|
||||
|
||||
self._fill_cache()
|
||||
|
||||
def _fill_cache(self):
|
||||
"""Füllt Cache initial."""
|
||||
pass # LLM wird asynchron in refill_async geladen
|
||||
|
||||
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]
|
||||
self.event_log.insert(0, msg)
|
||||
if len(self.event_log) > self.max_event_log:
|
||||
self.event_log = self.event_log[:self.max_event_log]
|
||||
|
||||
def _add_loot_log(self, msg: str):
|
||||
self.loot_log.insert(0, msg)
|
||||
if len(self.loot_log) > self.max_loot_log:
|
||||
self.loot_log = self.loot_log[:self.max_loot_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]
|
||||
def _spawn_monster(self) -> DnDMonster:
|
||||
name = random.choice(FALLBACK_MONSTERS)
|
||||
# Add adjective sometimes
|
||||
if random.randint(1, 3) == 1:
|
||||
adj = random.choice(["Wütender", "Verirrter", "Verfluchter", "Hungriger", "Müder", "Brüllender"])
|
||||
name = f"{adj} {name}"
|
||||
|
||||
# Rarity based on luck and level
|
||||
roll = random.randint(1, 100) + self.character.lck
|
||||
if roll > 95:
|
||||
level = max(1, self.character.level + random.randint(-1, 1))
|
||||
return DnDMonster(name, level, self.character.level)
|
||||
|
||||
def _generate_loot(self, monster: DnDMonster) -> Item:
|
||||
item_types = ["weapon", "armor", "shield", "helmet", "boots", "accessory", "junk"]
|
||||
weights = [15, 15, 10, 10, 8, 8, 34]
|
||||
item_type = random.choices(item_types, weights=weights)[0]
|
||||
|
||||
# Rarity
|
||||
roll_val = random.randint(1, 100) + (modifier(self.character.stats["CHA"]) if "CHA" in self.character.stats else 0)
|
||||
if roll_val > 95:
|
||||
rarity = "legendär"
|
||||
elif roll > 80:
|
||||
elif roll_val > 80:
|
||||
rarity = "episch"
|
||||
elif roll > 60:
|
||||
elif roll_val > 60:
|
||||
rarity = "selten"
|
||||
else:
|
||||
rarity = "gewöhnlich"
|
||||
|
||||
name = self.cache.get_item(item_type, self.character.level, rarity)
|
||||
|
||||
name = random.choice(FALLBACK_ITEMS.get(item_type, FALLBACK_ITEMS["junk"]))
|
||||
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))
|
||||
power = max(1, int(self.character.level * mult + random.randint(0, 2)))
|
||||
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."""
|
||||
def _player_attack(self, monster: DnDMonster) -> dict:
|
||||
"""Spieler greift an — D&D 5e Attack Roll."""
|
||||
atk_mod = self.character.attack_bonus
|
||||
atk_roll = roll_d20(atk_mod)
|
||||
self.dice_log.add(f"[cyan]🎲 Du greifst an: d20{atk_roll['rolls']}{'+' if atk_mod >= 0 else ''}{atk_mod} = {atk_roll['total']} vs AC {monster.ac}[/]")
|
||||
|
||||
# Track nat 20
|
||||
if atk_roll["raw"] == 20:
|
||||
self.character.nat_20s += 1
|
||||
|
||||
crit = atk_roll["raw"] == 20
|
||||
fumble = atk_roll["raw"] == 1
|
||||
|
||||
if fumble:
|
||||
self.character.crit_misses += 1
|
||||
self.dice_log.add(f"[red]💀 Natürliche 1! Kritischer Fehlschlag![/]")
|
||||
return {"hit": False, "damage": 0, "crit": False, "fumble": True}
|
||||
|
||||
hit = atk_roll["total"] >= monster.ac
|
||||
|
||||
if not hit:
|
||||
self.dice_log.add(f"[yellow]💨 Du verfehlst! ({atk_roll['total']} < AC {monster.ac})[/]")
|
||||
return {"hit": False, "damage": 0, "crit": False, "fumble": False}
|
||||
|
||||
if crit:
|
||||
self.character.crit_hits += 1
|
||||
|
||||
# Damage roll
|
||||
dice_str = self.character.weapon_dice
|
||||
count, sides = map(int, dice_str.split("d"))
|
||||
dmg_mod = self.character.damage_bonus
|
||||
weapon_bonus = self.character.equipped["weapon"].power if self.character.equipped["weapon"] else 0
|
||||
total_mod = dmg_mod + weapon_bonus
|
||||
|
||||
dmg_roll = roll(sides, count, total_mod)
|
||||
|
||||
if crit:
|
||||
# Double dice on crit (D&D 5e)
|
||||
extra = roll(sides, count, 0)
|
||||
total_dmg = dmg_roll["total"] + extra["total"]
|
||||
self.dice_log.add(f"[bold green]💥 KRITISCHER TREFFER! {dice_str}+{total_mod} = {dmg_roll['total']} + {extra['total']} (crit) = {total_dmg} Schaden![/]")
|
||||
else:
|
||||
total_dmg = dmg_roll["total"]
|
||||
self.dice_log.add(f"[green]⚔️ Du triffst! {dice_str}+{total_mod} = {total_dmg} Schaden[/]")
|
||||
|
||||
return {"hit": True, "damage": total_dmg, "crit": crit, "fumble": False}
|
||||
|
||||
def _combat(self, monster: DnDMonster) -> dict:
|
||||
"""D&D 5e Combat: abwechselnde Runden."""
|
||||
rounds = 0
|
||||
while monster.is_alive() and self.character.is_alive():
|
||||
self.dice_log.add(f"[bold]━━━ Kampf: {self.character.name} vs {monster.name} (AC {monster.ac}, HP {monster.hp}) ━━━[/]")
|
||||
|
||||
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
|
||||
# Player attacks (initiative: DEX check)
|
||||
result = self._player_attack(monster)
|
||||
if result["hit"]:
|
||||
monster.hp = max(0, monster.hp - result["damage"])
|
||||
if not monster.is_alive:
|
||||
self.dice_log.add(f"[bold green]☠️ {monster.name} besiegt![/]")
|
||||
break
|
||||
|
||||
# Monster attacks
|
||||
mon_dmg = max(1, monster.attack - self.character.defense + random.randint(-2, 2))
|
||||
actual = self.character.take_damage(mon_dmg)
|
||||
if monster.is_alive:
|
||||
m_result = monster.attack(self.character.ac, self.dice_log.entries)
|
||||
self.dice_log.entries = self.dice_log.entries # update ref
|
||||
if m_result["hit"]:
|
||||
self.character.hp = max(0, self.character.hp - m_result["damage"])
|
||||
if not self.character.is_alive:
|
||||
self.dice_log.add(f"[bold red]💀 Du wurdest besiegt![/]")
|
||||
return {"result": "death", "rounds": rounds, "monster": monster.name}
|
||||
|
||||
if not self.character.is_alive():
|
||||
return {"result": "death", "rounds": rounds, "monster": monster.name}
|
||||
self.dice_log.add(f"[dim]── Runde {rounds} ── HP: {self.character.hp}/{self.character.max_hp} | Monster: {monster.hp}/{monster.max_hp} ──[/]")
|
||||
|
||||
return {"result": "victory", "rounds": rounds, "monster": monster.name}
|
||||
|
||||
@@ -106,90 +279,29 @@ class GameEngine:
|
||||
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}[/]")
|
||||
self._add_log(f"[dim]Belohnung: +{quest_gold} Gold, +{quest_xp} XP[/]")
|
||||
self.dice_log.add(f"[green]📜 Quest-Belohnung: +{quest_gold}G, +{quest_xp}XP[/]")
|
||||
|
||||
if leveled:
|
||||
self._add_log(f"[bold yellow]⬆️ Stufe {self.character.level} erreicht![/]")
|
||||
self.dice_log.add(f"[bold yellow]⬆️ LEVEL UP! Stufe {self.character.level}[/]")
|
||||
self.character.heal()
|
||||
# Travel to new location before new quest
|
||||
|
||||
self._add_log(f"[blue]🌍 Du machst dich auf den Weg...[/]")
|
||||
self.current_location = self.cache.get_location()
|
||||
self.current_location = random.choice(FALLBACK_LOCATIONS)
|
||||
self._add_log(f"[blue]🌍 Ankunft in: {self.current_location}[/]")
|
||||
# New quest
|
||||
self.current_quest = self.cache.get_quest(self.character.level)
|
||||
|
||||
self.current_quest = random.choice(FALLBACK_QUESTS)
|
||||
self.quest_progress = 0
|
||||
self.quest_target = random.randint(3, 8)
|
||||
self._add_log(f"[cyan]📜 Neue Quest: {self.current_quest}[/]")
|
||||
self._add_log(f"[dim]Ziel: Besiege {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_loot_log(f"[magenta]📦 {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![/]", True)) # True = pause flag
|
||||
|
||||
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"):
|
||||
if item.item_type in ("weapon", "armor", "shield", "helmet", "boots", "accessory"):
|
||||
slot = item.item_type
|
||||
|
||||
if slot:
|
||||
@@ -204,7 +316,6 @@ class GameEngine:
|
||||
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:
|
||||
@@ -212,7 +323,6 @@ class GameEngine:
|
||||
self._add_loot_log(f"[dim]💰 Verkauft: {item.name} (+{item.value}G)[/]")
|
||||
|
||||
def _sell_junk(self):
|
||||
"""Verkauft alle Schrott-Items."""
|
||||
sold = 0
|
||||
total_gold = 0
|
||||
for item in self.character.inventory[:]:
|
||||
@@ -224,6 +334,80 @@ class GameEngine:
|
||||
if sold > 0:
|
||||
self._add_loot_log(f"[dim]💰 {sold} Schrott verkauft (+{total_gold}G)[/]")
|
||||
|
||||
def step(self) -> dict:
|
||||
self.character.steps += 1
|
||||
events = []
|
||||
|
||||
# Random travel
|
||||
if random.randint(1, 50) == 1:
|
||||
self.current_location = random.choice(FALLBACK_LOCATIONS)
|
||||
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} (AC {monster.ac}, HP {monster.hp}) 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)
|
||||
self.dice_log.add(f"[green]📊 +{monster.xp} XP, +{monster.gold} Gold[/]")
|
||||
events.append(("reward", 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.dice_log.add(f"[bold yellow]⬆️ LEVEL UP! Stufe {self.character.level}[/]")
|
||||
self.character.heal()
|
||||
events.append(("levelup", f"[bold yellow]⬆️ STUFE {self.character.level}![/]"))
|
||||
|
||||
# Loot
|
||||
if random.randint(1, 100) <= 70:
|
||||
item = self._generate_loot(monster)
|
||||
self._add_loot_log(f"[magenta]📦 {item}[/]")
|
||||
events.append(("loot", f"[magenta]📦 {item}[/]", item))
|
||||
self._auto_equip(item)
|
||||
|
||||
# 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![/]", True))
|
||||
|
||||
elif result["result"] == "death":
|
||||
self.character.deaths += 1
|
||||
# Death saving throw
|
||||
save = self.character.saving_throw("CON")
|
||||
self.dice_log.add(f"[yellow]🎲 Todesschaden-Save: d20{save['rolls']}+{save['modifier']} = {save['total']} (DC 10)[/]")
|
||||
|
||||
if save["success"]:
|
||||
self.dice_log.add(f"[green]💚 Du stabilisierst dich![/]")
|
||||
self._add_log(f"[yellow]💀 {monster.name} besiegt dich, aber du überlebst![/]")
|
||||
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.[/]")
|
||||
else:
|
||||
self.dice_log.add(f"[red]💀 Rettungswurf fehlgeschlagen![/]")
|
||||
self._add_log(f"[bold red]💀 Von {monster.name} besiegt! Respawn...[/]")
|
||||
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.[/]")
|
||||
|
||||
events.append(("death", f"[bold red]💀 Niederlage gegen {monster.name}![/]"))
|
||||
|
||||
return {"events": events}
|
||||
|
||||
def get_state(self) -> dict:
|
||||
return {
|
||||
"character": self.character.to_dict(),
|
||||
@@ -231,8 +415,9 @@ class GameEngine:
|
||||
"current_quest": self.current_quest,
|
||||
"quest_progress": self.quest_progress,
|
||||
"quest_target": self.quest_target,
|
||||
"log": self.log,
|
||||
"event_log": self.event_log,
|
||||
"loot_log": self.loot_log,
|
||||
"dice_log": self.dice_log.get_all(),
|
||||
}
|
||||
|
||||
def save(self, path: str):
|
||||
@@ -245,12 +430,13 @@ class GameEngine:
|
||||
import json
|
||||
with open(path) as f:
|
||||
d = json.load(f)
|
||||
char = Character.from_dict(d["character"])
|
||||
char = DnDCharacter.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", [])
|
||||
engine.event_log = d.get("event_log", [])
|
||||
engine.loot_log = d.get("loot_log", [])
|
||||
engine.dice_log.entries = d.get("dice_log", [])
|
||||
return engine
|
||||
-216
@@ -1,216 +0,0 @@
|
||||
"""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
|
||||
+96
-100
@@ -1,12 +1,10 @@
|
||||
"""Fortschritts-Quest — Automatisches RPG mit lokalem LLM."""
|
||||
"""Fortschritts-Quest — D&D 5e Automatisches RPG."""
|
||||
|
||||
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
|
||||
@@ -15,13 +13,10 @@ 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 dnd import DnDCharacter, DND_RACES, DND_CLASSES, roll_stats, modifier
|
||||
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")
|
||||
@@ -29,92 +24,102 @@ os.makedirs(SAVE_DIR, exist_ok=True)
|
||||
|
||||
BANNER = """
|
||||
[bold magenta]╔═══════════════════════════════════════════════════════════╗
|
||||
║ FORTSCHRITTS-QUEST — Das automatische RPG ║
|
||||
║ "Wo du nichts tust und alles passiert" ║
|
||||
║ FORTSCHRITTS-QUEST — D&D 5e Automatisches RPG ║
|
||||
║ "Wo du nichts tust und alles passiert — mit Würfeln" ║
|
||||
╚═══════════════════════════════════════════════════════════╝[/]
|
||||
"""
|
||||
|
||||
def create_character():
|
||||
"""Character-Erstellung mit Zufallsoption."""
|
||||
console.clear()
|
||||
console.print(BANNER)
|
||||
console.print("[bold]Character-Erstellung[/]\n")
|
||||
console.print("[bold]Character-Erstellung (D&D 5e)[/]\n")
|
||||
|
||||
# Name
|
||||
name = console.input("[cyan]Name deines Helden (oder Enter für Zufall): [/]").strip()
|
||||
name = console.input("[cyan]Name deines Helden (Enter=Zufall): [/]").strip()
|
||||
if not name:
|
||||
name = random.choice(["Günther", "Brigitte", "Klaus-Dieter", "Waltraud", "Herbert", "Sieglinde", "Wolfgang", "Margot"])
|
||||
name = random.choice(["Aldric", "Lyanna", "Thorin", "Seraphina", "Gunnar", "Morgaine", "Kael", "Brienna"])
|
||||
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]
|
||||
for i, r in enumerate(DND_RACES):
|
||||
boni = DND_RACES[r]["boni"]
|
||||
bonus_str = ", ".join(f"+{v} {k}" for k, v in boni.items())
|
||||
console.print(f" [cyan]{i+1}[/] — {r} ([dim]{bonus_str}[/])")
|
||||
race_choice = console.input(f"\n[cyan]Rasse (1-{len(DND_RACES)}, Enter=Zufall): [/]").strip()
|
||||
if race_choice.isdigit() and 1 <= int(race_choice) <= len(DND_RACES):
|
||||
race = list(DND_RACES.keys())[int(race_choice) - 1]
|
||||
else:
|
||||
race = random.choice(RACES)
|
||||
race = random.choice(list(DND_RACES.keys()))
|
||||
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]
|
||||
for i, c in enumerate(DND_CLASSES):
|
||||
hd = DND_CLASSES[c]["hit_die"]
|
||||
primary = DND_CLASSES[c]["primary"]
|
||||
console.print(f" [cyan]{i+1}[/] — {c} ([dim]HD d{hd}, primär {primary}[/])")
|
||||
class_choice = console.input(f"\n[cyan]Klasse (1-{len(DND_CLASSES)}, Enter=Zufall): [/]").strip()
|
||||
if class_choice.isdigit() and 1 <= int(class_choice) <= len(DND_CLASSES):
|
||||
dnd_class = list(DND_CLASSES.keys())[int(class_choice) - 1]
|
||||
else:
|
||||
char_class = random.choice(CLASSES)
|
||||
console.print(f"[dim]Zufällige Klasse: {char_class}[/]")
|
||||
dnd_class = random.choice(list(DND_CLASSES.keys()))
|
||||
console.print(f"[dim]Zufällige Klasse: {dnd_class}[/]")
|
||||
|
||||
char = Character(name, race, char_class)
|
||||
char = DnDCharacter(name, race, dnd_class)
|
||||
|
||||
# Roll stats
|
||||
console.print(f"\n[bold]📊 Stats werden gewürfelt (4d6 drop lowest)...[/]")
|
||||
for ab in ["STR", "DEX", "CON", "INT", "WIS", "CHA"]:
|
||||
score = char.stats[ab]
|
||||
mod = modifier(score)
|
||||
mod_str = f"+{mod}" if mod >= 0 else str(mod)
|
||||
bar = "█" * (score // 2)
|
||||
console.print(f" [cyan]{ab}[/] {score:2d} {bar:<10} ({mod_str})")
|
||||
|
||||
# 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" Klasse: [bold]{char.dnd_class}[/] (Trefferwürfel d{char.hit_die})")
|
||||
console.print(f" HP: {char.hp}/{char.max_hp} | AC: {char.ac} | Prof: +{char.prof_bonus}")
|
||||
console.print(f" Angriff: +{char.attack_bonus} | Schaden: {char.weapon_dice}+{char.damage_bonus}")
|
||||
console.print(f" Rettungswürfe: {', '.join(char.save_prof)}")
|
||||
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."""
|
||||
def render_game(engine: GameEngine) -> Layout:
|
||||
c = engine.character
|
||||
|
||||
# === Character Panel ===
|
||||
char_table = Table(show_header=False, box=box.SIMPLE, padding=(0, 1), expand=True)
|
||||
char_table.add_column("Stat", style="cyan", width=10, no_wrap=True)
|
||||
char_table.add_column("Stat", style="cyan", width=8, no_wrap=True)
|
||||
char_table.add_column("Value", style="bold", no_wrap=False, overflow="fold")
|
||||
|
||||
char_table.add_row("Name", f"{c.name}")
|
||||
char_table.add_row("Name", c.name)
|
||||
char_table.add_row("Rasse", c.race)
|
||||
char_table.add_row("Klasse", c.char_class)
|
||||
char_table.add_row("Klasse", c.dnd_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("AC", str(c.ac))
|
||||
char_table.add_row("Prof", f"+{c.prof_bonus}")
|
||||
char_table.add_row("ATK", f"+{c.attack_bonus}")
|
||||
char_table.add_row("DMG", f"{c.weapon_dice}+{c.damage_bonus}")
|
||||
char_table.add_row("Gold", f"{c.gold}G")
|
||||
|
||||
# Stats row
|
||||
stats_str = " ".join(f"{k}:{c.stats[k]}" for k in ["STR","DEX","CON","INT","WIS","CHA"])
|
||||
char_table.add_row("Stats", stats_str)
|
||||
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))
|
||||
char_table.add_row("Crits", f"✅{c.crit_hits} ❌{c.crit_misses} 🎯{c.nat_20s}")
|
||||
|
||||
# === Equipment Panel ===
|
||||
equip_table = Table(show_header=False, box=box.SIMPLE, padding=(0, 1), title="[bold]Ausrüstung[/]", expand=True)
|
||||
equip_table.add_column("Slot", style="cyan", width=10, no_wrap=True)
|
||||
equip_table.add_column("Item", no_wrap=False, overflow="fold")
|
||||
|
||||
slot_names = {"weapon": "Waffe", "armor": "Rüstung", "helmet": "Helm", "boots": "Stiefel", "accessory": "Accessoir."}
|
||||
slot_names = {"weapon": "Waffe", "armor": "Rüstung", "shield": "Schild", "helmet": "Helm", "boots": "Stiefel", "accessory": "Accessoir."}
|
||||
for slot, name in slot_names.items():
|
||||
item = c.equipped[slot]
|
||||
item = c.equipped.get(slot)
|
||||
equip_table.add_row(name, str(item) if item else "[dim]leer[/]")
|
||||
|
||||
# === Quest Panel ===
|
||||
@@ -122,65 +127,69 @@ def render_game(engine: GameEngine) -> Panel:
|
||||
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)
|
||||
# === Event Log ===
|
||||
event_text = Text()
|
||||
for entry in engine.event_log:
|
||||
t = Text.from_markup(entry)
|
||||
event_text.append(t)
|
||||
event_text.append("\n")
|
||||
|
||||
# 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:
|
||||
if isinstance(entry, str):
|
||||
t = Text.from_markup(entry)
|
||||
log_text.append(t)
|
||||
log_text.append("\n")
|
||||
|
||||
# === Loot Log Panel ===
|
||||
# === Loot Log ===
|
||||
loot_text = Text()
|
||||
for entry in engine.loot_log:
|
||||
if isinstance(entry, str):
|
||||
t = Text.from_markup(entry)
|
||||
loot_text.append(t)
|
||||
loot_text.append("\n")
|
||||
t = Text.from_markup(entry)
|
||||
loot_text.append(t)
|
||||
loot_text.append("\n")
|
||||
|
||||
# === Location ===
|
||||
loc_text = f"🌍 {engine.current_location}"
|
||||
# === Dice Log (Würfel-Fenster) ===
|
||||
dice_text = Text()
|
||||
for entry in engine.dice_log.get_all():
|
||||
t = Text.from_markup(entry)
|
||||
dice_text.append(t)
|
||||
dice_text.append("\n")
|
||||
|
||||
# === HP/XP Bars ===
|
||||
hp_pct = c.hp / c.max_hp
|
||||
xp_pct = c.xp / c.xp_to_next if c.xp_to_next > 0 else 0
|
||||
bar_len = 20
|
||||
hp_bar = "█" * int(hp_pct * bar_len) + "░" * (bar_len - int(hp_pct * bar_len))
|
||||
xp_bar = "█" * int(xp_pct * bar_len) + "░" * (bar_len - int(xp_pct * bar_len))
|
||||
|
||||
# === Layout ===
|
||||
layout = Layout()
|
||||
|
||||
# Top: Title bar
|
||||
layout.split_column(
|
||||
Layout(Panel(f"[bold magenta]FORTSCHRITTS-QUEST[/] — {c.name} der {c.race} {c.char_class}",
|
||||
Layout(Panel(f"[bold magenta]FORTSCHRITTS-QUEST D&D 5e[/] — {c.name}, {c.race} {c.dnd_class} (Stufe {c.level})",
|
||||
style="magenta"), size=3),
|
||||
Layout(name="main"),
|
||||
Layout(name="bottom", size=8),
|
||||
Layout(name="bottom", size=6),
|
||||
)
|
||||
|
||||
# Main: left=char/equip, center=log, right=quest/stats
|
||||
# Main: left=char, center=eventlog+dice, right=equip+loot+quest
|
||||
layout["main"].split_row(
|
||||
Layout(Panel(char_table, title="[bold]Character[/]"), name="left", size=35),
|
||||
Layout(Panel(log_text, title="[bold]Ereignis-Log[/]"), name="center"),
|
||||
Layout(name="center"),
|
||||
Layout(name="right", size=40),
|
||||
)
|
||||
|
||||
# Center: event log on top, dice log on bottom
|
||||
layout["center"].split_column(
|
||||
Layout(Panel(event_text, title="[bold]Ereignis-Log[/]"), name="events"),
|
||||
Layout(Panel(dice_text, title="[bold yellow]🎲 Würfel[/]"), name="dice", size=12),
|
||||
)
|
||||
|
||||
# Right: equip, loot, quest
|
||||
layout["right"].split_column(
|
||||
Layout(Panel(equip_table), name="equip"),
|
||||
Layout(Panel(loot_text, title="[bold]Loot-Log[/]"), name="loot"),
|
||||
Layout(Panel(quest_text, title="[bold]Aktuelle Quest[/]"), name="quest"),
|
||||
)
|
||||
|
||||
# Bottom: bars + location
|
||||
# Bottom: bars
|
||||
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}")
|
||||
bars.append(f"HP [{hp_bar}] {c.hp}/{c.max_hp} ", style="red")
|
||||
bars.append(f"XP [{xp_bar}] {c.xp}/{c.xp_to_next} ", style="green")
|
||||
bars.append(f"🌍 {engine.current_location} ", style="blue")
|
||||
bars.append(f"Schritte: {c.steps}", style="dim")
|
||||
|
||||
layout["bottom"].update(Panel(bars, style="dim"))
|
||||
|
||||
@@ -188,42 +197,31 @@ def render_game(engine: GameEngine) -> Panel:
|
||||
|
||||
|
||||
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()
|
||||
|
||||
# Check for quest completion pause
|
||||
quest_pause = False
|
||||
for ev in result.get("events", []):
|
||||
if isinstance(ev, tuple) and len(ev) > 2 and ev[0] == "quest_done" and ev[2]:
|
||||
quest_pause = True
|
||||
|
||||
# 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)
|
||||
speed = max(0.5, 2.0 - engine.character.level * 0.02)
|
||||
if quest_pause:
|
||||
time.sleep(2.0) # Pause after quest completion
|
||||
time.sleep(3.0)
|
||||
else:
|
||||
time.sleep(speed)
|
||||
|
||||
@@ -232,7 +230,7 @@ def run_game(engine: GameEngine, save_path: str):
|
||||
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...[/]")
|
||||
console.print("[dim]Enter zum Beenden...[/]")
|
||||
input()
|
||||
|
||||
|
||||
@@ -242,7 +240,6 @@ def main():
|
||||
|
||||
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", ""):
|
||||
@@ -253,7 +250,6 @@ def main():
|
||||
run_game(engine, save_path)
|
||||
return
|
||||
|
||||
# New game
|
||||
char = create_character()
|
||||
engine = GameEngine(char)
|
||||
run_game(engine, save_path)
|
||||
|
||||
Reference in New Issue
Block a user