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