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)