nodes/enemy_types.pyΒΆ

Part of Dungeon Explorer.

  1"""Concrete enemy variants: melee, ranged, swarm, boss."""
  2
  3import random as _rng_mod
  4
  5from simvx.core import Vec2
  6
  7from .enemy_base import (
  8    AFFIX_FAST,
  9    ALL_AFFIXES,
 10    ATTACK,
 11    CHASE,
 12    IDLE,
 13    EnemyBase,
 14)
 15
 16_ARCHETYPE_COLOURS = {
 17    "skeleton": (0.85, 0.82, 0.75, 1.0),
 18    "archer_skeleton": (0.7, 0.5, 0.8, 1.0),
 19    "slime": (0.3, 0.8, 0.2, 1.0),
 20    "bat_swarm": (0.4, 0.3, 0.25, 1.0),
 21    "goblin": (0.35, 0.55, 0.25, 1.0),
 22    "wraith": (0.5, 0.3, 0.7, 0.6),
 23    "golem": (0.5, 0.45, 0.35, 1.0),
 24    "demon": (0.85, 0.15, 0.1, 1.0),
 25}
 26
 27
 28class MeleeEnemy(EnemyBase):
 29    """Standard melee enemy that chases and attacks."""
 30
 31    def __init__(self, **kwargs):
 32        super().__init__(ai_type="chase", **kwargs)
 33        self._colour = (0.8, 0.3, 0.2, 1.0)
 34
 35
 36class RangedEnemy(EnemyBase):
 37    """Ranged enemy that kites: approaches to range then retreats. Hits hard from distance."""
 38
 39    def __init__(self, **kwargs):
 40        super().__init__(ai_type="kite", **kwargs)
 41        self.attack_range = 180.0
 42        self.attack_cooldown = 0.8  # Faster than melee default (1.0)
 43        self._colour = (0.6, 0.2, 0.7, 1.0)
 44
 45
 46class SwarmEnemy(EnemyBase):
 47    """Weak, fast enemy that chases aggressively."""
 48
 49    def __init__(self, **kwargs):
 50        super().__init__(ai_type="chase", **kwargs)
 51        self.hp = 10
 52        self.max_hp = 10
 53        self.speed = 90.0
 54        self.damage = 2
 55        self.attack_range = 24.0  # Same as default: attack when close
 56        self.attack_cooldown = 0.4  # Attacks frequently
 57        self._colour = (0.4, 0.3, 0.2, 1.0)
 58
 59
 60class PatrolEnemy(EnemyBase):
 61    """Slow, tough enemy that patrols an area."""
 62
 63    def __init__(self, **kwargs):
 64        super().__init__(ai_type="patrol", **kwargs)
 65        self.hp = 80
 66        self.max_hp = 80
 67        self.speed = 30.0
 68        self.damage = 15
 69        self.detection_range = 120.0
 70        self._colour = (0.5, 0.5, 0.3, 1.0)
 71
 72
 73class WraithEnemy(RangedEnemy):
 74    """Wraith: phases through walls periodically."""
 75
 76    def __init__(self, **kwargs):
 77        super().__init__(**kwargs)
 78        self._phase_timer = 0.0
 79        self._phasing = False
 80
 81    def on_fixed_update(self, dt: float):
 82        self._phase_timer += dt
 83        # Phase through walls for 2s every 6s
 84        self._phasing = (self._phase_timer % 6.0) < 2.0
 85        super().on_fixed_update(dt)
 86
 87    def _process_chase(self, dt: float):
 88        """Override chase to ignore walls while phasing."""
 89        if self._phasing and self._target:
 90            dist = self._dist_to_target()
 91            if dist > self.detection_range * 1.5:
 92                self._state = IDLE
 93                return
 94            if dist <= self.attack_range and self._attack_timer <= 0:
 95                self._state = ATTACK
 96                return
 97            # Direct movement ignoring walls
 98            direction = self._dir_to_target()
 99            self.velocity = direction * self.speed
100            self.position = Vec2(
101                self.position.x + self.velocity.x * dt,
102                self.position.y + self.velocity.y * dt,
103            )
104        else:
105            super()._process_chase(dt)
106
107
108class GolemEnemy(PatrolEnemy):
109    """Golem: ground-pound AoE attack."""
110
111    def __init__(self, **kwargs):
112        super().__init__(**kwargs)
113        self._slam_cooldown = 0.0
114
115    def on_fixed_update(self, dt: float):
116        self._slam_cooldown = max(0.0, self._slam_cooldown - dt)
117        super().on_fixed_update(dt)
118        # Ground pound when target is close
119        if self._state == CHASE and self._target and self._slam_cooldown <= 0 and self._dist_to_target() < 60:
120            self._ground_pound()
121
122    def _ground_pound(self):
123        """Large AoE slam centred on self."""
124        if self.parent is None:
125            return
126        from .combat import Hitbox
127
128        hitbox = Hitbox(damage=int(self.damage * 1.5), direction=Vec2(0, 1), name="EnemyHit")
129        hitbox.position = Vec2(self.position.x, self.position.y)
130        if hasattr(hitbox, "radius"):
131            hitbox.radius = 50
132        hitbox.lifetime = 0.2
133        self.parent.add_child(hitbox)
134        self._slam_cooldown = 8.0
135
136
137class DemonEnemy(MeleeEnemy):
138    """Demon: charge attack toward player."""
139
140    def __init__(self, **kwargs):
141        super().__init__(**kwargs)
142        self._charge_cooldown = 0.0
143
144    def on_fixed_update(self, dt: float):
145        self._charge_cooldown = max(0.0, self._charge_cooldown - dt)
146        super().on_fixed_update(dt)
147        # Charge when target is in range
148        if self._state == CHASE and self._target and self._charge_cooldown <= 0 and 50 < self._dist_to_target() < 200:
149            self._charge_attack()
150
151    def _charge_attack(self):
152        """Dash toward player with a damaging hitbox."""
153        if self.parent is None or self._target is None:
154            return
155        direction = self._dir_to_target()
156        self.position = Vec2(
157            self.position.x + direction.x * 80,
158            self.position.y + direction.y * 80,
159        )
160        from .combat import Hitbox
161
162        hitbox = Hitbox(damage=int(self.damage * 1.8), direction=direction, name="EnemyHit")
163        hitbox.position = Vec2(
164            self.position.x + direction.x * 15,
165            self.position.y + direction.y * 15,
166        )
167        hitbox.lifetime = 0.15
168        self.parent.add_child(hitbox)
169        self._charge_cooldown = 6.0
170
171
172def create_enemy(archetype: str, dungeon_level: int = 1) -> EnemyBase:
173    """Factory: create a scaled enemy from an archetype name.
174
175    Scaling (linear, balanced for 100 levels):
176        HP *= 1 + (level-1) * 0.20   (level 100 = ~21x base)
177        Damage *= 1 + (level-1) * 0.12  (level 100 = ~13x base)
178        Speed *= 1 + (level-1) * 0.006  (level 100 = ~1.6x base)
179        XP *= 1 + (level-1) * 0.06  (level 100 = ~7x base)
180    """
181    # (class, base_hp, base_dmg, base_speed, base_xp)
182    # Ranged enemies deal significantly more damage to compensate for kiting safety
183    configs = {
184        "skeleton": (MeleeEnemy, 40, 5, 60, 10),  # Basic melee, moderate damage
185        "archer_skeleton": (RangedEnemy, 25, 12, 50, 15),  # Ranged: high damage, low HP
186        "slime": (SwarmEnemy, 20, 3, 45, 5),  # Weak but numerous
187        "bat_swarm": (SwarmEnemy, 12, 3, 95, 4),  # Fast, low damage
188        "goblin": (MeleeEnemy, 35, 7, 75, 12),  # Slightly tougher melee
189        "wraith": (WraithEnemy, 45, 15, 55, 25),  # Ranged: very high damage
190        "golem": (GolemEnemy, 100, 12, 30, 40),  # Tanky, moderate damage
191        "demon": (DemonEnemy, 75, 18, 65, 50),  # Late-game, strong melee
192    }
193
194    cls, base_hp, base_dmg, base_spd, base_xp = configs.get(archetype, (MeleeEnemy, 30, 5, 60, 10))
195    level = max(1, dungeon_level)
196    lm = level - 1  # Level multiplier base (0 at level 1)
197
198    enemy = cls(name=archetype.replace("_", " ").title())
199    enemy._archetype = archetype
200    enemy._colour = _ARCHETYPE_COLOURS.get(archetype, (0.8, 0.2, 0.2, 1.0))
201    enemy.hp = int(base_hp * (1 + lm * 0.20))
202    enemy.max_hp = enemy.hp
203    enemy.damage = int(base_dmg * (1 + lm * 0.12))
204    enemy.speed = base_spd * (1 + lm * 0.006)
205    enemy.xp_reward = int(base_xp * (1 + lm * 0.05))
206    enemy.display_name = archetype.replace("_", " ").title()
207    return enemy
208
209
210def make_elite(enemy: EnemyBase, rng: _rng_mod.Random | None = None) -> EnemyBase:
211    """Promote an enemy to elite status: 2x HP, 1.5x damage, random affix."""
212    rng = rng or _rng_mod.Random()
213    enemy._is_elite = True
214    enemy.hp = int(enemy.hp * 2)
215    enemy.max_hp = int(enemy.max_hp * 2)
216    enemy.damage = int(enemy.damage * 1.5)
217    enemy.xp_reward = int(enemy.xp_reward * 2.5)
218    # 1-2 random affixes
219    count = rng.randint(1, 2)
220    enemy._elite_affixes = rng.sample(ALL_AFFIXES, min(count, len(ALL_AFFIXES)))
221    # Apply fast affix immediately
222    if AFFIX_FAST in enemy._elite_affixes:
223        enemy.speed *= 1.4
224    enemy.display_name = f"Elite {enemy.display_name}"
225    return enemy