여러 방법을 시도해봤지만 안됬었는데, 중간에 비주얼 스튜디오 업데이트를 하면서 더 꼬였었다.
결국 깃허브에서 엔진 재설치를 하고 프로젝트 임시 파일을 싹 날린다음 .uproject 파일에서 Generate project file을 하고 클린 프로젝트 -> Rebuild를 하니 살릴 수 있었다.
엔진의 빌드가 8시간 넘게 걸려 새로 빌드를 해보는 날엔 거의 하루를 날렸었는데, 고치는동안 빌드를 너무 많이 시도해 날린 많은 시간들이 아깝다..
그다음 세션 생성 및 참가를 위해 로직을 작성했는데 처음에는 GameMode 클래스에 모든 로직을 작성했었다.
근데 서버 - 클라이언트 동작이 GameMode 클래스에서 동작하지 않아 PlayerController로 로직을 일부 옮겨 멀티플레이의 핵심인 세션 생성 및 세션 검색, 세션 참가를 GameMode 클래스에서 동작하도록 하고, PlayerController 클래스에서 각 플레이어가 서버에 해당하는 동작을 요청하도록 변경하였다.
세션 관리는 따로 GameMode 클래스가 서버에 단 하나만 존재한다고 생각해 SessionInfo 구조체에 세션 이름, 맵 이름, 현재인원수, 최대인원수, 세션 UID를 저장하는 방식으로 설계하고 OnlineSubsystem이 세션을 생성할 때 FName 타입으로 UID를 지정해서 추후 세션 참가시 세션 UID를 참고하여 접속할 수 있도록 구성했었다.
하지만 PIE 테스트 특성 때문인지 GameMode 클래스에서 TMap 타입 ActiveSession을 UID-SessionInfo 조합으로 선언했는데, 인스턴스 마다 ActiveSession을 새로 생성해서 참조하는 일이 발생했다.
이것을 해결하기 위해 여러 방법을 시도해봤지만, 최종적으로 GameInstance 클래스에 ActiveSession TMap을 옮기고 관리하도록 구성한 다음, GameInstance를 싱글톤 형식으로 다른 클래스들에서 참조하도록 하니 ActiveSession의 일관성이 유지되었다.
그다음 Widget Blueprint로 UI를 구성하고 C++로 구성해놓은 세션 관련 함수들을 바인딩 해서 테스트 해보았다.
Create Session은 현재 처음 엔진을 가동하고 나서 첫 동작시 정상적으로 레벨 이동이 되지 않고 메인 UI로 튕겨져나오는 현상이 발생하고 있다. 그거 제외하고는 잘 동작하고 있다.
블루프린트도 디자이너쪽에서 사용하기 좋다는 말을 봐서 UI를 모두 Widget Blueprint로 생성하고 블루프린트로 동작을 구성했다.
Event Construct 노드를 사용해 처음 세션 검색을 하도록 하고, Refresh버튼의 클릭 이벤트가 발생하면 세션 리스트를 새로고침 하도록 했다. 세션 리스트 중 하나를 클릭하면 SessionList Widget BP에 담겨있는 Session UID를 참고해 세션에 참가하도록 설계했다.
하지만 Scroll Box 내부에 세션 리스트가 겹쳐서 생성되거나, 클릭이 안되거나 하는 여러 문제가 생겼었다.
대부분 해결했지만 현재 해결해야할 문제는 다음과 같다.
1. SessionList 내부 UID를 사용해 세션 참가 요청을 보내지만 일치하는 UID가 없다고 세션에 참가하지 못하는 문제
2. 클릭한 UI에 강조표시가 나타나지 않는 문제
3. 세션 리스트 중 하나를 클릭하고, 다른 세션을 클릭했을 때 선택된 세션의 정보로 데이터가 바뀌지 않는 문제
프로젝트 폴더에 GameData 폴더를 만들고, 엑셀 파일을 UTF-8의 csv 파일로 저장
위의 CSV 파일을 사용하기 위해 인덱스 이름과 동일한 이름을 가진 구조체를 생성
-> 소스파일 폴더에서 구조체 헤더파일 작성 -> Generated project file 실행해 헤더파일을 프로젝트에 추가
엑셀 데이터의 임포트
DataAsset과 유사하게 FTableRowBase를 상속받은 구조체를 선언
엑셀의 Name 컬럼 (얘가 key값이 된다)을 제외한 컬럼과 동일하게 UPROPERTY 속성 선언
엑셀 데이터를 csv로 익스포트 한 후 언리얼 엔진에 임포트
기타->DataTable로 생성 후 Reimport를 클릭해 위에서 만든 Csv 파일을 가져온다
데이터를 관리할 싱글톤 클래스의 설정
언리얼 엔진에서 제공하는 싱글톤 클래스
게임 인스턴스
에셋 매니저
게임 플레이 관련 액터 (GameMode, GameState)
프로젝트에 싱글톤으로 등록한 언리얼 오브젝트
언리얼 오브젝트 생성자에서 사용하지 않도록 주의
위의 사진에 싱글톤 클래스를 설정하면 엔진이 초기화 될 때 자동으로 GEngine이라는 전역변수에 싱글톤을 만들어줌
// ABGameSingleton.h// Fill out your copyright notice in the Description page of Project Settings.#pragma once#include"CoreMinimal.h"#include"UObject/NoExportTypes.h"#include"ABCharacterStat.h"#include"ABGameSingleton.generated.h"DECLARE_LOG_CATEGORY_EXTERN(LogABGameSingleton, Error, All);
/**
*
*/UCLASS()
classARENABATTLE_APIUABGameSingleton :public UObject
{
GENERATED_BODY()
public:
UABGameSingleton();
static UABGameSingleton& Get();
// Character Stat Data Sectionpublic:
FORCEINLINE FABCharacterStat GetCharacterStat(int32 InLevel)const{ return CharacterStatTable.IsValidIndex(InLevel) ? CharacterStatTable[InLevel] : FABCharacterStat(); }
UPROPERTY()
int32 CharacterMaxLevel;
private:
// Stat Data 내부 보관용 배열 - 추후 필요한 객체들에게 이 배열로 뿌림
TArray<FABCharacterStat> CharacterStatTable;
};
// ABGameSingleton.cpp// Fill out your copyright notice in the Description page of Project Settings.#include"GameData/ABGameSingleton.h"DEFINE_LOG_CATEGORY(LogABGameSingleton);
UABGameSingleton::UABGameSingleton()
{
// UDataTable -> (Key, Value)의 한쌍으로 이루어짐static ConstructorHelpers::FObjectFinder<UDataTable> DataTableRef(TEXT("/Script/Engine.DataTable'/Game/ArenaBattle/GameData/ABCharacterStatTable.ABCharacterStatTable'"));
if (nullptr != DataTableRef.Object)
{
const UDataTable* DataTable = DataTableRef.Object;
check(DataTable->GetRowMap().Num() > 0);
TArray<uint8*> ValueArray;
DataTable->GetRowMap().GenerateValueArray(ValueArray);
// Algo의 Transform 알고리즘으로 value값만 가져오도록 설계
Algo::Transform(ValueArray, CharacterStatTable,
[](uint8* Value)
{
return *reinterpret_cast<FABCharacterStat*>(Value);
}
);
}
CharacterMaxLevel = CharacterStatTable.Num();
ensure(CharacterMaxLevel > 0);
}
UABGameSingleton& UABGameSingleton::Get(){
// 엔진 초기화시 바로 활성화 되기 때문에 Get 함수로 받을 수 없다면 잘못된것이므로 CastCheck로 형변환 됬는지 강력히 검사
UABGameSingleton* Singleton = CastChecked<UABGameSingleton>(GEngine->GameSingleton);
if (Singleton) {
return *Singleton;
}
UE_LOG(LogABGameSingleton, Error, TEXT("Invalid Game Singleton"));
// 코드 흐름을 위해 작성한것일 뿐 실제 여기까지 가는 일이 일어나지 않도록 앞에서 철저히 검사return *NewObject<UABGameSingleton>();
}
싱글톤 클래스의 로직. 싱글톤 클래스가 엔진 초기화시 제대로 생성되었는지 검사하며, 요청한 객체가 잘 쓸수 있도록 생성자에서 DataTable의 value들을 가져온다.
프로젝트의 주요 레이어
게임 레이어: 기믹과 NPC
미들웨어 레이어: 캐릭터의 스탯 컴포넌트, 아이템 박스
데이터 레이어: 스탯 데이터, 데이터 관리를 위한 싱글톤 클래스
위에서 아래로만 참조하도록
캐릭터 스탯 시스템
위의 구조대로 Stat Component가 Base Stat과 무기의 능력치에 따른 modifier Stat을 합산하여 반영하도록 ABCharacterStatComponent의 구조를 변경, 아래의 함수와 변수를 추가
// ABStageGimmick.h// Fight Sectionprotected:
// 언리얼에서 제공하는 TSubclassOf 템플릿 사용. 지정한 클래스로부터 상속받은 클래스 목록만 표시되도록 한정해서 지정 가능// NPC 구현시 블루프린트로 확장해 다양한 캐릭터 만들 때, 모든 액터의 목록 조사하지 않도록 클래스 정보 한정시킨다.UPROPERTY(EditAnywhere, Category = Fight, Meta = (AllowPrivateAccess = "true"))
TSubclassOf<classAABCharacterNonPlayer> OpponentClass;
UPROPERTY(EditAnywhere, Category = Fight, Meta = (AllowPrivateAccess = "true"))
float OpponentSpawnTime; // NPC가 바로 스폰되지 않도록 Delay 걸어줄 변수// NPC 죽었을 때 보상단계로 진입시키기 위한 함수UFUNCTION()
voidOnOpponentDestroyed(AActor* DestroydActor);
FTimerHandle OpponentTimerHandle;
voidOnOpponentSpawn();
// ABStageGimmick.cppvoidAABStageGimmick::OnStageTriggerBeginOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult){
SetState(EStageState::FIGHT);
}
voidAABStageGimmick::OnGateTriggerBeginOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult){
check(OverlappedComponent->ComponentTags.Num() == 1); // GateSockets 이름으로 달아준 OverlappedComponent의 태그 검증
FName ComponentTag = OverlappedComponent->ComponentTags[0];
FName SocketName = FName(*ComponentTag.ToString().Left(2)); // 태그를 받아서 왼쪽 두개만 잘라낸다 (+X, +Y 등)check(Stage->DoesSocketExist(SocketName));
FVector NewLocation = Stage->GetSocketLocation(SocketName); // 소켓 위치를 태그 이름을 통해 얻음
TArray<FOverlapResult> OverlapResults;
FCollisionQueryParams CollisionQueryParam(SCENE_QUERY_STAT(GateTrigger), false, this); // this -> 자신을 제외한 상태에서 검사// OverlapMultiByObjectType 함수를 사용해 해당 위치에 무언가 배치되어 있는지 검사bool bResult = GetWorld()->OverlapMultiByObjectType(
OverlapResults,
NewLocation,
FQuat::Identity,
FCollisionObjectQueryParams::InitType::AllStaticObjects, // 모든 스태틱 오브젝트 타입에 대해
FCollisionShape::MakeSphere(775.0f), // 해당 위치에 큰 구체 생성
CollisionQueryParam
);
if (!bResult) { // 검사한 위치에 아무것도 없을 시GetWorld()->SpawnActor<AABStageGimmick>(NewLocation, FRotator::ZeroRotator);
}
}
voidAABStageGimmick::OnOpponentDestroyed(AActor* DestroydActor){
SetState(EStageState::REWARD);
}
voidAABStageGimmick::OnOpponentSpawn(){
const FVector SpawnLocation = GetActorLocation() + FVector::UpVector * 88.0f;
AActor* OpponentActor = GetWorld()->SpawnActor(OpponentClass, &SpawnLocation, &FRotator::ZeroRotator);
AABCharacterNonPlayer* ABOpponentCharacter = Cast<AABCharacterNonPlayer>(OpponentActor);
if (ABOpponentCharacter) {
ABOpponentCharacter->OnDestroyed.AddDynamic(this, &AABStageGimmick::OnOpponentDestroyed);
}
}
플레이어와 싸울 NPC를 ABCharacterNonPlayer를 상속받아 구현, NPC의 스폰 로직 및 전투 후 State 변경 함수 구현
Gate 트리거 함수를 작성. 문을 통과하는 시점에서 전방 구역을 검사해 아무것도 배치되있지 않다면 맵을 생성
Trigger의 충돌은 CPROFILE_ABTRIGGER로 ABCollision.h에 선언했던걸 사용
OnComponentBeginOverlap 델리게이트는 컴포넌트간에 겹칠때 호출되는 이벤트. 워프포인트, 캐릭터의 히트판정시 데미지 구현 등등 많이 쓴다고 하니 잘 기억해두자.
OnComponentBeginOverlap에 바인딩한 OnOverlapBegin 함수에서는 Effect를 true로 설정해 재생하고, 발동한 상자 메시를 지우고 Collision을 없앤다. Effect 끝난 뒤 OnSystemFinished 델리게이트를 다시 바인딩
OnSystemFinished 델리게이트가 실행되면 최종적으로 OnEffectFinished 함수에서 이펙트를 없앤다.
이 아이템 상자에 아이템 정보를 넣어 무기를 습득하도록 구현
// ABItemData.h// Fill out your copyright notice in the Description page of Project Settings.#pragma once#include"CoreMinimal.h"#include"Engine/DataAsset.h"#include"ABItemData.generated.h"/**
*
*/UENUM(BlueprintType)
enumclassEItemType : uint8 {
Weapon = 0,
Potion,
Scroll
};
UCLASS()
classARENABATTLE_APIUABItemData :public UPrimaryDataAsset
{
GENERATED_BODY()
public:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = Type)
EItemType Type;
};
아이템 데이터를 에셋으로 관리할 수 있도록 PrimaryDataAsset 상속받은 ItemData 클래스 생성
// ABWeaponItemData.h// Fill out your copyright notice in the Description page of Project Settings.#pragma once#include"CoreMinimal.h"#include"Item/ABItemData.h"#include"ABWeaponItemData.generated.h"/**
*
*/UCLASS()
classARENABATTLE_APIUABWeaponItemData :public UABItemData
{
GENERATED_BODY()
public:
UPROPERTY(EditAnywhere, Category = Weapon)
TObjectPtr<USkeletalMesh> WeaponMesh;
};
ItemData를 상속받은 WeaponItemData 클래스 생성해 확장 구현
별도로 프로젝트 폴더에 Item 클래스들을 관리하기 위한 폴더 생성, 각각 DataAsset 생성
프로젝트에서 사용할 아이템 에셋
총 3가지 종류의 아이템 타입 지정
무기 타입: 캐릭터에 무기 부착(무기에 의한 부가 스탯 강화)
포션 타입: 캐릭터의 HP 회복
스크롤 타입: 캐릭터의 기본 스탯 상승
실제 스탯 구현은 차후 강좌
프로젝트의 주요 레이어
데이터 레이어: 게임을 구성하는 기본 데이터 (스탯 정보, 캐릭터 레벨 테이블 등)
미들웨어 레이어: 게임에 사용되는 미들웨어 모듈 (UI, 아이템, 애니메이션, AI 등)
게임 레이어: 게임 로직을 구체적으로 구현하는데 사용 (캐릭터, 게임모드 등)
위에서 아래로는 직접 참조하되, 아래에서 위로는 인터페이스를 통해 접근하도록 설정
ex) 아이템을 먹어 캐릭터가 행동을 수행하도록 아래에서 위로 명령을 내리는 상황 -> 인터페이스 사용
ABCharacterItemInterface 클래스를 생성해 TakeItem 가상함수 생성
// ABCharacterItemInterface.h// Fill out your copyright notice in the Description page of Project Settings.#pragma once#include"CoreMinimal.h"#include"UObject/Interface.h"#include"ABCharacterItemInterface.generated.h"// This class does not need to be modified.UINTERFACE(MinimalAPI)
classUABCharacterItemInterface :public UInterface
{
GENERATED_BODY()
};
/**
*
*/classARENABATTLE_APIIABCharacterItemInterface
{GENERATED_BODY()
// Add interface functions to this class. This is the class that will be inherited to implement this interface.public:
virtualvoidTakeItem(class UABItemData* InItemData)= 0;
};
ItemBox 클래스에 Item 언리얼 오브젝트 포인터 변수 추가
OnOverlapBegin 함수에 아이템 습득 추가
voidAABItemBox::OnOverlapBegin(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepHitResult){
if (nullptr == Item) {
Destroy();
return;
}
IABCharacterItemInterface* OverlappingPawn = Cast<IABCharacterItemInterface>(OtherActor);
if (OverlappingPawn) {
OverlappingPawn->TakeItem(Item);
}
Effect->Activate(true);
Mesh->SetHiddenInGame(true);
SetActorEnableCollision(false);
// Effect 끝난 뒤 델리게이트 사용
Effect->OnSystemFinished.AddDynamic(this, &AABItemBox::OnEffectFinished);
}
CharacterBase 클래스에 TakeItem 가상함수 구현 및 아이템 종류별 행동 구현할 Delegate 및 구조체 선언, Delegate에 바인딩 할 가상함수 3개 선언
델리게이트를 이용해 서로의 존재를 모른 채 자기일만 하는 컴포넌트끼리 데이터를 주고받게 함
컴포넌트의 느슨한 결합
HPBar 생성
Widget Blueprint에서 Vertical Box에 Progressbar를 자식으로 추가
Fill 옵션 선택해 채우고, 적당히 크기 조절. 어차피 외부에서 크기를 조절한다.
UserWidget을 상속받은 ABHpBarWidget C++ 클래스를 만들어 준 후, WBP HpBar의 Parent Class를 C++ 클래스로 바꿔준다
BP 그래프를 쓰지않고 C++ 클래스에서 로직을 구성
ABCharacterStatComponent에서 Hp의 변경에 따른 로직들을 구현한다.
// ABCharacterStatComponent.h// Fill out your copyright notice in the Description page of Project Settings.#pragma once#include"CoreMinimal.h"#include"Components/ActorComponent.h"#include"ABCharacterStatComponent.generated.h"DECLARE_MULTICAST_DELEGATE(FOnHpZeroDelegate);
DECLARE_MULTICAST_DELEGATE_OneParam(FOnHpChangedDelegate, float/*CurrentHp*/);
UCLASS( ClassGroup=(Custom), meta=(BlueprintSpawnableComponent) )
classARENABATTLE_APIUABCharacterStatComponent :public UActorComponent
{
GENERATED_BODY()
public:
// Sets default values for this component's propertiesUABCharacterStatComponent();
protected:
// Called when the game startsvirtualvoidBeginPlay()override;
// Tick 사용하지 않고 필요할때만 함수를 호출해 부하를 최대한 줄인다.public:
FOnHpZeroDelegate OnHpZero;
FOnHpChangedDelegate OnHpChanged;
FORCEINLINE floatGetMaxHp(){ return MaxHp; }
FORCEINLINE floatGetCurrentHp(){ return CurrentHp; }
floatApplyDamage(float InDamage);
protected:
voidSetHp(float NewHp);
UPROPERTY(VisibleInstanceOnly, Category = Stat) // 배치된 캐릭터들 마다 각각 다른값을 사용하게 만듦float MaxHp;
UPROPERTY(Transient, VisibleInstanceOnly, Category = Stat) // 현재 Hp는 게임을 할 때 마다 새롭게 지정이 되므로 Transient 키워드 사용해 디스크에 저장xfloat CurrentHp;
};
// ABCharacterStatComponent.cpp// Fill out your copyright notice in the Description page of Project Settings.#include"CharacterStat/ABCharacterStatComponent.h"// Sets default values for this component's properties
UABCharacterStatComponent::UABCharacterStatComponent()
{
MaxHp = 200.0f;
SetHp(MaxHp);
}
// Called when the game startsvoidUABCharacterStatComponent::BeginPlay(){
Super::BeginPlay();
SetHp(MaxHp);
}
floatUABCharacterStatComponent::ApplyDamage(float InDamage){
constfloat PrevHp = CurrentHp;
constfloat ActualDamage = FMath::Clamp<float>(InDamage, 0, InDamage); // 음수가 들어오지 않도록 처리SetHp(PrevHp - ActualDamage);
if (CurrentHp <= KINDA_SMALL_NUMBER) {
OnHpZero.Broadcast();
}
return ActualDamage;
}
voidUABCharacterStatComponent::SetHp(float NewHp){
CurrentHp = FMath::Clamp<float>(NewHp, 0.0f, MaxHp); // 0보다 크고 MaxHp보단 작도록 처리
OnHpChanged.Broadcast(CurrentHp);
}
ABHpBarWidget 클래스에서는 HpBar Widget의 동작에 관련된 로직을 추가한다.
LineTrace: 지정한 방향으로 선을 투사해 이 선이 어떤 물체와 충돌되는지 파악하는 방법
Sweep: LineTrace와 비슷하지만 선 대신 도형을 투사
Overlap: 지정한 영역에 큰 범위의 도형을 설정해 해당 볼륨 영역과 물체가 충돌되는지 검사
트레이스 채널과 충돌 프로필 생성
액션 판정을 위한 트레이스 채널의 생성: ABAction, 기본 반응은 무시
캐릭터는 두가지 요소 고려
캐릭터 캡슐용 프로필: ABAction 트레이스 채널에 반응, 오브젝트 타입은 Pawn
스켈레탈 메시용 프로필: 랙돌 구현을 위해 주로 활용됨
기믹 트리거용 프로필: 폰 캡슐에만 반응하도록 설정. 오브젝트 타입은 WorldStatic
edit -> project setting -> CollisionTrace Type은 Ignore와 Block만 고려. Object Type은 Overlap까지 세가지 경우 모두 고려Config -> DefaultEngine.ini에 추가된 모습. 코드 내에선 ECC_GameTraceChannel1 이라는 이름을 사용하는것이 더 편하다Anim Notify 클래스를 추가하여 몽타주 에셋의 적절한 프레임을 선택, 넣어준다.
월드 트레이싱 함수의 선택
세가지 카테고리로 원하는 함수 이름을 얻을 수 있음
카테고리 1: 처리 방법
카테고리 2: 대상
Test: 무언가 감지되었는지를 테스트
Single 또는 AnyTest: 감지된 단일 물체 정보를 반환
Multi: 감지된 모든 물체 정보를 배열로 반환
카테고리3: 처리 설정
ByChannel: 채널 정보를 사용해 감지
ByObjectType: 물체에 지정된 물리 타입 정보를 사용해 감지
ByProfile: 프로필 정보를 사용해 감지
{처리방법}{대상}{처리설정}
캐릭터 공격 판정의 구현
캐릭터의 위치에서 시선 방향으로 물체가 있는지 감지
작은 구체를 제작하고 시선 방향으로 특정 거리까지만 투사 (Sweep 사용)
하나의 물체만 감지
트레이스 채널을 사용해 감지
그러므로 사용할 함수는 {처리방법}{대상}{처리설정} -> {Sweep}{Single}{ByChannel}