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", {})