Ситуация, похожая на описанную в превью, произошла со мной в ходе работы над игрой Разведка 1944. Как сделать движение по точкам представление я имел, но вот сделать полноценное движение по сплайну оказалось серьезным вызовом, который потребовал некоторого времени на изучение вопроса и эксперименты. Причем, что необычно, даже каких-то полезных гайдов и материалов по теме практически не удалось найти. Обычно находится хотя бы что-то, что дает пищу для размышлений или какие-то зацепки для базового подхода, но не в этот раз. Пожалуй, именно это обстоятельство и стало ключевым в выборе темы для следующей статьи, которую я решил написать.

Прежде чем перейти к содержательной части, как обычно, небольшой дисклеймер о том, что статья рассчитана на уже смешариков не совсем новичков, а на разработчиков, которые могут отличить актор от блюпринта и класс от метода. Совсем базовые понятия тут поясняться не будут, а рассказ пойдет больше об идеях и подходах. Ну и поскольку я работаю на Unreal Engine, весь код и все примеры я привожу для Unreal’а. Хотя это не мешает применять описанные принципы на любом движке

Ну вот, с нудными предупреждениями покончено, давайте наконец погружаться в тему.

Итак, стартовая задача – нам нужно уметь двигать группу из одного или даже нескольких персонажей (под персонажем подразумевается класс ACharacter или в широком смысле сущность, умеющая осуществлять движение) по произвольному маршруту. Причем параметры движения этого персонажа должны максимально гибко настраиваться силами геймдизайнеров, без участия программистов.

В общем случае, для управления движением AI-персонажа UE предлагает нам использовать таску MoveTo, которая отправляет персонажа в заданную точку и позволяет этим движением управлять. Отсюда вытекает первая очевидная идея: задать набор точек и каждый раз когда персонаж доходит до очередной точки давать ему команду двигаться к следующей. И это даже будет работать и выглядеть сносно, хоть и получить таким образом плавное движение по кривой не выйдет, всегда будет движение прямо с резкими поворотом. А делать пути из десятков точек для имитации плавности довольно трудозатратно. Но по-настоящему серьезным препятствием для этого способа является групповое движение. Когда двигать надо не одного, а нескольких персонажей, сложность задачи очень быстро улетает в космос. Смотрите сами: теперь нам надо не просто отправить персонажа в точку и подождать когда он дойдет, а сначала отправить всех персонажей в нужные точки, дождаться когда они все придут, еще одной пачкой MoveTo перестроить строй лицом к следующей точке, снова дождаться пока они все придут и отправить дальше.

И в какой-то момент в попытках сделать всё это в отражении монитора вы видите что-то вроде этого:

И если насчет движения таким образом по условному треугольнику или квадрату еще можно подискутировать, то о плавных поворотах придется забыть окончательно, ведь постоянные циклы подходов-перестроений каждые 100-200 юнитов будут выглядеть ужасно в любом случае. А потом еще выясняется, что фактически синхронизировать движение мобов таким образом невозможно, поскольку даже если они вышли одновременно и правильным строем, то это не гарантирует сохранение этого строя в процессе ходьбы, потому что если, например, на пути есть препятствие, или просто моб запнется об микрогорку, то весь строй летит в тартарары и проконтролировать это мы фактически никак не можем.

И вот на этом моменте хочется задаться несколькими риторическими вопросами: во-первых, мне очень интересно, почему такая, казалось бы, важная и полезная фича как движение персонажа по сплайну, никак не предусмотрена дефолтными инструментами движка, хотя, казалось бы, оно явно напрашивается.

Если вдруг вы знаете как это можно удобно сделать дефолтными инструментами UE – напишите пожалуйста. Мне будет больно от осознания бессмысленности проделанной работы, но такова цена познания

Что еще более интересно и, я бы даже сказал, абсурдно, что практически нет гайдов, которые бы рассказывали как решить эту проблему (подробнее про это будет чуть ниже). И, наконец, самое интересное – что во всех играх где эта фича нужна, она реализована в полной мере. Может быть есть какой-то пакт разработчиков, который запрещает разглашать секрет реализации этой механики непосвященным? Чтож, меня, по крайней мере, об этом никто не уведомил.

Так вот, касательно имеющихся гайдов: на ютубе я нашел всего два англоязычных видео, описание которых примерно соответствовало нашей задаче. В одном из них рассматривался разобранный выше метод с движением по точкам, а в другом персонаж добавлялся чайлд актором в сплайн актор и далее на нем запускалась анимация бега и на тике обновлялся трансформ так, чтобы получалось движение вдоль сплайна. И хоть это внешне напоминает желаемый результат, костыльность такого решения находится на каком-то неприличном уровне. Более того у меня лично сразу появляется вопрос о том, что делать с анимацией движения если я хочу чтобы она зависела от скорости (очевидно, использовать одну и ту же анимацию бега для всех патрулей в игре независимо от скорости – плохая идея). Да, можно сказать что можно прокинуть скорость движения в Anim BP и привязать там к блендспейсу, но тогда опять же при движении двух и более персонажей по кругу шеренгой, очевидно, тот, что ближе к центру будет идти с меньшей скоростью чем тот, что дальше. а значит, уже как-то более сложно нужно рассчитывать скорости и менеджерить всю эту историю. А еще поскольку это не честный мувмент, любые неидеальные участки пути нужно обрабатывать отдельно, включая такие банальные как наклонная поверхность. В общем, допускаю, что докрутив этот метод можно добиться чего-то удобоваримого, но это все еще костыль подпертый пачкой костылей, что нас, профессионалов своего дела, удовлетворить однозначно не может.

Кстати, если кто-то не понял почему бы нам просто не делать MoveTo на тике, то это потому, что выдача таски просто слишком комплексное действие чтобы выполняться на тике. Ведь каждой MoveTo таске надо оформить запрос в контроллер, присвоить этой задаче Handle, рассчитать маршрут, потом заставить моба двигаться по нему (что является продолжительным процессом), следить за ходом этого движения и потом вернуть результат. Вызов такой штуки на тике, даже если и будет работать, что не факт:

  • приводит к огромной куче избыточных действий

  • не особо безопасен, поскольку хэндлы новых тасок могут выдаваться быстрее чем завершаются предыдущие, переписывая их и лишая нас возможности контролировать все активные таски

  • вероятно, движение перезапускающееся на тике еще и будет ужасно дерганым (честно, не проверял, но вполне допускаю такое)

В силу всего вышеописанного, все эти очевидные подходы пришлось откинуть и перейти к более тонким изысканиям. Для начала давайте разберемся с принципом движения по кривой траектории одного персонажа.

Движение одного персонажа по сплайну

Теперь, достаточно углубившись в контекст проблемы, мы можем обсудить решение, лишенное большинства вышеописанных изъянов. Сразу оговорюсь, что его синтез занял у меня пару недель R&D работ, так что пересказывать всю эволюцию своих идей я не буду (иначе статья растянется в бесконечность) и сразу перейду к итоговому варианту.

Ход рассуждений был примерно следующий: оформлять полноценный мувмент на тике мы не можем, но при этом способы произвольно двигать персонажа на тике существуют (например, обычное WASD управление PlayerCharacter’ом), так почему бы не использовать тот же подход, двигая персонажа за счет имитации «правильного» инпута?

Выяснилось, что для этого вполне можно использовать функцию UPawnMovementComponent::AddInputVector() . Вот с ней на тике проблем нет, поскольку она как раз позволяет добавить инпут, который в конце фрейма сложится с другими инпутами (которых у нас предположительно быть не должно) и применится к персонажу. И давайте сразу обозначим чем это лучше всех вышеизложенных способов:

  • обновляем направление движения на тике, то есть можем двигаться по любой кривой без проблем

  • используем честный мувмент, а значит всё, что связано с движением (анимация, например), подтягивается автоматически универсальными механизмами движка

  • величину и направление инпута мы определяем сами, что дает неограниченную гибкость для решения нашей задачи

Ну и, собственно, приступаем к реализации:

Дисклеймер: код который я привожу ни в коей мере не претендует на продуктовое качество. Я руководствуюсь единственным критерием – наглядность в рамках статьи. Это означает, что я пишу как можно меньше кода, опускаю ненужные мне для примера спецификаторы, failsafe проверки и так далее. Моя цель в данном тексте – познакомить многоуважаемого читателя с принципом. Со структуризацией и оптимизацией кода он, верю, справится самостоятельно

Для начала заведем актор маршрута ASplinePath. По сути это актор со сплайн компонентом и пачкой utility-функций. Именно его мы будем настраивать на сцене и с него будем получать нужную информацию о маршруте. Итак, заводим в акторе поле под сплайн компонент:

//ASplinePath.h
//...

UPROPERTY(BlueprintReadOnly)
USplineComponent* Spline;

//...

В конструкторе создаем этот сплайн компонент:

Тут небольшая ремарка: на практике маршрут может быть замкнутый или разомкнутый. В первом случае по достижении конца пути он просто начинается сначала, во втором – по достижении конца нужно развернуться и идти в обратном направлении от конца к началу. Я буду рассматривать только вариант с замкнутым маршрутом, поскольку он проще, а следовательно, нагляднее. Поэтому я сразу сделаю сплайн замкнутым в конструкторе. Реализация движения по разомкнутому маршруту отличается, фактически, только логикой нахождения следующей желаемой точки, которая требует запоминания направления движения (прямое или обратное). Уверен, заинтересованный читатель без труда сумеет его реализовать

//ASplinePath.cpp
//...

ASplinePath::ASplinePath()
{
	PrimaryActorTick.bCanEverTick = false;

	Spline = CreateDefaultSubobject<USplineComponent>(TEXT("Spline"));
	Spline->SetClosedLoop(true);
}

//...

Добавим в актор пару тулзовых геттеров, которые пригодятся позднее:

Еще одна ремарка: положение вдоль маршрута будем определять одномерной координатой вдоль маршрута. Это удобно в использовании, а также хорошо сочетается с встроенным функционалом сплайн компонента

//ASplinePath.h
//...

//Получение WorldTransform точки на сплайне для указанной одномерной координаты
UFUNCTION()
FVector GetLocationAlongPath(float Coordinate) const 
 { return Spline->GetTransformAtDistanceAlongSpline(Coordinate, ESplineCoordinateSpace::World).GetLocation(); }

//Функция для получения длины пути
UFUNCTION()
float GetPathLength() const { return Spline->GetSplineLength(); }

//...

Управлять движением персонажа будем при помощи специального компонента на этом персонаже. Создаем актор-компонент FollowSplineComponent и в нем заводим поля:

//FollowSplineComponent.h
//...

//Ссылка на путь, вдоль которого будем двигаться
UPROPERTY(EditAnywhere, BlueprintReadWrite)
ASplinePath* SplinePath = nullptr;

//Ссылка на овнер-персонажа (для удобства)
UPROPERTY()
ACharacter* OwnerCharacter = nullptr;

//Скорость движения и текущая координата
float Velocity = 0.f;
float CurrentCoordinateAlongPath = 0.f;

//...

Включение/выключение движения по маршруту для простоты я сделаю через включение/выключение тика компонента. На практике, конечно, я рекомендую сделать какой-то менеджмент режимов работы компонента через enum или хотя бы буль, но через тик проще и нагляднее, поэтому в конструкторе установим:

//AFollowSplineComponent.cpp
//...

UFollowSplineComponent::UFollowSplineComponent()
{
	PrimaryComponentTick.bCanEverTick = true;
	PrimaryComponentTick.bStartWithTickEnabled = false;
}

//...

Теперь добавим инициализацию на BeginPlay() и непосредственно расчет и применение инпута в Tick():

//AFollowSplineComponent.cpp
//...

void UFollowSplineComponent::BeginPlay()
{
	Super::BeginPlay();

    //Проверяем что указан корректный путь...
	if (!SplinePath)
		return;

    //... и что Owner Actor - персонаж с корректным CMC
	OwnerCharacter = Cast<ACharacter>(GetOwner());
	if (!OwnerCharacter || !OwnerCharacter->GetCharacterMovement())
		return;

    //Сохраняем скорость персонажа
	Velocity = OwnerCharacter->GetCharacterMovement()->MaxWalkSpeed;

    //«Включаем» движение по сплайну
	SetComponentTickEnabled(true);
}

void UFollowSplineComponent::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction)
{
	Super::TickComponent(DeltaTime, TickType, ThisTickFunction);

    //Увеличиваем координату с учетом замкнутости пути
	CurrentCoordinateAlongPath = FMath::Fmod(CurrentCoordinateAlongPath + Velocity * DeltaTime, SplinePath->GetPathLength());

    //Определяем положение в пространстве к которому нужно двигаться
	FVector DesiredPosition = SplinePath->GetLocationAlongPath(CurrentCoordinateAlongPath);

    //Рассчитываем инпут от текущего положения к желаемому
    //AddInputVector() ожидает вектор длиной от 0 до 1, поэтому его лучше нормировать
 	FVector InputVector = DesiredPosition - OwnerCharacter->GetActorLocation();
	InputVector.Z = 0.f;
	InputVector.Normalize();

    //Непосредственно применяем инпут
	OwnerCharacter->GetCharacterMovement()->AddInputVector(InputVector);

    //Для наглядности рисуем текущую желаемое положение
	DrawDebugSphere(GetWorld(), DesiredPosition, 30.f, 10, FColor::Orange);
}

//...

И... собственно всё. Простейшая реализация готова. Для демонстрации я добавил созданный компонент на дефолтного персонажа в демо-проекте, создал блюпринт BP_SplinePath, наследуемый от нашего ASplinePath, разместил их обоих (персонажа и путь) на тестовой сцене, сделал простенький замкнутый путь (получилось кривовато, но так даже лучше) и указал его в поле SplinePath FollowSplineComponent'а на персонаже. Давайте посмотрим что у нас получилось:

Для еще большей наглядности я включил через консоль show Splines чтобы видеть сам сплайн в игре. Благодаря этому можно убедиться в корректности движения и даже увидеть как персонажа заносит на поворотах из-за того, что CMC по умолчанию использует ускорение, а не скорость, что приводит к наличию инерции при движении
Для еще большей наглядности я включил через консоль show Splines чтобы видеть сам сплайн в игре. Благодаря этому можно убедиться в корректности движения и даже увидеть как персонажа заносит на поворотах из-за того, что CMC по умолчанию использует ускорение, а не скорость, что приводит к наличию инерции при движении

Теперь, когда мы получили принципиальное решение, первое, что мы захотим добавить – регулировку скорости. Конечно, это можно сделать просто поменяв MaxWalkSpeed в CMC, но ведь мы можем хотеть патрулировать со скоростью меньше максимальной.

Добавим в наш FollowSplineComponent поле

//AFollowSplineComponent.h
//...

//Часть скорости от максимальной, с которой будет двигаться наш персонаж
UPROPERTY(EditAnywhere, BlueprintReadWrite)
float VelocityMultiplier = 1.f;

//...

И подкорректируем логику с в паре мест:

//UFollowSplineComponent::BeginPlay()
//...

//Скорость патруля теперь учитывает множитель
Velocity = OwnerCharacter->GetCharacterMovement()->MaxWalkSpeed * VelocityMultiplier;

//...

//UFollowSplineComponent::TickComponent()
//...

FVector InputVector = DesiredPosition - OwnerCharacter->GetActorLocation();
InputVector.Z = 0.f;
InputVector.Normalize();
//Инпут нужно уменьшить чтобы его длина стала меньше единицы
InputVector *= VelocityMultiplier; 

//...

Теперь в компоненте персонажа на сцене поставим VelocityMultiplier = 0.5 и запускаем:

Обратите внимание, что без каких-либо дополнительных действий анимация подстроилась под скорость, потому что она динамически выбирается в Anim BP
Обратите внимание, что без каких-либо дополнительных действий анимация подстроилась под скорость, потому что она динамически выбирается в Anim BP

И вот уже наш персонаж двигается с вдвое меньшей скоростью.

Полный код
//ASplinePath.h
#pragma once

#include "CoreMinimal.h"
#include "Components/SplineComponent.h"
#include "GameFramework/Actor.h"
#include "SplinePath.generated.h"

UCLASS()
class SPLINEPATROLSEXAMPLE_API ASplinePath : public AActor
{
	GENERATED_BODY()
	
public:
	ASplinePath();

	UPROPERTY(BlueprintReadOnly, EditAnywhere)
	USplineComponent* Spline;

	UFUNCTION()
	FVector GetLocationAlongPath(float Coordinate) const {	return Spline->GetTransformAtDistanceAlongSpline(Coordinate, ESplineCoordinateSpace::World).GetLocation(); }
	UFUNCTION()
	float GetPathLength() const { return Spline->GetSplineLength(); }

protected:
	virtual void BeginPlay() override;
public:	
	virtual void Tick(float DeltaTime) override;

};



//ASplinePath.cpp
#include "SplinePath.h"
#include "Components/SplineComponent.h"

ASplinePath::ASplinePath()
{
	PrimaryActorTick.bCanEverTick = false;

	Spline = CreateDefaultSubobject<USplineComponent>(TEXT("Spline"));
	Spline->SetClosedLoop(true);
}
//FollowSplineComponent.h
#pragma once

#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "FollowSplineComponent.generated.h"


class ASplinePath;

UCLASS( ClassGroup=(Custom), meta=(BlueprintSpawnableComponent) )
class SPLINEPATROLSEXAMPLE_API UFollowSplineComponent : public UActorComponent
{
	GENERATED_BODY()

public:	
	UFollowSplineComponent();
	
	UPROPERTY(EditAnywhere, BlueprintReadWrite)
	ASplinePath* SplinePath = nullptr;

	UPROPERTY(EditAnywhere, BlueprintReadWrite)
	float VelocityMultiplier = 1.f;

protected:
	UPROPERTY()
	ACharacter* OwnerCharacter = nullptr;
	
	float Velocity = 0.f;
	float CurrentCoordinateAlongPath = 0.f;
	
	virtual void BeginPlay() override;
	virtual void TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override;
};
//FollowSplineComponent.cpp
#include "FollowSplineComponent.h"
#include "SplinePath.h"
#include "GameFramework/Character.h"
#include "GameFramework/CharacterMovementComponent.h"

UFollowSplineComponent::UFollowSplineComponent()
{
	PrimaryComponentTick.bCanEverTick = true;
	PrimaryComponentTick.bStartWithTickEnabled = false;
}

void UFollowSplineComponent::BeginPlay()
{
	Super::BeginPlay();

	if (!SplinePath)
		return;

	OwnerCharacter = Cast<ACharacter>(GetOwner());
	if (!OwnerCharacter || !OwnerCharacter->GetCharacterMovement())
		return;
	
	Velocity = OwnerCharacter->GetCharacterMovement()->MaxWalkSpeed * VelocityMultiplier;
	
	SetComponentTickEnabled(true);
}

void UFollowSplineComponent::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction)
{
	Super::TickComponent(DeltaTime, TickType, ThisTickFunction);
	
	CurrentCoordinateAlongPath = FMath::Fmod(CurrentCoordinateAlongPath + Velocity * DeltaTime, SplinePath->GetPathLength());
	
	FVector DesiredPosition = SplinePath->GetLocationAlongPath(CurrentCoordinateAlongPath);
	
	FVector InputVector = DesiredPosition - OwnerCharacter->GetActorLocation();
	InputVector.Z = 0.f;
	InputVector.Normalize();
	InputVector *= VelocityMultiplier;
	
	OwnerCharacter->GetCharacterMovement()->AddInputVector(InputVector);

	DrawDebugSphere(GetWorld(), DesiredPosition, 30.f, 10, FColor::Orange);
}

А теперь главный сюжетный поворот этой статьи, до которого наиболее прошаренный читатель уже мог догадаться: таска MoveTo это по сути и есть обертка над AddInputVector(). AITask_MoveTo регистрирует запрос в контроллере, PathFollowingComponent его обрабатывает, и в итоге через кучу кода все сводится к функции UPawnMovementComponent::RequestPathMove(), которая делает БУКВАЛЬНО то же самое что и UPawnMovementComponent::AddInputVector !

Смотрите сами:

Итак, к этому моменту мы посмотрели на разные предлагаемые способы двигать персонажа по сплайну, разобрались почему они недостаточно хороши и придумали велосипед способ как сделать красиво. Но ради этого я бы вряд ли сел писать эту статью, потому что далее в программе самое интересное и загадочное

Групповое движение по сплайну

Если движение одного персонажа по сплайну было просто слабоосвещенным, то групповое движение – это, видимо, вообще какое-то сакральное знание. В частности, на момент написания я не нашел на ютубе НИ ОДНОГО гайда по этой теме. Более того, на самом деле изначально эта статья планировалась про групповой патруль, но потом оказалось, что без части про движение вдоль кривой она будет структурно неполной. Так что можно сказать что на ваших экранах не публиковавшийся ранее эксклюзив :)

Но вернемся к сути вопроса. Принципиально для движения группы персонажей мы оставляем тот же подход с добавлением им инпута, но только теперь надо пройтись по всем участникам патруля. Давайте попробуем, но сначала оговорим один нюанс. Для нормальной синхронизации движение всех персонажей должно обрабатываться в одном месте. Я буду использовать для этого отдельную сущность. В целом, если очень захотеть, можно назначить кого-то из персонажей главным и заставить его крутить эту логику, но:
а) нужно в любом случае как-то указывать кто вообще входит в патруль, и шанс неправильно что-то настроить повышается 
б) переиначивая классика, проблема в том, что персонаж может быть смертен. причем что еще хуже – внезапно смертен. То есть нужно уметь в произвольный момент переключать главного, а следовательно переносить все настройки, что лишняя морока

То есть принципиальных проблем идти по пути назначения «главного» персонажа нет, но мне такой вариант не нравится и в примере будет реализовано по-другому. А дальше уже смотрите сами что лучше подходит для ваших задач.

Итак, начнем с создания нашего «менеджера патруля» – AGroupMovementManager. По функционалу он схож с рассматриваемым выше FollowSplineComponent'ом, но должен отвечать за движение всех персонажей в группе. Добавим в него следующие поля и методы:

//AGroupMovementManager.h
//...


UPROPERTY(EditAnywhere, BlueprintReadWrite)
ASplinePath* SplinePath = nullptr;

//Список персонажей, участвующих в движении
UPROPERTY(EditAnywhere, BlueprintReadWrite)
TArray<ACharacter*> MovingCharacters;

UPROPERTY(EditAnywhere, BlueprintReadWrite)
float VelocityMultiplier = 1.f;

float Velocity = 0.f;
float CurrentCoordinateAlongPath = 0.f;
//World Transform точки маршрута, соответствующей текущей координате
FTransform CurrentFormationTransform = FTransform::Identity;

//Желаемые положения для всех персонажей из MovingCharacters
TArray<FVector> DesiredPositions;
//Метод для перерасчета DesiredPositions по CurrentCoordinateAlongPath
void UpdateDesiredPositions();

//Описание построения (см. ниже)
TArray<FVector2D> Formation;
//Метод для формирования Formation в зависимости от количества персонажей
TArray<FVector2D> GetFormation(int NumCharacters);

//...

Отдельно стоит проговорить способ задания относительного положения персонажей в построении (поле Formation). Подойти к этому вопросу можно с разных сторон, но наиболее удобным и наглядным способом мне кажется описание в виде массива двумерных векторов-смещений относительно «центра» строя (CurrentFormationTransform), где Y - координата вдоль направления движения, а X - вдоль направления «вправо»:

Пример расположения персонажей относительно «центра» строя и их координаты
Пример расположения персонажей относительно «центра» строя и их координаты

Указывать значение Formation тоже можно разными способами – хоть вручную, хоть через Data Table, но я написал небольшую функцию, автоматически составляющую построение в колонну по двое для произвольного количества персонажей:

//AGroupMovementManager.cpp
//...

TArray<FVector2D> AGroupMovementManager::GetFormation(int NumCharacters)
{
	if (NumCharacters <= 0)
		return {};

	if (NumCharacters == 1)
		return { FVector2D(0.f, 0.f) };

	const float WidthInterval = 80.f, DepthInterval = 80.f;
	TArray<FVector2D> ResultFormation;
	for (int i = 0; i < NumCharacters; ++i)
	{
		int RowIndex = i / 2;
		int ColumnIndex = i % 2;

		ResultFormation.Add(FVector2D(WidthInterval * (ColumnIndex - 1 / 2.f), -DepthInterval * RowIndex));
	}

	return ResultFormation;
}

//...

Обратите внимание на деление int / int на 16й строке, которое позволяет провести деление без остатка, что в данном случае нам и нужно

Далее делаем то же что и раньше: в конструкторе выключаем тик актора, инициализируем параметры на BeginPlay() и если всё корректно – включаем тик, а в самом Tick() обновляем текущее положение вдоль маршрута (теперь в виде Transform, а не Location) и рассчитываем желаемый инпут для каждого персонажа. Из отличий: при инициализации добавился расчет Formation для указанных в MovingCharacters персонажей, а расчет желаемых позиций был вынесен в отдельную функцию UpdateDesiredPositions():

//AGroupMovementManager.cpp
//...

AGroupMovementManager::AGroupMovementManager()
{
	PrimaryActorTick.bCanEverTick = true;
	PrimaryActorTick.bStartWithTickEnabled = false;
}

void AGroupMovementManager::UpdateDesiredPositions()
{
	if (Formation.IsEmpty())
		return;

    //Получаем текущий World Transform центра строя
	CurrentFormationTransform = SplinePath->GetTransformAlongSpline(CurrentCoordinateAlongPath);

    //Получаем направления «вперед» и «вправо» для текущего положения строя
	FVector ForwardDirection = CurrentFormationTransform.GetRotation().GetAxisX();
	FVector RightDirection = CurrentFormationTransform.GetRotation().GetAxisY();

    //Рассчитываем положение для каждого персонажа
	DesiredPositions.Empty();
	for (int i = 0; i < Formation.Num(); ++i)
	{
		DesiredPositions.Add(CurrentFormationTransform.GetLocation() + RightDirection * Formation[i].X + ForwardDirection * Formation[i].Y);
	}
}

void AGroupMovementManager::BeginPlay()
{
	Super::BeginPlay();

	if (MovingCharacters.IsEmpty() || !SplinePath)
		return;

    //Устанавливаем скорость движения группы. Предполагается, что Walk Speed у всех участников одинаков
	Velocity = MovingCharacters[0]->GetCharacterMovement()->MaxWalkSpeed * VelocityMultiplier;
	//Определяем построение
    Formation = GetFormation(MovingCharacters.Num());

    //Запускаем движение
	SetActorTickEnabled(true);
}

void AGroupMovementManager::Tick(float DeltaTime)
{
	Super::Tick(DeltaTime);

    //Перерассчитываем положение построения и всех персонажей
	CurrentCoordinateAlongPath = FMath::Fmod(CurrentCoordinateAlongPath + Velocity * DeltaTime, SplinePath->GetPathLength());
	UpdateDesiredPositions();

    //Двигаем всех персонажей аналогично случаю с одиночным движением
	for (int i = 0; i < DesiredPositions.Num(); ++i)
	{
		ACharacter* CurrentCharacter = MovingCharacters[i];
		FVector CurrentDesiredPosition = DesiredPositions[i];
		
		FVector InputVector = CurrentDesiredPosition - CurrentCharacter->GetActorLocation();
		InputVector.Z = 0.f;
		InputVector.Normalize();
		InputVector *= VelocityMultiplier;
	
		CurrentCharacter->GetCharacterMovement()->AddInputVector(InputVector);

		DrawDebugSphere(GetWorld(), CurrentDesiredPosition, 30.f, 10, FColor::Orange);
	}
  }

//...

SplinePath->GetTransformAlongSpline(CurrentCoordinateAlongPath) на 15й строке это простой геттер, аналогичный рассмотренному выше GetLocationAlongPath()

Давайте посмотрим что получилось: ставим нескольких персонажей (убедитесь что у них не указан путь в FollowPathComponent, иначе он будет работать параллельно), заводим блюпринт BP_GroupMovementManager (я добавил в него сферу со свойством HiddenInGame для удобства выделения), ставим менеджер на сцену, указываем в нем персонажей, путь и VelocityMultiplier (я для наглядности замедлю до 0,33) и пробуем:

Не нужно быть геймдизайнером, чтобы понять что что-то тут не так. Спустя небольшое время после начала движения по изогнутому маршруту (я специально его сделал более круглым для наглядности) персонаж, идущий по внешней стороне неизбежно отстает, а тот, что по внутренней – слегка обгоняет нужное положение, из-за чего периодически дергается, получая инпут назад.

Тут мы впервые столкнулись с проблемой такого простого решения – отсутствием обратной связи: мы просто даем инпут персонажам и никак не проверяем что они реально делают и где находятся. Благодаря тому, что мы синхронизировали скорости, для одного персонажа это было незаметно, хотя и там мы могли бы столкнуться с негативными последствиями, если бы на пути было какое-то препятствие или мы бы изначально разместили персонажа далеко от пути. И если с одним персонажем еще можно этот эффект замаскировать, для нескольких уже придется добавлять регулировку. Здесь мы вступаем на путь эмпирических решений, поэтому оговорюсь, что не претендую на то, что предложенные мной ниже способы единственные верные или лучшие, но, как вы увидите, с проблемами они справляются.

Итак, задача сводится к корректировке входного инпута таким образом, чтобы все участвующие в движении персонажи постоянно находились в желаемых позициях. Наиболее простым и логичным критерием изменения величины инпута в описанной ситуации является расстояние до желаемой точки. Так что с этого момента для определения правильного инпута будем отдельно находить его направление и величину. Получается что нужно что-то вроде:

float InputMagnitude = FMath::GetMappedRangeValueClamped(FVector2d(0, 50), 
                                                         FVector2d(0, VelocityMultiplier),
                                                         DistToDesiredLocation);

Примечание:
Здесь и далее в коде будет существенное количество "магических чисел", которые определяют интервалы в которых мы рассматриваем отклонение, величины коррекции и так далее. Не вижу большого смысла делать их переменными с длинными названиями, поскольку это только создаст лишнюю путаницу в и так непростой теме. Значения, которые я использую, подобраны эмпирически, так что интересующийся читатель может поэкспериментировать с ними и подобрать наиболее подходящие для своих целей

То есть на расстоянии 50 единиц и больше от желаемой позиции мы будем двигаться с заданной скоростью, но по мере дальнейшего приближения замедлимся. Таким образом, у отстающих членов группы будет возможность догнать свое место в случае отставания.

Кроме этого, двигать формацию вперед теперь будем, только если среднее расстояние до желаемых точек достаточно мало. То есть формация как бы «ждет» пока все персонажи ее догонят и только потом двигается вперед. Конечно, это можно реализовать и через строгие условия, но мне способ через среднее расстояние показался достаточно практичным и хорошо работающим. Поскольку подход эмпирический, вы можете поэкспериментировать с разными вариантами и найти тот, который вам больше нравится.

Получившийся у нас код движения:

//AGroupMovementManager.cpp
//...

void AGroupMovementManager::Tick(float DeltaTime)
{
	Super::Tick(DeltaTime);

    //Переменная для вычисления среднего расстояния до желаемых позиций
    float Sum = 0.f;
  
    //Проходим циклом по всем персонажам
	for (int i = 0; i < DesiredPositions.Num(); ++i)
	{	
        //Определяем текущего персонажа и его желаемое положение
		ACharacter* CurrentCharacter = MovingCharacters[i];
		FVector CurrentDesiredPosition = DesiredPositions[i];

        //Вычисляем расстояние до желаемого положения
		float DistToDesiredLocation = FVector::Dist2D(CurrentCharacter->GetActorLocation(), CurrentDesiredPosition);
		Sum += DistToDesiredLocation;

        //Определяем направление инпута
		FVector InputDirection = CurrentDesiredPosition - CurrentCharacter->GetActorLocation();
		InputDirection.Z = 0.f;
		InputDirection.Normalize();

        //Определяем величину инпута
		float InputMagnitude = FMath::GetMappedRangeValueClamped(FVector2d(0, 50), 
														 FVector2d(0, VelocityMultiplier),
														 DistToDesiredLocation);

        //Вычисляем и применяем итоговый инпут
		FVector InputVector = InputDirection * InputMagnitude;
		CurrentCharacter->GetCharacterMovement()->AddInputVector(InputVector);

		DrawDebugSphere(GetWorld(), CurrentDesiredPosition, 30.f, 10, FColor::Orange);
		DrawDebugDirectionalArrow(GetWorld(), CurrentCharacter->GetActorLocation(), CurrentCharacter->GetActorLocation() + InputVector * 100.f, 10.f, FColor::Blue);
	}

    //Рассчитываем среднее расстояние до желаемых позиций
	MeanDistanceToDesiredPosition = Sum / MovingCharacters.Num();

    //Если оно достаточно малое - двигаем строй вдоль маршрута
	if (MeanDistanceToDesiredPosition < 50.f)
	{
		CurrentCoordinateAlongPath = FMath::Fmod(CurrentCoordinateAlongPath + Velocity * DeltaTime, SplinePath->GetPathLength());
		UpdateDesiredPositions();
	}
    //Иначе - ничего не делаем (ждем пока все персонажи подойдут достаточно близко к желаемым позициям)
}

//...

И вот такое решение будет работать уже существенно лучше и позволяет проходить довольно кривые маршруты:

Поставил побольше персонажей чтобы вы не думали, что оно работает только для двух
Поставил побольше персонажей чтобы вы не думали, что оно работает только для двух

На этом, думаю, с изложением базового принципа можно закруглиться. Дальше можно бесконечное количество времени подбирать параметры регуляции, придумывать сложные кейсы, проверять их и добавлять новые эмпирические усложнения в логику. Я же выпишу еще несколько проблем с которыми мне пришлось столкнуться и подходы к их решению, но уже без подробного разбора.

  1. Поиск маршрута до желаемой точки

    Реальный случай: через некоторое время после завершения работы над групповым движением мне репортят проблему: «патрульные сваливаются в пропасть». Какую пропасть? Зачем сваливаются? Пошел разбираться. Оказалось, что в одном месте маршрут проходил достаточно близко к неогороженному перепаду высот и персонаж, который идет ближе к этой яме, просто в нее сваливается, после чего патруль ломается, поскольку один из участников где-то в яме пытается идти в стену. В целом, думаю, можно было бы ограничиться сдвигом маршрута чуть дальше от ямы, но я решил потратить некоторое время чтобы попробовать придумать как от такого защититься на будущее и придумал.

    Для того, чтобы избежать выхода за навигацию, можно формировать инпут не в сторону желаемой точки, а в сторону первой точки на маршруте до желаемой точки (UNavigationSystemV1::FindPathToLocationSynchronously()). Таким образом мы движемся не напрямую к точке, а как бы вдоль корректного навигационного маршрута к ней. Если точка доступна по навигации, то маршрут будет состоять из двух точек - положения нашего персонажа и желаемой точки и движение вдоль маршрута будет идентично движению к самой точке. Если, как в описанном случае с ямой или, наоборот, со стенкой на пути, до точки по прямой дойти нельзя (или вообще нельзя), то маршрут будет содержать несколько точек, но поскольку мы обновляем инпут каждый тик, нам достаточно идти по первому сегменту маршрута, то есть, как и в простом случае, в сторону точки с индексом [1].

    Нужно только учесть, что в случае когда дойти до желаемой точки по прямой нельзя, условие «все персонажи находятся рядом с желаемыми точками» может не выполняться и нужно как-то все равно двигать формацию вперед, например, введя флаг «принудительного движения».

    Описанным образом можно как избежать уходов в ямы, так и обходить препятствия на пути, что дает высокую устойчивость решения к ошибкам левелдизайнеров.

  2. Дергание на поворотах

    Еще один неприятный случай с которым я столкнулся – это что при проходе резкого поворота желаемая точка персонажа, двигающегося по внутренней стороне может оказаться сзади него, что может привести к некрасивым дерганиям назад-вперед, пока поворот не будет пройден. Этот эффект даже виден на последней гифке, но в моем случае он был сильно более выраженным.

    Для обхода этой проблемы я решил запретить персонажам двигаться назад. Для этого нужно проверить, что желаемая точка находится впереди и если не оказывается, то вместо движения к ней двигать персонажа вдоль сплайна (напоминаю, что Transform текущей точки на сплайне у нас имеется) с сильно уменьшенной скоростью (можно и вообще не двигать, но тогда дергание сохраняется из-за частых переключений состояний стоим-идем). Таким образом, на повороте вместо хаотичного дергания персонаж просто медленно двигается дальше по пути, дожидаясь пока вся группа преодолеет сложный участок, а дальше возвращается к штатной работе.

  3. Начальное положение персонажей

    Вообще, теоретически, алгоритм (возможно, с некоторыми доработками), должен работать независимо от начального положения персонажей. Ну, в адекватных пределах, конечно. Но на практике с этим могут возникать проблемы и появляются рассинхроны движения, которые выравниваются не сразу. Для повышения стабильности работы системы и нивелирования целого ряда потенциальных левелдизайнерских ошибок, хорошей практикой может быть расстановка двигающихся персонажей в правильные места перед началом движения. Для этого достаточно на BeginPlay() рассчитать DesiredPositions в нулевой точке маршрута и сделать для всех персонажей SetTransform() в эти точки. Таким образом, движение никак не будет зависеть от того, как персонажи стоят изначально, в каком порядке они указаны в менеджере движения и прочих возможных неприятностей.

Ну и под конец давайте еще обсудим тему, которая уже не относится напрямую к движению по сплайну, но, на мой взгляд, полезна в обсуждаемом контексте, а на отдельную статью не тянет.

Действия на маршруте

Так или иначе, но скорее всего понадобится уметь делать определенные действия в определенных точках маршрута. Самый простой пример – остановиться и поиграть анимацию. Еще вариант – открыть дверь перед собой или закрыть ее когда прошли. Хоть это и отдельная тема, но сложно представить чтобы где-то где есть движение персонажей по кривой не понадобилась бы такая фича.

Концептуально, подход к реализации следующий: создаем точки на маршруте, двигаем их в нужные места, потом в начале игры запоминаем их координаты вдоль пути (если точка лежит не на самом пути, то ищем ближайшую точку на пути), а потом при движении вдоль маршрута проверяем, не дошли ли мы до какой-то из точек. Если вдруг дошли, то что-нибудь делаем.

Для начала, давайте посмотрим на создание таких точек. Есть в анриле прикольная пропертя которая позволяет рисовать точки прямо во вьюере и там же их перетаскивать:

//ASplinePath.h
//...

UPROPERTY(EditAnywhere, BlueprintReadWrite, meta=(MakeEditWidget))
TArray<FVector> ActionPoints;

//...

Добавив в этот список несколько элементов, получаем вот такую простенькую визуализацию

С технической точки зрения всё отлично, но вот если попытаться понастраивать такие штуки, то очень быстро обнаружатся существенные недостатки. Во-первых, их видно только когда актор выделен, а во-вторых, их еще и достаточно трудно выделять, поскольку нужно попасть мышкой точно в одну из линий этого ромбика. По итогу, хоть теоретически способ корректный, на практике он крайне неудобен и утомителен в использовании.Поэтому пришлось искать альтернативное решение.

Мой выбор пал на биллборд компонент. Он всегда повернут к камере, что означает что его всегда видно и в него легко попасть с любого расстояния. Бонусом, ему можно настроить картинку. Опять же, тут непринципиально, можно использовать хоть цветной куб, но биллборд банально удобнее.

На практике я рекомендую создать набор тулзов в виде CallInEditor функций для работы с такими точками. Например: создание новой точки (или набора точек по определенным правилам), очистка всех точек, коррекция положения точек (перемещение их в ближайшую точку на сплайне) и так далее. Также хорошей идеей может быть создание кастомного компонента, наследованного от UBillboardComponent, который будет содержать какие-либо настройки, доступные при выборе конкретной точки. Но в рамках этого, и так растянувшегося, гайда, пожалуй, пойду простым путем и просто создам несколько BillboardComponent'ов на инстансе маршрута, а собирать их будем на BeginPlay при помощи GetComponents():

Дефолтную иконку с динозавриком я заменил на какую-то другую из базового набора
Дефолтную иконку с динозавриком я заменил на какую-то другую из базового набора
//SplinePath.h
//...

TArray<float> ActionPointsCoords;

//...

//SplinePath.cpp
//...

void ASplinePath::BeginPlay()
{
	Super::BeginPlay();

    //Собираем все Billboard компоненты
	TArray<UBillboardComponent*> ActionPointsBBs;
	GetComponents(UBillboardComponent::StaticClass(), ActionPointsBBs);

    //Сохраняем их координаты
	ActionPointsCoords.Empty();
	for (UBillboardComponent* BBComp : ActionPointsBBs)
	{
		ActionPointsCoords.Add(Spline->GetDistanceAlongSplineAtLocation(BBComp->GetComponentLocation(), ESplineCoordinateSpace::World));
	}
}

//...

Теперь немного модифицируем движение по сплайну чтобы оно учитывало наличие точек. Я покажу на примере одного персонажа, но для группового оно работает точно так же.

Итак, при движении желаемой позиции вдоль сплайна будем проверять не находится ли какая-то из точек в непосредственной близости от нас. Если такая точка нашлась, то останавливаем движение и делаем что-то что надо делать на этой точке. Для примера, пусть этим чем-то будет вызов специальной BlueprintImplementableEvent функции в классе нашего персонажа (у меня он называется ASplinePatrolsExampleCharacter). Чтобы не выполнять действия на точке несколько раз, будем запоминать индексы точек, которые находятся рядом с нами и действие которых мы уже выполнили:

//SplinePatrolsExampleCharacter.h
//...

UFUNCTION(BlueprintImplementableEvent)
void DoSomethingOnActionPoint();

//...


//FollowSplineComponent.h
//...

TArray<int> ActionPointsInRange;

//...


//FollowSplineComponent.cpp
//...

void UFollowSplineComponent::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction)
{
	Super::TickComponent(DeltaTime, TickType, ThisTickFunction);

	//Проверяем, есть ли необработанные точки рядом с текущим положением на пути
	for (int i = 0; i < SplinePath->ActionPointsCoords.Num(); ++i)
	{
		if (!ActionPointsInRange.Contains(i) &&
			FMath::Abs(CurrentCoordinateAlongPath - SplinePath->ActionPointsCoords[i]) <= 15.f)
		{
            //Если такая точка нашлась - останавливаем движение и выполняем действие
			ActionPointsInRange.Add(i);
			SetComponentTickEnabled(false);
			Cast<ASplinePatrolsExampleCharacter>(OwnerCharacter)->DoSomethingOnActionPoint();
			return;
		}
	}

	//Проверяем, если ли среди обработанных точек те, которые уже не находятся рядом и удаляем их
	//Поскольку нам необходимо удалять элементы массива в теле цикла по этому массиву, проходим по массиву с конца
	for (int i = ActionPointsInRange.Num() - 1; i >= 0; i--)
	{
			if (FMath::Abs(CurrentCoordinateAlongPath - SplinePath->ActionPointsCoords[ActionPointsInRange[i]]) > 15.f)
                ActionPointsInRange.RemoveAt(i);
	}
	
	CurrentCoordinateAlongPath = FMath::Fmod(CurrentCoordinateAlongPath + Velocity * DeltaTime, SplinePath->GetPathLength());

    //Далее все остается как раньше
    //...
}
//...

В блюпринте персонажа имплементируем созданный ивент. Я поставил на него проигрывание какой-то анимации из какого-то пака:

Не забудьте включить движение после окончания действия
Не забудьте включить движение после окончания действия

В результате получается что-то такое:

<Место для шутки про лайк, подписка, колокольчик>
<Место для шутки про лайк, подписка, колокольчик>

Ну и, как я сказал выше, добавив инструменты для удобной настройки точек и возможность настраивать действия на точках внутри самих точек, мы получим вполне добротный, удобно расширяемый и гибкий инструментарий для работы с маршрутами.

Заключение

Ну вот теперь точно всё. Как я уже писал выше, почти все описанные подходы можно тем или иным образом улучшить, но дальше всё уже упирается в конкретные требования и задачи. Базовые принципы, на которые можно опираться при их решении, мне кажется, я изложил достаточно подробно, включая подводные камни, с которыми разработчик может столкнуться.

Основополагающим принципом при написании статей я для себя ставлю «написать что-то, что было бы полезно мне когда я решал эту задачу». Поэтому если у вас есть что добавить по теме, то призываю написать об этом в комменты. Не раскрутки ради, а развития геймдев коммьюнити для. В любом случае спасибо всем кто дочитал. Надеюсь вам было полезно и может быть даже немного забавно.

А за сим откланиваюсь. Всем интересных задач и поменьше багов!

Комментарии (1)


  1. Jijiki
    04.01.2026 18:31

    так правильно кривая - то это роут, у нпц, есть роуты которые ищем и есть маршруты патрулирования всё так

    тоесть состояние патрулирования,а еще мне кажется если мы вводим группы, группа тоже может иметь координаты(если все в одной точке а не раскиданы по миру входя в тот же момент в группу)