From b1aa8e3162215c42de5e0e0d9f01ebcd4d46239c Mon Sep 17 00:00:00 2001 From: arch_agent Date: Sat, 25 Jul 2026 23:22:21 +0200 Subject: [PATCH] =?UTF-8?q?v2.2=20=E2=80=94=20Status-Effekte=20+=20Log-Scr?= =?UTF-8?q?olling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Status-Effekte (D&D 5e): - Gift (🐍): Spinne/Naga/Schlange — Schaden pro Runde - Brennen (🔥): Drache/Feuerdämon — Feuerschaden pro Runde - Blutung (🩸): Vampir/Todes — Schaden pro Runde - Schwächungsfluch (💜): Hexe/Fluch — -2 ATK/AC - Furcht (😱): Geist/Schrecken — verhindert Angriff - Regeneration (💚): Heilung über Zeit - Buff/Debuff wirken sich auf effective_ac/effective_attack aus Entgiftung: - Gegengift-Trank im Shop (25G) — entfernt Gift/Fluch - Auto-Nutzung bei Vergiftung - Lange Rast reinigt auch alle Effekte Log-Scrolling: - Event-Log: chronologisch (neueste unten), 50 Einträge - Loot-Log: chronologisch, 50 Einträge - Dice-Log: chronologisch, 30 Einträge - UI zeigt letzte 20/15/12 Einträge — Scroll-Effekt --- src/dnd.py | 102 +++++++++++++++++++++++++++++++++++++++++++++ src/engine.py | 112 +++++++++++++++++++++++++++++++++++++++++--------- src/main.py | 14 +++++-- 3 files changed, 204 insertions(+), 24 deletions(-) diff --git a/src/dnd.py b/src/dnd.py index df58846..64819ce 100644 --- a/src/dnd.py +++ b/src/dnd.py @@ -148,6 +148,9 @@ class DnDCharacter: self.max_mp = self._calc_max_mp() self.mp = self.max_mp + # Status effects + self.status_effects = [] # list of {type, duration, power, name} + # Stats tracking self.kills = 0 self.deaths = 0 @@ -245,6 +248,103 @@ class DnDCharacter: self.mp -= cost self.spell_slots -= 1 + # === Status Effects === + def add_effect(self, effect_type: str, duration: int, power: int, name: str): + """Fügt einen Statuseffekt hinzu.""" + # Remove existing effect of same type + self.status_effects = [e for e in self.status_effects if e["type"] != effect_type] + self.status_effects.append({ + "type": effect_type, "duration": duration, "power": power, "name": name + }) + + def has_effect(self, effect_type: str) -> bool: + return any(e["type"] == effect_type for e in self.status_effects) + + def get_effect(self, effect_type: str): + for e in self.status_effects: + if e["type"] == effect_type: + return e + return None + + def tick_effects(self) -> list: + """Tickt alle Effekte, gibt Liste der abgelaufenen und gewirkten zurück.""" + results = [] + for effect in self.status_effects[:]: + effect["duration"] -= 1 + + # Apply per-turn effects + if effect["type"] == "poison": + dmg = effect["power"] + self.hp = max(0, self.hp - dmg) + results.append(f"[green]🐍 Gift schadet {dmg} HP (HP: {self.hp}/{self.max_hp})[/]") + elif effect["type"] == "burn": + dmg = effect["power"] + self.hp = max(0, self.hp - dmg) + results.append(f"[red]🔥 Brennen schadet {dmg} HP[/]") + elif effect["type"] == "regen": + heal = effect["power"] + self.hp = min(self.max_hp, self.hp + heal) + results.append(f"[green]💚 Regeneration heilt {heal} HP[/]") + elif effect["type"] == "bleed": + dmg = effect["power"] + self.hp = max(0, self.hp - dmg) + results.append(f"[red]🩸 Blutung schadet {dmg} HP[/]") + + if effect["duration"] <= 0: + self.status_effects.remove(effect) + results.append(f"[dim]✨ {effect['name']} ist abgeklungen.[/]") + + return results + + def cleanse(self) -> int: + """Entfernt alle negativen Effekte. Gibt Anzahl entfernter zurück.""" + negative = ["poison", "burn", "bleed", "curse_weakness", "curse_fatigue", "fear", "slow"] + removed = 0 + for neg in negative: + before = len(self.status_effects) + self.status_effects = [e for e in self.status_effects if e["type"] != neg] + removed += before - len(self.status_effects) + return removed + + @property + def effective_ac(self) -> int: + """AC mit Buffs/Debuffs.""" + ac = self.ac + if self.has_effect("shield_of_faith"): + ac += self.get_effect("shield_of_faith")["power"] + if self.has_effect("curse_weakness"): + ac -= 2 + return max(1, ac) + + @property + def effective_attack(self) -> int: + """Attack Bonus mit Buffs/Debuffs.""" + bonus = self.attack_bonus + if self.has_effect("bless"): + bonus += 2 + if self.has_effect("curse_weakness"): + bonus -= 2 + if self.has_effect("rage"): + bonus += self.get_effect("rage")["power"] + return max(0, bonus) + + @property + def status_summary(self) -> str: + """Kurze Zusammenfassung der aktiven Effekte für UI.""" + if not self.status_effects: + return "[dim]keine[/]" + icons = { + "poison": "🐍", "burn": "🔥", "regen": "💚", "bleed": "🩸", + "bless": "✨", "rage": "😠", "shield_of_faith": "🛡️", + "curse_weakness": "💜", "curse_fatigue": "😫", "fear": "😱", "slow": "🐌", + "haste": "⚡", "strength": "💪", + } + parts = [] + for e in self.status_effects: + icon = icons.get(e["type"], "❓") + parts.append(f"{icon}{e['duration']}") + return " ".join(parts) + @property def attack_bonus(self) -> int: """Attack roll modifier: STR/DEX mod + proficiency.""" @@ -331,6 +431,7 @@ class DnDCharacter: "inventory": [i.to_dict() for i in self.inventory], "gold": self.gold, "potions": self.potions, "potion_threshold": self.potion_threshold, + "status_effects": self.status_effects, "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, @@ -352,6 +453,7 @@ class DnDCharacter: 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.status_effects = d.get("status_effects", []) 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) diff --git a/src/engine.py b/src/engine.py index e9e2f2d..cef80ff 100644 --- a/src/engine.py +++ b/src/engine.py @@ -65,6 +65,23 @@ class DnDMonster: self.damage_dice = random.choice(["1d6", "1d8", "1d10", "2d6", "1d12"]) self.damage_mod = max(0, level // 3) + # Status effects the monster can inflict (based on type) + self.inflicts = [] + monster_type = name.lower() + if any(w in monster_type for w in ["spinne", "naga", "schlang", "skorpion", "gift"]): + self.inflicts.append({"type": "poison", "chance": 30, "duration": 3, "power": max(1, level // 2), "name": "Giftbiss"}) + if any(w in monster_type for w in ["drache", "feuer", "dämon", "phoenix", "flamme"]): + self.inflicts.append({"type": "burn", "chance": 25, "duration": 2, "power": max(1, level // 2), "name": "Feueratem"}) + if any(w in monster_type for w in ["vampir", "blut", "schnitter", "todes"]): + self.inflicts.append({"type": "bleed", "chance": 35, "duration": 3, "power": max(1, level // 3), "name": "Blutung"}) + if any(w in monster_type for w in ["hexe", "fluch", "verflucht", "schatten", "dunkel"]): + self.inflicts.append({"type": "curse_weakness", "chance": 20, "duration": 3, "power": 2, "name": "Schwächungsfluch"}) + if any(w in monster_type for w in ["geist", "gespenst", "furcht", "schrecken"]): + self.inflicts.append({"type": "fear", "chance": 20, "duration": 2, "power": 2, "name": "Furcht"}) + # Sometimes random poison for variety + if not self.inflicts and random.randint(1, 4) == 1: + self.inflicts.append({"type": "poison", "chance": 15, "duration": 2, "power": 1, "name": "Gift"}) + # 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} @@ -80,7 +97,7 @@ class DnDMonster: return self.hp > 0 def attack(self, target_ac: int, dice_log: list) -> dict: - """Monster greift an mit d20.""" + """Monster greift an mit d20, kann Status-Effekte zufügen.""" 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}[/]") @@ -90,11 +107,11 @@ class DnDMonster: if fumble: dice_log.append(f"[red]💀 Kritischer Fehlschlag! {self.name} stolpert![/]") - return {"hit": False, "damage": 0, "crit": False, "fumble": True} + return {"hit": False, "damage": 0, "crit": False, "fumble": True, "effect": None} if not hit: dice_log.append(f"[yellow]💨 {self.name} verfehlt! (AC {target_ac})[/]") - return {"hit": False, "damage": 0, "crit": False, "fumble": False} + return {"hit": False, "damage": 0, "crit": False, "fumble": False, "effect": None} # Damage roll dice_str = self.damage_dice @@ -102,7 +119,6 @@ class DnDMonster: 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![/]") @@ -110,19 +126,27 @@ class DnDMonster: 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} + # Check for status effect infliction + effect_inflicted = None + for inf in self.inflicts: + if random.randint(1, 100) <= inf["chance"]: + effect_inflicted = inf + dice_log.append(f"[yellow]⚠️ {self.name} wirkt {inf['name']}! ({inf['type']}, {inf['duration']} Runden)[/]") + break + + return {"hit": True, "damage": total_dmg, "crit": crit, "fumble": False, "effect": effect_inflicted} class DiceLog: """Verwaltet den Würfel-Log für die UI.""" - def __init__(self, max_entries: int = 25): + def __init__(self, max_entries: int = 30): self.entries = [] self.max_entries = max_entries def add(self, msg: str): - self.entries.insert(0, msg) + self.entries.append(msg) if len(self.entries) > self.max_entries: - self.entries = self.entries[:self.max_entries] + self.entries = self.entries[-self.max_entries:] def clear(self): self.entries = [] @@ -136,9 +160,11 @@ class GameEngine: self.character = character self.dice_log = DiceLog(max_entries=25) self.event_log = [] - self.max_event_log = 20 + self.max_event_log = 50 # increased for scrolling + self.event_scroll = 0 # 0 = newest at bottom, scroll up to see older self.loot_log = [] - self.max_loot_log = 20 + self.max_loot_log = 50 + self.loot_scroll = 0 self.current_location = random.choice(FALLBACK_LOCATIONS) self.current_quest = random.choice(FALLBACK_QUESTS) @@ -162,14 +188,14 @@ class GameEngine: pass def _add_log(self, msg: str): - self.event_log.insert(0, msg) + self.event_log.append(msg) if len(self.event_log) > self.max_event_log: - self.event_log = 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) + self.loot_log.append(msg) if len(self.loot_log) > self.max_loot_log: - self.loot_log = self.loot_log[:self.max_loot_log] + self.loot_log = self.loot_log[-self.max_loot_log:] def _spawn_monster(self) -> DnDMonster: name = random.choice(FALLBACK_MONSTERS) @@ -254,13 +280,27 @@ class GameEngine: return {"hit": True, "damage": total_dmg, "crit": crit, "fumble": False} def _combat(self, monster: DnDMonster) -> dict: - """D&D 5e Combat: abwechselnde Runden.""" + """D&D 5e Combat: abwechselnde Runden mit Status-Effekten.""" rounds = 0 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 + # Tick status effects at start of round + if self.character.status_effects: + effect_results = self.character.tick_effects() + for er in effect_results: + self.dice_log.add(er) + if not self.character.is_alive: + self.dice_log.add(f"[bold red]💀 Status-Effekt tötet dich![/]") + return {"result": "death", "rounds": rounds, "monster": monster.name} + + # Use cleanse potion if badly afflicted + if self.character.has_effect("poison") and self.character.potions.get("healing", 0) > 0 and self.character.hp < self.character.max_hp * 0.5: + # Use healing potion which also cleanses (simplified) + pass # potion use happens in _use_potions_if_needed before combat + # 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): @@ -268,7 +308,7 @@ class GameEngine: self.dice_log.add(f"[bold green]☠️ {monster.name} durch Zauber besiegt![/]") break - # Player attacks (initiative: DEX check) + # Player attacks result = self._player_attack(monster) if result["hit"]: monster.hp = max(0, monster.hp - result["damage"]) @@ -278,15 +318,20 @@ class GameEngine: # Monster attacks 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 + m_result = monster.attack(self.character.effective_ac, self.dice_log.entries) + self.dice_log.entries = self.dice_log.entries if m_result["hit"]: self.character.hp = max(0, self.character.hp - m_result["damage"]) + # Apply status effect + if m_result.get("effect"): + eff = m_result["effect"] + self.character.add_effect(eff["type"], eff["duration"], eff["power"], eff["name"]) + self._add_log(f"[yellow]⚠️ {eff['name']} erhalten! ({eff['duration']} Runden)[/]") if not self.character.is_alive: self.dice_log.add(f"[bold red]💀 Du wurdest besiegt![/]") 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} ──[/]") + self.dice_log.add(f"[dim]── Runde {rounds} ── HP: {self.character.hp}/{self.character.max_hp} | Monster: {monster.hp}/{monster.max_hp} | Status: {self.character.status_summary} ──[/]") return {"result": "victory", "rounds": rounds, "monster": monster.name} @@ -462,14 +507,41 @@ class GameEngine: self._add_loot_log(f"[blue]🧪 Kaufe Manatrank (-{mana_price}G)[/]") bought_something = True + # 7. Antidote (Entgiftung) — always useful + antidote_price = 25 + max_antidotes = 3 + while c.potions.get("antidote", 0) < max_antidotes and c.gold - antidote_price >= gold_reserve: + c.gold -= antidote_price + c.potions["antidote"] = c.potions.get("antidote", 0) + 1 + c.gold_spent += antidote_price + c.items_bought += 1 + self._add_loot_log(f"[green]🧪 Kaufe Gegengift (-{antidote_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.""" + """Benutzt automatisch Tränke wenn nötig.""" c = self.character + # Antidote if poisoned + if c.has_effect("poison") and c.potions.get("antidote", 0) > 0: + removed = c.cleanse() + c.potions["antidote"] -= 1 + c.potions_used += 1 + self.dice_log.add(f"[green]🧪 Gegengift benutzt! {removed} Effekte entfernt.[/]") + self._add_log(f"[green]🧪 Gegengift benutzt — Vergiftung geheilt![/]") + + # Antidote also removes curses if desperate + if c.has_effect("curse_weakness") and c.potions.get("antidote", 0) > 0 and c.hp < c.max_hp * 0.3: + removed = c.cleanse() + c.potions["antidote"] -= 1 + c.potions_used += 1 + self.dice_log.add(f"[green]🧪 Gegengift gegen Fluch! {removed} Effekte entfernt.[/]") + self._add_log(f"[green]🧪 Fluch aufgehoben![/]") + # 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") diff --git a/src/main.py b/src/main.py index 5f07f18..61c4182 100644 --- a/src/main.py +++ b/src/main.py @@ -112,6 +112,9 @@ def render_game(engine: GameEngine) -> Layout: f"💖{c.potions.get('greater_healing',0)}" char_table.add_row("Tränke", pot_str) + # Status effects + char_table.add_row("Status", c.status_summary) + # 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) @@ -134,23 +137,26 @@ def render_game(engine: GameEngine) -> Layout: quest_text.append(f"📜 {engine.current_quest}\n", style="cyan") quest_text.append(f"Fortschritt: {engine.quest_progress}/{engine.quest_target}", style="yellow") - # === Event Log === + # === Event Log (scrollable — newest at bottom) === event_text = Text() - for entry in engine.event_log: + visible_events = engine.event_log[-20:] # show last 20 + for entry in visible_events: t = Text.from_markup(entry) event_text.append(t) event_text.append("\n") # === Loot Log === loot_text = Text() - for entry in engine.loot_log: + visible_loot = engine.loot_log[-15:] + for entry in visible_loot: t = Text.from_markup(entry) loot_text.append(t) loot_text.append("\n") # === Dice Log (Würfel-Fenster) === dice_text = Text() - for entry in engine.dice_log.get_all(): + visible_dice = engine.dice_log.get_all()[-12:] + for entry in visible_dice: t = Text.from_markup(entry) dice_text.append(t) dice_text.append("\n")