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