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)