RPG에서 레벨업은 단순히 숫자가 올라가는 것보다, 그에 따라 어떤 스킬이 새롭게 열렸는지를 플레이어가 바로 알 수 있게 해주는 것이 중요하다.
특히 이미 만들어둔 스킬 해금 위젯이 있다면, 이를 실제 레벨 시스템과 스킬 데이터에 연결해 “이 레벨에서 어떤 스킬이 새로 열렸는지”를 자연스럽게 보여줄 수 있어야 한다.
지난 글에서는 신규 스킬 해금 시 출력되는 알림 Widget과 스킬창 메뉴 배지 시스템을 먼저 구현했다.
이번 글에서는 그 구조를 바탕으로 플레이어의 레벨 시스템 및 스킬 데이터 테이블을 연동하여, 레벨업 시점에 맞춰 신규 스킬이 실제로 해금되고 해당 정보가 UI에 표시되는 흐름을 정리해보려고 한다.
이전 글 보기
[신규 스킬 해금 Widget과 스킬창 알림 배지 구현]
이번 구현의 전체 흐름은 아래와 같다.
- 서버에서 레벨업 감지
- 서버에서 해당 레벨 구간의 신규 해금 스킬 계산
- 서버에서 실제 어빌리티 부여
- 서버가 소유 클라이언트에 UI 이벤트 전송
- 클라이언트가 UI 이벤트를 받아 위젯 표시
즉, 이번 구조는 서버가 해금 권한을 가지고, 클라이언트는 결과를 UI로만 표시하는 방식으로 설계했다.
1. 먼저 기존 레벨업 흐름과 스킬 해금 흐름 정리
우선 기존 작업자가 작성해 둔 코드를 확인해 보니, 플레이어의 레벨업과 경험치 관련 함수들은 PlayerStateBase에 정리되어 있었다.
이번 구현에서는 그 흐름을 바탕으로 아래와 같이 역할을 나눴다.
- PlayerStateBase
경험치 증가와 레벨 변경 감지 - ClassComponentBase
레벨업 이벤트를 받아 신규 해금 스킬 계산 및 실제 어빌리티 부여 - PlayerControllerBase
서버에서 받은 UI 이벤트를 클라이언트 쪽 UI 시스템으로 전달 - UIEventSubsystem
UI 이벤트를 중앙에서 브로드캐스트 하는 이벤트 버스 역할 - SkillUnlockedWidget
이벤트를 구독하고 있다가 실제 해금 위젯 표시
즉, 레벨업 감지부터 UI 출력까지를 한 클래스에 몰아넣지 않고, 데이터 계산과 표시 흐름을 분리한 구조로 가져갔다.
2. 서버에서 클라이언트로 보낼 UI 이벤트 구조체 정의
먼저 서버에서 클라이언트로 어떤 정보를 보낼지 정해야 했기 때문에, 스킬 해금 UI에 필요한 정보를 담는 구조체를 UIEventTypes로 정의했다.
이번 구조에서 중요한 점은, 단순히 “스킬이 해금됐다”는 사실만 보내는 것이 아니라 위젯에 실제로 채워 넣을 수 있는 정보 단위를 미리 구조체로 정리했다는 점이다.
예를 들면 아래 정보가 포함된다.
- 스킬의 데이터 행 이름
- 어빌리티 클래스
- 요구 레벨
- 표시용 이름
- 아이콘
그리고 이를 다시 FSkillUnlockPayload로 감싸, 한 번에 여러 스킬이 해금되는 상황까지 처리할 수 있도록 만들었다.
UIEventType.h
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
|
#pragma once
#include "CoreMinimal.h"
#include "UObject/ObjectMacros.h"
#include "UIEventTypes.generated.h"
class UTexture2D;
class UGameplayAbility;
/** UI 이벤트 타입 */
UENUM(BlueprintType)
enum class EUIEventType : uint8
{
MenuBadge UMETA(DisplayName = "Menu Badge"),
SkillUnlocked UMETA(DisplayName = "Skill Unlocked"),
LevelUp UMETA(DisplayName = "Level Up"),
};
/** 해금된 스킬 1개 항목 - 위젯에 이름/아이콘 채울 때 사용 가능 */
USTRUCT(BlueprintType)
struct FSkillUnlockItem
{
GENERATED_BODY()
UPROPERTY(BlueprintReadOnly, EditAnywhere)
FName RowName = NAME_None;
UPROPERTY(BlueprintReadOnly, EditAnywhere)
TSubclassOf<UGameplayAbility> AbilityClass = nullptr;
UPROPERTY(BlueprintReadOnly, EditAnywhere)
int32 RequireLevel = 0;
UPROPERTY(BlueprintReadOnly, EditAnywhere)
FText DisplayName;
UPROPERTY(BlueprintReadOnly, EditAnywhere)
TSoftObjectPtr<UTexture2D> Icon;
};
/** 스킬 해금 페이로드 */
USTRUCT(BlueprintType)
struct FSkillUnlockPayload
{
GENERATED_BODY()
UPROPERTY(BlueprintReadOnly, EditAnywhere)
TArray<FSkillUnlockItem> Items;
};
/** 공용 UI 이벤트 패킷 */
USTRUCT(BlueprintType)
struct FUIEvent
{
GENERATED_BODY()
/** 이벤트 타입 */
UPROPERTY(BlueprintReadWrite, EditAnywhere)
EUIEventType Type = EUIEventType::MenuBadge;
/** 메뉴 배지 대상 ("Skills" 등) */
UPROPERTY(BlueprintReadWrite, EditAnywhere)
FName TargetId = NAME_None;
/** 스킬 해금용 페이로드 */
UPROPERTY(BlueprintReadWrite, EditAnywhere)
FSkillUnlockPayload SkillPayload;
/** 레벨업 알림용 페이로드 */
UPROPERTY(BlueprintReadWrite, EditAnywhere)
int32 NewLevel = 0;
};
|
cs |
이 구조의 장점은 이벤트 타입이 늘어나더라도 같은 FUIEvent 포맷 안에서 확장할 수 있다는 점이다.
즉, 스킬 해금뿐 아니라 레벨업 알림, 메뉴 배지 같은 다른 UI 이벤트도 동일한 흐름으로 전달할 수 있게 된다.
3. PlayerState에서 레벨업 구간 감지
이제 실제로 레벨업을 감지하고, 그에 따라 후속 처리를 시작해야 한다.
기존 경험치 추가 함수인 AddExperience()를 수정해서, 경험치가 증가한 뒤 레벨이 이전보다 올랐는지 검사하도록 만들었다.
그리고 레벨이 올랐을 경우에는 단순히 “새 레벨 하나”만 보내는 것이 아니라, 몇 레벨에서 몇 레벨로 올라갔는지 구간 정보를 전달하는 OnLevelUpRange 델리게이트를 호출하도록 했다.
이렇게 해두면 경험치를 한 번에 많이 얻어서 레벨이 여러 단계 오르는 경우에도 대응할 수 있다.
PlayerStateBase
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
PlayerStateBase.h
// OldLevel -> NewLevel 범위형 레벨업 델리게이트
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnLevelUpRange, int32, OldLevel, int32, NewLevel);
PlayerStateBase.cpp
void APlayerStateBase::AddExperience(int64 NewExperience) // 기존에 있던 함수 수정
{
Experience += (NewExperience * ExpOffsetForTest);
int32 OldLevel = Level;
Level = CalculateLevel();
if (OldLevel < Level)
{
// 범위형 레벨업 브로드캐스트 (서버 구독 대상)
OnLevelUpRange.Broadcast(OldLevel, Level); // 추가
.
.
.
}
Client_ReplicateExperience(Level, Experience, LevelStartExperience, LevelEndExperience);
}
|
cs |
즉, 이번 구조에서는 레벨업 자체보다 레벨업이 일어난 구간을 서버 내 다른 객체들이 구독할 수 있게 만든 것이 핵심이다.
4. ClassComponent에서 신규 스킬 해금 계산 및 실제 어빌리티 부여
레벨업 구간 정보가 생겼으니, 이제 그 범위 안에서 새로 해금되는 스킬을 찾아 실제로 부여해야 한다.
이 역할은 ClassComponentBase가 맡도록 했다.
BeginPlay()에서 PlayerStateBase의 OnLevelUpRange 델리게이트를 구독해 두고, 이벤트가 발생하면 HandleLevelUpRange_Server()를 통해 본격적인 해금 로직을 시작하도록 만들었다.
ClassComponentBase
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
|
ClassComponentBase.h
private:
/** 레벨업 서버 핸들러; 신규 스킬 해금/부여 */
UFUNCTION() void HandleLevelUpRange_Server(int32 OldLevel, int32 NewLevel);
/** OldLevel+1 ~ NewLevel 구간에서 신규 해금 스킬을 찾아 부여 */
void TryUnlockSkillsForLevelRange(UAbilitySystemComponent* ASC, int32 OldLevel, int32 NewLevel);
/** 해금 여부 검사; Ability Spec 존재 여부로 판단(중복 부여 방지) */
bool IsSkillAlreadyUnlocked(UAbilitySystemComponent* ASC, const USkillDataAsset* SkillDA) const;
/** 스킬 부여 + 패시브면 즉시 활성화 */
void GrantSkillAbilityAndMark(UAbilitySystemComponent* ASC, const USkillDataAsset* SkillDA);
ClassComponentBase.cpp
void UClassComponentBase::BeginPlay()
{
Super::BeginPlay();
if (GetOwnerRole() == ROLE_Authority)
{
// PlayerState 범위 레벨업 델리게이트 구독
if (APlayerBase* Player = Cast<APlayerBase>(GetOwner()))
{
if (APlayerStateBase* PS = Player->GetPlayerState<APlayerStateBase>())
{
PS->OnLevelUpRange.AddDynamic(this, &UClassComponentBase::Server_HandleLevelUpRange);
}
}
}
}
/** 레벨업 서버 핸들러; 신규 스킬 해금/부여; OldLevel 이전 레벨, NewLevel 현재 레벨 */
void UClassComponentBase::HandleLevelUpRange_Server(int32 OldLevel, int32 NewLevel)
{
if (GetOwnerRole() < ROLE_Authority) return;
APlayerBase* Player = Cast<APlayerBase>(GetOwner()); if (!Player) return;
UAbilitySystemComponent* ASC = Player->GetAbilitySystemComponent(); if (!ASC) return;
TryUnlockSkillsForLevelRange(ASC, OldLevel, NewLevel);
}
/** OldLevel+1 ~ NewLevel 구간에서 신규 해금 스킬을 찾아 부여 */
void UClassComponentBase::TryUnlockSkillsForLevelRange(UAbilitySystemComponent* ASC, int32 OldLevel, int32 NewLevel)
{
// ... 데이터 테이블 로드 ...
FSkillUnlockPayload Payload; // UI로 보낼 페이로드
for (FName RowName : DT->GetRowNames())
{
// ... 데이터 행(Row) 파싱 ...
const int32 ReqLv = SkillDA->RequireLevel;
// 이전 레벨보다 높고, 현재 레벨보다 낮거나 같은 스킬만 필터링
const bool bNewlyPassed = (ReqLv > OldLevel) && (ReqLv <= NewLevel);
if (!bNewlyPassed) continue;
// 이미 보유한 스킬인지 확인 (중복 부여 방지)
if (IsSkillAlreadyUnlocked(ASC, SkillDA)) continue;
// 스킬 어빌리티 부여
GrantSkillAbilityAndMark(ASC, SkillDA);
// UI에 보낼 페이로드에 정보 추가
FSkillUnlockItem& Item = Payload.Items.AddDefaulted_GetRef();
Item.RowName = RowName;
Item.AbilityClass = SkillDA->GameplayAbility;
Item.RequireLevel = ReqLv;
}
// 신규 해금 스킬이 하나라도 있으면 클라이언트에 UI 이벤트 전송
if (Payload.Items.Num() > 0)
{
if (APlayerBase* Player = Cast<APlayerBase>(GetOwner()))
{
if (APlayerControllerBase* PC = Cast<APlayerControllerBase>(Player->GetController()))
{
FUIEvent Evt;
Evt.Type = EUIEventType::SkillUnlocked;
Evt.SkillPayload = Payload;
PC->Client_PushUIEvent(Evt); // 소유 클라에 전송
}
}
}
}
/** 스킬 부여 + 패시브면 즉시 활성화 */
void UClassComponentBase::GrantSkillAbilityAndMark(UAbilitySystemComponent* ASC, const USkillDataAsset* SkillDA)
{
if (!ASC || !SkillDA || !SkillDA->GameplayAbility) return;
// Ability 부여
const FGameplayAbilitySpec Spec(SkillDA->GameplayAbility);
ASC->GiveAbility(Spec);
// 패시브면 즉시 활성화
if (SkillDA->SkillTypeTags.HasTag(PJTAG_SKILL_TYPE_PASSVIE))
{
ASC->TryActivateAbilityByClass(SkillDA->GameplayAbility);
}
}
|
cs |
TryUnlockSkillsForLevelRange()의 핵심은 아래와 같다.
- 스킬 데이터 테이블을 순회
- OldLevel 초과, NewLevel 이하 구간만 필터링
- 이미 배운 스킬은 건너뜀
- 새 스킬만 실제로 ASC에 부여
- 동시에 UI용 Payload에 정보 추가
즉, 서버가 해금 대상 선정과 실제 부여까지 모두 처리하고, 클라이언트는 그 결과만 전달받는 구조다.
5. PlayerController는 직접 처리하지 않고 UIEventSubsystem으로 넘기기
서버가 Client_PushUIEvent() RPC를 통해 소유 클라이언트에 UI 이벤트를 보내면, 클라이언트의 PlayerController는 이 이벤트를 직접 처리하지 않고 UIEventSubsystem으로 넘기도록 구성했다.
PlayerControllerBase
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
|
PlayerControllerBase.h
.
.
.
#include "ProjectJin/UI/UIEventTypes.h" // 추가
.
.
.
protected:
/** 서버에서 PlayerState의 레벨업 이벤트를 처리할 함수 */
UFUNCTION()
void HandlePlayerLevelUp_Server(int32 NewLevel);
PlayerControllerBase.cpp
/** 서버에서 PlayerState의 레벨업 이벤트를 처리할 함수 */
void APlayerControllerBase::HandlePlayerLevelUp_Server(int32 NewLevel)
{
FUIEvent Evt;
Evt.Type = EUIEventType::LevelUp; // 이벤트 타입을 LevelUp으로 설정
Evt.NewLevel = NewLevel; // 페이로드에 새로운 레벨 정보 추가
Client_PushUIEvent(Evt);
}
/** (클라) 단일 UI 이벤트 분기 처리 → UI 브로드캐스트 */
void APlayerControllerBase::Client_PushUIEvent_Implementation(const FUIEvent& Event)
{
if (UGameInstance* GI = GetGameInstance())
{
if (UUIEventSubsystem* UI = GI->GetSubsystem<UUIEventSubsystem>())
{
switch (Event.Type)
{
case EUIEventType::MenuBadge:
// 대상 메뉴만 배지 ON
UI->BroadcastMenuBadge(Event.TargetId);
break;
case EUIEventType::SkillUnlocked:
UI->BroadcastSkillUnlocked(Event.SkillPayload);
break;
case EUIEventType::LevelUp:
UI->BroadcastPlayerLevelUp(Event.NewLevel); // 레벨업 이벤트 추가
break;
default:
break;
}
}
}
}
|
cs |
이렇게 하면 Widget 입장에서는 PlayerController를 직접 참조할 필요가 없다.
즉, UI 쪽은 오직 UIEventSubsystem만 바라보면 되기 때문에 클래스 간 결합도가 낮아진다.
6. UIEventSubsystem을 중앙 이벤트 버스로 사용
UIEventSubsystem은 게임 전체에서 UI 관련 이벤트를 중계하는 역할을 한다.
이번 구현에서는 스킬 해금과 레벨업 이벤트를 브로드캐스트 하도록 확장했다.
UIEventSubsystem
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
|
UIEventSubsystem.h
/** 스킬 해금 발생 알림(페이로드 포함) */
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnSkillUnlocked, const FSkillUnlockPayload&, Payload);
/** 레벨업 발생 알림 */
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnPlayerLevelUp, int32, NewLevel);
public:
.
.
.
/** 스킬 해금(복수) 브로드캐스트: 페이로드 포함 */
UFUNCTION(BlueprintCallable, Category = "UI|Events")
void BroadcastSkillUnlocked(const FSkillUnlockPayload& Payload);
/** 레벨업 브로드캐스트 */
UFUNCTION(BlueprintCallable, Category = "UI|Events")
void BroadcastPlayerLevelUp(int32 NewLevel);
UPROPERTY(BlueprintAssignable)
FOnPlayerLevelUp OnPlayerLevelUp;
UIEventSubsystem.cpp
void UUIEventSubsystem::BroadcastPlayerLevelUp(int32 NewLevel)
{
OnPlayerLevelUp.Broadcast(NewLevel);
}
|
cs |
이 구조의 장점은 분명하다.
위젯들이 특정 플레이어 컨트롤러나 특정 캐릭터를 직접 물지 않고, 이벤트만 구독해서 반응할 수 있다는 점이다.
7. SkillUnlockedWidget은 이벤트를 구독하고 표시만 담당
이제 마지막으로 SkillUnlockedWidget이 UIEventSubsystem의 OnSkillUnlocked 델리게이트를 구독하도록 연결했다.
즉, 위젯은 해금 로직을 직접 계산하지 않는다.
그저 이벤트가 오면 Payload를 저장하고, 자신의 애니메이션과 표시 로직만 실행한다.
SkillUnlockedWidget
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
|
SkillUnlockedWidget.h
#include "MyGame/UI/UIEventTypes.h"
public:
/** 페이로드 기반 표시 */
UFUNCTION()
void OnSkillUnlocked(const FSkillUnlockPayload& Payload);
private:
/** 최근 페이로드: BP에서 목록/아이콘 채울 때 사용 */
UPROPERTY(BlueprintReadOnly, meta = (AllowPrivateAccess = "true"))
FSkillUnlockPayload LastPayload;
SkillUnlockedWidget.cpp
void USkillUnlockedWidget::NativeConstruct()
{
Super::NativeConstruct();
SetVisibility(ESlateVisibility::Collapsed);
if (NewSkillUnlockedAnimation)
{
FWidgetAnimationDynamicEvent FinishEvent;
FinishEvent.BindDynamic(this, &USkillUnlockedWidget::OnAnimFinished);
BindToAnimationFinished(NewSkillUnlockedAnimation, FinishEvent);
}
if (UGameInstance* GI = GetGameInstance()) // 이하 추가
{
if (UUIEventSubsystem* UI = GI->GetSubsystem<UUIEventSubsystem>())
{
UI->OnSkillUnlocked.AddDynamic(this, &USkillUnlockedWidget::OnSkillUnlocked);
}
}
}
void USkillUnlockedWidget::NativeDestruct()
{
if (NewSkillUnlockedAnimation)
{
UnbindAllFromAnimationFinished(NewSkillUnlockedAnimation);
}
if (UGameInstance* GI = GetGameInstance()) // 추가
{
if (UUIEventSubsystem* UI = GI->GetSubsystem<UUIEventSubsystem>())
{
UI->OnSkillUnlocked.RemoveDynamic(this, &USkillUnlockedWidget::OnSkillUnlocked);
}
}
Super::NativeDestruct();
}
void USkillUnlockedWidget::OnSkillUnlocked(const FSkillUnlockPayload& Payload)
{
LastPayload = Payload;
ShowUnlocked();
}
|
cs |
여기서 핵심은 Widget이 이벤트를 받았을 때 직접 데이터를 조회하러 가지 않는다는 점이다.
이미 서버에서 정리해 보낸 Payload를 그대로 사용하면 되기 때문에, 표시 구조가 훨씬 단순해진다.
8. 여러 스킬이 한 번에 열리는 상황까지 고려
짧은 시간에 경험치를 많이 얻으면, 한 번에 레벨이 여러 단계 오르면서 신규 스킬이 여러 개 동시에 해금될 수 있다.
이 경우 UI가 한 번에 겹쳐서 뜨면 보기가 좋지 않기 때문에, UnlockQueue라는 대기열을 두고 해금 알림이 하나씩 순차적으로 출력되도록 구현했다.
또한 bIsDisplaying 플래그를 사용해 현재 애니메이션 재생 중인지 확인하고, 이미 표시 중일 때는 다음 알림이 겹치지 않도록 막았다.
즉, 이번 구현은 단순히 “한 번 뜬다”가 아니라, 복수 해금 상황에서도 UI가 안정적으로 동작하는 구조까지 고려한 셈이다.
9. 테스트 과정에서 초기 스킬 지급 조건도 수정
여기까지 작성한 뒤 PIE로 실행해 보니, 처음에는 위젯이 제대로 출력되지 않았다.
원인을 확인해 보니 테스트 단계에서 플레이어가 게임 시작 시 이미 모든 스킬을 가지고 있었기 때문이었다.
즉, 레벨업을 해도 새로 해금될 스킬이 없으니 위젯도 뜰 수 없는 상태였다.
그래서 테스트를 위해 기존 초기화 로직을 약간 수정했다.
- 플레이어는 레벨 1 상태에서 시작
- 레벨 1에 맞는 스킬만 초기 지급
- 현재 레벨 이상에서만 초기 어빌리티를 부여하도록 조건 추가
ClassComponent 및 PlayerBase 수정
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
|
ClassComponentBase.cpp
TArray<FInitClassAbility> UClassComponentBase::GetInitClassAbilityInfo(FGameplayTag Class, int CurrentLevel) const
{
// 스킬 해금 UI 출력을 위해 임시로 수정
UDataTable* DT = nullptr;
TArray<FInitClassAbility> Result;
if (Class == PJTAG_JOB_CLASS_ARCHER)
{
DT = LoadObject<UDataTable>(nullptr, TEXT("/Game/09_DataAssets/Class/ClassDataTable/DT_ArcherAbility.DT_ArcherAbility"));
}
if (DT != nullptr)
{
// 어떤 레벨 기준으로 스킬을 필터링하는지 확인하기 위한 로그
UE_LOG(LogTemp, Log, TEXT("[InitClassAbility] Getting skills for Class '%s' at Level '%d'"), *Class.ToString(), CurrentLevel);
for (FName RowName : DT->GetRowNames())
{
const FAbilityData* RowData = DT->FindRow<FAbilityData>(RowName, TEXT("GetInitClassAbilityInfo"));
if (!RowData || !RowData->SkillDataAsset)
{
UE_LOG(LogTemp, Warning, TEXT("[InitClassAbility] Row '%s' has invalid SkillDataAsset."), *RowName.ToString());
continue;
}
const USkillDataAsset* SkillDA = RowData->SkillDataAsset;
const int32 RequireLevel = SkillDA->RequireLevel;
// 각 스킬의 요구 레벨을 확인하는 디버그 로그
UE_LOG(LogTemp, Log, TEXT("[InitClassAbility] Checking Skill '%s', RequireLevel: %d"), *SkillDA->GetName(), RequireLevel);
// 현재 레벨이 스킬 요구 레벨보다 높거나 같을 때만 스킬을 부여하도록 조건을 명확히 함
if (CurrentLevel >= RequireLevel)
{
FInitClassAbility AbilityInfo;
AbilityInfo.Ability = SkillDA->GameplayAbility;
AbilityInfo.bIsPassive = SkillDA->SkillTypeTags.HasTag(PJTAG_SKILL_TYPE_PASSVIE);
Result.Add(AbilityInfo);
// 어떤 스킬이 조건에 맞아 추가되었는지 확인하는 로그
UE_LOG(LogTemp, Log, TEXT("[InitClassAbility] Granted Skill '%s' (ReqLv: %d)"), *SkillDA->GetName(), RequireLevel);
}
}
}
return Result;
}
PlayerBase.cpp
void APlayerBase::PossessedBy(AController* NewController)
{
.
.
.
// 레벨, 클래스에 맞게 사용 가능한 어빌리티 초기화
ClassComp->InitClassAbility(ASC, ClassComp->GetCharacterClass(), 1); // Level -> 1 로 수정
.
.
.
}
|
cs |
이렇게 수정한 뒤 다시 테스트해 보면, 레벨이 올라갈 때마다 신규 스킬이 정상적으로 해금되고, UI도 습득 레벨이 낮은 순서대로 잘 출력되는 것을 확인할 수 있다.

이번 작업은 단순히 스킬 해금 위젯을 화면에 띄우는 것이 아니라, 레벨업 이벤트, 스킬 데이터, ASC 어빌리티 부여, 서버-클라이언트 UI 전달 구조를 하나로 연결한 작업이라고 볼 수 있다.
정리하면 이번 구현의 핵심은 아래와 같다.
- PlayerStateBase에서 레벨업 구간 감지
- ClassComponentBase에서 신규 해금 스킬 계산 및 실제 어빌리티 부여
- 서버가 소유 클라이언트에게 UI 이벤트 전송
- PlayerControllerBase가 이를 UIEventSubsystem으로 중계
- SkillUnlockedWidget이 이벤트를 구독하고 애니메이션과 함께 표시
- 복수 해금 상황까지 큐 기반으로 순차 출력 처리
이번 구현은 단순 UI 연출이 아니라, 레벨 시스템 연동, 데이터 테이블 기반 스킬 해금, ASC 부여 흐름, 서버 권한 처리, 중앙 이벤트 버스 구조까지 함께 보여줄 수 있다는 점에서 의미가 있다.
'Unreal Engine > Functional Implementation' 카테고리의 다른 글
| 언리얼 엔진 플레이어 사망 UI 구현 - 공격자 이름 표시와 경험치 페널티 계산 (0) | 2025.09.08 |
|---|---|
| ClassComponent 추가 변동 사항; 매개변수 추가 (0) | 2025.09.03 |
| 언리얼 엔진 신규 스킬 해금 UI 구현 - 위젯 출력과 스킬창 알림 배지 만들기 (5) | 2025.08.25 |
| 언리얼 엔진 GAS Aura 시스템 구현 - Gameplay Tag로 직업과 직업 차수에 맞는 Aura 출력하기 (0) | 2025.08.21 |
| 언리얼 엔진 네트워크 동기화 정리 - Multicast와 Replication 차이, 언제 어떻게 써야 할까 (1) | 2025.08.07 |