nodes/stats_tracker.pyΒΆ
Part of Dungeon Explorer.
1"""Statistics tracker: per-run and lifetime game statistics."""
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 = 500, 500
11_TITLE_H = 36
12
13
14class StatsTracker:
15 """Tracks per-run and cumulative lifetime statistics."""
16
17 def __init__(self):
18 self.reset_run()
19 self._lifetime = {
20 "total_kills": 0,
21 "total_deaths": 0,
22 "total_gold_earned": 0,
23 "total_floors_cleared": 0,
24 "total_bosses_killed": 0,
25 "total_items_found": 0,
26 "total_potions_used": 0,
27 "total_damage_dealt": 0,
28 "total_damage_taken": 0,
29 "highest_combo": 0,
30 "fastest_boss_kill": 0.0,
31 "games_completed": 0,
32 "elites_killed": 0,
33 "mini_bosses_killed": 0,
34 "secret_rooms_found": 0,
35 "quests_completed": 0,
36 "chests_opened": 0,
37 }
38
39 def reset_run(self):
40 """Reset per-run statistics."""
41 self._run = {
42 "kills": 0,
43 "gold_earned": 0,
44 "floors_cleared": 0,
45 "items_found": 0,
46 "potions_used": 0,
47 "damage_dealt": 0,
48 "damage_taken": 0,
49 "highest_combo": 0,
50 "time_played": 0.0,
51 "deaths": 0,
52 "elites_killed": 0,
53 "mini_bosses_killed": 0,
54 "secret_rooms_found": 0,
55 "quests_completed": 0,
56 "chests_opened": 0,
57 }
58
59 def record_kill(self):
60 self._run["kills"] += 1
61 self._lifetime["total_kills"] += 1
62
63 def record_death(self):
64 self._run["deaths"] += 1
65 self._lifetime["total_deaths"] += 1
66
67 def record_gold(self, amount: int):
68 self._run["gold_earned"] += amount
69 self._lifetime["total_gold_earned"] += amount
70
71 def record_floor(self):
72 self._run["floors_cleared"] += 1
73 self._lifetime["total_floors_cleared"] += 1
74
75 def record_item(self):
76 self._run["items_found"] += 1
77 self._lifetime["total_items_found"] += 1
78
79 def record_damage_dealt(self, amount: int):
80 self._run["damage_dealt"] += amount
81 self._lifetime["total_damage_dealt"] += amount
82
83 def record_damage_taken(self, amount: int):
84 self._run["damage_taken"] += amount
85 self._lifetime["total_damage_taken"] += amount
86
87 def record_combo(self, count: int):
88 self._run["highest_combo"] = max(self._run["highest_combo"], count)
89 self._lifetime["highest_combo"] = max(self._lifetime["highest_combo"], count)
90
91 def record_boss_kill(self, time_taken: float = 0.0):
92 self._lifetime["total_bosses_killed"] += 1
93 if time_taken > 0 and (
94 self._lifetime["fastest_boss_kill"] == 0 or time_taken < self._lifetime["fastest_boss_kill"]
95 ):
96 self._lifetime["fastest_boss_kill"] = time_taken
97
98 def record_game_complete(self):
99 self._lifetime["games_completed"] += 1
100
101 def record_elite_kill(self):
102 self._run["elites_killed"] += 1
103 self._lifetime["elites_killed"] += 1
104
105 def record_mini_boss_kill(self):
106 self._run["mini_bosses_killed"] += 1
107 self._lifetime["mini_bosses_killed"] += 1
108
109 def record_secret_room(self):
110 self._run["secret_rooms_found"] += 1
111 self._lifetime["secret_rooms_found"] += 1
112
113 def record_quest_complete(self):
114 self._run["quests_completed"] += 1
115 self._lifetime["quests_completed"] += 1
116
117 def record_chest_opened(self):
118 self._run["chests_opened"] += 1
119 self._lifetime["chests_opened"] += 1
120
121 def update_time(self, dt: float):
122 self._run["time_played"] += dt
123
124 @property
125 def run_stats(self) -> dict:
126 return dict(self._run)
127
128 @property
129 def lifetime_stats(self) -> dict:
130 return dict(self._lifetime)
131
132 def to_dict(self) -> dict:
133 return {"run": dict(self._run), "lifetime": dict(self._lifetime)}
134
135 def from_dict(self, d: dict):
136 if "lifetime" in d:
137 for k, v in d["lifetime"].items():
138 if k in self._lifetime:
139 self._lifetime[k] = v
140
141
142_STATS_CANVAS_SIZE = (480.0, 440.0)
143
144
145class _StatsCanvas(OverlayCanvas):
146 """Custom-drawn statistics content."""
147
148 content_size = Property(_STATS_CANVAS_SIZE)
149
150 def __init__(self, popup):
151 # The literal, not self.content_size: property storage is set up by
152 # Node.__init__, so reading the descriptor before that call yields None.
153 super().__init__(content_size=_STATS_CANVAS_SIZE)
154 self._popup = popup
155 self.mouse_filter = False
156
157 def custom_draw(self, renderer):
158 x, y, _, _ = self.get_global_rect()
159 p = self._popup
160
161 run_c = (1.0, 0.9, 0.3) if p._tab == 0 else (0.5, 0.5, 0.5)
162 life_c = (1.0, 0.9, 0.3) if p._tab == 1 else (0.5, 0.5, 0.5)
163 renderer.draw_text("< This Run >", (x + 10, y + 10), scale=1.2, colour=run_c)
164 renderer.draw_text("< Lifetime >", (x + 150, y + 10), scale=1.2, colour=life_c)
165
166 data = p._tracker.run_stats if p._tab == 0 else p._tracker.lifetime_stats
167 cy = y + 50
168 for key, value in data.items():
169 label = key.replace("_", " ").title()
170 if isinstance(value, float):
171 text = f"{label}: {value:.1f}"
172 else:
173 text = f"{label}: {value}"
174 renderer.draw_text(text, (x + 10, cy), scale=1.0, colour=(0.8, 0.8, 0.8))
175 cy += 22
176
177 renderer.draw_text("Left/Right: Switch Tab Esc: Close", (x + 10, cy + 20), scale=0.8, colour=(0.4, 0.4, 0.4))
178
179
180class StatsPanel(Control):
181 """Statistics popup showing per-run and lifetime stats."""
182
183 visible = Property(
184 False,
185 coerce=bool,
186 hint="Whether this node and its subtree are drawn",
187 on_change="_on_visible_changed",
188 )
189
190 def __init__(self, tracker: StatsTracker, **kwargs):
191 super().__init__(name="StatsPanel", **kwargs)
192 self.set_anchor_preset(AnchorPreset.FULL_RECT)
193
194 self._tracker = tracker
195 self._tab = 0
196
197 self._canvas = _StatsCanvas(self)
198 self.add_child(self._canvas)
199
200 def show(self):
201 self._tab = 0
202 self.show_overlay("blocking")
203
204 def _on_gui_input(self, event):
205 if not self.visible:
206 return
207 if event.button == MouseButton.LEFT and event.pressed:
208 mx, my = float(event.position[0]), float(event.position[1])
209 if back_button_hit(mx, my):
210 self.cancel_requested.emit()
211 self.close_overlay()
212 event.handled = True
213 return
214 if event.key and event.pressed:
215 if event.key in ("a", "left"):
216 self._tab = 0
217 event.handled = True
218 elif event.key in ("d", "right"):
219 self._tab = 1
220 event.handled = True
221 elif event.key == "e":
222 self.cancel_requested.emit()
223 self.close_overlay()
224 event.handled = True
225
226 def _screen_size(self) -> tuple[float, float]:
227 if self.tree is not None:
228 sw, sh = self.tree.screen_size
229 return float(sw), float(sh)
230 return 1280.0, 720.0
231
232 def on_draw(self, renderer):
233 if not self.visible:
234 return
235 sw, sh = self._screen_size()
236 renderer.draw_rect((0, 0), (sw, sh), colour=(0.0, 0.0, 0.0, 0.6), filled=True)
237
238 px = (sw - _PW) / 2
239 py = (sh - _PH) / 2
240 renderer.draw_rect((px, py), (_PW, _PH), colour=(0.12, 0.12, 0.18, 0.95), filled=True)
241 renderer.draw_rect((px, py), (_PW, _TITLE_H), colour=(0.15, 0.15, 0.25, 1.0), filled=True)
242 renderer.draw_text("STATISTICS", (px + 12, py + 8), scale=1.5, colour=(1, 1, 1))
243
244 self._canvas.position = Vec2(px + 10, py + _TITLE_H + 8)
245 self._canvas.size = Vec2(_PW - 20, _PH - _TITLE_H - 16)
246
247 draw_back_button(renderer)