scripts/enemy_spawner.pyΒΆ
Part of Dungeon Explorer.
1"""Room-based enemy spawning for dungeon levels."""
2
3import random
4
5from nodes.enemy_types import create_enemy, make_elite
6
7from simvx.core import NavGrid2D, Vec2
8
9from .dungeon_generator import TILE_SIZE, DungeonData, Room
10
11# Floors where mini-bosses spawn
12MINI_BOSS_FLOORS = {25, 50, 75}
13
14
15def get_enemy_pool(dungeon_level: int) -> list[str]:
16 """Return list of valid enemy archetypes for a given dungeon level."""
17 pool = []
18 enemy_ranges = {
19 "slime": (1, 20),
20 "bat_swarm": (1, 30),
21 "skeleton": (1, 40),
22 "goblin": (5, 50),
23 "archer_skeleton": (5, 50),
24 "wraith": (20, 70),
25 "golem": (30, 80),
26 "demon": (50, 100),
27 }
28 for name, (lo, hi) in enemy_ranges.items():
29 if lo <= dungeon_level <= hi:
30 pool.append(name)
31 return pool or ["skeleton"]
32
33
34def spawn_enemies_for_room(
35 room: Room,
36 dungeon_level: int,
37 nav_grid: NavGrid2D | None,
38 player,
39 data: DungeonData | None = None,
40 rng: random.Random | None = None,
41 min_enemies: int = 1,
42 max_enemies: int = 4,
43 difficulty_mults: tuple[float, float, float, float] = (1.0, 1.0, 1.0, 1.0),
44) -> list:
45 """Spawn enemies in a room. Returns list of enemy nodes."""
46 rng = rng or random.Random()
47 pool = get_enemy_pool(dungeon_level)
48 count = rng.randint(min_enemies, max_enemies)
49 enemies = []
50 hp_mult, dmg_mult, xp_mult, _ = difficulty_mults
51
52 for _ in range(count):
53 archetype = rng.choice(pool)
54 enemy = create_enemy(archetype, dungeon_level)
55 # Apply difficulty multipliers
56 enemy.hp = int(enemy.hp * hp_mult)
57 enemy.max_hp = enemy.hp
58 enemy.damage = int(enemy.damage * dmg_mult)
59 enemy.xp_reward = int(enemy.xp_reward * xp_mult)
60
61 # Elite chance scales with dungeon level: ~2% at level 1, ramps to ~15% by level 20+
62 elite_chance = min(0.15, 0.02 + (dungeon_level - 1) * 0.007)
63 if rng.random() < elite_chance:
64 make_elite(enemy, rng)
65
66 # Random walkable position within room bounds
67 gx = rng.randint(room.x + 1, room.x + room.w - 2)
68 gy = rng.randint(room.y + 1, room.y + room.h - 2)
69 if data is not None:
70 # Retry up to 10 times to find a walkable cell
71 for _ in range(10):
72 if data.is_walkable(gx, gy):
73 break
74 gx = rng.randint(room.x + 1, room.x + room.w - 2)
75 gy = rng.randint(room.y + 1, room.y + room.h - 2)
76 else:
77 if not data.is_walkable(gx, gy):
78 continue # Skip this enemy entirely
79 enemy.position = Vec2(gx * TILE_SIZE + TILE_SIZE // 2, gy * TILE_SIZE + TILE_SIZE // 2)
80 enemy.setup(player, nav_grid)
81 enemies.append(enemy)
82
83 return enemies
84
85
86def spawn_all_dungeon_enemies(
87 data: DungeonData,
88 dungeon_level: int,
89 nav_grid: NavGrid2D | None,
90 player,
91 seed: int | None = None,
92 difficulty_mults: tuple[float, float, float, float] = (1.0, 1.0, 1.0, 1.0),
93) -> list:
94 """Spawn enemies in all rooms except entrance room. Returns flat list."""
95 rng = random.Random(seed)
96 all_enemies = []
97
98 for i, room in enumerate(data.rooms):
99 # Skip entrance room (first room)
100 if i == 0:
101 continue
102 # Scale enemy count with dungeon level
103 max_e = min(3 + dungeon_level // 10, 6)
104 enemies = spawn_enemies_for_room(
105 room,
106 dungeon_level,
107 nav_grid,
108 player,
109 data,
110 rng,
111 max_enemies=max_e,
112 difficulty_mults=difficulty_mults,
113 )
114 all_enemies.extend(enemies)
115
116 # Mini-boss floors place their mini-boss in the last room
117 if dungeon_level in MINI_BOSS_FLOORS and len(data.rooms) >= 2:
118 from .mini_boss import create_mini_boss
119
120 last_room = data.rooms[-1]
121 boss = create_mini_boss(dungeon_level, rng)
122 boss.position = Vec2(
123 (last_room.x + last_room.w // 2) * TILE_SIZE + TILE_SIZE // 2,
124 (last_room.y + last_room.h // 2) * TILE_SIZE + TILE_SIZE // 2,
125 )
126 boss.setup(player, nav_grid)
127 all_enemies.append(boss)
128
129 return all_enemies