Unreal Engine 5
February 9

Лента Мёбиуса часть 3.

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

UCRCharacterMovementComponent

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

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

N.B. Создание таких Setter может быть не самым правильным, так как мы создаем зависимости между классами, создающими контроллеры гравитации, и нашим компонентом. В общем случае лучше использовать интерфейсы или события. Но, поскольку мы сейчас концентрируемся на тестировании механики и простой реализации ленты Мёбиуса, такой подход допустим. В дальнейшем мы сможем легко его переписать.

UCLASS(ClassGroup=(Custom), meta=(BlueprintSpawnableComponent))
class FPSGAME_API UCRCharacterMovementComponent : public UCharacterMovementComponent
{
	GENERATED_BODY()

public:
	virtual void TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override;
	FVector GetUpOrientation() const;
	UFUNCTION(BlueprintCallable)
	void SetGravityController(UObject* InGravityController);

	TOptional<FVector> UpOverride;
	UPROPERTY()
	TScriptInterface<class ICRGravityController> GravityController;
};
FVector UCRCharacterMovementComponent::GetUpOrientation() const
{
	return UpOverride.Get(FVector::UpVector);
}

void UCRCharacterMovementComponent::SetGravityController(UObject* InGravityController)
{
	if (InGravityController->Implements<UCRGravityController>())
	{
		GravityController = TScriptInterface<ICRGravityController>(InGravityController);
	}
}

Во время тика нашего компонента получим у гравитационного контроллера (если он есть) ориентацию и гравитацию, сохраним ориентации и проставим как направление гравитации.

void UCRCharacterMovementComponent::TickComponent(float DeltaTime, ELevelTick TickType,
	FActorComponentTickFunction* ThisTickFunction)
{
	Super::TickComponent(DeltaTime, TickType, ThisTickFunction);
	UE_VLOG(GetOwner(), LogCRCharacterMovementComponent, VeryVerbose,  TEXT("Mode %s CustomMode %s"), *GetMovementName(), *GetCustomMovementName());
	if (GravityController)
	{		
		FVector ControllerUp;
		FVector Gravity;	
		GravityController->GetGravityAndOrientation(GetActorLocation(), Gravity, ControllerUp);
		SetGravityDirection(Gravity);
		UpOverride = ControllerUp;
	}
}

ACRPlayerController

Второй класс, который нам необходимо расширить - это класс нашего контроллера. В данном примере мы используем базовые настройки Unreal Engine 5.6 для шаблона FPS. Ориентация персонажа следует за камерой, контроллер поворачивает камеру. Для третьего лица (или если у вас сложный контроллер, который выполняет некоторые дополнительные функции) может потребоваться дополнительная логика.

Унаследуем наш от APlayerController. Переопределим функцию UpdateRotation и создадим пару функций для удобного вычисления ориентации, плюс добавим поле, чтобы кешировать предыдущую ориентацию “вверх”.

UCLASS()
class FPSGAME_API ACRPlayerController : public APlayerController
{
	GENERATED_BODY()

	virtual void UpdateRotation(float DeltaTime) override;
public:
	static FRotator GetUpRelativeRotation(FRotator Rotation, FVector GravityDirection);
	static FRotator GetUpWorldRotation(FRotator Rotation, FVector GravityDirection);
 
private:
	FVector LastFrameUp = FVector::ZeroVector;
};

Функции GetUpRelativeRotation и GetUpWorldRotation достаточно простые: мы вычисляем итоговый поворот ориентации относительно того, насколько у нас гравитационный “вверх” отличается от мирового. Разница в том, что в одном случае мы считаем, что поворот локальный, а в другом - мировой.

FRotator ACRPlayerController::GetUpRelativeRotation(FRotator Rotation, FVector UpDirection)
{
	if (!UpDirection.Equals(FVector::UpVector))
	{
		FQuat GravityRotation = FQuat::FindBetweenNormals(UpDirection, FVector::UpVector);
		return (GravityRotation * Rotation.Quaternion()).Rotator();
	}
 
	return Rotation;
}
 
FRotator ACRPlayerController::GetUpWorldRotation(FRotator Rotation, FVector UpDirection)
{
	if (!UpDirection.Equals(FVector::UpVector))
	{
		FQuat GravityRotation = FQuat::FindBetweenNormals(FVector::UpVector, UpDirection);
		return (GravityRotation * Rotation.Quaternion()).Rotator();
	}
 
	return Rotation;
}

Функция поворота UpdateRotation требует более подробного рассмотрения.

Для начала получим требуемое направление “вверх” из UCRCharacterMovementComponent

	FVector UpDirection = FVector::UpVector;
	if (ACharacter* PlayerCharacter = Cast<ACharacter>(GetPawn()))
	{
		if (UCRCharacterMovementComponent* MoveComp = PlayerCharacter->GetCharacterMovement<UCRCharacterMovementComponent>())
		{
			UpDirection = MoveComp->GetUpOrientation();
		}
	}

Получим требуемую игроком ориентацию контроллера.

FRotator ViewRotation = GetControlRotation();

Эту ориентацию надо повернуть так, чтобы она соответствовала новой гравитации. Например если игрок смотрел на 45 градусов вверх относительно мировых координат, теперь мы должны повернуть эту ориентацию так, чтобы эти 45 градусов сохранились от нашего локального “вверх”.

	if (!LastFrameUp.Equals(FVector::ZeroVector))
	{
		const FQuat DeltaGravityRotation = FQuat::FindBetweenNormals(LastFrameUp, UpDirection);
		const FQuat WarpedCameraRotation = DeltaGravityRotation * FQuat(ViewRotation);
 
		ViewRotation = WarpedCameraRotation.Rotator();	
	}
	LastFrameUp = UpDirection;

Теперь нам надо взять управление от игрока, чтобы добавить поворот, который пытается сделать игрок, и передать в PlayerCameraManager, чтобы он применил модификаторы и правила поворота камеры. После чего убрать Roll, перевести нашу локальную ориентацию в мировую, и выставить в контроллер.

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

	ViewRotation = GetUpRelativeRotation(ViewRotation, UpDirection);

	FRotator DeltaRot(RotationInput);
	if (PlayerCameraManager)
	{
		PlayerCameraManager->ProcessViewRotation(DeltaTime, ViewRotation, DeltaRot);

		ViewRotation.Roll = 0;
 
		SetControlRotation(GetUpWorldRotation(ViewRotation, UpDirection));
	}

В конце убираем Roll и Pitch у нашей ориентации, переводим в мировые координаты и передаем это нашей пешке.

	ViewRotation.Roll = 0.0f;
	ViewRotation.Pitch = 0.0f;
	APawn* const P = GetPawnOrSpectator();
	if (P)
	{
		P->FaceRotation(GetUpWorldRotation(ViewRotation, UpDirection), DeltaTime);
	}

ApplyGravityController

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

Теперь наш код готов к полноценному тестированию.