nodes/achievements.pyΒΆ

Part of Dungeon Explorer.

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