У нас есть враг. Он стоит на месте. Игрок проходит мимо - враг стоит. Игрок бьёт его - враг стоит. Игрок уходит - враг стоит. Не очень впечатляет.
Нам нужно, чтобы враг вёл себя хотя бы минимально осмысленно: ходил по маршруту, замечал игрока, преследовал его, атаковал. Не нужен настоящий интеллект - достаточно создать иллюзию разумного поведения. Игрок не отличит простой автомат от сложной нейросети, если враг вовремя реагирует и не делает очевидно глупых вещей.
Как раз для этого мы используем конечные автоматы, поиск пути и коллизии - всё, что уже разбирали.
Самое базовое поведение: враг ходит по маршруту. Туда-сюда, по кругу, между точками - неважно. Главное - он не стоит столбом.
Простейший вариант: задаём список точек патрулирования (waypoints). Враг идёт к первой точке, дошёл - идёт ко второй, и так далее. Добрался до последней - начинает сначала.
class PatrollingEnemy:
def __init__(self, x, y, waypoints, speed):
self.x = x
self.y = y
self.waypoints = waypoints # list of (x, y) tuples
self.current_wp = 0
self.speed = speed
def update(self, delta_time):
target_x, target_y = self.waypoints[self.current_wp]
dx = target_x - self.x
dy = target_y - self.y
dist = (dx ** 2 + dy ** 2) ** 0.5
if dist < 2: # close enough, move to next waypoint
self.current_wp = (self.current_wp + 1) % len(self.waypoints)
else:
# normalize direction and move
self.x += (dx / dist) * self.speed * delta_time
self.y += (dy / dist) * self.speed * delta_time
# usage
guard = PatrollingEnemy(100, 100, waypoints=[(100, 100), (300, 100), (300, 300), (100, 300)], speed=80)
public class PatrollingEnemy
{
public float X { get; set; }
public float Y { get; set; }
private Vector2[] waypoints;
private int currentWp;
private float speed;
public PatrollingEnemy(float x, float y, Vector2[] waypoints, float speed)
{
X = x;
Y = y;
this.waypoints = waypoints;
this.speed = speed;
}
public void Update(float deltaTime)
{
var target = waypoints[currentWp];
float dx = target.X - X;
float dy = target.Y - Y;
float dist = MathF.Sqrt(dx * dx + dy * dy);
if (dist < 2f)
{
currentWp = (currentWp + 1) % waypoints.Length;
}
else
{
X += (dx / dist) * speed * deltaTime;
Y += (dy / dist) * speed * deltaTime;
}
}
}
// usage
var guard = new PatrollingEnemy(100, 100,
new[] { new Vector2(100, 100), new Vector2(300, 100),
new Vector2(300, 300), new Vector2(100, 300) },
speed: 80f);
Враг движется по квадрату 200x200 пикселей. dist < 2 - порог "прибытия": если до точки осталось меньше 2 пикселей, считаем, что дошли. Без этого порога враг может бесконечно колебаться вокруг точки, не попадая в неё точно.
Точки патрулирования можно задавать вручную на уровне (в редакторе или в коде) или генерировать - например, случайные точки внутри комнаты. Для процедурно генерируемых уровней второй вариант удобнее.
Ну, патрулировать враг умеет. Теперь было бы хорошо, если б он замечал игрока. Самый простой способ - радиус обнаружения: если расстояние между врагом и игроком меньше определённого значения, враг "видит" игрока.
dist = distance(enemy, player)
if dist < detection_radius:
# enemy sees the player
Это, по сути, проверка столкновения двух окружностей: хитбокс игрока и невидимая "зона видимости" врага. Знакомая тема.
def distance(ax, ay, bx, by):
return ((ax - bx) ** 2 + (ay - by) ** 2) ** 0.5
def can_detect(enemy, player, radius):
return distance(enemy.x, enemy.y, player.x, player.y) < radius
# usage
if can_detect(guard, player, radius=200):
# switch to chase
pass
public static float Distance(float ax, float ay, float bx, float by)
{
float dx = ax - bx;
float dy = ay - by;
return MathF.Sqrt(dx * dx + dy * dy);
}
public static bool CanDetect(PatrollingEnemy enemy, Player player, float radius)
{
return Distance(enemy.X, enemy.Y, player.X, player.Y) < radius;
}
// usage
if (CanDetect(guard, player, radius: 200f))
{
// switch to chase
}
Это работает, но есть проблема: враг видит сквозь стены. Игрок за толстой стеной, в соседней комнате, но попадает в радиус - и враг его "видит". Это выглядит нечестно.
Чтобы враг не видел сквозь стены, нужно проверить, есть ли между врагом и игроком препятствие. Простой способ: пройтись от врага к игроку маленькими шагами и проверить, не попали ли мы в стену. Это, по сути, рейкаст (raycast) - "бросаем луч" от врага к игроку.
Если мы работаем с тайловой картой, то проверяем, не проходит ли луч через тайл-стену:
def has_line_of_sight(x1, y1, x2, y2, level, tile_size):
dx = x2 - x1
dy = y2 - y1
dist = (dx ** 2 + dy ** 2) ** 0.5
if dist == 0:
return True
steps = int(dist / (tile_size / 2)) # check every half-tile
for i in range(1, steps):
t = i / steps
check_x = x1 + dx * t
check_y = y1 + dy * t
col = int(check_x // tile_size)
row = int(check_y // tile_size)
if level[row][col] == 1: # wall
return False
return True
# combined check: in range AND no walls between
def can_see_player(enemy, player, radius, level, tile_size):
if distance(enemy.x, enemy.y, player.x, player.y) > radius:
return False
return has_line_of_sight(enemy.x, enemy.y, player.x, player.y, level, tile_size)
public static bool HasLineOfSight(float x1, float y1, float x2, float y2,
int[,] level, int tileSize)
{
float dx = x2 - x1;
float dy = y2 - y1;
float dist = MathF.Sqrt(dx * dx + dy * dy);
if (dist == 0) return true;
int steps = (int)(dist / (tileSize / 2f));
for (int i = 1; i < steps; i++)
{
float t = (float)i / steps;
int col = (int)((x1 + dx * t) / tileSize);
int row = (int)((y1 + dy * t) / tileSize);
if (level[row, col] == 1) // wall
return false;
}
return true;
}
public static bool CanSeePlayer(PatrollingEnemy enemy, Player player,
float radius, int[,] level, int tileSize)
{
if (Distance(enemy.X, enemy.Y, player.X, player.Y) > radius)
return false;
return HasLineOfSight(enemy.X, enemy.Y, player.X, player.Y, level, tileSize);
}
Мы проходим от врага к игроку шагами в пол-тайла и проверяем каждую точку. Если хоть одна попала в стену - враг не видит игрока. Шаг в пол-тайла - компромисс: слишком крупный шаг может "перешагнуть" тонкую стену, слишком мелкий - медленно.
Это упрощённый рейкаст. Существуют более точные алгоритмы (алгоритм Брезенхэма, DDA и другие). В этом простом варианте, к примеру, враг может "видеть" сквозь угол стены.
Враг заметил игрока. Что дальше? Бежать к нему. Наивный подход - двигаться напрямую к координатам игрока:
direction = normalize(player.pos - enemy.pos)
enemy.pos += direction * speed * delta_time
Это работает на открытом пространстве. Но если между врагом и игроком стена, враг упрётся в неё и будет стоять, дёргаясь. Не очень умно выглядит.
Решение: использовать поиск пути. Строим маршрут от врага к игроку (например, через A* или алгоритм Дейкстры на тайловой сетке) и двигаемся по найденному пути.
class ChasingEnemy:
def __init__(self, x, y, speed):
self.x = x
self.y = y
self.speed = speed
self.path = [] # list of (x, y) waypoints from pathfinder
def chase(self, player, level, tile_size):
# rebuild path periodically
start = (int(self.x // tile_size), int(self.y // tile_size))
goal = (int(player.x // tile_size), int(player.y // tile_size))
self.path = find_path(level, start, goal) # your A* / BFS
def update(self, delta_time):
if not self.path:
return
target_x, target_y = self.path[0]
dx = target_x - self.x
dy = target_y - self.y
dist = (dx ** 2 + dy ** 2) ** 0.5
if dist < 2:
self.path.pop(0)
else:
self.x += (dx / dist) * self.speed * delta_time
self.y += (dy / dist) * self.speed * delta_time
public class ChasingEnemy
{
public float X { get; set; }
public float Y { get; set; }
private float speed;
private List<Vector2> path = new();
public ChasingEnemy(float x, float y, float speed)
{
X = x;
Y = y;
this.speed = speed;
}
public void Chase(Player player, int[,] level, int tileSize)
{
var start = new Point((int)(X / tileSize), (int)(Y / tileSize));
var goal = new Point((int)(player.X / tileSize), (int)(player.Y / tileSize));
path = FindPath(level, start, goal); // your A* / BFS
}
public void Update(float deltaTime)
{
if (path.Count == 0) return;
var target = path[0];
float dx = target.X - X;
float dy = target.Y - Y;
float dist = MathF.Sqrt(dx * dx + dy * dy);
if (dist < 2f)
path.RemoveAt(0);
else
{
X += (dx / dist) * speed * deltaTime;
Y += (dy / dist) * speed * deltaTime;
}
}
}
Но пересчитывать путь каждый кадр - дорого. Игрок двигается, но не настолько быстро, чтобы пересчитывать маршрут 60 раз в секунду. Обычно пересчитывают раз в 0.5-1 секунду или когда игрок переместился на определённое расстояние от последней точки.
Враг добрался до игрока. Теперь нужно атаковать. Два вопроса: когда атаковать и как не атаковать слишком часто.
Когда: если расстояние до игрока меньше радиуса атаки (значительно меньше радиуса обнаружения).
Как не слишком часто: вводим кулдаун (cooldown) - минимальное время между атаками.
Если вы реализовали событийную модель, атака - хороший кандидат на событие: on_attack.fire(player, damage). Тогда звуковая система проиграет звук, система анимаций запустит анимацию удара, UI обновит полоску здоровья - и всё это без того, чтобы AttackBehavior знал обо всех этих системах.
Теперь у нас есть все кусочки: патрулирование, обнаружение, преследование, атака. Как их связать? Правильно - конечный автомат. Состояния врага:

Можно реализовать это через классы-состояния (как в разделе про автоматы).
Обратите внимание на несколько деталей:
lose_radius > detect_radius. Враг замечает игрока на расстоянии 200, но прекращает погоню на 350. Без этого враг на границе радиуса будет бесконечно переключаться между Patrol и Chase - увидел, потерял, увидел, потерял. Этот зазор называют гистерезис.
Скорость погони выше (speed * 1.5). Враг при преследовании ускоряется. Иначе, если его скорость равна скорости игрока, он никогда не догонит - и это выглядит абсурдно.
attack_range * 1.5 для выхода из Attack. Тот же гистерезис: чтобы враг не дёргался между Attack и Chase, когда игрок стоит на границе дистанции атаки.
Гистерезис - главный секрет того, чтобы ИИ выглядел "спокойным" и уверенным. Без него враги постоянно дёргаются, переключаясь между состояниями на каждом кадре. Используйте разные пороги для входа в состояние и для выхода из него.
Меняя параметры, из одного и того же автомата можно получить совершенно разных врагов:
| Параметр | Стражник | Берсерк | Лучник |
|---|---|---|---|
| Скорость | 60 | 120 | 50 |
| Радиус обнаружения | 150 | 300 | 250 |
| Радиус потери | 250 | 500 | 400 |
| Дистанция атаки | 40 | 50 | 200 |
| Кулдаун атаки | 1.5 сек | 0.5 сек | 2.0 сек |
| Урон | 10 | 25 | 15 |
Хм. Стражник - медленный, видит недалеко, бьёт средне. Берсерк - быстрый, видит далеко, бьёт часто и больно. Лучник - медленный, но стреляет издалека. Та же логика, разные числа.
Это хороший пример того, зачем нужна архитектура: когда поведение отделено от данных, создание новых типов врагов - это не написание нового кода, а подстановка других чисел. В разумных пределах.
Несколько вещей, которые часто идут не так (+ к типичными ошибками):
Описанный автомат - базовый, но расширяемый. Несколько направлений для усложнения:
bus.fire("alert", player_position). Остальные враги подписаны и переключаются в Chase.Ок. Каждое из этих улучшений - ещё одно состояние или параметр в уже существующем автомате. Конечные автоматы хороши тем, что новое поведение - это новая стрелочка на диаграмме, а не переписывание всего кода.
А если состояний становится слишком много и переходы превращаются в паутину - стоит посмотреть на деревья поведений (Behavior Trees). Это следующий шаг: вместо графа переходов - дерево приоритетов, где добавить поведение = добавить ветку.