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 = -r
454        bar_y = -r - 6
455        renderer.rect((bar_x, bar_y), (bar_w, 3), fill=(0.15, 0.15, 0.15, 0.7))
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.rect((bar_x, bar_y), (fill_w, 3), fill=bar_colour)
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.text(affix_text, (bar_x, bar_y - 10), fill=(1.0, 0.85, 0.2, 0.8), scale=0.5)
466
467    def on_draw(self, renderer):
468        colour, fog_state = self._resolve_colour()
469        if colour is None:
470            return
471        t = self._anim_timer
472        arch = self._archetype
473
474        if arch == "skeleton":
475            # Bone-white torso, head, thin arms, triangle feet
476            renderer.rect((-5, -3), (10, 12), fill=colour)  # Torso
477            renderer.circle((0, -7), 5, fill=colour)  # Head
478            renderer.line((-5, 0), (-9, 6), stroke=colour)  # Left arm
479            renderer.line((5, 0), (9, 6), stroke=colour)  # Right arm
480            renderer.polygon([(-5, 9), (-2, 9), (-3, 14)], fill=colour)
481            renderer.polygon([(5, 9), (2, 9), (3, 14)], fill=colour)
482
483        elif arch == "archer_skeleton":
484            # Purple-tinted skeleton with bow arc
485            renderer.rect((-4, -3), (8, 11), fill=colour)
486            renderer.circle((0, -7), 4, fill=colour)
487            # Bow (arc on left side)
488            for i in range(5):
489                angle = -0.6 + i * 0.3
490                bx = -8 + math.sin(angle) * 3
491                by = -4 + i * 3
492                renderer.rect((bx, by), (2, 2), fill=(0.5, 0.3, 0.2, colour[3]))
493            # Quiver on back
494            renderer.rect((4, -5), (3, 8), fill=(0.4, 0.25, 0.15, colour[3]))
495
496        elif arch == "slime":
497            # Wobbling circle with dot eyes
498            wobble = math.sin(t * 4.0) * 1.5
499            renderer.circle((0, wobble), 8, fill=colour)
500            # Eyes
501            eye_c = (0.1, 0.1, 0.1, colour[3])
502            renderer.rect((-3, -2 + wobble), (2, 2), fill=eye_c)
503            renderer.rect((1, -2 + wobble), (2, 2), fill=eye_c)
504
505        elif arch == "bat_swarm":
506            # 3 small oscillating triangles (wing flap)
507            for i in range(3):
508                offset_x = (i - 1) * 8
509                flap = math.sin(t * 10.0 + i * 2.1) * 4
510                bx, by = offset_x, flap
511                renderer.polygon([(bx, by - 4), (bx - 5, by + 2), (bx + 5, by + 2)], fill=colour)
512
513        elif arch == "goblin":
514            # Squat green body, helmet cap, pointy ears, dagger
515            renderer.rect((-6, -2), (12, 10), fill=colour)  # Squat body
516            renderer.rect((-7, -6), (14, 5), fill=(0.3, 0.4, 0.2, colour[3]))  # Helmet
517            # Pointy ears
518            renderer.polygon([(-7, -3), (-10, -6), (-5, -6)], fill=colour)
519            renderer.polygon([(7, -3), (10, -6), (5, -6)], fill=colour)
520            # Dagger
521            renderer.line((6, 2), (12, -3), stroke=(0.7, 0.7, 0.7, colour[3]))
522
523        elif arch == "wraith":
524            # Transparent diamond shape with float oscillation
525            float_y = math.sin(t * 3.0) * 3
526            wy = float_y
527            # Purple glow underneath
528            renderer.circle((0, wy + 2), 10, fill=(0.4, 0.1, 0.5, 0.2 * colour[3]))
529            # Diamond body
530            renderer.polygon([(0, wy - 10), (-8, wy), (8, wy)], fill=colour)
531            renderer.polygon([(0, wy + 10), (-8, wy), (8, wy)], fill=colour)
532
533        elif arch == "golem":
534            # Largest enemy: stacked rectangles, eye dots, crack lines below 50% HP
535            renderer.rect((-8, -6), (16, 18), fill=colour)  # Main body
536            renderer.rect((-10, -2), (4, 8), fill=colour)  # Left arm
537            renderer.rect((6, -2), (4, 8), fill=colour)  # Right arm
538            renderer.rect((-5, -10), (10, 5), fill=colour)  # Head
539            # Eyes
540            eye_c = (1.0, 0.6, 0.1, colour[3])
541            renderer.rect((-3, -9), (2, 2), fill=eye_c)
542            renderer.rect((1, -9), (2, 2), fill=eye_c)
543            # Crack lines below 50% HP
544            if self.hp <= self.max_hp * 0.5 and self._state != DEATH:
545                crack_c = (0.2, 0.15, 0.1, colour[3])
546                renderer.line((-4, -3), (2, 4), stroke=crack_c)
547                renderer.line((3, -1), (-1, 6), stroke=crack_c)
548
549        elif arch == "demon":
550            # Upward triangle body, horns, red glow, wing stubs
551            flicker = 0.9 + math.sin(t * 8.0) * 0.1
552            fc = (colour[0] * flicker, colour[1] * flicker, colour[2] * flicker, colour[3])
553            # Glow
554            renderer.circle((0, 0), 12, fill=(0.6, 0.1, 0.0, 0.15 * colour[3]))
555            # Body triangle
556            renderer.polygon([(0, -10), (-8, 8), (8, 8)], fill=fc)
557            # Horns
558            renderer.line((-4, -9), (-7, -15), stroke=(0.3, 0.0, 0.0, colour[3]))
559            renderer.line((4, -9), (7, -15), stroke=(0.3, 0.0, 0.0, colour[3]))
560            # Wing stubs
561            renderer.polygon([(-8, -2), (-14, -6), (-8, 3)], fill=fc)
562            renderer.polygon([(8, -2), (14, -6), (8, 3)], fill=fc)
563
564        elif arch == "elder_dragon":
565            # Multi-segment body, wing triangles, phase-based colour, breathing animation
566            breath = math.sin(t * 2.0) * 2
567            # Main body (large ellipse via overlapping circles)
568            renderer.circle((0, 0), 14, fill=colour)
569            renderer.circle((0, 8), 10, fill=colour)
570            # Head
571            renderer.circle((0, -16 + breath), 8, fill=colour)
572            # Eyes
573            eye_c = (1.0, 0.9, 0.2, colour[3])
574            renderer.rect((-4, -18 + breath), (3, 2), fill=eye_c)
575            renderer.rect((1, -18 + breath), (3, 2), fill=eye_c)
576            # Wings
577            wing_flap = math.sin(t * 3.0) * 4
578            renderer.polygon([(-14, -4), (-28, -14 + wing_flap), (-10, 6)], fill=colour)
579            renderer.polygon([(14, -4), (28, -14 + wing_flap), (10, 6)], fill=colour)
580            # Tail
581            renderer.polygon([(0, 18), (-4, 12), (4, 12)], fill=colour)
582            renderer.line((0, 18), (-6, 26), stroke=colour)
583
584        else:
585            # Fallback: plain rectangle
586            renderer.rect((-10, -10), (20, 20), fill=colour)
587
588        # Elite gold outline
589        if self._is_elite and self._state != DEATH:
590            gold = (1.0, 0.85, 0.2, 0.8)
591            renderer.rect((-12, -12), (24, 1), fill=gold)
592            renderer.rect((-12, 11), (24, 1), fill=gold)
593            renderer.rect((-12, -12), (1, 24), fill=gold)
594            renderer.rect((11, -12), (1, 24), fill=gold)
595
596        self._draw_health_bar(renderer)