Post

2026-04-21 TIL (41일차)

2026-04-21 TIL (41일차)

Unreal

아이템 시스템 구현 설계: 상속과 인터페이스의 활용


• 상속 vs 인터페이스

  • 상속 “부모의 유산을 물려받는 것”
    • 부모 클래스(Base Class)가 가진 속성(변수)과 기능(함수)을 자식 클래스가 그대로 물려받아 사용하는 개념입니다. (예: AActor를 상속받으면 트랜스폼, 충돌, 렌더링 기능을 물려받음)
  • 인터페이스: “반드시 지켜야 할 약속(규격)을 정하는 것”
    • 구체적인 기능 구현은 없고, “이 인터페이스를 상속받는다면 반드시 A, B, C 함수를 구현해야 한다”라는 설계도(함수 원형)만 제공합니다.


• 인터페이스를 활용한 설계 및 장점

모든 아이템의 공통적인 특징은 “플레이어와 오버랩(충돌)했을 때 특정한 상호작용을 한다”는 것입니다. 이를 인터페이스로 설계합니다.

🔹 구현 순서

  1. 인터페이스 정의: UInterface를 생성하고, OnItemOverlap()과 같은 상호작용 함수의 원형(가상 함수)만 정의합니다.
  2. 클래스 적용: 아이템 역할을 할 클래스들이 이 인터페이스를 상속(public IItemInterface)받아, 각자 자신의 특징에 맞게 함수를 재정의(Override)하여 구현합니다.

🔹 인터페이스 사용의 핵심 장점

  • 인터페이스를 안 쓴다면? (의존성 증가): 플레이어가 어떤 물체와 충돌했을 때, 그 물체가 코인인지, 지뢰인지, 포션인지 일일이 캐스팅(Casting)해서 타입을 확인하고, 각 클래스에 맞는 전용 함수를 각각 불러줘야 합니다. 아이템 종류가 100개면 if-else문이 100개가 필요합니다.
  • 인터페이스를 쓴다면? (의존성 감소): 플레이어는 충돌한 물체가 무슨 아이템인지 알 필요가 전혀 없습니다. 그저 “너 IItemInterface를 가지고 있어? 그럼 네가 가진 상호작용 함수를 실행해!”라고 명령(Execute_OnItemOverlap)만 내리면 끝납니다. 새로운 아이템이 수백 개 추가되어도 플레이어 코드는 단 한 줄도 수정할 필요가 없습니다.


• Unreal 인터페이스 헤더 분석

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
UINTERFACE(MinimalAPI)
class UItemInterface : public UInterface
{
    GENERATED_BODY()
};

/**
 * 
 */
class WEEK3_ASSIGNMENTS_API IItemInterface
{
    GENERATED_BODY()

    // Add interface functions to this class. This is the class that will be inherited to implement this interface.
public:
    
};

🔹 왜 클래스가 2개로 나뉘어져 있을까?

이러한 구조는 언리얼 엔진의 리플렉션 시스템(가비지 컬렉션, 블루프린트 연동 등)이 C++의 순수 다중 상속을 지원하지 않기 때문입니다. 이를 해결하기 위해 언리얼은 두 개의 클래스를 조합하여 인터페이스를 흉내냅니다.

1. UItemInterface (U-클래스)

  • 역할: 언리얼 엔진의 런타임 및 리플렉션 시스템(UObject 시스템)에 “이러한 인터페이스가 존재한다”고 등록하는 껍데기(타입 정보용) 클래스입니다.
  • 수정 여부: 수정하지 않습니다. 엔진 내부에서 타입 확인(예: IsA(), Implements<>())이나 가비지 컬렉션을 위해 보이지 않게 사용됩니다.
  • 특징: 블루프린트에서 이 인터페이스를 타입으로 참조하거나 적용할 때, 엔진은 내부적으로 이 U 클래스의 정보를 사용합니다.

2. IItemInterface (I-클래스)

  • 역할: C++에서 실제로 다중 상속을 받고, 우리가 구현할 가상 함수(기능)들을 정의하는 실제 인터페이스 클래스입니다.
  • 수정 여부: 이곳에 코드를 작성해야 합니다. 사용할 가상 함수나 블루프린트 네이티브 이벤트 등을 이 클래스 내부 public: 아래에 선언합니다.
  • 특징: 다른 액터나 UObject가 이 인터페이스를 상속받을 때는 반드시 U가 아닌 IItemInterface를 상속받아야 합니다.

🔹 요약 및 활용 팁

  1. 규칙: 위에 있는 UItemInterface는 건드리지 말고 그대로 둡니다.
  2. 함수 선언: 아래에 있는 IItemInterface 내부에 virtual void DoSomething() = 0; 혹은 UFUNCTION(BlueprintNativeEvent) 등을 선언합니다.
  3. 상속 방법: 새로운 클래스(예: 포션 아이템)에서 이 인터페이스를 사용할 때는 public IItemInterface를 다중 상속받아 사용합니다.
1
2
3
4
5
6
7
8
// 예시: 액터에 인터페이스 상속받기
UCLASS()
class AHealthPotion : public AActor, public IItemInterface // 반드시 'I'를 상속
{
    GENERATED_BODY()
    
    // ... IItemInterface의 함수 구현 ...
};


• 아이템 클래스 계층 구조 설계

1
2
3
// 최상위 부모 (BaseItem)
// AActor를 상속받아 게임 세상에 배치될 수 있게 하고, IItemInterface로 아이템 규격을 장착함
class ABaseItem : public AActor, public IItemInterface
  • [최상위 부모] ABaseItem: 모든 아이템의 뼈대. 스태틱 메쉬, 콜리전 박스, 인터페이스 오버라이드 뼈대 등 공통 컴포넌트만 가집니다.
    • [중간 부모] ACoinItem: ABaseItem을 상속. 점수를 올려주는 기능의 뼈대를 구현합니다.
      • [최종 자식] ASmallCoinItem: 10점짜리 작은 코인 (실제 배치용)
      • [최종 자식] ABigCoinItem: 50점짜리 큰 코인 (실제 배치용)
    • [중간 부모] AMineItem: 데미지를 주는 지뢰 아이템의 뼈대.
    • [중간 부모] AHealingItem: 체력을 회복시켜주는 포션 아이템의 뼈대.


• Base, Coin 등 부모 클래스에서 구체적인 값을 지정하지 않는 이유

ABaseItem이나 ACoinItem 같은 부모 클래스에 특정 타입(이름)이나 구체적인 점수(수치)를 하드코딩(고정)하지 않습니다. 그 이유는 객체 지향 프로그래밍의 핵심인 ‘추상화(Abstraction)’‘재사용성’ 때문입니다.

1) 상속의 경직성 방지 (유연성 확보)

만약 ACoinItem 클래스 내부에서 점수를 ‘10점’, 크기를 ‘작음’으로 완전히 박아버린다면, 이를 상속받는 ABigCoinItem은 부모가 강제로 설정한 10점을 지우고 50점으로 덮어써야 하는 불필요한 연산 비용과 구조적 오류가 발생합니다.

2) 추상적인 뼈대 역할 (템플릿)

ABaseItem이나 ACoinItem은 실세계에 존재하는 구체적인 물건이라기보다는, “코인이라는 것은 점수라는 변수를 가지고 있고, 획득하면 소리가 나야 해”라는 개념(템플릿)을 정의하는 곳입니다. 실질적인 값(10점, 50점, 빨간색, 파란색 등)은 이 뼈대를 상속받아 파생된 가장 마지막 자식 클래스(또는 블루프린트)에서 설정해 주는 것이 올바른 구조입니다.

3) 데이터 주도 설계 지원

C++ 기반 부모 클래스에서 변수(예: int32 ItemScore;)만 선언해 두고 값을 고정하지 않으면, 기획자나 디자이너가 언리얼 에디터(블루프린트)에서 자식 클래스를 수십 개 복제해 나가며 자유롭게 수치만 바꿔 새로운 아이템들을 무한히 찍어낼 수 있습니다. (결과적으로 프로그래머가 C++ 코드를 매번 수정하고 컴파일할 필요가 없어집니다.)




충돌 이벤트

언리얼 엔진에서 액터나 컴포넌트가 다른 객체와 상호작용하려면 충돌(Collision) 처리가 필수적입니다. 아이템 획득, 데미지 판정, 벽에 부딪히기 등 모든 물리적/논리적 상호작용이 이 시스템을 기반으로 작동합니다.

Tip: BP에서 Tag 추가하기

C++ 코드에서 OtherActor->ActorHasTag("Player")처럼 태그를 기준으로 충돌 대상을 판별하려면, 해당 액터 블루프린트에 태그를 설정해 두어야 합니다.

[태그 추가 순서]

  1. 태그를 부여할 플레이어 또는 액터 블루프린트(BP)를 엽니다.
  2. 우측의 디테일(Details) 패널을 확인합니다.
  3. Actor 섹션을 찾거나, 패널 위쪽 검색창에 ‘Tag’를 검색합니다.
  4. Tags 항목의 + 버튼을 눌러 배열(Array) 요소를 추가한 뒤, 원하는 태그 이름(예: Player)을 입력합니다.

주의사항: 입력한 태그의 대소문자와 띄어쓰기가 C++ 코드(ActorHasTag 안의 문자열)와 일치해야만 정상적으로 인식됩니다!


• 충돌 이벤트의 종류

액터 간 상호작용 시 발생하는 이벤트는 크게 오버랩(Overlap)히트(Hit) 두 가지로 나뉩니다.

🔹 오버랩 (Overlap) 이벤트

  • 정의: 객체들이 물리적으로 부딪혀서 튕겨 나가지 않고, 서로 “겹치기 시작할 때(통과할 때)” 발생하는 이벤트입니다.
  • 사용처: * 플레이어가 다가가면 자동으로 획득되는 아이템
    • 특정 구역에 들어가면 문이 열리거나 적이 스폰되는 트리거 존(센서)

🔹 히트 (Hit) 이벤트

  • 정의: 객체들이 물리적으로 겹치지 못하고, 표면이 맞닿아 “막히거나 튕겨 나가는 실제 물리 충돌”이 일어날 때 발생하는 이벤트입니다.
  • 사용처: * 캐릭터가 벽에 부딪혀 더 이상 전진하지 못할 때
    • 날아가던 총알이나 화살이 바닥에 튕길 때

Tip: 콜리전 컴포넌트를 사용하는 이유 눈에 보이는 ‘스태틱 메시(Static Mesh)’ 자체에도 충돌을 세팅할 수는 있습니다. 하지만 메시의 복잡한 폴리곤 형태를 그대로 충돌 판정에 사용하면 연산이 너무 무거워져 게임 성능이 저하됩니다. 따라서 눈에 보이는 것은 스태틱 메시로 두고, 충돌 판정은 계산이 가벼운 단순한 도형인 콜리전 컴포넌트(Sphere, Box, Capsule)를 따로 씌워서 처리하는 것이 정석입니다.


• 콜리전 프리셋 정리

  • 정의: 언리얼 엔진은 자주 쓰이는 충돌 설정(누구랑은 겹치고, 누구랑은 막힐 것인지)을 미리 세팅해 놓았습니다. 이를 프리셋(Preset, 사전 설정)이라고 부릅니다.

기본 제공 프리셋 종류와 사용처

  1. NoCollision (충돌 없음)
    • 역할: 어떠한 충돌이나 겹침도 감지하지 않습니다.
    • 사용처: 단순히 배경을 꾸며주는 하늘, 멀리 있는 산, 장식용 풀이나 구름 등.
  2. BlockAll (모두 차단)
    • 역할: 게임 내의 모든 객체와 물리적으로 부딪히고(Hit) 통과를 막습니다.
    • 사용처: 절대로 뚫고 지나갈 수 없는 단단한 벽, 바닥, 거대한 바위 등.
  3. OverlapAll (모두 겹침)
    • 역할: 모든 객체의 통과를 허용하되, 통과하는 순간 오버랩 이벤트를 발생시킵니다.
    • 사용처: 투명한 센서 영역, 맵 전체에 깔리는 환경 이펙트 구역 등.
  4. BlockAllDynamic (동적 객체만 차단)
    • 역할: 플레이어나 굴러가는 공처럼 ‘움직이는(Dynamic) 객체’와의 충돌만 막습니다.
    • 사용처: 움직이는 엘리베이터, 부서지는 장애물 등.
  5. OverlapAllDynamic (동적 객체만 겹침)
    • 역할: 움직이는 객체가 들어왔을 때만 오버랩 이벤트를 발생시킵니다.
    • 사용처: 아이템 획득 범위, 플레이어 감지 센서. 물리 충돌(Hit)은 필요 없고 겹침(Overlap)만 필요할 때 최적입니다.
  6. Pawn (폰)
    • 역할: 플레이어나 AI 같은 ‘Pawn’ 객체를 대상으로 한 프리셋입니다.
    • 사용처: 플레이어 캐릭터의 캡슐 컴포넌트(몸통) 등에 기본적으로 적용됩니다.


• 콜리전 활성화 상태 (Collision Enabled)

만약 기본 제공 프리셋이 아니라 Custom을 하려면, 먼저 Collision Enabled 옵션의 정의를 알아야 합니다. 이 옵션은 해당 컴포넌트가 ‘어떤 종류의 충돌 연산을 수행할지’를 결정합니다.

  • No Collision: 충돌 연산을 아예 끕니다. (가장 가벼움)
  • Query Only (No Physics Collision): * 정의: 물리적으로 튕겨 나가는 반응(Physics)은 하지 않고, 오로지 ‘겹침(Overlap)’이나 ‘레이캐스트(Line Trace, 시야 검사)’ 같은 공간 검색(Query)만 감지하겠다는 뜻입니다.
    • 사용처: 아이템 획득 범위, 트리거 박스.
  • Physics Only (No Query Collision): * 정의: 물리 엔진의 시뮬레이션(중력, 튕김, 굴러감)만 적용받고, 겹침(Overlap) 이벤트 등은 감지하지 않습니다.
    • 사용처: 바닥에 굴러다니는 빈 깡통, 파편 이펙트.
  • Collision Enabled (Query and Physics): * 정의: 겹침/레이캐스트(Query)도 감지하고, 실제 물리적 부딪힘(Physics)도 모두 수행합니다. (가장 무거움)
    • 사용처: 플레이어 캐릭터, 굴러가면서 데미지도 주는 거대한 돌 던전 트랩 등.


이벤트 바인딩

• 이벤트 바인딩을 하는 이유

“그냥 OnComponentBeginOverlap 이거 쓰면 안되나? 왜 굳이 내 함수랑 연결(Bind)을 해줘야 하지?”

  • 예측 불가능한 런타임 환경: 게임 실행 중(Runtime)에는 언제, 어떤 객체가, 어떻게 충돌할지 미리 알 수 없습니다.
  • 엔진과 내 코드의 역할 분담: 언리얼 엔진은 “충돌이 일어났다!”라는 신호(Event)를 발생시키는 것까지만 책임집니다. 충돌했을 때 코인이 올라갈지, 체력이 깎일지, 문이 열릴지 엔진은 알 수 없습니다.
  • 동적 연결: 따라서 엔진이 발생시킨 신호에 “내가 직접 만든 로직(함수)”을 게임 실행 중에 동적으로 연결해두는 것입니다. “충돌 이벤트가 터지면, 준비해둔 내 함수를 실행해줘!” 라고 예약하는 과정이 바로 이벤트 바인딩입니다.

함수 시그니처를 똑같이 맞춰야 하는 이유

내 함수를 엔진의 이벤트에 연결하려면, 내 함수의 매개변수 형태가 엔진이 요구하는 형태와 틀리지 않고 똑같아야 합니다. * 함수 시그니처(Signature)란? 함수의 이름, 반환형, 그리고 매개변수들의 타입과 순서를 통틀어 부르는 말입니다. (사람의 지문이나 서명처럼 함수를 식별하는 규격입니다.)

  • 왜 맞춰야 할까? (플러그와 콘센트): 언리얼의 이벤트 시스템(OnComponentBeginOverlap 등)은 델리게이트(Delegate, 대리자)라는 기술로 구현되어 있습니다. 델리게이트는 ‘특정 모양의 플러그만 꽂을 수 있는 콘센트’와 같습니다. 엔진이 충돌 순간에 6개의 데이터(전기)를 쏴주는데, 내 함수가 그 6개의 데이터를 받을 수 있는 똑같은 모양의 플러그(매개변수 6개)를 가지고 있지 않으면 아예 연결(AddDynamic) 자체가 되지 않기 때문입니다.

🔹 OnComponentBeginOverlap 매개변수

충돌이 발생하면 엔진은 내 함수에 아래 6개의 귀중한 정보를 담아서 던져줍니다. (특히 1~3번은 누가 누구랑 부딪혔는지 파악하는 핵심 데이터입니다.)

1
2
3
4
5
6
7
8
void OnItemOverlap(
    UPrimitiveComponent* OverlappedComp, // 1번
    AActor* OtherActor,                  // 2번
    UPrimitiveComponent* OtherComp,      // 3번
    int32 OtherBodyIndex,                // 4번
    bool bFromSweep,                     // 5번
    const FHitResult& SweepResult        // 6번
)
  • 핵심 매개변수 (1~3번)
    1. UPrimitiveComponent* OverlappedComp (나의 컴포넌트)
    • 의미: 현재 충돌 이벤트를 감지한 ‘나 자신(아이템)의 충돌체’입니다.
    • 언제 쓸까?: 한 액터에 충돌체가 여러 개 있을 때(예: 머리 감지용, 몸통 감지용), 지금 어느 충돌체에 닿은 건지 구분할 때 씁니다. (예: 아, 내 SphereComponent가 감지했구나!)
  1. AActor* OtherActor (상대방 액터)
    • 의미: 내 영역에 들어온 ‘상대방(침입자) 액터 전체’입니다. (가장 많이 사용함)
    • 언제 쓸까?: 나랑 부딪힌 녀석이 플레이어인지, 몬스터인지, 총알인지 판별할 때 씁니다. (if (OtherActor->ActorHasTag("Player")) 처럼 사용)
  2. UPrimitiveComponent* OtherComp (상대방의 컴포넌트)
    • 의미: 내 영역에 들어온 ‘상대방 액터의 특정 부위(컴포넌트)’입니다.
    • 언제 쓸까?: 상대방이 플레이어라는 건 알겠는데, 플레이어의 ‘칼(Sword Component)’이 닿은 건지, ‘몸통(Capsule Component)’이 닿은 건지 디테일하게 구분해야 할 때 씁니다.
  • 부가 매개변수 (4~6번)
    1. int32 OtherBodyIndex: 상대방이 여러 개의 충돌 뼈대(물리 에셋)를 가졌을 때, 몇 번째 뼈대인지 알려주는 인덱스입니다. (일반적으로 잘 안 씁니다.)
    2. bool bFromSweep: 상대방이 물리적으로 이동(Sweep)하면서 나한테 부딪힌 건지, 아니면 순간이동(Teleport)해서 겹친 건지 여부(true/false)를 알려줍니다.
    3. const FHitResult& SweepResult: 물리적 이동(Sweep)으로 부딪혔을 경우, 정확히 X, Y, Z 어디 좌표에 닿았는지 상세한 물리 결과를 담고 있는 구조체입니다.

🔹 EndOverlap에서는 왜 매개변수가 2개(5~6번) 빠질까?

OnComponentEndOverlap에서는 5번(bFromSweep)과 6번(SweepResult) 매개변수가 빠져 총 4개만 받습니다.

  • 이유: BeginOverlap은 무언가 부딪히는 시점이므로 “어디서, 어떻게 물리적으로 닿았는지(Hit Result)”의 정보가 존재합니다. 하지만 EndOverlap은 단순히 영역에서 ‘벗어나는’ 순간이므로, 물리적인 타격 위치나 이동 궤적(Sweep)이라는 개념 자체가 성립하지 않기 때문입니다.

🔹 이벤트 바인딩 코드 작성법

준비가 끝났다면 액터의 생성자(ABaseItem::ABaseItem()) 등에서 아래와 같이 내 함수와 충돌처리 함수를 연결시켜줍니다.

1
2
3
4
5
// BeginOverlap 콘센트에 내 OnItemOverlap 함수를 연결
Collision->OnComponentBeginOverlap.AddDynamic(this, &ABaseItem::OnItemOverlap);

// EndOverlap 콘센트에 내 OnItemEndOverlap 함수를 연결
Collision->OnComponentEndOverlap.AddDynamic(this, &ABaseItem::OnItemEndOverlap);

🔹 이벤트 바인딩 시 UFUNCTION() 사용

언리얼 엔진에서 OnComponentBeginOverlap 같은 델리게이트에 내 커스텀 함수를 동적으로 연결(AddDynamic)할 때, 헤더 파일의 함수 선언부 위에 UFUNCTION() 매크로를 붙이지 않으면 에러가 나거나 아예 작동하지 않습니다. 그 이유는 무엇일까요?

  • 일반적인 C++의 한계
    일반적인 순수 C++ 프로그램은 코드를 ‘컴파일(빌드)’하고 나면, 함수 이름(OnItemOverlap 등)이나 변수 이름 같은 텍스트 정보는 전부 사라지고 오직 컴퓨터가 읽을 수 있는 메모리 주소(0x1A2B…)로만 변환됩니다.
    즉, 게임이 실행 중인 상태(런타임)에서는 엔진이 “여기 OnItemOverlap이라는 이름의 함수 있니?”라고 찾아볼 방법이 전혀 없습니다.

  • 언리얼의 해결책: 리플렉션(Reflection) 시스템
    언리얼 엔진은 이 문제를 해결하기 위해 리플렉션(Reflection)이라는 자체적인 정보 수집 시스템을 만들었습니다. 리플렉션은 프로그램이 실행 중일 때도 자신의 구조(어떤 클래스가 있고, 그 안에 어떤 함수와 변수가 있는지)를 들여다볼 수 있게 해주는 마법 같은 기능입니다. 블루프린트, 가비지 컬렉션, 세이브/로드 시스템이 모두 이를 기반으로 돌아갑니다.

  • UFUNCTION()의 역할
    AddDynamic 매크로는 내부적으로 함수의 이름(문자열)을 이용해 런타임에 함수를 찾아 연결하는 동적 델리게이트 방식입니다.

  • 함수 위에 UFUNCTION()을 붙여주면:

    • 언리얼 헤더 툴(UHT)이 코드를 컴파일하기 전에 이 매크로를 발견하고, 해당 함수의 이름, 매개변수 정보 등을 언리얼 엔진의 리플렉션 시스템에 등록해 둡니다.
    • 덕분에 게임 실행 중(런타임)에 엔진이 충돌 이벤트를 발생시키면서 “어? OnItemOverlap 함수 실행해야 하네? 호적부에서 찾아서 실행해!” 라고 정상적으로 작동할 수 있게 됩니다.
    • 덤으로 블루프린트에서도 이 함수를 인식하고 가져다 쓸 수 있게 됩니다.

UFUNCTION()을 빼먹고 AddDynamic으로 바인딩을 시도하면 다음과 같은 일이 벌어집니다.

  • 컴파일은 성공할 수 있습니다: 순수 C++ 문법상으로는 틀린 게 없기 때문에 비주얼 스튜디오에서는 에러를 뿜지 않고 빌드가 성공해버립니다.
  • 게임 실행 시 버그 발생: 하지만 엔진을 켜서 부딪혀보면 충돌 로직이 아예 먹통이 되거나, 심하면 엔진이 뻗어버리는 Crash가 발생합니다. 엔진 입장에서는 런타임에 실행하라고 지시받은 함수를 리플렉션에서 찾을 수 없기 때문입니다.

요약

  • 동적 델리게이트(AddDynamic)에 묶이는 콜백 함수는 무조건 UFUNCTION()을 달아야 한다.


Tip: Super 키워드를 활용한 부모 함수 호출 (오버라이딩)

부모 클래스의 함수를 자식 클래스에서 똑같은 이름으로 재정의(Override)했을 때, 두 함수를 어떻게 구분해서 부를지 결정하는 방법입니다.

  • 1. 그냥 함수명만 쓸 때 (예: ActivateItem();)
    • 실행 대상: 자기 자신(자식 클래스)이 새롭게 덮어쓴 함수가 실행됩니다.
  • 2. Super::를 붙일 때 (예: Super::ActivateItem();)
    • 실행 대상: 내 코드가 아니라, 부모 클래스에 원래 작성되어 있던 기초 함수가 실행됩니다.

[핵심 활용처: 언제 사용할까?] 부모의 기능을 완전히 지우고 처음부터 새로 짜려는 게 아니라, “부모가 만들어둔 기본 동작은 그대로 실행시키고, 거기에 내 특수 기능만 추가로 얹고 싶을 때” 주로 사용합니다.

[실전 C++ 코드 예시]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 1. 부모 클래스 (기본 아이템)
void ABaseItem::ActivateItem(AActor* Activator)
{
    // 기본 동작: 획득 이펙트를 터뜨린다.
    SpawnEffect();
}

// 2. 자식 클래스 (힐링 포션)
void AHealingItem::ActivateItem(AActor* Activator)
{
    // 부모의 기능을 먼저 호출함 (이펙트가 터짐!)
    Super::ActivateItem(Activator);

    // 그 다음, 자식만의 전용 특수 기능을 마저 실행함 (체력이 50 참!)
    AddHealth(50); 
}




타이머 매니저와 타이머 핸들

• 타이머 매니저

  • 역할: 게임 월드 내에서 실행되는 모든 타이머를 중앙에서 관리하는 스케줄러(통제소)입니다.
  • 특징: 매 프레임마다 등록된 타이머들의 남은 시간을 계산하고, 시간이 다 된 타이머가 있다면 연결된 함수(콜백)를 실행시킵니다.
  • 접근 방법: 일반적으로 월드 객체를 통해 접근합니다. (GetWorld()->GetTimerManager())


• 타이머 핸들

  • 역할: 타이머 매니저에게 타이머를 등록할 때 발급받는 ‘고유 식별표(ID)’ 혹은 ‘리모컨’입니다.
  • 특징: 타이머 매니저 안에는 수많은 타이머가 굴러가고 있습니다. 내가 방금 설정한 “3초 뒤 폭발” 타이머를 실행 전(2초 쯤)에 취소하거나 남은 시간을 확인하고 싶을 때, 타이머 매니저에게 “이 핸들 번호에 해당하는 타이머 취소해 줘!”라고 요청하기 위해 필요합니다.


• 작동 원리

  1. 핸들 준비: 타이머를 조작할 수 있도록 FTimerHandle 변수를 선언합니다.
  2. 타이머 등록 (SetTimer): 타이머 매니저에게 타이머 핸들, 실행할 객체와 함수, 지연 시간, 반복 여부를 전달하여 타이머를 등록합니다.
  3. 대기 및 실행: 타이머 매니저가 백그라운드에서 시간을 계산하다가, 지정된 시간이 되면 등록된 함수를 실행합니다.
  4. 조작 (선택): 실행되기 전에 취소하거나, 일시 정지하거나, 남은 시간을 알고 싶을 때 핸들을 이용해 타이머 매니저에 요청합니다.


• 타이머 주요 기능

  • 타이머 설정하기 (SetTimer)
    • 가장 기본적으로 함수를 특정 시간 뒤에 실행하도록 예약하는 방법입니다.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    
    // Cpp 파일 (.cpp)
    void AMyActor::StartCountdown()
    {
        // 3초 뒤에 Explode() 함수를 한 번 실행 (반복 X)
        GetWorld()->GetTimerManager().SetTimer(
            ExplosionTimerHandle, // 조작을 위해 사용할 핸들
            this,                 // 함수를 실행할 객체 자신
            &AMyActor::Explode,   // 실행될 함수 포인터
            3.0f,                 // 딜레이 시간 (초 단위)
            false                 // 반복 여부 (true면 3초마다 계속 실행됨)
        );
    }
    
  • 타이머 취소하기 (ClearTimer)
    • 수류탄을 던졌는데 폭발하기 전에 해체해야 하는 상황 등에 사용합니다.
    1
    2
    3
    4
    
      void AMyActor::DefuseBomb()
    {
        GetWorld()->GetTimerManager().ClearTimer(ExplosionTimerHandle);
    }
    
  • 타이머 일시 정지 및 재개 (PauseTimer / UnPauseTimer)
    • 게임이 일시정지 되거나, 캐릭터가 상태 이상(빙결 등)에 걸려 쿨타임이 멈춰야 할 때 유용합니다.
    1
    2
    3
    4
    
    // 타이머 멈춤
    GetWorld()->GetTimerManager().PauseTimer(ExplosionTimerHandle);
    // 타이머 다시 시작
    GetWorld()->GetTimerManager().UnPauseTimer(ExplosionTimerHandle);
    
  • 타이머 상태 및 정보 확인하기
    • UI에 스킬 쿨타임이 얼마나 남았는지 프로그레스 바 등으로 표시할 때 사용합니다. ```cpp // 타이머가 현재 작동 중인지 확인 bool bIsActive = GetWorld()->GetTimerManager().IsTimerActive(ExplosionTimerHandle);

    // 타이머가 실행되기까지 남은 시간 (초) 반환 float TimeLeft = GetWorld()->GetTimerManager().GetTimerRemaining(ExplosionTimerHandle);

    // 타이머에 설정되었던 총 시간 (초) 반환 float TotalRate = GetWorld()->GetTimerManager().GetTimerRate(ExplosionTimerHandle); ```


• Tick 대신 Timer를 써야하는 이유

많이 하는 실수가 시간을 체크하기 위해 Tick(float DeltaTime) 함수 내부에 변수를 계속 더하며 확인하는 것입니다.

1
2
3
4
5
6
7
8
9
10
11
12
// 안 좋은 예시 (Tick을 이용한 타이머 구현)
void AMyActor::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);
    
    CurrentTime += DeltaTime;
    if (CurrentTime >= 3.0f)
    {
        Explode();
        CurrentTime = 0.0f; // 초기화
    }
}

타이머를 써야 하는 이유:

  1. 성능 최적화: Tick은 매 프레임(1초에 60~144번) 호출되므로 연산 비용이 비쌉니다. 타이머 매니저를 사용하면 엔진 레벨에서 훨씬 최적화된 방식으로 시간을 관리해 줍니다.
  2. 가독성과 유지보수: 코드가 분산되지 않고, “언제, 어떤 함수를 실행할지” 한 줄로 깔끔하게 선언할 수 있어 로직 파악이 쉬워집니다.
  3. 유연한 제어: 위에서 본 것처럼 일시정지, 취소, 남은 시간 확인 등 시간 관련 기능들을 직접 구현할 필요 없이 엔진이 제공하는 API로 손쉽게 처리할 수 있습니다.


• TSubclassOf의 핵심 기능과 레퍼런스(참조) 관리

언리얼 C++에서 블루프린트 클래스를 변수로 참조할 때(예: 스폰할 몬스터나 아이템을 에디터에서 지정할 때) 가장 많이, 그리고 필수적으로 사용하는 템플릿입니다.

🔹 TSubclassOf란? (하드 레퍼런스)

특정 부모 클래스와 그 자식 클래스들만 담을 수 있도록 타입을 제한해주는 안전한 ‘클래스 포인터(UClass*)’입니다. 기본적으로 하드 레퍼런스 방식으로 동작합니다.

🔹 왜 그냥 UClass*를 쓰지 않을까? (안전성 확보)

만약 몬스터가 죽을 때 드랍할 아이템을 기획자가 에디터에서 직접 선택하게 만든다고 가정해 봅시다.

  • ** UClass*를 사용할 때 (위험함):** 디자이너가 에디터에서 드롭다운 메뉴를 열면, 언리얼 엔진에 존재하는 수만 개의 모든 클래스(카메라, 게임모드, UI 등)가 전부 노출됩니다. 기획자가 실수로 ‘아이템’ 대신 ‘플레이어 캐릭터’를 드랍하도록 설정하면 심각한 버그나 크래시가 발생합니다.
  • ** TSubclassOf<ABaseItem>을 사용할 때 (안전함):** 에디터 드롭다운 메뉴에 오직 ABaseItem과 이를 상속받은 자식들(코인, 포션, 지뢰 등)만 노출됩니다. 엉뚱한 클래스를 넣는 휴먼 에러를 차단해 줍니다.

🔹 코드 예시

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 1. 헤더 파일 (.h)
// TSubclassOf를 사용하면 에디터 디테일 패널에서 ABaseItem 계열만 선택 가능해집니다.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Drop System")
TSubclassOf<class ABaseItem> ItemToDropClass; 

// 2. Cpp 파일 (.cpp) - 액터 스폰 시
void AMonster::Die()
{
    // 에디터에서 클래스가 비어있지 않게 제대로 할당되었는지 안전 검사
    if (ItemToDropClass != nullptr) 
    {
        // 할당된 아이템 클래스를 내 위치에 스폰(생성)한다.
        GetWorld()->SpawnActor<ABaseItem>(ItemToDropClass, GetActorLocation(), GetActorRotation());
    }
}


###. • 심화: 하드 레퍼런스와 소프트 레퍼런스의 차이

위에서 사용한 TSubclassOf나 일반적인 포인터 변수는 하드 레퍼런스입니다. 게임의 규모가 커질수록 메모리 관리를 위해 이 둘의 차이를 반드시 알아야 합니다.

🔹 하드 레퍼런스 (Hard Reference)

  • 개념: 클래스나 에셋이 항상 메모리에 로드된 상태를 유지하며 바로 접근할 수 있는 방식입니다.
  • 특징: A라는 액터가 B라는 아이템을 하드 레퍼런스로 가지고 있다면, 게임이 시작되어 A가 메모리에 올라올 때 B도 강제로 함께 메모리에 로드됩니다.
  • 장점: 메모리에 이미 올라와 있으므로 필요할 때 즉시(지연 시간 없이) 스폰하거나 사용할 수 있습니다.
  • 단점: 당장 쓰지 않는 데이터까지 메모리를 차지하므로 게임의 초기 로딩 시간이 길어지고 메모리 낭비가 심해집니다. (예: 캐릭터가 100개의 무기 클래스를 하드 참조하면 100개가 다 로드됨)

🔹 소프트 레퍼런스 (Soft Reference)

  • 개념: 실제 데이터를 메모리에 올리지 않고, 해당 에셋이 있는 ‘파일 경로(주소 문자열)’만 유지하는 방식입니다.
  • 특징: A가 메모리에 올라와도 B는 로드되지 않습니다. 실제로 B가 필요한 순간이 오면, 그때 경로를 찾아가 비동기적으로 메모리에 로드하여 사용합니다.
  • 장점: 초기 로딩 속도가 매우 빨라지고, 메모리를 획기적으로 절약할 수 있습니다. (오픈월드나 대규모 MMORPG에서 필수)
  • 단점: 에셋을 쓰기 전에 메모리에 로드하는 시간(비동기 로딩 대기)이 필요하며, 로딩 로직을 추가로 작성해야 해서 코드가 조금 복잡해집니다.
  • 사용 예시: TSoftClassPtr<ABaseItem>, TSoftObjectPtr<UStaticMesh>




아이템 스폰 및 레벨 데이터 관리

• Box Component 함수

🔹 GetComponentLocation()

이 함수는 해당 컴포넌트(박스)가 게임 월드 상에서 어디에 위치해 있는지를 알려줍니다.

  • 반환값
  • 타입: FVector (X, Y, Z 좌표)
  • 의미: 월드 좌표계(World Space)를 기준으로 한 박스 컴포넌트의 정중앙(Center) 좌표를 반환합니다.

  • 언제 사용하나요?
  • 박스의 중심점 위치에 이펙트(Particle)나 사운드를 발생시킬 때
  • 박스와 플레이어 사이의 거리를 계산할 때
  • 레이캐스트(Line Trace)를 쏠 때 시작점이나 끝점으로 사용할 때

    코드 예시: FVector CenterPos = BoxComp->GetComponentLocation();

🔹 GetScaledBoxExtent()

이 함수는 월드 상에서 이 박스가 실제로 차지하는 크기를 알려줍니다.

  • 반환값
  • 타입: FVector (X, Y, Z 크기)
  • 의미: 컴포넌트(및 부모)에 적용된 스케일(비율)이 모두 곱해진 박스의 ‘절반 크기(Half-Size)’를 반환합니다.
    • 주의: 언리얼 엔진에서 Extent(익스텐트)는 항상 전체 길이의 절반을 의미합니다. (예를 들어 X축 Extent가 50이면, 실제 X축 전체 길이는 100입니다.)
  • 언제 사용하나요?
  • 스폰 구역, 트리거 구역의 실제 넓이나 높이를 수학적으로 계산해야 할 때
  • 충돌 체크를 위한 Bounding Box(경계 상자) 크기를 구할 때

    코드 예시: FVector BoxSize = BoxComp->GetScaledBoxExtent();

🔹 예시

실무에서 이 두 함수는 “박스 영역 내부의 무작위(Random) 위치 구하기” 패턴에서 항상 세트로 묶여서 사용됩니다. (아이템 랜덤 스폰, 몬스터 랜덤 스폰 등)

1
2
3
4
5
6
7
8
9
10
#include "Kismet/KismetMathLibrary.h"

// 1. 박스의 중심 위치를 구한다.
FVector BoxCenter = BoxComp->GetComponentLocation();

// 2. 박스의 절반 크기를 구한다.
FVector BoxExtent = BoxComp->GetScaledBoxExtent();

// 3. 중심점과 절반 크기를 주면, 그 박스 영역 안의 무작위 X,Y,Z 좌표를 하나 뽑아준다!
FVector RandomSpawnPos = UKismetMathLibrary::RandomPointInBoundingBox(BoxCenter, BoxExtent);


• 데이터 테이블

아이템 스폰 확률을 하드코딩하게 되면, 기획자가 수정을 요청 할 때마다 프로그래머가 코드를 수정하고 엔진을 다시 빌드해야 합니다. 이를 해결하기 위한 시스템이 데이터 테이블(Data Table)입니다.

  • 정의: 게임 내의 각종 수치 데이터(아이템 정보, 몬스터 스탯, 스폰 확률 등)를 C++ 코드에서 분리하여, 엑셀(.csv)이나 JSON 형태의 외부 파일로 관리할 수 있게 해주는 언리얼 엔진의 데이터 컨테이너입니다.
  • 장점: 프로그래머의 개입 없이 기획자가 엑셀에서 수치를 수정하고 임포트만 하면 즉시 게임에 반영됩니다. 밸런스 테스트와 유지보수에 압도적으로 유리합니다.

🔹 데이터 테이블을 위한 C++ 구조체(Struct) 생성

데이터 테이블의 ‘행(Row)’이 어떤 데이터들을 가질지 C++로 규격을 정해줘야 합니다.

1
2
3
4
5
6
7
8
9
10
11
12
USTRUCT(BlueprintType)
struct FItemSpawnRow : public FTableRowBase
{
    GENERATED_BODY()

public:
    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    FName ItemName;

    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    float SpawnChance;
};
  • USTRUCT(BlueprintType)의 의미: 이 매크로를 붙이면 해당 구조체를 C++뿐만 아니라 블루프린트에서도 변수 타입으로 인식하고 사용할 수 있게(BlueprintType) 열어줍니다. 블루프린트 에디터에서 이 구조체의 핀(Pin)을 연결하거나 값을 수정하려면 반드시 필요합니다.
  • public FTableRowBase 상속: 구조체가 데이터 테이블의 한 줄(Row)로서 엔진에 인식되려면, 무조건 FTableRowBase 클래스를 상속받아야 합니다. 이 상속이 없으면 데이터 테이블 생성 시 행 구조체 목록에 나타나지 않습니다.


• FName vs FString

언리얼의 문자열 클래스는 내부 작동 방식이 완전히 다르므로 용도에 따라 써야 성능 저하를 막을 수 있습니다.

  • FName (검색/식별용 - 초고속): 문자열을 해시(Hash) 값으로 변환해 내부적인 정수 인덱스로 관리합니다. 글자가 같으면 완벽히 동일한 메모리를 가리키므로 비교 속도가 매우 빠릅니다. 단, 한 번 생성되면 문자를 도중에 수정할 수 없습니다. (데이터 테이블의 행 이름(Row Name), 태그(Tag), 에셋 경로 등에 주로 사용)
  • FString (조작용 - 느림): 일반적인 C++의 std::string처럼 글자를 더하고, 자르고, 수정할 수 있는 동적 문자열입니다. 사용할 때마다 메모리 할당이 일어나 비교적 연산이 무겁고 느립니다. (UI에 출력하는 안내 텍스트, 플레이어 채팅 등에 사용)


• 경로 마지막의 _C

  1. _C가 붙는 경우는 오직 “블루프린트 클래스”의 경로를 문자열로 참조해서 로드할 때붙습니다. (예: Blueprint'/Game/Item/BP_Coin.BP_Coin_C')
  2. 즉, 데이터 테이블의 행 값으로 “스폰할 아이템이 무엇인지(클래스 경로)”를 텍스트로 적어둘 때, 그 대상이 블루프린트라면 끝에 _C를 붙여주어야 엔진이 정상적으로 C++ 클래스화된 블루프린트를 찾아냅니다.


• 자주 사용하는 데이터 테이블 C++ 함수

  • GetAllRows<T>(): 테이블의 모든 행(Row)을 배열(TArray) 형태로 한 번에 가져옵니다. 아이템 전체 목록을 UI에 띄우거나, 가챠 시스템에서 모든 확률의 합을 구할 때 사용합니다.
  • FindRow<T>(): 특정 Row Name(행 이름)을 가진 단 하나의 데이터를 쏙 뽑아옵니다. (예: 테이블에서 “Item_Sword_01”의 능력치 정보만 가져오고 싶을 때 가장 많이 사용합니다.)


• 누적 랜덤 확률 (가중치 랜덤) 알고리즘

일반적으로 확률을 계산할 때 if (rand < 0.5) 형태로 하드코딩하면, 확률의 총합이 무조건 100%(1.0)가 되어야만 정상 작동합니다. 하지만 아이템을 추가하면서 매번 합을 100%로 맞추는 것은 불가능에 가깝습니다.

이때 사용하는 누적 랜덤 확률(Weighted Random) 알고리즘은 확률의 합이 100%가 아니어도(예: 합이 73이든 250이든) 비율에 맞춰 완벽하게 동작하는 최고의 범용 알고리즘입니다.

1. 원리 이해

각 아이템의 등장 확률(가중치)을 쭉 이어 붙여 총 합이 100을 만듭니다.

  • 아이템 A (가중치 50) 👉 0 ~ 50 구간 차지
  • 아이템 B (가중치 30) 👉 50 ~ 80 구간 차지
  • 아이템 C (가중치 20) 👉 80 ~ 100 구간 차지
  • Total = 100

2. 무작위 값 뽑기

0부터 100 사이에서 무작위 값을 뽑습니다. (예: 65가 나왔다고 가정)

3. 누적하며 값 확인하기

**누적값이 무작위로 얻은 값이 가중치 구간에 해당되면 당첨됩니다.

  1. A 확인: 누적값(0 + 50 = 50). 무작위 점(65)보다 작습니다. 👉 다음으로 넘어감.
  2. B 확인: 누적값(50 + 30 = 80). 무작위 점(65) (80 >= 65) 👉 B 아이템 당첨!

구현 예시

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
// 1) 모든 아이템의 데이터 가져오기
TArray<FItemSpawnRow*> AllRows;
static const FString ContextString(TEXT("ItemSpawnContext"));
ItemDataTable->GetAllRows(ContextString, AllRows);

if (AllRows.IsEmpty()) return nullptr;

// 2) 막대기의 전체 길이(TotalChance) 구하기
float TotalChance = 0.0f; 
for (const FItemSpawnRow* Row : AllRows) 
{
    if (Row) 
    {
        TotalChance += Row->SpawnChance; // 가중치를 모두 더해 전체 막대기 길이를 만듬
    }
}

// 3) 0 ~ 막대기 전체 길이 사이에서 무작위 점 하나 찍기
const float RandValue = FMath::FRandRange(0.0f, TotalChance);
float AccumulateChance = 0.0f; // 누적값을 저장할 변수

// 4) 처음부터 훑으며 누적 확률로 당첨 아이템 찾기
for (FItemSpawnRow* Row : AllRows)
{
    AccumulateChance += Row->SpawnChance; // 현재 아이템의 막대기 길이를 이어 붙임
    
    // 이어 붙인 누적 길이가 내가 찍은 무작위 점(RandValue)을 넘어서면 당첨!
    if (RandValue <= AccumulateChance)
    {
        return Row; // 당첨된 아이템 데이터 반환
    }
}




캐릭터 체력 및 전역 점수 관리

• 캐릭터 체력 관리

🔹 PlayerState vs Character 내부 변수

  • PlayerState: 원래 멀티플레이어 게임에서 ‘모든 클라이언트에게 공유되어야 하는 플레이어 정보(이름, 핑, 점수 등)’를 네트워크로 동기화(Replication)하기 위해 사용하는 클래스입니다.
  • 싱글 플레이어에서의 체력 관리: 질문하신 대로 싱글 플레이어 게임에서는 굳이 무거운 PlayerState를 강제로 쓸 필요가 없습니다. 단순히 내 캐릭터(Character) 클래스 안에 float Health; 변수를 선언해서 관리하는 것이 훨씬 직관적이고 효율적입니다.

🔹 언리얼의 데미지 프레임워크 (ApplyDamage & TakeDamage)

언리얼 엔진은 데미지를 ‘주는 쪽’‘받는 쪽’의 기능을 완벽하게 분리해 두었습니다.

  • 1) UGameplayStatics::ApplyDamage (데미지를 주는 함수)
  • 역할: “너 공격받았어!”라고 대상에게 신호를 보내는(Event Trigger) 트리거 역할을 합니다.
  • 사용처: 총알이 적에게 맞았을 때, 지뢰가 터졌을 때 타격 판정을 내리는 곳에서 호출합니다.

  • 2) AActor::TakeDamage (데미지를 받는 함수)
  • 역할: 누군가 나에게 ApplyDamage를 호출했을 때, 그 데미지를 내 몸으로 받아들여 실제로 체력을 깎는 처리를 하는 가상(Virtual) 함수입니다.
  • 매개변수 분석:
    1. float DamageAmount: 적(상대방)이 나에게 주려고 시도한 원본 데미지 수치입니다. (예: “총알 데미지 50 먹어라!”)
    2. FDamageEvent const& DamageEvent: 데미지의 종류입니다. (점 데미지인지, 폭발처럼 범위 데미지인지 등의 정보)
    3. AController* EventInstigator: 공격을 지시한 ‘흑막(컨트롤러)’입니다. 누가 방아쇠를 당겼는지(AI인지 플레이어인지) 파악할 때 씁니다.
    4. AActor* DamageCauser: 나를 직접 때린 ‘무기(액터)’입니다. (예: 날아온 총알 액터, 지뢰 액터 그 자체)
  • 3) Super::TakeDamage 호출의 중요성
  • 자식 클래스에서 TakeDamage를 오버라이딩했다면, 반드시 float ActualDamage = Super::TakeDamage(...);를 먼저 호출해야 합니다.
  • 이유: 부모(엔진 코어) 클래스에서 무적 상태(God Mode)인지, 데미지 타입에 따른 기본 감소율이 있는지 등을 먼저 쫙 계산해 줍니다. 그리고 나서 최종적으로 내 몸에 진짜 들어와야 할 ‘실제 데미지(ActualDamage)’ 값을 반환해 줍니다. 우리는 이 반환된 값으로 내 체력을 깎아야 버그가 안 납니다.

  • 4) FMath::Clamp를 사용하는 이유
  • 목적: 수치가 지정한 최소~최대 범위를 벗어나지 않도록 가둬두는(Clamp) 함수입니다.
  • 체력 계산 적용: Health = FMath::Clamp(Health - ActualDamage, 0.0f, MaxHealth);
  • 사용 이유: 체력이 10 남았는데 50의 데미지를 받았다고 해서 체력이 -40이 되면 UI 게이지가 뚫고 나가는 등 온갖 버그가 발생합니다. 마찬가지로 힐링 포션을 먹었을 때 최대 체력(MaxHealth)을 넘어 초과 회복되는 것을 막아줍니다.


• 전역 점수 관리 (GameState)

점수, 남은 시간, 클리어한 퀘스트 목록 등은 캐릭터 클래스에 저장하기 애매합니다. 캐릭터가 죽어서 파괴(Destroy)되면 그 안에 있던 점수도 같이 공중분해 되기 때문입니다. 이때 사용하는 것이 GameState입니다.

🔹 GameState란 무엇인가?

  • 정의: 게임 월드의 ‘전반적인 현재 상태(상황판)’를 저장하는 클래스입니다.
  • 특징: 플레이어 캐릭터가 죽고 부활해도 GameState는 게임이 끝날 때까지 살아있어 데이터를 보존해 줍니다.

🔹 싱글플레이 vs 멀티플레이에서의 GameState

  • 멀티플레이: 서버가 1개의 GameState를 관리하며, 접속한 모든 클라이언트(유저)에게 이 상황판을 복사해서 똑같이 보여줍니다. (예: “현재 A팀 10점, B팀 5점, 남은 시간 1분”이라는 정보를 모두가 공유함)
  • 싱글플레이: 통신할 상대는 없지만, “캐릭터가 죽어도 유지되어야 하는 게임의 전체 데이터(내 총점, 스테이지 진행도)”를 저장하는 역할을 합니다.

🔹 GameStateBase vs GameState의 차이

언리얼 클래스를 생성할 때 두 가지 버전을 볼 수 있습니다.

  1. AGameStateBase (기본형): * 아주 가볍고 심플한 상태 관리자입니다.
    • 복잡한 규칙이 없는 싱글 플레이어 게임이나 가벼운 코옵(Co-op) 게임에 적합합니다.
  2. AGameState (확장형): * Base를 상속받아 Match State Machine(매치 상태 관리기) 기능이 추가된 무거운 버전입니다.
    • 대기실(Waiting) 👉 게임 시작(InProgress) 👉 게임 종료(PostMatch) 등 흐름이 명확한 전통적인 멀티플레이어 슈팅/대전 게임에서 주로 사용합니다.

🔹 GameMode에 세팅하기 (매우 중요)

GameState 클래스를 C++로 만들었어도, 게임의 룰을 관리하는 GameMode에 선언하지 않으면 무용지물입니다.

  • 설정법: 커스텀 GameMode 생성자 C++ 코드 내부에서 GameStateClass = AMyCustomGameState::StaticClass();로 지정하거나, 블루프린트 GameMode 디테일 패널에서 Game State Class 항목을 내가 만든 클래스로 변경해주어야 작동합니다.




알고리즘


• 약수 구하기

$O(N)$ 탐색

가장 직관적인 방법입니다. 1부터 $N$까지 모든 수를 돌면서 나누어 떨어지는지 확인합니다.

  • 사용 조건: $N$이 매우 작을 때 (보통 $N \le 10,000$)
  • 단점: $N$이 1억(100,000,000) 이상 넘어가면 시간 초과가 발생할 확률이높습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <iostream>
#include <vector>

using namespace std;

vector<int> getDivisorsBasic(int n) {
    vector<int> divisors;
    for (int i = 1; i <= n; i++) {
        if (n % i == 0) {
            divisors.push_back(i);
        }
    }
    return divisors;
}


최적화 방식: $O(\sqrt{N})$ 탐색

코딩 테스트에서 특정 수의 약수를 구해야 한다면, 시간 복잡도를 $O(N)$에서 $O(\sqrt{N})$으로 줄이는 이 방식이 사실상의 표준(Standard)입니다.

핵심 원리: 약수의 대칭성 (Pair)

약수는 항상 으로 존재합니다. 어떤 수 $N$의 약수 $i$를 찾았다면, $N / i$ 역시 반드시 $N$의 약수가 됩니다.

  • 예: $N = 16$일 때
    • $1 \times 16$
    • $2 \times 8$
    • $4 \times 4$ (중간 지점)
    • $8 \times 2$ (반복)
    • $16 \times 1$ (반복)

이처럼 모든 약수 쌍은 $\sqrt{N}$을 기점으로 대칭을 이룹니다. 따라서 $1$부터 $\sqrt{N}$까지만 확인하면 모든 약수를 찾을 수 있습니다.

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
#include <iostream>
#include <vector>
#include <algorithm> // sort 활용
#include <cmath>     // sqrt 활용 (선택 사항)

using namespace std;

vector<int> getDivisors(int n) {
    vector<int> divisors;

    // 1. 1부터 루트 n까지만 탐색
    // i * i <= n 은 sqrt(n) 호출보다 정수 연산이
    for (int i = 1; i * i <= n; i++) {
        if (n % i == 0) {
            divisors.push_back(i); // 약수 i 추가

            // 2. 짝꿍 약수(n / i) 추가 (중복 방지)
            // i가 4이고 n이 16인 경우(제곱근일 때) 중복을 방지
            if (i != n / i) {
                divisors.push_back(n / i);
            }
        }
    }

    // 3. 약수를 오름차순으로 보고 싶다면 정렬 필수
    // 짝꿍 약수를 뒤에서부터 넣었기 때문에 배열이 정렬되어 있지 않습니다.
    sort(divisors.begin(), divisors.end());

    return divisors;
}


번외: 최대공약수구하기 (유클리드 호제법)

코딩 테스트의 약수 문제에서 재귀가 등장한다면, 단일 숫자의 모든 약수를 구하는 것이 아니라 두 수의 최대공약수를 구하는 상황일 확률이 있습니다.

단일 숫자의 약수를 나열할 때는 $O(\sqrt{N})$ for문 탐색이 표준이지만, 두 수의 공통된 가장 큰 약수(최대공약수)를 구할 때는 유클리드 호제법이라는 재귀 알고리즘을 사용합니다.

  • 핵심 원리: $A$를 $B$로 나눈 나머지를 $R$이라고 할 때, $A$와 $B$의 최대공약수는 $B$와 $R$의 최대공약수와 같다는 수학적 법칙을 이용합니다. 이를 나머지가 $0$이 될 때까지 재귀적으로 반복합니다.
  • 시간 복잡도: $O(\log(\min(A, B)))$
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <iostream>

using namespace std;

// 최대공약수(GCD)를 구하는 유클리드 호제법 (재귀 함수)
int getGCD(int a, int b) {
    // 나머지가 0이 되는 순간의 나누는 수(a)가 최대공약수입니다.
    if (b == 0) {
        return a;
    }
    // a를 b로 나눈 나머지를 다시 함수의 인자로 넘겨 재귀 호출합니다.
    return getGCD(b, a % b);
}

// (참고) 최소공배수(LCM) 구하는 공식
// 두 수의 곱을 최대공약수로 나누면 최소공배수가 됩니다.
int getLCM(int a, int b) {
    return (a * b) / getGCD(a, b);
}
This post is licensed under CC BY 4.0 by the author.