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