v2.1 — Shop, Tränke, Spells, Weg-Progress

Shop-System (klassenspezifisch):
- Waffen-Upgrades: Langschwert, Magierstab, Dolch etc. pro Klasse
- Rüstung-Upgrades: Kettenhemd, Magierrobe etc.
- Schilde für Tanks (Kämpfer/Paladin)
- Heiltränke (2d4+2), Große Heiltränke (4d4+4) ab Stufe 5
- Manatränke für Caster
- 10% Gold-Reserve wird immer behalten

Trank-System:
- Auto-Benutzung bei HP < 40% (Heiltrank) / HP < 20% (Großer)
- Manatrank bei MP < 30% für Caster
- Würfel-Log zeigt jeden Trankwurf

Zauber-System:
- Caster wirken Spells in Runde 1 (50% Chance)
- Feuerball, Verdammnisstrahl, Göttlicher Schlag etc.
- Kosten 2 MP + 1 Spell Slot
- 3d6 + Proficiency Bonus Schaden

Weg-Progress:
- Travel-Phasen zwischen Quests mit Progress-Balken
- 🚶 [████░░░░] Zielort im Bottom-Panel
- Shop-Besuch bei Ankunft
This commit is contained in:
arch_agent
2026-07-25 23:07:44 +02:00
parent f6105c33b3
commit 36cb713b86
3 changed files with 288 additions and 14 deletions
+61
View File
@@ -140,6 +140,14 @@ class DnDCharacter:
self.inventory = []
self.gold = roll(6, 3)["total"] * 10 # Starting gold 3d6 * 10
# Potions
self.potions = {"healing": 2, "mana": 0, "greater_healing": 0, "strength": 0}
self.potion_threshold = 0.4 # Use healing potion when HP < 40% of max
# MP / Spell points
self.max_mp = self._calc_max_mp()
self.mp = self.max_mp
# Stats tracking
self.kills = 0
self.deaths = 0
@@ -148,6 +156,9 @@ class DnDCharacter:
self.crit_hits = 0
self.crit_misses = 0
self.nat_20s = 0
self.potions_used = 0
self.gold_spent = 0
self.items_bought = 0
@property
def ac(self) -> int:
@@ -161,6 +172,47 @@ class DnDCharacter:
return max(0, self.level) # simplified: 1 slot per level
return 0
def _calc_max_mp(self) -> int:
"""MP für Zauber — basierend auf Klasse und INT/WIS/CHA."""
if self.dnd_class in ("Magier", "Hexenmeister", "Barde", "Kleriker", "Druide"):
primary = DND_CLASSES.get(self.dnd_class, {}).get("primary", "INT")
return 10 + self.level * 2 + modifier(self.stats.get(primary, 10)) * 2
return 0
def use_potion(self, potion_type: str) -> int:
"""Benutzt einen Trank. Gibt geheilte Menge zurück, 0 wenn keine da."""
if self.potions.get(potion_type, 0) <= 0:
return 0
self.potions[potion_type] -= 1
self.potions_used += 1
if potion_type == "healing":
heal = roll(4, 2, 2)["total"] # 2d4+2
self.hp = min(self.max_hp, self.hp + heal)
return heal
elif potion_type == "greater_healing":
heal = roll(8, 4, 4)["total"] # 4d4+4
self.hp = min(self.max_hp, self.hp + heal)
return heal
elif potion_type == "mana":
heal = roll(4, 2, 2)["total"]
self.mp = min(self.max_mp, self.mp + heal)
return heal
elif potion_type == "strength":
# Temporary buff: +2 to next attack (simplified — just return 0)
return 0
return 0
def needs_healing(self) -> bool:
return self.hp < self.max_hp * self.potion_threshold
def can_cast_spell(self, cost: int = 1) -> bool:
return self.mp >= cost and self.spell_slots > 0
def cast_spell(self, cost: int = 1):
self.mp -= cost
self.spell_slots -= 1
@property
def attack_bonus(self) -> int:
"""Attack roll modifier: STR/DEX mod + proficiency."""
@@ -211,6 +263,8 @@ class DnDCharacter:
self.hp = self.max_hp
self.prof_bonus = self._calc_prof_bonus()
self.spell_slots = self._calc_spell_slots()
self.max_mp = self._calc_max_mp()
self.mp = self.max_mp
# ASI: +2 to one stat every 4 levels
if self.level % 4 == 0:
best_stat = max(self.stats, key=lambda k: self.stats[k])
@@ -238,13 +292,16 @@ class DnDCharacter:
"hit_die": self.hit_die, "save_prof": self.save_prof,
"weapon_dice": self.weapon_dice,
"max_hp": self.max_hp, "hp": self.hp,
"max_mp": self.max_mp, "mp": self.mp,
"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,
"potions": self.potions, "potion_threshold": self.potion_threshold,
"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,
"potions_used": self.potions_used, "gold_spent": self.gold_spent, "items_bought": self.items_bought,
}
@classmethod
@@ -255,13 +312,17 @@ class DnDCharacter:
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.max_mp = d.get("max_mp", 0); c.mp = d.get("mp", 0)
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.potions = d.get("potions", {"healing": 2, "mana": 0, "greater_healing": 0, "strength": 0})
c.potion_threshold = d.get("potion_threshold", 0.4)
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"]
c.potions_used = d.get("potions_used", 0); c.gold_spent = d.get("gold_spent", 0); c.items_bought = d.get("items_bought", 0)
return c
+210 -11
View File
@@ -145,11 +145,15 @@ class GameEngine:
self.quest_progress = 0
self.quest_target = random.randint(3, 8)
# Travel progress
self.travel_progress = 0.0 # 0.0 to 1.0
self.is_traveling = False
self.travel_destination = ""
self._fill_cache()
def _fill_cache(self):
"""Füllt Cache initial."""
pass # LLM wird asynchron in refill_async geladen
pass
def _add_log(self, msg: str):
self.event_log.insert(0, msg)
@@ -251,6 +255,13 @@ class GameEngine:
while monster.is_alive and self.character.is_alive:
rounds += 1
# Caster tries spell first (50% chance per round)
if rounds == 1 and self.character.max_mp > 0 and random.random() < 0.5:
if self._cast_spell_if_possible(monster):
if not monster.is_alive:
self.dice_log.add(f"[bold green]☠️ {monster.name} durch Zauber besiegt![/]")
break
# Player attacks (initiative: DEX check)
result = self._player_attack(monster)
if result["hit"]:
@@ -288,16 +299,17 @@ class GameEngine:
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()
self.character.mp = self.character.max_mp
self._add_log(f"[blue]🌍 Du machst dich auf den Weg...[/]")
self.current_location = random.choice(FALLBACK_LOCATIONS)
self._add_log(f"[blue]🌍 Ankunft in: {self.current_location}[/]")
# Start travel to new location
self.is_traveling = True
self.travel_progress = 0.0
self.travel_destination = random.choice(FALLBACK_LOCATIONS)
self._add_log(f"[blue]🌍 Du machst dich auf den Weg nach {self.travel_destination}...[/]")
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 _auto_equip(self, item: Item):
slot = None
@@ -334,22 +346,203 @@ class GameEngine:
if sold > 0:
self._add_loot_log(f"[dim]💰 {sold} Schrott verkauft (+{total_gold}G)[/]")
def _visit_shop(self):
"""Charakter kauft Ausrüstung und Tränke im Shop (klassenspezifisch)."""
c = self.character
bought_something = False
is_caster = c.dnd_class in ("Magier", "Hexenmeister", "Barde", "Kleriker", "Druide")
is_tank = c.dnd_class in ("Kämpfer", "Paladin")
# 1. Buy better weapon first (most impact)
weapon_price = 30 + c.level * 15
current_weapon = c.equipped.get("weapon")
desired_power = c.level + 2
if c.gold >= weapon_price and (not current_weapon or current_weapon.power < desired_power):
weapon_names = {
"Kämpfer": ["Langschwert", "Kampfaxt", "Streitkolben"],
"Paladin": ["Langschwert", "Heilige Klinge", "Göttlicher Hammer"],
"Schurke": ["Dolch", "Kurzschwert", "Wurfmesser"],
"Mönch": ["Kampfstab", "Waffenlose Faust", "Nunchaku"],
"Magier": ["Magierstab", "Zauberfokus", "Kristallkugel"],
"Hexenmeister": ["Verdammnisstab", "Schattenfokus"],
"Barde": ["Rapier", "Laute der Macht", "Elfenklinge"],
"Kleriker": ["Streitkolben", "Heiliges Symbol", "Göttlicher Schild"],
"Druide": ["Eichenstab", "Dornenpeitsche", "Naturfokus"],
"Waldläufer": ["Langbogen", "Kurzschwert", "Jagdspeer"],
}
names = weapon_names.get(c.dnd_class, ["Langschwert"])
rarity = "selten" if c.level >= 5 else "gewöhnlich"
w_name = random.choice(names)
w = Item(w_name, "weapon", desired_power, weapon_price // 2, rarity)
c.gold -= weapon_price
c.gold_spent += weapon_price
c.items_bought += 1
if current_weapon:
c.inventory.append(current_weapon)
c.equipped["weapon"] = w
self._add_loot_log(f"[cyan]⚔️ Kaufe {w} (-{weapon_price}G)[/]")
bought_something = True
# 2. Buy armor
armor_price = 40 + c.level * 10
current_armor = c.equipped.get("armor")
desired_armor = c.level // 2 + 1
if c.gold >= armor_price and (not current_armor or current_armor.power < desired_armor):
armor_names = {
"Kämpfer": ["Kettenhemd", "Plattenpanzer", "Schwere Rüstung"],
"Paladin": ["Plattenpanzer", "Heilige Rüstung", "Göttlicher Harnisch"],
"Schurke": ["Lederrüstung", "Schattenmantel", "Wendiger Umhang"],
"Magier": ["Magierrobe", "Zauberergewand", "Arkanes Gewand"],
"default": ["Lederrüstung", "Kettenhemd", "Schuppengewand"],
}
names = armor_names.get(c.dnd_class, armor_names["default"])
a_name = random.choice(names)
a = Item(a_name, "armor", desired_armor, armor_price // 2, "gewöhnlich")
c.gold -= armor_price
c.gold_spent += armor_price
c.items_bought += 1
c.armor_bonus = desired_armor
if current_armor:
c.inventory.append(current_armor)
c.equipped["armor"] = a
self._add_loot_log(f"[cyan]🛡️ Kaufe {a} (-{armor_price}G)[/]")
bought_something = True
# 3. Buy shield for tanks
if is_tank and not c.equipped.get("shield"):
shield_price = 25
if c.gold >= shield_price:
s = Item("Eisenschild", "shield", 2, 12, "gewöhnlich")
c.gold -= shield_price
c.gold_spent += shield_price
c.items_bought += 1
c.shield_bonus = 2
c.equipped["shield"] = s
self._add_loot_log(f"[cyan]🛡️ Kaufe {s} (-{shield_price}G)[/]")
bought_something = True
# 4. Buy healing potions (keep 10% gold reserve)
heal_price = 50
max_healing = 5 if is_tank else 3
gold_reserve = c.gold * 0.1
while c.potions["healing"] < max_healing and c.gold - heal_price >= gold_reserve:
c.gold -= heal_price
c.potions["healing"] += 1
c.gold_spent += heal_price
c.items_bought += 1
self._add_loot_log(f"[green]🧪 Kaufe Heiltrank (-{heal_price}G)[/]")
bought_something = True
# 5. Greater healing potions at higher levels
if c.level >= 5:
greater_price = 150
while c.potions["greater_healing"] < 2 and c.gold - greater_price >= gold_reserve:
c.gold -= greater_price
c.potions["greater_healing"] += 1
c.gold_spent += greater_price
c.items_bought += 1
self._add_loot_log(f"[green]🧪 Kaufe Großen Heiltrank (-{greater_price}G)[/]")
bought_something = True
# 6. Mana potions for casters
if is_caster:
mana_price = 50
while c.potions["mana"] < 3 and c.gold - mana_price >= gold_reserve:
c.gold -= mana_price
c.potions["mana"] += 1
c.gold_spent += mana_price
c.items_bought += 1
self._add_loot_log(f"[blue]🧪 Kaufe Manatrank (-{mana_price}G)[/]")
bought_something = True
if bought_something:
self._add_log(f"[yellow]🏪 Shop besucht — Ausrüstung und Tränke gekauft[/]")
self.dice_log.add(f"[yellow]🏪 Shop: {c.items_bought} Items, Rest-Gold: {c.gold}G[/]")
def _use_potions_if_needed(self):
"""Benutzt automatisch Heiltränke wenn HP niedrig ist."""
c = self.character
# Use greater healing first if very low
if c.hp < c.max_hp * 0.2 and c.potions.get("greater_healing", 0) > 0:
heal = c.use_potion("greater_healing")
self.dice_log.add(f"[green]🧪 Großer Heiltrank! 4d4+4 = {heal} HP geheilt (HP: {c.hp}/{c.max_hp})[/]")
self._add_log(f"[green]🧪 Großen Heiltrank benutzt (+{heal} HP)[/]")
# Regular healing potion
elif c.needs_healing() and c.potions.get("healing", 0) > 0:
heal = c.use_potion("healing")
self.dice_log.add(f"[green]🧪 Heiltrank! 2d4+2 = {heal} HP geheilt (HP: {c.hp}/{c.max_hp})[/]")
self._add_log(f"[green]🧪 Heiltrank benutzt (+{heal} HP)[/]")
# Mana potion for casters
if c.max_mp > 0 and c.mp < c.max_mp * 0.3 and c.potions.get("mana", 0) > 0:
heal = c.use_potion("mana")
self.dice_log.add(f"[blue]🧪 Manatrank! 2d4+2 = {heal} MP geheilt (MP: {c.mp}/{c.max_mp})[/]")
self._add_log(f"[blue]🧪 Manatrank benutzt (+{heal} MP)[/]")
def _cast_spell_if_possible(self, monster) -> bool:
"""Caster versucht einen Zauber zu wirken."""
c = self.character
if not c.can_cast_spell(2):
return False
spell_names = {
"Magier": ["Feuerball", "Magisches Geschoss", "Blitzschlag"],
"Hexenmeister": ["Verdammnisstrahl", "Schattenfluch", "Höllisches Feuer"],
"Barde": ["Spottlied", "Heilige Hymne", "Verwirrendes Lied"],
"Kleriker": ["Göttlicher Schlag", "Heilige Flamme", "Bannstrahl"],
"Druide": ["Frostgriff", "Dornenranke", "Blitzwelle"],
}
spells = spell_names.get(c.dnd_class, ["Arkane Explosion"])
spell = random.choice(spells)
# Spell damage: higher dice
spell_dmg = roll(6, 3, c.prof_bonus)["total"]
c.cast_spell(2)
self.dice_log.add(f"[magenta]✨ {c.name} wirkt {spell}! 3d6+{c.prof_bonus} = {spell_dmg} Schadenszauber[/]")
monster.hp = max(0, monster.hp - spell_dmg)
self._add_log(f"[magenta]✨ {c.name} wirkt {spell}! ({spell_dmg} Schaden)[/]")
return True
def step(self) -> dict:
self.character.steps += 1
events = []
# Random travel
if random.randint(1, 50) == 1:
# === Travel phase ===
if self.is_traveling:
self.travel_progress += random.uniform(0.15, 0.35)
if self.travel_progress >= 1.0:
self.is_traveling = False
self.current_location = self.travel_destination
self.travel_progress = 0.0
self._add_log(f"[blue]🌍 Ankunft in: {self.current_location}[/]")
self._add_log(f"[cyan]📜 Neue Quest: {self.current_quest}[/]")
self._add_log(f"[dim]Ziel: Besiege {self.quest_target} Gegner[/]")
# Visit shop on arrival
self._visit_shop()
else:
# Still traveling, no combat this step
return {"events": events, "traveling": True}
# === Random travel between fights ===
if random.randint(1, 50) == 1 and not self.is_traveling:
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
# === Use potions if needed (before fight) ===
self._use_potions_if_needed()
# === 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
# === Fight ===
result = self._combat(monster)
if result["result"] == "victory":
@@ -418,6 +611,9 @@ class GameEngine:
"event_log": self.event_log,
"loot_log": self.loot_log,
"dice_log": self.dice_log.get_all(),
"is_traveling": self.is_traveling,
"travel_progress": self.travel_progress,
"travel_destination": self.travel_destination,
}
def save(self, path: str):
@@ -439,4 +635,7 @@ class GameEngine:
engine.event_log = d.get("event_log", [])
engine.loot_log = d.get("loot_log", [])
engine.dice_log.entries = d.get("dice_log", [])
engine.is_traveling = d.get("is_traveling", False)
engine.travel_progress = d.get("travel_progress", 0.0)
engine.travel_destination = d.get("travel_destination", "")
return engine
+17 -3
View File
@@ -99,12 +99,19 @@ def render_game(engine: GameEngine) -> Layout:
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}" if c.max_mp > 0 else "[dim]—[/]")
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")
# Potions
pot_str = f"❤️{c.potions.get('healing',0)} " \
f"{c.potions.get('mana',0)} " \
f"💖{c.potions.get('greater_healing',0)}"
char_table.add_row("Tränke", pot_str)
# 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)
@@ -184,12 +191,19 @@ def render_game(engine: GameEngine) -> Layout:
Layout(Panel(quest_text, title="[bold]Aktuelle Quest[/]"), name="quest"),
)
# Bottom: bars
# Bottom: bars + travel
bars = Text()
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")
if engine.is_traveling:
travel_len = 20
travel_filled = int(engine.travel_progress * travel_len)
travel_bar = "" * travel_filled + "" * (travel_len - travel_filled)
bars.append(f"🚶 [{travel_bar}] {engine.travel_destination}", style="blue")
else:
bars.append(f"🌍 {engine.current_location} ", style="blue")
bars.append(f"Schritte: {c.steps}", style="dim")
layout["bottom"].update(Panel(bars, style="dim"))