diff --git a/src/dnd.py b/src/dnd.py index c4a89e1..c66aec3 100644 --- a/src/dnd.py +++ b/src/dnd.py @@ -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 diff --git a/src/engine.py b/src/engine.py index b85ca49..ae66c46 100644 --- a/src/engine.py +++ b/src/engine.py @@ -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 \ No newline at end of file diff --git a/src/main.py b/src/main.py index 752aa30..0c393d8 100644 --- a/src/main.py +++ b/src/main.py @@ -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"))