Dungeon Explorer

a 100-floor action-RPG descent with loot, skills, and a village hub.

▶ Run in browser

Fight down through procedurally generated floors, collect randomised loot with rolled affixes and rarities, spend skill points, and finish the run against a three-phase boss on floor 100. Every tenth floor returns you to a village hub with a shop, an inn, a quest board, an enchanter, a forge, and a seed-rerolling well. Beating the boss starts New Game+ with your gear and a harder dungeon.

A flagship showcase of the engine’s 2D stack:

  • BSP dungeon generation stamped into an instanced TileMap (one draw for the whole floor), with per-floor themes, secret rooms, traps, and chests.

  • CharacterBody2D movement and collision layers for player and enemies, plus NavGrid2D pathfinding behind the chase and kite AI (fleeing just runs the other way).

  • Combat built from weapon profiles: melee arcs, projectiles, crits, knockback, dodge-roll invincibility frames, shield blocks, and hit-stop.

  • Fog of war as a dimming layer on the floor TileMap, recomputed only when the player crosses a tile boundary.

  • One-call save/load: SaveManager snapshots every persist=True Property in the tree, with the non-Node subsystems parked on a SaveBag node.

  • Typed gameplay events (deaths, level-ups, boss phases, hotbar presses) on the SceneTree event bus, which holds handlers weakly.

  • Twenty anchored Control screens (inventory, skill tree, shops, quest log, stats, achievements, settings, …) and procedural sound effects baked with AudioSynth: the demo ships no audio files.

  • Playable with keyboard, mouse, or touch: Settings switches between keyboard, click-to-path, and an on-screen virtual gamepad.

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

Saves are written to $XDG_DATA_HOME/simvx/dungeon_explorer/ (~/.local/share when that variable is unset), never beside the game; set $DUNGEON_EXPLORER_SAVE_DIR to put them somewhere else.

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

Source files

File

Summary

Lines

main.py

Dungeon Explorer: a 100-floor action-RPG descent with loot, skills, and a village hub.

1988

collision_layers.py

Collision layers for Dungeon Explorer.

29

data/__init__.py

0

data/dialogs.py

NPC dialog trees: Python dicts with callable conditions and actions.

171

events.py

Typed event dataclasses for the Dungeon Explorer game.

87

nodes/__init__.py

Dungeon Explorer scene-graph node classes (Node2D, Popup, CharacterBody2D, etc.).

4

nodes/_back_button.py

Back-button helpers shared across popup screens.

65

nodes/abilities.py

Active ability implementations with visual effects.

372

nodes/achievements.py

Achievement system: unlock conditions, popup notifications, and log.

206

nodes/audio_manager.py

Centralised audio manager: procedural SFX via the engine AudioSynth API.

175

nodes/boss_enemy.py

Multi-phase boss enemy for level 100.

276

nodes/boss_health_bar.py

Boss HP bar overlay: displayed during boss encounters.

40

nodes/camera_controller.py

Camera2D controller: smooth follow with room bounds clamping and directional shake.

86

nodes/combat.py

Combat system: hitbox management, damage formulas, cooldowns.

140

nodes/confirm_dialog.py

Reusable Yes/No confirmation modal.

124

nodes/credits_screen.py

Animated credits screen with scrolling text.

139

nodes/death_screen.py

Death / respawn overlay: red vignette, stat summary, pulsing respawn prompt.

130

nodes/dialog_ui.py

Dialog box: NPC portrait area, proper dialog box, typewriter text reveal.

269

nodes/enchanter.py

Enchanter NPC: add a random affix to equipment for gold.

203

nodes/enemy_base.py

Base enemy: CharacterBody2D with AI state machine and pathfinding.

597

nodes/enemy_types.py

Concrete enemy variants: melee, ranged, swarm, boss.

225

nodes/game_manager.py

Game manager: global state, scene transitions, and save/load orchestration.

135

nodes/hotbar_assign_ui.py

Hotbar assignment popup: assign unlocked active abilities to hotbar slots.

217

nodes/inn.py

Inn: rest/heal for gold.

25

nodes/inventory_ui.py

Inventory popup: 48px cells, drawn icons, rarity borders, equipment panel, mouse support.

617

nodes/item_tooltip.py

Item tooltip: hover panel showing item details with rarity-coloured stats.

70

nodes/level_up_ui.py

Stat point allocation screen shown on level up.

118

nodes/loading_screen.py

Loading screen: ‘Generating Dungeon…’ with progress indicator.

117

nodes/loot_drop.py

Floating loot notification: coloured text that drifts upward and fades.

29

nodes/loot_pickup.py

World item pickup: dropped by enemies, collected by player proximity.

79

nodes/particles2d.py

Simple 2D particle system for visual effects.

282

nodes/pause_menu.py

Pause overlay: centred panel, animated selector, floor/level/gold display.

231

nodes/player.py

Player character: CharacterBody2D with 8-direction movement, combat, and dodge roll.

410

nodes/player_hud.py

Player HUD: HP/XP bars (rounded), minimap, level label, hotbar with drawn icons.

565

nodes/player_pause_bridge.py

PlayerPauseBridge: toggles only the player’s update_mode on overlay events.

106

nodes/projectile.py

Projectile node for ranged attacks: travels in a direction, damages on contact.

109

nodes/quest_board.py

Quest board popup: rotating selection of quests with scroll support.

308

nodes/quest_log_ui.py

Quest log popup: active/completed tabs, progress bars, type icons, claim indicators.

253

nodes/settings_ui.py

Settings menu: audio, display, gameplay options as a Control overlay.

256

nodes/shop.py

Shop: dual-panel buy/sell UI with comparison tooltips and buy confirmation.

413

nodes/skill_tree_ui.py

Skill tree popup UI: tree lines, unique drawn icons, glow effects, full tooltip.

434

nodes/stats_tracker.py

Statistics tracker: per-run and lifetime game statistics.

247

nodes/title_screen.py

Title screen: New Game / Continue / Quit with decorative styling.

420

nodes/town.py

Town hub: safe zone between dungeon runs with NPCs.

72

nodes/town_npc.py

Town NPC base: dialog trigger and trade interface.

75

nodes/transition_overlay.py

Fade-to-black transition overlay.

68

nodes/victory_screen.py

Victory sequence: gold theme, particle fireworks, final stats, credits option.

237

nodes/village.py

Tile-based village: replaces the flat brown town.

377

nodes/waypoint_ui.py

Waypoint selection popup: teleport to previously visited floors.

123

nodes/well.py

The Well: coin toss to re-roll the dungeon, resets save points.

34

scripts/__init__.py

0

scripts/blacksmith.py

Blacksmith: combine 3 same-rarity items into 1 next-rarity item.

44

scripts/click_controller.py

Click-to-path controller – click ground to move, click enemy to attack, click interactable to interact.

239

scripts/dungeon_generator.py

BSP-based dungeon generator: rooms, corridors, floor/wall TileMap + collision.

932

scripts/dungeon_theme.py

Per-tier visual theming: tile palette and enemy pool by dungeon depth.

63

scripts/enemy_spawner.py

Room-based enemy spawning for dungeon levels.

129

scripts/fog_of_war.py

Per-tile fog of war visibility tracking.

79

scripts/inventory.py

Inventory grid logic: add/remove/equip/stack items.

145

scripts/item.py

Item class: base template + affixes + stat rolls.

181

scripts/item_generator.py

Loot generator: roll random items from TOML data files.

197

scripts/mini_boss.py

Mini-boss enemies: named elites on floors 25/50/75 with unique visuals.

62

scripts/player_skills.py

Skill tree: unlock, cooldown, passive bonus tracking.

143

scripts/player_stats.py

Derived stat computation: base + gear + level bonuses.

72

scripts/quests.py

Quest system: tracking, progression, and rewards.

170

scripts/room_templates.py

Pre-designed room shapes, decoration rules, and visual room decorations.

121

scripts/save_state.py

SaveBag: a Node holding persist=True Property snapshots of game-state

34

scripts/tutorial.py

Tutorial system: contextual tooltips for first-time players.

92

scripts/weapons.py

Weapon behaviours: melee arc and ranged projectile attacks.

90

Source

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