Unreal - 스킬 트리

2025. 12. 10. 00:17·Unreal 프로젝트 다이어리/두번째 프로젝트

구현내용

게임중 레벨업으로 얻는 어빌리티 포인트를 사용하여

플레이어가 나뉘어진스킬 트리에서 포인트를 투자하고 스킬을 언락하거나

영구적으로 스탯을 증가시키는 브랜치로 선택할 수 있는 시스템

 

-오리와 눈먼숲의 능력나무 Branch-

앞의 스킬을 찍으면 뒤의 스킬을 연달아서 찍을수있는 시스템을 구현해보고싶었습니다

 

프로토타입 구상도

총 3가지의 분기점으로 나누어져있으며 1번 2번 3번 트리가 존재합니다

해당 노드에는 가중치(코스트)가 존재하며 어빌리티포인트를 코스트로 사용합니다

1번을 찍어야 1-1을 찍을수있으며 2-1를 찍어야 2-1-1이 열리는 스킬트리를 구상했습니다

 

해당 노드의 언락 능력은 이와 같습니다

  • 1 : 체력이 100 증가한다
  • 1-1 : 체력이 200 증가한다
  • 1-1-1 : 체력이 3분간 20% 증가한다
  • 2 : 공격력이 100증가한다
  • 2-1 : 공격력이 150 증가한다
  • 2-1-1 : 공격력이 3분간 20% 증가한다
  • 2-2 : 공격속도가 10프로 빨라진다
  • 2-2-1 공격력이 1분간 50% 공격속도가 20프로 빨라진다
  • 2-2-2 공격속도가 5분간 20프로 빨라진다
  • 3 : 크리티컬 확률이 5프로 증가한다
  • 3-1 : 크리티컬 확률이 10프로 증가한다
  • 3-2 크리티컬 확률이 10프로 증가한다
  • 3-1-1 크리티컬 확률이 5분간 30프로 증가한다.

사용한 클래스

클래스 이름 클래스 목적
SkillManager(액터컴포넌트) 플레이어의 스킬 트리 구조를 관리하고 스킬 언락/획득 적용을 담당
SkillData(Struct) 각 스킬 노드의 데이터 를 담는 데이터 구조
StatComponent(액터컴포넌트) 플레이어 캐릭터의 현재 스탯과 영구/임시 버프 or 증가 를 계산하는 컴포넌트

 

구현

우선 프로토타입 구상도를 살펴보자

\

 

알고리즘 공부를 진행하면서 많이 보았던 그래프 그림입니다

단방향 DFS 그래프 구조입니다

 

스킬트리를 Vertex / Edge 를 사용하여 가중치를 사용하여 구현하게되었습니다

먼저 ActorComponent로 SkillManager를 만들어서 플레이어한테 붙혀준다음 작업하였습니다

 

USTRUCT(BlueprintType)
struct FSkillNode
{
	GENERATED_BODY()

	UPROPERTY()
	TArray<FSkillUsing> skill;

	UPROPERTY()
	TArray<int32> searchNode;

	UPROPERTY()
	TArray<int32> prerequisites;

	UPROPERTY()
	int32 cost = 0;

	UPROPERTY()
	ESkillState state = ESkillState::locked;
};

 

배열에 노드를 추가합니다

void USkillManager::AddSkillNode(int32 nodeID, const FSkillNode& nodeData)
{
	skillNodes.Add(nodeID, nodeData);
}

 

노드간 Edge를 추가해줍니다

void USkillManager::AddSkillEdge(int32 vertexFrom, int32 vertexTo)
{
	if (skillNodes.Contains(vertexFrom))
		skillNodes[vertexFrom].searchNode.Add(vertexTo);
}

 

BeginPlay에서 실행되는 그래프 추가 함수입니다

void USkillManager::MakeAbilityGraph()
{
	//node add
	AddSkillNode(1, FSkillNode());
	AddSkillNode(11, FSkillNode());
	AddSkillNode(111, FSkillNode());

	AddSkillNode(2, FSkillNode());
	AddSkillNode(21, FSkillNode());
	AddSkillNode(211, FSkillNode());
	AddSkillNode(22, FSkillNode());
	AddSkillNode(221, FSkillNode());
	AddSkillNode(222, FSkillNode());

	AddSkillNode(3, FSkillNode());
	AddSkillNode(31, FSkillNode());
	AddSkillNode(311, FSkillNode());
	AddSkillNode(32, FSkillNode());

	//부모조건
	skillNodes[11].prerequisites.Add(1);
	skillNodes[111].prerequisites.Add(11);

	skillNodes[21].prerequisites.Add(2);
	skillNodes[211].prerequisites.Add(21);

	skillNodes[22].prerequisites.Add(2);
	skillNodes[221].prerequisites.Add(22);
	skillNodes[222].prerequisites.Add(22);

	skillNodes[31].prerequisites.Add(3);
	skillNodes[311].prerequisites.Add(31);

	skillNodes[32].prerequisites.Add(3);

	//edge
	AddSkillEdge(1, 11);
	AddSkillEdge(11, 111);

	AddSkillEdge(2, 21);
	AddSkillEdge(21, 211);

	AddSkillEdge(2, 22);
	AddSkillEdge(22, 221);
	AddSkillEdge(22, 222);

	AddSkillEdge(3, 31);
	AddSkillEdge(31, 311);
	AddSkillEdge(3, 32);

	//cost 가중치
	skillNodes[1].cost = 1;
	skillNodes[11].cost = 2;
	skillNodes[111].cost = 5;

	skillNodes[2].cost = 1;
	skillNodes[21].cost = 2;
	skillNodes[211].cost = 4;

	skillNodes[22].cost = 2;
	skillNodes[221].cost = 4;
	skillNodes[222].cost = 4;

	skillNodes[3].cost = 1;
	skillNodes[31].cost = 2;
	skillNodes[311].cost = 5;
	skillNodes[32].cost = 2;
}

이와 같이 그래프를 만들어주었습니다

 

 

스테이트를 정의해줍니다

UENUM(BlueprintType)
enum class ESkillState : uint8
{
	locked,
	Available,
	learned
};

 

부모노드가 찍혀있어야 다음 자식노드가 찍히는 조건을 추가해주었습니다

bool USkillManager::HasPrerequisitesLearned(int32 node)
{
	if (!skillNodes.Contains(node))
		return false;

	const FSkillNode& sn = skillNodes[node];
	for (int32 pre : sn.prerequisites)
	{
		if (!skillNodes.Contains(pre))
			return false;

		if (skillNodes[pre].state != ESkillState::learned)
			return false;
	}
	return true;
}

 

원래 DFS는 재귀함수를 사용하여 구현할려고했으나

스택 오버플로 위험이나 성능 안정성때문에 스택형 DFS로 구현하였습니다

bool USkillManager::LearnSkillDFS(int32 skillID)
{
	// 스킬 존재 체크하기
	if (!skillNodes.Contains(skillID))
		return false;

	//이미 배웠다면 x
	if (skillNodes[skillID].state == ESkillState::learned)
		return false;

	//부모조건으로부터 확인하기 (엣지와 연결되어있고 부모가 상위노드가 배워져있을때)
	if (!HasPrerequisitesLearned(skillID))
		return false;

	//가중치 코스트 확인
	int32 skillCost = skillNodes[skillID].cost;
	if (statComp->currentAbilityPoint < skillCost)
		return false;
	statComp->currentAbilityPoint -= skillCost;
	statComp->onAbilityPointChanged.Broadcast(statComp->currentAbilityPoint, statComp->totalAbilityPoint);

	//스택 DFS
	TSet<int32> visited;
	TArray<int32> stack;

	stack.Add(1);
	stack.Add(2);
	stack.Add(3);

	while (stack.Num() > 0)
	{
		int32 currentID = stack.Pop();
		if (currentID == skillID)
		{
			skillNodes[currentID].state = ESkillState::learned;

			ApplySkillPermanentBuff(skillID);
			return true;
		}
		visited.Add(currentID);

		for (int32 childNode : skillNodes[currentID].searchNode)
		{
			if (!visited.Contains(childNode))
				stack.Add(childNode);
		}
	}
	return false;
	
}

 

그럼 이제 위젯에서 해당 버튼을 누르면 LearnSkillDFS 함수를 호출하여 DFS호출을하면됩니다

위젯에서 업그레이드 버튼 맵을 선언

UPROPERTY()
TMap<class UButton*, int32> buttonAbilityMap;

 

해당 버튼을 맵에 넣어준뒤 Hover / Unhover 함수와 연동하여주었습니다

void UCubeWidget::AddButtonAbilityMap()
{
	buttonAbilityMap.Add(Button_0By1, 1);
	buttonAbilityMap.Add(Button_1By1, 11);
	buttonAbilityMap.Add(Button_1By1By1, 111);

	buttonAbilityMap.Add(Button_2By0, 2);
	buttonAbilityMap.Add(Button_2By1, 21);
	buttonAbilityMap.Add(Button_2By1By1, 211);
	buttonAbilityMap.Add(Button_2By2, 22);
	buttonAbilityMap.Add(Button_2By2By1, 221);
	buttonAbilityMap.Add(Button_2By2By2, 222);

	buttonAbilityMap.Add(Button_3By0, 3);
	buttonAbilityMap.Add(Button_3By1, 31);
	buttonAbilityMap.Add(Button_3By1By1, 311);
	buttonAbilityMap.Add(Button_3By2, 32);

	for (auto& buttonMapElem : buttonSkillMap)
	{
		if (buttonMapElem.Key)
		{
			buttonMapElem.Key->OnHovered.AddDynamic(this, &UCubeWidget::OnAbilityButtonHovered);
			buttonMapElem.Key->OnUnhovered.AddDynamic(this, &UCubeWidget::OnAbilityButtonUnHovered);
		}
	}
}

 

버튼을 클릭하여 눌러진 버튼에 맞는 맵 ID를 LearnSkillDFS에 추가

void UCubeWidget::OnAbilityUpgradeButtonClicked()
{
	clickedAbilityButton = nullptr;
	for (auto& abilityElem : buttonAbilityMap)
	{
		if (abilityElem.Key && abilityElem.Key->IsHovered())
		{
			clickedAbilityButton = abilityElem.Key;
			break;
		}
	}
	if (!clickedAbilityButton)
		return;

	int32 upgradeAbilityID = buttonAbilityMap[clickedAbilityButton];
	if (skillManager->LearnSkillDFS(upgradeAbilityID))
	{
		UpdateAllAbilitySkill();
		UpdateLockUI();
	}

}

 

 

호버시 SkillData에 있는 값들로 툴팁이 표시됩니다

void UCubeWidget::OnAbilityButtonHovered()
{
	FVector2D mousePos = UWidgetLayoutLibrary::GetMousePositionOnViewport(pc);

	for (auto& buttonElem : buttonSkillMap)
	{
		if (buttonElem.Key && buttonElem.Key->IsHovered())
		{
			hoveredSkillID = buttonElem.Value;

			FSkillData* skillData = skillManager->GetSkillDataByID(hoveredSkillID);
			if (skillData)
				mainCon->toolTipWidget->SetSkillInformation(*skillData);

			mainCon->toolTipWidget->WidgetSwitcher_Main->SetActiveWidget(mainCon->toolTipWidget->Overlay_Ability);
			mainCon->toolTipWidget->SetVisibility(ESlateVisibility::SelfHitTestInvisible);

			FVector2D viewportSize;
			GEngine->GameViewport->GetViewportSize(viewportSize);

			FVector2D tooltipSize = mainCon->toolTipWidget->GetDesiredSize();
			FVector2D adjustedPos = mousePos;

			if (adjustedPos.X + tooltipSize.X > viewportSize.X)
				adjustedPos.X = viewportSize.X - tooltipSize.X;
			if (adjustedPos.Y + tooltipSize.Y > viewportSize.Y)
				adjustedPos.Y = viewportSize.Y - tooltipSize.Y;

			mainCon->toolTipWidget->SetPositionInViewport(adjustedPos, false);
			break;
		}
	}
}

 

스킬데이터는 이와같습니다

#pragma once

#include "CoreMinimal.h"
#include "Engine/DataTable.h"
#include "SkillData.generated.h"

USTRUCT(BlueprintType)
struct FSkillData : public FTableRowBase
{
	GENERATED_BODY()
public:
	UPROPERTY(EditAnywhere, BlueprintReadWrite)
	FString skillName;

	UPROPERTY(EditAnywhere, BlueprintReadWrite)
	FString skillDescription;

	UPROPERTY(EditAnywhere, BlueprintReadWrite)
	UTexture2D* skillIcon;

	UPROPERTY(EditAnywhere, BlueprintReadWrite)
	FLinearColor symbolColor;

	UPROPERTY(EditAnywhere, BlueprintReadWrite)
	FString skillCost;
};

 

또한 값을 관리하기 편하게 SCV파일로 리임포트 해주었습니다

 

값들이 초기화되지않도록 세이브 도 해주었습니다

void USkillManager::SaveSkill(USaveFile* saveDatas)
{
	if (!saveDatas)
		return;
	saveDatas->savedSkillNodeds.Empty();

	for (auto& skillElem : skillNodes)
	{
		FSaveSkillData newDT;
		newDT.skillID = skillElem.Key;
		newDT.skillState = skillElem.Value.state;

		saveDatas->savedSkillNodeds.Add(newDT);
	}
	
}

void USkillManager::LoadSkills(const USaveFile* saveDatas)
{
	if (!saveDatas)
		return;
	for (const FSaveSkillData& loadDT : saveDatas->savedSkillNodeds)
	{
		if (skillNodes.Contains(loadDT.skillID))
		{
			skillNodes[loadDT.skillID].state = loadDT.skillState;
			if (loadDT.skillState == ESkillState::learned)
				ApplySkillPermanentBuff(loadDT.skillID);
		}
	}
	onSkillStateChanged.Broadcast();
}

 

이제 해당 노드를 찍으면 실직적으로 값들이 증가하게 해주었습니다

void UStatComponent::ApplyPermanentStat(EBaseStatType statType, float bonusValue, float bonusRate)
{
	if (stats.Contains(statType))
	{
		stats[statType].entryBonus += bonusValue;
		stats[statType].entryRate += bonusRate;
		UpdateCurrentStats();
	}
}
void USkillManager::ApplySkillPermanentBuff(int32 skillID)
{
	if (!statComp)
		return;

	switch (skillID)
	{
	case 1:  //체력 100 증가
		statComp->ApplyPermanentStat(EBaseStatType::Hp, 100.f, 0.f);
		break;
	case 11: // 체력 200 증가
		statComp->ApplyPermanentStat(EBaseStatType::Hp, 200.f, 0.f);
	case 2: // 공격력 100 증가
		statComp->ApplyPermanentStat(EBaseStatType::AttackPower, 100.f, 0.f);
		break;
	case 21: // 공격력 150 증가
		statComp->ApplyPermanentStat(EBaseStatType::AttackPower, 150.f, 0.f);
		break;
	case 22: // 공격속도 10% 증가
		statComp->ApplyPermanentStat(EBaseStatType::AttackSpeed, 0.f, 0.1f);
		break;
	case 3: // 크리티컬 확률 5% 증가
		statComp->ApplyPermanentStat(EBaseStatType::CriticalRate, 0.f, 0.05f);
		break;
	case 31: // 크리티컬 확률 10% 증가
		statComp->ApplyPermanentStat(EBaseStatType::CriticalRate, 0.f, 0.1f);
		break;
	case 32: // 크리티컬 확률 10% 증가
		statComp->ApplyPermanentStat(EBaseStatType::CriticalRate, 0.f, 0.1f);
		break;
	default:
		break;
	}
}

 

위젯

완성된 위젯입니다

결과물

플레이어가 마우스를 노드에 올리면 SaveFile에 있는 이미지, 텍스트가 표시됩니다

 

플레이어의 어빌리티 포인트를 코스트로 삼아 스킬을 해금할수있습니다

 

부모의 노드를 거치지않으면 자식노드는 해금이 불가능합니다

 

영상

 

 

구현하면서..

알고리즘 공부하기전 구조 생각하기전에 막 구현할때는 bool 변수로 찍혔다 안찍혔다 이렇게 구현했을텐데

여러번 프로젝트 거치면서 구조가 조금씩 잡혀가는거같다

그냥 구현하는것보다 구조를 생각해서 구현하니 많이 어렵다.. 아직 많이 부족한거같다..

 

저작자표시 비영리 변경금지 (새창열림)

'Unreal 프로젝트 다이어리 > 두번째 프로젝트' 카테고리의 다른 글

Unreal - Blood Decal (피 튀기기)  (0) 2025.12.10
Unreal - 스탯창  (0) 2025.12.02
Unreal - 세이브 게임  (0) 2025.12.02
Unreal - 경험치, 레벨업 구현하기  (0) 2025.12.02
Unreal - 데미지 오버레이  (0) 2025.12.01
'Unreal 프로젝트 다이어리/두번째 프로젝트' 카테고리의 다른 글
  • Unreal - Blood Decal (피 튀기기)
  • Unreal - 스탯창
  • Unreal - 세이브 게임
  • Unreal - 경험치, 레벨업 구현하기
lucodev
lucodev
커피와 노트북 그리고 개발
  • lucodev
    루코 개발테이블
    lucodev
  • 전체
    오늘
    어제
    • 분류 전체보기 (210) N
      • Unreal 프로젝트 다이어리 (107) N
        • 첫번째 프로젝트 (73)
        • 두번째 프로젝트 (34) N
      • Unreal 팁 (8)
      • Unreal 디버깅 (8)
      • C++ 프로그래머스 (52)
        • Stack,Queue (7)
        • Hash (4)
        • Heap (2)
        • Sort (5)
        • Exhaustive search (5)
        • Greedy (2)
        • BFS , DFS (7)
        • Graph (2)
        • Dynamic Programming (1)
        • C++ Math (2)
        • 기타 문제 (14)
      • C++ 백준 (4)
      • C++ 팁 (1)
      • 개인 코테 & 스타디 <비공개> (29)
        • 코드 개인보관함 (9)
        • 코딩테스트+@ (11)
        • 알고리즘 스타디 (6)
        • 알고리즘 스타디 과제 (3)
        • 비공개 (0)
  • 인기 글

  • 최근 글

  • 최근 댓글

  • 링크

  • 공지사항

  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 태그

    언리얼 컷씬
    언리얼 시퀀스
    언리얼 motionmatching
    언리얼
    언리얼 인벤토리
    Unreal Parkour
    언리얼 behavior tree
    언리얼 상호작용
    언리얼 parkour
    언리얼 behaviortree
    언리얼 모션매칭
    unreal 인벤토리
    언리얼 프로그래스바
    언리얼 비헤이비어트리
    unreal 파쿠르
    언리얼 파쿠르
    unreal 모션매칭
    unreal 시퀀스
    unreal inventory
    언리얼 ui
  • hELLO· Designed By정상우.v4.10.3
lucodev
Unreal - 스킬 트리
상단으로

티스토리툴바