Dungeon Explorer

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