nodes/achievements.pyΒΆ
Part of Dungeon Explorer.
1"""Achievement system: unlock conditions, popup notifications, and log."""
2
3from simvx.core import Control, Property, Vec2
4from simvx.core.input import MouseButton
5from simvx.core.ui import OverlayCanvas
6from simvx.core.ui.enums import AnchorPreset
7
8from ._back_button import back_button_hit, draw_back_button
9
10_PW, _PH = 600, 500
11_TITLE_H = 36
12
13# Achievement definitions: (id, name, description, check_func_key, threshold)
14# check_func_key maps to a lifetime stat key or special check
15_ACHIEVEMENT_DEFS = [
16 # Kill milestones
17 ("first_blood", "First Blood", "Kill your first enemy", "total_kills", 1),
18 ("century_slayer", "Century Slayer", "Kill 100 enemies", "total_kills", 100),
19 ("thousand_corpses", "Thousand Corpses", "Kill 1000 enemies", "total_kills", 1000),
20 # Elite/boss
21 ("elite_hunter", "Elite Hunter", "Kill your first elite enemy", "elites_killed", 1),
22 ("elite_veteran", "Elite Veteran", "Kill 25 elite enemies", "elites_killed", 25),
23 ("mini_boss_slayer", "Mini-Boss Slayer", "Defeat a mini-boss", "mini_bosses_killed", 1),
24 ("dragon_slayer", "Dragon Slayer", "Defeat the Elder Dragon", "total_bosses_killed", 1),
25 # Exploration
26 ("explorer", "Explorer", "Clear 10 dungeon floors", "total_floors_cleared", 10),
27 ("deep_diver", "Deep Diver", "Clear 50 dungeon floors", "total_floors_cleared", 50),
28 ("spelunker", "Spelunker", "Clear 100 dungeon floors", "total_floors_cleared", 100),
29 ("secret_finder", "Secret Finder", "Find a secret room", "secret_rooms_found", 1),
30 ("treasure_hunter", "Treasure Hunter", "Open 10 chests", "chests_opened", 10),
31 # Wealth
32 ("pocket_change", "Pocket Change", "Earn 100 gold total", "total_gold_earned", 100),
33 ("wealthy", "Wealthy", "Earn 1000 gold total", "total_gold_earned", 1000),
34 ("tycoon", "Tycoon", "Earn 10000 gold total", "total_gold_earned", 10000),
35 # Loot
36 ("collector", "Collector", "Find 25 items", "total_items_found", 25),
37 ("hoarder", "Hoarder", "Find 100 items", "total_items_found", 100),
38 # Quests
39 ("quest_starter", "Quest Starter", "Complete your first quest", "quests_completed", 1),
40 ("quest_master", "Quest Master", "Complete 10 quests", "quests_completed", 10),
41 # Combat
42 ("combo_king", "Combo King", "Reach a 10x combo", "highest_combo", 10),
43 ("survivor", "Survivor", "Die for the first time", "total_deaths", 1),
44 ("persistent", "Persistent", "Die 10 times", "total_deaths", 10),
45 # Completionist
46 ("new_game_plus", "New Game+", "Complete the game", "games_completed", 1),
47 ("veteran", "Veteran", "Complete the game 3 times", "games_completed", 3),
48]
49
50
51class AchievementManager:
52 """Tracks and unlocks achievements based on lifetime stats."""
53
54 def __init__(self):
55 self.unlocked: list[str] = []
56 self._pending_popups: list[tuple[str, str]] = [] # (name, description)
57
58 def check(self, stats_tracker) -> None:
59 """Check all achievements against current stats. Queue popup for new unlocks."""
60 lifetime = stats_tracker.lifetime_stats
61 for ach_id, name, desc, stat_key, threshold in _ACHIEVEMENT_DEFS:
62 if ach_id in self.unlocked:
63 continue
64 value = lifetime.get(stat_key, 0)
65 if value >= threshold:
66 self.unlocked.append(ach_id)
67 self._pending_popups.append((name, desc))
68
69 def pop_notification(self) -> tuple[str, str] | None:
70 """Pop the next pending achievement notification, or None."""
71 if self._pending_popups:
72 return self._pending_popups.pop(0)
73 return None
74
75 @property
76 def total_count(self) -> int:
77 return len(_ACHIEVEMENT_DEFS)
78
79 @property
80 def unlocked_count(self) -> int:
81 return len(self.unlocked)
82
83 def all_achievements(self) -> list[tuple[str, str, str, bool]]:
84 """Return list of (id, name, description, is_unlocked)."""
85 return [(ach_id, name, desc, ach_id in self.unlocked) for ach_id, name, desc, _, _ in _ACHIEVEMENT_DEFS]
86
87 def to_dict(self) -> dict:
88 return {"unlocked": list(self.unlocked)}
89
90 def from_dict(self, d: dict) -> None:
91 self.unlocked = list(d.get("unlocked", []))
92
93
94class _AchievementCanvas(OverlayCanvas):
95 """Custom-drawn achievement log content.
96
97 Achievements are listed in two columns and clipped to the canvas height so
98 overflow doesn't escape the popup panel. The column / row layout keeps the
99 full 24-entry definition list visible without needing a scroll container.
100 """
101
102 content_size = Property((580.0, 1000.0))
103
104 def __init__(self, popup):
105 # The literal, not self.content_size: property storage is set up by
106 # Node.__init__, so reading the descriptor before that call yields None.
107 super().__init__(content_size=(580.0, 1000.0))
108 self._popup = popup
109 self.mouse_filter = False
110
111 def custom_draw(self, renderer):
112 x, y, w, h = self.get_global_rect()
113 p = self._popup
114 renderer.draw_text(
115 f"{p._mgr.unlocked_count}/{p._mgr.total_count} Unlocked",
116 (x + 10, y + 10),
117 scale=1.0,
118 colour=(0.7, 0.7, 0.7),
119 )
120
121 achievements = p._mgr.all_achievements()
122 row_h = 38
123 top = y + 45
124 bottom_limit = y + h - 4
125 col_w = (w - 20) / 2
126 rows_per_col = max(1, int((bottom_limit - top) // row_h))
127
128 for idx, (_ach_id, name, desc, unlocked) in enumerate(achievements):
129 col = idx // rows_per_col
130 row = idx % rows_per_col
131 cx = x + 10 + col * col_w
132 cy = top + row * row_h
133 if cy + 30 > bottom_limit:
134 continue # safety: out of panel
135 if cx + col_w > x + w:
136 break # ran out of columns; suppress overflow
137 if unlocked:
138 name_c = (1.0, 0.85, 0.2)
139 desc_c = (0.7, 0.7, 0.7)
140 icon = "[*]"
141 else:
142 name_c = (0.4, 0.4, 0.4)
143 desc_c = (0.3, 0.3, 0.3)
144 icon = "[ ]"
145 renderer.draw_text(f"{icon} {name}", (cx, cy), scale=1.0, colour=name_c)
146 renderer.draw_text(f" {desc}", (cx, cy + 16), scale=0.8, colour=desc_c)
147
148
149class AchievementLogUI(Control):
150 """Achievement log popup (a blocking overlay)."""
151
152 visible = Property(
153 False,
154 coerce=bool,
155 hint="Whether this node and its subtree are drawn",
156 on_change="_on_visible_changed",
157 )
158
159 def __init__(self, manager: AchievementManager, **kwargs):
160 super().__init__(name="AchievementLogUI", **kwargs)
161 self.set_anchor_preset(AnchorPreset.FULL_RECT)
162
163 self._mgr = manager
164 self._canvas = _AchievementCanvas(self)
165 self.add_child(self._canvas)
166
167 def show(self):
168 self.show_overlay("blocking")
169
170 def _on_gui_input(self, event):
171 if not self.visible:
172 return
173 if event.button == MouseButton.LEFT and event.pressed:
174 mx, my = float(event.position[0]), float(event.position[1])
175 if back_button_hit(mx, my):
176 self.cancel_requested.emit()
177 self.close_overlay()
178 event.handled = True
179 return
180 if event.key and event.pressed and event.key == "e":
181 self.cancel_requested.emit()
182 self.close_overlay()
183 event.handled = True
184
185 def _screen_size(self) -> tuple[float, float]:
186 if self.tree is not None:
187 sw, sh = self.tree.screen_size
188 return float(sw), float(sh)
189 return 1280.0, 720.0
190
191 def on_draw(self, renderer):
192 if not self.visible:
193 return
194 sw, sh = self._screen_size()
195 renderer.draw_rect((0, 0), (sw, sh), colour=(0.0, 0.0, 0.0, 0.6), filled=True)
196
197 px = (sw - _PW) / 2
198 py = (sh - _PH) / 2
199 renderer.draw_rect((px, py), (_PW, _PH), colour=(0.12, 0.12, 0.18, 0.95), filled=True)
200 renderer.draw_rect((px, py), (_PW, _TITLE_H), colour=(0.15, 0.15, 0.25, 1.0), filled=True)
201 renderer.draw_text("ACHIEVEMENTS", (px + 12, py + 8), scale=1.5, colour=(1, 1, 1))
202
203 self._canvas.position = Vec2(px + 10, py + _TITLE_H + 8)
204 self._canvas.size = Vec2(_PW - 20, _PH - _TITLE_H - 16)
205
206 draw_back_button(renderer)