Вивчаючи розробку ігор в Unreal Engine на C++ не так давно, я натрапив на проблему в проєкті, над яким працював. Мої класи ставали надто заплутаними, з великою кількістю методів та умов if else.
Тому я вирішив реалізувати більш чистий код, орієнтуючись на принципи Clean Architecture.
Я трохи пошукав в інтернеті, але не знайшов багато прикладів практичного використання Clean Architecture в Unreal Engine з використанням мови C++.
Сподіваюся, цей артикул буде корисним для інших розробників (devs) і стане відправною точкою.
Структура проєкту
У межах Application ми маємо BallStaticMeshActor, що представляє футбольний м'яч, і ми хочемо маніпулювати його траєкторією згідно з введенням, яке ми отримуємо від нашого гравця.
Для цього ми створимо компонент, відповідальний за малювання та маніпулювання траєкторією м'яча.
Ми створили файл з назвою SplineDrawComponent в межах Components/StaticMeshActor, де зберігаються всі компоненти, які можуть маніпулювати класами типу AStaticMeshActor.
// USplineDrawComponent.h
class NEWPROJECT_API USplineDrawComponent : public UActorComponent, public ISplineDrawComponentInterface
{
GENERATED_BODY()
public:
USplineDrawComponent();
virtual void SetDistance(float Value) override;
virtual void SetCurrentDistance(float Value) override;
virtual void SetComponentVelocity(FVector Velocity) override;
virtual void AddSplinePointFunction() override;
virtual float GetDistance() override;
virtual float GetCurrentDistance() override;
virtual float GetCuurentVelocity() override;
virtual float CalculateSplineLength(FVector TrajectoryEnd) override;
float CurrentDistance = 0.0f;
float Distance = 0.0f;
};
// USplineDrawComponent.cpp
USplineDrawComponent::USplineDrawComponent()
{
PrimaryComponentTick.bCanEverTick = true;
}
void USplineDrawComponent::AddSplinePointFunction()
{
if (!GetOwner())
{
UE_LOG(LogTemp, Warning, TEXT("GetOwner не знайдено в USplineDrawComponent::AddSplinePointFunction"));
return;
}
FVector Start = FVector(GetOwner()->GetActorLocation().X, GetOwner()->GetActorLocation().Y, 24.f);
FVector End = Start + Start.GetSafeNormal() * Distance;
// Намалюємо траєкторію за допомогою Debug Draw
DrawDebugLine(GetWorld(), Start, FVector(End.X, End.Y, 24.f), FColor::Green, true, 1.f, SDPG_World, 4.f);
DrawDebugPoint(GetWorld(), Start, 10.f, FColor::Blue, true);
DrawDebugPoint(GetWorld(), FVector(End.X, End.Y, 24.f), 10.f, FColor::Blue, true);
}
void USplineDrawComponent::SetComponentVelocity(FVector Velocity)
{
AStaticMeshActor* Actor = Cast<GetOwner>();
if (!Actor)
{
UE_LOG(LogTemp, Warning, TEXT("Актор не знайдений в USplineDrawComponent::SetComponentVelocity"));
}
return;
}
Actor->GetStaticMeshComponent()->SetPhysicsLinearVelocity(FVector(Velocity.X, Velocity.Y, 22.5f));
}
float USplineDrawComponent::CalculateSplineLength(FVector TrajectoryEnd)
{
AStaticMeshActor* Actor = Cast<GetOwner>();
if (!Actor)
{
UE_LOG(LogTemp, Warning, TEXT("Актор не знайдений в USplineDrawComponent::CalculateSplineLength"));
return 0.f;
}
FVector Start = FVector(GetOwner()->GetActorLocation().X, GetOwner()->GetActorLocation().Y, 0.f);
return FVector::Distance(Start, TrajectoryEnd);
}
float USplineDrawComponent::GetDistance()
{
return Distance;
}
float USplineDrawComponent::GetCurrentDistance()
{
return CurrentDistance;
}
float USplineDrawComponent::GetCuurentVelocity()
{
return GetOwner()->GetVelocity().Size2D();
}
void USplineDrawComponent::SetCurrentDistance(float Value)
{
CurrentDistance = Value;
}
void USplineDrawComponent::SetDistance(float Value)
{
Distance = Value;
}
Зверніть увагу, що цей компонент реалізує інтерфейс ISplineDrawComponentInterface.h
class NEWPROJECT_API ISplineDrawComponentInterface
{
GENERATED_BODY()
// Додайте функції інтерфейсу до цього класу.
Це клас, який буде успадкований для реалізації цього інтерфейсу.
public:
virtual void SetComponentVelocity(FVector Velocity) = 0;
virtual void AddSplinePointFunction() = 0;
virtual void SetDistance(float Value) = 0;
virtual void SetCurrentDistance(float CurrentDistance) = 0;
virtual float GetDistance() = 0;
virtual float GetCurrentDistance() = 0;
virtual float GetCuurentVelocity() = 0;
virtual float CalculateSplineLength(FVector TrajectoryEnd) = 0;
};
Тепер давайте створимо Використання (Use Case), яке містить логіку, яку ми будемо використовувати для переміщення м'яча за певною траєкторією.
// UTrajectoryRuntimeDrawUseCase.h
class NEWPROJECT_API UTrajectoryRuntimeDrawUseCase : public UObject
{
GENERATED_BODY()
public:
UTrajectoryRuntimeDrawUseCase();
static void Handle
(
TScriptInterface SplineBallComponentInterface,
const FVector& Input,
float DeltaTime
);
};
// UTrajectoryRuntimeDrawUseCase.cpp
void UTrajectoryRuntimeDrawUseCase::Handle
{
(
TScriptInterface SplineBallComponentInterface,
const FVector& Input,
float DeltaTime
)
{
// Тут ми реалізуємо логіку траєкторії нашого м'яча
float Distance = SplineBallComponentInterface->GetDistance();
if (Distance > 0.0f)
{
float DistanceAt = SplineBallComponentInterface->GetCurrentDistance();
float VelocityAt = SplineBallComponentInterface->GetCuurentVelocity();
float CurrentDistance = DistanceAt + (VelocityAt * DeltaTime);
SplineBallComponentInterface->SetCurrentDistance(CurrentDistance);
if (CurrentDistance > Distance)
{
SplineBallComponentInterface->SetCurrentDistance(Distance);
SplineBallComponentInterface->SetComponentVelocity(FVector::ZeroVector);
return;
}
return;
}
float Average = SplineBallComponentInterface->CalculateSplineLength(Input);
SplineBallComponentInterface->SetDistance(Average * 1.4f);
SplineBallComponentInterface->AddSplinePointFunction();
// Input * Force
FVector LinearVelocity = Input.GetSafeNormal() * 200.0f;
SplineBallComponentInterface->SetComponentVelocity(LinearVelocity);
}
Зверніть увагу, що ми можемо мати декілька Використань (Use Case) для одного компонента і можемо реалізувати цей компонент у різних класах, що успадковують AStaticMeshActor, використовуючи різні Використання (Use Case) або повторно використовувати вже існуюче використання.
Наприкінці давайте використаємо цей компонент та створене використання для визначення траєкторії нашого м'яча.
У Application/BallStaticMeshActor створіть файл з наступним кодом.
// Sets default values
ABallStaticMeshActor::ABallStaticMeshActor()
{
PrimaryActorTick.bCanEverTick = true;
// Ініціалізуємо наш компонент
Spline = CreateDefaultSubobject(TEXT("Spline"));
Spline->RegisterComponent();
static ConstructorHelpers::FObjectFinder BallStaticMesh(TEXT("/Script/Engine.StaticMesh'/Game/SuaStaticMesh'"));
if (BallStaticMesh.Succeeded())
{
GetStaticMeshComponent()->SetStaticMesh(BallStaticMesh.Object);
GetStaticMeshComponent()->SetMassOverrideInKg(NAME_None, 0.450f);
}
SetActorScale3D(FVector(1.1f, 1.1f, 1.1f));
GetStaticMeshComponent()->SetMobility(EComponentMobility::Movable);
GetStaticMeshComponent()->SetSimulatePhysics(true);
GetStaticMeshComponent()->SetEnableGravity(true);
GetStaticMeshComponent()->SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics);
GetStaticMeshComponent()->SetCollisionResponseToAllChannels(ECR_Block);
GetStaticMeshComponent()->SetupAttachment(RootComponent);
}
void ABallStaticMeshActor::BeginPlay()
{
Super::BeginPlay();
}
void ABallStaticMeshActor::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
if (Spline)
{
// Тут ми передаємо в наше використання компонент, відповідальний за маніпулювання нашим м'ячем, і фінальну траєкторію м'яча
FVector Trajectory = FVector(GetActorLocation().X * 2, GetActorLocation().Y * 2, 0.f);
UTrajectoryRuntimeDrawUseCase::Handle(Spline, Trajectory, DeltaTime);
}
}
З цим ми отримуємо наступний результат.
Сподіваюся, що це допомогло, 😊
Перекладено з: Implementação básica clean architecture na UE-5 com c++