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))