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