Привет, меня зовут Дмитрий, программист из Snowforged Entertainment. Я только что закончил рефакторинг компонента движения кораблей для проекта Starfall Tactics — тактической игры в реальном времени, в которой игроки могут собрать свой собственный космический флот и повести его в бой. Компонент движения переписывался уже три раза, от релиза до начала разработки альфа версии. Было собрано множество граблей, как архитектурных, так и сетевых. Постараюсь подбить весь этот опыт и рассказать вам о: Navigation Volume, Movement component, AIController, Pawn.
Задача: реализовать процесс движения космического корабля на плоскости.
Условия задачи:
- У корабля есть максимальная скорость, скорость поворота и скорость разгона. Эти параметры задают динамику движения корабля.
- Необходимо воспользоваться Navigtaion Volume для автоматического поиска препятствий и прокладки безопасного пути.
- Не должно быть постоянной синхронизации координат положения через сеть.
- Мы можем начинать движение из разных текущих скоростных состояний.
- Всё должно быть нативно для архитектуры Unreal engine 4.
Архитектура процесса
Разделим задачу на два этапа: первый — это поиск оптимального пути, второй — движение к конечной точки под курсором.
Задача первая. Поиск оптимального пути
Рассмотрим условия и архитектуру процесса поиска оптимального пути в Unreal engine 4. Наш UShipMovementComponent — это компонент движения, который наследуется от UPawnMovementComponent, т.к. конечный юнит, корабль, будет наследником APawn.
В свою очередь, UPawnMovementComponent — наследник UNavMovementComponent, который добавляет ему в своём составе FNavProperties — это параметры навигации, описывающие данный APawn, и которые будут использованы AIController'ом при поиске пути.
Предположим, у нас есть уровень на котором находится наш корабль, статические объекты и Navigation Volume, охватывающий его. Мы отправляем корабль из одной точки карты в другую и вот что при этом происходит внутри UE4:
1) APawn, находит внутри себя ShipAIController (в нашем случае это просто наследник AIController, имеющий один единственный метод) и вызывает созданный нами метод поиска пути.
2) Внутри этого метода мы сначала готовим запроса к системе навигации, затем отправляем его и получаем контрольные точки движения.
TArray<FVector> AShipAIController::SearchPath(const FVector& location)
{
FPathFindingQuery Query;
const bool bValidQuery = PreparePathfinding(Query, location, NULL);
UNavigationSystem* NavSys = UNavigationSystem::GetCurrent(GetWorld());
FPathFindingResult PathResult;
TArray<FVector> Result;
if(NavSys)
{
PathResult = NavSys->FindPathSync(Query);
if(PathResult.Result != ENavigationQueryResult::Error)
{
if(PathResult.IsSuccessful() && PathResult.Path.IsValid())
{
for(FNavPathPoint point : PathResult.Path->GetPathPoints())
{
Result.Add(point.Location);
}
}
}
else
{
DumpToLog("Pathfinding failed.", true, true, FColor::Red);
}
}
else
{
DumpToLog("Can't find navigation system.", true, true, FColor::Red);
}
return Result;
}
3) Эти точки возвращаются списком APawn'у в удобном для нас формате (FVector). Дальше, запускается процесс движения.
По сути, получается так: APawn имеет в себе ShipAIController, который в момент вызова PreparePathfinding() обращается к APawn и получает UShipMovementComponent, внутри которого находит FNavProperties, которые и передаёт в систему навигации для поиска пути.
Задача вторая. Движение к конечной точке.
Итак, нам вернулся список контрольных точек движения. Первая точка — это всегда наше текущее положение, последняя — пункт назначения. В нашем случае — это место, куда мы кликнули курсором, отправив корабль.
Здесь стоит сделать небольшое отступление и рассказать о том, как мы собираемся построить работу с сетью. Разделим её на шаги и распишем каждый из них:
1) Мы вызываем метод начала движения — AShip::CommandMoveTo():
UCLASS()
class STARFALL_API AShip : public APawn, public ITeamInterface
{
...
UFUNCTION(BlueprintCallable, Server, Reliable, WithValidation, Category = "Ship")
void CommandMoveTo(const FVector& location);
void CommandMoveTo_Implementation(const FVector& location);
bool CommandMoveTo_Validate(const FVector& location);
...
}
Обратите внимание — на клиентской стороне, у всех Pawn'ов отсутствует AIController, они есть лишь на сервере. Поэтому, когда клиент будет вызывать метод отправки корабля к новому местоположению, мы должны выполнить все просчёты на сервере. Другими словами, поиском пути для каждого корабля будет занят сервер. Потому что именно AIController работает с системой навигации.
2) После того, как внутри CommandMoveTo() метода, мы нашли список контрольных точек, мы вызываем следующий для начала движения корабля. Этот метод должен быть вызван на всех клиентах.
UCLASS()
class STARFALL_API AShip : public APawn, public ITeamInterface
{
...
UFUNCTION(BlueprintCallable, Reliable, NetMulticast, Category = "Ship")
void StartNavMoveFrom(const FVector& location);
virtual void StartNavMoveFrom_Implementation(const FVector& location);
...
}
В этом методе клиент, у которого нет никаких контрольных точек, включает первую переданную ему координату в список контрольных точек и «заводит двигатель», начиная движение. В этот момент, на сервере, мы через таймеры запускаем отправку остальных промежуточных и конечной точки нашего пути:
void AShip::CommandMoveTo(const FVector& location)
{
...
GetWorldTimerManager().SetTimer(timerHandler, FTimerDelegate::CreateUObject(this, &AShip::SendNextPathPoint), 0.1f, true);
...
}
UCLASS()
class STARFALL_API AShip : public APawn, public ITeamInterface
{
...
FTimerHandle timerHandler;
UFUNCTION(BlueprintCallable, Reliable, NetMulticast, Category = "Ship")
void SendPathPoint(const FVector& location);
virtual void SendPathPoint_Implementation(const FVector& location);
...
}
На стороне клиента, пока корабль начинает разгоняться и двигаться к первой контрольной точке своего пути, он постепенно получает остальные и складывает их себе в массив. Это позволяет нам разгрузить сеть и растянуть отправку данных во времени, распределяя нагрузку на сеть.
Закончим с отступлением и вернёмся к сути вопроса. Текущая задача — начать полет в сторону ближайшей контрольной точки. Обратим внимание, что по условиям, наш корабль имеет скорость поворота, акселерацию и максимальную скорость движения. Следовательно в момент в момент отправки к новой точки назначения корабль может, например, лететь на полной скорости, стоять, только разгоняться, или находиться в процесс поворота. Поэтому, корабль обязан вести себ по-разному, исходя из текущих скоростных характеристик и пункта назначения. Мы выделили три основных линии поведения корабля:
- Мы можем долететь до точки назначения не ограничивая себя в разгоне хоть до максимальной скорости и нам при этом хватит скорости поворота, чтобы вписаться в поворот и прибыть на место.
- Учитывая нашу скорость мы будем лететь слишком быстро, поэтому мы постараемся долететь до точки назначения на малом ходе, а когда нос корабля будет указывать чётко в её направлении, попробуем разогнаться до максимальной скорость
- Если путь движения займёт больше времени, нежели тупо развернуться и долететь по прямой, то мы пойдём простым путём.
Значит перед началом движения к точке, нам надо определиться с какими скоростными параметрами мы будем лететь. Для этого мы реализуем метод имитации полёта. Не буду приводить её код здесь, если кому-то очень интересно — пишите, расскажу. Суть у неё простая — мы, используя текущую DeltaTime, всё время передвигаем вектор нашего положения и поворачиваем направление взгляда вперёд, имитируя вращение корабля. Это простейшие операции над векторами, с участие FRotator. Небольшими усилием воображения вы легко это реализуете.
Единственный момент, о котором стоит упомянуть — это то, что в каждой итерации поворота корабля нужно запоминать насколько мы уже повернули его. Если больше чем на 180 градусов — это означает, что мы начинаем кружить вокруг точки назначения и нам необходимо пробовать следующие скоростные параметры для попытки добраться к контрольной точке. Естественно, сначала мы пытаемся лететь на полной скорости, потом на сниженной (сейчас мы работает со средней скоростью), а если ни один из этих вариантов не подошёл — значит кораблю надо просто развернуться и долететь.
Хочу обратить ваше внимание, что вся логика оценки ситуации и процессов движения должна быть реализована в AShip — потому что AIController'а у нас нет на клиенте, а UShipMovementComponent играет другую роль (о нём чуть ниже, мы уже почти добрались до него). Поэтому, чтобы у нас корабли могли двигаться самостоятельно и без постоянной синхронизации координат с сервером (в чём нет нужды), мы должны реализовывать логику управления движения внутри AShip.
Итак, теперь самое важное во всём этом — наш компонент движения UShipMovementComponent. Стоит осознать, что компоненты этих типов — это моторы. Их функция — давать газу вперёд и поворачивать объект. Они не думают о том по какой логике должен двигаться объект, они не думают о том в каком состоянии объект. Они лишь отвечают за фактическое движение объекта. За вброс топлива и сдвиг в пространстве. Логика работы с UMovementComponent и его наследниками такова: мы в данном нам Tick(), производим все свои математические калькуляции, связанные с параметрами нашего компонента (скорость, максимальная скорость, скорость поворота), после чего выставляем параметр UMovementComponent::Velocity в значение, которое релевантно сдвигу нашего корабля в данный тик, затем вызываем UMovementComponent::MoveUpdatedComponent() — именно здесь происходит сдвиг нашего корабля и его поворот.
void UShipMovementComponent::TickComponent(float DeltaTime, enum ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction)
{
Super::TickComponent(DeltaTime, TickType, ThisTickFunction);
if(!PawnOwner || !UpdatedComponent || ShouldSkipUpdate(DeltaTime))
{
return;
}
if (CheckState(EShipMovementState::Accelerating))
{
if (CurrentSpeed < CurrentMaxSpeed)
{
CurrentSpeed += Acceleration;
AccelerationPath += CurrentSpeed*DeltaTime;
}
else
{
CurrentSpeed = CurrentMaxSpeed;
RemoveState(EShipMovementState::Accelerating);
}
}
else
if (CheckState(EShipMovementState::Braking))
{
if (CurrentSpeed > 0.0f)
{
CurrentSpeed -= Acceleration;
DeaccelerationPath += CurrentSpeed*DeltaTime;
}
else
{
CurrentSpeed = 0.0f;
CurrentMaxSpeed = MaxSpeed;
RemoveState(EShipMovementState::Braking);
RemoveState(EShipMovementState::Moving);
}
}
else
if (CheckState(EShipMovementState::SpeedDecreasing))
{
if (CurrentSpeed > CurrentMaxSpeed)
{
CurrentSpeed -= Acceleration;
DeaccelerationPath += CurrentSpeed*DeltaTime;
}
else
{
CurrentSpeed = CurrentMaxSpeed;
RemoveState(EShipMovementState::SpeedDecreasing);
}
}
if (CheckState(EShipMovementState::Moving) || CheckState(EShipMovementState::Turning))
{
MoveForwardWithCurrentSpeed(DeltaTime);
}
}
...
void UShipMovementComponent::MoveForwardWithCurrentSpeed(float DeltaTime)
{
Velocity = UpdatedComponent->GetForwardVector() * CurrentSpeed * DeltaTime;
MoveUpdatedComponent(Velocity, AcceptedRotator, false);
UpdateComponentVelocity();
}
...
Скажу два слова про состояния, которые тут фигурируют. Они необходимы для того, чтобы сочетать разные процессы движения. Мы можем, например, снижать скорость (потому что для маневра нам надо перейти на среднюю скорость) и поворачивать в сторону новой точки назначения. В компоненте движения мы используем их только для оценки работы со скоростью: надо ли нам продолжать набор скорости, или её снижение и т.п. Вся логика относящаяся к переходам из одного состояния движения в другое, как я уже говорил, происходит в AShip: например мы идём на максимальной скорости, а нам меняют точку назначения, а для её достижения нам надо сбросить скорость до средней.
И последние две копейки про AcceptedRotator. Это наш поворот корабля в данный тик. В тике AShip мы вызываем следующий метод нашего UShipMovementComponent:
bool UShipMovementComponent::AcceptTurnToRotator(const FRotator& RotateTo)
{
if(FMath::Abs(RotateTo.Yaw - UpdatedComponent->GetComponentRotation().Yaw) < 0.1f)
{
return true;
}
FRotator tmpRot = FMath::RInterpConstantTo(UpdatedComponent->GetComponentRotation(), RotateTo, GetWorld()->GetDeltaSeconds(), AngularSpeed);
AcceptedRotator = tmpRot;
return false;
}
RotateTo = (GoalLocation — ShipLocation).Rotation() — т.е. это ротатор, которые обозначает в каком значении rotation корабля должен находится, чтобы смотреть на точку назначения. И в этом методе мы оцениваем, а не смотрит ли корабль на точку назначения? Если смотрит — то такой результат и возвращаем, значит нам уже не надо поворачиваться. И наш AShip в своей логике оценки ситуации сбросит состояние EShipMovementState::Turning — и UShipMovementComponent не будет больше стремиться вращаться. Иначе — мы берём rotation корабля и интерпретируем его с учётом DeltaTime и скорости поворота корабля. После чего применяем этот поворот в текущем тике, при вызове UMovementComponent::MoveUpdatedComponent.
Перспективы
Мне кажется, что в этой реинкарнации UShipMovementComponent учтены все проблемы, с которыми мы столкнулись на этапе прототипа. Также, данная версия получилась расширяемая и теперь есть возможность её развивать и дальше. Например, процесс поворота корабля: если мы будем просто поворачивать корабль, то это будет выглядеть скучно, как будто он нанизан на стержень, который его вращает. Однако добавьте лёгкий крен носа в сторону поворота и этот процесс становится намного привлекательнее.
Также сейчас по-минимуму реализована синхронизация промежуточных положений корабля. Как только мы долетаем до точки назначения, мы синхронизируем данные с сервером. Пока что разница в конечном положении на сервере и клиенте расходится на очень маленькую величину, однако если она будет увеличиваться, то есть множество идей о том как провернуть эту синхронизацию плавно, без рывков и «прыжков» кораблей в пространстве. Но об этом я расскажу уже видимо в другой раз.
Автор: Alanir