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)