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)