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