Dungeon Explorer

2D top-down dungeon crawler.

▶ Run in browser

Run: uv run python examples/demos/dungeon_explorer/main.py

Controls: WASD/Arrows: Move Space: Attack Shift: Dodge roll I: Inventory E: Interact Escape: Pause L: Level up (if points) J: Quest log K: Skill tree 1-4: Use hotbar abilities N: Skip level (–debug only)

Source

   1#!/usr/bin/env python3
   2"""Dungeon Explorer: 2D top-down dungeon crawler.
   3
   4Run: uv run python examples/demos/dungeon_explorer/main.py
   5
   6Controls:
   7    WASD/Arrows: Move        Space: Attack       Shift: Dodge roll
   8    I: Inventory             E: Interact         Escape: Pause
   9    L: Level up (if points)  J: Quest log        K: Skill tree
  10    1-4: Use hotbar abilities N: Skip level (--debug only)
  11"""
  12
  13import sys
  14from pathlib import Path
  15
  16# Ensure game root is on sys.path for script imports
  17_GAME_DIR = str(Path(__file__).resolve().parent)
  18if _GAME_DIR not in sys.path:
  19    sys.path.insert(0, _GAME_DIR)
  20
  21from events import (
  22    BossDefeated,
  23    BossPhaseChanged,
  24    HotbarSlotClicked,
  25    HotbarSlotLongPressed,
  26    PlayerDied,
  27    PlayerLevelledUp,
  28)
  29from nodes.abilities import TrapNode
  30from nodes.achievements import AchievementLogUI, AchievementManager
  31from nodes.audio_manager import AudioManager
  32from nodes.boss_enemy import BossEnemy
  33from nodes.boss_health_bar import BossHealthBar
  34from nodes.camera_controller import CameraController
  35from nodes.combat import Hitbox, apply_defence
  36from nodes.confirm_dialog import ConfirmDialog
  37from nodes.death_screen import DeathScreen
  38from nodes.dialog_ui import DialogUI
  39from nodes.enchanter import EnchanterUI
  40from nodes.enemy_base import EnemyBase
  41from nodes.game_manager import GameManager
  42from nodes.hotbar_assign_ui import HotbarAssignUI
  43from nodes.inn import Inn
  44from nodes.inventory_ui import InventoryUI
  45from nodes.level_up_ui import LevelUpUI
  46from nodes.loading_screen import LoadingScreen
  47from nodes.loot_drop import LootDrop
  48from nodes.loot_pickup import LootPickup
  49from nodes.particles2d import SimpleParticles
  50from nodes.pause_menu import PauseMenu
  51from nodes.player import Player
  52from nodes.player_hud import PlayerHUD
  53from nodes.player_pause_bridge import PlayerPauseBridge
  54from nodes.projectile import Projectile
  55from nodes.quest_board import QuestBoardUI
  56from nodes.quest_log_ui import QuestLogUI
  57from nodes.settings_ui import SettingsUI, game_settings
  58from nodes.shop import ShopUI
  59from nodes.skill_tree_ui import SkillTreeUI
  60from nodes.stats_tracker import StatsPanel, StatsTracker
  61from nodes.title_screen import TitleScreen
  62from nodes.transition_overlay import TransitionOverlay
  63from nodes.victory_screen import VictoryScreen
  64from nodes.village import VILLAGE_H, VILLAGE_W, VillageScene
  65from nodes.waypoint_ui import WaypointUI
  66from nodes.well import Well
  67from scripts.click_controller import ClickController
  68from scripts.dungeon_generator import TILE_SIZE, DungeonLevel, generate_dungeon, path_exists
  69from scripts.dungeon_theme import get_theme
  70from scripts.enemy_spawner import spawn_all_dungeon_enemies
  71from scripts.fog_of_war import FogOfWar
  72from scripts.inventory import Inventory
  73from scripts.item import RARITY_NAMES, Rarity
  74from scripts.item_generator import ItemGenerator
  75from scripts.player_skills import SkillTree
  76from scripts.player_stats import compute_derived_stats
  77from scripts.quests import QuestManager
  78from scripts.save_state import SaveBag
  79from scripts.tutorial import TutorialManager
  80from scripts.weapons import get_melee_profile, get_ranged_profile, is_ranged, melee_attack, ranged_attack
  81
  82from simvx.core import CanvasLayer, Input, InputMap, Key, Node2D, SaveManager, UpdateMode, Vec2
  83from simvx.core.ui import GamepadOverlay
  84from simvx.graphics import App
  85
  86try:
  87    import tomllib
  88except ImportError:
  89    import tomli as tomllib  # type: ignore[no-redef]
  90
  91DATA_DIR = Path(__file__).resolve().parent / "data"
  92SAVE_DIR = Path(_GAME_DIR) / "saves"
  93SAVE_SLOT = "savegame"
  94SAVE_PATH = SAVE_DIR / f"{SAVE_SLOT}.sav"
  95
  96# Archetype -> loot table key mapping
  97_LOOT_TABLE_MAP = {
  98    "skeleton": "skeleton",
  99    "archer_skeleton": "skeleton",
 100    "slime": "slime",
 101    "bat_swarm": "bat",
 102    "goblin": "goblin",
 103    "wraith": "wraith",
 104    "golem": "golem",
 105    "demon": "demon",
 106    "elder_dragon": "boss",
 107}
 108
 109# Game states
 110STATE_TITLE = "title"
 111STATE_DUNGEON = "dungeon"
 112STATE_TOWN = "town"
 113
 114class _FogOverlayNode(Node2D):
 115    """World-space fog overlay drawn ABOVE enemies/particles to hide non-visible areas.
 116
 117    z_index 20 ensures this draws after enemies (10) and particles (15)
 118    but before the CanvasLayer (HUD/popups).
 119    """
 120
 121    def __init__(self, **kwargs):
 122        super().__init__(name="FogOverlay", **kwargs)
 123        self.z_index = 20
 124        self._fog = None
 125        self._dungeon_data = None
 126
 127    def setup(self, fog, dungeon_data):
 128        self._fog = fog
 129        self._dungeon_data = dungeon_data
 130
 131    def on_draw(self, renderer):
 132        fog = self._fog
 133        data = self._dungeon_data
 134        if not fog or not data:
 135            return
 136        ts = TILE_SIZE
 137        for y in range(data.height):
 138            for x in range(data.width):
 139                state = fog.get_state(x, y)
 140                if state == 2:
 141                    continue  # Fully visible: no overlay
 142                px, py_ = x * ts, y * ts
 143                if state == 0:
 144                    # Unexplored: opaque black
 145                    renderer.draw_rect((px, py_), (ts, ts), colour=(0.0, 0.0, 0.0, 1.0), filled=True)
 146                else:
 147                    # Explored but not currently visible: semi-transparent dark overlay
 148                    renderer.draw_rect((px, py_), (ts, ts), colour=(0.0, 0.0, 0.0, 0.55), filled=True)
 149
 150class _StatusOverlay(Node2D):
 151    """Screen-space status text and controls help (lives inside CanvasLayer)."""
 152
 153    def __init__(self, game, **kwargs):
 154        super().__init__(name="StatusOverlay", **kwargs)
 155        self._game = game
 156
 157    def on_update(self, dt: float):
 158        g = self._game
 159        # Tick level-up timers
 160        if g._levelup_flash_timer > 0:
 161            g._levelup_flash_timer -= dt
 162        if g._levelup_text_timer > 0:
 163            g._levelup_text_timer -= dt
 164        # Tick boss phase flash/slowmo
 165        if g._boss_phase_flash > 0:
 166            g._boss_phase_flash -= dt
 167        if g._boss_phase_slowmo > 0:
 168            g._boss_phase_slowmo -= dt
 169        # Quest notification ticking
 170        hud = g._hud
 171        if hasattr(hud, '_quest_notifications'):
 172            hud._quest_notifications = [
 173                (text, t - dt) for text, t in hud._quest_notifications if t - dt > 0
 174            ]
 175
 176    def on_draw(self, renderer):
 177        g = self._game
 178        if g._state == STATE_TITLE:
 179            return
 180
 181        # Get actual screen size
 182        sw, sh = 1280, 720
 183        if self.tree:
 184            sw, sh = self.tree.screen_size
 185
 186        # Level-up screen flash
 187        if g._levelup_flash_timer > 0:
 188            alpha = g._levelup_flash_timer / 0.15
 189            renderer.draw_rect((0, 0), (sw, sh), colour=(1.0, 0.95, 0.7, alpha * 0.4), filled=True)
 190
 191        # Level-up animated text
 192        if g._levelup_text_timer > 0:
 193            alpha = min(1.0, g._levelup_text_timer / 0.5)
 194            if g._levelup_text_timer > 1.5:
 195                scale = 1.5 + (2.0 - g._levelup_text_timer) * 2.0
 196            else:
 197                scale = 2.5
 198            renderer.draw_text(f"LEVEL {g._levelup_level}!",
 199                              (sw / 2 - 100, sh * 0.39), scale=scale,
 200                              colour=(1.0, 0.9, 0.3, alpha))
 201            renderer.draw_text("+3 Stat Points  +1 Skill Point",
 202                              (sw / 2 - 120, sh * 0.44), scale=1.0,
 203                              colour=(0.8, 0.9, 1.0, alpha * 0.8))
 204
 205        # Boss phase transition flash
 206        if g._boss_phase_flash > 0:
 207            alpha = g._boss_phase_flash / 0.2
 208            renderer.draw_rect((0, 0), (sw, sh), colour=(1.0, 0.3, 0.1, alpha * 0.3), filled=True)
 209
 210        # Tutorial tip overlay
 211        g._tutorial.draw_tip(renderer, sw, sh)
 212
 213        # Achievement notification
 214        notif = g._achievements.pop_notification()
 215        if notif:
 216            g._achievement_notif = notif
 217            g._achievement_notif_timer = 3.0
 218        if hasattr(g, '_achievement_notif_timer') and g._achievement_notif_timer > 0:
 219            g._achievement_notif_timer -= 0.016
 220            a = min(1.0, g._achievement_notif_timer / 0.5)
 221            name, desc = g._achievement_notif
 222            ax = sw - 380
 223            renderer.draw_rect((ax, 20), (360, 50), colour=(0.1, 0.1, 0.15, 0.9 * a), filled=True)
 224            renderer.draw_rect((ax, 20), (360, 2), colour=(1.0, 0.85, 0.2, 0.8 * a), filled=True)
 225            renderer.draw_text(f"Achievement: {name}", (ax + 10, 28), scale=1.0,
 226                              colour=(1.0, 0.85, 0.2, a))
 227            renderer.draw_text(desc, (ax + 10, 48), scale=0.8, colour=(0.7, 0.7, 0.7, a))
 228
 229        if g._state == STATE_TOWN:
 230            renderer.draw_text("TOWN  |  Safe Zone", (10, sh - 20), scale=1, colour=(0.5, 0.7, 0.5))
 231            return
 232
 233        # Dungeon info
 234        renderer.draw_text(
 235            f"Dungeon Lv {g._dungeon_level}  |  {get_theme(g._dungeon_level)['name']}",
 236            (10, sh - 20), scale=1, colour=(0.5, 0.5, 0.5),
 237        )
 238        enemies_alive = len(g.find_all(EnemyBase))
 239        if enemies_alive > 0:
 240            renderer.draw_text(
 241                f"Enemies: {enemies_alive}",
 242                (10, sh - 38), scale=1, colour=(0.8, 0.3, 0.3),
 243            )
 244        cx = sw - 380
 245        cy = sh - 50
 246        grey = (0.45, 0.45, 0.45, 1.0)
 247        renderer.draw_text("WASD: Move   Space: Attack   Shift: Dodge", (cx, cy), scale=0.9, colour=grey)
 248        renderer.draw_text("I: Inventory   K: Skills   L: Level Up   J: Quests", (cx, cy + 16), scale=0.9, colour=grey)
 249        renderer.draw_text("E: Interact   1-4: Abilities   Esc: Pause", (cx, cy + 32), scale=0.9, colour=grey)
 250
 251class DungeonGame(Node2D):
 252    """Root node for the dungeon explorer game."""
 253
 254    def __init__(self, *, debug: bool = False, **kwargs):
 255        super().__init__(**kwargs)
 256        self._debug = debug
 257
 258    def on_ready(self):
 259        # PAUSABLE (default) so children (enemies, particles) pause when a
 260        # modal opens. PlayerPauseBridge handles the player pause; modals
 261        # and TransitionOverlay run in ALWAYS mode where needed.
 262
 263        # Register input actions
 264        InputMap.add_action("pause", [Key.ESCAPE])
 265        InputMap.add_action("next_level", [Key.N])
 266        InputMap.add_action("inventory", [Key.I])
 267        InputMap.add_action("interact", [Key.E])
 268        InputMap.add_action("level_up", [Key.L])
 269        InputMap.add_action("skill_tree", [Key.K])
 270        InputMap.add_action("quest_log", [Key.J])
 271        InputMap.add_action("ability_1", [Key.KEY_1])
 272        InputMap.add_action("ability_2", [Key.KEY_2])
 273        InputMap.add_action("ability_3", [Key.KEY_3])
 274        InputMap.add_action("ability_4", [Key.KEY_4])
 275        InputMap.add_action("toggle_fullscreen", [Key.F11])
 276
 277        # Core systems
 278        self.gm = GameManager()
 279        self.add_child(self.gm)
 280
 281        # SaveBag: child Node holding ``persist=True`` Property snapshots of the
 282        # non-Node subsystems (inventory, fog, quests, skills). Synced from the
 283        # live objects in :meth:`_save_current_game` and read back in
 284        # :meth:`_continue_game`. ``simvx.core.SaveManager`` walks every
 285        # ``persist=True`` Property in the tree on save/apply.
 286        self._save_bag = SaveBag()
 287        self.add_child(self._save_bag)
 288        self._save_mgr = SaveManager(save_dir=SAVE_DIR)
 289
 290        self._inventory = Inventory()
 291        self._item_gen = ItemGenerator(DATA_DIR / "items.toml", DATA_DIR / "affixes.toml")
 292        with open(DATA_DIR / "loot_tables.toml", "rb") as f:
 293            self._loot_tables = tomllib.load(f)
 294
 295        # UI layer
 296        self._ui_layer = CanvasLayer(name="UILayer")
 297        self._ui_layer.update_mode = UpdateMode.ALWAYS
 298        self.add_child(self._ui_layer)
 299
 300        # HUD
 301        self._hud = PlayerHUD()
 302        self._ui_layer.add_child(self._hud)
 303        self._status_overlay = _StatusOverlay(self)
 304        self._ui_layer.add_child(self._status_overlay)
 305
 306        # Transition overlay
 307        self._transition = TransitionOverlay()
 308        self._ui_layer.add_child(self._transition)
 309
 310        # Player-pause bridge: toggles only player.update_mode on modal events
 311        # so HUD, ambient audio, and music keep ticking while a popup is open.
 312        self._popups = PlayerPauseBridge()
 313        self._ui_layer.add_child(self._popups)
 314
 315        # Popup instances: modal Controls parented under the UI layer.
 316        # Each popup is a top-level Control with set_anchor_preset(FULL_RECT)
 317        # so it draws screen-space regardless of where it's parented.
 318        def _add_popup(popup):
 319            self._ui_layer.add_child(popup)
 320            return popup
 321
 322        self._pause_popup = _add_popup(PauseMenu(on_action=self._on_pause_action))
 323        self._inventory_popup = _add_popup(InventoryUI(on_use_consumable=self._on_consumable_used))
 324        self._inventory_popup.set_inventory(self._inventory)
 325        self._level_up_popup = _add_popup(LevelUpUI())
 326        self._death_popup = _add_popup(DeathScreen(on_respawn=self._on_respawn))
 327        self._title_popup = _add_popup(TitleScreen(on_action=self._on_title_action, save_path=SAVE_PATH))
 328        self._dialog_popup = _add_popup(DialogUI())
 329        self._shop_popup = _add_popup(ShopUI(on_transaction=self._on_shop_transaction))
 330        self._victory_popup = _add_popup(VictoryScreen(on_continue=self._on_victory_continue))
 331
 332        # Skill tree
 333        self._skill_tree = SkillTree()
 334        self._skill_tree.load_from_toml(DATA_DIR / "skills.toml")
 335        self._skill_tree_popup = _add_popup(SkillTreeUI(self._skill_tree))
 336
 337        # Sprint 74: Statistics tracker
 338        self._stats = StatsTracker()
 339        self._stats_popup = _add_popup(StatsPanel(self._stats))
 340
 341        # Sprint 75: Achievements
 342        self._achievements = AchievementManager()
 343        self._achievement_log_popup = _add_popup(AchievementLogUI(self._achievements))
 344        self._achievement_display_timer = 0.0
 345        self._achievement_display_text = ""
 346
 347        # Sprint 78: Enchanter
 348        self._enchanter_popup = _add_popup(EnchanterUI())
 349
 350        # Settings popup
 351        self._settings_popup = _add_popup(SettingsUI(on_close_cb=self._on_settings_close))
 352
 353        # Hotbar assignment popup
 354        self._hotbar_assign_popup = _add_popup(HotbarAssignUI(self._skill_tree))
 355        self._hotbar_assign_popup.set_inventory(self._inventory)
 356
 357        # Loading screen
 358        self._loading_popup = _add_popup(LoadingScreen())
 359
 360        # Tutorial system
 361        self._tutorial = TutorialManager()
 362        self.tutorial_seen = False
 363
 364        # Quest system
 365        self._quest_mgr = QuestManager()
 366        self._quest_mgr.load_from_toml(DATA_DIR / "quests.toml")
 367        self._quest_board_popup = _add_popup(QuestBoardUI(self._quest_mgr))
 368        self._quest_log_popup = _add_popup(QuestLogUI(self._quest_mgr, on_claim=self._on_quest_claimed))
 369
 370        # Waypoint popup
 371        self._waypoint_popup = _add_popup(WaypointUI(on_teleport=self._on_waypoint_teleport))
 372        self._opened_chests: set[tuple[int, int, int]] = set()  # (level, gx, gy)
 373
 374        # Persistent player
 375        self._dungeon_level = 1
 376        self._fog = None
 377        self._pending_level_up = False
 378        self._town_scene: VillageScene | None = None
 379        self._boss: BossEnemy | None = None
 380        self._boss_hud: BossHealthBar | None = None
 381        self._rewarded_enemies: set[int] = set()  # Track enemies by _reward_id to prevent double-counting
 382        self._next_reward_id = 0
 383        self._return_floor: int | None = None  # Town portal return floor
 384
 385        self._player = Player(name="Player")
 386        self._player.z_index = 10  # Above dungeon floor, below UI
 387        self.add_child(self._player)
 388
 389        self._popups.player = self._player
 390        self._inventory_popup.set_player(self._player)
 391        self._inventory_popup.set_skill_tree(self._skill_tree)
 392        self._skill_tree_popup.set_player(self._player)
 393        self._hud.set_quest_manager(self._quest_mgr)
 394        self._hud.set_skill_tree(self._skill_tree)
 395
 396        # Typed-event connections via the engine EventBus. The bus holds
 397        # ``WeakMethod`` refs so handlers detach automatically when this
 398        # node is freed -- no explicit disconnect required on scene change.
 399        bus = self.tree.events
 400        bus.subscribe(PlayerDied, self._on_player_died_event)
 401        bus.subscribe(PlayerLevelledUp, self._on_player_levelled_up_event)
 402        bus.subscribe(HotbarSlotClicked, self._on_hotbar_clicked_event)
 403        bus.subscribe(HotbarSlotLongPressed, self._on_hotbar_long_pressed_event)
 404        bus.subscribe(BossDefeated, self._on_boss_defeated_event)
 405        bus.subscribe(BossPhaseChanged, self._on_boss_phase_changed_event)
 406
 407        # Particle system
 408        self._particles = SimpleParticles()
 409        self._particles.z_index = 15  # Above entities
 410        self.add_child(self._particles)
 411
 412        # Audio manager (no-op without audio files)
 413        self._audio = AudioManager()
 414        self.add_child(self._audio)
 415
 416        # Hit-stop
 417        self._hitstop_frames = 0
 418        self._hitstop_ramp = 0.0  # Time-scale ramp back after hit-stop
 419        self._prev_dodging = False  # Track dodge start for camera shake
 420
 421        # Level-up celebration
 422        self._levelup_flash_timer = 0.0
 423        self._levelup_text_timer = 0.0
 424        self._levelup_level = 0
 425
 426        # Boss phase transition
 427        self._boss_prev_phase = 1
 428        self._boss_phase_flash = 0.0
 429        self._boss_phase_slowmo = 0.0
 430
 431        # Click-to-path controller
 432        self._click_ctrl = ClickController()
 433
 434        # Virtual gamepad overlay
 435        self._virtual_controls = GamepadOverlay()
 436        self._vc_draw_layer = _VirtualControlsDrawLayer(self._virtual_controls, self)
 437        self._ui_layer.add_child(self._vc_draw_layer)
 438
 439        # Auto-detect mobile/web: WebApp exposes is_mobile (set by JS before
 440        # set_root); desktop App does not. Both have an `engine` attribute, so
 441        # is_mobile is the clean discriminator.
 442        app = self.app
 443        self._is_mobile_web = getattr(app, 'is_mobile', False) if app else False
 444        if app is not None and hasattr(app, 'is_mobile'):
 445            # Web export: default to virtual gamepad
 446            game_settings.control_mode = "virtual_gamepad"
 447
 448        # State machine
 449        self._state = STATE_TITLE
 450        self._popups.open(self._title_popup)
 451
 452    # ── State transitions ──────────────────────────────────────────────
 453
 454    def _start_new_game(self):
 455        """Start a fresh game."""
 456        # Reset player stats
 457        self._player.hp = 100
 458        self._player.max_hp = 100
 459        self._player.level = 1
 460        self._player.xp = 0
 461        self._player.xp_to_next = 100
 462        self._player.gold = 0
 463        self._player.strength = 5
 464        self._player.dexterity = 5
 465        self._player.vitality = 5
 466        self._player.intelligence = 5
 467        self._player.stat_points = 0
 468        self._player.skill_points = 0
 469        self._skill_tree.unlocked.clear()
 470        self._skill_tree.skill_points = 0
 471        self._skill_tree.hotbar = [None, None, None, None]
 472        self._quest_mgr.active.clear()
 473        self._quest_mgr.completed.clear()
 474        self._inventory = Inventory()
 475        self._inventory_popup.set_inventory(self._inventory)
 476        self._opened_chests.clear()
 477        self.gm.reset_run_state()
 478        self._dungeon_level = 1
 479        self._enter_town()
 480
 481    def _start_new_game_plus(self):
 482        """Start New Game+: keep items/skills, reset dungeon, scale enemies (Sprint 73)."""
 483        self.gm.start_new_game_plus()
 484        self._stats.reset_run()
 485        self._stats.record_game_complete()
 486        self._quest_mgr.active.clear()
 487        self._quest_mgr.completed.clear()
 488        self._opened_chests.clear()
 489        self._dungeon_level = 1
 490        # Keep inventory, skills, and player stats
 491        self._enter_town()
 492
 493    def _continue_game(self):
 494        """Load saved game and resume."""
 495        try:
 496            data = self._save_mgr.load(SAVE_SLOT)
 497        except FileNotFoundError:
 498            self._start_new_game()
 499            return
 500        # SaveManager pours every persisted Property back onto the live tree
 501        # (player stats including position, GameManager state, SaveBag dicts).
 502        # Procedural nodes (DungeonLevel, WallBody, EnemyBase) opt out of
 503        # snapshot via ``__save_persist__ = False`` and are rebuilt from the
 504        # dungeon seed in ``_load_dungeon`` below.
 505        self._save_mgr.apply(self, data)
 506
 507        bag = self._save_bag
 508        if bag.inventory_data:
 509            self._inventory = Inventory.from_dict(bag.inventory_data)
 510        else:
 511            self._inventory = Inventory()
 512        self._inventory_popup.set_inventory(self._inventory)
 513
 514        self._fog = FogOfWar.from_dict(bag.fog_data) if bag.fog_data else None
 515        if bag.quests_data:
 516            self._quest_mgr.from_dict(bag.quests_data)
 517        if bag.skills_data:
 518            self._skill_tree.from_dict(bag.skills_data)
 519        self._opened_chests = {tuple(t) for t in bag.opened_chests_data}
 520        self.tutorial_seen = bool(bag.tutorial_seen)
 521
 522        self._dungeon_level = self.gm.dungeon_level
 523        self._state = STATE_DUNGEON
 524        self._load_dungeon(self._dungeon_level)
 525
 526    def _enter_town(self):
 527        """Transition to town hub."""
 528        self._save_current_game()
 529        self._state = STATE_TOWN
 530        self.gm.enter_town()
 531        # Clean up dungeon nodes
 532        for old in self.find_all(DungeonLevel) + self.find_all(CameraController):
 533            if old.parent is self:
 534                old.destroy()
 535        for old in self.find_all(EnemyBase):
 536            old.destroy()
 537        # Disable fog overlay in town
 538        fog_node = self.find(_FogOverlayNode, direct=True)
 539        if fog_node:
 540            fog_node.setup(None, None)
 541        # Remove old town scene if any
 542        if self._town_scene and self._town_scene.parent:
 543            self._town_scene.destroy()
 544
 545        self._town_scene = VillageScene(self._player, on_interact=self._on_npc_interact)
 546        self.add_child(self._town_scene)
 547        self._player.position = Vec2(VILLAGE_W // 2, VILLAGE_H // 2)
 548
 549        # Camera for town
 550        camera = CameraController(name="Camera")
 551        camera.zoom = 1.5
 552        camera.set_target(self._player)
 553        camera.set_bounds(Vec2(0, 0), Vec2(VILLAGE_W, VILLAGE_H))
 554        camera.position = Vec2(VILLAGE_W // 2, VILLAGE_H // 2)
 555        self.add_child(camera)
 556
 557        self._hud.setup(self._player, inventory=self._inventory)
 558        self._tutorial.trigger("first_town")
 559
 560    def _leave_town(self):
 561        """Exit town and enter next dungeon level."""
 562        def _do_leave():
 563            if self._town_scene and self._town_scene.parent:
 564                self._town_scene.destroy()
 565                self._town_scene = None
 566            for old in self.find_all(CameraController):
 567                if old.parent is self:
 568                    old.destroy()
 569            self._state = STATE_DUNGEON
 570            # If returning from town portal, go back to that floor
 571            if self._return_floor is not None:
 572                target = self._return_floor
 573                self._return_floor = None
 574                self._load_dungeon(target)
 575            else:
 576                self._load_dungeon(self._dungeon_level)
 577        self._transition.transition(_do_leave)
 578
 579    # ── Dungeon loading ────────────────────────────────────────────────
 580
 581    def _load_dungeon(self, level: int, seed: int | None = None):
 582        """Generate and load a dungeon level. Player persists across calls.
 583
 584        Uses persistent seeds so the same level always produces the same layout
 585        until the player rerolls at the well.
 586        """
 587        # Clean up old dungeon/camera/enemy nodes
 588        for old in self.find_all(DungeonLevel) + self.find_all(CameraController):
 589            if old.parent is self:
 590                old.destroy()
 591        for old in self.find_all(EnemyBase):
 592            old.destroy()
 593
 594        # Use persistent seed for this level (same layout until well reroll)
 595        if seed is None:
 596            seed = self.gm.get_dungeon_seed(level)
 597
 598        w = min(48 + level * 2, 128)
 599        h = min(48 + level * 2, 128)
 600        data = generate_dungeon(
 601            width=w, height=h,
 602            min_rooms=min(5 + level // 5, 15),
 603            max_rooms=min(8 + level // 3, 20),
 604            seed=seed,
 605        )
 606        while not path_exists(data):
 607            seed = seed + 1
 608            data = generate_dungeon(width=w, height=h,
 609                                    min_rooms=min(5 + level // 5, 15),
 610                                    max_rooms=min(8 + level // 3, 20), seed=seed)
 611
 612        self._populate_special_objects(data, level)
 613
 614        # Apply random room decorations (pillars, cross-shapes)
 615        # Skip entrance (first) and exit (last) rooms to avoid blocking spawn/exit
 616        import random as rng_mod
 617
 618        from scripts.room_templates import apply_random_decoration
 619        dec_rng = rng_mod.Random(seed or level)
 620        safe_rooms = {0, len(data.rooms) - 1} if len(data.rooms) >= 2 else set(range(len(data.rooms)))
 621        for i, room in enumerate(data.rooms):
 622            if i in safe_rooms:
 623                continue
 624            if dec_rng.random() < 0.3:
 625                apply_random_decoration(data.grid, room, dec_rng)
 626        # Guarantee entrance/exit tiles remain walkable
 627        from scripts.dungeon_generator import ENTRANCE, EXIT
 628        ex, ey = data.entrance
 629        data.grid[ey][ex] = ENTRANCE
 630        xx, xy = data.exit
 631        data.grid[xy][xx] = EXIT
 632
 633        from scripts.dungeon_generator import COLOURS, CORRIDOR, FLOOR
 634        theme = get_theme(level)
 635        COLOURS[FLOOR] = theme["floor_colour"]
 636        COLOURS[CORRIDOR] = theme["corridor_colour"]
 637
 638        dungeon = DungeonLevel(data, level=level)
 639        self.add_child(dungeon)
 640
 641        self._fog = FogOfWar(data.width, data.height, reveal_radius=6)
 642        # Fog overlay node covers enemies/particles in world space
 643        old_fog_overlay = self.find(_FogOverlayNode, direct=True)
 644        if old_fog_overlay:
 645            old_fog_overlay.destroy()
 646        self._fog_overlay = _FogOverlayNode()
 647        self._fog_overlay.setup(self._fog, data)
 648        self.add_child(self._fog_overlay)
 649
 650        self._player.position = dungeon.entrance_world_pos()
 651
 652        camera = CameraController(name="Camera")
 653        camera.zoom = 2.5
 654        camera.smoothing = 8.0
 655        camera.set_target(self._player)
 656        camera.set_bounds_from_dungeon(data.width, data.height)
 657        camera.position = Vec2(self._player.position)
 658        self.add_child(camera)
 659
 660        # Clean up boss/reward state
 661        self._boss = None
 662        self._rewarded_enemies.clear()
 663        if self._boss_hud and self._boss_hud.parent:
 664            self._boss_hud.destroy()
 665            self._boss_hud = None
 666
 667        if level >= 100 and not self.gm.boss_defeated:
 668            # Boss level: spawn only the boss in the last room
 669            boss = BossEnemy(name="Elder Dragon")
 670            # Scale boss for level
 671            scale = 1 + (level - 1) * 0.15
 672            boss.hp = int(500 * scale)
 673            boss.max_hp = boss.hp
 674            boss.damage = int(25 * (1 + (level - 1) * 0.08))
 675            boss._base_damage = boss.damage
 676            boss.speed = 50.0 * (1 + (level - 1) * 0.005)
 677            boss._base_speed = boss.speed
 678            last_room = data.rooms[-1]
 679            boss.position = Vec2(
 680                (last_room.x + last_room.w // 2) * TILE_SIZE,
 681                (last_room.y + last_room.h // 2) * TILE_SIZE,
 682            )
 683            boss.setup(self._player, dungeon.nav_grid)
 684            # Boss-specific events (BossDefeated, BossPhaseChanged) are
 685            # delivered via ``self.tree.events`` -- subscribed in ``ready``.
 686            boss._reward_id = self._next_reward_id
 687            self._next_reward_id += 1
 688            boss.z_index = 10
 689            self.add_child(boss)
 690            self._boss = boss
 691            # Boss health bar in UI layer
 692            self._boss_hud = BossHealthBar()
 693            self._boss_hud.setup(boss)
 694            self._ui_layer.add_child(self._boss_hud)
 695        else:
 696            enemies = spawn_all_dungeon_enemies(
 697                data, level, dungeon.nav_grid, self._player, seed=seed,
 698                difficulty_mults=self.gm.difficulty_multipliers,
 699            )
 700            for enemy in enemies:
 701                enemy._fog = self._fog
 702                enemy._reward_id = self._next_reward_id
 703                self._next_reward_id += 1
 704                enemy.z_index = 10
 705                self.add_child(enemy)
 706
 707        self._hud.setup(self._player, data, self._fog, inventory=self._inventory)
 708        self._level_up_popup.set_player(self._player)
 709
 710        # Set up click-to-path controller with dungeon nav grid
 711        camera = self.find(CameraController, direct=True)
 712        sw, sh = (self.tree.screen_size if self.tree else (1280, 720))
 713        self._click_ctrl.setup(
 714            self._player, camera, dungeon.nav_grid, screen_size=(sw, sh),
 715            enemies_fn=lambda: [e for e in self.find_all(EnemyBase) if e.hp > 0],
 716            attack_fn=self._player_attack,
 717            interact_fn=lambda: self._check_special_interact() or self._check_stairs(),
 718        )
 719
 720        self.gm.enter_dungeon(level)
 721        self._dungeon_level = level
 722        # Tutorial tips
 723        self._tutorial.trigger("move")
 724        if level == 1:
 725            self._tutorial.trigger("first_stairs")
 726        self._quest_mgr.on_floor_reached(level)
 727
 728    # ── Per-frame processing ───────────────────────────────────────────
 729
 730    def on_update(self, dt: float):
 731        # F11 fullscreen toggle (always available)
 732        if Input.is_action_just_pressed("toggle_fullscreen"):
 733            app = self.app
 734            if app and hasattr(app, 'toggle_fullscreen'):
 735                app.toggle_fullscreen()
 736                game_settings.fullscreen = getattr(app, 'is_fullscreen', False)
 737
 738        # Virtual gamepad is processed by _VirtualControlsDrawLayer (in UILayer, ALWAYS mode)
 739        # so it works even when the tree is paused for popups.
 740
 741        if self._popups.is_open:
 742            return
 743
 744        if self._state == STATE_TITLE:
 745            # Title screen popup handles everything
 746            return
 747
 748        if self._transition.is_transitioning:
 749            return
 750
 751        if self._hitstop_frames > 0:
 752            self._hitstop_frames -= 1
 753            if self._hitstop_frames == 0:
 754                self._hitstop_ramp = 0.15  # Ramp back over 0.15s
 755            return
 756
 757        # Time-scale ramp back after hit-stop
 758        if self._hitstop_ramp > 0:
 759            self._hitstop_ramp -= dt
 760            ramp_factor = max(0.5, 1.0 - self._hitstop_ramp / 0.15)
 761            dt = dt * ramp_factor
 762
 763        # Track play time
 764        self._stats.update_time(dt)
 765
 766        # Boss phase transition slow-motion
 767        if self._boss_phase_slowmo > 0:
 768            self._boss_phase_slowmo -= dt
 769            dt = dt * 0.3  # 30% speed during boss phase transition
 770
 771        if self._state == STATE_TOWN:
 772            self._process_town(dt)
 773            return
 774
 775        # STATE_DUNGEON
 776        self._process_dungeon(dt)
 777
 778    def _process_town(self, dt: float = 0.016):
 779        """Town-specific input processing."""
 780        self._sync_derived_stats()
 781        self._tutorial.update(dt)
 782        if not self._tutorial.tutorial_seen:
 783            self._tutorial.trigger("first_town")
 784
 785        if Input.is_action_just_pressed("pause"):
 786            self._pause_popup.set_context(player=self._player, dungeon_level=self._dungeon_level)
 787            self._popups.open(self._pause_popup)
 788            return
 789        if Input.is_action_just_pressed("inventory"):
 790            self._popups.open(self._inventory_popup)
 791            return
 792        if Input.is_action_just_pressed("skill_tree"):
 793            self._popups.open(self._skill_tree_popup)
 794            return
 795        if Input.is_action_just_pressed("quest_log"):
 796            self._popups.open(self._quest_log_popup)
 797            return
 798
 799        # NPC interaction / dungeon entrance
 800        if self._town_scene:
 801            if Input.is_action_just_pressed("interact") and self._town_scene.is_near_dungeon_entrance():
 802                self._leave_town()
 803                return
 804            self._town_scene.check_interactions()
 805
 806        # Clamp player to village bounds
 807        if self._town_scene:
 808            self._town_scene.clamp_player()
 809
 810    def _process_dungeon(self, dt: float = 0.016):
 811        """Dungeon-specific input processing."""
 812        # Tutorial tick
 813        self._tutorial.update(dt)
 814
 815        if Input.is_action_just_pressed("pause"):
 816            self._pause_popup.set_context(player=self._player, dungeon_level=self._dungeon_level)
 817            self._popups.open(self._pause_popup)
 818            return
 819        if Input.is_action_just_pressed("inventory"):
 820            self._popups.open(self._inventory_popup)
 821            return
 822        if Input.is_action_just_pressed("level_up") and self._player.stat_points > 0:
 823            self._popups.open(self._level_up_popup)
 824            return
 825        if Input.is_action_just_pressed("skill_tree"):
 826            self._popups.open(self._skill_tree_popup)
 827            return
 828        if Input.is_action_just_pressed("quest_log"):
 829            self._popups.open(self._quest_log_popup)
 830            return
 831
 832        # Debug: next level (only when launched with --debug)
 833        if self._debug and Input.is_action_just_pressed("next_level"):
 834            self._dungeon_level += 1
 835            # Town every 10 levels
 836            if self._dungeon_level % 10 == 1 and self._dungeon_level > 1:
 837                self._transition.transition(self._enter_town)
 838            else:
 839                lvl = self._dungeon_level
 840                self._transition.transition(lambda: self._load_dungeon(lvl))
 841            return
 842
 843        # Interact: stairs, chests, waypoints, secret walls
 844        if Input.is_action_just_pressed("interact"):
 845            if self._check_stairs():
 846                return
 847            if self._check_secret_wall():
 848                return
 849            self._check_special_interact()
 850
 851        # Click-to-path mode
 852        if game_settings.control_mode == "click_to_path":
 853            self._click_ctrl.process(dt)
 854
 855        # Combat (keyboard + virtual gamepad inject key events, so this works for all modes)
 856        if Input.is_action_just_pressed("attack"):
 857            self._player_attack()
 858
 859        # Sync derived stats (vitality → max_hp, dex → speed, gear, passives)
 860        self._sync_derived_stats()
 861
 862        # Fog of war
 863        if self._fog:
 864            gx = int(self._player.position.x / TILE_SIZE)
 865            gy = int(self._player.position.y / TILE_SIZE)
 866            self._fog.update(gx, gy)
 867            # Set fog + dungeon data on projectiles for visibility and wall collision
 868            dungeon = self.find(DungeonLevel, direct=True)
 869            dungeon_data = dungeon.dungeon_data if dungeon else None
 870            for proj in self.find_all(Projectile):
 871                if proj._fog is None:
 872                    proj._fog = self._fog
 873                if proj._dungeon_data is None and dungeon_data:
 874                    proj._dungeon_data = dungeon_data
 875
 876        # Corner collision safety: prevent diagonal squeeze through wall corners
 877        self._clamp_to_walkable()
 878
 879        # Abilities / hotbar items
 880        for i in range(4):
 881            if Input.is_action_just_pressed(f"ability_{i + 1}"):
 882                self._activate_hotbar(i)
 883        self._skill_tree.process_cooldowns(dt)
 884        self._hud.handle_hotbar_input(dt)
 885
 886        self._check_enemy_attacks()
 887        self._check_player_attacks()
 888        self._check_loot_pickups()
 889
 890        # Check traps
 891        for trap_node in self.find_all(TrapNode):
 892            enemies = [e for e in self.find_all(EnemyBase) if e.hp > 0]
 893            triggered = trap_node.check_trigger(enemies)
 894            for enemy in triggered:
 895                enemy.take_damage(trap_node._damage, self._player.position)
 896                if enemy.hp <= 0:
 897                    self._on_enemy_killed(enemy)
 898
 899        # Dodge shake detection
 900        if self._player.is_dodging and not self._prev_dodging:
 901            camera = self.find(CameraController, direct=True)
 902            if camera:
 903                camera.dodge_shake()
 904        self._prev_dodging = self._player.is_dodging
 905
 906        # Boss ground slam screen shake (A2)
 907        if self._boss and self._boss._slam_fired:
 908            self._boss._slam_fired = False
 909            camera = self.find(CameraController, direct=True)
 910            if camera:
 911                camera.boss_shake(intensity=10.0, duration=0.2)
 912
 913        # Update combo display when combo breaks
 914        if self._player.combo_count == 0 and self._hud._combo_display_count >= 2:
 915            self._hud.update_combo(0)
 916
 917        # Combo time-slow at 5x/10x milestones
 918        if self._player.combo_count in (5, 10) and self._hitstop_frames == 0:
 919            if self._player._combo_timer > self._player._combo_window - 0.05:
 920                self._hitstop_frames = 2  # Brief time-slow at milestone
 921
 922        # Level-up notification: _pending_level_up is set by the signal but we
 923        # no longer auto-open the popup.  The player presses L to allocate.
 924        # Clear the flag once the player has no remaining stat points.
 925        if self._pending_level_up and self._player.stat_points <= 0:
 926            self._pending_level_up = False
 927
 928    def _compute_stats(self) -> dict[str, float]:
 929        """Compute derived stats with skill passive bonuses applied."""
 930        return compute_derived_stats(
 931            self._player.strength, self._player.dexterity,
 932            self._player.vitality, self._player.intelligence,
 933            self._player.level, self._inventory,
 934            skill_bonuses=self._skill_tree.get_passive_bonuses(),
 935        )
 936
 937    def _sync_derived_stats(self):
 938        """Apply derived max_hp and speed to the player so vitality/dex actually matter."""
 939        derived = self._compute_stats()
 940        new_max = int(derived["max_hp"])
 941        if new_max != self._player.max_hp:
 942            delta = new_max - self._player.max_hp
 943            self._player.max_hp = new_max
 944            # If max_hp increased, grant the extra HP immediately
 945            if delta > 0:
 946                self._player.hp = min(self._player.max_hp, self._player.hp + delta)
 947            else:
 948                self._player.hp = min(self._player.hp, self._player.max_hp)
 949        self._player.speed = derived["speed"]
 950        # Sync shield state for block mechanic
 951        shield = self._inventory.get_equipped("offhand")
 952        self._player._has_shield = shield is not None and getattr(shield, 'template_key', '') == "shield"
 953
 954    # ── Combat ─────────────────────────────────────────────────────────
 955
 956    def _player_attack(self):
 957        if self._player.is_dodging or self._player._attack_cooldown > 0:
 958            return
 959        self._audio.play_sfx("attack")
 960        derived = self._compute_stats()
 961        base_dmg = 5 + int(derived["damage"])
 962        crit = derived["crit_chance"]
 963        str_val = int(derived["damage"])
 964        weapon = self._inventory.get_equipped("weapon")
 965        weapon_key = weapon.template_key if weapon else ""
 966
 967        if is_ranged(weapon_key):
 968            colour, style, speed, rng, cooldown = get_ranged_profile(weapon_key)
 969            ranged_attack(self._player, self._player.facing,
 970                          base_damage=base_dmg, speed=speed, max_range=rng,
 971                          attacker_str=str_val, crit_chance=crit,
 972                          colour=colour, style=style)
 973        else:
 974            arc_colour, arc_spread, hitbox_r, cooldown = get_melee_profile(weapon_key)
 975            melee_attack(self._player, self._player.facing,
 976                         base_damage=base_dmg, attacker_str=str_val,
 977                         crit_chance=crit, hitbox_radius=hitbox_r)
 978            self._player.start_attack_lunge()
 979            self._player.start_melee_arc(spread=arc_spread, colour=arc_colour)
 980        self._player._attack_cooldown = cooldown
 981        # Degrade weapon durability
 982        if weapon:
 983            weapon.degrade(1)
 984            if weapon.is_broken:
 985                notif = LootDrop("Weapon broken!", colour=(0.8, 0.2, 0.2, 1.0))
 986                notif.position = Vec2(self._player.position.x, self._player.position.y - 30)
 987                self.add_child(notif)
 988
 989    def _check_enemy_attacks(self):
 990        import random as _rng
 991        derived = self._compute_stats()
 992        player_defence = int(derived["defence"])
 993        dodge_chance = derived["dodge_chance"]
 994        block_chance = derived["block_chance"]
 995
 996        def _apply_hit(target, raw_dmg, from_boss=False, source_pos=None):
 997            if _rng.random() < dodge_chance:
 998                return  # Dodged
 999            if _rng.random() < block_chance:
1000                self.add_child(LootDrop("Blocked!", colour=(0.6, 0.8, 1.0, 1.0)))
1001                return
1002            # Active shield block
1003            if self._player._blocking and source_pos is not None:
1004                import math as _math
1005                atk_dx = source_pos[0] - self._player.position.x
1006                atk_dy = source_pos[1] - self._player.position.y
1007                atk_dist = _math.sqrt(atk_dx * atk_dx + atk_dy * atk_dy)
1008                if atk_dist > 0.01:
1009                    atk_nx = atk_dx / atk_dist
1010                    atk_ny = atk_dy / atk_dist
1011                    dot = self._player._block_direction.x * atk_nx + self._player._block_direction.y * atk_ny
1012                    if dot > 0.588:  # cos(54deg) ~ 0.588, ~108 degree cone
1013                        shield = self._inventory.get_equipped("offhand")
1014                        shield_def = shield.total_stats().get("defence", 0) if shield else 0
1015                        blocked_dmg = max(1, raw_dmg - shield_def * 3)
1016                        target.take_damage(blocked_dmg)
1017                        self._player._block_flash_timer = 0.15
1018                        pos = Vec2(self._player.position.x, self._player.position.y - 20)
1019                        drop = LootDrop("Blocked!", colour=(0.4, 0.7, 1.0, 1.0))
1020                        drop.position = pos
1021                        self.add_child(drop)
1022                        camera = self.find(CameraController, direct=True)
1023                        if camera:
1024                            camera.damage_shake(blocked_dmg * 0.5, self._player.max_hp)
1025                        return
1026            # Enemy crit: 10% chance, 1.5x damage
1027            is_crit = _rng.random() < 0.10
1028            dmg = apply_defence(raw_dmg, player_defence)
1029            if is_crit:
1030                dmg = int(dmg * 1.5)
1031                self._player.trigger_crit_flash()
1032            target.take_damage(dmg)
1033            self._stats.record_damage_taken(dmg)
1034            self._audio.play_sfx("hit")
1035            # Degrade armour durability
1036            for slot in ("body", "head", "feet"):
1037                armour = self._inventory.get_equipped(slot)
1038                if armour:
1039                    armour.degrade(1)
1040            camera = self.find(CameraController, direct=True)
1041            if camera:
1042                if from_boss:
1043                    camera.boss_shake(intensity=8.0, duration=0.25)
1044                else:
1045                    camera.damage_shake(dmg, self._player.max_hp)
1046
1047        boss_active = self._boss is not None and self._boss.hp > 0
1048        for hitbox in self.find_all(Hitbox):
1049            if hitbox.name == "EnemyHit":
1050                hits = hitbox.check_hits([self._player])
1051                for target in hits:
1052                    _apply_hit(target, hitbox.damage, from_boss=boss_active,
1053                               source_pos=(hitbox.position.x, hitbox.position.y))
1054        for proj in self.find_all(Projectile):
1055            if proj.name == "EnemyProjectile":
1056                hits = proj.check_hits([self._player])
1057                for target in hits:
1058                    _apply_hit(target, proj.damage, from_boss=boss_active,
1059                               source_pos=(proj.position.x, proj.position.y))
1060
1061    def _check_player_attacks(self):
1062        enemies = [e for e in self.find_all(EnemyBase) if e.hp > 0]
1063        derived = self._compute_stats()
1064        life_steal = derived["life_steal"]
1065        hp_on_kill = derived["hp_on_kill"]
1066
1067        knockback_dist = derived["knockback"]
1068
1069        def _handle_hit(enemy, dmg):
1070            dx = enemy.position.x - self._player.position.x
1071            dy = enemy.position.y - self._player.position.y
1072            dist = (dx * dx + dy * dy) ** 0.5
1073            direction = Vec2(dx / dist, dy / dist) if dist > 0.01 else Vec2(0, 1)
1074            is_crit = dmg > (10 + int(derived["damage"])) * 1.3  # Approximate crit detection
1075            is_boss = isinstance(enemy, BossEnemy)
1076            enemy.take_damage(dmg, self._player.position)
1077            self._stats.record_damage_dealt(dmg)
1078            # Knockback scales with stat; bosses resist 70%
1079            kb = knockback_dist * (0.3 if is_boss else 1.0)
1080            if kb > 0.5:
1081                enemy.apply_knockback(direction, kb, 0.1)
1082            # Hit-stop: 2 normal, 4 crit, 4 boss
1083            if is_boss:
1084                self._hitstop_frames = 4
1085            elif is_crit:
1086                self._hitstop_frames = 4
1087            else:
1088                self._hitstop_frames = 2
1089            # Melee hit sparks
1090            if self._particles:
1091                self._particles.emit_melee_sparks(enemy.position, direction, is_crit=is_crit)
1092            # Directional camera shake on hit
1093            camera = self.find(CameraController, direct=True)
1094            if camera:
1095                camera.directional_shake(direction, intensity=4.0 + dmg * 0.1, duration=0.1)
1096            if life_steal > 0:
1097                heal_amt = int(dmg * life_steal)
1098                self._player.heal(heal_amt)
1099                if heal_amt > 0:
1100                    from nodes.combat import DamageNumber
1101                    hn = DamageNumber(heal_amt, is_heal=True)
1102                    hn.position = Vec2(self._player.position.x, self._player.position.y - 15)
1103                    self.add_child(hn)
1104            if enemy.hp <= 0:
1105                if hp_on_kill > 0:
1106                    self._player.heal(int(hp_on_kill))
1107                    hn = DamageNumber(int(hp_on_kill), is_heal=True)
1108                    hn.position = Vec2(self._player.position.x, self._player.position.y - 15)
1109                    self.add_child(hn)
1110                self._on_enemy_killed(enemy)
1111
1112        for hitbox in self.find_all(Hitbox):
1113            if hitbox.name == "MeleeHit":
1114                hits = hitbox.check_hits(enemies)
1115                for enemy in hits:
1116                    _handle_hit(enemy, hitbox.damage)
1117        for proj in self.find_all(Projectile):
1118            if proj.name == "EnemyProjectile":
1119                continue
1120            hits = proj.check_hits(enemies)
1121            for enemy in hits:
1122                # Ranged hit particles
1123                if self._particles:
1124                    style = getattr(proj, '_style', 'arrow')
1125                    self._particles.emit_ranged_hit(enemy.position, style=style)
1126                    # Enemy impact flash
1127                    enemy._flash_on_ranged_hit = 0.08
1128                _handle_hit(enemy, proj.damage)
1129
1130    def _on_enemy_killed(self, enemy: EnemyBase):
1131        """Award XP, gold, and loot from killed enemy."""
1132        # Guard against double-counting (death animation keeps enemy in tree)
1133        rid = getattr(enemy, '_reward_id', id(enemy))
1134        if rid in self._rewarded_enemies:
1135            return
1136        self._rewarded_enemies.add(rid)
1137        self._audio.play_sfx("death")
1138
1139        import random
1140
1141        # Stats tracking
1142        self._stats.record_kill()
1143        if getattr(enemy, '_is_elite', False):
1144            self._stats.record_elite_kill()
1145        if getattr(enemy, '_is_mini_boss', False):
1146            self._stats.record_mini_boss_kill()
1147
1148        # Combo tracking
1149        combo = self._player.register_kill()
1150        self._stats.record_combo(combo)
1151        self._hud.update_combo(combo)
1152
1153        # Achievements: check all thresholds against stats
1154        self._achievements.check(self._stats)
1155        combo_bonus = max(0, (combo - 1) * 5)  # +5 XP per combo stack
1156
1157        xp = enemy.xp_reward + combo_bonus
1158        self._player.gain_xp(xp)
1159
1160        archetype = enemy.name.lower().replace(" ", "_")
1161        # Check for quest completion before and after kill tracking
1162        pre_complete = {q.quest_id for q in self._quest_mgr.active if q.is_complete}
1163        self._quest_mgr.on_enemy_killed(archetype)
1164        post_complete = {q.quest_id for q in self._quest_mgr.active if q.is_complete}
1165        for qid in post_complete - pre_complete:
1166            quest = next((q for q in self._quest_mgr.active if q.quest_id == qid), None)
1167            if quest:
1168                self._hud.notify_quest_complete(quest.name)
1169        loot_key = _LOOT_TABLE_MAP.get(archetype)
1170        loot_table = self._loot_tables.get(loot_key, {}) if loot_key else {}
1171
1172        gold_range = loot_table.get("gold_range", [1, 5])
1173        gold = random.randint(gold_range[0], gold_range[1])
1174        self._player.gold += gold
1175        self._stats.record_gold(gold)
1176
1177        # Floating notifications
1178        y_off = -10
1179        notif = LootDrop(f"+{gold} Gold", colour=(1.0, 0.85, 0.2, 1.0))
1180        notif.position = Vec2(enemy.position.x, enemy.position.y + y_off)
1181        self.add_child(notif)
1182
1183        xp_notif = LootDrop(f"+{xp} XP", colour=(0.4, 0.7, 1.0, 1.0))
1184        xp_notif.position = Vec2(enemy.position.x, enemy.position.y + y_off - 14)
1185        self.add_child(xp_notif)
1186
1187        if loot_table:
1188            rng = random.Random()
1189            drops = self._item_gen.roll_from_loot_table(loot_table, self._dungeon_level, rng)
1190            for item in drops:
1191                pickup = LootPickup(item)
1192                pickup.position = Vec2(enemy.position.x, enemy.position.y)
1193                self.add_child(pickup)
1194
1195        # Sprint 69: Mini-boss guaranteed epic drop
1196        if getattr(enemy, '_guaranteed_epic', False):
1197            rng = random.Random()
1198            epic_item = self._item_gen.roll_item(ilvl=self._dungeon_level, rarity=Rarity.EPIC, rng=rng)
1199            pickup = LootPickup(epic_item)
1200            pickup.position = Vec2(enemy.position.x + 10, enemy.position.y)
1201            self.add_child(pickup)
1202
1203        # Death particles
1204        if self._particles:
1205            self._particles.emit(10, enemy.position, vel_range=(-60, 60),
1206                                colour=(0.8, 0.2, 0.1, 1.0), lifetime=0.5)
1207
1208        # Camera shake on kill
1209        camera = self.find(CameraController, direct=True)
1210        if camera:
1211            camera.directional_shake(Vec2(0, -1), intensity=3.0, duration=0.1)
1212
1213    def _clamp_to_walkable(self):
1214        """Prevent player from squeezing through diagonal wall corners.
1215
1216        After move_and_slide, check if any corner of the player's bounding
1217        circle overlaps a wall tile and push back into walkable space.
1218        """
1219        dungeon = self.find(DungeonLevel, direct=True)
1220        if not dungeon:
1221            return
1222        data = dungeon.dungeon_data
1223        px, py = self._player.position.x, self._player.position.y
1224        r = 10  # Player collision radius
1225        ts = TILE_SIZE
1226
1227        # Check the 4 diagonal corners of the player bounding box
1228        for dx, dy in ((-r, -r), (r, -r), (-r, r), (r, r)):
1229            cx, cy = px + dx, py + dy
1230            gx, gy = int(cx / ts), int(cy / ts)
1231            if not data.is_walkable(gx, gy):
1232                # This corner is inside a wall: push player away from this cell
1233                wall_cx = gx * ts + ts / 2
1234                wall_cy = gy * ts + ts / 2
1235                # Push along the axis with less penetration
1236                pen_x = (ts / 2 + r) - abs(px - wall_cx)
1237                pen_y = (ts / 2 + r) - abs(py - wall_cy)
1238                if pen_x > 0 and pen_y > 0:
1239                    if pen_x < pen_y:
1240                        self._player.position = Vec2(
1241                            px + (pen_x if px > wall_cx else -pen_x), py,
1242                        )
1243                    else:
1244                        self._player.position = Vec2(
1245                            px, py + (pen_y if py > wall_cy else -pen_y),
1246                        )
1247                    px, py = self._player.position.x, self._player.position.y
1248
1249    def _near(self, pos, radius=24) -> bool:
1250        """Return True if the player is within *radius* px of *pos*."""
1251        dx = self._player.position.x - pos.x
1252        dy = self._player.position.y - pos.y
1253        return dx * dx + dy * dy < radius * radius
1254
1255    def _is_near_interactable(self) -> bool:
1256        """Check if the player is near stairs, chests, or waypoints in the dungeon."""
1257        dungeon = self.find(DungeonLevel, direct=True)
1258        if not dungeon:
1259            return False
1260        if self._near(dungeon.exit_world_pos(), 30) or self._near(dungeon.entrance_world_pos(), 30):
1261            return True
1262        ts = TILE_SIZE
1263        px, py = self._player.position.x, self._player.position.y
1264        for obj in dungeon.dungeon_data.special_objects:
1265            ox = obj["gx"] * ts + ts / 2
1266            oy = obj["gy"] * ts + ts / 2
1267            if (px - ox) ** 2 + (py - oy) ** 2 < 30 * 30:
1268                return True
1269        return False
1270
1271    def _check_stairs(self) -> bool:
1272        """Handle interact on dungeon stairs. Returns True if a transition fired."""
1273        dungeon = self.find(DungeonLevel, direct=True)
1274        if not dungeon:
1275            return False
1276        # Exit stairs → descend deeper
1277        if self._near(dungeon.exit_world_pos()):
1278            self._dungeon_level += 1
1279            self._stats.record_floor()
1280            if self._dungeon_level % 10 == 0:
1281                self.gm.dungeon_level = self._dungeon_level
1282                self.gm.set_save_point()
1283            if self._dungeon_level % 10 == 1 and self._dungeon_level > 1:
1284                self._transition.transition(self._enter_town)
1285            else:
1286                lvl = self._dungeon_level
1287                self._transition.transition(lambda: self._load_dungeon(lvl))
1288            return True
1289        # Entrance stairs → return to town
1290        if self._near(dungeon.entrance_world_pos()):
1291            self._transition.transition(self._enter_town)
1292            return True
1293        return False
1294
1295    def _check_secret_wall(self) -> bool:
1296        """Check if the player is near a secret wall and reveal it."""
1297        dungeon = self.find(DungeonLevel, direct=True)
1298        if not dungeon:
1299            return False
1300        gx = int(self._player.position.x / TILE_SIZE)
1301        gy = int(self._player.position.y / TILE_SIZE)
1302        result = dungeon.dungeon_data.has_secret_wall_near(gx, gy)
1303        if result is None:
1304            return False
1305        sx, sy = result
1306        dungeon.dungeon_data.reveal_secret_wall(sx, sy)
1307        dungeon.rebuild_wall_bodies()  # Remove wall collision so player can walk through
1308        self._audio.play_sfx("chest_open")
1309        # Floating notification
1310        notif = LootDrop("Secret room found!", colour=(0.8, 0.6, 1.0, 1.0))
1311        notif.position = Vec2(sx * TILE_SIZE + TILE_SIZE / 2,
1312                              sy * TILE_SIZE - 10)
1313        self.add_child(notif)
1314        if hasattr(self, '_stats'):
1315            self._stats.record_secret_room()
1316        # Populate the secret room with a reward
1317        self._populate_secret_room(dungeon.dungeon_data, sx, sy)
1318        return True
1319
1320    def _populate_secret_room(self, data, door_x: int, door_y: int):
1321        """Spawn a chest or elite enemy inside the secret room behind the door."""
1322        import random
1323
1324        from scripts.dungeon_generator import FLOOR
1325
1326        # Find which side of the door leads to the secret room (not the parent room).
1327        # The door has exactly two floor neighbors: one in the parent room, one in
1328        # the secret room.  The secret room side is the one NOT inside any known room.
1329        seed_tiles = []
1330        parent_tiles = []
1331        for dx, dy in ((0, -1), (0, 1), (-1, 0), (1, 0)):
1332            nx, ny = door_x + dx, door_y + dy
1333            if 0 <= nx < data.width and 0 <= ny < data.height and data.grid[ny][nx] == FLOOR:
1334                in_room = any(
1335                    r.x <= nx < r.x + r.w and r.y <= ny < r.y + r.h
1336                    for r in data.rooms
1337                )
1338                if in_room:
1339                    parent_tiles.append((nx, ny))
1340                else:
1341                    seed_tiles.append((nx, ny))
1342        # Fallback: if heuristic fails, use all neighbors
1343        if not seed_tiles:
1344            seed_tiles = parent_tiles
1345
1346        # Flood-fill only into the secret room (block the door to prevent leaking
1347        # back into the parent room)
1348        room_tiles: list[tuple[int, int]] = []
1349        visited = {(door_x, door_y)}  # Block the door cell
1350        stack = list(seed_tiles)
1351        while stack:
1352            tx, ty = stack.pop()
1353            if (tx, ty) in visited:
1354                continue
1355            visited.add((tx, ty))
1356            if data.grid[ty][tx] != FLOOR:
1357                continue
1358            room_tiles.append((tx, ty))
1359            if len(room_tiles) > 20:  # Safety cap: secret rooms are 4x4=16
1360                break
1361            for dx, dy in ((0, -1), (0, 1), (-1, 0), (1, 0)):
1362                nx, ny = tx + dx, ty + dy
1363                if 0 <= nx < data.width and 0 <= ny < data.height and (nx, ny) not in visited:
1364                    stack.append((nx, ny))
1365
1366        if not room_tiles:
1367            return
1368
1369        # Center of the secret room
1370        avg_x = sum(t[0] for t in room_tiles) / len(room_tiles)
1371        avg_y = sum(t[1] for t in room_tiles) / len(room_tiles)
1372        cx = avg_x * TILE_SIZE + TILE_SIZE / 2
1373        cy = avg_y * TILE_SIZE + TILE_SIZE / 2
1374
1375        rng = random.Random()
1376        if rng.random() < 0.5:
1377            # Chest with guaranteed rare+ loot
1378            data.special_objects.append({"type": "chest", "gx": int(avg_x), "gy": int(avg_y)})
1379        else:
1380            # Elite guardian enemy
1381            from nodes.enemy_types import create_enemy, make_elite
1382            dungeon_node = self.find(DungeonLevel, direct=True)
1383            enemy = create_enemy("golem", dungeon_level=self._dungeon_level)
1384            make_elite(enemy, rng)
1385            enemy.display_name = "Secret Guardian"
1386            enemy.xp_reward = int(enemy.xp_reward * 1.5)
1387            enemy._guaranteed_epic = True  # Drop epic on death
1388            enemy.position = Vec2(cx, cy)
1389            enemy.setup(self._player, dungeon_node.nav_grid if dungeon_node else None)
1390            enemy._reward_id = self._next_reward_id
1391            self._next_reward_id += 1
1392            enemy.z_index = 10
1393            self.add_child(enemy)
1394
1395    def _check_loot_pickups(self):
1396        for pickup in self.find_all(LootPickup):
1397            # Grab item reference before try_collect (which destroys the node)
1398            item = pickup.item
1399            if pickup.try_collect(self._player.position, self._inventory):
1400                self._audio.play_sfx("pickup")
1401                # Sprint 71: Track item collection for quests
1402                rarity_name = RARITY_NAMES.get(item.rarity, "Common").lower()
1403                self._quest_mgr.on_item_collected(rarity_name)
1404                # Sprint 74: Stats
1405                self._stats.record_item()
1406
1407    # ── Callbacks ──────────────────────────────────────────────────────
1408
1409    # Typed-event adapters. Subscribers on ``self.tree.events`` receive the
1410    # full event dataclass; these thin wrappers preserve the original handler
1411    # signatures so internal call sites stay unchanged.
1412
1413    def _on_player_died_event(self, event: PlayerDied) -> None:
1414        self._on_player_died()
1415
1416    def _on_player_levelled_up_event(self, event: PlayerLevelledUp) -> None:
1417        self._on_level_up(event.new_level)
1418
1419    def _on_hotbar_clicked_event(self, event: HotbarSlotClicked) -> None:
1420        self._on_hotbar_slot_clicked(event.slot)
1421
1422    def _on_hotbar_long_pressed_event(self, event: HotbarSlotLongPressed) -> None:
1423        self._on_hotbar_slot_long_pressed(event.slot)
1424
1425    def _on_boss_defeated_event(self, event: BossDefeated) -> None:
1426        self._on_boss_killed()
1427
1428    def _on_boss_phase_changed_event(self, event: BossPhaseChanged) -> None:
1429        self._on_boss_phase_change(event.new_phase)
1430
1431    def _on_player_died(self):
1432        self._stats.record_death()
1433        self._death_popup.set_context(player=self._player, dungeon_level=self._dungeon_level)
1434        self._popups.open(self._death_popup)
1435
1436    def _on_respawn(self):
1437        # Respawn in village: player recovers at town after death
1438        self._player.hp = self._player.max_hp
1439        self._transition.transition(self._enter_town)
1440
1441    def _on_level_up(self, new_level):
1442        self._pending_level_up = True
1443        self._audio.play_sfx("level_up")
1444        # Level-up celebration visuals
1445        self._levelup_flash_timer = 0.15
1446        self._levelup_text_timer = 2.0
1447        self._levelup_level = new_level
1448        if self._particles:
1449            self._particles.emit_levelup(self._player.position)
1450
1451    def _on_boss_phase_change(self, new_phase):
1452        """Boss transitioned to a new phase: screen flash, slow-motion."""
1453        self._boss_phase_flash = 0.2
1454        self._boss_phase_slowmo = 0.3
1455        self._hitstop_frames = 4  # Brief freeze on transition
1456        camera = self.find(CameraController, direct=True)
1457        if camera:
1458            camera.boss_shake(intensity=10.0, duration=0.3)
1459
1460    def _on_boss_killed(self):
1461        """Boss defeated: show victory screen.
1462
1463        Note: _on_enemy_killed is already called from _check_player_attacks
1464        when the boss hp hits 0, so we don't call it again here.
1465        """
1466        self.gm.boss_defeated = True
1467        self._stats.record_boss_kill()
1468        self._achievements.check(self._stats)
1469        self._victory_popup.set_context(player=self._player, dungeon_level=self._dungeon_level)
1470        self._popups.open(self._victory_popup)
1471
1472    def _on_consumable_used(self, template_key: str):
1473        """Handle scroll/consumable effects from inventory UI."""
1474        if template_key == "town_portal_scroll":
1475            if self._state == STATE_DUNGEON:
1476                self._return_floor = self._dungeon_level
1477                self._popups.close()
1478                self._transition.transition(self._enter_town)
1479        elif template_key == "fire_scroll":
1480            self._popups.close()
1481            # AoE fire burst at player position
1482            derived = self._compute_stats()
1483            dmg = 40 + int(derived.get("spell_power", 0))
1484            hitbox = Hitbox(damage=dmg, direction=Vec2(0, 1), name="MeleeHit")
1485            hitbox.position = Vec2(self._player.position.x, self._player.position.y)
1486            hitbox.radius = 80
1487            hitbox.lifetime = 0.25
1488            self.add_child(hitbox)
1489            # Fire particles
1490            if self._particles:
1491                self._particles.emit(
1492                    12, self._player.position, vel_range=(-60, 60),
1493                    colour=(1.0, 0.4, 0.1, 1.0), lifetime=0.5,
1494                )
1495
1496    def _on_victory_continue(self):
1497        """Continue to free play after boss victory."""
1498        self._dungeon_level += 1
1499        lvl = self._dungeon_level
1500        self._transition.transition(lambda: self._load_dungeon(lvl))
1501
1502    def _open_confirm(self, message: str, on_confirm) -> None:
1503        """Instantiate, parent, and open a ConfirmDialog."""
1504        dialog = ConfirmDialog(message, on_confirm=on_confirm)
1505        self._ui_layer.add_child(dialog)
1506        self._popups.open(dialog)
1507
1508    def _on_title_action(self, action):
1509        if action == "confirm_new_game":
1510            self._open_confirm(
1511                "Overwrite existing save?",
1512                lambda yes: self._transition.transition(self._start_new_game) if yes else
1513                           self._popups.open(self._title_popup),
1514            )
1515        elif action == "settings":
1516            self._settings_popup._on_close_cb = self._on_title_settings_close
1517            self._popups.open(self._settings_popup)
1518        elif action == "new_game":
1519            self._transition.transition(self._start_new_game)
1520        elif action == "continue":
1521            self._transition.transition(self._continue_game)
1522        elif action == "quit":
1523            if hasattr(self, 'app') and self.app:
1524                self.app.quit()
1525
1526    def _on_title_settings_close(self):
1527        """Return to title screen after closing settings from the title menu."""
1528        self._on_settings_close()
1529        self._settings_popup._on_close_cb = self._on_settings_close  # Restore normal callback
1530        self._popups.open(self._title_popup)
1531
1532    def _on_pause_action(self, action):
1533        if action == "confirm_quit_to_title":
1534            self._open_confirm("Quit to title?", self._on_quit_confirmed)
1535        elif action == "quit_to_title":
1536            self._do_quit_to_title()
1537        elif action == "save_game":
1538            self._save_current_game()
1539        elif action == "stats":
1540            self._popups.open(self._stats_popup)
1541        elif action == "achievements":
1542            self._popups.open(self._achievement_log_popup)
1543        elif action == "settings":
1544            self._popups.open(self._settings_popup)
1545        elif action == "level_up":
1546            if self._player.stat_points > 0:
1547                self._popups.open(self._level_up_popup)
1548
1549    def _on_settings_close(self):
1550        """Apply settings when settings menu closes."""
1551        # Sync fullscreen state
1552        app = self.app
1553        if app and hasattr(app, 'is_fullscreen') and hasattr(app, 'toggle_fullscreen'):
1554            if app.is_fullscreen != game_settings.fullscreen:
1555                app.toggle_fullscreen()
1556
1557    def _on_hotbar_slot_clicked(self, slot_index: int):
1558        """Activate ability or use item in hotbar slot."""
1559        self._activate_hotbar(slot_index)
1560
1561    def _activate_hotbar(self, slot: int):
1562        """Activate a hotbar slot: skill or consumable item."""
1563        entry = self._skill_tree.hotbar[slot] if 0 <= slot < 4 else None
1564        if not entry:
1565            return
1566        if entry.startswith("item:"):
1567            template_key = entry[5:]
1568            self._use_hotbar_item(template_key)
1569        else:
1570            nearby = [e for e in self.find_all(EnemyBase) if e.hp > 0]
1571            nodes = self._skill_tree.activate(slot, self._player, targets=nearby)
1572            for node in nodes:
1573                self.add_child(node)
1574
1575    def _use_hotbar_item(self, template_key: str):
1576        """Use a consumable item from inventory via hotbar."""
1577        if self._inventory is None:
1578            return
1579        for idx in range(self._inventory.capacity):
1580            item = self._inventory.get(idx)
1581            if item and item.is_consumable and item.template_key == template_key:
1582                if template_key == "potion":
1583                    heal = int(item.base_stats.get("heal", 30))
1584                    self._player.heal(heal)
1585                elif template_key in ("town_portal_scroll", "fire_scroll"):
1586                    self._on_consumable_used(template_key)
1587                if item.stack_count > 1:
1588                    item.stack_count -= 1
1589                else:
1590                    self._inventory.remove(idx)
1591                return
1592
1593    def _on_hotbar_slot_long_pressed(self, slot_index: int):
1594        """Open hotbar assignment popup for the given slot."""
1595        self._hotbar_assign_popup.open_for_slot(slot_index)
1596        self._popups.open(self._hotbar_assign_popup)
1597
1598    def _on_quit_confirmed(self, confirmed: bool):
1599        if confirmed:
1600            self._do_quit_to_title()
1601
1602    def _do_quit_to_title(self):
1603        self._save_current_game()
1604        self._popups.close()
1605        self._state = STATE_TITLE
1606        for old in self.find_all(DungeonLevel) + self.find_all(CameraController):
1607            if old.parent is self:
1608                old.destroy()
1609        for old in self.find_all(EnemyBase):
1610            old.destroy()
1611        if self._town_scene and self._town_scene.parent:
1612            self._town_scene.destroy()
1613            self._town_scene = None
1614        self._popups.open(self._title_popup)
1615
1616    def _save_current_game(self):
1617        # Let SaveManager walk the tree and pickle every ``persist=True``
1618        # Property in one shot (player.position is now a persisted Property).
1619        bag = self._save_bag
1620        bag.inventory_data = self._inventory.to_dict() if self._inventory else {}
1621        bag.fog_data = self._fog.to_dict() if self._fog else {}
1622        bag.quests_data = self._quest_mgr.to_dict() if self._quest_mgr else {}
1623        bag.skills_data = self._skill_tree.to_dict() if self._skill_tree else {}
1624        seed = self.gm._dungeon_seed
1625        bag.has_dungeon_seed = seed is not None
1626        bag.dungeon_seed = int(seed) if seed is not None else 0
1627        bag.opened_chests_data = tuple(self._opened_chests)
1628        bag.tutorial_seen = bool(self.tutorial_seen)
1629
1630        self._save_mgr.save(self, SAVE_SLOT)
1631        self._hud.show_save_indicator()
1632
1633    def _on_npc_interact(self, npc_id: str):
1634        """Handle NPC interaction in town."""
1635        from data.dialogs import DIALOGS
1636
1637        dialog_tree = DIALOGS.get(npc_id)
1638        if not dialog_tree:
1639            return
1640
1641        game_state = {
1642            "gold": self._player.gold,
1643            "level": self._player.level,
1644            "dungeon_level": self._dungeon_level,
1645            "max_dungeon_reached": self.gm.max_dungeon_reached,
1646        }
1647        self._dialog_popup.start(dialog_tree, game_state, on_action=self._on_dialog_action)
1648        self._popups.open(self._dialog_popup)
1649
1650    def _on_dialog_action(self, action: str):
1651        if action == "inn_rest":
1652            Inn.rest(self._player)
1653        elif action == "well_reroll":
1654            Well.reroll(self._player, self.gm)
1655        elif action == "open_shop":
1656            # Generate shop items
1657            import random
1658            rng = random.Random(self._dungeon_level)
1659            shop_items = [self._item_gen.roll_item(ilvl=self._dungeon_level, rng=rng) for _ in range(6)]
1660            self._shop_popup.setup(self._inventory, shop_items, player=self._player)
1661            # Close dialog first, then open shop
1662            self._popups.close()
1663            self._popups.open(self._shop_popup)
1664        elif action == "open_sell":
1665            import random
1666            rng = random.Random(self._dungeon_level)
1667            shop_items = [self._item_gen.roll_item(ilvl=self._dungeon_level, rng=rng) for _ in range(6)]
1668            self._shop_popup.setup(self._inventory, shop_items, player=self._player)
1669            self._shop_popup._selected_side = 1  # Start on player inventory (sell side)
1670            self._popups.close()
1671            self._popups.open(self._shop_popup)
1672        elif action == "open_quest_board":
1673            self._quest_board_popup.set_floor(self._dungeon_level)
1674            self._popups.close()
1675            self._popups.open(self._quest_board_popup)
1676        elif action == "open_blacksmith":
1677            self._popups.close()
1678            from scripts.blacksmith import upgrade
1679            # Find first group of 3 same-rarity items
1680            items_by_rarity: dict[int, list[int]] = {}
1681            for idx, item in self._inventory.items():
1682                r = int(item.rarity)
1683                items_by_rarity.setdefault(r, []).append(idx)
1684            forged = False
1685            for _, idxs in items_by_rarity.items():
1686                if len(idxs) >= 3:
1687                    result = upgrade(self._inventory, idxs[:3])
1688                    if result:
1689                        notif = LootDrop(f"Forged: {result.display_name}", colour=result.colour)
1690                        notif.position = Vec2(self._player.position.x, self._player.position.y - 20)
1691                        self.add_child(notif)
1692                        self._audio.play_sfx("level_up")
1693                        forged = True
1694                    break
1695            if not forged:
1696                notif = LootDrop("Need 3 items of the same rarity!", colour=(0.8, 0.4, 0.4, 1.0))
1697                notif.position = Vec2(self._player.position.x, self._player.position.y - 20)
1698                self.add_child(notif)
1699        elif action == "repair_equipment":
1700            if self._player.gold >= 50:
1701                self._player.gold -= 50
1702                repaired = 0
1703                for slot in ("weapon", "offhand", "head", "body", "feet", "ring", "neck"):
1704                    item = self._inventory.get_equipped(slot)
1705                    if item and item.durability < item.max_durability:
1706                        item.repair()
1707                        repaired += 1
1708                msg = f"Repaired {repaired} items!" if repaired > 0 else "All equipment in good shape!"
1709                notif = LootDrop(msg, colour=(0.4, 0.8, 0.4, 1.0))
1710                notif.position = Vec2(self._player.position.x, self._player.position.y - 20)
1711                self.add_child(notif)
1712        elif action == "open_enchanter":
1713            self._enchanter_popup.setup(self._inventory, player=self._player)
1714            self._popups.close()
1715            self._popups.open(self._enchanter_popup)
1716
1717    def _on_shop_transaction(self, txn_type, item, price):
1718        pass  # Gold is updated directly in ShopUI
1719
1720    # ── Quest rewards ─────────────────────────────────────────────────
1721
1722    def _on_quest_claimed(self, quest_name: str, rewards: dict):
1723        """Apply quest rewards to the player."""
1724        gold = rewards.get("gold", 0)
1725        xp = rewards.get("xp", 0)
1726        if gold:
1727            self._player.gold += gold
1728            self._stats.record_gold(gold)
1729        if xp:
1730            self._player.gain_xp(xp)
1731        self._stats.record_quest_complete()
1732        self._hud.notify_quest_complete(f"Claimed: {quest_name}")
1733
1734    # ── Waypoints / chests ─────────────────────────────────────────────
1735
1736    def _on_waypoint_teleport(self, floor: int):
1737        """Teleport player to a previously activated waypoint floor."""
1738        self._save_current_game()
1739        self._dungeon_level = floor
1740        self._transition.transition(lambda: self._load_dungeon(floor))
1741
1742    def _populate_special_objects(self, data, level):
1743        """Add chests and waypoints to dungeon data."""
1744        import random as rng_mod
1745        rng = rng_mod.Random(level)
1746        # Skip entrance room (first) and exit room (last) for chests
1747        safe_rooms = data.rooms[1:-1] if len(data.rooms) > 2 else []
1748        for room in safe_rooms:
1749            if rng.random() < 0.15:
1750                data.special_objects.append({"type": "chest", "gx": room.cx, "gy": room.cy})
1751        if level % 10 == 0:
1752            data.special_objects.append({"type": "waypoint", "gx": data.entrance[0], "gy": data.entrance[1]})
1753
1754    def _check_special_interact(self):
1755        """Check if the player is near a chest or waypoint and interact."""
1756        dungeon = self.find(DungeonLevel, direct=True)
1757        if not dungeon:
1758            return
1759        data = dungeon.dungeon_data
1760        px, py = self._player.position.x, self._player.position.y
1761        for obj in data.special_objects:
1762            ox = obj["gx"] * TILE_SIZE + TILE_SIZE / 2
1763            oy = obj["gy"] * TILE_SIZE + TILE_SIZE / 2
1764            dx, dy = px - ox, py - oy
1765            if dx * dx + dy * dy > 30 * 30:
1766                continue
1767            if obj["type"] == "chest":
1768                key = (self._dungeon_level, obj["gx"], obj["gy"])
1769                if key not in self._opened_chests:
1770                    self._opened_chests.add(key)
1771                    self._stats.record_chest_opened()
1772                    self._audio.play_sfx("chest_open")
1773                    # Sparkle particles on chest open
1774                    if self._particles:
1775                        self._particles.emit(
1776                            8, Vec2(ox, oy), vel_range=(-40, 40),
1777                            colour=(1.0, 0.85, 0.3, 1.0), lifetime=0.4,
1778                        )
1779                    import random
1780                    item = self._item_gen.roll_item(ilvl=self._dungeon_level, rng=random.Random())
1781                    pickup = LootPickup(item)
1782                    pickup.position = Vec2(ox, oy - 10)
1783                    self.add_child(pickup)
1784            elif obj["type"] == "waypoint":
1785                self.gm.activate_waypoint(self._dungeon_level)
1786                self._save_current_game()
1787                self._waypoint_popup.set_waypoints(self.gm.activated_waypoints)
1788                self._popups.open(self._waypoint_popup)
1789                break
1790
1791    # ── Draw ───────────────────────────────────────────────────────────
1792
1793    def _tile_visible(self, world_x: float, world_y: float) -> bool:
1794        """Check if a world position is in a currently visible fog tile."""
1795        if not self._fog:
1796            return True
1797        gx = int(world_x / TILE_SIZE)
1798        gy = int(world_y / TILE_SIZE)
1799        return self._fog.get_state(gx, gy) == 2
1800
1801    def on_draw(self, renderer):
1802        if self._state != STATE_DUNGEON:
1803            return
1804        dungeon = self.find(DungeonLevel, direct=True)
1805        if dungeon:
1806            ts = TILE_SIZE
1807            ep = dungeon.exit_world_pos()
1808            if self._tile_visible(ep.x, ep.y):
1809                renderer.draw_rect((ep.x - ts // 2, ep.y - ts // 2), (ts, ts), colour=(0.6, 0.1, 0.1, 0.8), filled=True)
1810                renderer.draw_text("EXIT", (ep.x - 14, ep.y - ts // 2 - 14), scale=1.2, colour=(1.0, 0.4, 0.4))
1811                if self._near(ep):
1812                    renderer.draw_text(
1813                        "[E] Descend", (ep.x - 30, ep.y + ts // 2 + 4),
1814                        scale=0.8, colour=(1.0, 0.9, 0.3),
1815                    )
1816            sp = dungeon.entrance_world_pos()
1817            if self._tile_visible(sp.x, sp.y):
1818                renderer.draw_rect((sp.x - ts // 2, sp.y - ts // 2), (ts, ts), colour=(0.1, 0.4, 0.2, 0.5), filled=True)
1819                renderer.draw_text("STAIRS", (sp.x - 18, sp.y - ts // 2 - 14), scale=1.0, colour=(0.4, 0.9, 0.5))
1820                if self._near(sp):
1821                    renderer.draw_text(
1822                        "[E] Return to town", (sp.x - 46, sp.y + ts // 2 + 4),
1823                        scale=0.8, colour=(1.0, 0.9, 0.3),
1824                    )
1825
1826            # Special objects (chests, waypoints): only draw if visible
1827            for obj in dungeon.dungeon_data.special_objects:
1828                ox = obj["gx"] * ts + ts // 2
1829                oy = obj["gy"] * ts + ts // 2
1830                if not self._tile_visible(ox, oy):
1831                    continue
1832                if obj["type"] == "chest":
1833                    key = (self._dungeon_level, obj["gx"], obj["gy"])
1834                    if key not in self._opened_chests:
1835                        renderer.draw_rect((ox - 7, oy - 2), (14, 8), colour=(0.6, 0.45, 0.15, 0.9), filled=True)
1836                        renderer.draw_rect((ox - 8, oy - 5), (16, 4), colour=(0.75, 0.6, 0.2, 0.9), filled=True)
1837                        renderer.draw_rect((ox - 1, oy - 3), (2, 3), colour=(0.9, 0.8, 0.3, 1.0), filled=True)
1838                        renderer.draw_text("?", (ox - 3, oy - 16), scale=1.0, colour=(1.0, 0.9, 0.3))
1839                elif obj["type"] == "waypoint":
1840                    renderer.draw_rect((ox - 3, oy - 2), (6, 12), colour=(0.5, 0.5, 0.55, 0.8), filled=True)
1841                    renderer.fill_triangle(ox, oy - 10, ox - 5, oy - 2, ox + 5, oy - 2,
1842                                                  colour=(0.2, 0.5, 1.0, 0.8))
1843                    renderer.draw_circle((ox, oy - 6), 3, colour=(0.4, 0.7, 1.0, 0.9), filled=True)
1844
1845        # Click-to-path visual feedback (drawn in world space)
1846        if game_settings.control_mode == "click_to_path":
1847            self._click_ctrl.draw(renderer)
1848
1849class _VirtualControlsDrawLayer(Node2D):
1850    """Screen-space overlay for virtual controls (drawn in CanvasLayer).
1851
1852    Lives in UILayer (UpdateMode.ALWAYS) so it processes even when the
1853    tree is paused, this is what makes the gamepad work inside popups.
1854
1855    Uses physics_process (runs before process in the frame) so injected
1856    key events are visible to game code in the same frame's process().
1857    """
1858
1859    def __init__(self, overlay, game, **kwargs):
1860        super().__init__(name="VirtualControlsLayer", **kwargs)
1861        self._overlay = overlay
1862        self._game = game
1863
1864    def on_fixed_update(self, dt: float):
1865        g = self._game
1866        overlay = self._overlay
1867        overlay.visible = game_settings.control_mode == "virtual_gamepad"
1868        if not overlay.visible:
1869            return
1870        sw, sh = (self.tree.screen_size if self.tree else (1280, 720))
1871        overlay.configure(sw, sh, mobile=g._is_mobile_web)
1872        near_interact = g._state == STATE_TOWN
1873        if not near_interact and g._state == STATE_DUNGEON:
1874            near_interact = g._is_near_interactable()
1875        in_menu = (
1876            g._state == STATE_TITLE
1877            or g._state == STATE_TOWN
1878            or g._popups.is_open
1879        )
1880        overlay.set_context(
1881            has_shield=g._player._has_shield,
1882            near_interactable=near_interact,
1883            in_menu=in_menu,
1884        )
1885        overlay.on_update(dt)
1886        # GamepadOverlay exposes joystick_value; the player consumes it via
1887        # _virtual_input. Skip propagation while a popup owns input.
1888        if not g._popups.is_open:
1889            g._player._virtual_input = overlay.joystick_value
1890
1891    def on_draw(self, renderer):
1892        self._overlay.on_draw(renderer)
1893
1894def main():
1895    debug = "--debug" in sys.argv
1896    app = App(
1897        title="Dungeon Explorer",
1898        width=1280,
1899        height=720,
1900        bg_colour=(0.08, 0.07, 0.06, 1.0),
1901    )
1902    app.run(DungeonGame(name="DungeonGame", debug=debug))
1903
1904if __name__ == "__main__":
1905    main()