scripts/player_skills.pyΒΆ

Part of Dungeon Explorer.

  1"""Skill tree: unlock, cooldown, passive bonus tracking."""
  2
  3from pathlib import Path
  4
  5
  6class SkillTree:
  7    """Player skill tree with unlocking, cooldowns, and passive bonuses."""
  8
  9    def __init__(self, skill_defs: list[dict] | None = None):
 10        self._skills: dict[str, dict] = {}
 11        if skill_defs:
 12            for s in skill_defs:
 13                self._skills[s["id"]] = dict(s)
 14        self.unlocked: set[str] = set()
 15        self.skill_points: int = 0
 16        self.hotbar: list[str | None] = [None, None, None, None]
 17        self._cooldowns: dict[str, float] = {}
 18
 19    def load_from_toml(self, path: Path):
 20        """Load skill definitions from TOML file."""
 21        try:
 22            import tomllib
 23        except ImportError:
 24            import tomli as tomllib
 25        with open(path, "rb") as f:
 26            data = tomllib.load(f)
 27        for s in data.get("skills", []):
 28            self._skills[s["id"]] = dict(s)
 29
 30    def get_skill(self, skill_id: str) -> dict | None:
 31        return self._skills.get(skill_id)
 32
 33    def all_skills(self) -> list[dict]:
 34        return list(self._skills.values())
 35
 36    def branch_skills(self, branch: str) -> list[dict]:
 37        return [s for s in self._skills.values() if s.get("branch") == branch]
 38
 39    def can_unlock(self, skill_id: str) -> bool:
 40        skill = self._skills.get(skill_id)
 41        if not skill or skill_id in self.unlocked:
 42            return False
 43        if self.skill_points < skill.get("cost", 1):
 44            return False
 45        for prereq in skill.get("prerequisites", []):
 46            if prereq not in self.unlocked:
 47                return False
 48        return True
 49
 50    def unlock(self, skill_id: str) -> bool:
 51        if not self.can_unlock(skill_id):
 52            return False
 53        skill = self._skills[skill_id]
 54        self.skill_points -= skill.get("cost", 1)
 55        self.unlocked.add(skill_id)
 56        return True
 57
 58    def get_passive_bonuses(self) -> dict[str, float]:
 59        bonuses: dict[str, float] = {}
 60        for sid in self.unlocked:
 61            skill = self._skills.get(sid)
 62            if skill and skill.get("type") == "passive":
 63                for key, val in skill.get("stat_bonus", {}).items():
 64                    bonuses[key] = bonuses.get(key, 0) + val
 65        return bonuses
 66
 67    def has_passive(self, skill_id: str) -> bool:
 68        """Check if a passive skill is unlocked."""
 69        return skill_id in self.unlocked
 70
 71    def assign_hotbar(self, slot: int, entry: str | None) -> bool:
 72        """Assign a skill or item to a hotbar slot.
 73
 74        *entry* is a skill ID, an ``"item:<template_key>"`` string, or None to clear.
 75        """
 76        if not 0 <= slot < 4:
 77            return False
 78        if entry is None:
 79            self.hotbar[slot] = None
 80            return True
 81        # Item entries bypass skill validation
 82        if entry.startswith("item:"):
 83            self.hotbar[slot] = entry
 84            return True
 85        # Skill validation
 86        if entry not in self.unlocked:
 87            return False
 88        skill = self._skills.get(entry)
 89        if not skill or skill.get("type") != "active":
 90            return False
 91        self.hotbar[slot] = entry
 92        return True
 93
 94    def get_cooldown(self, skill_id: str) -> float:
 95        return self._cooldowns.get(skill_id, 0.0)
 96
 97    def is_ready(self, skill_id: str) -> bool:
 98        return self._cooldowns.get(skill_id, 0.0) <= 0
 99
100    def set_cooldown(self, skill_id: str):
101        skill = self._skills.get(skill_id)
102        if skill:
103            self._cooldowns[skill_id] = skill.get("cooldown", 0.0)
104
105    def activate(self, slot: int, player, targets=None):
106        """Activate skill ability in hotbar slot. Returns list of Node2D to add to scene.
107
108        Item entries (``"item:..."``}) are ignored: the game handles those directly.
109        """
110        if slot < 0 or slot >= 4:
111            return []
112        skill_id = self.hotbar[slot]
113        if not skill_id or skill_id.startswith("item:") or not self.is_ready(skill_id):
114            return []
115        from nodes.abilities import ABILITY_MAP
116
117        ability_fn = ABILITY_MAP.get(skill_id)
118        if not ability_fn:
119            return []
120        skill_data = self._skills.get(skill_id, {})
121        nodes = ability_fn(player, targets or [], skill_data)
122        self.set_cooldown(skill_id)
123        return nodes
124
125    def process_cooldowns(self, dt: float):
126        for sid in list(self._cooldowns):
127            self._cooldowns[sid] -= dt
128            if self._cooldowns[sid] <= 0:
129                del self._cooldowns[sid]
130
131    def to_dict(self) -> dict:
132        return {
133            "unlocked": list(self.unlocked),
134            "skill_points": self.skill_points,
135            "hotbar": list(self.hotbar),
136            "cooldowns": dict(self._cooldowns),
137        }
138
139    def from_dict(self, d: dict):
140        self.unlocked = set(d.get("unlocked", []))
141        self.skill_points = d.get("skill_points", 0)
142        self.hotbar = d.get("hotbar", [None, None, None, None])
143        self._cooldowns = d.get("cooldowns", {})