Добавление противников с AI
Enemies are the important part of any shooter. We are going to create an enemy that moves around the scene chasing the player, starts firing at a certain distance from the player, and gets killed (deleted) if hit by the player's bullets.Противники — важная часть любого шутера. Мы собираемся создать противника, который перемещается по сцене, преследуя игрока, начинает стрелять на определенном расстоянии от него и убивается (удаляется) при попадании одной пули игрока.
Before adding an enemy model, you should create it in a 3D modeling software.Прежде чем добавлять модель врага, вы должны создать ее в каком-то стороннем приложении 3D-моделирования.
Find our ready-to-use robot_enemy.node enemy template in the data/fps/robot folder and place it in the scene.Найдите наш шаблон врага robot_enemy.node в папке data/fps/robot и добавьте его в сцену.
Applying a Finite-State Machine for AIПрименение конечного автомата для AI#
To be a strong opponent, your enemy must have a certain level of intelligence. A simple AI can be implemented using a Finite-State Machine — a concept allowing you to describe the logic in terms of states and transitions between them.Чтобы быть достойным противником, ваш враг должен обладать определенным уровнем интеллекта. Простую модель “разумного” поведения можно реализовать с использованием Конечного автомата — концепции, позволяющей описывать логику в терминах состояний и переходов между ними.
For simplicity, consider three states: Idle, Chase, and Attack/Fire.Для простоты рассмотрим три состояния: Бездействие (Idle), Преследование (Chase) и Атака (Attack/Fire).
The following diagram describes what the enemy should do in each state, and how it will switch different states. The typical transitions would be from Idle to Chase, from Chase to Attack, and vice versa.Следующая диаграмма описывает, что враг должен делать в каждом состоянии и как он будет переходить из одного состояния в другое. Типичными будут переходы от Idle к Chase, от Chase к Attack и наоборот.
Raycasts to Determine VisibilityРейкасты для определения видимости#
How will the enemy "see" us? This can be implemented with the help of raycast (Intersections), which we have already used to determine the bullet hits. The algorithm is simple: we shoot a ray from the enemy's location in the direction he is looking at, detect the first object intersected by the ray and check if it is the player. All this can be described using the following function:Как враг будет нас “видеть”? Реализовать это можно при помощи рейкастов (Intersections), которые мы уже использовали для определения попадания пули. Алгоритм простой: из точки расположения врага стреляем лучом в направлении его взгляда, ищем первый пересеченный лучом объект и проверяем, не игрок ли это? Все это можно описать в виде такой функции:
private bool IsTargetVisible()
{
Vec3 direction = (player.WorldPosition - enemy.WorldPosition);
Vec3 p0 = intersectionSocket.WorldPosition;
Vec3 p1 = p0 + direction;
Unigine.Object hitObject = World.GetIntersection(p0, p1, playerIntersectionMask, hitExcludes, hitInfo);
if (!hitObject)
return false;
return player.ID == hitObject.ID;
}
To implement transition between states in each frame, we are going to do the following:Для реализации переходов между состояниями в каждом кадре будем выполнять следующее:
private void Update()
{
// обновляем информацию о цели, путь до нее и ориентацию
UpdateTargetState();
UpdateOrientation();
UpdateRoute();
switch (currentState)
{
case EnemyLogicState.Idle: ProcessIdleState(); break;
case EnemyLogicState.Chase: ProcessChaseState(); break;
case EnemyLogicState.Attack: ProcessAttackState(); break;
}
}
private void ProcessIdleState()
{
// если видна цель (игрок) - переход Бездействие -> Преследование
if (targetIsVisible)
currentState = EnemyLogicState.Chase;
}
private void ProcessChaseState()
{
// Перерасчет координат направления и ускорения
// если цель не видна - переход Преследование -> Бездействие
if (!targetIsVisible)
currentState = EnemyLogicState.Idle;
// проверка дистанции и переход Преследование -> Атака
if (targetIsVisible && lastSeenDistanceSqr < attackInnerRadius * attackInnerRadius)
{
currentState = EnemyLogicState.Attack;
// начинаем стрельбу
}
// движение к цели
}
private void ProcessAttackState()
{
// проверка дистанции и переход Атака -> Преследование
if (!targetIsVisible || lastSeenDistanceSqr > attackOuterRadius * attackOuterRadius)
{
currentState = EnemyLogicState.Chase;
// прекращаем стрельбу
}
}
Using NavigationИспользование навигации#
Just seeing the object is not enough, one must also get within shooting distance (within the attack radius). The enemy should be able to chase the player correctly — build a route from its current position to the player's current position, without walking through obstacles or getting stuck halfway. To give the enemy additional knowledge about how it can navigate through the level, you can use navigation. The PathRoute class in the UNIGINE API is responsible for finding path on the plane or in three-dimensional space. Pathfinding is performed only within the Navigation Area, which can be either of the following two types:Мало увидеть объект, нужно еще подобраться к нему на расстояние выстрела (в пределах радиуса атаки). Наш враг должен уметь правильно преследовать игрока – построить маршрут от своей текущей позиции до текущей позиции игрока, не проходя при этом сквозь препятствия и не застревая на полпути. Чтобы дать врагу дополнительные знания о том, как можно перемещаться по уровню можно использовать навигацию. За нахождение путей на плоскости или в трехмерном пространстве в API UNIGINE отвечает класс PathRoute. Поиск пути осуществляется только в рамках области навигации (Navigation Area), которые бывают двух типов:
- Navigation Sector is used to search for a path both in three-dimensional space (a multi-story house, for example) and on the plane — in the sector projection area (in this case the Z coordinate is ignored). Sectors can be combined to build complex areas — a set of intersecting sectors forms a single navigation area.Navigation Sector – используется для поиска пути, как в трехмерном пространстве (многоэтажный дом, например), так и на плоскости, в области проекции сектора (при этом координата Z игнорируется). Секторы можно объединять между собой, для построения сложных областей – множество пересекающихся секторов образует единую область навигации.
- Navigation Mesh is used for pathfinding only on the plane at a specified height above the mesh polygons — i.e. polygons in this case show where you can walk. Unlike sectors, Navigation Mesh is always on its own, i.e. you cannot create areas by combining several meshes or a mesh and sectors.Navigation Mesh is used for pathfinding only on the plane at a specified height above the mesh polygons — i.e. polygons in this case show where you can walk. Unlike sectors, Navigation Mesh – используется для поиска пути только на плоскости в области заданной высоты над полигонами меша – т.е. полигоны в этом случае показывают, где можно ходить. В отличие от секторов, Navigation Mesh – одиночка, т.е. нельзя создавать области, комбинируя несколько мешей или же меш с секторами.
In our case, since our characters move in a relatively simple environment, we will use Navigation Mesh to define the navigation area.В нашем случае, поскольку персонажи у нас перемещаются в относительно простом окружении, для определения области навигации будем использовать Navigation Mesh.
Such a mesh can be generated based on the FBX model of the scene using special tools, for example, RecastBlenderAddon. We already have prepater such a mesh and added it in the data/fps/navigation folder.Сгенерировать такой меш можно на основе FBX модели сцены с использованием специальных инструментов, например, RecastBlenderAddon. У нас он уже готов и находится в папке data/fps/navigation.
To place the mesh in the scene, click Create -> Navigation -> NavigationMesh in the Menu Bar and select the navigation/navmesh.mesh file. Align the mesh with the area to cover all areas where walking is allowed.Чтобы поместить меш в сцену, выберите в меню Create -> Navigation -> NavigationMesh и укажите файл navigation/navmesh.mesh. Выровняйте меш так, чтобы он отмечал области доступные для перемещения.
In the Parameters window, set the Height of the navigation mesh to 3 for proper route calculation.Также в окне Parameters для Navigation Mesh установите значение Height равное 3 для правильного расчета маршрута.
Now that we have a navigation area, we can start pathfinding. In the Chase state, our enemy, instead of rushing to the last visible position of the player along a straight line, will follow the path using the Navigation Mesh we added. The path consists of a queue of route points calculated using the functionality of the PathRoute class. It looks something like this:Теперь, когда у нас есть область навигации, можно приступать к поиску пути. В состоянии Chase наш враг, вместо того, чтобы устремиться к последней видимой позиции игрока по прямой, будет следовать по пути, используя добавленный нами Navigation Mesh. Путь состоит из очереди точек маршрута, вычисленных с использованием функционала класса PathRoute. Выглядит это примерно так:
private void UpdateRoute()
{
if (Game.Time - lastCalculationTime < routeRecalculationInterval)
return;
if (shouldUpdateRoute)
{
// рассчитываем путь до игрока
route.Create2D(node.WorldPosition, lastSeenPosition, 1);
shouldUpdateRoute = false;
}
// если расчет пути окончен
if (route.IsReady)
{
// проверяем, не достигнута ли целевая точка
if (route.IsReached)
{
// очищаем очередь точек пути
calculatedRoute.Clear();
// добавляем все корневые точки в очередь
for(int i = 1; i < route.NumPoints; ++i)
calculatedRoute.Enqueue(route.GetPoint(i));
shouldUpdateRoute = true;
lastCalculationTime = Game.Time;
}
else
// пересчитываем путь, если целевая точка не была достигнута
shouldUpdateRoute = true;
}
}
Teaching the Enemy to ShootУчим врага стрелять#
After teaching the enemy to chase the player, we need to teach it to shoot. You don't want to strangle the player, do you?После обучения врага преследованию игрока, надо еще научить его стрелять, не душить же игрока в самом деле.
To implement the shooting ability, we need a bullet NodeReference that will be created at the moment of shooting when the robot is in the Attack state.Чтобы реализовать возможность стрельбы, нам нужен шаблон (NodeReference) пули, которая будет создаваться в моменты выстрелов, когда робот находится в состоянии атаки (Attack).
Let's add the shooting logic in the EnemyFireController component to make the robot shoot alternately from the left and right muzzle. The positions of their muzzles where bullets will be spawned are defined by the positions of two Dummy Nodes that are assigned to the Left Muzzle and Right Muzzle fields of the component.В компоненте EnemyFireController мы добавим логику стрельбы, предусматривающую попеременную стрельбу из левого и правого ствола. Позиции дул этих стволов, где будут создаваться пули, будем определять позициями двух Dummy Nodes, которые мы назначим в поля Left Muzzle и Right Muzzle компонента.
-
Create the EnemyFireController.cs componentt and paste the following code into it:Создайте компонент EnemyFireController.cs и вставьте туда следующий код:
EnemyFireController.cs
using System; using System.Collections; using System.Collections.Generic; using Unigine; [Component(PropertyGuid = "AUTOGENERATED_GUID")] // <-- идентификатор генерируется автоматически для нового компонента public class EnemyFireController : Component { public Node leftMuzzle = null; public Node rightMuzzle = null; public AssetLink bulletPrefab = null; public float shootInterval = 1.0f; private float currentTime = 0.0f; private bool isLeft = false; private bool isFiring = false; public void StartFiring() { isFiring = true; } public void StopFiring() { isFiring = false; } private void Init() { // сброс таймера currentTime = 0.0f; // переключаем стрельбу на правый ствол isLeft = false; } private void Update() { // если робот не в состоянии атаки (Бездействие или Преследование), то ничего не делаем if (!isFiring) return; // обновляем таймер currentTime += Game.IFps; // проверка интервала стрельбы if (currentTime > shootInterval) { // сброс таймера currentTime -= shootInterval; // создаем пулю из ассета назначенного в bulletPrefab Node bullet = World.LoadNode(bulletPrefab.AbsolutePath); // устанавливаем положение пули в зависимости от того, с какой стороны стреляем bullet.WorldTransform = (isLeft) ? leftMuzzle.WorldTransform : rightMuzzle.WorldTransform; // меняем ствол для следующего выстрела isLeft = !isLeft; } } }
- If necessary, enable editing of the robot_enemy node and assign the EnemyFireController.cs component to the robot_root Dummy Object.Включите при необходимости редактирование ноды robot_enemy и назначьте компонент EnemyFireController.cs ноде robot_root (Dummy Object).
-
Drag and drop the LeftGunMuzzle and RightGunMuzzle Dummy Nodes to the corresponding fields of the EnemyFireController component.Перетащите Dummy Node LeftGunMuzzle и RightGunMuzzle в соответствующие поля компонента EnemyFireController.
-
Drag and drop data/fps/bullet/bullet.node to the Bullet Prefab field.Перетащите data/fps/bullet/bullet.node в поле Bullet Prefab.
After spawning, the bullet should move in the appropriate direction changing its position in the world. If the bullet intersects with an object, a hit effect should be spawned at the point of impact. And if this object can take damage (i.e., it has a Health component, we'll do that a bit later), its health should be decreased by a certain value. Also, you can make the bullet apply an impulse to physical objects.После появления пуля должна двигаться в соответствующем направлении, меняя свое положение в мире. Если пуля пересекается с объектом, в точке попадания должен отобразиться соответствующий эффект. И если у объекта, в который попала пуля, есть такая характеристика, как “здоровье” (т.е. ему назначен компонент Health, его мы сделаем чуть позже), то это здоровье уменьшается на определенное значение.
- Add the data/fps/bullet/bullet.node asset to the scene.Добавьте ассет data/fps/bullet/bullet.node в сцену.
-
Create the Bullet.cs component and copy the following code:Создайте компонент Bullet.cs и скопируйте следующий код:
Bullet.cs
using System; using System.Collections; using System.Collections.Generic; using Unigine; #region Math Variables #if UNIGINE_DOUBLE using Vec3 = Unigine.dvec3; #else using Vec3 = Unigine.vec3; #endif #endregion [Component(PropertyGuid = "AUTOGENERATED_GUID")] // <-- идентификатор генерируется автоматически для нового компонента public class Bullet : Component { public float speed = 10.0f; public int damage = 1; public AssetLink hitPrefab = null; [ParameterMask] public int intersectionMask = ~0; private WorldIntersectionNormal hitInfo = new WorldIntersectionNormal(); private void Update() { // устанавливаем текущую позицию пули Vec3 currentPosition = node.WorldPosition; // устанавливаем направление движения пули вдоль оси Y vec3 currentDirection = node.GetWorldDirection(MathLib.AXIS.Y); // обновляем положение пули вдоль траектории в соответствии с заданной скоростью node.WorldPosition += currentDirection * speed * Game.IFps; // ищем пересечение траектории пули с каким-либо объектом Unigine.Object hitObject = World.GetIntersection(currentPosition, node.WorldPosition, intersectionMask, hitInfo); // если пересечений не найдено, ничего не делаем if (!hitObject) return; // иначе загружаем NodeReference с эффектом попадания Node hitEffect = World.LoadNode(hitPrefab.AbsolutePath); // устанавливаем NodeReference в точку попадания и ориентируем его по нормали к поверхности hitEffect.Parent = hitObject; hitEffect.WorldPosition = hitInfo.Point; hitEffect.SetWorldDirection(hitInfo.Normal, vec3.UP, MathLib.AXIS.Y); // удаляем пулю node.DeleteLater(); } }
- Enable editing of the bullet node and assign the bullet component to its Static Mesh node.Включите редактирование ноды bullet и назначьте ее дочерней ноде Static Mesh компонент bullet.
-
Drag data/fps/bullet/bullet_hit.node to the Hit Prefab field.Перетащите data/fps/bullet/bullet_hit.node в поле Hit Prefab.
- Assign the LifeTime.cs component to the bullet (Static Mesh) node and set its Life Time value to 5 seconds.Назначьте компонент LifeTime.cs ноде bullet (Static Mesh) и установите для него значение Life Time равным 5 секундам.
- Select the bullet Node Reference and click Apply to save changes and remove the bullet node from the scene.Выберите ноду bullet (Node Reference) и нажмите Apply чтобы сохранить изменения, затем удалите ноду bullet со сцены.
Putting All TogetherСобираем все вместе#
Now summarizing the above, let's create the EnemyLogic.cs component with the following code:Итак, резюмируя все вышесказанное, создадим компонент EnemyLogic.cs с таким кодом:
EnemyLogic.cs
using System;
using System.Collections;
using System.Collections.Generic;
using Unigine;
#region Math Variables
#if UNIGINE_DOUBLE
using Vec3 = Unigine.dvec3;
#else
using Vec3 = Unigine.vec3;
#endif
#endregion
// определяем состояния врага
public enum EnemyLogicState
{
Idle,
Chase,
Attack,
}
[Component(PropertyGuid = "AUTOGENERATED_GUID")] // <-- идентификатор генерируется автоматически для нового компонента
public class EnemyLogic : Component
{
public Node player = null;
public Node intersectionSocket = null;
public float reachRadius = 0.5f;
public float attackInnerRadius = 5.0f;
public float attackOuterRadius = 7.0f;
public float speed = 1.0f;
public float rotationStiffness = 8.0f;
public float routeRecalculationInterval = 3.0f;
[ParameterMask(MaskType = ParameterMaskAttribute.TYPE.INTERSECTION)]
public int playerIntersectionMask = ~0;
// инициализируем состояние врага
private EnemyLogicState currentState = EnemyLogicState.Idle;
private bool targetIsVisible;
private Vec3 lastSeenPosition;
private vec3 lastSeenDirection;
private float lastSeenDistanceSqr;
private BodyRigid bodyRigid = null;
private WorldIntersection hitInfo = new WorldIntersection();
private Node[] hitExcludes = new Node[2];
private EnemyFireController fireController = null;
// создаем очередь для точек пути
private Queue<Vec3> calculatedRoute = new Queue<Vec3>();
private PathRoute route = new PathRoute();
private bool shouldUpdateRoute = true;
private float lastCalculationTime = 0.0f;
private bool IsTargetVisible()
{
Vec3 direction = (player.WorldPosition - intersectionSocket.WorldPosition);
Vec3 p0 = intersectionSocket.WorldPosition;
Vec3 p1 = p0 + direction;
Unigine.Object hitObject = World.GetIntersection(p0, p1, playerIntersectionMask, hitExcludes, hitInfo);
if (!hitObject)
return false;
return player.ID == hitObject.ID;
}
private void Init()
{
// инициализируем параметры точки, движущейся по пути в пределах навигационного меша
route.Radius = 0.0f;
route.Height = 1.0f;
route.MaxAngle = 0.5f;
bodyRigid = node.ObjectBodyRigid;
hitExcludes[0] = node;
hitExcludes[1] = node.GetChild(0);
targetIsVisible = false;
// получаем компонент EnemyFireController
fireController = node.GetComponent<EnemyFireController>();
shouldUpdateRoute = true;
lastCalculationTime = Game.Time;
}
private void Update()
{
UpdateTargetState();
UpdateOrientation();
UpdateRoute();
// переключение состояний врага
switch (currentState)
{
case EnemyLogicState.Idle: ProcessIdleState(); break;
case EnemyLogicState.Chase: ProcessChaseState(); break;
case EnemyLogicState.Attack: ProcessAttackState(); break;
}
// переключение цвета в зависимости от текущего состояния
vec4 color = vec4.BLACK;
switch (currentState)
{
case EnemyLogicState.Idle: color = vec4.BLUE; break;
case EnemyLogicState.Chase: color = vec4.YELLOW; break;
case EnemyLogicState.Attack: color = vec4.RED; break;
}
// визуализируем состояния врага
Visualizer.RenderPoint3D(node.WorldPosition + vec3.UP * 2.0f, 0.25f, color);
Visualizer.RenderPoint3D(node.WorldPosition + vec3.UP * 3.0f, 0.25f, IsTargetVisible() ? vec4.GREEN : vec4.RED);
Visualizer.RenderPoint3D(lastSeenPosition, 0.1f, vec4.MAGENTA);
// визуализируем радиус атаки
Visualizer.RenderSphere(attackInnerRadius, node.WorldTransform, vec4.RED);
Visualizer.RenderSphere(attackOuterRadius, node.WorldTransform, vec4.RED);
// визуализируем точки маршрута
foreach (vec3 route_point in calculatedRoute)
Visualizer.RenderPoint3D(route_point + vec3.UP, 0.25f, vec4.BLACK);
}
private void UpdateRoute()
{
if (Game.Time - lastCalculationTime < routeRecalculationInterval)
return;
if (shouldUpdateRoute)
{
// рассчитываем путь до игрока
route.Create2D(node.WorldPosition, lastSeenPosition, 1);
shouldUpdateRoute = false;
}
// если расчет пути окончен
if (route.IsReady)
{
// проверяем, не достигнута ли целевая точка
if (route.IsReached)
{
// очищаем очередь точек пути
calculatedRoute.Clear();
// добавляем все корневые точки в очередь
for(int i = 1; i < route.NumPoints; ++i)
calculatedRoute.Enqueue(route.GetPoint(i));
shouldUpdateRoute = true;
lastCalculationTime = Game.Time;
}
else
// пересчитываем путь, если целевая точка не была достигнута
shouldUpdateRoute = true;
}
}
private void UpdateTargetState()
{
targetIsVisible = IsTargetVisible();
// если игрока видно, запоминаем его последнее зарегистрированное положение
if (targetIsVisible)
lastSeenPosition = player.WorldPosition;
lastSeenDirection = (vec3)(lastSeenPosition - node.WorldPosition)
lastSeenDistanceSqr = lastSeenDirection.Length2;
lastSeenDirection.Normalize();
}
private void UpdateOrientation()
{
vec3 direction = lastSeenDirection;
direction.z = 0.0f;
quat targetRotation = new quat(MathLib.SetTo(vec3.ZERO, direction.Normalized, vec3.UP, MathLib.AXIS.Y));
quat currentRotation = node.GetWorldRotation();
currentRotation = MathLib.Slerp(currentRotation, targetRotation, Game.IFps * rotationStiffness);
node.SetWorldRotation(currentRotation);
}
private void ProcessIdleState()
{
// если видна цель (игрок) - переход Бездействие -> Преследование (Chase)
if (targetIsVisible)
currentState = EnemyLogicState.Chase;
}
private void ProcessChaseState()
{
vec3 currentVelocity = bodyRigid.LinearVelocity;
currentVelocity.x = 0.0f;
currentVelocity.y = 0.0f;
if (calculatedRoute.Count > 0)
{
float distanceToTargetSqr = (calculatedRoute.Peek() - node.WorldPosition).Length2;
bool targetReached = (distanceToTargetSqr < reachRadius * reachRadius);
if (targetReached)
calculatedRoute.Dequeue();
if (calculatedRoute.Count > 0)
{
vec3 direction = calculatedRoute.Peek() - node.WorldPosition;
direction.z = 0.0f;
direction.Normalize();
currentVelocity.x = direction.x * speed;
currentVelocity.y = direction.y * speed;
}
}
// если цель не видна - переход Преследование -> Бездействие
if (!targetIsVisible)
currentState = EnemyLogicState.Idle;
// проверка дистанции и переход Преследование -> Атака
else if (lastSeenDistanceSqr < attackInnerRadius * attackInnerRadius)
{
currentState = EnemyLogicState.Attack;
currentVelocity.x = 0.0f;
currentVelocity.y = 0.0f;
// начинаем стрельбу
if (fireController)
fireController.StartFiring();
}
bodyRigid.LinearVelocity = currentVelocity;
}
private void ProcessAttackState()
{
// проверка дистанции и переход Атака -> Преследование
if (!targetIsVisible || lastSeenDistanceSqr > attackOuterRadius * attackOuterRadius )
{
currentState = EnemyLogicState.Chase;
// прекращаем стрельбу
if (fireController)
fireController.StopFiring();
}
}
}
- Enable editing of the robot_enemy node and assign the new component to the robot_root Dummy Node in the Parameters window.Включите редактирование ноды robot_enemy и назначьте новый компонент ноде robot_root (Dummy Node) в окне Parameters.
-
Right-click the player Node Reference in the World Nodes window and select Unpack to Node Content. The Node Reference will be removed and its contents will be displayed in the World Nodes hierarchy.Щелкните правой кнопкой мыши на Node Reference player в окне World Nodes и выберите Unpack to Node Content. Node Reference будет удалена, и ее содержимое будет отображаться в иерархии World Nodes.
- Drag and drop the player_hit_box node to the Player field of the EnemyLogic component. This node imitates the player body and is used in calculations. Make sure that the Intersection option is checked for player_hit_box.Перетащите ноду player_hit_box в поле Player компонента EnemyLogic, эта нода имитирует тело игрока и используется в вычислениях. Убедитесь, что у player_hit_box включена опция Intersection.
-
Drag and drop the robot_intersection_socket node of the robot_enemy node to Intersection Socket field. This is the node from which the robot will do intersection checks.Перетащите ноду robot_intersection_socket ноды robot_enemy в поле Intersection Socket. Это нода, от которой робот будет выполнять проверку пересечений.
For debugging, you can enable Visualizer that will display the inner and outer attack radius, as well as the colored squares above the robot indicating:Для отладки вы можете включить Visualizer, который будет отображать внутренний и внешний радиус атаки, а также цветные квадраты над роботом, указывающие:
- The state of the robot: Idle — BLUE, Chase — YELLOW, Attack — RED.Состояние робота: Idle — СИНИЙ, Chase — ЖЕЛТЫЙ, Attack — КРАСНЫЙ.
- If the target is visible: Yes — GREEN, No — RED.Видна ли цель: Да — ЗЕЛЕНЫЙ, Нет — КРАСНЫЙ.
And the points of the calculated path:А также точки построенного пути: