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)