-
[UE5 | FPS] 히트 박스를 관리하기 위한 에디터 유틸리티언리얼엔진/FPS 프로젝트 2024. 6. 2. 18:10
게임에서는 캐릭터의 피격 판정을 위해 단순한 형태의 히트 박스를 따로 만든다.
만약 캐릭터에 애니메이션이 있다면 히트 박스도 애니메이션에 따라 움직여야하므로, 부위별로 나눠서 캐릭터 메시의 본을 따라가게 해야한다.
그런데 움직이는 부위가 많을 수록 히트 박스도 많아질 것이고, 이걸 하나씩 직접 관리하는건 번거로운 작업이다.
언리얼 엔진에서 캐릭터의 히트 박스를 만들어야 한다면 가장 쉽게 떠올릴 수 있는 방법은 캐릭터를 상속받은 클래스나 블루프린트에서 박스 컴포넌트를 추가해서 캐릭터의 메시의 본에 어태치하는 방법이다.
하지만 히트 박스를 이렇게 추가하면 히트 박스가 많아질수록 관리하기도 어려워진다.
같은 메시를 사용하는 다른 캐릭터에도 동일한 히트 박스를 추가하고싶다거나, 히트 박스 작업이 끝난 캐릭터의 메시를 다른 메시로 바꾸고 싶은 경우를 생각해 볼 수 있다.
만약 각 스켈레탈 메시마다 히트 박스 정보를 어딘가에 저장해두고 런타임에 불러올 수 있다면, 히트 박스 정보는 하나의 블루프린트에 종속되지 않아서 다른 캐릭터에서도 불러올 수 있게되고, 작업중이던 캐릭터의 메시를 바꾸는것도 부담스럽지 않게된다.
추가로 에디터에서는 히트 박스의 트랜스폼 정보를 직관적인 방법으로 편집하고 수정할 수 있다면 누구나 쉽고 빠르게 히트 박스를 관리할 수 있게된다.
히트 박스의 정보에는 어느 스켈레탈 메시의 히트 박스인지, 어느 본에 어태치 되는지, 히트 박스의 위치, 회전, 크기 정보가 필요하다.
이 정보들을 구조체로 묶어서 데이터 테이블에 나열하고, 각 데이터 테이블은 하나의 스켈레탈 메시를 담당하면 된다.
데이터 테이블의 행 이름을 히트 박스가 어태치된 본 이름으로 할 수도 있지만, 본 하나에 여러개의 히트 박스가 어태치 되는 경우를 고려해서 본 이름은 구조체에 저장하게 했다.
따라서 아래처럼 데이터 테이블의 Row 구조체를 만들었다.
USTRUCT(BlueprintType) struct FHitboxInfoRow : public FTableRowBase { GENERATED_BODY() public: UPROPERTY(EditAnywhere) FName BoneName; UPROPERTY(EditAnywhere) FVector Location; UPROPERTY(EditAnywhere) FRotator Rotation; UPROPERTY(EditAnywhere) FVector Extend; UPROPERTY(EditAnywhere) bool bIsWeakHitbox; // 히트 박스가 치명타를 허용하는지에 대한 정보 };
언리얼 엔진에는 에디터 유틸리티를 통해 컨텐츠 브라우저의 에셋이나 레벨에 배치된 액터를 이용해서 스크립팅된 액션을 실행할 수 있다.
https://dev.epicgames.com/documentation/ko-kr/unreal-engine/scripted-actions-in-unreal-engine
언리얼 엔진의 스크립팅된 액션 | 언리얼 엔진 5.4 문서 | Epic Developer Community
콘텐츠 브라우저의 에셋이나 레벨의 액터를 우클릭하여 호출할 수 있는 블루프린트를 만듭니다.
dev.epicgames.com
이 방법으로 스크립트를 작성해서 반복 작업을 자동화 할 수 있다.
에디터 유틸리티는 에셋 액션 유틸리티와 액터 액션 유틸리티로 나뉘어진다.
컨텐츠 브라우저의 에셋을 선택해서 실행하려면 에셋 액션 유틸리티를, 월드에 배치된 액터를 선택해서 실행하려면 에셋 액션 유틸리티를 사용해야한다.
히트 박스를 스켈레탈 메시에 어태치하고 트랜스폼을 조정하는 작업은 월드에 스켈레탈 메시 액터와 히트 박스 액터를 배치해서 작업하면 될듯해서 액터 액션 유틸리티를 선택했다.
스켈레탈 메시 에셋은 월드에 배치하면 액터로 배치되므로 문제가 없지만, 액터 컴포넌트인 히트 박스는 월드에 배치하기 위한 액터가 필요하므로 편집용으로 사용할 히트 박스 액터를 만들 필요가 있다.
작성할 스크립트는 간단하다.
선택된 스켈레탈 메시 액터에 어태치된 히트 박스 액터에 접근해서 어태치된 본 이름과 상대 위치, 상대 회전, 크기 정보를 가져와서 데이터 테이블에 저장하면 된다.
데이터 테이블에 있는 정보로 히트 박스를 불러올 때에는 저장된 정보를 이용해서 SpawnActor로 히트 박스 액터를 스폰하면 된다.
문제는 데이터 테이블을 C++ 코드로 어떻게 다루냐는 것인데, 이런 작업을 위한 라이브러리가 존재하기 때문에 쉽게 해결할 수 있다.
UEditorAssetLibrary로 에셋이 존재하는지 여부를 알 수 있고, 에셋을 로드하거나 저장할 수 있다.
데이터 테이블을 작성할 때에는 UDataTableFunctionLibrary에서 JSON 문자열로 데이터 테이블을 채워넣을 수 있다.
새로운 에셋을 추가하는건 UPackage 오브젝트를 생성하면 된다.
새로운 데이터 테이블 에셋을 생성하려면 NewObject 함수로 데이터 테이블 오브젝트를 생성한 다음에 패키지로 저장하면 된다.
UDataTable* UActorActionUtility_HitboxInfo::CreateDataTableAsset(FString FullPath, bool bSyncBrowserToObject) { UPackage* Package = CreatePackage(*FullPath); Package->FullyLoad(); UDataTable* DataTable = NewObject<UDataTable>(Package, *FPaths::GetCleanFilename(FullPath), RF_Public | RF_Standalone | RF_MarkAsRootSet); DataTable->RowStruct = FHitboxInfoRow::StaticStruct(); Package->MarkPackageDirty(); FAssetRegistryModule::AssetCreated(DataTable); FString PackageFileName = FPackageName::LongPackageNameToFilename( FullPath, FPackageName::GetAssetPackageExtension() ); FSavePackageArgs SaveArgs; SaveArgs.TopLevelFlags = EObjectFlags::RF_Public | EObjectFlags::RF_Standalone; SaveArgs.Error = GError; SaveArgs.bForceByteSwapping = true; SaveArgs.bWarnOfLongFilename = true; SaveArgs.SaveFlags = SAVE_NoError; bool bSaved = UPackage::SavePackage( Package, DataTable, *PackageFileName, SaveArgs ); if (bSaved) { if (bSyncBrowserToObject) { // 컨텐츠 브라우저에 에셋 표시 TArray<UObject*> ObjectsToSync; ObjectsToSync.Add(DataTable); GEditor->SyncBrowserToObjects(ObjectsToSync); } return DataTable; } return nullptr; }
선택한 스켈레탈 메시 에셋을 통해 어태치된 히트 박스 액터의 정보를 가져오는 과정은 간단하므로 생략한다.
중요한 부분은 JSON 문자열으로 데이터 테이블에 정보를 넣는 방법이다.
데이터 테이블에 정보를 추가하는 함수가 몇몇 있지만, 그중에서 작동이 확인된 방법이 JSON 문자열을 이용한 방법 뿐이었다.
JSON을 활용하는 방법은 아래 코드에서 확인할 수 있다.
void UActorActionUtility_HitboxInfo::SaveHitbox() { TArray<AActor*> SelectedActors = UEditorUtilityLibrary::GetSelectionSet(); for (AActor* SelectedActor : SelectedActors) { ASkeletalMeshActor* SkeletalMeshActor = Cast<ASkeletalMeshActor>(SelectedActor); if (!SkeletalMeshActor) { continue; } const FString DataTablePath = UNLFunctionLibrary::MakeHitboxInfoDataTablePath(SkeletalMeshActor->GetSkeletalMeshComponent()); // Load or Create DataTable UDataTable* DataTable = nullptr; if (UEditorAssetLibrary::DoesAssetExist(DataTablePath)) { DataTable = Cast<UDataTable>(UEditorAssetLibrary::LoadAsset(DataTablePath)); } else { DataTable = CreateDataTableAsset(DataTablePath); if (!DataTable) { UE_LOG(LogTemp, Error, TEXT("Failed to create new DataTable asset.")); continue; } } DataTable->RowStruct = FHitboxInfoRow::StaticStruct(); // Get Hitbox Info TMap<FName, TArray<FHitboxInfoRow>> HitboxInfos; GetAttachedHitboxInfo(SelectedActor, HitboxInfos); // Convert to JSON format TArray<TSharedPtr<FJsonValue>> JsonArray; for (const TTuple<FName, TArray<FHitboxInfoRow>>& Item : HitboxInfos) { FName BoneName = Item.Key; const TArray<FHitboxInfoRow>& InfoArray = Item.Value; for (int i = 0; i < InfoArray.Num(); i++) { const FHitboxInfoRow& Info = InfoArray[i]; TSharedPtr<FJsonObject> Json_Row = MakeShareable(new FJsonObject); Json_Row->SetStringField("Name", FString("Hitbox_") + BoneName.ToString() + FString("_") + FString::FromInt(i)); Json_Row->SetStringField("BoneName", Info.BoneName.ToString()); TSharedPtr<FJsonObject> Json_Location = MakeShared<FJsonObject>(); Json_Location->SetNumberField("X", Info.Location.X); Json_Location->SetNumberField("Y", Info.Location.Y); Json_Location->SetNumberField("Z", Info.Location.Z); Json_Row->SetObjectField("Location", Json_Location); TSharedPtr<FJsonObject> Json_Rotation = MakeShared<FJsonObject>(); Json_Rotation->SetNumberField("Pitch", Info.Rotation.Pitch); Json_Rotation->SetNumberField("Yaw", Info.Rotation.Yaw); Json_Rotation->SetNumberField("Roll", Info.Rotation.Roll); Json_Row->SetObjectField("Rotation", Json_Rotation); TSharedPtr<FJsonObject> Json_Extend = MakeShared<FJsonObject>(); Json_Extend->SetNumberField("X", Info.Extend.X); Json_Extend->SetNumberField("Y", Info.Extend.Y); Json_Extend->SetNumberField("Z", Info.Extend.Z); Json_Row->SetObjectField("Extend", Json_Extend); Json_Row->SetBoolField("bIsWeakHitbox", Info.bIsWeakHitbox); TSharedPtr<FJsonValueObject> JsonValue = MakeShareable(new FJsonValueObject(Json_Row)); JsonArray.Add(JsonValue); } } // To JSON string FString JsonString; const TSharedRef<TJsonWriter<>> JsonWriter = TJsonWriterFactory<>::Create(&JsonString); FJsonSerializer::Serialize(JsonArray, JsonWriter); // Fill DataTable UDataTableFunctionLibrary::FillDataTableFromJSONString(DataTable, JsonString); // Save DataTable Asset UEditorAssetLibrary::SaveAsset(DataTablePath); } }
참고로 여기에서는 히트 박스 데이터 테이블이 저장될 경로가 정해져있고, 스켈레탈 메시 에셋의 이름으로 데이터 테이블을 구분한다.
히트 박스 데이터 테이블 이런식으로 스켈레탈 메시 에셋의 이름으로 데이터 테이블 에셋의 경로를 만들어서 해당 데이터 테이블이 존재하는지 확인하게 된다.
위 코드에 적혀있듯이 UEditorAssetLibrary::DoesAssetExist를 사용하는게 간단한 방법이다.
아니면 UObjectLibrary를 통해 지정한 경로 아래에 있는 에셋의 목록을 가져와서 찾는것도 가능하다.
생성한 UObjectLibrary 오브젝트의 멤버 변수 중에서 bRecursivePaths를 true로 하면 재귀적으로 하위 폴더들을 모두 탐색할 수도 있다.
경우에 따라서는 이 방법이 필요할 수도 있기에 적어둔다.
bool UNLFunctionLibrary::AssetExists(FString FullPath) { const FString FolderPath = FPaths::GetPath(FullPath); const FName AssetName = FName(FPaths::GetCleanFilename(FullPath)); UObjectLibrary* ObjectLibrary = UObjectLibrary::CreateLibrary(nullptr, false, GIsEditor); ObjectLibrary->LoadAssetDataFromPath(FolderPath); TArray<FAssetData> AssetDatas; ObjectLibrary->GetAssetDataList(AssetDatas); for (const FAssetData& AssetData : AssetDatas) { if (AssetData.AssetName.IsEqual(AssetName)) { return true; } } return false; }
저장된 히트 박스 정보를 수정하기위해 히트 박스를 불러와야하는 경우에는 선택된 스켈레탈 메시 액터를 통해 World에 접근해서 SpawnActor 함수를 호출하는 방법을 쓰면 된다.
간단한 작업이므로 생략한다.
적용 결과는 다음과 같다.
아래처럼 히트 박스를 월드에 배치해서 작업했다.
이 스켈레탈 메시 액터를 마우스 우클릭 하면 아래 이미지처럼 스크립트 된 액터 액션이 뜨고, 히트 박스를 저장하는 기능이 나타난다.
이렇게 저장된 히트 박스 데이터 테이블에는 이렇게 데이터가 저장된다.
추가로 좌우 대칭 기능도 만들어서 반복 작업을 더 줄였다.
위 예시의 과정은 이렇다.
1. 기존에 저장된 히트 박스 정보 불러오기
2. 히트 박스 대칭
3. 히트 박스 저장
4. 히트 박스 정보 불러와서 저장된 데이터 확인
이렇게 저장된 히트 박스 정보를 캐릭터가 생성될 때 불러와서 히트 박스 컴포넌트를 추가하면 된다.
런타임에서 히트 박스 정보를 가져온 결과 '언리얼엔진 > FPS 프로젝트' 카테고리의 다른 글
[UE5 | FPS] 멀티플레이에서 작동하는 달리기 구현 과정 (0) 2024.06.14 [UE5 | FPS] FGameplayEffectContext의 자식 구조체로 데미지 관련 정보 전달 (2) 2024.06.05 [UE5 | FPS] 수평 FOV와 수직 FOV, 그리고 뷰모델 (0) 2024.05.08 [UE5 | FPS] 애니메이션 블루프린트 링크 (1) 2024.05.07 [UE5 | FPS] 반동을 담하는 ControlShake와 반동 패턴 (0) 2024.05.06