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