nodes/enemy.pyΒΆ

Part of Clear Code Zelda.

  1"""Enemy with idle/move/attack state machine + simple distance-based pathing.
  2
  3Uses an :class:`NavGrid2D` (built from the FloorBlocks layout) for robust
  4chase pathfinding around walls. Enemies fall back to direct chase when no
  5path exists or the player is in line-of-sight.
  6
  7The level gives each enemy its chase target with :meth:`Enemy.set_chase_target`
  8and listens to the ``attacked`` / ``died`` signals; nothing is injected onto
  9the instance.
 10"""
 11
 12from __future__ import annotations
 13
 14from settings import TILESIZE, monster_data
 15from support import import_folder
 16
 17from simvx.core import NavGrid2D, Node2D, Signal, Vec2
 18
 19from .anim_sprite import FolderSprite
 20
 21# AI states
 22STATE_IDLE = "idle"
 23STATE_MOVE = "move"
 24STATE_ATTACK = "attack"
 25
 26ENEMY_SIZE = 64
 27
 28#: How long the sprite tints white after being hit.
 29FLASH_DURATION = 0.15
 30
 31
 32class Enemy(Node2D):
 33    """Top-down enemy with a 3-state FSM (idle, move, attack)."""
 34
 35    attacked = Signal(int, str)  # damage, attack type
 36    died = Signal(Node2D)  # the enemy that just ran out of health
 37
 38    def __init__(self, monster_name: str, position: Vec2, **kwargs):
 39        super().__init__(position=position, **kwargs)
 40        self.sprite_type = "enemy"
 41        self.monster_name = monster_name
 42
 43        info = monster_data[monster_name]
 44        self.hp = info["health"]
 45        self.max_hp = info["health"]
 46        self.exp = info["exp"]
 47        self.attack_damage = info["damage"]
 48        self.attack_type = info["attack_type"]
 49        self.speed = info["speed"]
 50        self.attack_radius = info["attack_radius"]
 51        self.notice_radius = info["notice_radius"]
 52        self.knockback = info["resistance"] * 18.0  # tuned for px/sec velocity
 53        self.colour_tint = info["colour"]
 54
 55        # State machine
 56        self.state = STATE_IDLE
 57        self._attack_timer = 0.0
 58        self.attack_cooldown = 0.4
 59        self.can_attack = True
 60
 61        # Damage feedback
 62        self.vulnerable = True
 63        self._invuln_timer = 0.0
 64        self._invuln_duration = 0.30
 65        self._flash_timer = 0.0
 66        self._knockback_velocity = Vec2(0.0, 0.0)
 67        self._knockback_timer = 0.0
 68        self._dead = False
 69
 70        # Sprite anims
 71        self._anims: dict[str, list[str]] = {}
 72        self._sprite: FolderSprite | None = None
 73
 74        # Chase target and pathfinding, both set by the level once the map is built.
 75        self._target: Node2D | None = None
 76        self._nav: NavGrid2D | None = None
 77        self._path: list[tuple[int, int]] = []
 78        self._path_timer = 0.0
 79        self._path_target_cell: tuple[int, int] | None = None
 80
 81    # -- lifecycle ----------------------------------------------------------
 82
 83    def on_ready(self):
 84        # Animation paths
 85        self._anims = {a: import_folder(f"monsters/{self.monster_name}/{a}") for a in ("idle", "move", "attack")}
 86        self._sprite = self.add_child(
 87            FolderSprite(
 88                frames=self._anims.get("idle") or [],
 89                fps=8.0,
 90                width=ENEMY_SIZE,
 91                height=ENEMY_SIZE,
 92                name=f"{self.monster_name}Sprite",
 93            )
 94        )
 95
 96    def set_chase_target(self, target: Node2D, nav: NavGrid2D | None) -> None:
 97        """Give this enemy something to hunt, and the grid to path across."""
 98        self._target = target
 99        self._nav = nav
100
101    # -- per-frame ----------------------------------------------------------
102
103    def on_update(self, dt: float):
104        if self._dead:
105            return
106
107        # Cooldowns
108        if self._invuln_timer > 0:
109            self._invuln_timer -= dt
110            if self._invuln_timer <= 0:
111                self.vulnerable = True
112        if not self.can_attack:
113            self._attack_timer -= dt
114            if self._attack_timer <= 0:
115                self.can_attack = True
116        self._update_flash(dt)
117
118        # Knockback override
119        if self._knockback_timer > 0:
120            self.position += self._knockback_velocity * dt
121            self._knockback_timer -= dt
122            if self._knockback_timer <= 0:
123                self._knockback_velocity = Vec2(0.0, 0.0)
124            self._update_animation()
125            return
126
127        if self._target is None:
128            return
129
130        diff = Vec2(self._target.position.x - self.position.x, self._target.position.y - self.position.y)
131        dist = diff.length()
132        direction = diff / dist if dist > 1e-3 else Vec2(0.0, 0.0)
133
134        # State transitions
135        if dist <= self.attack_radius:
136            self.state = STATE_ATTACK
137        elif dist <= self.notice_radius:
138            self.state = STATE_MOVE
139        else:
140            self.state = STATE_IDLE
141
142        # Actions
143        if self.state == STATE_ATTACK:
144            if self.can_attack:
145                self.can_attack = False
146                self._attack_timer = self.attack_cooldown
147                self.attacked(self.attack_damage, self.attack_type)
148        elif self.state == STATE_MOVE:
149            self._chase(dt, direction)
150
151        self._update_animation()
152
153    # -- helpers ------------------------------------------------------------
154
155    def _chase(self, dt: float, direct: Vec2):
156        """Move toward the player, preferring the nav path when one is available."""
157        speed = self.speed
158        # Re-plan a path every 0.3s, or when arriving at the current waypoint.
159        self._path_timer -= dt
160        if self._nav is not None and self._path_timer <= 0:
161            self._path_timer = 0.3
162            sx, sy = int(self.position.x // TILESIZE), int(self.position.y // TILESIZE)
163            tx, ty = int(self._target.position.x // TILESIZE), int(self._target.position.y // TILESIZE)
164            try:
165                path = self._nav.find_path((sx, sy), (tx, ty))
166            except (KeyError, ValueError):
167                # Out-of-bounds start/target cells: no reachable path.
168                path = None
169            self._path = list(path) if path else []
170            # Drop the cell we're already in
171            if self._path and self._path[0] == (sx, sy):
172                self._path.pop(0)
173            self._path_target_cell = self._path[0] if self._path else None
174
175        # If we have a waypoint, move toward its centre.
176        if self._path_target_cell is not None:
177            cx = self._path_target_cell[0] * TILESIZE + TILESIZE * 0.5
178            cy = self._path_target_cell[1] * TILESIZE + TILESIZE * 0.5
179            d = Vec2(cx - self.position.x, cy - self.position.y)
180            ld = d.length()
181            if ld < 4.0:
182                if self._path:
183                    self._path.pop(0)
184                self._path_target_cell = self._path[0] if self._path else None
185            else:
186                d /= ld
187                self.position += d * (speed * dt)
188                return
189        # No path: direct chase as a fallback (line-of-sight cases).
190        self.position += direct * (speed * dt)
191
192    def _update_animation(self):
193        if self._sprite is None:
194            return
195        frames = self._anims.get(self.state) or self._anims.get("idle") or []
196        if frames is self._sprite._frames:
197            return
198        self._sprite.play(frames, fps=8.0, loop=(self.state != STATE_ATTACK))
199
200    def _update_flash(self, dt: float):
201        """Blink the sprite while the hit-flash timer runs."""
202        if self._sprite is None or self._flash_timer <= 0:
203            return
204        self._flash_timer = max(0.0, self._flash_timer - dt)
205        self._sprite.colour = (1.0, 0.5, 0.5, 0.45) if self._flash_timer > 0 else (1.0, 1.0, 1.0, 1.0)
206
207    # -- combat -------------------------------------------------------------
208
209    def take_damage(self, player, attack_type: str) -> bool:
210        """Take a hit from *player*. Returns True when the hit landed."""
211        if not self.vulnerable or self._dead:
212            return False
213        if attack_type == "weapon":
214            dmg = player.get_full_weapon_damage()
215        else:
216            dmg = player.get_full_magic_damage()
217        self.hp -= dmg
218        self.vulnerable = False
219        self._invuln_timer = self._invuln_duration
220        self._flash_timer = FLASH_DURATION
221        # Knockback away from player
222        kb = Vec2(self.position.x - player.position.x, self.position.y - player.position.y)
223        kbl = kb.length()
224        if kbl > 1e-3:
225            kb /= kbl
226            self._knockback_velocity = kb * self.knockback * 12.0
227            self._knockback_timer = 0.12
228        if self.hp <= 0:
229            self._dead = True
230            self.died(self)
231        return True