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