nodes/achievements.pyΒΆ

Part of Dungeon Explorer.

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