nodes/enemy_base.py¶

Part of Dungeon Explorer.

  1"""Base enemy: CharacterBody2D with AI state machine and pathfinding."""
  2
  3import math
  4
  5from collision_layers import LAYER_ENEMY, MASK_WORLD_ONLY
  6from events import EnemyKilled
  7from scripts.weapons import ranged_attack
  8
  9from simvx.core import CharacterBody2D, CircleShape2D, NavGrid2D, Property, Vec2
 10
 11from .combat import DamageNumber, Hitbox
 12
 13# AI states
 14IDLE = "idle"
 15CHASE = "chase"
 16ATTACK = "attack"
 17KITE = "kite"
 18DEATH = "death"
 19PATROL = "patrol"
 20HIT = "hit"
 21FLEE = "flee"
 22
 23# Elite affixes
 24AFFIX_FAST = "fast"
 25AFFIX_THORNS = "thorns"
 26AFFIX_TELEPORT = "teleport"
 27AFFIX_REGEN = "regen"
 28ALL_AFFIXES = [AFFIX_FAST, AFFIX_THORNS, AFFIX_TELEPORT, AFFIX_REGEN]
 29
 30
 31class EnemyBase(CharacterBody2D):
 32    """Base enemy with AI state machine, pathfinding, and combat.
 33
 34    Subclasses override behaviour by setting `ai_type` and configuring stats.
 35    """
 36
 37    # Enemies are procedurally spawned per dungeon level; their state isn't
 38    # part of the player's save game.
 39    __save_persist__ = False
 40
 41    hp = Property(30, range=(0, 99999), group="Stats")
 42    max_hp = Property(30, range=(1, 99999), group="Stats")
 43    damage = Property(5, range=(0, 9999), group="Stats")
 44    speed = Property(60.0, range=(0, 500), group="Movement")
 45    xp_reward = Property(10, range=(0, 99999), group="Stats")
 46    detection_range = Property(300.0, range=(50, 1000), group="AI")
 47    attack_range = Property(24.0, range=(10, 500), group="AI")
 48    attack_cooldown = Property(1.0, range=(0.1, 5.0), group="AI")
 49    display_name = Property("Enemy", group="Info")
 50
 51    # Death is surfaced as an :class:`EnemyKilled` event on
 52    # ``self.tree.events``. Handlers connect via
 53    # ``tree.events.subscribe(EnemyKilled, handler)``.
 54
 55    def __init__(self, ai_type: str = "chase", **kwargs):
 56        # Enemies sweep against the walls only: on a shared layer they would jam
 57        # against each other and box the player in, which the AI does not expect.
 58        kwargs.setdefault("collision_layer", LAYER_ENEMY)
 59        kwargs.setdefault("collision_mask", MASK_WORLD_ONLY)
 60        super().__init__(shape=CircleShape2D(8.0), **kwargs)
 61        self._ai_type = ai_type
 62        self._state = IDLE
 63        self._attack_timer = 0.0
 64        self._hit_timer = 0.0
 65        self._hit_duration = 0.2
 66        self._death_timer = 0.0
 67        self._target = None
 68        self._nav_grid: NavGrid2D | None = None
 69        self._path: list[tuple[float, float]] = []
 70        self._path_index = 0
 71        self._path_cooldown = 0.0
 72        self._patrol_target: Vec2 | None = None
 73        self._colour = (0.8, 0.2, 0.2, 1.0)
 74        self._archetype = ""
 75        self._anim_timer = 0.0
 76        self._knockback_vel = Vec2()
 77        self._knockback_timer = 0.0
 78        self._fog = None
 79        self._flash_on_ranged_hit = 0.0  # Brief white flash on ranged impact
 80        # Elite system
 81        self._is_elite = False
 82        self._elite_affixes: list[str] = []
 83        self._teleport_cooldown = 0.0
 84        self._regen_timer = 0.0
 85
 86    def setup(self, target, nav_grid: NavGrid2D | None = None) -> None:
 87        """Set the chase target (usually player) and pathfinding grid."""
 88        self._target = target
 89        self._nav_grid = nav_grid
 90
 91    def take_damage(self, amount: int, from_pos: Vec2 | None = None) -> None:
 92        """Apply damage to this enemy."""
 93        if self._state == DEATH:
 94            return
 95        # Thorns affix: reflect 20% damage back to attacker
 96        if AFFIX_THORNS in self._elite_affixes and from_pos is not None and self._target:
 97            thorns_dmg = max(1, int(amount * 0.2))
 98            if hasattr(self._target, "take_damage"):
 99                self._target.take_damage(thorns_dmg)
100        self.hp = max(0, self.hp - amount)
101        self._state = HIT
102        self._hit_timer = self._hit_duration
103
104        # Spawn damage number
105        if self.parent:
106            dmg_num = DamageNumber(amount, is_crit=(amount > self.damage * 2))
107            dmg_num.position = Vec2(self.position.x, self.position.y - 15)
108            self.parent.add_child(dmg_num)
109
110        if self.hp <= 0:
111            self._state = DEATH
112            self._death_timer = 0.4
113            if self.tree is not None:
114                self.tree.events.publish(EnemyKilled(enemy=self))
115
116    def apply_knockback(self, direction: Vec2, distance: float = 30.0, duration: float = 0.1):
117        """Apply knockback velocity to this enemy."""
118        if duration > 0:
119            self._knockback_vel = direction * (distance / duration)
120            self._knockback_timer = duration
121
122    def on_fixed_update(self, dt: float):
123        self._attack_timer = max(0.0, self._attack_timer - dt)
124        self._path_cooldown = max(0.0, self._path_cooldown - dt)
125        self._anim_timer += dt
126        if self._flash_on_ranged_hit > 0:
127            self._flash_on_ranged_hit -= dt
128        if self._knockback_timer > 0:
129            self._knockback_timer -= dt
130            new_x = self.position.x + self._knockback_vel.x * dt
131            new_y = self.position.y + self._knockback_vel.y * dt
132            # Per-axis wall collision via nav grid
133            if self._nav_grid:
134                cs = self._nav_grid.cell_size
135                gx_new = int(new_x / cs)
136                gy_old = int(self.position.y / cs)
137                if self._nav_grid.is_solid(gx_new, gy_old):
138                    new_x = self.position.x
139                    self._knockback_vel = Vec2()
140                gx_cur = int(new_x / cs)
141                gy_new = int(new_y / cs)
142                if self._nav_grid.is_solid(gx_cur, gy_new):
143                    new_y = self.position.y
144                    self._knockback_vel = Vec2()
145            self.position = Vec2(new_x, new_y)
146            if self._knockback_timer <= 0:
147                self._knockback_vel = Vec2()
148
149        # Elite affix processing
150        self._process_elite_affixes(dt)
151
152        if self._state == DEATH:
153            self._process_death(dt)
154        elif self._state == HIT:
155            self._process_hit(dt)
156        elif self._state == FLEE:
157            self._process_flee(dt)
158        elif self._state == IDLE:
159            self._process_idle(dt)
160        elif self._state == CHASE:
161            self._process_chase(dt)
162        elif self._state == KITE:
163            self._process_kite(dt)
164        elif self._state == ATTACK:
165            self._process_attack(dt)
166        elif self._state == PATROL:
167            self._process_patrol(dt)
168
169    def _process_elite_affixes(self, dt: float):
170        """Process elite-specific affix effects each frame."""
171        if not self._is_elite or self._state == DEATH:
172            return
173        # Regen: heal 2% max_hp per second
174        if AFFIX_REGEN in self._elite_affixes:
175            self._regen_timer += dt
176            if self._regen_timer >= 1.0:
177                self._regen_timer -= 1.0
178                heal = max(1, int(self.max_hp * 0.02))
179                self.hp = min(self.max_hp, self.hp + heal)
180        # Teleport: blink toward target every 5s when chasing
181        if AFFIX_TELEPORT in self._elite_affixes:
182            self._teleport_cooldown = max(0.0, self._teleport_cooldown - dt)
183            if self._teleport_cooldown <= 0 and self._target and self._state in (CHASE, KITE):
184                dist = self._dist_to_target()
185                if 60 < dist < 300:
186                    direction = self._dir_to_target()
187                    blink = min(dist * 0.6, 120.0)
188                    self.position = Vec2(
189                        self.position.x + direction.x * blink,
190                        self.position.y + direction.y * blink,
191                    )
192                    self._teleport_cooldown = 5.0
193
194    def _process_flee(self, dt: float):
195        """Flee state: run away from target (used by goblin at low HP)."""
196        if self._target is None:
197            self._state = IDLE
198            return
199        dist = self._dist_to_target()
200        if dist > self.detection_range * 1.5 or self.hp > self.max_hp * 0.3:
201            self._state = CHASE
202            return
203        # Run away from target
204        direction = self._dir_to_target() * -1.0
205        self.velocity = direction * self.speed * 1.3
206        self.move_and_slide(dt)
207
208    def _dist_to_target(self) -> float:
209        if self._target is None:
210            return float("inf")
211        dx = self._target.position.x - self.position.x
212        dy = self._target.position.y - self.position.y
213        return math.sqrt(dx * dx + dy * dy)
214
215    def _dir_to_target(self) -> Vec2:
216        if self._target is None:
217            return Vec2(0, 1)
218        dx = self._target.position.x - self.position.x
219        dy = self._target.position.y - self.position.y
220        length = math.sqrt(dx * dx + dy * dy)
221        if length < 0.01:
222            return Vec2(0, 1)
223        return Vec2(dx / length, dy / length)
224
225    def _process_idle(self, dt: float):
226        self.velocity = Vec2()
227        dist = self._dist_to_target()
228        if dist < self.detection_range:
229            if self._ai_type == "kite":
230                self._state = KITE
231            elif self._ai_type == "patrol":
232                self._state = CHASE
233            else:
234                self._state = CHASE
235
236    def _process_chase(self, dt: float):
237        dist = self._dist_to_target()
238        if dist > self.detection_range * 1.5:
239            self._state = IDLE
240            return
241        # Goblin: flee at low HP
242        if self._archetype == "goblin" and self.hp <= self.max_hp * 0.25:
243            self._state = FLEE
244            return
245        if dist <= self.attack_range and self._attack_timer <= 0:
246            self._state = ATTACK
247            return
248
249        # Move toward target using pathfinding or direct
250        direction = self._get_nav_direction()
251        self.velocity = direction * self.speed
252        self.move_and_slide(dt)
253
254    def _process_kite(self, dt: float):
255        """Ranged enemy: approach to attack range, retreat if too close."""
256        dist = self._dist_to_target()
257        if dist > self.detection_range * 1.5:
258            self._state = IDLE
259            return
260
261        if dist <= self.attack_range * 0.6:
262            # Too close, back away
263            direction = self._dir_to_target() * -1.0
264            self.velocity = direction * self.speed
265        elif dist <= self.attack_range:
266            # In range: attack
267            if self._attack_timer <= 0:
268                self._state = ATTACK
269                return
270            self.velocity = Vec2()
271        else:
272            # Approach
273            direction = self._get_nav_direction()
274            self.velocity = direction * self.speed
275
276        self.move_and_slide(dt)
277
278    def _process_attack(self, dt: float):
279        """Execute attack, then return to chase/kite."""
280        if self._attack_timer > 0:
281            if self._ai_type == "kite":
282                self._state = KITE
283            else:
284                self._state = CHASE
285            return
286
287        # Perform attack
288        self._do_attack()
289        self._attack_timer = self.attack_cooldown
290
291        if self._ai_type == "kite":
292            self._state = KITE
293        else:
294            self._state = CHASE
295
296    def _process_patrol(self, dt: float):
297        """Patrol: wander randomly, chase if player is near."""
298        dist = self._dist_to_target()
299        if dist < self.detection_range:
300            self._state = CHASE
301            return
302
303        # Simple random patrol
304        if self._patrol_target is None or self._dist_to(self._patrol_target) < 5:
305            import random
306
307            self._patrol_target = Vec2(
308                self.position.x + random.uniform(-100, 100),
309                self.position.y + random.uniform(-100, 100),
310            )
311
312        direction = self._dir_to(self._patrol_target)
313        self.velocity = direction * self.speed * 0.5
314        self.move_and_slide(dt)
315
316    def _process_hit(self, dt: float):
317        """Stun/knockback from taking a hit."""
318        self._hit_timer -= dt
319        self.velocity = Vec2()
320        if self._hit_timer <= 0:
321            if self.hp <= 0:
322                self._state = DEATH
323            elif self._ai_type == "kite":
324                self._state = KITE
325            else:
326                self._state = CHASE
327
328    def _process_death(self, dt: float):
329        """Death animation: shrink + fade + particle burst, then destroy."""
330        self._death_timer -= dt
331        # Spawn particle burst once at start of death
332        if self._death_timer > 0.35 and self.parent and not hasattr(self, "_death_burst"):
333            self._death_burst = True
334            from .particles2d import SimpleParticles
335
336            burst = SimpleParticles(pool_size=12)
337            burst.position = Vec2(self.position.x, self.position.y)
338            self.parent.add_child(burst)
339            burst.emit(8, Vec2(0, 0), vel_range=(-80, 80), colour=self._colour, lifetime=0.4)
340        if self._death_timer <= 0:
341            self.destroy()
342
343    def _do_attack(self):
344        """Spawn a hitbox (melee) or projectile (kite) to damage the player."""
345        if self._target is None or self.parent is None:
346            return
347        direction = self._dir_to_target()
348        if self._ai_type == "kite":
349            proj = ranged_attack(self, direction, base_damage=self.damage, speed=300.0, max_range=350.0)
350            proj.name = "EnemyProjectile"
351        else:
352            hitbox = Hitbox(damage=self.damage, direction=direction, name="EnemyHit")
353            hitbox.position = Vec2(
354                self.position.x + direction.x * (self.attack_range * 0.5),
355                self.position.y + direction.y * (self.attack_range * 0.5),
356            )
357            hitbox.lifetime = 0.1
358            self.parent.add_child(hitbox)
359
360    def _get_nav_direction(self) -> Vec2:
361        """Get movement direction using pathfinding or direct line-of-sight."""
362        if self._nav_grid and self._path_cooldown <= 0 and self._target:
363            self._path_cooldown = 0.5  # Repath every 0.5s
364            path = self._nav_grid.find_path_world(
365                (float(self.position.x), float(self.position.y)),
366                (float(self._target.position.x), float(self._target.position.y)),
367            )
368            if len(path) > 1:
369                self._path = path
370                self._path_index = 1  # Skip current cell
371
372        if self._path and self._path_index < len(self._path):
373            wp = self._path[self._path_index]
374            dx = wp[0] - self.position.x
375            dy = wp[1] - self.position.y
376            dist = math.sqrt(dx * dx + dy * dy)
377            if dist < 4:
378                self._path_index += 1
379                if self._path_index >= len(self._path):
380                    return self._dir_to_target()
381                wp = self._path[self._path_index]
382                dx = wp[0] - self.position.x
383                dy = wp[1] - self.position.y
384                dist = math.sqrt(dx * dx + dy * dy)
385            if dist > 0.01:
386                return Vec2(dx / dist, dy / dist)
387
388        return self._dir_to_target()
389
390    def _dir_to(self, pos: Vec2) -> Vec2:
391        dx = float(pos.x) - self.position.x
392        dy = float(pos.y) - self.position.y
393        length = math.sqrt(dx * dx + dy * dy)
394        if length < 0.01:
395            return Vec2()
396        return Vec2(dx / length, dy / length)
397
398    def _dist_to(self, pos: Vec2) -> float:
399        dx = float(pos.x) - self.position.x
400        dy = float(pos.y) - self.position.y
401        return math.sqrt(dx * dx + dy * dy)
402
403    def _resolve_colour(self):
404        """Compute colour factoring in death/hit/fog state."""
405        if self._fog:
406            from scripts.dungeon_generator import TILE_SIZE
407
408            gx = int(self.position.x / TILE_SIZE)
409            gy = int(self.position.y / TILE_SIZE)
410            fog_state = self._fog.get_state(gx, gy)
411            if fog_state < 2:
412                return None, fog_state
413        else:
414            fog_state = 2
415
416        if self._state == DEATH:
417            alpha = max(0.0, self._death_timer / 0.4)
418            colour = (self._colour[0], self._colour[1], self._colour[2], alpha)
419        elif self._state == HIT:
420            colour = (1.0, 1.0, 1.0, 1.0)
421        elif self._flash_on_ranged_hit > 0:
422            # Brief white flash on ranged impact
423            t = min(1.0, self._flash_on_ranged_hit / 0.08)
424            colour = (
425                self._colour[0] + (1.0 - self._colour[0]) * t,
426                self._colour[1] + (1.0 - self._colour[1]) * t,
427                self._colour[2] + (1.0 - self._colour[2]) * t,
428                self._colour[3],
429            )
430        else:
431            colour = self._colour
432
433        if fog_state == 1:
434            colour = (colour[0], colour[1], colour[2], 0.3)
435        return colour, fog_state
436
437    @staticmethod
438    def _hp_bar_colour(ratio: float) -> tuple:
439        """Colour gradient: green (full) > yellow (half) > red (low)."""
440        if ratio > 0.5:
441            t = (ratio - 0.5) * 2.0
442            return (1.0 - t, 0.8 + t * 0.2, 0.0, 0.9)
443        else:
444            t = ratio * 2.0
445            return (1.0, t * 0.8, 0.0, 0.9)
446
447    def _draw_health_bar(self, renderer, r: int = 10):
448        """Draw health bar above enemy: always visible, colour gradient green>yellow>red."""
449        if self._state == DEATH:
450            return
451        ratio = self.hp / max(1, self.max_hp)
452        bar_w = r * 2
453        bar_x = self.position.x - r
454        bar_y = self.position.y - r - 6
455        renderer.draw_rect((bar_x, bar_y), (bar_w, 3), colour=(0.15, 0.15, 0.15, 0.7), filled=True)
456        fill_w = bar_w * ratio
457        if self._is_elite:
458            bar_colour = (1.0, 0.85, 0.2, 0.9)
459        else:
460            bar_colour = self._hp_bar_colour(ratio)
461        renderer.draw_rect((bar_x, bar_y), (fill_w, 3), colour=bar_colour, filled=True)
462        # Elite affix icons
463        if self._is_elite and self._elite_affixes:
464            affix_text = " ".join(a[0].upper() for a in self._elite_affixes)
465            renderer.draw_text(affix_text, (bar_x, bar_y - 10), scale=0.5, colour=(1.0, 0.85, 0.2, 0.8))
466
467    def on_draw(self, renderer):
468        colour, fog_state = self._resolve_colour()
469        if colour is None:
470            return
471        px, py = self.position.x, self.position.y
472        t = self._anim_timer
473        arch = self._archetype
474
475        if arch == "skeleton":
476            # Bone-white torso, head, thin arms, triangle feet
477            renderer.draw_rect((px - 5, py - 3), (10, 12), colour=colour, filled=True)  # Torso
478            renderer.draw_circle((px, py - 7), 5, colour=colour, filled=True)  # Head
479            renderer.draw_line((px - 5, py), (px - 9, py + 6), colour=colour)  # Left arm
480            renderer.draw_line((px + 5, py), (px + 9, py + 6), colour=colour)  # Right arm
481            renderer.fill_triangle(px - 5, py + 9, px - 2, py + 9, px - 3, py + 14, colour=colour)
482            renderer.fill_triangle(px + 5, py + 9, px + 2, py + 9, px + 3, py + 14, colour=colour)
483
484        elif arch == "archer_skeleton":
485            # Purple-tinted skeleton with bow arc
486            renderer.draw_rect((px - 4, py - 3), (8, 11), colour=colour, filled=True)
487            renderer.draw_circle((px, py - 7), 4, colour=colour, filled=True)
488            # Bow (arc on left side)
489            for i in range(5):
490                angle = -0.6 + i * 0.3
491                bx = px - 8 + math.sin(angle) * 3
492                by = py - 4 + i * 3
493                renderer.draw_rect((bx, by), (2, 2), colour=(0.5, 0.3, 0.2, colour[3]), filled=True)
494            # Quiver on back
495            renderer.draw_rect((px + 4, py - 5), (3, 8), colour=(0.4, 0.25, 0.15, colour[3]), filled=True)
496
497        elif arch == "slime":
498            # Wobbling circle with dot eyes
499            wobble = math.sin(t * 4.0) * 1.5
500            renderer.draw_circle((px, py + wobble), 8, colour=colour, filled=True)
501            # Eyes
502            eye_c = (0.1, 0.1, 0.1, colour[3])
503            renderer.draw_rect((px - 3, py - 2 + wobble), (2, 2), colour=eye_c, filled=True)
504            renderer.draw_rect((px + 1, py - 2 + wobble), (2, 2), colour=eye_c, filled=True)
505
506        elif arch == "bat_swarm":
507            # 3 small oscillating triangles (wing flap)
508            for i in range(3):
509                offset_x = (i - 1) * 8
510                flap = math.sin(t * 10.0 + i * 2.1) * 4
511                bx, by = px + offset_x, py + flap
512                renderer.fill_triangle(bx, by - 4, bx - 5, by + 2, bx + 5, by + 2, colour=colour)
513
514        elif arch == "goblin":
515            # Squat green body, helmet cap, pointy ears, dagger
516            renderer.draw_rect((px - 6, py - 2), (12, 10), colour=colour, filled=True)  # Squat body
517            renderer.draw_rect((px - 7, py - 6), (14, 5), colour=(0.3, 0.4, 0.2, colour[3]), filled=True)  # Helmet
518            # Pointy ears
519            renderer.fill_triangle(px - 7, py - 3, px - 10, py - 6, px - 5, py - 6, colour=colour)
520            renderer.fill_triangle(px + 7, py - 3, px + 10, py - 6, px + 5, py - 6, colour=colour)
521            # Dagger
522            renderer.draw_line((px + 6, py + 2), (px + 12, py - 3), colour=(0.7, 0.7, 0.7, colour[3]))
523
524        elif arch == "wraith":
525            # Transparent diamond shape with float oscillation
526            float_y = math.sin(t * 3.0) * 3
527            wy = py + float_y
528            # Purple glow underneath
529            renderer.draw_circle((px, wy + 2), 10, colour=(0.4, 0.1, 0.5, 0.2 * colour[3]), filled=True)
530            # Diamond body
531            renderer.fill_triangle(px, wy - 10, px - 8, wy, px + 8, wy, colour=colour)
532            renderer.fill_triangle(px, wy + 10, px - 8, wy, px + 8, wy, colour=colour)
533
534        elif arch == "golem":
535            # Largest enemy: stacked rectangles, eye dots, crack lines below 50% HP
536            renderer.draw_rect((px - 8, py - 6), (16, 18), colour=colour, filled=True)  # Main body
537            renderer.draw_rect((px - 10, py - 2), (4, 8), colour=colour, filled=True)  # Left arm
538            renderer.draw_rect((px + 6, py - 2), (4, 8), colour=colour, filled=True)  # Right arm
539            renderer.draw_rect((px - 5, py - 10), (10, 5), colour=colour, filled=True)  # Head
540            # Eyes
541            eye_c = (1.0, 0.6, 0.1, colour[3])
542            renderer.draw_rect((px - 3, py - 9), (2, 2), colour=eye_c, filled=True)
543            renderer.draw_rect((px + 1, py - 9), (2, 2), colour=eye_c, filled=True)
544            # Crack lines below 50% HP
545            if self.hp <= self.max_hp * 0.5 and self._state != DEATH:
546                crack_c = (0.2, 0.15, 0.1, colour[3])
547                renderer.draw_line((px - 4, py - 3), (px + 2, py + 4), colour=crack_c)
548                renderer.draw_line((px + 3, py - 1), (px - 1, py + 6), colour=crack_c)
549
550        elif arch == "demon":
551            # Upward triangle body, horns, red glow, wing stubs
552            flicker = 0.9 + math.sin(t * 8.0) * 0.1
553            fc = (colour[0] * flicker, colour[1] * flicker, colour[2] * flicker, colour[3])
554            # Glow
555            renderer.draw_circle((px, py), 12, colour=(0.6, 0.1, 0.0, 0.15 * colour[3]), filled=True)
556            # Body triangle
557            renderer.fill_triangle(px, py - 10, px - 8, py + 8, px + 8, py + 8, colour=fc)
558            # Horns
559            renderer.draw_line((px - 4, py - 9), (px - 7, py - 15), colour=(0.3, 0.0, 0.0, colour[3]))
560            renderer.draw_line((px + 4, py - 9), (px + 7, py - 15), colour=(0.3, 0.0, 0.0, colour[3]))
561            # Wing stubs
562            renderer.fill_triangle(px - 8, py - 2, px - 14, py - 6, px - 8, py + 3, colour=fc)
563            renderer.fill_triangle(px + 8, py - 2, px + 14, py - 6, px + 8, py + 3, colour=fc)
564
565        elif arch == "elder_dragon":
566            # Multi-segment body, wing triangles, phase-based colour, breathing animation
567            breath = math.sin(t * 2.0) * 2
568            # Main body (large ellipse via overlapping circles)
569            renderer.draw_circle((px, py), 14, colour=colour, filled=True)
570            renderer.draw_circle((px, py + 8), 10, colour=colour, filled=True)
571            # Head
572            renderer.draw_circle((px, py - 16 + breath), 8, colour=colour, filled=True)
573            # Eyes
574            eye_c = (1.0, 0.9, 0.2, colour[3])
575            renderer.draw_rect((px - 4, py - 18 + breath), (3, 2), colour=eye_c, filled=True)
576            renderer.draw_rect((px + 1, py - 18 + breath), (3, 2), colour=eye_c, filled=True)
577            # Wings
578            wing_flap = math.sin(t * 3.0) * 4
579            renderer.fill_triangle(px - 14, py - 4, px - 28, py - 14 + wing_flap, px - 10, py + 6, colour=colour)
580            renderer.fill_triangle(px + 14, py - 4, px + 28, py - 14 + wing_flap, px + 10, py + 6, colour=colour)
581            # Tail
582            renderer.fill_triangle(px, py + 18, px - 4, py + 12, px + 4, py + 12, colour=colour)
583            renderer.draw_line((px, py + 18), (px - 6, py + 26), colour=colour)
584
585        else:
586            # Fallback: plain rectangle
587            renderer.draw_rect((px - 10, py - 10), (20, 20), colour=colour, filled=True)
588
589        # Elite gold outline
590        if self._is_elite and self._state != DEATH:
591            gold = (1.0, 0.85, 0.2, 0.8)
592            renderer.draw_rect((px - 12, py - 12), (24, 1), colour=gold, filled=True)
593            renderer.draw_rect((px - 12, py + 11), (24, 1), colour=gold, filled=True)
594            renderer.draw_rect((px - 12, py - 12), (1, 24), colour=gold, filled=True)
595            renderer.draw_rect((px + 11, py - 12), (1, 24), colour=gold, filled=True)
596
597        self._draw_health_bar(renderer)