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 px, py = self.position
108 renderer.draw_circle((px, py + 7), 4.0, colour=COLOUR_SHADOW, filled=True)
109 if self._invulnerable and not self._flash_visible:
110 return
111
112 flap = math.sin(self._wing_phase) * 3.0
113 renderer.draw_polygon(
114 [(px - 2, py - 1), (px - 10, py - 3 - flap), (px - 8, py + 3 - flap)], colour=COLOUR_BAT_WING
115 )
116 renderer.draw_polygon(
117 [(px + 2, py - 1), (px + 10, py - 3 - flap), (px + 8, py + 3 - flap)], colour=COLOUR_BAT_WING
118 )
119 # Ears first: the body disc covers their bases.
120 renderer.draw_polygon([(px - 3.5, py - 3), (px - 2.5, py - 8), (px - 0.5, py - 3)], colour=COLOUR_BAT_BODY)
121 renderer.draw_polygon([(px + 3.5, py - 3), (px + 2.5, py - 8), (px + 0.5, py - 3)], colour=COLOUR_BAT_BODY)
122 renderer.draw_circle((px, py), 4.5, colour=COLOUR_BAT_BODY, filled=True)
123 renderer.draw_circle((px - 1.7, py - 0.8), 0.9, colour=COLOUR_BAT_EYE, filled=True)
124 renderer.draw_circle((px + 1.7, py - 0.8), 0.9, colour=COLOUR_BAT_EYE, filled=True)
125
126 # ── State machine ────────────────────────────────────────────────────────
127
128 def _update_state(self, dt: float):
129 if self._player is None:
130 return
131 dist_to_player = (self._player.position - self.position).length()
132
133 if self._state == "idle":
134 self._wander_timer -= dt
135 if self._wander_timer <= 0:
136 self._state = "wander"
137 self._pick_wander_target()
138 elif self._state == "wander":
139 self._wander_timer -= dt
140 reached = (self._wander_target - self.position).length() < 5
141 if self._wander_timer <= 0 or reached:
142 self._rest()
143 elif self._state == "chase" and dist_to_player > ENEMY_DETECTION_RADIUS * 1.5:
144 self._rest()
145
146 # A hurt player is left alone until the invincibility frames run out,
147 # which is what stops a swarm from draining every heart at once.
148 hunting = not (self._player.is_invulnerable or self._player.is_dead)
149 if hunting and dist_to_player <= ENEMY_DETECTION_RADIUS:
150 self._state = "chase"
151
152 def _rest(self):
153 self._state = "idle"
154 self._wander_timer = random.uniform(0, ENEMY_WANDER_TIMEOUT)
155
156 def _pick_wander_target(self):
157 angle = random.uniform(0, math.tau)
158 dist = random.uniform(40, 100)
159 target = self.position + Vec2(math.cos(angle) * dist, math.sin(angle) * dist)
160 self._wander_target = Vec2(
161 min(max(target[0], EDGE_MARGIN), WORLD_WIDTH - EDGE_MARGIN),
162 min(max(target[1], EDGE_MARGIN), WORLD_HEIGHT - EDGE_MARGIN),
163 )
164
165 # ── Movement ─────────────────────────────────────────────────────────────
166
167 def _update_movement(self, dt: float):
168 if self._state == "idle":
169 self._velocity = Vec2(0.0, 0.0)
170 elif self._state == "wander":
171 self._velocity = self._steer_towards(self._wander_target, ENEMY_SPEED)
172 elif self._state == "chase" and self._player is not None:
173 self._velocity = self._steer_towards(self._player.position, ENEMY_CHASE_SPEED)
174
175 # Soft collision is accumulated by neighbours during the World's sweep.
176 self._velocity += self._soft_collision_push
177 self._soft_collision_push = Vec2(0.0, 0.0)
178
179 moved = self.position + self._velocity * dt
180 self.position = Vec2(
181 min(max(moved[0], EDGE_MARGIN), WORLD_WIDTH - EDGE_MARGIN),
182 min(max(moved[1], EDGE_MARGIN), WORLD_HEIGHT - EDGE_MARGIN),
183 )
184
185 def _steer_towards(self, target: Vec2, speed: float) -> Vec2:
186 offset = target - self.position
187 return offset.normalized() * speed if offset.length() > 0 else Vec2(0.0, 0.0)
188
189 # ── Soft collision ───────────────────────────────────────────────────────
190
191 def apply_soft_collision(self, other: Enemy):
192 """Push this bat and ``other`` apart if their soft radii overlap."""
193 offset = self.position - other.position
194 dist = offset.length()
195 min_dist = ENEMY_SOFT_COLLISION_RADIUS * 2
196 if 0 < dist < min_dist:
197 push = offset / dist * (min_dist - dist) * 0.5
198 self._soft_collision_push += push
199 other._soft_collision_push -= push
200
201 # ── Damage ───────────────────────────────────────────────────────────────
202
203 def _update_invulnerability(self, dt: float):
204 if not self._invulnerable:
205 return
206 self._invuln_timer -= dt
207 self._flash_timer += dt
208 if self._flash_timer >= FLASH_INTERVAL:
209 self._flash_timer = 0.0
210 self._flash_visible = not self._flash_visible
211 if self._invuln_timer <= 0:
212 self._invulnerable = False
213 self._flash_visible = True
214
215 def take_damage(self, amount: int = 1):
216 if self._invulnerable or self._dead:
217 return
218 self.stats.health -= amount
219 self._invulnerable = True
220 self._invuln_timer = ENEMY_INVULN_DURATION
221 self._flash_timer = 0.0
222 self._flash_visible = True
223 self.hit_effect_signal.emit(self.position)
224
225 def _on_death(self):
226 self._dead = True
227 self.death_effect_signal.emit(self.position)
228 self.destroy()
229
230 def get_hurtbox(self) -> tuple[Vec2, float]:
231 """``(centre, radius)`` of the bat's hurtbox."""
232 return (self.position, ENEMY_HURTBOX_RADIUS)