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)