Single FPS Project(최종)

정혜창·2025년 3월 6일
1

내일배움캠프

목록 보기
35/43
post-thumbnail

🎮 프로젝트 개요

1️⃣ 요약

프로젝트 명 : GunFire_Paragon
개발 일정 : 2025.02.17 ~ 2025.03.07 (3Weeks)
개발 장르 : FPS, 로그라이크
개발 환경 :

  • C++ 17 (v14.38)
  • MSVC v143
  • Unreal Engine 5.5
  • Visual Studio 2022
  • Git LFS

    개발 특징 :
  • Unreal 3D Shooting Game
  • 객체 프로그래밍 형식 OOP 기반
  • 추후 멀티플레이어 확장성까지 고려한 프로그래밍
  • Git Lfs와 Cmd Git, Git Convention 을 활용한 협업구조 형성 (Rebase Merge 형식)
  • Object Pooling 및 GC를 통한 최적화된 코드 및 효율적임 메모리 사용

🎮 나의 역할


내가 프로젝트에서 맡은 역할은 게임 기획, GameMode전반 로직 구현 (GameState, GameMode, GameInstance), PortalClass(ClearPortal, TrapPortal 구현), PassiveCard 구현, 맵 디자인을 담당하였다.

1️⃣ 게임 기획

👉 GunfireReborn 게임을 모티브로 하여 기획하였다.

📌 게임 진행 방식

  • 5개의 스테이지
    • 1~4 스테이지 : 일반 몬스터
      • 1 스테이지 : (일반 근거리) 5~10 마리
      • 2 스테이지 : (일반 근거리, 일반 원거리) 10~15 마리
      • 3 스테이지 : (일반 근거리 & 원거리 / 정예 근거리) 15~20 마리
      • 4 스테이지 : (일반 근거리 & 원거리 / 정예 근거리 & 원거리) 20~25마리
    • 5스테이지 : 보스 등장
  • 보스 클리어 시 게임 클리어
  • 사망 시 회귀하여 메인메뉴 -> 강화된 캐릭터로 다시 1스테이지부터 시작(로그라이크)

📌 보상 시스템

  • 레벨 업 & 트랩레벨 클리어시 주어지는 3개의 카드.
    • 플레이어가 원하는 카드를 한 장을 선택
    • 해당 카드를 통해 무기 or 유저를 업그레이드
    • 나오는 카드종류, 레어도 랜덤
  • 카드(패시브) 시스템
    • 무기 공격력 증가 Normal, Rare, Legendary
    • 무기 공격속도 감소 Normal, Rare, Legendary
    • 캐릭터 이동속도 증가 Normal, Rare, Legendary
    • 캐릭터 최대체력 증가 Normal, Rare, Legendary
    • 캐릭터 최대쉴드 증가 Normal, Rare, Legendary
    • 캐릭터 쉴드리젠타임 감소 Normal, Rare, Legendary
    • 캐릭터 쉴드회복량 증가 Normal, Rare, Legendary

📌 캐릭터 시스템

  • 캐릭터 시스템
    • 점프(space), 대시(shift), 기본공격(마우스 좌클릭),
    • 무기 1,2번 저장, 더블점프, 장전(R), 상호작용(G)

📌 추가기능

  • 스테이지 중간 중간 트랩 스테이지 배치
  • 여러가지 종류의 총 구현
  • 게임 클리어 시 엔딩크래딧

2️⃣ GameMode Logic Implement

나의 작업 브랜치 : https://github.com/NbcampUnreal/1st-Team1-CH3-Project/tree/gamemode/04

📌 GameMode UML Diagram



🎮 Tech Stack

👉 Map Design / Enemy Object Pooing(DataTable) / GameInstance(Level Conversion Property Save & Load) / Portal Implement / GameReward System


1️⃣ Map Design


  • 언리얼 내의 ModelingMode를 활용하여 디자인

    • 다양한 모양의 Cube, CubeGrid, PlaneCut, MeshCut, MeshMerge기능 등을 활용하여 디자인
  • 언제든지 원하는 Texture과 Material을 적용할 수 있어서 외부 Assets 과 톤 앤 매너를 유지하는데 더 자유로움

  • 생각보다 AI와 Player이 맵이 끼는 버그가 자주 발생

    • 처음 월드에 등장하는 액터들을 뷰포트에 올려둔 뒤 넉넉하게 레벨 디자인을 해야겠다고 생각
  • CubeGrid를 적용할 때 액터를 선택한 뒤 추가하거나 삭제하면 Static Mesh에 곧바로 적용됨.

  • 뷰포트 Layout Directory를 항상 정리하면서 Mesh설정해야 나중에 복잡하지 않고 찾기 쉬움


2️⃣ Enemy Object Pooing

📌 DataTable

DataTable을 활용하여 적의 종류와 개체 수를 Stage마다 다르게 Spawn & Pooling 되도록 함.

  • 첫 번째 사진은 EnemyPool에 얼마만큼의 적을 저장할 지 Initialize를 설정하는 DataTable

    • Raw에서 적의 종류와 EnemyCount 값을 받아서 ObjectPool에 저장
  • 두 번째 사진은 저장된 ObjectPool에서 얼마만큼의 적을 GetPool 할지 나타내는 DataTable

    • Raw에서 적의 종류, 개체 수, GetPool되는 Stage Index 값을 받아옴
  • 🔥 하드 코딩하지 않고 DataTable로 관리함으로써 코드 수정없이 에디터내에서 간단하게 수정할 수 있다는데 의의
    → 기획자가 편하게 게임 설계가능
📋 FPSGameMode.cpp
TMap<TSubclassOf<ABaseEnemy>, int32> AFPSGameMode::GetPoolInitializationData()
{
	TMap<TSubclassOf<ABaseEnemy>, int32> PoolData;

	if (!AIDataTable) return PoolData;

	static const FString ContextString(TEXT("PoolInitializationContext"));
	TArray<FAIEnemyPoolRaw*> AllRows;
	AIDataTable->GetAllRows(ContextString, AllRows);
	UE_LOG(LogTemp, Warning, TEXT("Sucess DT"));

	for (FAIEnemyPoolRaw* Raw : AllRows)
	{
		if (Raw)
		{
			PoolData.Add(Raw->EnemyClass, Raw->InitEnemyCount);
		}
		UE_LOG(LogTemp, Warning, TEXT("Find Row"));
	}
	return PoolData;
}
  
----------------------------------------------------------------------------------------------

  TMap<TSubclassOf<ABaseEnemy>, int32> AFPSGameMode::GetEnemySpawnData(int32 StageNumber)
{
	TMap<TSubclassOf<ABaseEnemy>, int32> EnemyData;
	if (!EnemySpawnTable) return EnemyData;

	static const FString ContextString(TEXT("EnemySpawnContext"));
	TArray<FAIEnemySpawnRaw*> AllRows;
	EnemySpawnTable->GetAllRows(ContextString, AllRows);
	UE_LOG(LogTemp, Warning, TEXT("Sucess Get DTspawnData! AllRows.Num : %d"), AllRows.Num());![](https://velog.velcdn.com/images/hch9097/post/914a587e-68ff-45b6-88f9-48ca9771fe4b/image.png)


	for (FAIEnemySpawnRaw* Row : AllRows)
	{
		if (Row && Row->StageNumber == StageNumber)
		{
			if (EnemyData.Contains(Row->EnemyClass))
			{
				EnemyData[Row->EnemyClass] += Row->EnemyCount;
			}
			else
			{
				EnemyData.Add(Row->EnemyClass, Row->EnemyCount);
			}
		}
	}
	return EnemyData;
}
  • 위의 함수는 첫 번째 사진의 DataTable에서 데이터를 받아 TMap으로 선언된 PoolData에 저장하는 로직이다.

    • 차후 언급할 InitializeObjectPool() 메소드에서 ObjectPoolInstance에 PoolData를 넘겨준다.
  • 두 번째 함수는 StageNumber을 매개변수로 들고와서 두 번째 사진의 DataTable의 StageNumber과 비교하여 같으면 해당 Raw의 EnemyClass와 GetPool할 EnemyCount를 EnemyData에 저장하는 로직이다.

    • 차후 언급할 SpawnEnemiesForStage(int32 StageNumber) 에서 호출되어 ObjectPoolInstance에 값을 넘겨준다.

📌 ObjectPool

적이 많이 소환되면 재사용할 수 있는 ObjectPooling 방식을 통해 적 AI를 Spawn

📋AIObjectPool.cpp
void AAIObjectPool::InitializePool(TMap<TSubclassOf<ABaseEnemy>, int32> EnemyClasses)
{
   .
   .
   .
	FActorSpawnParameters SpawnParams;
	SpawnParams.SpawnCollisionHandlingOverride =
    ESpawnActorCollisionHandlingMethod::AdjustIfPossibleButAlwaysSpawn;
   
	for (int32 i = 0; i < Count; i++)
	{
		ABaseEnemy* NewEnemy = GetWorld()->SpawnActor<ABaseEnemy>(
			EnemyClass,
			SpawnParams
			);
	
		if (NewEnemy->IsValidLowLevelFast())
		{
			NewEnemy->SetCanAttack(false);
			NewEnemy->SetActorHiddenInGame(true);
			NewEnemy->SetActorEnableCollision(false);
			NewEnemy->SetActorTickEnabled(false);

			if (Pool.IsValid())
			{
				Pool->Add(NewEnemy);
				UE_LOG(LogTemp, Warning, TEXT("Pool Save Data %d"), Pool->Num())
			}
   		}
    }
}
 
-----------------------------------------------------------------------------------------
ABaseEnemy* AAIObjectPool::GetPooledAI(ASpawnVolume* SpawnVolume, TSubclassOf<ABaseEnemy> EnemyClass)
{
	if (!SpawnVolume || !EnemyClass) return nullptr;

	if (EnemyPools.Contains(EnemyClass))
	{
		UE_LOG(LogTemp, Warning, TEXT("EnemyPools Contains BaseEnemyClass"));
		
		Pool = EnemyPools[EnemyClass];

		if (Pool.IsValid())
		{
			UE_LOG(LogTemp, Warning, TEXT("Pool Is Valid"));	
			for (ABaseEnemy* Enemy : *Pool)
			{
				if (!Enemy->IsActorTickEnabled())
				{
					FVector SpawnLocation = SpawnVolume->GetSafeSpawnPoint();
					Enemy->SetActorLocation(SpawnLocation);
					Enemy->SetCanAttack(true);
					Enemy->ResetEnemy();
					UE_LOG(LogTemp, Log, TEXT("%s spawned from pool"), *EnemyClass->GetName());
					return Enemy;
				}
			}
		}
		else
		{
			UE_LOG(LogTemp, Warning, TEXT("Pool is Nullptr"));
		}
	}
	return nullptr;
}
  • 첫 번째 메소드는 ObjectPoolInstance에서 InitializePool 하는 단계.

    • EnemyObject를 월드상에 스폰한 뒤 비활성화 하는 로직.
    • 최종적으로 ObjectPoolInstance 안에 있는 Pool에 몬스터 클래스들이 들어감.
  • 두 번째 메소드는 Pool에서 월드상에 활성화를 시켜주는 로직.

    • SpawnVolume 클래스에서 스폰 시 안전한 위치와, VolumeBox안에서 스폰되도록 하는 GetSafeSpawnPoint()를 호출하여 위치 조정.

🔥 필요한 순간에 Pool에서 꺼내어 활성화 되었다가 죽으면 다시 Pool에 저장하고 비활성화 되는 방식으로 새로운 Enemy를 스폰하는 것이 아니라 재사용이 되는 것으므로 최적화에 매우 유리
🔥 또한 AIObjectPool을 관리하는 클래스를 따로 만들어서 코드 수정과 재사용성이 높아짐


3️⃣ GameInstance

언리얼에서는 기본 레벨 오프너 함수인 OpenLevel을 사용하여 Level이 전환되면 Engine에서 관리하는 최상위 객체인 GameInstance 외엔 모두 초기화가 된다. 기획에 따라 Stage마다 레벨이 전환 되므로 PlayerStatus, WeaponStatus, LevelIndex 등 유지가 필요한 Property들은 GameInstance 에서 저장하고 로드 시켜줘야 한다.

📌 PlayerStatus

📋 GameInstance.cpp
void UFPSGameInstance::SavePlayerStats(ACharacter* PlayerCharacter)
{
	if (PlayerCharacter)
	{
		APlayerCharacter* Char = Cast<APlayerCharacter>(PlayerCharacter);
		if (Char)
		{
			MaxHP = Char->GetMaxHealth();
			MaxShield = Char->GetMaxShield();
			ShieldRegenDelay = Char->GetShieldRegenDelay();
			ShieldRegenRate = Char->GetShieldRegenRate();
			NormalSpeed = Char->GetNormalSpeed();
			DashCoolDown = Char->GetDashCoolDown();
		}
	}
}
------------------------------------------------------------------------------------
void UFPSGameInstance::LoadPlayerStats()
{
	UWorld* World = GetWorld();
	APlayerController* PlayerController = World->GetFirstPlayerController();
	if (PlayerController)
	{
		AMyPlayerController* FPSPlayerController = Cast<AMyPlayerController>(PlayerController);
		if (FPSPlayerController)
		{
			APlayerCharacter* PlayerCharacter = Cast<APlayerCharacter>(FPSPlayerController->GetPawn());
			if (PlayerCharacter)
			{
				APlayerCharacter* Char = Cast<APlayerCharacter>(PlayerCharacter);
				if (Char)
				{
					Char->SetMaxHealth(MaxHP);
					Char->SetMaxShield(MaxShield);
					Char->SetShieldRegenDelay(ShieldRegenDelay);
					Char->SetShieldRegenRate(ShieldRegenRate);
					Char->SetNormalSpeed(NormalSpeed);
					Char->SetDashCoolDown(DashCoolDown);
				}
			}
		}
	}	
}
  • PassiveCard로 인해 변하는 Player Property들은 Instance에 저장한다.

  • LoadPlayerStats()Player.cpp 의 BeginPlay() 에서 호출되어 레벨이 전환될 때 GameInstance에서 저장된 값들을 로드한다.


📌 WeaponStatus

📋 GameInstance.cpp
void UFPSGameInstance::SaveWeaponStats(APlayerCharacter* Player)
{
	{
		if (!Player) return;

		if (Player->Inventory[0])
		{
			PrimaryWeaponClass = Player->Inventory[0]->GetClass();
			PrimaryGunDamage = Player->Inventory[0]->GetGunDamage();
			PrimaryDelay = Player->Inventory[0]->GetGunDelay();
			PrimaryAmmo = Player->Inventory[0]->GetMaxAmmo();
		}

		if (Player->Inventory[1])
		{
			SecondaryWeaponClass = Player->Inventory[1]->GetClass();
			SecondGunDamage = Player->Inventory[1]->GetGunDamage();
			SecondDelay = Player->Inventory[1]->GetGunDelay();
			SecondAmmo = Player->Inventory[1]->GetMaxAmmo();
		}
	}
}
--------------------------------------------------------------------
void UFPSGameInstance::LoadWeaponStats(APlayerCharacter* Player)
{
	if (!Player) return;

	UWorld* World = Player->GetWorld();
	if (!World) return;

	if (PrimaryWeaponClass)
	{
		ACGunBase* NewPrimaryWeapon = World->SpawnActor<ACGunBase>(PrimaryWeaponClass);
		if (NewPrimaryWeapon)
		{
			Player->Inventory[0] = NewPrimaryWeapon;
			Player->CurrentWeapon = NewPrimaryWeapon;
			Player->CurrentWeaponSlot = 0;
			Player->AttachWeaponToHand(NewPrimaryWeapon, 0);
			Player->Inventory[0]->SetGunDamage(PrimaryGunDamage);
			Player->Inventory[0]->SetGunDelay(PrimaryDelay);
			Player->Inventory[0]->SetMaxAmmo(PrimaryAmmo);
		}
	}

	if (SecondaryWeaponClass)
	{
		ACGunBase* NewSecondaryWeapon = World->SpawnActor<ACGunBase>(SecondaryWeaponClass);
		if (NewSecondaryWeapon)
		{
			Player->Inventory[1] = NewSecondaryWeapon;
			Player->Inventory[1]->SetGunDamage(SecondGunDamage);
			Player->Inventory[1]->SetGunDelay(SecondDelay);
			Player->Inventory[1]->SetMaxAmmo(SecondAmmo);
		}
	}
}
  • Weapon은 플레이어에서 2개를 장착하므로 따로 저장

    • Player에서 Power나 AttackSpeed를 가지고 있는 것이 아니라 Weapon 클래스에서 Damge와 Delay로 가지고 있기 때문에 Player->Inventory[n]-> 를 통해 Weapon의 변수를 가져온다.
    • 최대탄창인 MaxAmmo 까지 GameInstance Property에 저장한다.
  • LoadWeaponStats() 은 Weapon클래스에 있는 Setter 함수를 통해 설정하고 Player의 BeginPlay함수에서 호출하여 장착 무기에 저장한 변수들이 로드 될 수 있도록 함.

🔥 처음엔 어떻게 저장을 해야될까 막막했지만 막상 Instance에 저장하는 연습을 하니 그렇게 어렵지 않고 레벨 전환 시에 어떻게 게임상황을 유지할지 알게 되었다.
🔥 멀티 플레이어 환경에서는 SeamlessTravel, SeverTravel, PlayerState 를 통해서도 유지가 가능하다고 한다.

4️⃣ Portal Implement

레벨 전환 시 Portal로 이동하는 형식 게임의 큰 흐름을 이어주는 ClearPortal과 스테이지 중간중간 들어갈 수 있는 TrapPortal로 나뉘어짐

📌 ClearPortal

  • 적을 처치하면 ClearPortal이 생성되게 설계

  • 적이 Pooling되면 GameState에서 관리하는 RemainEnemyCount가 올라가고
    모두 처치하면 OnStageClear()이 호출되면서 ClearPotal이 생성되는 방식

  • ClearPortal의 생성위치를 코드에서 지정해주게 되면 맵이 수정되거나, 포탈의 위치를 옮길 때
    매번 코드를 수정해야하는 불편함

    • 따라서 TargetPoint를 이용하여 에디터 상에서 편하게 위치 이동이 가능
📋 FPSGameMode.cpp
void AFPSGameMode::SpawnPortal()
{
	bPortalSpawned = true;

	AActor* PortalSpawnPoint = nullptr;
	TArray<AActor*> FoundActors;
	UGameplayStatics::GetAllActorsOfClass(GetWorld(), AClearPortalPoint::StaticClass(), FoundActors);

	for (AActor* Actor : FoundActors)
	{
		if (Actor && Actor->ActorHasTag(FName("ClearPortalPoint")))
		{
			PortalSpawnPoint = Actor;
			break;
		}
	}

	if (PortalSpawnPoint)
	{
		UE_LOG(LogTemp, Warning, TEXT("Portal Spawn!"));

		AActor* SpawnedPortal = GetWorld()->SpawnActor<AClearPortal>(
			PortalClass,
			PortalSpawnPoint->GetActorLocation(),
			FRotator::ZeroRotator
		);
	}
}
  • TargetPoint의 HasTag를 찾아서 해당 위치에서 Spawn하도록 설계된 로직

  • ClearPortal은 Overlap되면 OpenLevel이 호출되도록 함


📌 TrapPortal

  • Stage 중간 숨겨진 방에 TrapPortal 구현

  • TrapLevel 들어가기전에 PlayerLocation을 저장하고 GameInstance에 저장.

    • 클리어한 후 TrapPortal을 타면 들어갔던 위치로 캐릭터 위치 지정.
📋 TrapPortalType.cpp, FPSGameMode.cpp
UENUM(BlueprintType)
enum class ETrapPortalTypes : uint8
{
	TravelToTrap UMETA(DisplayName = "TravelToTrap"),
	ReturnToStage UMETA(DisplayName = "ReturnToStage")
};

UENUM(BlueprintType)
enum class ETrapPortalAction : uint8
{
	Entry UMETA(DisplayName = "Entry"),
	Exit UMETA(DisplayName = "Exit")
};

void AFPSGameMode::SpawnTrapPortals()
{	
	UFPSGameInstance* FPSGameInstnace = Cast<UFPSGameInstance>(UGameplayStatics::GetGameInstance(this));
	if (!FPSGameInstnace) return;

	AActor* TrapPortalSpawnPoint = nullptr;
	TArray<AActor*> FoundActors;
	UGameplayStatics::GetAllActorsOfClass(GetWorld(), ATrapPortalPoint::StaticClass(), FoundActors);
	for (AActor* Actor : FoundActors)
	{
		if (Actor && Actor->ActorHasTag(FName("TrapPortalPoint")))
		{
			UE_LOG(LogTemp, Warning, TEXT("Found Actor: %s"), *Actor->GetFName().ToString());
			TrapPortalSpawnPoint = Actor;
			break;
		}
	}

	if (TrapPortalSpawnPoint)
	{
		FActorSpawnParameters SpawnParams;
		ATrapPortal* SpawnedPortal = GetWorld()->SpawnActor<ATrapPortal>(
			TrapPortalClass,
			TrapPortalSpawnPoint->GetActorLocation(),
			FRotator::ZeroRotator,
			SpawnParams
			);

		if (SpawnedPortal)
		{
			if (ATrapPortalPoint* TrapPortalPoint = Cast<ATrapPortalPoint>(TrapPortalSpawnPoint))
			{
				if (TrapPortalPoint->PortalAction == ETrapPortalAction::Entry)
				{
					SpawnedPortal->PortalType = ETrapPortalTypes::TravelToTrap;
				}
				else if (TrapPortalPoint->PortalAction == ETrapPortalAction::Exit)
				{
					SpawnedPortal->PortalType = ETrapPortalTypes::ReturnToStage;
				}
			}
		}
	}
}
  • ClearPortal과 마찬가지로 TargetPoint 의 위치에서 생성되도록 설계
  • 하나의 Potal클래스와 TargetPoint로 TrapMap으로 들어가는 역할과 나오는 역할을 모두 하도록
    Enum을 활용해서 설계.

🔥 차후 역할이 늘어나도 따로 클래스를 생성하지 않고 Enum에 추가만 하면 되므로 코드가 간결해지고 확장성이 용이

5️⃣ GameRewardSystem

  • 게임은 플레이하는 유저에게 동기부여를 하는 것이 중요.
  • 보스몹을 깬다는 최종 목표에 도달할 수 있도록 Player의 Status를 업그레이드 할 수 있도록 PassiveCard를 구현.
    • 레벨 업을 하거나, TrapLevel을 클리어 하면 PassiveCard 3장이 팝업되고 유저가 원하는 능력치를 Get

📌 DataTable

📋 CardData.cpp,
enum class ECardEffectType : uint8
{
    AttackPowerIncrease UMETA(DisplayName = "Attack Power Increase"),
    AttackSpeedIncrease UMETA(DisplayName = "Attack Speed Increase"),
    MoveSpeedIncrease UMETA(DisplayName = "Move Speed Increase"),
    ShieldAmountIncrease UMETA(DisplayName = "Shield Amount Increase"),
    ShieldRegeneTimeDecrease UMETA(DisplayName = "Shield Regene Time Decrease"),
    ShieldRateIncrease UMETA(DisplayName = "Shield Rate Increase"),
    MaxHealthIncrease UMETA(DisplayName = "Max Health Increase"),
    DashCoolTimeDecrease UMETA(DisplayName = "Dash CoolTime Decrese"),
    LastIndex UMETA(Hidden) // Rand 시 최대값 탐색용
};

UENUM(BlueprintType)
enum class EEffectValueType : uint8
{
    FlatValue UMETA(DisplayName = "Flat Value"),
    Percentage UMETA(DisplayName = "Percentage"),
    Duration UMETA(DisplayName = "Duration")
};

UENUM(BlueprintType)
enum class ECardRarity : uint8
{
    Common UMETA(DisplayName = "Common"),
    Rare UMETA(DisplayName = "Rare"),
    Legendary UMETA(DisplayName = "Legendary"),
    LastIndex UMETA(Hidden)
};

USTRUCT(BlueprintType)
struct FCardEffect
{
    GENERATED_BODY()

    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    ECardEffectType EffectType;

    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    EEffectValueType ValueType;

    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    float EffectValues;

    FCardEffect()
        : EffectType(ECardEffectType::AttackPowerIncrease)
        , ValueType(EEffectValueType::FlatValue)
        , EffectValues(0.0f) {
    }

    FCardEffect(ECardEffectType InEffectType, EEffectValueType InValueType, float InEffectValues)
        : EffectType(InEffectType)
        , ValueType(InValueType)
        , EffectValues(InEffectValues) {
    }
};

USTRUCT(BlueprintType)
struct FCardDataTable : public FTableRowBase
{
    GENERATED_BODY()

    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    FString CardName;

    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    FString CardDescription;

    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    ECardRarity Rarity;

    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    FCardEffect CardEffect;
};
  • Enum 클래스를 활용하여 패시브 종류를 설정
  • 여러가지의 Enum 클래스를 만들고 하나의 Struct로 묶음, 그리고 Struct를 DataTable 의 Raw로 사용
    FCardEfect -> FCardDataTable

📌 GameMode

📋 FPSGameMode.cpp
   
UCardData* AFPSGameMode::GetRandomCard()
{
	if (AllCardPool.Num() == 0)
	{
		return nullptr;
	}

	TArray<UCardData*> CommonCards;
	TArray<UCardData*> RareCards;
	TArray<UCardData*> LegendaryCards;

	for (UCardData* Card : AllCardPool)
	{
		switch (Card->Rarity)
		{
		case ECardRarity::Common:
			CommonCards.Add(Card);
			break;
		case ECardRarity::Rare:
			RareCards.Add(Card);
			break;
		case ECardRarity::Legendary:
			LegendaryCards.Add(Card);
			break;
		}
	}

	float Roll = FMath::FRand();

	if (Roll < 0.6f && CommonCards.Num() > 0)
	{
		return CommonCards[FMath::RandRange(0, CommonCards.Num() - 1)];
	}
	else if (Roll < 0.95f && RareCards.Num() > 0)
	{
		return RareCards[FMath::RandRange(0, RareCards.Num() - 1)];
	}
	else if (LegendaryCards.Num() > 0)
	{
		return LegendaryCards[FMath::RandRange(0, LegendaryCards.Num() - 1)];
	}

	return nullptr;
}

TArray<UCardData*> AFPSGameMode::GetRandomCards(int32 CardCount)
{
	TArray<UCardData*> SelectedCards;

	for (int32 i = 0; i < CardCount; i++)
	{
		UCardData* RandomCard = GetRandomCard();
		if (RandomCard)
		{
			SelectedCards.Add(RandomCard);
		}
	}

	return SelectedCards;
}
  • GameMode에서 DataTable의 값을 받아 레어도에 따라 배열에 카드를 저장
  • 이후 FRand 를 통해 레어도에 따라 다른 확률로 카드가 나오게 설정
  • CardCount를 통해 원하는 수 만큼 Card를 화면에 띄우는게 가능

📌 PlayerCharacter

📋 PlayerCharacter.cpp
void APlayerCharacter::ApplyCardEffect(const FCardEffect& SelectedCard)
{
	//if (!SelectedCard) return;

	FCardEffect Effect = SelectedCard;//->CardEffect;
	float AppliedValue = Effect.EffectValues;

	switch (Effect.EffectType)
	{
	case ECardEffectType::DashCoolTimeDecrease:
		DashCoolDown *= (1.0f - AppliedValue * 0.01f);
		break;

	case ECardEffectType::MoveSpeedIncrease:
		NormalSpeed *= (1.0f + AppliedValue * 0.01f);
		break;

	case ECardEffectType::ShieldAmountIncrease:
		MaxShield += AppliedValue;
		OnShieldChanged.Broadcast(CurrentShield, MaxShield);
		break;

	case ECardEffectType::ShieldRateIncrease:
		ShieldRegenRate += AppliedValue;
		break;

	case ECardEffectType::MaxHealthIncrease:
		MaxHealth += AppliedValue;
		OnHealthChanged.Broadcast(CurrentHealth, MaxHealth);
		break;

	case ECardEffectType::AttackPowerIncrease:
	case ECardEffectType::AttackSpeedIncrease:
		ApplyEffectToGun(Effect);
		AppliedCardEffects.Add(Effect);
		break;
	case ECardEffectType::ShieldRegeneTimeDecrease:
		//미구현
		break;
	}
}

void APlayerCharacter::ApplyEffectToGun(FCardEffect Effect)
{
	ACGunBase* EquippedGun = GetEquippedGun();

	if (!EquippedGun) return;

	float AppliedValue = Effect.EffectValues;

	if (Effect.EffectType == ECardEffectType::AttackPowerIncrease)
	{
		EquippedGun->SetGunDamage(EquippedGun->GetGunDamage() + AppliedValue);
	}
	else if (Effect.EffectType == ECardEffectType::AttackSpeedIncrease)
	{
		EquippedGun->SetGunDelay(EquippedGun->GetGunDelay() * (1.0f - (AppliedValue * 0.01f)));
	}
}
  
  • GameMode에서 CardSelectionWidget을 띄우고 Widget 클래스 내에서 카드를 선택하면 PlayerCharacter의 ApplyCardEffect를 호출하도록 구현.
  • GunDelay와 GunDamage의 경우 장착한 무기의 클래스를 가져와서 클래스내부의 Property를 올리도록 설계.

🔥 Enum을 활용하여 DataTable을 만들고 해당 DataTable을 에서 값을 받아서 Widget에 연동하고 카드를 고르면 Player와 Weapon의 Status에 적용되도록 설계


🎮 Trouble Shooting

1️⃣ GameInstance ObjectPool

처음에 레벨 전환을 하더라도 Enemy를 저장하는 Pool이 계속 유지를 하면 좋겠다는 생각으로 GameInstance에서 ObjectPoolInstance를 관리를 하였다. 그러나 GameInstance가 게임프레임워크 순서로 인해 Pool이 계속 EnemyClass를 못받아오는 문제가 생겼다. Wolrd가 생성되기도 전에 GameInstance의 Init이 먼저 호출되면서 발생한 문제라고 추측

👉 아쉽지만 레벨이 전환되어 Enemy가 삭제되고 다시 생성이 되더라도 재사용될 수 있다는 의의를 가지고 GameMode에서 ObjectPoolInstance를 관리를 하였다. 그러니 정상적으로 Pool에 Enemy가 들어가면서 해결이 되었음.


2️⃣ CardSelectionUI

GameMode에서 TrapLevel 클리어 후 원래의 Level이 오픈될 때 TrapLevel 보상으로 CardSelectionUI가 뜨지만 입력이 되지 않는 문제가 발생 했었다. BeginPlay()에서 바로 CreateWidget을 하면, UI가 생성되긴 하지만 아직 입력 처리가 활성화되기 전에 띄워졌을 가능성이 있을거라고 착안 라이프사이클의 지식이 부족해서 일어난 문제라고 생각했다.

👉 단순하게 UI를 생성하는 함수를 따로 만들고 TimeManager를 활용하여 0.2초 뒤에 UI를 생성하도록 하니깐 문제가 해결되었다. GameMode의 프레임워크와 액터, Object의 라이프사이클을 공부해야 겠다는 생각이 들었다.


🎮 KPT 회고

1️⃣ Keep

  • 처음 게임 기획을 주도적으로 해본 것.
  • EnemyClass를 ObjectPooling 방식을 통해 효율적으로 메모리를 사용
  • MainStreamLevel 전환이 매끄러웠고, SideLevel 인 Trap Level 전환도 자연스럽게 구현이 잘 된 것 같음. SideLevel에서 MainStreamLevel로 돌아왔을 때 위치 저장도 잘된 것 같아 만족.
  • GameMode 담당을 했지만 국한되지 않고 PassiveCard 구현과, Portal 구현, EnemyClass Spawn을 담당. 주도적으로 내가 팀에서 할 거리를 찾았다는 것.
  • 처음엔 Git을 잘 몰랐지만 나중에는 Git Rebase도 문제없이 진행하고 Confict가 일어나도 당황하지 않고 해결후 Merge할 수 있을정도로 Git 능력이 오름.
  • 매일 코드카타 시간이 끝나면 자기가 오늘 작업 할 것을 공유하고 퇴근 직전에 오늘 작업한 것을 공유하여 서로 학습이 가능했던 점
  • 맵에 배치되는 것은 Asset을 활용하긴 했지만 직접 Unreal의 Modeling Tool을 활용하여 맵을 디자인 한 것이 보람차고 만족스러웠음.


2️⃣ Problem

  • GameMode 담당을 했지만 AI, Player, UI 파트들도 조금씩 공부를 하면서 같이 구현하며 진행하고 싶었는데 그러지 못해서 아쉬움. 앞으로 많이 배워야 겠다고 생각
  • Enemy를 ObjectPooing으로 하고 있지만 실질적으로 Level 전환 시 다시 재생성이 되기 때문에 Instance에서 Pool을 관리하려고 했으나 실패. 지금은 어떻게 Instance에서 관리할 지 떠오르지만 이미 시간이 없어서 너무 아쉬웠다.
  • 발표자료에 TroubleShooting이 없어서 매우 아쉬웠다. 실제로 우리조는 정말 많은 TroubleShooting을 했는데, 시간이 촉박해서 준비를 못한 것 같아 많이 아쉬웠다.
  • 할 일이 너무 많고 바빴을 때, 나에게 들어오는 질문을 조금 날카롭게 답변을 한 적이 있다. 사과는 했지만 아직 까지 너무 미안할 따름이다.
  • 기획단계를 거치긴했지만 구체적으로 하지 못했던 것이 너무 아쉽다. 프로젝트를 해보니깐 기획을 구체적으로 하는 것이 얼마나 중요한 것인지 알게되었다.
  • GameMode 쪽 Class 설계를 할 때 Private과 Public을 잘 활용을 해야하는데 거의 다 Public에 Property랑 Method를 넣었던 것이 아쉬웠다. 다음부턴 적절하게 잘 활용해야겠다고 생각한다.
  • UML을 너무 늦게 작성한 것이 아쉬웠다. 미리 기획단계에서 작성을하고 작업 단계를 거치면서 UML을 업데이트하면서 진행했으면 조금 더 GameMode 로직에 대해 편하게 로직을 작성했을 것 같다.


3️⃣ Try

  • 게임 시작 후 최초의 레벨 GameState에 ObjectPoolInstance를 만들어서 GameMode의 BeginPlay에서 Initialize 하여 사용한 뒤 차후 레벨에서는 GameInstance의 ObjectPoolInstance를 사용하는 방법이 괜찮지 않을까 생각한다.

  • 적 Enemy Skill은 어떤 식으로 동작할 것인지, 체력을 회복하는 매커니즘, 데미지 팝업 방식 등 기획 단계에서 조금 더 구체적으로 각 담당마다 세부적으로 어떻게 구현할지 계획을 수립해야한다.

  • UML의 경우 처음에 기획을 구체적으로 함으로써 예상되는 Property와 Method를 구상한 다음 관계도를 빠르게 정립하는 것이 일이 효율적으로 돌아갈 것이라 생각한다.

  • Property들은 Private에 선언하고 Getter, Setter 함수를 사용하는 것이 안전할 것이다. 또한 다른 곳에서 호출이 되지 않는 Method도 Protected에 선언하는게 좋겠다.

  • TroubleShooting시 즉각적으로 해당 이슈를 메모에 적고 해결진행 상황을 체크하여 기록해두는 것이 공부와 발표에 도움이 많이 될 것 같다.

profile
Unreal 1기

0개의 댓글

관련 채용 정보