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