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