Dungeon Explorer

Play Demo

Dungeon Explorer — 2D top-down dungeon crawler.

Run: uv run python games/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 Code

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