nodes/enemy.pyΒΆ
Part of Tower Defence.
1"""Waypoint-following enemy.
2
3The port walks the waypoint list by hand rather than routing through
4`simvx.core.navigation.NavGrid2D`, because:
5
6 - the path is hand-authored, not derived, so there is nothing to solve,
7 - a full A* search per spawn would be wasted work for a fixed route,
8 - enemies align with the painted road exactly, with no grid quantisation.
9
10The result is a short read: walk towards the next waypoint at ``speed``, take
11the next one when you reach it, and signal when the road runs out.
12"""
13
14from __future__ import annotations
15
16import math
17from pathlib import Path
18
19from simvx.core import Node2D, Property, Signal, Sprite2D, Vec2
20
21from .td_data import ENEMY_COLOURS, ENEMY_DATA, KILL_REWARD, TILE_SIZE
22
23ENEMY_IMAGES = {
24 "weak": "enemy_1.png",
25 "medium": "enemy_2.png",
26 "strong": "enemy_3.png",
27 "elite": "enemy_4.png",
28}
29
30_ENEMY_DIR = Path(__file__).parent.parent / "assets" / "images" / "enemies"
31
32
33class Enemy(Node2D):
34 """An enemy unit walking the waypoint polyline."""
35
36 speed = Property(120.0, range=(20, 1000), hint="Walking speed in px/sec")
37 max_health = Property(10, range=(1, 1000))
38
39 died = Signal(int) # kill reward
40 escaped = Signal() # reached the path end alive
41 damaged = Signal(int) # damage taken
42
43 def __init__(self, enemy_type: str, waypoints: list[tuple[float, float]], **kwargs):
44 super().__init__(**kwargs)
45 self.add_to_group("enemies")
46
47 data = ENEMY_DATA[enemy_type]
48 self.enemy_type = enemy_type
49 self.max_health = data["health"]
50 self.speed = data["speed"]
51 self.health = data["health"]
52 self.waypoints = waypoints
53 self.target_index = 1
54 self.position = Vec2(*waypoints[0])
55 self.reached_end = False
56
57 # Slow debuff state (set by Slow turrets)
58 self.slow_factor: float = 1.0
59 self.slow_timer: float = 0.0
60
61 # Sprite child -- 96x96, scaled to ~TILE_SIZE.
62 self.sprite = self.add_child(
63 Sprite2D(
64 texture=str(_ENEMY_DIR / ENEMY_IMAGES[enemy_type]),
65 width=TILE_SIZE,
66 height=TILE_SIZE,
67 colour=ENEMY_COLOURS[enemy_type],
68 )
69 )
70
71 # ------------------------------------------------------------------
72 # Damage helpers
73 # ------------------------------------------------------------------
74
75 def take_damage(self, amount: int, *, slow_factor: float | None = None, slow_seconds: float | None = None) -> None:
76 if self.reached_end or self.health <= 0:
77 return
78 self.health -= amount
79 self.damaged(amount)
80 if slow_factor is not None and slow_seconds is not None:
81 # Apply only if stronger than current slow
82 if slow_factor < self.slow_factor or self.slow_timer <= 0:
83 self.slow_factor = slow_factor
84 self.slow_timer = slow_seconds
85 if self.health <= 0:
86 self.died(KILL_REWARD)
87 self.destroy()
88
89 # ------------------------------------------------------------------
90 # Tick
91 # ------------------------------------------------------------------
92
93 def on_update(self, dt: float) -> None:
94 if self.reached_end:
95 return
96
97 # Apply the world's fast-forward multiplier (the F key / "FAST x2"
98 # button) by reading its ``game_speed`` Property.
99 speed_mult = getattr(self.parent, "game_speed", 1) or 1
100 dt = dt * speed_mult
101
102 # Slow debuff decay
103 if self.slow_timer > 0:
104 self.slow_timer -= dt
105 if self.slow_timer <= 0:
106 self.slow_factor = 1.0
107
108 if self.target_index >= len(self.waypoints):
109 self.reached_end = True
110 self.escaped()
111 self.destroy()
112 return
113
114 target = self.waypoints[self.target_index]
115 dx = target[0] - self.position.x
116 dy = target[1] - self.position.y
117 dist = math.hypot(dx, dy)
118
119 step = self.speed * self.slow_factor * dt
120 if dist <= step:
121 self.position = Vec2(target[0], target[1])
122 self.target_index += 1
123 else:
124 self.position = Vec2(
125 self.position.x + dx / dist * step,
126 self.position.y + dy / dist * step,
127 )
128 # Rotate sprite to face direction of travel.
129 self.sprite.rotation = math.atan2(dy, dx)