nodes/boss_enemy.py¶
Part of Dungeon Explorer.
1"""Multi-phase boss enemy for level 100."""
2
3import math
4
5from events import BossDefeated, BossPhaseChanged
6from scripts.weapons import ranged_attack
7
8from simvx.core import Vec2
9
10from .enemy_base import DEATH, HIT, EnemyBase
11
12
13class BossEnemy(EnemyBase):
14 """Three-phase boss with melee, ranged, and enraged modes.
15
16 Phase 1 (>50% HP): Melee chase, aggressive pursuit.
17 Phase 2 (50-25% HP): Ranged kiting with projectiles.
18 Phase 3 (<25% HP): Enraged: faster and stronger.
19 """
20
21 # Phase transitions and death are surfaced as typed events on
22 # ``self.tree.events``: :class:`BossPhaseChanged`, :class:`BossDefeated`.
23
24 def __init__(self, **kwargs):
25 super().__init__(ai_type="chase", **kwargs)
26 self.hp = 500
27 self.max_hp = 500
28 self.damage = 25
29 self.speed = 50.0
30 self.xp_reward = 1000
31 self.detection_range = 400.0
32 self.attack_range = 28.0
33 self.attack_cooldown = 1.5
34 self.display_name = "Elder Dragon"
35 self._archetype = "elder_dragon"
36 self._colour = (0.8, 0.15, 0.0, 1.0)
37 self._base_speed = 50.0
38 self._base_damage = 25
39 self._phase = 1
40 self._charge_cooldown = 0.0
41 self._minion_timer = 0.0
42 self._minion_count = 0
43 self._slam_cooldown = 0.0
44 self._phase_ring_timer = 0.0 # Colour ring on phase transition
45 # Windup timers for telegraphed attacks
46 self._charge_windup = 0.0
47 self._charge_windup_dir = Vec2(0, 1)
48 self._slam_windup = 0.0
49 self._slam_fired = False # Flag for main.py to detect slam for screen shake
50
51 @property
52 def phase(self) -> int:
53 return self._phase
54
55 def take_damage(self, amount: int, from_pos: Vec2 | None = None) -> None:
56 """Apply damage; emit :class:`BossDefeated` when hp first reaches zero."""
57 was_alive = self.hp > 0
58 super().take_damage(amount, from_pos)
59 if was_alive and self.hp <= 0 and self.tree is not None:
60 self.tree.events.publish(BossDefeated(boss=self))
61
62 def on_fixed_update(self, dt: float):
63 if self._phase_ring_timer > 0:
64 self._phase_ring_timer -= dt
65
66 # Update phase based on HP ratio
67 ratio = self.hp / max(1, self.max_hp)
68 prev_phase = self._phase
69 if ratio > 0.5:
70 self._phase = 1
71 elif ratio > 0.25:
72 self._phase = 2
73 else:
74 self._phase = 3
75
76 if self._phase != prev_phase:
77 self._phase_ring_timer = 0.4
78 if self.tree is not None:
79 self.tree.events.publish(BossPhaseChanged(boss=self, new_phase=self._phase))
80
81 # Apply phase modifiers
82 if self._phase == 1:
83 self._ai_type = "chase"
84 self.speed = self._base_speed
85 self.damage = self._base_damage
86 self._colour = (0.8, 0.15, 0.0, 1.0)
87 elif self._phase == 2:
88 self._ai_type = "kite"
89 self.attack_range = 150.0
90 self.speed = self._base_speed * 0.8
91 self.damage = self._base_damage
92 self._colour = (0.6, 0.1, 0.5, 1.0)
93 else:
94 self._ai_type = "chase"
95 self.speed = self._base_speed * 1.6
96 self.damage = int(self._base_damage * 1.5)
97 self.attack_range = 28.0
98 self._colour = (1.0, 0.2, 0.0, 1.0)
99
100 self._do_phase_attacks(dt)
101 super().on_fixed_update(dt)
102
103 def _do_phase_attacks(self, dt: float):
104 """Phase-specific special attacks (in addition to base AI attacks)."""
105 self._charge_cooldown = max(0.0, self._charge_cooldown - dt)
106 self._slam_cooldown = max(0.0, self._slam_cooldown - dt)
107 self._minion_timer = max(0.0, self._minion_timer - dt)
108
109 # Charge windup countdown
110 if self._charge_windup > 0:
111 self._charge_windup -= dt
112 if self._charge_windup <= 0:
113 self._execute_charge()
114 return # Don't start other attacks during windup
115
116 # Slam windup countdown
117 if self._slam_windup > 0:
118 self._slam_windup -= dt
119 if self._slam_windup <= 0:
120 self._execute_slam()
121 return
122
123 if self._phase == 1 and self._charge_cooldown <= 0:
124 # Start charge windup
125 if self._target and self._dist_to_target() < 200:
126 self._charge_windup = 0.5
127 self._charge_windup_dir = self._dir_to_target()
128 self._charge_cooldown = 8.0
129
130 elif self._phase == 2 and self._minion_timer <= 0 and self._minion_count < 4:
131 # Spawn 2 minion demons
132 if self.parent:
133 from .enemy_types import create_enemy
134
135 for _ in range(2):
136 minion = create_enemy("demon", dungeon_level=max(1, int(self.max_hp / 500 * 10)))
137 minion.hp = minion.hp // 2
138 minion.max_hp = minion.hp
139 minion.position = Vec2(
140 self.position.x + (50 if self._minion_count % 2 == 0 else -50),
141 self.position.y + 30,
142 )
143 if self._target:
144 minion.setup(self._target, self._nav_grid)
145 self.parent.add_child(minion)
146 self._minion_count += 1
147 self._minion_timer = 15.0
148
149 elif self._phase == 3 and self._slam_cooldown <= 0:
150 # Start slam windup
151 if self._target and self._dist_to_target() < 120:
152 self._slam_windup = 0.8
153 self._slam_cooldown = 10.0
154
155 def _execute_charge(self):
156 """Execute the charge attack after windup completes."""
157 direction = self._charge_windup_dir
158 self.position = Vec2(
159 self.position.x + direction.x * 120,
160 self.position.y + direction.y * 120,
161 )
162 from .combat import Hitbox
163
164 hitbox = Hitbox(damage=int(self._base_damage * 2), direction=direction, name="EnemyHit")
165 hitbox.position = Vec2(self.position.x + direction.x * 15, self.position.y + direction.y * 15)
166 hitbox.radius = 22
167 hitbox.lifetime = 0.15
168 if self.parent:
169 self.parent.add_child(hitbox)
170
171 def _execute_slam(self):
172 """Execute ground slam after windup. Sets _slam_fired for screen shake."""
173 from .combat import Hitbox
174
175 hitbox = Hitbox(damage=int(self._base_damage * 2.5), direction=Vec2(0, 1), name="EnemyHit")
176 hitbox.position = Vec2(self.position.x, self.position.y)
177 hitbox.radius = 100
178 hitbox.lifetime = 0.25
179 if self.parent:
180 self.parent.add_child(hitbox)
181 self._slam_fired = True
182
183 def _do_attack(self):
184 """Boss attack: melee in phase 1/3, ranged in phase 2."""
185 if self._target is None or self.parent is None:
186 return
187 direction = self._dir_to_target()
188 if self._phase == 2:
189 # Ranged projectile (named EnemyProjectile so it targets the player)
190 proj = ranged_attack(self, direction, base_damage=self.damage, speed=200, max_range=400)
191 proj.name = "EnemyProjectile"
192 else:
193 # Melee hitbox (wider than normal)
194 from .combat import Hitbox
195
196 hitbox = Hitbox(damage=self.damage, direction=direction, name="EnemyHit")
197 hitbox.position = Vec2(
198 self.position.x + direction.x * 20,
199 self.position.y + direction.y * 20,
200 )
201 hitbox.lifetime = 0.15
202 if hasattr(hitbox, "radius"):
203 hitbox.radius = 20
204 self.parent.add_child(hitbox)
205
206 def on_draw(self, renderer):
207 if self._state == DEATH:
208 alpha = max(0.0, self._death_timer / 0.4)
209 colour = (self._colour[0], self._colour[1], self._colour[2], alpha)
210 elif self._state == HIT:
211 colour = (1.0, 1.0, 1.0, 1.0)
212 else:
213 colour = self._colour
214
215 px, py = self.position.x, self.position.y
216
217 # Charge windup: red pulsing outline
218 if self._charge_windup > 0:
219 pulse = 0.5 + 0.5 * math.sin(self._charge_windup * 20.0)
220 renderer.draw_rect((px - 20, py - 20), (40, 2), colour=(1.0, 0.2, 0.0, pulse), filled=True)
221 renderer.draw_rect((px - 20, py + 18), (40, 2), colour=(1.0, 0.2, 0.0, pulse), filled=True)
222 renderer.draw_rect((px - 20, py - 20), (2, 40), colour=(1.0, 0.2, 0.0, pulse), filled=True)
223 renderer.draw_rect((px + 18, py - 20), (2, 40), colour=(1.0, 0.2, 0.0, pulse), filled=True)
224
225 # Slam windup: expanding floor ring
226 if self._slam_windup > 0:
227 t = 1.0 - self._slam_windup / 0.8
228 ring_r = t * 100
229 ring_alpha = 0.3 + 0.3 * math.sin(t * 12.0)
230 segments = 16
231 for i in range(segments):
232 angle = (i / segments) * math.pi * 2
233 rx = px + math.cos(angle) * ring_r
234 ry = py + math.sin(angle) * ring_r
235 renderer.draw_rect((rx - 2, ry - 2), (4, 4), colour=(1.0, 0.4, 0.1, ring_alpha), filled=True)
236
237 # Phase transition colour ring
238 if self._phase_ring_timer > 0:
239 ring_alpha = self._phase_ring_timer / 0.4
240 ring_t = 1.0 - self._phase_ring_timer / 0.4
241 ring_r = 20 + ring_t * 40
242 phase_colours = {
243 1: (0.8, 0.15, 0.0),
244 2: (0.6, 0.1, 0.5),
245 3: (1.0, 0.2, 0.0),
246 }
247 rc = phase_colours.get(self._phase, (1.0, 0.5, 0.0))
248 segments = 12
249 for i in range(segments):
250 angle = (i / segments) * math.pi * 2
251 rx = px + math.cos(angle) * ring_r
252 ry = py + math.sin(angle) * ring_r
253 renderer.draw_rect(
254 (rx - 2, ry - 2), (4, 4), colour=(rc[0], rc[1], rc[2], ring_alpha * 0.8), filled=True
255 )
256
257 # Larger body for boss (16px radius vs 10px)
258 r = 16
259 renderer.draw_rect((px - r, py - r), (r * 2, r * 2), colour=colour, filled=True)
260
261 # Phase indicator with colour coding
262 if self._state != DEATH:
263 phase_colours_text = {1: (1.0, 0.5, 0.0, 0.8), 2: (0.7, 0.2, 0.9, 0.8), 3: (1.0, 0.15, 0.0, 0.9)}
264 phase_c = phase_colours_text.get(self._phase, (1.0, 0.5, 0.0, 0.8))
265 phase_text = f"Phase {self._phase}"
266 renderer.draw_text(phase_text, (px - 15, py - r - 18), scale=0.7, colour=phase_c)
267
268 # Health bar
269 if self.hp < self.max_hp and self._state != DEATH:
270 bar_w = r * 2
271 bar_h = 3
272 bar_x = px - r
273 bar_y = py - r - 6
274 renderer.draw_rect((bar_x, bar_y), (bar_w, bar_h), colour=(0.3, 0.0, 0.0, 0.8), filled=True)
275 fill_w = bar_w * (self.hp / max(1, self.max_hp))
276 renderer.draw_rect((bar_x, bar_y), (fill_w, bar_h), colour=(0.9, 0.1, 0.1, 0.8), filled=True)