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