구현내용
게임중 레벨업으로 얻는 어빌리티 포인트를 사용하여
플레이어가 나뉘어진스킬 트리에서 포인트를 투자하고 스킬을 언락하거나
영구적으로 스탯을 증가시키는 브랜치로 선택할 수 있는 시스템
-오리와 눈먼숲의 능력나무 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 |