Как мы реализовали систему камер для мобильной TPS игры
Содержание
Решаемая проблема
В консольных ААА играх мы видим динамическую камеру от третьего лица, которая в процессе игры постоянно двигается, меняет планы. Иногда это нужно для художественных задач, например, приблизить камеру, чтобы вызвать ощущение клаустрофобии или создать напряжение. Иногда, чтобы показать общий план, и акцентировать внимание на масштабе противника и игрока. Но чаще основная задача камеры — показать игроку то, что он должен и хочет видеть в данный момент времени.Показать те объекты, с которыми он сейчас взаимодействует, и не вынуждать его лишний раз вращать камеру вручную.
Рассмотрим несколько примеров с точки зрения дизайна, о которых пойдет речь в статье.
Обычный вид камеры
Игрок немного сбоку. Мы освобождаем центральную область экрана, чтобы игрок видел куда он идёт.
The Last of us Part IIПрицеливание
При стрельбе от третьего лица нужно приблизить камеру, уменьшить FoV (Field of View), чтобы лучше видеть цель. И изменить позицию камеры, чтобы создавалось впечатление, что мы целимся вместе с игровым персонажем.
Division 2Поведение камеры в бою
В бою мы отдаляем камеру, чтобы игрок видел больше пространства вокруг игрового персонажа. Это нужно, чтобы видеть противников, кого атаковать, от кого уворачиваться.
Assassin’s Creed OdysseyКамера на бегу
В спринте выгодно отдалить камеру и увеличить угол обзора FoV. Это нужно, чтобы в экран попадало больше объектов, и игрок успевал управлять движением персонажа на большой скорости. Также FoV даёт дополнительный визуальный эффект, которым в играх принято обозначать ускорение.
Batman Arkham KnightКамера внутри помещений
Внутри помещений мы сталкиваемся с другой организацией пространства. В играх размерности комнат и зданий обычно больше и просторней реальных, но они всё равно стесняют движения персонажа и камеры. Для того чтобы камера лишний раз не «спотыкалась» об окружение, в помещениях мы держим камеру ближе к игроку. Также это создаёт некоторое ощущение замкнутости окружающего пространства, позволяет игроку ощутить себя в тесноте.
Red Dead Redemption 2Конечно случаев, требующих уникальной настройки, может быть больше. Например, у нас в проекте их более 40. Описываемая система позволяет легко создавать для таких случаев готовые наборы настроек.
Цели и задачи системы
Реализовать консольный фил. Поведение камеры похожее на поведение в ААА-консольных играх.
Удобство настройки. Все настройки и создание нового контента должны происходить без написания кода.
Масштабируемость. Возможность расширять и дополнять систему.
Основные элементы системы
Всистеме есть три основных элемента.
Первый элемент — режим камеры
Режим камеры (camera mode) реализован как DataAsset, который содержит все необходимые настройки, выставляющие камеру в нужное положение.
Внутри camera mode настраиваются простейшие параметры, такие как:
Pitch. Вверх/вниз.
Yaw. Влево/вправо.
Roll. Вращение.
Distance. Или Arm Length в UE4. Дистанция до игрока.
Offset. Смещение камеры.
FoV. Поле зрения.
Ниже показано, как изменение отдельных параметров камеры постепенно приводит её в то состояние, которое нам требуется в конкретном camera mode.
Второй элемент — вспомогательные системы
Вспомогательные системы (subsystems) являются отдельными механиками, влияющими на поведение камеры, в зависимости от определенных условий и настроек.
Эти системы выполняют различные задачи. Например, Subsystem pitch position автоматически выставляют камеру в определённые Pitch и Yaw параметры, при движении игрока.
Subsystem auto rotation поворачивает камеру по направлению движения игрока.
Subsystem focus target включают фокус на цель в бою.
Каждая такая subsystem — это отдельный модуль. Модульный подход удобен по множеству причин:
Не каждому режиму камеры нужны все вспомогательные системы.
Куда легче работать с кодом и блупринтами, когда ассет не переполнен множеством механик собранных в кучу.
Есть возможность параллельной разработки нескольких subsystems сразу.
Четкий и явный порядок выполнения subsystems, воздействующих на камеру.
Subsystems — это отдельная большая тема, мы не будем углубляться в неё в этой статье. Просто обозначим, что они есть. Результат, на некоторых представленных материалах из игры, не был бы достигнут без этих систем.
Третий элемент — переходы между режимами
Система выбирает тот или иной режим, исходя из условий, в которых находится игровой персонаж.
Примеры некоторых режимов камеры с условиями перехода:
Base mode. Обычный вид камеры.
Обычное оружие в руках.
Игрок стоит на улице.
Противников рядом нет.
Battle mode. Поведение камеры в бою.
Рядом находятся противники.
Игрок наносит удары по противникам.
Aim mode. Прицеливание.
У игрока в руках стрелковое оружие.
Игрок активировал прицеливание.
Sprint mode. Камера на бегу.
Игрок начал двигаться.
Игрок активировал ускорение.
Indoor mode. Камера внутри помещений.
Игрок находится внутри помещения.
С точки зрения дизайна, мы всегда однозначно знаем в каком режиме камеры должен находиться игрок в зависимости от условий. Но может так получиться, что два режима хотят активироваться одновременно.
Например:
Игрок находится в бою с противниками. Включен Battle mode камеры. Но при этом он достал стрелковое оружие и активировал прицеливание. В таком случае мы хотим активировать Aim mode.
Чтобы разрешать такие ситуации у режимов есть приоритизация. Порядок, согласно которому они активируются. На картинке ниже Aim mode более приоритетный, чем Battle mode.
Нужна была гибкая, простая в плане настроек и прозрачная система для смены camera mode, чтобы она могла бы настраиваться гейм-дизайнерами без участия программистов. В итоге было решено сделать систему перехода от одного мода к другому, базирующуюся наgameplay tags.
Реализация
Режимы камеры
Дляначала необходимо создать C++ проект из шаблона Third Person.
Набор состояний персонажа мы будем хранить в виде TMap, где ключ — FGameplayTag, а значение — количество тегов. Это необходимо, когда теги накладываются и убираются из разных источников.
Так же необходимы функции для добавления/удаления тегов, геттер, а также делегат, который будет вызываться при их изменении. Не забудьте добавить модуль GameplayTags в build.cs файле.
Код//CameraSystemCharacter.h
public:
DECLARE_EVENT_TwoParams(ACameraSystemCharacter, FOnTagContainerChanged, const FGameplayTag& /*ChangedTag*/, bool /*bExist*/);
FOnTagContainerChanged OnTagContainerChanged;
void AddTag(const FGameplayTag& Tag);
void RemoveTag(const FGameplayTag& Tag);
FGameplayTagContainer GetGameplayTags() const;
protected:
TMap TagMap;
//CameraSystemCharacter.срр
void ACameraSystemCharacter::AddTag(const FGameplayTag& Tag)
{
auto& val = ++TagMap.FindOrAdd(Tag);
OnTagContainerChanged.Broadcast(Tag, val > 0);
}
void ACameraSystemCharacter::RemoveTag(const FGameplayTag& Tag)
{
auto& val = --TagMap.FindOrAdd(Tag);
OnTagContainerChanged.Broadcast(Tag, val > 0);
}
FGameplayTagContainer ACameraSystemCharacter::GetGameplayTags() const
{
FGameplayTagContainer tags;
Algo::ForEach(TagMap, [&](const auto& it) { if (it.Value > 0) tags.AddTag(it.Key); });
return tags;
}
После этого добавим компонент, который будет отвечать за систему режимов камеры и их работу.
Код//CameraModeComponent.h
UCLASS( ClassGroup=(Custom), meta=(BlueprintSpawnableComponent) )
class CAMERASYSTEMHABR_API UCameraModeComponent : public UActorComponent
{
GENERATED_BODY()
};
Также необходимо создать класс для camera mode. Каждый camera mode представлен в виде DataAsset с набором настроек для требуемого поведения.
Код//CameraMode.h
UCLASS(Abstract, Blueprintable, editinlinenew)
class UCameraMode: public UDataAsset
{
...
};
В CameraModeComponent необходимо объявить структуру, которая должна содержать Camera mode и FGameplayTagQuery, который определяет условия для перехода в этот режим, исходя из состояния персонажа на данный момент.
Код//CameraMode.h
USTRUCT(BlueprintType)
struct FCameraModeData
{
GENERATED_BODY()
public:
bool CanActivateMode(const FGameplayTagContainer& TagsToCheck) const
{
return ModeActivationConditions.IsEmpty() || ModeActivationConditions.Matches(TagsToCheck);
}
UCameraMode* GetCameraMode() const
{
return CameraMode;
}
protected:
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Instanced)
UCameraMode* CameraMode;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
FGameplayTagQuery ModeActivationConditions;
};
Добавим массив конфигов для всех возможных режимов камеры в компонент и переменную текущего camera mode, а также функцию callback, которая будет вызываться при изменении тегов на персонаже.
Код//CameraModeComponent.h
UCLASS( ClassGroup=(Custom), meta=(BlueprintSpawnableComponent) )
class CAMERASYSTEMHABR_API UCameraModeComponent : public UActorComponent
{
GENERATED_BODY()
...
protected:
virtual void BeginPlay() override();
void OnAbilityTagChanged(const FGameplayTag& Tag, bool TagExists);
protected:
UPROPERTY(EditDefaultsOnly)
TArray CameraModes;
UPROPERTY()
UCameraMode* CurrentCameraMode;
TWeakObjectPtr Character;
...
};
//CameraModeComponent.cpp
void UCameraModeComponent::BeginPlay()
{
Super::BeginPlay();
Character = CastChecked(GetOwner());
if (Character->IsLocallyControlled())
{
Character->OnTagContainerChanged.AddUObject(this, &UCameraModeComponent::OnAbilityTagChanged));
}
}
Дальше, после смены тега, необходимо определить, подходит ли один из camera mode из конфига под текущие условия и, если да, сменить camera mode на новый.
Код//CameraModeComponent.cpp
void UCameraModeComponent::OnAbilityTagChanged(const FGameplayTag& Tag, bool TagExists)
{
SetCameraMode(DetermineCameraMode(Character->GetGameplayTags()));
}
UCameraMode* UCameraModeComponent::DetermineCameraMode(const FGameplayTagContainer& Tags) const
{
if (auto foundMode = Algo::FindByPredicate(CameraModes, [&](const auto modeData) {return modeData.CanActivateMode(Tags);}))
{
return foundMode->GetCameraMode();
}
return nullptr;
}
void UCameraModeComponent::SetCameraMode(UCameraMode* NewMode)
{
CurrentCameraMode = NewMode;
}
Для примера сделаем два режима камеры и переходы между ними. Базовый и во время бега персонажа. По аналогии можно добавлять любое количество состояний для перехода в различные режимы камеры, например для прицеливания, боя и тд.
Прежде всего в ProjectSettings надо завести gameplay tags для состояний персонажа, влияющих на камеру. В данном случае это состояние, когда персонаж бежит.
CharacterState.Sprint
Далее в персонаже заведём ещё несколько методов, для привязки клавиш и включения/выключения состояния бега:
Код//CameraSystemCharacter.h
virtual void SetupPlayerInputComponent(class UInputComponent* PlayerInputComponent) override;
void EnableSprint();
void DisableSprint();
//CameraSystemCharacter.cpp
void ACameraSystemCharacter::SetupPlayerInputComponent(class UInputComponent* PlayerInputComponent)
{
PlayerInputComponent->BindAction("Sprint", IE_Pressed, this, &ACameraSystemCharacter::EnableSprint);
PlayerInputComponent->BindAction("Sprint", IE_Released, this, &ACameraSystemCharacter::DisableSprint);
}
void ACameraSystemCharacter::EnableSprint()
{ AddTag(FGameplayTag::RequestGameplayTag(TEXT("CharacterState.Sprint")));
}
void ACameraSystemCharacter::DisableSprint()
{
RemoveTag(FGameplayTag::RequestGameplayTag(TEXT("CharacterState.Sprint")));
}
После этого необходимо в редакторе создать два ассета UCameraMode и выбрать их в конфиге Camera mode component. Базовый Camera mode необходимо разместить в самом конце, т.к. он не имеет условий для перехода и активируется только в случае, если ни один режим камеры не подошёл под текущие условия.
Дальше настраиваем условия перехода.
Переход в Sprint mode осуществляется при наличии тега CharacterState.Sprint.
Интерполяция при переключении
Необходимо, чтобы один мод плавно переходил в другой при помощи интерполяции параметров. Ниже показано переключение Base mode в Sprint mode и обратно с интерполяцией и без.
В UCameraMode добавляем настройки положения камеры и скорости перехода между режимами камеры.
Код//CameraMode.h
UCLASS(Abstract, Blueprintable, editinlinenew)
class UCameraMode : public UDataAsset
{
GENERATED_BODY()
public:
//Cкорость интерполяции для текущего мода
UPROPERTY(EditDefaultsOnly, meta = (ClampMin = 0.f, UIMin = 0.f))
float InterpolationSpeed = 5.f;
//Продолжительность смены скорости интерполяции при переходе из одного мода в другой, требуется, чтобы достичь максимальной плавности при переходе между модами.
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, meta = (ClampMin = 0.f, UIMin = 0.f))
float InterpolationLerpDuration = 0.5f;
//Длина SprintArm в камера моде
UPROPERTY(EditDefaultsOnly, meta = (ClampMin = 0.f, UIMin = 0.f))
float ArmLength = 250.f;
//Значение FOV для камеры
UPROPERTY(EditDefaultsOnly, meta = (ClampMin = 0.f, UIMin = 0.f))
float Fov= 60.f;
//оффсет камеры относительно SprigArm
UPROPERTY(EditDefaultsOnly)
FVector CameraOffset = FVector::ZeroVector;
//Параметр, который будет включать вращение персонажа по направлению контроллера, например, для режима прицеливания
UPROPERTY(EditDefaultsOnly)
bool bUseControllerDesiredRotation = false;
};
Далее в Camera component tick добавляем методы, которые будут отвечать за интерполяцию значений:
Код//CameraModeComponent.h
protected:
virtual void TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override;
virtual void BeginPlay() override;
void SetCameraMode(UCameraMode* NewMode);
float GetInterpSpeed() const;
void UpdateCameraMode (float DeltaTime);
void UpdateSpringArmLength(float DeltaTime);
void UpdateCameraLocation(float DeltaTime);
void UpdateFOV(float DeltaTime);
//Мировое время, во время смены одного камера мода на другой, потребуется, чтобы реализовать плавный переход скорости интерполяции между модами
float TimeSecondsAfterSetNewMode = 0.f;
//Скорость интерполяции с прошлого мода, требуется, чтобы плавно перейти в новую скорость интерполяции
float PreviousInterpSpeed = 0.f;
//Камера менеджер, пригодится в дальнейшем для смены значений FOV
TWeakObjectPtr PlayerCameraManager;
//CameraModeComponent.cpp
void UCameraModeComponent::SetCameraMode(UCameraMode* NewMode)
{
if (CurrentCameraMode != NewMode)
{
PreviousInterpSpeed = CurrentCameraMode == nullptr ? NewMode->InterpolationSpeed : CurrentCameraMode->InterpolationSpeed;
CurrentCameraMode = NewMode;
Character->GetCharacterMovement()->bUseControllerDesiredRotation = CurrentCameraMode->bUseControllerDesiredRotation;
Character->GetCharacterMovement()->bOrientRotationToMovement = !CurrentCameraMode->bUseControllerDesiredRotation;
TimeSecondsAfterSetNewMode = GetWorld()->GetTimeSeconds();
}
}
void UCameraModeComponent::BeginPlay()
{
...
PlayerCameraManager = CastChecked (Character->GetController())->PlayerCameraManager;
...
}
float UCameraModeComponent::GetInterpSpeed() const
{
auto timeAfterModeWasChanged = GetWorld()->GetTimeSeconds() - TimeSecondsAfterSetNewMode;
auto lerpDuration = CurrentCameraMode->InterpolationLerpDuration;
auto lerpAlpha = FMath::IsNearlyZero(lerpDuration) ? 1.f : FMath::Min(1.f, timeAfterModeWasChanged / lerpDuration);
return FMath::Lerp(PreviousInterpSpeed, CurrentCameraMode->InterpolationSpeed, lerpAlpha);
}
void UCameraModeComponent::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction)
{
Super::TickComponent(DeltaTime, TickType, ThisTickFunction);
UpdateCameraMode(DeltaTime);
}
void UCameraModeComponent::UpdateCameraMode(float DeltaTime)
{
if (CurrentCameraMode != nullptr)
{
UpdateSpringArmLength(DeltaTime);
UpdateSpringArmPivotLocation(DeltaTime);
UpdateCameraLocation(DeltaTime);
}
}
//Интерполяция параметра TargetArmLength
void UCameraModeComponent::UpdateSpringArmLength(float DeltaTime)
{
const auto currentLength = Character->GetCameraBoom()->TargetArmLength;
const auto targetLength = CurrentCameraMode->ArmLength;
const auto newArmLength = FMath::FInterpTo(currentLength, targetLength, DeltaTime, GetInterpSpeed());
Character->GetCameraBoom()->TargetArmLength = newArmLength;
}
void UCameraModeComponent::UpdateCameraLocation(float DeltaTime)
{
const auto currentLocation = Character->GetCameraBoom()->SocketOffset;
const auto targetLocation = CurrentCameraMode->CameraOffset;
FVector newLocation = FMath::VInterpTo(currentLocation, targetLocation, DeltaTime, GetInterpSpeed());
Character->GetCameraBoom()->SocketOffset = newLocation;
}
//Смена значений FOV
void UCameraModeComponent::UpdateFOV(float DeltaTime)
{
const auto currentFov = PlayerCameraManager->GetFOVAngle();
const auto targetFov = CurrentCameraMode->Fov;
auto newFov = FMath::FInterpTo(currentFov, targetFov, DeltaTime, GetInterpSpeed());
PlayerCameraManager->SetFOV(newFov);
}
Таким образом, после смены режима камеры на новый, будет происходить плавный переход между ними.
Результат
Навидео показано, как работает переход между режимами камеры у нас в проекте. Конечно, на видео немного более сложная система, чем та, которая описана в статье. Так как в рамках одной статьи невозможно описать все элементы системы камер.
Мы подготовили небольшой проект на GitHub, в котором реализованы описанные в статье camera modes. Ссылка для скачивания.
Над статьёй работали:
Дмитрий Горбачев, Technical Game Designer
Дмитрий Вергасов, C++ programmer
Кирилл Минцев, C++ programmer