RPG나 MMORPG에서 캐릭터의 직업과 전직 단계에 따라 외형적인 차이를 주는 대표적인 방식 중 하나가 Aura 연출이다.
특히 같은 직업이라도 차수가 올라갈수록 다른 분위기의 이펙트를 출력하면, 플레이어는 현재 캐릭터의 성장 상태를 직관적으로 인식할 수 있다.
이번에는 GAS의 Gameplay Tag 기능을 활용해, Player의 직업(클래스)과 직업 차수(클래스 티어)를 확인한 뒤, 해당 정보에 맞는 Aura를 Data Asset에서 찾아 Gameplay Cue로 출력하는 구조를 구현해 보았다.
이번 작업의 목표는 아래와 같았다.
- Player의 직업과 직업 차수를 Gameplay Tag로 관리
- 직업과 차수에 따라 다른 Aura 출력
- Aura 정보를 Data Asset으로 관리해 확장성 확보
- Gameplay Cue를 이용해 실제 Niagara 이펙트 부착
- 온라인 프로젝트에서도 정상 동작하도록 네트워크 동기화 반영
즉, 이번 구현은 단순히 Niagara를 붙이는 것이 아니라, 상태 태그 → Aura 데이터 조회 → Gameplay Cue 출력 → 네트워크 동기화까지 이어지는 구조를 만드는 작업에 가깝다.
1. 먼저 Aura 관련 Gameplay Tag 정리
GAS를 활용하기 위해서는 우선 Gameplay Tag 설계가 필요하다.
이번 구조에서는 Aura가 Player에게 출력되는 이펙트 중 하나이기 때문에, Effect 하위에 Aura 관련 태그를 두었다.
또한 Aura는 직업과 직업 차수에 따라 달라져야 하므로, 아래처럼 직업과 티어를 구분할 수 있는 태그도 함께 추가했다.
- Job.Class.*
- Job.Tier.*
- GameplayCue.Aura.* 계열 태그
예를 들어 아래처럼 확장할 수 있다.
- Job.Class.Archer
- Job.Tier.1
- Job.Tier.2
이렇게 태그 체계를 먼저 잡아두면, 나중에 새로운 직업이나 전직 차수를 추가할 때도 코드 구조를 크게 바꾸지 않고 확장할 수 있다.

2. 직업별/티어별 Aura를 담는 Data Asset 만들기
다음으로 직업과 직업 차수에 따라 어떤 Aura를 출력할지 저장할 Data Asset 구조를 만들었다.
핵심 아이디어는 아래와 같다.
- FGameplayTag를 Key로 사용해 직업을 구분
- 각 직업마다 FAuraTierSet을 하나씩 보관
- FAuraTierSet 안에는 티어 순서대로 Niagara System 배열 저장
- 현재 직업 태그와 티어를 넣으면 해당하는 Niagara System 반환
즉, 직업 → 티어별 Niagara 목록 구조로 관리하는 방식이다.
AuraLibrary.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
|
#pragma once
#include "CoreMinimal.h"
#include "Engine/DataAsset.h"
#include "GameplayTagContainer.h"
#include "AuraLibrary.generated.h"
class UNiagaraSystem;
/** 티어별 나이아가라 세트 */
USTRUCT(BlueprintType)
struct FAuraTierSet
{
GENERATED_BODY()
/** 티어 순서대로 NS 지정; 0 = 1티어 */
UPROPERTY(EditAnywhere, BlueprintReadOnly)
TArray<TSoftObjectPtr<UNiagaraSystem>> TierNS;
};
UCLASS()
class MyGame_API UAuraLibrary : public UDataAsset
{
GENERATED_BODY()
public:
/** Key = Character.Class. 태그 */
UPROPERTY(EditAnywhere, BlueprintReadOnly)
TMap<FGameplayTag, FAuraTierSet> AuraMap;
/** 주어진 클래스 태그와 티어로 NS 반환(동기 로드) */
UFUNCTION(BlueprintCallable, BlueprintPure)
UNiagaraSystem* GetNiagaraSystem(const FGameplayTag& ClassTag, int32 Tier) const;
/** 클래스 태그에 해당하는 세트 찾기 */
const FAuraTierSet* FindSet(const FGameplayTag& ClassTag) const;
};
|
cs |
AuraLibrary.cpp
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
#include "AuraLibrary.h"
#include "NiagaraSystem.h"
UNiagaraSystem* UAuraLibrary::GetNiagaraSystem(const FGameplayTag& ClassTag, int32 Tier) const
{
const FAuraTierSet* Set = FindSet(ClassTag);
if (!Set) return nullptr;
const int32 Index = FMath::Clamp(Tier, 0, Set->TierNS.Num() - 1);
if (!Set->TierNS.IsValidIndex(Index)) return nullptr;
// 동기 로드(프리로드했다면 즉시 반환)
return Set->TierNS[Index].LoadSynchronous();
}
const FAuraTierSet* UAuraLibrary::FindSet(const FGameplayTag& ClassTag) const
{
if (const FAuraTierSet* Found = AuraMap.Find(ClassTag))
{
return Found;
}
return nullptr;
}
|
cs |
이 코드를 기반으로 AuraLibrary Data Asset을 만들면, 각 직업 태그를 Key로 넣고 TierNS 배열에 티어별 Niagara System을 넣어줄 수 있다.
예를 들어 Job.Class.Archer를 Key로 두고,
- 0번 Index = 1차 직업
- 1번 Index = 2차 직업
- 2번 Index = 3차 직업
이런 식으로 Aura를 넣으면 된다.

이번 프로젝트에서는 1차 직업일 때는 Aura를 출력하지 않을 예정이었기 때문에, 0번 Index는 비워두고 그다음 티어부터 Niagara System을 넣는 구조로 사용했다.
3. Aura를 유지시키기 위한 Infinite Gameplay Effect 준비
다음으로 Aura를 출력 상태로 유지시키기 위한 Gameplay Effect가 필요했다.
이번 프로젝트에서는 전직 후 해당 직업의 Aura를 지속적으로 출력할 생각이었기 때문에, Cooldown용 Effect를 새로 만드는 것이 아니라 Infinite Duration의 Gameplay Effect를 하나 만들어 사용했다.
즉, 이 GE의 역할은 스킬 쿨타임 관리가 아니라 Aura 상태를 붙들고 있는 지속 효과에 가깝다.
여기에는 아래와 같은 설정을 넣어주면 된다.
- Aura 관련 Gameplay Tag 지정
- Duration Policy = Infinite
- 필요시 Gameplay Cue와 연결

이렇게 하면 직업 변경 또는 전직 시 이 GE를 다시 적용하는 것만으로, 관련 Aura Gameplay Cue가 자연스럽게 활성화되도록 만들 수 있다.
4. Gameplay Cue가 실제 Aura 이펙트를 출력하도록 구성
이제 실제로 Player에게 Aura를 붙여주는 Gameplay Cue Notify Actor를 구현해야 한다.
이번에 만든 GCN_Aura의 역할은 아래와 같다.
- 대상의 ASC에서 현재 직업 태그 추출
- 대상의 ASC에서 현재 티어 태그 추출
- 해당 태그 조합에 맞는 Niagara System을 AuraLibrary에서 조회
- Player의 스켈레탈 메시를 찾아 Niagara System 부착
- 이후 태그가 바뀌면 Aura를 교체
즉, GCN_Aura는 현재 상태를 해석해서 적절한 Aura를 선택하고, 실제 이펙트를 부착하는 중간 계층이다.
GCN_Aura.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
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
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
|
#pragma once
#include "CoreMinimal.h"
#include "GameplayCueNotify_Actor.h"
#include "GameplayTagContainer.h"
#include "GCN_Aura.generated.h"
class UAbilitySystemComponent;
class USkeletalMeshComponent;
class UNiagaraComponent;
class UAuraLibrary;
class UClassComponentBase;
/**
* 클래스 태그(Job.Class.*)와 티어(Job.Tier.*)를 읽어 알맞은 NS를 Target Mesh에 부착
*/
UCLASS()
class MyGame_API AGCN_Aura : public AGameplayCueNotify_Actor
{
GENERATED_BODY()
public:
AGCN_Aura();
/** Cue가 활성화될 때 호출(스폰/재적용 포함) */
virtual bool OnActive_Implementation(AActor* MyTarget, const FGameplayCueParameters& Parameters) override;
/** 활성 중 주기적으로 호출(가벼운 파라미터 갱신용) */
virtual bool WhileActive_Implementation(AActor* MyTarget, const FGameplayCueParameters& Parameters) override;
/** Cue가 제거될 때 호출 */
virtual bool OnRemove_Implementation(AActor* MyTarget, const FGameplayCueParameters& Parameters) override;
protected:
/** EndPlay로 안전 정리 */
virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override;
/** 클래스 태그 -> 티어별 NS */
UPROPERTY(EditDefaultsOnly, Category = "Aura|DB")
TSoftObjectPtr<UAuraLibrary> AuraDB;
/** 클래스 루트태그; 클래스 판별 */
UPROPERTY(EditDefaultsOnly, Category = "Aura|Tags")
FGameplayTag ClassRootTag;
/** 티어 루트태그; 티어 판별 */
UPROPERTY(EditDefaultsOnly, Category = "Aura|Tags")
FGameplayTag TierRootTag;
/** 부착 대상 스켈메시 선택용 태그(있으면 우선 사용) */
UPROPERTY(EditDefaultsOnly, Category = "Aura|Attach")
FName TargetMeshComponentTag;
/** 로드된 AuraDB 인스턴스(지연 로딩 후 캐시) */
UPROPERTY(Transient)
TObjectPtr<UAuraLibrary> AuraDBAsset;
/** 현재 부착된 NS 컴포넌트 */
UPROPERTY(Transient)
UNiagaraComponent* AuraNC;
/** 캐시된 상태 */
UPROPERTY(Transient)
FGameplayTag CachedClassTag;
UPROPERTY(Transient)
int32 CachedTier; // 0-based
/** 이벤트 바인딩 대상 ASC (약한 참조) */
TWeakObjectPtr<UAbilitySystemComponent> BoundASC;
/** 태그 이벤트 핸들 */
FDelegateHandle TierTagChangedHandle;
FDelegateHandle ClassTagChangedHandle;
/** 현재 타깃(콜백에서 사용) */
TWeakObjectPtr<AActor> WeakTarget;
/** 최초 Parameters 백업(클라 지연 초기화용) */
UPROPERTY(Transient)
FGameplayCueParameters InitialParams;
private:
/** DB 지연 로딩/캐시 접근 */
UAuraLibrary* GetAuraDB();
/** 타깃의 스켈메시 해상(태그 지정 > 캐릭터 GetMesh > 첫 번째 스켈메시) */
USkeletalMeshComponent* ResolveTargetMesh(AActor* Target) const;
/** ASC로부터 클래스 태그 추출 (ClassRootTag 하위 중 최하위/가장 구체적인 태그) */
FGameplayTag ExtractClassTag(UAbilitySystemComponent* ASC, const FGameplayCueParameters* Params, AActor* Target) const; // [FIX] Target 추가
/** ASC로부터 Job.Tier.N(1-based) → 0-based 인덱스 계산 */
int32 ExtractTier(UAbilitySystemComponent* ASC, const FGameplayCueParameters* Params, AActor* Target) const; // [FIX] Target 추가
/** NS 스폰/교체(메시 원점에 부착, 동일 에셋이면 파라미터만 갱신) */
void SpawnOrSwitchNiagara(USkeletalMeshComponent* Mesh, const FGameplayTag& ClassTag, int32 Tier);
/** Niagara User 파라미터 갱신 */
void ApplyNiagaraParams();
/** ASC 태그 이벤트 바인딩/해제 */
void BindASCEvents(UAbilitySystemComponent* ASC);
void UnbindASCEvents();
/** 태그 이벤트 콜백 */
void OnTierTagChanged(FGameplayTag Tag, int32 NewCount);
void OnClassTagChanged(FGameplayTag Tag, int32 NewCount);
/** 지연 초기화(ASC/메시/태그 준비될 때까지 재시도) */
bool TryDeferredSetupOnce();
void StartDeferredSetup();
void StopDeferredSetup();
void CleanupNiagara();
private:
static const FName NIAGARA_PARAM_Tier;
/** 지연 초기화 타이머 */
FTimerHandle DeferredSetupTimer;
int32 DeferredAttempts = 0;
/** 재시도 최대 횟수(0.1s 간격) */
static constexpr int32 MaxDeferredAttempts = 20;
};
|
cs |
GCN_Aura.cpp
|
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
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
|
#include "GCN_Aura.h"
#include "AbilitySystemGlobals.h"
#include "AbilitySystemComponent.h"
#include "NiagaraFunctionLibrary.h"
#include "NiagaraComponent.h"
#include "MyGame/DataAsset/AuraLibrary.h"
#include "GameFramework/Character.h"
#include "Components/SkeletalMeshComponent.h"
#include "Engine/World.h"
#include "TimerManager.h"
#include "MyGame/AC/ClassComponentBase.h"
const FName AGCN_Aura::NIAGARA_PARAM_Tier(TEXT("User.AuraTier"));
AGCN_Aura::AGCN_Aura()
: AuraDBAsset(nullptr)
, AuraNC(nullptr)
, CachedTier(0)
{
ClassRootTag = FGameplayTag::RequestGameplayTag(FName("Job.Class"));
TierRootTag = FGameplayTag::RequestGameplayTag(FName("Job.Tier"));
bAutoDestroyOnRemove = false;
}
void AGCN_Aura::CleanupNiagara()
{
if (AuraNC)
{
AuraNC->DeactivateImmediate();
AuraNC->DestroyComponent();
AuraNC = nullptr;
}
}
void AGCN_Aura::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
StopDeferredSetup();
UnbindASCEvents();
CleanupNiagara();
Super::EndPlay(EndPlayReason);
}
UAuraLibrary* AGCN_Aura::GetAuraDB()
{
if (AuraDBAsset) return AuraDBAsset;
if (AuraDB.IsValid())
{
AuraDBAsset = AuraDB.Get();
}
else if (!AuraDB.IsNull())
{
AuraDBAsset = AuraDB.LoadSynchronous();
}
return AuraDBAsset;
}
/** Target의 SKM 찾기 */
USkeletalMeshComponent* AGCN_Aura::ResolveTargetMesh(AActor* Target) const
{
if (!Target) return nullptr;
// 태그로 지정된 SKM 우선
if (!TargetMeshComponentTag.IsNone())
{
TArray<USkeletalMeshComponent*> SkeletalComponents;
Target->GetComponents<USkeletalMeshComponent>(SkeletalComponents);
for (USkeletalMeshComponent* Sk : SkeletalComponents)
{
if (Sk && Sk->ComponentHasTag(TargetMeshComponentTag))
{
return Sk;
}
}
}
// 캐릭터면 GetMesh
if (const ACharacter* Char = Cast<ACharacter>(Target))
if (USkeletalMeshComponent* Mesh = Char->GetMesh())
return Mesh;
// 첫 번째 스켈레탈 찾기
TArray<USkeletalMeshComponent*> Comps;
Target->GetComponents<USkeletalMeshComponent>(Comps);
return Comps.Num() > 0 ? Comps[0] : nullptr;
}
/** ClassRoot 하위 중 가장 구체적인 태그 선택 */
static FGameplayTag FindMostSpecificClassTag(const FGameplayTagContainer& Container, const FGameplayTag& ClassRoot)
{
FGameplayTag BestTag;
int32 BestDepth = -1;
for (const FGameplayTag& T : Container)
{
if (T.MatchesTag(ClassRoot) && T != ClassRoot)
{
const FString S = T.ToString();
int32 Depth = 0;
for (int32 i = 0; i < S.Len(); ++i)
{
if (S[i] == TCHAR('.')) ++Depth;
}
if (Depth > BestDepth)
{
BestDepth = Depth;
BestTag = T;
}
}
}
return BestTag;
}
/** TierRoot 하위 Job.Tier.N 찾음 (없으면 INDEX_NONE) */
static int32 FindTierIndexInContainer(const FGameplayTagContainer& Container, const FGameplayTag& TierRoot)
{
int32 BestIndex = INDEX_NONE;
for (const FGameplayTag& T : Container)
{
if (T.MatchesTag(TierRoot) && T != TierRoot) // 루트 단독 스킵
{
const FString S = T.ToString();
int32 Dot = INDEX_NONE;
if (S.FindLastChar('.', Dot))
{
const FString Num = S.Mid(Dot + 1);
if (Num.IsNumeric())
{
const int32 OneBased = FCString::Atoi(*Num);
BestIndex = FMath::Max(BestIndex, FMath::Max(0, OneBased - 1));
}
}
}
}
return BestIndex;
}
/** 클래스 태그 추출 */
FGameplayTag AGCN_Aura::ExtractClassTag(UAbilitySystemComponent* ASC, const FGameplayCueParameters* Params, AActor* Target) const
{
if (Params)
{
if (FGameplayTag Found = FindMostSpecificClassTag(Params->AggregatedSourceTags, ClassRootTag); Found.IsValid())
return Found;
if (FGameplayTag Found = FindMostSpecificClassTag(Params->AggregatedTargetTags, ClassRootTag); Found.IsValid())
return Found;
}
if (ASC)
{
FGameplayTagContainer Owned;
ASC->GetOwnedGameplayTags(Owned);
if (FGameplayTag Found = FindMostSpecificClassTag(Owned, ClassRootTag); Found.IsValid())
return Found;
}
// 컴포넌트에서 현재 클래스 태그 얻기
if (Target)
if (UClassComponentBase* CC = Target->FindComponentByClass<UClassComponentBase>())
return CC->GetCurrentClassTagForAura();
return FGameplayTag();
}
/** 티어 추출 */
int32 AGCN_Aura::ExtractTier(UAbilitySystemComponent* ASC, const FGameplayCueParameters* Params, AActor* Target) const
{
if (Params)
{
int32 Idx = FindTierIndexInContainer(Params->AggregatedSourceTags, TierRootTag);
if (Idx != INDEX_NONE) return Idx;
Idx = FindTierIndexInContainer(Params->AggregatedTargetTags, TierRootTag);
if (Idx != INDEX_NONE) return Idx;
}
if (ASC)
{
FGameplayTagContainer Owned;
ASC->GetOwnedGameplayTags(Owned);
int32 Idx = FindTierIndexInContainer(Owned, TierRootTag);
if (Idx != INDEX_NONE) return Idx;
}
// 폴백: 컴포넌트에서 1-based 읽고 0-based 변환
if (Target)
if (UClassComponentBase* CC = Target->FindComponentByClass<UClassComponentBase>())
return FMath::Max(0, CC->GetCurrentTierOneBasedForAura() - 1);
return 0;
}
/** NS 스폰/교체 */
void AGCN_Aura::SpawnOrSwitchNiagara(USkeletalMeshComponent* Mesh, const FGameplayTag& ClassTag, int32 TierZeroBased)
{
UAuraLibrary* DB = GetAuraDB();
if (!DB || !Mesh)
{
UE_LOG(LogTemp, Warning, TEXT("[GCN_Aura] DB or Mesh null (DB=%d, Mesh=%d)"), DB ? 1 : 0, Mesh ? 1 : 0);
return;
}
UNiagaraSystem* NS = DB->GetNiagaraSystem(ClassTag, TierZeroBased);
if (!NS)
{
UE_LOG(LogTemp, Warning, TEXT("[GCN_Aura] NS not found. ClassTag=%s Tier(0b)=%d"),
*ClassTag.ToString(), TierZeroBased);
return;
}
if (AuraNC && AuraNC->GetAsset() == NS)
{
AuraNC->SetVariableInt(NIAGARA_PARAM_Tier, TierZeroBased);
return;
}
CleanupNiagara(); // 기존 컴포넌트 정리
AuraNC = UNiagaraFunctionLibrary::SpawnSystemAttached(
NS,
Mesh,
NAME_None,
FVector::ZeroVector,
FRotator::ZeroRotator,
EAttachLocation::KeepRelativeOffset,
false, // bAutoDestroy
true // bAutoActivate
);
if (AuraNC)
{
AuraNC->SetAbsolute(false, false, false);
AuraNC->SetRelativeScale3D(FVector::OneVector);
AuraNC->SetVariableInt(NIAGARA_PARAM_Tier, TierZeroBased);
AuraNC->Activate(true);
}
}
/** NS 파라미터 갱신 */
void AGCN_Aura::ApplyNiagaraParams()
{
if (!AuraNC) return;
AuraNC->SetVariableInt(NIAGARA_PARAM_Tier, CachedTier);
}
void AGCN_Aura::BindASCEvents(UAbilitySystemComponent* ASC)
{
UnbindASCEvents();
if (!ASC) return;
BoundASC = ASC;
TierTagChangedHandle = ASC->RegisterGameplayTagEvent(TierRootTag, EGameplayTagEventType::AnyCountChange)
.AddUObject(this, &AGCN_Aura::OnTierTagChanged);
ClassTagChangedHandle = ASC->RegisterGameplayTagEvent(ClassRootTag, EGameplayTagEventType::AnyCountChange)
.AddUObject(this, &AGCN_Aura::OnClassTagChanged);
}
void AGCN_Aura::UnbindASCEvents()
{
if (UAbilitySystemComponent* ASC = BoundASC.Get())
{
if (TierTagChangedHandle.IsValid())
{
ASC->RegisterGameplayTagEvent(TierRootTag, EGameplayTagEventType::AnyCountChange)
.Remove(TierTagChangedHandle);
}
if (ClassTagChangedHandle.IsValid())
{
ASC->RegisterGameplayTagEvent(ClassRootTag, EGameplayTagEventType::AnyCountChange)
.Remove(ClassTagChangedHandle);
}
}
TierTagChangedHandle.Reset();
ClassTagChangedHandle.Reset();
BoundASC.Reset();
}
void AGCN_Aura::OnTierTagChanged(FGameplayTag Tag, int32 NewCount)
{
UAbilitySystemComponent* ASC = BoundASC.Get();
if (!ASC) return;
const int32 NewTier = ExtractTier(ASC, /*Params=*/nullptr, WeakTarget.Get());
if (NewTier != CachedTier)
{
CachedTier = NewTier;
if (USkeletalMeshComponent* Mesh = ResolveTargetMesh(WeakTarget.Get()))
{
SpawnOrSwitchNiagara(Mesh, CachedClassTag, CachedTier);
}
}
else
{
ApplyNiagaraParams();
}
}
void AGCN_Aura::OnClassTagChanged(FGameplayTag Tag, int32 NewCount)
{
UAbilitySystemComponent* ASC = BoundASC.Get();
if (!ASC) return;
const FGameplayTag NewClass = ExtractClassTag(ASC, /*Params=*/nullptr, WeakTarget.Get());
if (NewClass != CachedClassTag && NewClass.IsValid())
{
CachedClassTag = NewClass;
if (USkeletalMeshComponent* Mesh = ResolveTargetMesh(WeakTarget.Get()))
{
SpawnOrSwitchNiagara(Mesh, CachedClassTag, CachedTier);
}
}
}
/** 한 번의 지연 재시도에서 설치 시도 */
bool AGCN_Aura::TryDeferredSetupOnce()
{
AActor* Target = WeakTarget.Get();
if (!Target) return false;
UAbilitySystemComponent* ASC = UAbilitySystemGlobals::GetAbilitySystemComponentFromActor(Target);
const FGameplayTag ClassTag = ExtractClassTag(ASC, &InitialParams, Target);
const int32 Tier = ExtractTier(ASC, &InitialParams, Target);
USkeletalMeshComponent* Mesh = ResolveTargetMesh(Target);
if (ClassTag.IsValid() && Mesh)
{
CachedClassTag = ClassTag;
CachedTier = Tier;
SpawnOrSwitchNiagara(Mesh, CachedClassTag, CachedTier);
if (ASC)
{
BindASCEvents(ASC);
}
return (AuraNC != nullptr);
}
return false;
}
void AGCN_Aura::StartDeferredSetup()
{
if (UWorld* World = GetWorld())
{
if (!World->GetTimerManager().IsTimerActive(DeferredSetupTimer))
{
DeferredAttempts = 0;
World->GetTimerManager().SetTimer(
DeferredSetupTimer,
[this]()
{
++DeferredAttempts;
if (TryDeferredSetupOnce() || DeferredAttempts >= MaxDeferredAttempts)
{
StopDeferredSetup();
}
},
0.1f, true
);
}
}
}
void AGCN_Aura::StopDeferredSetup()
{
if (UWorld* World = GetWorld())
{
World->GetTimerManager().ClearTimer(DeferredSetupTimer);
}
}
/** 최초 스폰/재적용 시 */
bool AGCN_Aura::OnActive_Implementation(AActor* MyTarget, const FGameplayCueParameters& Parameters)
{
if (!MyTarget) return false;
if (GetNetMode() == NM_DedicatedServer)
{
return true;
}
WeakTarget = MyTarget; // 콜백에서 사용할 타깃 보관
InitialParams = Parameters; // 클라 지연 초기화용 백업
UAbilitySystemComponent* ASC = UAbilitySystemGlobals::GetAbilitySystemComponentFromActor(MyTarget);
FGameplayTag ClassTag = ExtractClassTag(ASC, &InitialParams, MyTarget);
int32 Tier = ExtractTier(ASC, &InitialParams, MyTarget); // 0-based
UE_LOG(LogTemp, Log, TEXT("[GCN_Aura] OnActive: Target=%s ClassTag=%s Tier(0b)=%d ASC=%d"),
*GetNameSafe(MyTarget), *ClassTag.ToString(), Tier, ASC ? 1 : 0);
CachedClassTag = ClassTag;
CachedTier = Tier;
if (USkeletalMeshComponent* Mesh = ResolveTargetMesh(MyTarget))
{
SpawnOrSwitchNiagara(Mesh, CachedClassTag, CachedTier);
}
else
{
UE_LOG(LogTemp, Warning, TEXT("[GCN_Aura] Mesh not ready. Deferred setup start."));
StartDeferredSetup();
}
if (ASC)
{
BindASCEvents(ASC);
}
return true;
}
/** 변화 감지 시 가벼운 갱신(예: 티어 태그 바뀜) */
bool AGCN_Aura::WhileActive_Implementation(AActor* MyTarget, const FGameplayCueParameters& Parameters)
{
return true;
}
/** 정리 */
bool AGCN_Aura::OnRemove_Implementation(AActor* MyTarget, const FGameplayCueParameters& Parameters)
{
StopDeferredSetup();
UnbindASCEvents();
CleanupNiagara();
Destroy(); // GCN Actor 자체 정리
return true;
}
|
cs |
이 클래스의 핵심은 아래 두 가지다.
1) 현재 직업과 티어를 직접 추출
ExtractClassTag()와 ExtractTier()를 통해 현재 Player가 어떤 직업/차수 상태인지 해석한다.
2) Aura를 교체 가능한 구조로 유지
태그가 바뀌면 SpawnOrSwitchNiagara()를 호출해 기존 Aura를 정리하고 새 Aura를 붙인다.
즉, 한 번 출력하고 끝나는 것이 아니라 클래스 변경과 차수 변경에 따라 계속 따라갈 수 있는 Aura 구조를 만든 셈이다.
5. GCN_Aura의 동작 방식 정리
GCN_Aura.cpp는 코드 양이 많지만, 실제 핵심 흐름은 비교적 단순하다.
OnActive
- 최초 활성화 시 호출
- 대상 Actor, ASC, 직업 태그, 티어 추출
- 적절한 Niagara System을 찾아 메시에 부착
- ASC의 태그 변화 이벤트 바인딩
OnTierTagChanged / OnClassTagChanged
- 직업 태그나 티어 태그가 바뀌면 호출
- 현재 상태를 다시 해석하고 Aura 재교체
OnRemove
- Aura 제거 시 Niagara 정리
- ASC 이벤트 바인딩 해제
- GCN Actor 자체도 정리
Deferred Setup
온라인 환경이나 초기화 타이밍 문제로 메시 또는 ASC가 아직 준비되지 않았을 수 있으므로, 일정 시간 동안 재시도하는 지연 초기화 로직도 넣어두었다.
즉, 이번 GCN_Aura는 단순 출력기가 아니라 초기화 지연, 태그 기반 상태 해석, Aura 교체, 안전 정리까지 포함한 구조다.
6. Gameplay Cue BP 설정 시 확인할 점
위 코드를 상속받는 BP를 만들면, Details 패널에서 Aura 관련 태그와 Gameplay Cue Tag를 지정할 수 있다.
이때 확인해야 할 핵심은 아래와 같다.
- Aura 관련 태그가 제대로 연결되어 있는지
- Gameplay Cue Tag가 올바른지
- 중복 생성을 막기 위해 Unique Instance Per Source Object가 체크되어 있는지

특히 마지막 옵션은 중요하다.
같은 Source에서 Aura GE가 다시 적용될 때 Cue Actor가 중복 생성되면, 원치 않는 Aura 중첩 출력이 발생할 수 있기 때문이다.
7. 네트워크 동기화는 ClassComponentBase에서 처리
이 프로젝트는 온라인 MMO RPG 구조이기 때문에, Aura도 반드시 네트워크 동기화가 되어야 한다.
이미 이전에 전직 연출 효과를 ClassComponentBase에서 처리하고 있었고, 동기화 역시 그쪽에서 관리하고 있었기 때문에 이번 Aura 동기화도 같은 컴포넌트에서 처리하도록 했다.
즉, GCN_Aura는 출력 전용에 가깝고, 실제로 클래스/티어 태그를 ASC에 반영하고 Aura GE를 재적용하는 책임은 ClassComponentBase가 맡는다.
ClassComponentBase.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
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
107
108
109
110
111
112
113
114
115
116
117
118
119
|
#pragma once
#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "MyGame/Character/PlayerBase.h"
#include "MyGame/Struct/AbilityData.h"
#include "GameplayEffectTypes.h"
#include "GameplayTagContainer.h"
#include "ClassComponentBase.generated.h"
USTRUCT()
struct FInitClassAbility
{
GENERATED_BODY()
public:
UPROPERTY()
bool bIsPassive = false;
UPROPERTY()
TSubclassOf<UGameplayAbility> Ability;
};
UCLASS(Blueprintable, ClassGroup = (Custom), meta = (BlueprintSpawnableComponent))
class MyGame_API UClassComponentBase : public UActorComponent
{
GENERATED_BODY()
public:
UClassComponentBase();
virtual void BeginPlay() override;
/** 현재 캐릭터의 클래스를 Enum 값으로 반환하는 함수 */
EClassType GetCharacterClass() const;
/** 클래스와 레벨에 맞게 어빌리티 부여하는 함수 */
void InitClassAbility(UAbilitySystemComponent* ASC, EClassType Class, int CurrentLevel);
/** 클래스 변경; Niagara 이펙트 실행 (BP에서 간단하게 연결하여 확인하기 위해 BlueprintCallable 사용) */
UFUNCTION(BlueprintCallable, Category = "Class")
void ClassChange();
UFUNCTION(BlueprintCallable)
void SetEffectActive();
/** 새로운 클래스/티어를 적용하고 Aura GE를 재적용하고 싶을 때 호출 */
UFUNCTION(BlueprintCallable, Category = "Class")
void ApplyClassAndAura(EClassType NewClass, int32 NewTierOneBased);
/** 서버에서 실행되는 버전 */
UFUNCTION(Server, Reliable)
void Server_ApplyClassAndAura(EClassType NewClass, int32 NewTierOneBased);
/** ClassChange를 하나의 서버 RPC로 일괄 처리 (순서 보장) */
UFUNCTION(Server, Reliable)
void Server_ClassChange(EClassType NewClass, int32 NewTierOneBased);
/** GCN 폴백용 공개 */
UFUNCTION(BlueprintPure, Category = "Aura|Debug")
FGameplayTag GetCurrentClassTagForAura() const { return MakeClassTag(ClassType); }
UFUNCTION(BlueprintPure, Category = "Aura|Debug")
int32 GetCurrentTierOneBasedForAura() const { return FMath::Max(1, PendingTierOneBased); }
private:
/** 클래스와 레벨에 맞는 어빌리티 배열을 반환하는 함수 */
TArray<FInitClassAbility> GetInitClassAbilityInfo(EClassType Class, int CurrentLevel) const;
/** 현재 Owner의 클래스; 클라 폴백을 위해 Replicate */
UPROPERTY(Replicated, EditDefaultsOnly, BlueprintReadWrite, Category = Class, meta = (AllowPrivateAccess = "true"))
EClassType ClassType = EClassType::None;
FGameplayTag MakeClassTag(EClassType InClass) const;
FGameplayTag MakeTierTagOneBased(int32 OneBased) const;
/** ASC에 Loose 태그로 클래스/티어를 반영 */
void RefreshClassTag(UAbilitySystemComponent* ASC, const FGameplayTag& NewClassTag) const;
void RefreshTierTag(UAbilitySystemComponent* ASC, int32 NewTierOneBased) const;
/** 오라 GE 재적용 (기존 핸들 제거 후 다시 적용) */
void ReapplyAuraGE(UAbilitySystemComponent* ASC, const FGameplayTag& ClassTag, int32 TierOneBased);
/** 전직 시 적용할 티어 UI에서 세팅 후 ClassChange()를 호출; 클라 폴백을 위해 Replicate */
UPROPERTY(Replicated, EditAnywhere, BlueprintReadWrite, Category = Class, meta = (AllowPrivateAccess = "true", ClampMin = "1", UIMin = "1"))
int32 PendingTierOneBased = 1; // [FIX] Replicated
/** 무한 오라 GE (WhileActive에 GameplayCue.Aura가 등록된 GE) */
UPROPERTY(EditDefaultsOnly, Category = "Aura")
TSubclassOf<UGameplayEffect> AuraGEClass;
/** 현재 적용된 오라 GE 핸들 */
FActiveGameplayEffectHandle AuraGEHandle;
protected:
/** Replicate할 Effect On/Off 플래그 */
UPROPERTY(ReplicatedUsing = OnRep_EffectActive)
bool bEffectActive = false;
/** 루트 태그(프로젝트 트리: Job.Class / Job.Tier 사용) */
UPROPERTY(EditDefaultsOnly, Category = "Aura|Tags")
FGameplayTag ClassRootTag = FGameplayTag::RequestGameplayTag(TEXT("Job.Class"));
UPROPERTY(EditDefaultsOnly, Category = "Aura|Tags")
FGameplayTag TierRootTag = FGameplayTag::RequestGameplayTag(TEXT("Job.Tier"));
/** RepNotify (클라이언트에만 자동 호출) */
UFUNCTION()
void OnRep_EffectActive();
void PlayClassChangeEffect();
/** 클라이언트 -> 서버 RPC */
UFUNCTION(Server, Reliable)
void Server_SetEffectActive();
/** 컴포넌트 복제 등록 */
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
};
|
cs |
ClassComponentBase.cpp
|
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
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
|
#include "ClassComponentBase.h"
#include "DataRegistrySubsystem.h"
#include "Net/UnrealNetwork.h"
#include "NiagaraFunctionLibrary.h"
#include "MyGame/Character/PlayerBase.h"
#include "MyGame/Struct/AbilityData.h"
#include "AbilitySystemInterface.h"
#include "AbilitySystemComponent.h"
#include "GameplayTagsManager.h"
#include "GameplayEffect.h"
#include "Engine/DataTable.h"
UClassComponentBase::UClassComponentBase()
{
PrimaryComponentTick.bCanEverTick = false;
SetIsReplicatedByDefault(true);
}
void UClassComponentBase::BeginPlay()
{
Super::BeginPlay();
if (GetOwnerRole() == ROLE_Authority)
{
if (const IAbilitySystemInterface* ASI = Cast<IAbilitySystemInterface>(GetOwner()))
{
if (UAbilitySystemComponent* ASC = ASI->GetAbilitySystemComponent())
{
ASC->SetReplicationMode(EGameplayEffectReplicationMode::Full);
}
}
}
}
EClassType UClassComponentBase::GetCharacterClass() const
{
return ClassType;
}
void UClassComponentBase::InitClassAbility(UAbilitySystemComponent* ASC, EClassType Class, int CurrentLevel)
{
if (!ASC || CurrentLevel <= 0 || Class == EClassType::None) return;
auto Infos = GetInitClassAbilityInfo(Class, CurrentLevel);
if (!Infos.IsEmpty())
{
for (auto Info : Infos)
{
FGameplayAbilitySpec Spec(Info.Ability);
ASC->GiveAbility(Spec);
if (Info.bIsPassive)
{
ASC->TryActivateAbilityByClass(Info.Ability);
}
UE_LOG(LogTemp, Log, TEXT("[%s] %s 어빌리티가 부여되었습니다."), *GetOwner()->GetName(), *Spec.Ability->GetName());
}
}
}
void UClassComponentBase::ClassChange()
{
UE_LOG(LogTemp, Warning, TEXT("[ClassComp] ClassChange() called on %s Role=%d"), *GetOwner()->GetName(), GetOwnerRole());
// 클라이언트에서는 단일 서버 RPC로 일괄 처리 -> 중복/순서 이슈 제거
if (GetOwnerRole() < ROLE_Authority) // Client
{
Server_ClassChange(ClassType, PendingTierOneBased); // Aura GE 재적용 + FX 토글(복제)
return;
}
// 서버(호스트)에서만 실제 적용 수행
ApplyClassAndAura(ClassType, PendingTierOneBased); // AuraGE 재적용 -> GCN 트리거
bEffectActive = !bEffectActive; // 전직 FX 토글
OnRep_EffectActive();
}
void UClassComponentBase::SetEffectActive()
{
// 클라이언트라면 서버 RPC 호출
if (!GetOwner() || GetOwnerRole() < ROLE_Authority)
{
Server_SetEffectActive();
return;
}
// 서버(호스트)라면 즉시 토글 및 이펙트 재생
bEffectActive = !bEffectActive;
PlayClassChangeEffect();
}
TArray<FInitClassAbility> UClassComponentBase::GetInitClassAbilityInfo(EClassType Class, int CurrentLevel) const
{
UDataTable* DT;
TArray<FInitClassAbility> Result;
switch (Class)
{
case EClassType::None:
DT = nullptr;
break;
case EClassType::Archer:
DT = LoadObject<UDataTable>(nullptr, TEXT("/Game/09_DataAssets/Class/DT_ArcherAbility.DT_ArcherAbility"));
break;
default: DT = nullptr;
}
if (DT != nullptr)
{
IAbilitySystemInterface* AbilitySystemInterface = Cast<IAbilitySystemInterface>(GetOwner());
for (FName RowName : DT->GetRowNames())
{
const FAbilityData* RowData = DT->FindRow<FAbilityData>(RowName, TEXT("순회"));
bool bHasRequiredLevel = RowData->Ability && CurrentLevel >= RowData->RequiredLevel;
bool bHasRequiredTag = RowData->EnhanceRequiredTags.IsEmpty() ||
(!RowData->EnhanceRequiredTags.IsEmpty() && AbilitySystemInterface->GetAbilitySystemComponent()->HasAllMatchingGameplayTags(RowData->EnhanceRequiredTags));
if (bHasRequiredLevel && bHasRequiredTag)
{
FInitClassAbility AbilityInfo = FInitClassAbility();
AbilityInfo.Ability = RowData->Ability;
AbilityInfo.bIsPassive = RowData->Type == EAbilityType::Passive;
Result.Add(AbilityInfo);
}
}
}
return Result;
}
void UClassComponentBase::OnRep_EffectActive()
{
// 클라이언트에서 RepNotify 발생 시 처리
PlayClassChangeEffect();
}
void UClassComponentBase::PlayClassChangeEffect()
{
// 소유자 캐스트
if (auto* Player = Cast<APlayerBase>(GetOwner()))
{
for (UNiagaraSystem* FX : Player->ClassChangeEffects)
{
if (FX)
{
UNiagaraFunctionLibrary::SpawnSystemAttached(
FX,
Player->GetRootComponent(),
NAME_None,
FVector::ZeroVector,
FRotator::ZeroRotator,
EAttachLocation::KeepRelativeOffset,
true
);
}
}
}
}
void UClassComponentBase::Server_SetEffectActive_Implementation()
{
bEffectActive = !bEffectActive;
OnRep_EffectActive();
}
void UClassComponentBase::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(UClassComponentBase, bEffectActive);
// 폴백용 상태 복제
DOREPLIFETIME(UClassComponentBase, ClassType);
DOREPLIFETIME(UClassComponentBase, PendingTierOneBased);
}
/** enum -> Job.Class.* */
FGameplayTag UClassComponentBase::MakeClassTag(EClassType InClass) const
{
switch (InClass)
{
case EClassType::Archer: return FGameplayTag::RequestGameplayTag(TEXT("Job.Class.Archer"));
// TODO: 다른 직업 추가
default:
return FGameplayTag();
}
}
/** Job.Tier.N */
FGameplayTag UClassComponentBase::MakeTierTagOneBased(int32 OneBased) const
{
OneBased = FMath::Max(1, OneBased);
const FString TierName = FString::Printf(TEXT("Job.Tier.%d"), OneBased);
return FGameplayTag::RequestGameplayTag(FName(*TierName));
}
/** 기존 클래스 태그 전부 제거 후 새 태그 부여(Loose Tag) */
void UClassComponentBase::RefreshClassTag(UAbilitySystemComponent* ASC, const FGameplayTag& NewClassTag) const
{
if (!ASC) return;
FGameplayTagContainer Owned;
ASC->GetOwnedGameplayTags(Owned);
TArray<FGameplayTag> ToRemove;
for (const FGameplayTag& T : Owned)
{
if (T.MatchesTag(ClassRootTag))
{
ToRemove.Add(T);
}
}
for (const FGameplayTag& T : ToRemove)
{
if (ASC->HasMatchingGameplayTag(T))
{
ASC->RemoveLooseGameplayTag(T);
}
}
if (NewClassTag.IsValid())
{
ASC->AddLooseGameplayTag(NewClassTag); // Loose로 심기(복제 안정)
}
}
/** 기존 Job.Tier.* 제거 후 Job.Tier.N 부여(Loose Tag) */
void UClassComponentBase::RefreshTierTag(UAbilitySystemComponent* ASC, int32 NewTierOneBased) const
{
if (!ASC) return;
FGameplayTagContainer Owned;
ASC->GetOwnedGameplayTags(Owned);
TArray<FGameplayTag> ToRemove;
for (const FGameplayTag& T : Owned)
{
if (T.MatchesTag(TierRootTag))
{
ToRemove.Add(T);
}
}
for (const FGameplayTag& T : ToRemove)
{
if (ASC->HasMatchingGameplayTag(T))
{
ASC->RemoveLooseGameplayTag(T);
}
}
NewTierOneBased = FMath::Max(1, NewTierOneBased);
const FGameplayTag TierTag = MakeTierTagOneBased(NewTierOneBased);
ASC->AddLooseGameplayTag(TierTag);
}
/** 오라 GE 재적용 */
void UClassComponentBase::ReapplyAuraGE(UAbilitySystemComponent* ASC, const FGameplayTag& ClassTag, int32 TierOneBased)
{
if (!ASC || !AuraGEClass) return;
// 기존 오라 제거
if (AuraGEHandle.IsValid())
{
ASC->RemoveActiveGameplayEffect(AuraGEHandle);
AuraGEHandle.Invalidate();
}
// 새 오라 적용 (무한 GE; WhileActive에 GameplayCue.Aura 필요)
FGameplayEffectContextHandle Ctx = ASC->MakeEffectContext();
FGameplayEffectSpecHandle Sh = ASC->MakeOutgoingSpec(AuraGEClass, 1.f, Ctx);
if (!Sh.IsValid()) return;
// GE Spec의 DynamicGrantedTags에 클래스/티어 태그를 주입
if (ClassTag.IsValid())
{
Sh.Data->DynamicGrantedTags.AddTag(ClassTag);
}
const FGameplayTag TierTag = MakeTierTagOneBased(TierOneBased);
if (TierTag.IsValid())
{
Sh.Data->DynamicGrantedTags.AddTag(TierTag);
}
AuraGEHandle = ASC->ApplyGameplayEffectSpecToSelf(*Sh.Data.Get());
// 즉시 복제 유도를 위해 소유 액터 ForceNetUpdate
if (AActor* OwnerActor = GetOwner())
{
OwnerActor->ForceNetUpdate();
}
}
/** 외부에서 호출: 새로운 클래스/티어 적용 후 오라 재적용 */
void UClassComponentBase::ApplyClassAndAura(EClassType NewClass, int32 NewTierOneBased)
{
const bool bAuth = (GetOwnerRole() >= ROLE_Authority);
UE_LOG(LogTemp, Log, TEXT("[ClassComp] ApplyClassAndAura: Auth=%d NewClass=%d NewTier=%d"), bAuth ? 1 : 0, (int32)NewClass, NewTierOneBased);
// 서버 위임
if (!bAuth)
{
Server_ApplyClassAndAura(NewClass, NewTierOneBased);
return;
}
IAbilitySystemInterface* ASI = Cast<IAbilitySystemInterface>(GetOwner());
UAbilitySystemComponent* ASC = ASI ? ASI->GetAbilitySystemComponent() : nullptr;
if (!ASC)
{
return;
}
const FGameplayTag NewClassTag = MakeClassTag(NewClass);
const int32 TierOneBased = FMath::Max(1, NewTierOneBased);
// 클래스 태그 갱신
RefreshClassTag(ASC, NewClassTag);
RefreshTierTag(ASC, TierOneBased);
// 오라 GE 재적용 → GCN_Aura가 새 태그로 NS 장착
ReapplyAuraGE(ASC, NewClassTag, TierOneBased);
// 내부 상태 갱신 (복제됨 -> GCN 폴백에서 사용 가능)
ClassType = NewClass;
PendingTierOneBased = TierOneBased;
// 적용 직후 한 번 더 갱신
if (AActor* OwnerActor = GetOwner())
{
OwnerActor->ForceNetUpdate();
}
// 현재 보유 태그 요약(루트만)
{
FGameplayTagContainer Owned;
ASC->GetOwnedGameplayTags(Owned);
int32 ClassCount = 0, TierCount = 0;
for (const FGameplayTag& T : Owned)
{
if (T.MatchesTag(ClassRootTag)) ++ClassCount;
if (T.MatchesTag(TierRootTag)) ++TierCount;
}
UE_LOG(LogTemp, Log, TEXT("[ClassComp] OwnedTags: Class=%d, Tier=%d under roots"), ClassCount, TierCount);
}
}
/** 서버 RPC */
void UClassComponentBase::Server_ApplyClassAndAura_Implementation(EClassType NewClass, int32 NewTierOneBased)
{
UE_LOG(LogTemp, Warning,
TEXT("[ClassComp][Server] Server_ApplyClassAndAura() called. Class=%d, Tier=%d"),
(int32)NewClass, NewTierOneBased);
ApplyClassAndAura(NewClass, NewTierOneBased);
}
/** 단일 서버 RPC로 ClassChange 일괄 처리 (순서 엇갈림 방지) */
void UClassComponentBase::Server_ClassChange_Implementation(EClassType NewClass, int32 NewTierOneBased)
{
// AuraGE 재적용(서버에서 확정) -> GCN 트리거가 클라로 복제됨
ApplyClassAndAura(NewClass, NewTierOneBased);
// 전직 FX 토글(복제)
bEffectActive = !bEffectActive;
// 서버에서도 즉시 재생
OnRep_EffectActive(); // (서버 로컬 재생)
}
|
cs |
ClassComponentBase.cpp 핵심 흐름
이 컴포넌트의 핵심 로직은 아래와 같다.
- ApplyClassAndAura()
새로운 클래스와 티어를 적용 - RefreshClassTag()
기존 Job.Class.* 태그 제거 후 새 클래스 태그 부여 - RefreshTierTag()
기존 Job.Tier.* 태그 제거 후 새 티어 태그 부여 - ReapplyAuraGE()
기존 Aura GE를 제거하고 새 Aura GE 재적용 - ForceNetUpdate()
서버에서 변경된 상태를 빠르게 클라이언트에 전파
즉, 실제 네트워크 동기화 흐름은 서버에서 클래스/티어 태그 갱신 → Aura GE 재적용 → Gameplay Cue가 클라에 반영
이라는 구조로 움직이게 된다.
8. ApplyClassAndAura로 테스트
이제 동작을 확인하기 위해, ClassComponentBase에 만들어둔 BlueprintCallable 함수를 Player BP에서 호출해 테스트할 수 있다.
- New Class에는 변경할 직업 지정
- New Tier One Based에는 직업 차수 입력

예를 들어
- Archer
- 3
처럼 설정하면, Player가 Archer의 2차로 전직한 상태로 바뀌고, 그에 맞는 Aura가 출력된다.
이번 테스트에서는 키 입력으로 이 함수를 실행하도록 연결했고, 실제로 눌러보면 직업과 직업 차수에 따라 서로 다른 Aura가 정상 출력되는 것을 확인할 수 있었다.

온라인 환경에서도 동기화는 정상적으로 동작했지만, 작업 중인 PC 사양 문제로 네트워크 동기화 장면 자체를 영상으로 남기지는 못했다.
이번 작업은 단순히 Aura 이펙트 하나를 붙이는 것이 아니라, Player의 직업과 직업 차수 상태를 GAS Gameplay Tag로 관리하고, 그 상태에 맞는 Aura를 Data Asset과 Gameplay Cue를 통해 출력하도록 구조를 만든 작업이다.
정리하면 이번 구현의 핵심은 아래와 같다.
- Gameplay Tag로 직업과 티어 상태 관리
- AuraLibrary Data Asset으로 직업별/티어별 Niagara 이펙트 관리
- GCN_Aura에서 현재 상태를 해석해 적절한 Aura 부착
- ClassComponentBase에서 태그 갱신과 Aura GE 재적용
- 온라인 환경에서도 동작하도록 네트워크 동기화 반영
이번 구현은 단순 이펙트 연출을 넘어서, GAS 활용, Gameplay Tag 기반 상태 해석, Data Asset 기반 확장 구조, Gameplay Cue 설계, 네트워크 동기화 처리까지 함께 보여줄 수 있다는 점에서 의미가 있다.
'Unreal Engine > Functional Implementation' 카테고리의 다른 글
| 언리얼 엔진 스킬 해금 UI 구현 - 레벨업 시 신규 스킬 정보를 위젯에 표시하기 (0) | 2025.09.01 |
|---|---|
| 언리얼 엔진 신규 스킬 해금 UI 구현 - 위젯 출력과 스킬창 알림 배지 만들기 (5) | 2025.08.25 |
| 언리얼 엔진 네트워크 동기화 정리 - Multicast와 Replication 차이, 언제 어떻게 써야 할까 (1) | 2025.08.07 |
| 언리얼 엔진 Rich Text Block Gradient 글꼴 적용 - Font Material과 Widget 애니메이션 정리 (0) | 2025.08.01 |
| NPC - Player 상호작용 (Paused) (2) | 2025.07.28 |