nodes/enemy.py¶
Part of HeartBeast Action RPG.
1"""Enemy (Bat): Idle/Wander/Chase state machine with soft collision.
2
3Mirrors the upstream HeartBeast Bat scene: a wandering bat that switches to a
4chase once the player enters its detection radius, pushes apart from its
5neighbours rather than stacking on them, and flashes while invincible after a
6sword hit. Drawn procedurally, like everything else in this port.
7"""
8
9from __future__ import annotations
10
11import math
12import random
13
14from settings import (
15 COLOUR_BAT_BODY,
16 COLOUR_BAT_EYE,
17 COLOUR_BAT_WING,
18 COLOUR_SHADOW,
19 ENEMY_CHASE_SPEED,
20 ENEMY_DETECTION_RADIUS,
21 ENEMY_HURTBOX_RADIUS,
22 ENEMY_INVULN_DURATION,
23 ENEMY_MAX_HP,
24 ENEMY_SOFT_COLLISION_RADIUS,
25 ENEMY_SPEED,
26 ENEMY_WANDER_TIMEOUT,
27 WORLD_HEIGHT,
28 WORLD_WIDTH,
29)
30
31from simvx.core import Node2D, Signal, Vec2
32
33from .stats import Stats
34
35#: Radians per second of the wing-flap cycle.
36FLAP_SPEED = 14.0
37#: Blink period of the post-hit flash, in seconds.
38FLASH_INTERVAL = 0.1
39#: Keep bats this far inside the world edge.
40EDGE_MARGIN = 12.0
41
42
43class Enemy(Node2D):
44 """Bat enemy with an Idle/Wander/Chase state machine."""
45
46 # The wings flap off a phase accumulator and the body blinks while
47 # invincible; neither is Property state, so re-collect every frame.
48 dynamic = True
49
50 hit_effect_signal = Signal() # (position: Vec2) emitted when hit
51 death_effect_signal = Signal() # (position: Vec2) emitted when killed
52
53 def __init__(self, position: Vec2 | None = None, player_ref: Node2D | None = None, **kwargs):
54 super().__init__(position=position if position is not None else Vec2(0.0, 0.0), **kwargs)
55
56 self._player = player_ref
57
58 #: One of "idle", "wander" or "chase".
59 self._state = "idle"
60 self._velocity = Vec2(0.0, 0.0)
61
62 # Wander
63 self._wander_target = Vec2(self.position)
64 self._wander_timer = random.uniform(0, ENEMY_WANDER_TIMEOUT)
65
66 # Invincibility
67 self._invulnerable = False
68 self._invuln_timer = 0.0
69 self._flash_visible = True
70 self._flash_timer = 0.0
71
72 # Soft collision push accumulated for this frame
73 self._soft_collision_push = Vec2(0.0, 0.0)
74
75 self._wing_phase = random.uniform(0, math.tau)
76 self._dead = False
77
78 self.stats = Stats(max_health=ENEMY_MAX_HP)
79 self.stats.no_health.connect(self._on_death)
80 self.add_child(self.stats)
81
82 # ── Public state ─────────────────────────────────────────────────────────
83
84 @property
85 def is_dead(self) -> bool:
86 """True once health hit zero and the bat is on its way out."""
87 return self._dead
88
89 @property
90 def state(self) -> str:
91 """Current state-machine state: "idle", "wander" or "chase"."""
92 return self._state
93
94 # ── Lifecycle ────────────────────────────────────────────────────────────
95
96 def on_update(self, dt: float):
97 if self._dead:
98 return
99 self._update_state(dt)
100 self._update_movement(dt)
101 self._update_invulnerability(dt)
102 self._wing_phase += dt * FLAP_SPEED
103
104 def on_draw(self, renderer):
105 if self._dead:
106 return
107 renderer.circle((0, 7), 4.0, fill=COLOUR_SHADOW)
108 if self._invulnerable and not self._flash_visible:
109 return
110
111 flap = math.sin(self._wing_phase) * 3.0
112 renderer.polygon([(-2, -1), (-10, -3 - flap), (-8, 3 - flap)], fill=COLOUR_BAT_WING)
113 renderer.polygon([(2, -1), (10, -3 - flap), (8, 3 - flap)], fill=COLOUR_BAT_WING)
114 # Ears first: the body disc covers their bases.
115 renderer.polygon([(-3.5, -3), (-2.5, -8), (-0.5, -3)], fill=COLOUR_BAT_BODY)
116 renderer.polygon([(3.5, -3), (2.5, -8), (0.5, -3)], fill=COLOUR_BAT_BODY)
117 renderer.circle((0, 0), 4.5, fill=COLOUR_BAT_BODY)
118 renderer.circle((-1.7, -0.8), 0.9, fill=COLOUR_BAT_EYE)
119 renderer.circle((1.7, -0.8), 0.9, fill=COLOUR_BAT_EYE)
120
121 # ── State machine ────────────────────────────────────────────────────────
122
123 def _update_state(self, dt: float):
124 if self._player is None:
125 return
126 dist_to_player = (self._player.position - self.position).length()
127
128 if self._state == "idle":
129 self._wander_timer -= dt
130 if self._wander_timer <= 0:
131 self._state = "wander"
132 self._pick_wander_target()
133 elif self._state == "wander":
134 self._wander_timer -= dt
135 reached = (self._wander_target - self.position).length() < 5
136 if self._wander_timer <= 0 or reached:
137 self._rest()
138 elif self._state == "chase" and dist_to_player > ENEMY_DETECTION_RADIUS * 1.5:
139 self._rest()
140
141 # A hurt player is left alone until the invincibility frames run out,
142 # which is what stops a swarm from draining every heart at once.
143 hunting = not (self._player.is_invulnerable or self._player.is_dead)
144 if hunting and dist_to_player <= ENEMY_DETECTION_RADIUS:
145 self._state = "chase"
146
147 def _rest(self):
148 self._state = "idle"
149 self._wander_timer = random.uniform(0, ENEMY_WANDER_TIMEOUT)
150
151 def _pick_wander_target(self):
152 angle = random.uniform(0, math.tau)
153 dist = random.uniform(40, 100)
154 target = self.position + Vec2(math.cos(angle) * dist, math.sin(angle) * dist)
155 self._wander_target = Vec2(
156 min(max(target[0], EDGE_MARGIN), WORLD_WIDTH - EDGE_MARGIN),
157 min(max(target[1], EDGE_MARGIN), WORLD_HEIGHT - EDGE_MARGIN),
158 )
159
160 # ── Movement ─────────────────────────────────────────────────────────────
161
162 def _update_movement(self, dt: float):
163 if self._state == "idle":
164 self._velocity = Vec2(0.0, 0.0)
165 elif self._state == "wander":
166 self._velocity = self._steer_towards(self._wander_target, ENEMY_SPEED)
167 elif self._state == "chase" and self._player is not None:
168 self._velocity = self._steer_towards(self._player.position, ENEMY_CHASE_SPEED)
169
170 # Soft collision is accumulated by neighbours during the World's sweep.
171 self._velocity += self._soft_collision_push
172 self._soft_collision_push = Vec2(0.0, 0.0)
173
174 moved = self.position + self._velocity * dt
175 self.position = Vec2(
176 min(max(moved[0], EDGE_MARGIN), WORLD_WIDTH - EDGE_MARGIN),
177 min(max(moved[1], EDGE_MARGIN), WORLD_HEIGHT - EDGE_MARGIN),
178 )
179
180 def _steer_towards(self, target: Vec2, speed: float) -> Vec2:
181 offset = target - self.position
182 return offset.normalized() * speed if offset.length() > 0 else Vec2(0.0, 0.0)
183
184 # ── Soft collision ───────────────────────────────────────────────────────
185
186 def apply_soft_collision(self, other: Enemy):
187 """Push this bat and ``other`` apart if their soft radii overlap."""
188 offset = self.position - other.position
189 dist = offset.length()
190 min_dist = ENEMY_SOFT_COLLISION_RADIUS * 2
191 if 0 < dist < min_dist:
192 push = offset / dist * (min_dist - dist) * 0.5
193 self._soft_collision_push += push
194 other._soft_collision_push -= push
195
196 # ── Damage ───────────────────────────────────────────────────────────────
197
198 def _update_invulnerability(self, dt: float):
199 if not self._invulnerable:
200 return
201 self._invuln_timer -= dt
202 self._flash_timer += dt
203 if self._flash_timer >= FLASH_INTERVAL:
204 self._flash_timer = 0.0
205 self._flash_visible = not self._flash_visible
206 if self._invuln_timer <= 0:
207 self._invulnerable = False
208 self._flash_visible = True
209
210 def take_damage(self, amount: int = 1):
211 if self._invulnerable or self._dead:
212 return
213 self.stats.health -= amount
214 self._invulnerable = True
215 self._invuln_timer = ENEMY_INVULN_DURATION
216 self._flash_timer = 0.0
217 self._flash_visible = True
218 self.hit_effect_signal.emit(self.position)
219
220 def _on_death(self):
221 self._dead = True
222 self.death_effect_signal.emit(self.position)
223 self.destroy()
224
225 def get_hurtbox(self) -> tuple[Vec2, float]:
226 """``(centre, radius)`` of the bat's hurtbox."""
227 return (self.position, ENEMY_HURTBOX_RADIUS)