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 self.tree.events.publish(BossPhaseChanged(boss=self, new_phase=self._phase))
79
80 # Apply phase modifiers
81 if self._phase == 1:
82 self._ai_type = "chase"
83 self.speed = self._base_speed
84 self.damage = self._base_damage
85 self._colour = (0.8, 0.15, 0.0, 1.0)
86 elif self._phase == 2:
87 self._ai_type = "kite"
88 self.attack_range = 150.0
89 self.speed = self._base_speed * 0.8
90 self.damage = self._base_damage
91 self._colour = (0.6, 0.1, 0.5, 1.0)
92 else:
93 self._ai_type = "chase"
94 self.speed = self._base_speed * 1.6
95 self.damage = int(self._base_damage * 1.5)
96 self.attack_range = 28.0
97 self._colour = (1.0, 0.2, 0.0, 1.0)
98
99 self._do_phase_attacks(dt)
100 super().on_fixed_update(dt)
101
102 def _do_phase_attacks(self, dt: float):
103 """Phase-specific special attacks (in addition to base AI attacks)."""
104 self._charge_cooldown = max(0.0, self._charge_cooldown - dt)
105 self._slam_cooldown = max(0.0, self._slam_cooldown - dt)
106 self._minion_timer = max(0.0, self._minion_timer - dt)
107
108 # Charge windup countdown
109 if self._charge_windup > 0:
110 self._charge_windup -= dt
111 if self._charge_windup <= 0:
112 self._execute_charge()
113 return # Don't start other attacks during windup
114
115 # Slam windup countdown
116 if self._slam_windup > 0:
117 self._slam_windup -= dt
118 if self._slam_windup <= 0:
119 self._execute_slam()
120 return
121
122 if self._phase == 1 and self._charge_cooldown <= 0:
123 # Start charge windup
124 if self._target and self._dist_to_target() < 200:
125 self._charge_windup = 0.5
126 self._charge_windup_dir = self._dir_to_target()
127 self._charge_cooldown = 8.0
128
129 elif self._phase == 2 and self._minion_timer <= 0 and self._minion_count < 4:
130 # Spawn 2 minion demons
131 if self.parent:
132 from .enemy_types import create_enemy
133
134 for _ in range(2):
135 minion = create_enemy("demon", dungeon_level=max(1, int(self.max_hp / 500 * 10)))
136 minion.hp = minion.hp // 2
137 minion.max_hp = minion.hp
138 minion.position = Vec2(
139 self.position.x + (50 if self._minion_count % 2 == 0 else -50),
140 self.position.y + 30,
141 )
142 if self._target:
143 minion.setup(self._target, self._nav_grid)
144 self.parent.add_child(minion)
145 self._minion_count += 1
146 self._minion_timer = 15.0
147
148 elif self._phase == 3 and self._slam_cooldown <= 0:
149 # Start slam windup
150 if self._target and self._dist_to_target() < 120:
151 self._slam_windup = 0.8
152 self._slam_cooldown = 10.0
153
154 def _execute_charge(self):
155 """Execute the charge attack after windup completes."""
156 direction = self._charge_windup_dir
157 self.position = Vec2(
158 self.position.x + direction.x * 120,
159 self.position.y + direction.y * 120,
160 )
161 from .combat import Hitbox
162
163 hitbox = Hitbox(damage=int(self._base_damage * 2), direction=direction, name="EnemyHit")
164 hitbox.position = Vec2(self.position.x + direction.x * 15, self.position.y + direction.y * 15)
165 hitbox.radius = 22
166 hitbox.lifetime = 0.15
167 if self.parent:
168 self.parent.add_child(hitbox)
169
170 def _execute_slam(self):
171 """Execute ground slam after windup. Sets _slam_fired for screen shake."""
172 from .combat import Hitbox
173
174 hitbox = Hitbox(damage=int(self._base_damage * 2.5), direction=Vec2(0, 1), name="EnemyHit")
175 hitbox.position = Vec2(self.position.x, self.position.y)
176 hitbox.radius = 100
177 hitbox.lifetime = 0.25
178 if self.parent:
179 self.parent.add_child(hitbox)
180 self._slam_fired = True
181
182 def _do_attack(self):
183 """Boss attack: melee in phase 1/3, ranged in phase 2."""
184 if self._target is None or self.parent is None:
185 return
186 direction = self._dir_to_target()
187 if self._phase == 2:
188 # Ranged projectile (named EnemyProjectile so it targets the player)
189 proj = ranged_attack(self, direction, base_damage=self.damage, speed=200, max_range=400)
190 proj.name = "EnemyProjectile"
191 else:
192 # Melee hitbox (wider than normal)
193 from .combat import Hitbox
194
195 hitbox = Hitbox(damage=self.damage, direction=direction, name="EnemyHit")
196 hitbox.position = Vec2(
197 self.position.x + direction.x * 20,
198 self.position.y + direction.y * 20,
199 )
200 hitbox.lifetime = 0.15
201 if hasattr(hitbox, "radius"):
202 hitbox.radius = 20
203 self.parent.add_child(hitbox)
204
205 def on_draw(self, renderer):
206 if self._state == DEATH:
207 alpha = max(0.0, self._death_timer / 0.4)
208 colour = (self._colour[0], self._colour[1], self._colour[2], alpha)
209 elif self._state == HIT:
210 colour = (1.0, 1.0, 1.0, 1.0)
211 else:
212 colour = self._colour
213
214 # Charge windup: red pulsing outline
215 if self._charge_windup > 0:
216 pulse = 0.5 + 0.5 * math.sin(self._charge_windup * 20.0)
217 renderer.rect((-20, -20), (40, 2), fill=(1.0, 0.2, 0.0, pulse))
218 renderer.rect((-20, 18), (40, 2), fill=(1.0, 0.2, 0.0, pulse))
219 renderer.rect((-20, -20), (2, 40), fill=(1.0, 0.2, 0.0, pulse))
220 renderer.rect((18, -20), (2, 40), fill=(1.0, 0.2, 0.0, pulse))
221
222 # Slam windup: expanding floor ring
223 if self._slam_windup > 0:
224 t = 1.0 - self._slam_windup / 0.8
225 ring_r = t * 100
226 ring_alpha = 0.3 + 0.3 * math.sin(t * 12.0)
227 segments = 16
228 for i in range(segments):
229 angle = (i / segments) * math.pi * 2
230 rx = math.cos(angle) * ring_r
231 ry = math.sin(angle) * ring_r
232 renderer.rect((rx - 2, ry - 2), (4, 4), fill=(1.0, 0.4, 0.1, ring_alpha))
233
234 # Phase transition colour ring
235 if self._phase_ring_timer > 0:
236 ring_alpha = self._phase_ring_timer / 0.4
237 ring_t = 1.0 - self._phase_ring_timer / 0.4
238 ring_r = 20 + ring_t * 40
239 phase_colours = {
240 1: (0.8, 0.15, 0.0),
241 2: (0.6, 0.1, 0.5),
242 3: (1.0, 0.2, 0.0),
243 }
244 rc = phase_colours.get(self._phase, (1.0, 0.5, 0.0))
245 segments = 12
246 for i in range(segments):
247 angle = (i / segments) * math.pi * 2
248 rx = math.cos(angle) * ring_r
249 ry = math.sin(angle) * ring_r
250 renderer.rect((rx - 2, ry - 2), (4, 4), fill=(rc[0], rc[1], rc[2], ring_alpha * 0.8))
251
252 # Larger body for boss (16px radius vs 10px)
253 r = 16
254 renderer.rect((-r, -r), (r * 2, r * 2), fill=colour)
255
256 # Phase indicator with colour coding
257 if self._state != DEATH:
258 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)}
259 phase_c = phase_colours_text.get(self._phase, (1.0, 0.5, 0.0, 0.8))
260 phase_text = f"Phase {self._phase}"
261 renderer.text(phase_text, (-15, -r - 18), fill=phase_c, scale=0.7)
262
263 # Health bar
264 if self.hp < self.max_hp and self._state != DEATH:
265 bar_w = r * 2
266 bar_h = 3
267 bar_x = -r
268 bar_y = -r - 6
269 renderer.rect((bar_x, bar_y), (bar_w, bar_h), fill=(0.3, 0.0, 0.0, 0.8))
270 fill_w = bar_w * (self.hp / max(1, self.max_hp))
271 renderer.rect((bar_x, bar_y), (fill_w, bar_h), fill=(0.9, 0.1, 0.1, 0.8))