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 a node’s 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 controller.

Run: simvx examples run demos_dungeon_explorer

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.

1947

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.

86

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.

57

nodes/abilities.py

Active ability implementations with visual effects.

366

nodes/achievements.py

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

202

nodes/audio_manager.py

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

190

nodes/boss_enemy.py

Multi-phase boss enemy for level 100.

271

nodes/boss_health_bar.py

Boss HP bar overlay: displayed during boss encounters.

39

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.

123

nodes/credits_screen.py

Animated credits screen with scrolling text.

138

nodes/death_screen.py

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

129

nodes/dialog_ui.py

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

259

nodes/enchanter.py

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

200

nodes/enemy_base.py

Base enemy: CharacterBody2D with AI state machine and pathfinding.

596

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.

211

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.

613

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.

116

nodes/loading_screen.py

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

115

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.

78

nodes/particles2d.py

Simple 2D particle system for visual effects.

278

nodes/pause_menu.py

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

226

nodes/player.py

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

434

nodes/player_hud.py

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

540

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.

108

nodes/quest_board.py

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

294

nodes/quest_log_ui.py

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

248

nodes/settings_ui.py

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

249

nodes/shop.py

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

405

nodes/skill_tree_ui.py

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

427

nodes/stats_tracker.py

Statistics tracker: per-run and lifetime game statistics.

246

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.

74

nodes/transition_overlay.py

Fade-to-black transition overlay.

66

nodes/victory_screen.py

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

235

nodes/village.py

Tile-based village: replaces the flat brown town.

366

nodes/waypoint_ui.py

Waypoint selection popup: teleport to previously visited floors.

120

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.

238

scripts/dungeon_generator.py

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

930

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