본문 바로가기

게임 시스템 구현

게임 프레임워크를 바탕으로 캐릭터·전투·인벤토리·UI·AI 시스템을 단계별로 통합합니다.

이전 절에서 우리는 게임 프로젝트의 성공적인 시작을 위한 기획과 설계의 중요성에 대해 알아보았습니다.

명확한 GDD(게임 디자인 문서)와 TDD(기술 설계 문서)는 개발의 길잡이가 되며, 이제 이 설계도를 바탕으로 언리얼 엔진에서 실제로 게임 시스템을 구현하는 단계에 돌입할 차례입니다.

이 절에서는 언리얼 엔진의 핵심 기능을 활용하여 게임의 주요 시스템을 어떻게 구현할 것인지에 대한 구체적인 방법론을 제시합니다.


작은 탄약 프로젝트의 시작 상태

앞 절의 목표는 탄약 0/30에서 Pickup 하나로 10/30이 되고, 저장한 값을 새 실행에서 복원하는 것입니다. 아래에서는 탄약 습득·HUD에 이어 저장·새 실행 복원을 연결할 C++ 코드와 에셋 설정 순서를 제공합니다. 준비된 프로젝트에 코드를 합치고 에셋을 설정해야 하며, 완성된 프로젝트 파일을 배포하는 예제는 아닙니다.

이미 플레이어가 카메라와 이동 입력으로 조작할 수 있는 C++ 프로젝트를 재사용하세요. 현재 레벨의 GameMode가 실제 플레이어 Pawn/Character와 PlayerController를 사용해야 합니다. HUD 절의 AMyCharacter, AMyPlayerController, UMyPlayerHUD는 교재의 예시 이름입니다. 프로젝트에서 이름이 다르면 include·캐스트·블루프린트 부모와 클래스 지정도 각 클래스의 실제 타입으로 일관되게 맞춥니다. HUD 예제의 AMyCharacter를 생성해 지정하는 것만으로 이동·카메라 입력이 생기지는 않습니다.

PlayerController의 PlayerHUDClass에는 UMyPlayerHUD를 상속한 실제 WBP_PlayerHUD를 지정합니다. UMG 의존성과 HealthProgressBar·AmmoCountText의 이름·타입·Is Variable 설정을 맞추고, 직접 갱신 경로에서는 Percent/Text의 Bind를 비워 둡니다. 화면 부착·Pawn 소유 뒤 현재값 조회와 캐릭터 초기화·변경 통지는 HUD 절의 연결을 유지합니다.

작은 레벨의 제안 이름은 L_AmmoLoop, Pickup 블루프린트의 제안 이름은 BP_AmmoPickup입니다. 이동 가능한 공간과 Pickup 하나를 두되 플레이어는 감지 영역 밖에서 시작하도록 계획합니다. 아래에서 첫 단계의 클래스·레벨 배치를 안내합니다. 에셋 파일 자체는 제공하지 않으며, 아래 코드에 맞춰 저장 입력과 새 실행의 최초 읽기를 연결합니다. 고급 메시·머티리얼·애니메이션은 숫자 왕복의 필수 조건이 아닙니다.

재사용할 코드와 추가할 책임

각 절의 발췌는 같은 프로젝트의 실제 클래스에 합칩니다. 특히 저장/로드의 AMyGameModeBase 헤더 조각은 한 클래스에 합쳐야 하며, 같은 이름의 클래스를 두 번 만들지 않습니다.

책임재사용할 앞선 절과 파일현재 제공된 부분과 추가할 연결
Pickup 감지Overlap의 감지 구조를 참고한 새 MyAmmoPickup.h/.cpp, AMyAmmoPickup아래 코드는 현재 로컬 플레이어의 캡슐만 받아 탄약을 지급하고, 실제 증가했을 때 한 번 소모합니다. 앞선 절의 범용 AMyTriggerVolume 로그 예제는 유지합니다.
현재 탄약HUD의 MyCharacter.h/.cpp, AMyCharacter, GetCurrentAmmo/GetMaxAmmo앞선 절의 기본값은 30/30입니다. 아래 최종 프로젝트 변경은 최초값 0·공개 AddAmmo·로드값 적용 함수와 HUD 통지를 연결합니다.
HUD 표시같은 절의 MyPlayerHUD.h/.cpp, UMyPlayerHUD::UpdateAmmo, MyPlayerController.h/.cpp, AMyPlayerController::OnAmmoChanged표시·현재값 조회 경로를 재사용하고 실제 위젯·Controller 클래스 할당을 맞춥니다.
저장 데이터SaveGame의 MySaveGame.h/.cpp, UMySaveGame 설계를 참고기존 예제와 구별되는 새 MyAmmoSaveGame.h/.cpp의 직계 USaveGame 파생 UMyAmmoSaveGame·SavedAmmo를 아래에서 제공합니다. 원래 클래스의 다른 저장 속성은 유지합니다.
저장·로드 요청같은 절의 MyGameModeBase.h/.cpp, AMyGameModeBase::SaveMyGame/LoadMyGame기존 void 함수는 보존하고, 아래 새 SaveAmmoLoop의 bool과 LoadAmmoLoopOnce·보류 적용 함수로 현재값 수집·복원을 연결합니다. 새 AmmoSaveGameClass는 기존 SaveGameClass와 다른 필드입니다.
레벨·진입 설정L_AmmoLoop, BP_AmmoPickup, 실제 GameMode/Character/Controller/HUD 에셋아래 순서로 Pickup·레벨·클래스 지정을 준비합니다. 카메라·이동 입력은 이미 조작 가능한 프로젝트의 설정을 재사용하고, 아래에서 Bool IA_SaveAmmo를 기존 활성 IMC에 연결합니다.

아래 AddAmmo가 증가량 검사와 HUD 통지를 맡고, TryApplyLoadedAmmo는 저장된 총 탄약을 검증해 적용합니다. GameMode/Pickup에서 보호된 CurrentAmmo를 직접 대입하지 않습니다. 초기 읽기 미판정·보류·오류 중에는 저장과 지급을 막습니다. 앞선 SaveGame의 체력 75.5와 아이템은 예시 데이터이고, 로드는 위치·회전만 실제 Pawn에 적용합니다. 그 코드를 그대로 가져와 탄약도 저장·복원된다고 읽지 마세요.

단계별 예상 결과

단계시작 조건과 추가할 연결통합 뒤 예상 결과다를 때 먼저 확인할 곳
새 슬롯의 시작 화면최초 탄약 0·최대 30, 읽기 판정·현재 캐릭터와 HUD 준비Pickup 전 Ammo: 0/30생성자·BeginPlay 초깃값, 최초 판정, 실제 Pawn 타입·HUD 현재값 조회
탄약 습득Pickup 감지→캐릭터 값 변경→HUD 통지한 번 진입하면 0→10, HUD 10/30Overlap 대상·캡슐, 한 번 처리 조건, getter 값과 표시 전달
현재값 저장현재 getter 값을 SavedAmmo에 복사하고 같은 슬롯으로 저장저장 결과가 성공이며 저장 대상 탄약은 10값 복사와 SaveGameToSlot 결과를 구별; 성공 로그만으로 데이터 복사를 대신하지 않음
새 실행 복원슬롯 읽기·타입/값 검사→초기화가 끝난 캐릭터 적용→HUD저장값 10을 적용해 10/30; 슬롯이 없으면 새 게임 0/30동일 슬롯·사용자 값, 로드/cast·값 검사, 실제 적용과 초기화 순서

지급량은 10으로 두고 실제 탄약이 증가했을 때만 Pickup을 한 번 소모합니다. 아래 코드는 캡슐 필터와 소모 표시·충돌 해제를 연결하며, 탄약이 가득 차 증가하지 않으면 Pickup을 남깁니다. 같은 지급을 Blueprint 콜백에서도 실행하지 않습니다. 표의 결과는 아래 코드와 실제 에셋 설정을 연결한 뒤의 예상입니다.

저장과 로드는 슬롯 AmmoLoopSlot과 UserIndex=0을 함께 사용합니다. 프로젝트의 기존 저장 슬롯과 구별되는 이름을 선택하고, 저장 사용자 인덱스를 PlayerController 인덱스와 혼동하지 마세요. 같은 이름을 적는 것만으로 입력 호출이 생기지는 않습니다. 아래 SaveAmmoLoop는 현재 캐릭터가 없거나 최초 읽기가 미결이면 파일 쓰기 전에 false로 중단합니다.

복원 값은 캐릭터 BeginPlay의 기본값 초기화 뒤에 적용합니다. 로드 시 Pawn이 없다면 아래 공통 적용 함수가 준비 경로에서 다시 호출될 때까지 값을 보류합니다. 객체 참조와 직렬화에서 설명한 것처럼 Pickup/HUD/Pawn 포인터가 다음 실행의 객체와 상태를 자동으로 복원하지는 않습니다. 이 프로젝트는 탄약 숫자만 저장하며 Pickup 소모 여부·플레이어 위치·전체 월드 상태는 저장하지 않으므로, 새 실행에서 레벨의 Pickup이 다시 나타날 수 있습니다. 전체 재소유·멀티플레이 복제도 이 작은 목표 밖입니다.


탄약 습득을 HUD까지 연결하기

이 단계의 결과는 Ammo: 0/30에서 Pickup 하나를 지나 Ammo: 10/30으로 바뀌는 것입니다. HUD 절의 스탯·getter·위젯·Controller 연결을 같은 실제 프로젝트에 먼저 합치세요. 캐릭터의 이동·카메라 컴포넌트와 입력 코드, Controller의 HUD 생성·현재값 조회는 유지합니다. 아래 Character 코드는 이 절의 저장 GameMode 연결까지 합친 최종형입니다. 기존 클래스에 멤버와 초기화 부분을 합치고, 모든 선언·정의를 갖춘 뒤 프로젝트의 기존 반영 절차로 확인하세요.

캐릭터의 초깃값과 탄약 증가 함수

MyCharacter.h의 기존 AMyCharacter 클래스 안에 다음 멤버를 추가합니다. 이미 있는 클래스 선언이나 getter를 다시 만들지 않습니다. 저장 입력을 위해 class UInputAction; 미리 선언이 없으면 클래스 앞에 추가하고, 기존 include와 .generated.h의 마지막 include 위치는 유지합니다. bAmmoInitialized는 탄약 초기화가 끝났는지를 나타내는 값이며, 객체가 유효한지만 확인하는 IsValid와 다릅니다.

MyCharacter.h (기존 클래스 안에 추가)
public:
    UFUNCTION(BlueprintPure, Category = "Combat")
    bool IsAmmoInitialized() const { return bAmmoInitialized; }

    UFUNCTION(BlueprintCallable, Category = "Combat")
    int32 AddAmmo(int32 Amount);

    bool TryApplyLoadedAmmo(int32 Value);

protected:
    UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Input")
    UInputAction* SaveAmmoAction = nullptr;

    void RequestSaveAmmo();
    bool bAmmoInitialized = false;

최대 탄약은 30으로 설정합니다. 기존 생성자의 CurrentAmmo = MaxAmmo; 한 줄을 다음으로 바꾸세요. 다른 생성자 코드와 체력 초깃값, 카메라·이동 컴포넌트는 그대로 둡니다.

MyCharacter.cpp (기존 생성자의 탄약 초기화)
CurrentAmmo = 0;

기존 BeginPlay에도 최초 탄약을 0으로 설정해야 합니다. 생성자만 바꾸면 HUD 예제의 BeginPlay가 다시 최댓값으로 채웁니다. 아래 초기화·통지를 기존 함수에 합치고, 기존 입력 Mapping Context 등록과 다른 준비 코드도 유지하세요. Super::BeginPlay()는 기존 호출을 사용합니다.

MyCharacter.cpp (초기화와 새 증가 함수)
#include "MyCharacter.h"
#include "MyPlayerController.h"
#include "MyGameModeBase.h"
#include "Kismet/GameplayStatics.h"
#include "EnhancedInputComponent.h"
#include "InputAction.h"

void AMyCharacter::BeginPlay()
{
    Super::BeginPlay();
    // 기존 프로젝트의 입력 Mapping Context 등록 등은 유지합니다.

    CurrentHealth = MaxHealth;
    CurrentAmmo = 0;
    bAmmoInitialized = true;

    AMyGameModeBase* Mode = Cast<AMyGameModeBase>(
        UGameplayStatics::GetGameMode(this));
    if (IsValid(Mode)) Mode->TryApplyPendingAmmo();

    AMyPlayerController* PC = Cast<AMyPlayerController>(GetController());
    if (IsValid(PC))
    {
        PC->OnHealthChanged(CurrentHealth, MaxHealth);
        PC->OnAmmoChanged(CurrentAmmo, MaxAmmo);
    }
}

int32 AMyCharacter::AddAmmo(int32 Amount)
{
    if (!bAmmoInitialized || Amount <= 0 || MaxAmmo < 0 ||
        CurrentAmmo < 0 || CurrentAmmo > MaxAmmo)
    {
        return 0;
    }

    AMyGameModeBase* Mode = Cast<AMyGameModeBase>(
        UGameplayStatics::GetGameMode(this));
    APlayerController* PC0 = UGameplayStatics::GetPlayerController(this, 0);
    if (!IsValid(Mode) || !Mode->IsAmmoSessionReady() || !IsValid(PC0) ||
        PC0->GetPawn() != this || GetController() != PC0)
    {
        return 0;
    }

    const int32 Added = FMath::Min(Amount, MaxAmmo - CurrentAmmo);
    if (Added <= 0) return 0;

    CurrentAmmo += Added;
    AMyPlayerController* PC = Cast<AMyPlayerController>(GetController());
    if (IsValid(PC))
    {
        PC->OnAmmoChanged(CurrentAmmo, MaxAmmo);
    }

    UE_LOG(LogTemp, Log, TEXT("Ammo gained %d: %d/%d"),
           Added, CurrentAmmo, MaxAmmo);
    return Added;
}

이 함수는 요청량이 아니라 실제로 더한 수를 반환합니다. 유효한 현재값에서 남은 용량을 먼저 계산하므로 큰 요청량을 현재 탄약에 더하다가 정수 범위를 넘기는 계산도 피합니다. 예시: 25/30에 10을 요청하면 실제 증가량은 5이고 30/30이 됩니다. 이미 30/30이면 0을 반환합니다. 최초 읽기가 미판정·보류·오류이면 실제 증가량 0을 돌려주며 Pickup을 남깁니다. 아래 GameMode가 판정을 마치고 현재 Pawn이 준비돼야 증가할 수 있습니다. 체력 초기화와 두 초기 통지는 유지하며, HUD가 나중에 준비되면 기존 SyncHUDFromPawn이 그때의 getter 값을 읽습니다.

Pickup은 현재 플레이어의 캡슐만 처리

기존 프로젝트 모듈에 MyAmmoPickup.h/.cpp를 추가합니다. 아래는 새 AActor 파생 클래스의 선언·정의입니다. MYPROJECT_API는 실제 게임 모듈의 API 매크로로 바꾸고, Character/Controller의 include·타입도 앞에서 맞춘 실제 클래스명과 일치시킵니다. .generated.h는 헤더의 마지막 include로 둡니다.

MyAmmoPickup.h
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "MyAmmoPickup.generated.h"

class UBoxComponent;
class UPrimitiveComponent;

UCLASS()
class MYPROJECT_API AMyAmmoPickup : public AActor
{
    GENERATED_BODY()

public:
    AMyAmmoPickup();

protected:
    virtual void BeginPlay() override;

    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Pickup")
    UBoxComponent* PickupBox;

    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Pickup")
    int32 AmmoAmount = 10;

private:
    bool bConsumed = false;

    UFUNCTION()
    void OnOverlapBegin(UPrimitiveComponent* OverlappedComp,
        AActor* OtherActor, UPrimitiveComponent* OtherComp,
        int32 OtherBodyIndex, bool bFromSweep,
        const FHitResult& SweepResult);
};
MyAmmoPickup.cpp
#include "MyAmmoPickup.h"
#include "MyCharacter.h"
#include "Components/BoxComponent.h"
#include "Components/CapsuleComponent.h"
#include "GameFramework/Controller.h"

AMyAmmoPickup::AMyAmmoPickup()
{
    PrimaryActorTick.bCanEverTick = false;
    PickupBox = CreateDefaultSubobject<UBoxComponent>(TEXT("PickupBox"));
    RootComponent = PickupBox;
    PickupBox->SetCollisionProfileName(TEXT("OverlapAllDynamic"));
    PickupBox->SetCollisionEnabled(ECollisionEnabled::QueryOnly);
    PickupBox->SetCollisionObjectType(ECC_WorldDynamic);
    PickupBox->SetCollisionResponseToChannel(ECC_Pawn, ECR_Overlap);
    PickupBox->SetGenerateOverlapEvents(true);
    PickupBox->SetBoxExtent(FVector(100.0f));
}

void AMyAmmoPickup::BeginPlay()
{
    Super::BeginPlay();
    PickupBox->OnComponentBeginOverlap.AddDynamic(
        this, &AMyAmmoPickup::OnOverlapBegin);
}

void AMyAmmoPickup::OnOverlapBegin(UPrimitiveComponent* OverlappedComp,
    AActor* OtherActor, UPrimitiveComponent* OtherComp,
    int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
    if (bConsumed || !IsValid(OtherActor)) return;

    AMyCharacter* Character = Cast<AMyCharacter>(OtherActor);
    if (!IsValid(Character) || !Character->IsPlayerControlled() ||
        !Character->IsLocallyControlled() ||
        OtherComp != Character->GetCapsuleComponent())
    {
        return;
    }

    const AController* Controller = Character->GetController();
    if (!IsValid(Controller) || Controller->GetPawn() != Character ||
        !Character->IsAmmoInitialized())
    {
        return;
    }

    const int32 Added = Character->AddAmmo(AmmoAmount);
    if (Added <= 0) return;

    bConsumed = true;
    PickupBox->SetCollisionEnabled(ECollisionEnabled::NoCollision);
    Destroy();
}

OtherActor의 실제 캐릭터 타입과 OtherComp의 캡슐을 함께 확인합니다. 같은 캐릭터의 메시가 겹쳐도 지급하지 않습니다. 현재 Controller가 소유한 로컬 플레이어인지와 탄약 초기화도 검사합니다. Components/CapsuleComponent.h는 캡슐 포인터를 다른 충돌 컴포넌트와 비교할 때 필요한 타입 정의를 제공합니다.

실제 증가량이 양수이면 먼저 bConsumed를 표시하고 충돌을 끈 뒤 액터 제거를 요청합니다. 제거가 끝날 때까지 새 콜백이 오더라도 같은 Pickup이 다시 지급되지 않게 합니다. Blueprint의 BeginOverlap에서도 탄약 증가를 실행하면 이 경로와 이중 지급될 수 있으므로 지급은 위 C++ 콜백 하나에서 맡깁니다.

에셋을 준비하고 레벨에 배치

  1. 기존 프로젝트의 C++ 빌드 절차로 클래스를 반영한 뒤, 현재 조작 가능한 Character에 위 스탯/getter/함수가 있고 실제 PlayerController가 HUD 절의 연결을 사용하도록 맞춥니다. 현재 레벨의 GameMode override, 실제 GameMode의 Default Pawn Class·PlayerController Class도 확인합니다. 기존 카메라·이동 입력이 작동할 준비 상태를 먼저 갖추세요. HUD 예제의 기본 Character로 단순 교체하면 조작 코드가 빠질 수 있습니다.
  2. 실제 WBP_PlayerHUD의 부모는 UMyPlayerHUD, 자식 이름/타입은 HealthProgressBar와 AmmoCountText로 맞춥니다. Is Variable·UMG 의존성과 Percent/Text의 Bind 비움을 확인하고, 실제 Controller의 PlayerHUDClass에 이 위젯 클래스를 지정합니다. GameMode의 AHUD용 HUD Class와 다른 설정입니다.
  3. AMyAmmoPickup을 부모로 한 Blueprint BP_AmmoPickup을 만들고 Ammo Amount=10으로 둡니다. box의 각 축 반길이 100 cm는 스케일 1에서 전체 크기 200 cm입니다. box 자체는 게임에서 보이지 않으므로 필요하면 기존의 단순 Static Mesh를 표식으로 붙이고, 표식의 Collision Preset은 NoCollision로 둡니다. 감지는 box 하나가 맡으며 Starter Content나 고급 메시가 필수는 아닙니다.
  4. box와 캐릭터 캡슐 양쪽의 Generate Overlap Events를 켭니다. box의 QueryOnly·Pawn 응답 Overlap, 상대 캡슐의 query 참여와 채널 응답을 확인하세요. 프로젝트에서 프리셋을 바꾸거나 한쪽 응답을 Ignore로 둔 경우 이름만 같아도 기대한 사건이 오지 않을 수 있습니다.
  5. 조작 가능한 작은 레벨을 L_AmmoLoop로 준비하고 바닥·시작점·Pickup 하나를 둡니다. Character의 최대 탄약을 30으로 맞추고 플레이어는 box 밖에서 시작합니다. 실제 게임 기본 맵/레벨 GameMode도 이 대상으로 지정합니다. 저장 입력은 아래 두 번째 단계의 실제 Input Action/기존 바인딩에 합칩니다.

이 예제는 BeginPlay에서 구독한 뒤 새로 들어오는 캡슐을 처리합니다. 처음부터 box 안에 있는 플레이어의 초기 겹침 목록 조회는 제공하지 않습니다. Overlap 절의 콜백과 점유 상태 설명을 참고해 시작점을 밖에 두세요.

값과 표시가 다를 때 확인할 곳

조건/행동연결 뒤 예상 결과먼저 확인할 곳
새 슬롯 판정·첫 준비 완료, Pickup 전Character 0/30, HUD Ammo: 0/30생성자·BeginPlay 두 초깃값, 최초 읽기·현재 Pawn, HUD 현재값 조회
새 슬롯에서 현재 플레이어 캡슐이 진입증가량 10, Character/HUD 10/30, Pickup 소모양쪽 query/Overlap·캡슐 필터·초기 읽기/초기화·getter와 통지
다른 Actor·캐릭터 메시증가하지 않고 Pickup 남음OtherActor cast와 OtherComp·현재 소유 필터
지급 성공 뒤 중복 콜백같은 Pickup의 추가 지급 없음bConsumed·충돌 해제·Blueprint 이중 지급
현재 25/30에 요청 10실제 5를 더해 30/30, Pickup 소모남은 용량 계산과 실제 증가량 반환
현재 30/30에 요청 10증가량 0, Pickup 남음증가량 0일 때 소모하지 않는 분기
HUD가 지급 뒤에 준비현재값 10을 읽어 10/30 표시HUD 부착 뒤 SyncHUDFromPawn, Character getter

25/30·30/30 행은 함수 정책을 설명하는 예상입니다. 그 상태를 만들 발사 입력·실제 발사체는 이 작은 목표에 추가하지 않습니다. 가득 찬 채 box 안에 머무르다 탄약이 줄어도 자동 재시도는 없습니다. 나갔다 다시 진입하면 새 BeginOverlap에서 지급을 시도할 수 있습니다.

HUD가 0/0에 머무르면 임시 표시를 실제 탄약으로 읽지 말고 위젯 부모/자식 이름·PlayerHUDClass·현재 Pawn·부착/소유 뒤 조회를 확인하세요. getter가 10인데 화면이 0이면 데이터 지급을 반복하지 말고 Controller의 OnAmmoChanged와 HUD 갱신·snapshot 연결부터 비교합니다.

캐릭터의 최초 초깃값은 0이며, 아래 저장 단계에서는 유효한 파일 값이 있으면 초기화 뒤 그 총 탄약을 적용합니다. 따라서 새 슬롯은 0, 유효한 저장값 10이 있는 새 실행은 준비 뒤 10으로 시작합니다. 단일 로컬 Standalone에서 처음 시작하는 캐릭터 한 개를 다루며 초기 점유 조회·전체 소유 해제/리스폰·네트워크 복제는 구현하지 않았습니다. 준비 순서·충돌·실제 에셋/입력과 C++ 빌드는 사용하는 프로젝트에서 확인해야 합니다.


저장한 탄약을 새 실행에서 복원하기

Pickup이 바꾼 현재 탄약을 저장 객체에 복사하고, 다음 실행에서는 읽은 값을 준비된 캐릭터에 돌려놓겠습니다. SaveGame 절의 슬롯 입출력과 직렬화의 객체 참조 경계를 복습하세요. 저장 파일을 읽는 것, 캐릭터 값을 적용하는 것, HUD에 표시하는 것은 각각 다른 단계입니다.

이 코드는 단일 로컬 Standalone에서 Controller 인덱스 0의 현재 캐릭터 하나를 처리합니다. 저장 슬롯은 AmmoLoopSlot, 저장 사용자 값은 0입니다. 두 값은 쓰기와 읽기에서 같아야 하며, 저장 사용자 값과 Controller 조회 인덱스는 다른 개념입니다. 기존 프로젝트의 다른 저장 슬롯은 사용하거나 삭제하지 않습니다.

탄약만 담는 새 SaveGame 클래스

기존 UMySaveGame에는 이름·체력·위치·아이템 등이 있습니다. 거기에 탄약 필드만 더하면 다른 non-transient 속성도 저장되므로, 게임 진행 데이터가 탄약 하나인 새 UMyAmmoSaveGame을 USaveGame에서 직접 만듭니다. 저장 파일에는 클래스·엔진 메타데이터도 있으며 int32 네 바이트만 기록한다는 뜻은 아닙니다. 기본 SaveGameToSlot은 SaveGame 지정자로 기존 다른 필드를 골라 제외하지 않습니다. 공식 저장 API의 속성 정책.

MyAmmoSaveGame.h
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/SaveGame.h"
#include "MyAmmoSaveGame.generated.h"

UCLASS()
class MYPROJECT_API UMyAmmoSaveGame : public USaveGame
{
    GENERATED_BODY()

public:
    UMyAmmoSaveGame();

    UPROPERTY()
    int32 SavedAmmo = 0;
};
MyAmmoSaveGame.cpp
#include "MyAmmoSaveGame.h"

UMyAmmoSaveGame::UMyAmmoSaveGame()
{
    SavedAmmo = 0;
}

기존 게임 모듈에 USaveGame 파생 클래스 MyAmmoSaveGame을 만들고 위 헤더·소스를 반영합니다. 생성 도구가 제공한 생성자가 있다면 위 선언·정의와 맞추고 중복하지 않습니다. 사용하는 프로젝트 모듈의 실제 API 매크로로 MYPROJECT_API를 바꾸세요. 별도 SaveGame Blueprint를 만드는 대신 아래 GameMode에서 이 native 클래스를 기본으로 지정합니다. 기존 MySaveGame.h/.cpp와 그 예제는 보존합니다.

GameMode에서 최초 읽기와 저장을 관리

실제 MyGameModeBase.h에서 .generated.h를 마지막 include로 유지하고, 클래스 선언 앞에 class UMyAmmoSaveGame;와 class AMyCharacter;를 미리 선언합니다. 아래 멤버를 기존 GameMode 한 클래스 안에 합칩니다. 이미 BeginPlay 선언이 있다면 재사용하세요. 앞선 절의 SaveGameClass, SaveMyGame, LoadMyGame과 구별되는 새 이름입니다.

MyGameModeBase.h (기존 클래스 안에 추가)
public:
    bool SaveAmmoLoop();
    void LoadAmmoLoopOnce();
    void TryApplyPendingAmmo();
    bool IsAmmoSessionReady() const;

protected:
    virtual void BeginPlay() override;

    UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Ammo Save")
    TSubclassOf<UMyAmmoSaveGame> AmmoSaveGameClass;

private:
    AMyCharacter* FindCurrentAmmoCharacter() const;
    bool bInitialLoadAttempted = false;
    bool bLoadFailed = false;
    bool bHasPendingAmmo = false;
    int32 PendingAmmo = 0;

읽기 결과가 없다는 뜻과 읽기를 아직 하지 않았다는 뜻을 구별합니다. 기존 슬롯 읽기에 실패했으면 오류 상태를 유지해 새 0을 덮어쓰지 않습니다. 읽은 값은 캐릭터 준비 전까지 int32 PendingAmmo에 보류합니다. SaveGame 객체나 이전 Pawn의 포인터를 보류 데이터로 저장하지 않습니다.

다음 include·멤버 정의도 실제 .cpp에 합칩니다. 생성자와 BeginPlay가 이미 있다면 해당 줄을 합치고 같은 함수를 두 번 정의하지 마세요. 기존 GameMode의 Pawn/Controller 클래스 설정과 다른 초기화는 유지합니다.

MyGameModeBase.cpp (탄약 저장·복원 연결)
#include "MyGameModeBase.h"
#include "MyAmmoSaveGame.h"
#include "MyCharacter.h"
#include "MyPlayerController.h"
#include "Engine/World.h"
#include "Kismet/GameplayStatics.h"

namespace
{
    const FString AmmoSlotName = TEXT("AmmoLoopSlot");
    constexpr int32 AmmoUserIndex = 0;
    constexpr int32 AmmoCapacity = 30;
}

AMyGameModeBase::AMyGameModeBase()
{
    // 기존 Pawn/Controller 클래스 설정 등은 유지합니다.
    AmmoSaveGameClass = UMyAmmoSaveGame::StaticClass();
}

void AMyGameModeBase::BeginPlay()
{
    Super::BeginPlay();
    // 기존 GameMode 초기화는 유지합니다.
    LoadAmmoLoopOnce();
}

AMyCharacter* AMyGameModeBase::FindCurrentAmmoCharacter() const
{
    const UWorld* World = GetWorld();
    if (!World || World->GetNetMode() != NM_Standalone) return nullptr;

    AMyPlayerController* PC = Cast<AMyPlayerController>(
        UGameplayStatics::GetPlayerController(this, 0));
    if (!IsValid(PC) || !PC->IsLocalController()) return nullptr;

    AMyCharacter* Character = Cast<AMyCharacter>(PC->GetPawn());
    if (!IsValid(Character) || Character->GetController() != PC ||
        !Character->IsPlayerControlled() || !Character->IsLocallyControlled() ||
        !Character->IsAmmoInitialized())
    {
        return nullptr;
    }
    return Character;
}

bool AMyGameModeBase::IsAmmoSessionReady() const
{
    if (!bInitialLoadAttempted || bLoadFailed || bHasPendingAmmo) return false;

    const AMyCharacter* Character = FindCurrentAmmoCharacter();
    return IsValid(Character) && Character->GetMaxAmmo() == AmmoCapacity &&
        Character->GetCurrentAmmo() >= 0 &&
        Character->GetCurrentAmmo() <= AmmoCapacity;
}

bool AMyGameModeBase::SaveAmmoLoop()
{
    if (!IsAmmoSessionReady())
    {
        UE_LOG(LogTemp, Warning, TEXT("Ammo session is not ready to save."));
        return false;
    }

    AMyCharacter* Character = FindCurrentAmmoCharacter();
    if (!IsValid(Character) || Character->GetMaxAmmo() != AmmoCapacity ||
        Character->GetCurrentAmmo() < 0 ||
        Character->GetCurrentAmmo() > AmmoCapacity)
    {
        return false;
    }
    if (!AmmoSaveGameClass)
    {
        UE_LOG(LogTemp, Error, TEXT("AmmoSaveGameClass is not set."));
        return false;
    }

    UMyAmmoSaveGame* Save = Cast<UMyAmmoSaveGame>(
        UGameplayStatics::CreateSaveGameObject(AmmoSaveGameClass));
    if (!IsValid(Save))
    {
        UE_LOG(LogTemp, Error, TEXT("Cannot create ammo SaveGame object."));
        return false;
    }

    Save->SavedAmmo = Character->GetCurrentAmmo();
    const bool bSaved = UGameplayStatics::SaveGameToSlot(
        Save, AmmoSlotName, AmmoUserIndex);
    if (bSaved)
    {
        UE_LOG(LogTemp, Log, TEXT("Saved ammo %d."), Save->SavedAmmo);
    }
    else
    {
        UE_LOG(LogTemp, Error, TEXT("Ammo slot write failed."));
    }
    return bSaved;
}

void AMyGameModeBase::LoadAmmoLoopOnce()
{
    if (bInitialLoadAttempted) return;
    bInitialLoadAttempted = true;

    const UWorld* World = GetWorld();
    if (!World || World->GetNetMode() != NM_Standalone)
    {
        bLoadFailed = true;
        UE_LOG(LogTemp, Error, TEXT("Ammo loop supports local Standalone only."));
        return;
    }
    if (!UGameplayStatics::DoesSaveGameExist(AmmoSlotName, AmmoUserIndex))
    {
        UE_LOG(LogTemp, Log, TEXT("No ammo slot: new game starts at zero."));
        return; // 자동 저장은 하지 않습니다.
    }

    USaveGame* Loaded = UGameplayStatics::LoadGameFromSlot(
        AmmoSlotName, AmmoUserIndex);
    if (!IsValid(Loaded))
    {
        bLoadFailed = true;
        UE_LOG(LogTemp, Error, TEXT("Existing ammo slot cannot be loaded."));
        return;
    }
    UMyAmmoSaveGame* Save = Cast<UMyAmmoSaveGame>(Loaded);
    if (!IsValid(Save))
    {
        bLoadFailed = true;
        UE_LOG(LogTemp, Error, TEXT("Ammo slot has a different SaveGame type."));
        return;
    }
    if (Save->SavedAmmo < 0 || Save->SavedAmmo > AmmoCapacity)
    {
        bLoadFailed = true;
        UE_LOG(LogTemp, Error, TEXT("Saved ammo is outside 0..30."));
        return;
    }

    PendingAmmo = Save->SavedAmmo;
    bHasPendingAmmo = true;
    TryApplyPendingAmmo();
}

void AMyGameModeBase::TryApplyPendingAmmo()
{
    if (!bInitialLoadAttempted || bLoadFailed || !bHasPendingAmmo) return;

    AMyCharacter* Character = FindCurrentAmmoCharacter();
    if (!IsValid(Character)) return; // 준비 경로에서 다시 호출합니다.
    if (Character->GetMaxAmmo() != AmmoCapacity ||
        !Character->TryApplyLoadedAmmo(PendingAmmo))
    {
        bLoadFailed = true;
        UE_LOG(LogTemp, Error, TEXT("Cannot apply pending ammo to current Pawn."));
        return;
    }

    bHasPendingAmmo = false;
    UE_LOG(LogTemp, Log, TEXT("Applied saved ammo %d."), PendingAmmo);
}

DoesSaveGameExist만으로 읽기·타입·값의 유효성이 확인되지는 않습니다. 기존 파일을 읽은 객체가 null인지, 탄약 전용 타입인지, 0~30인지 각각 검사합니다. 범위를 벗어난 값을 clamp해 정상 진행도로 바꾸지 않습니다. 파일이 없으면 새 게임으로 판정하고, 유효한 저장값이 있으면 실제 적용 성공 뒤에만 pending을 비웁니다.

SaveAmmoLoop는 최초 판정·보류·오류와 현재 Pawn의 준비·최대 30·현재값을 먼저 확인합니다. Pawn이 없다고 기본값 0을 저장하지 않습니다. 클래스 생성에 실패하면 쓰기 API에 도달하지 않고, 쓰기를 호출했다면 실제 bool 결과를 반환합니다. 쓰기 API가 false를 반환한 뒤 이전 파일 바이트가 반드시 보존된다는 보장은 하지 않습니다. 이 예제는 동기 저장이며 atomic write·backup·rollback·버전 migration을 구현하지 않습니다. 공식 저장/로드 안내.

복원값 적용과 HUD 현재값 조회

앞의 Character 헤더에 선언한 TryApplyLoadedAmmo는 증가 함수와 다릅니다. 저장된 총 탄약을 검증해 대입하고 HUD에 알립니다. pending 상태에서는 저장·습득을 막는 IsAmmoSessionReady가 false이므로, 이 적용 함수까지 그 조건을 요구하면 pending을 해소할 수 없습니다.

MyCharacter.cpp (로드한 총 탄약 적용)
bool AMyCharacter::TryApplyLoadedAmmo(int32 Value)
{
    if (!bAmmoInitialized || MaxAmmo != 30 || Value < 0 || Value > MaxAmmo)
    {
        return false;
    }

    AMyPlayerController* PC = Cast<AMyPlayerController>(GetController());
    if (!IsValid(PC) || !PC->IsLocalController() || PC->GetPawn() != this ||
        UGameplayStatics::GetPlayerController(this, 0) != PC)
    {
        return false;
    }

    CurrentAmmo = Value;
    PC->OnAmmoChanged(CurrentAmmo, MaxAmmo);
    return true;
}

로드값 0과 현재값 0이 같아도 유효한 적용은 성공입니다. 증가량이 0이면 소모하지 않는 Pickup 정책과 혼동하지 마세요. GameMode는 이 bool이 true일 때만 pending을 해제합니다. 캐릭터 초기화 직후의 호출은 앞의 BeginPlay에 이미 합쳤고, 소유 경로는 기존 Controller 함수에 다음처럼 합칩니다. 실제 MyPlayerController.cpp에 MyGameModeBase.h와 Kismet/GameplayStatics.h include도 추가합니다.

MyPlayerController.cpp (기존 OnPossess에 합치기)
void AMyPlayerController::OnPossess(APawn* InPawn)
{
    Super::OnPossess(InPawn);
    AMyGameModeBase* Mode = Cast<AMyGameModeBase>(
        UGameplayStatics::GetGameMode(this));
    if (IsValid(Mode)) Mode->TryApplyPendingAmmo();
    SyncHUDFromPawn();
}

같은 TryApplyPendingAmmo 함수를 로드 직후, 캐릭터 초깃값 0·ready 뒤/두 초기 통지 앞, Super::OnPossess 뒤/현재값 조회 앞에서 호출합니다. 로드가 늦으면 첫 경로, 캐릭터 초기화가 늦으면 둘째, 소유가 늦으면 셋째가 맡습니다. 서로의 BeginPlay 순서를 고정하지 않고 Tick이나 임의 지연 timer도 두지 않습니다.

HUD가 아직 없다고 데이터 적용을 막지 않습니다. 적용 통지가 먼저 왔어도 기존 Controller가 HUD를 붙인 뒤 SyncHUDFromPawn에서 현재 10을 읽습니다. HUD 재부착 기능을 추가하면 그 직후에도 같은 조회가 필요하며, 다시 파일을 읽거나 탄약을 지급하는 동작은 아닙니다.

저장 입력을 기존 바인딩에 추가

입력 처리 절의 Enhanced Input 설정이 이미 있는 프로젝트를 사용합니다. 게임 모듈의 EnhancedInput 의존성·입력 컴포넌트와 활성 Mapping Context를 확인하고, 기존 이동·카메라 바인딩과 Context 등록을 유지하세요. 앞의 헤더에 추가한 SaveAmmoAction에 연결할 IA_SaveAmmo를 Bool로 만들고, 이미 등록해 사용하는 IMC에 F5 키를 한 번 매핑합니다. Action과 해당 mapping에 Hold/Double Tap 같은 별도 trigger는 추가하지 않습니다.

실제 Character Blueprint의 Class Defaults에서 Save Ammo Action에 IA_SaveAmmo를 지정합니다. 아래 코드는 기존 SetupPlayerInputComponent의 UEnhancedInputComponent* EnhancedInputComponent cast가 성공한 블록 안에 덧붙입니다. Super 호출·Move/Look/Jump 바인딩은 유지하며 같은 저장을 Blueprint 이벤트에도 연결하지 않습니다.

MyCharacter.cpp (기존 Enhanced Input cast 블록 안에 추가)
if (IsValid(SaveAmmoAction))
{
    EnhancedInputComponent->BindAction(SaveAmmoAction,
        ETriggerEvent::Started, this, &AMyCharacter::RequestSaveAmmo);
}
MyCharacter.cpp (저장 요청 콜백)
void AMyCharacter::RequestSaveAmmo()
{
    AMyPlayerController* PC = Cast<AMyPlayerController>(GetController());
    if (!IsValid(PC) || !PC->IsLocalController() || PC->GetPawn() != this ||
        UGameplayStatics::GetPlayerController(this, 0) != PC)
    {
        return;
    }

    AMyGameModeBase* Mode = Cast<AMyGameModeBase>(
        UGameplayStatics::GetGameMode(this));
    if (!IsValid(Mode))
    {
        UE_LOG(LogTemp, Error, TEXT("Current GameMode is not the ammo loop Mode."));
        return;
    }
    const bool bSaved = Mode->SaveAmmoLoop();
    UE_LOG(LogTemp, Log, TEXT("Save request result: %s"),
        bSaved ? TEXT("success") : TEXT("failure"));
}

이 단순 키 정책은 Started에서 요청하며 누르고 있는 매 프레임마다 저장하지 않습니다. Started는 trigger 평가 시작이고 임의 trigger의 성공을 뜻하지 않으므로, 다른 trigger를 추가하면 입력 정책도 다시 정해야 합니다. 공식 Enhanced Input 상태 설명. 콜백이 없으면 실제 Pawn·SaveAmmoAction 할당·활성 IMC·컴포넌트 cast·키/입력 모드를 먼저 확인합니다. 원래 앞선 절의 BeginPlay 때 Controller/LocalPlayer가 없으면 Context 등록을 놓칠 수 있으며 재소유 시 등록/해제는 이 예제에도 추가하지 않았습니다.

두 번째 단계의 준비와 예상

실제 사용되는 GameMode가 위 코드의 클래스인지 확인하고, 그 Blueprint의 Ammo Save Game Class가 UMyAmmoSaveGame native 클래스로 설정됐는지 봅니다. 기존 Save Game Class와 다른 속성입니다. L_AmmoLoop의 GameMode override·Pawn/Controller·WBP_PlayerHUD 지정, 양쪽 캡슐/box Overlap과 시작점 밖 배치는 앞 단계의 설정을 유지합니다. 모든 선언·정의를 같은 실제 클래스에 합친 뒤 프로젝트의 기존 C++ 반영 절차로 확인하고 에셋을 설정하세요. 별도 프로젝트 파일·에셋 파일은 이 문서에 포함되지 않습니다.

최초 읽기 상태데이터 처리저장·습득
아직 판정 전초깃값 0은 임시값중단
슬롯 없음준비된 캐릭터의 탄약 0, 파일 자동 생성 없음준비 뒤 허용
유효값을 읽었으나 Pawn 미준비PendingAmmo 숫자 보관적용 전 중단
현재 준비된 Pawn에 적용 성공총 탄약 대입·HUD 통지·pending 해제허용
기존 슬롯 읽기/타입/범위 또는 적용 설정 오류실패 상태, 초깃값 0 자동 저장·파일 삭제 없음중단

준비가 미결인 동안 Overlap이 오면 AddAmmo는 0을 돌려주므로 Pickup은 남습니다. 준비가 끝났다고 box 안에 머무른 플레이어에게 자동 재지급하지 않으며, 나갔다 다시 진입해야 새 콜백에서 시도합니다. 오류 뒤 자동 재시도·파일 삭제·손상 복구 버튼은 구현하지 않습니다.

새 슬롯에서 0/30 → Pickup → 10/30 → F5 저장을 연결한 뒤, 같은 슬롯·사용자 값으로 새 실행을 시작하면 총 탄약 10이 캐릭터/HUD에 적용되는 것이 목표입니다. 화면이 0이라고 저장 키를 먼저 눌러 덮어쓰기보다 다음 절의 작은 예상·실패 표에서 최초 읽기·pending·현재 getter와 HUD 전달을 나누어 확인하세요. 이 숫자는 연결 뒤의 예상이며 실제 실행 결과로 제시한 로그는 아닙니다.

새 실행에는 레벨의 Pickup이 다시 나타납니다. 저장하는 것은 탄약뿐이라 다시 주우면 10→20이 될 수 있습니다. 위치·Pickup 소모·월드 전체·체력·인벤토리·포인터·cloud/backup은 저장하지 않습니다. 로드는 한 실행의 최초 1회이며 별도 로드 키·리스폰·전체 재소유/소유 해제·분할 화면·네트워크 복제는 범위 밖입니다.


예시: 준비 순서가 달라도 같은 저장값을 적용하기

위 코드에서 슬롯의 SavedAmmo가 유효한 10이라고 정합니다. GameMode가 먼저 읽었지만 Character 초기화나 소유가 아직 준비되지 않았다면 PendingAmmo에 10을 남기고 저장·습득을 막습니다. 뒤의 Character BeginPlay 또는 Controller OnPossess에서 같은 적용 함수를 다시 부릅니다. 현재 캐릭터의 탄약 초기화·소유·최댓값 조건이 맞으면 총 탄약 10을 적용하고 pending을 해제합니다. 반대로 캐릭터가 먼저 준비됐다면 로드 직후의 호출이 적용을 맡습니다. 초기화 순서 하나를 정해 두고 기다리는 timer를 넣는 모델이 아닙니다.

그다음 HUD가 늦게 붙어도 현재 getter의 10을 조회하면 됩니다. HUD 표시가 늦다는 이유로 파일 읽기나 Pickup 지급을 다시 실행하지 않습니다. AddAmmo는 남은 용량만큼 더하고 실제 증가량을 반환하지만 TryApplyLoadedAmmo는 검증된 총량을 대입한다는 차이도 유지합니다.

새 조건에서 저장값은 유효한 0이고 현재 캐릭터도 0/30입니다. 총량 적용의 결과와 pending 상태는 어떻게 되나요? 저장값이 31인 경우에도 같은 결과로 처리할까요? 준비가 안 된 동안 box에 들어와 지급을 거절당한 뒤, 그 안에서 준비가 끝나면 자동으로 10을 받는지도 답하세요.

0의 의미와 다시 시도하는 사건을 확인하기

유효한 총량 0은 적용 성공이며 pending을 해제할 수 있습니다. 현재값과 같아서 증가량이 0인 Pickup과 다른 계약입니다. 파일의 31은 범위 검사에서 실패 상태가 되며 30으로 clamp해 정상 파일처럼 적용하지 않습니다. 미결·오류 중에는 저장과 지급을 막습니다. 준비 완료 자체는 새 BeginOverlap이 아니므로 box 안에서 자동 지급하지 않습니다. 밖으로 나갔다 다시 들어오는 조건이 필요합니다. 실제 엔진 실행을 확인한 결과가 아니라 원 함수와 사건 조건을 따라 읽은 예상입니다.

프로젝트 생성 및 기본 설정

아직 기본 프로젝트가 없다면 아래 생성 옵션을 참고합니다. 이미 조작 가능한 프로젝트를 준비했다면 재사용하고 레벨·클래스·입력 설정을 먼저 확인하세요. 새 프로젝트 생성만으로 위의 탄약 루프가 완성되지는 않습니다.

새 프로젝트 생성

언리얼 엔진 런처 실행: 원하는 엔진 버전(예: 5.3, 5.4 등)을 선택하여 실행합니다.

새 프로젝트 생성: Games 탭에서 원하는 템플릿(Blank, First Person, Third Person 등)을 선택합니다.

  • Blank: 가장 기본적인 빈 프로젝트로, 모든 것을 직접 구성하고자 할 때 유용합니다.
  • First Person/Third Person: 기본적인 캐릭터 컨트롤러와 입력 설정이 되어 있어 빠르게 프로토타입을 만들 수 있습니다.
프로젝트 설정
  • Blueprint / C++: 프로젝트의 주 개발 언어를 선택합니다. C++ 기반 프로젝트는 블루프린트도 사용할 수 있습니다.
  • Quality Preset: Maximum 또는 Scalable 중 선택합니다. Scalable은 다양한 하드웨어에 대응하기 용이합니다.
  • Target Platform: Desktop 또는 Mobile 등을 선택합니다.
  • Starter Content: 필요에 따라 With Starter Content를 선택하여 기본적인 에셋(재료)을 포함할 수 있습니다.

프로젝트 이름 및 위치 지정: 프로젝트 이름을 명확하게 지정하고 저장될 디렉토리를 선택합니다.

기본 프로젝트 설정 조정

에디터에서 Edit -> Project Settings를 통해 프로젝트 전반에 걸친 설정을 조정합니다.

  • Maps & Modes
    • Game Default Map: 게임 시작 시 로드될 기본 맵을 지정합니다.
    • Default Game Mode: 게임 모드 클래스를 지정합니다.
  • 입력: Enhanced Input을 쓰는 프로젝트는 Input Action·Input Mapping Context와 Local Player Subsystem 등록을 맞춥니다. Engine - Input의 기존 Action/Axis Mapping 방식과 구분합니다.
  • Engine - Rendering: 렌더링 품질 설정, 나나이트/루멘 활성화/비활성화 등.
  • Engine - Physics: 물리 시뮬레이션 설정, 콜리전 프로파일 등.
  • Platforms: 대상 플랫폼별 설정 (모바일 앱 아이콘, 안드로이드 SDK 경로 등).

핵심 게임플레이 시스템 구현

GDD에 정의된 핵심 게임플레이 루프와 시스템을 언리얼 엔진의 구조에 맞춰 구현합니다. 아래는 프로젝트를 넓힐 때 참고할 기술별 선택지입니다. 첫 탄약 루프에는 전투·GAS·인벤토리 전체·AI·멀티플레이어를 필수로 넣지 않습니다.

게임 프레임워크 (Game Framework) 구성

언리얼 엔진은 게임의 기본 골격을 구성하는 프레임워크 클래스들을 제공합니다.

  • AGameModeBase / AGameMode
    • 게임의 규칙, 플레이어 접속/종료, 점수 계산, 게임 시작/종료 조건 등 게임의 전반적인 흐름을 관리합니다.
    • 멀티플레이어 게임에서는 서버에서만 존재하고 실행됩니다 (Server Authority).
    • Default Pawn Class, PlayerController Class, HUD Class, PlayerState Class, GameState Class 등을 지정합니다.
  • APlayerController
    • 플레이어의 입력(마우스, 키보드, 게임패드)을 받아 APawn 또는 ACharacter로 전달하고 제어합니다.
    • UI 상호작용, 카메라 제어 등 플레이어와 관련된 로직을 담당합니다.
    • 서버에는 각 연결 플레이어의 Controller가 있고, 클라이언트에는 자신이 소유한 로컬 플레이어의 Controller가 있습니다. 분할 화면은 로컬 플레이어가 여러 명일 수 있습니다.
  • APawn / ACharacter
    • 게임 세계에 존재하는 물리적인 엔티티를 나타냅니다. 플레이어가 조작할 수 있는 대상을 의미합니다.
    • ACharacter는 APawn의 특수한 형태로, 캡슐 콜리전, 이동 컴포넌트(CharacterMovementComponent) 등 사람 형태 캐릭터에 필요한 기능이 내장되어 있습니다.
    • 이동, 점프, 공격 등 캐릭터 고유의 행동 로직을 구현합니다.
  • APlayerState
    • 플레이어의 게임별 상태(점수, 이름, 남은 생명 등)를 저장하고 네트워크를 통해 동기화합니다.
    • 서버와 모든 클라이언트에 존재합니다.
  • AGameStateBase / AGameState
    • 게임의 전역적인 상태(남은 시간, 팀 점수, 게임 단계 등)를 저장하고 네트워크를 통해 동기화합니다.
    • 서버와 모든 클라이언트에 존재합니다.

구현 팁: 각 프레임워크 클래스는 C++로 기본 구현을 하고, 필요한 경우 블루프린트로 자식 클래스를 만들어 로직을 확장하는 것이 일반적입니다.

캐릭터 컨트롤 및 이동

  • ACharacter 클래스를 상속받아 캐릭터 블루프린트 또는 C++ 클래스를 생성합니다.
  • 입력 매핑: Enhanced Input에서는 Input Action·Mapping Context를 만들고 소유 Local Player에 Context를 추가합니다. SetupPlayerInputComponent에서 UEnhancedInputComponent에 액션을 바인딩합니다. 기존 입력 방식이라면 Project Settings의 Action/Axis Mapping과 해당 바인딩 API를 사용합니다.
  • 이동 구현: AddMovementInput, AddControllerYawInput, AddControllerPitchInput 등을 사용하여 캐릭터의 이동 및 시점 회전을 구현합니다.
  • 애니메이션: UAnimInstance를 상속받는 애니메이션 블루프린트를 생성하여 캐릭터의 이동, 점프, 공격 등에 따른 애니메이션 스테이트 머신과 로직을 구현합니다. CharacterMovementComponent의 속성(속도, 점프 상태 등)을 활용합니다.

컴뱃 시스템 (전투 시스템)

  • 공격 로직
    • 입력(공격 버튼) 감지 시 애니메이션 몽타주 재생, 공격 판정 영역 활성화(콜리전) 등.
    • UAbilitySystemComponent (GAS): 복잡한 스킬, 버프, 디버프 시스템을 구현할 때 매우 강력한 프레임워크입니다. (Gameplay Ability System)
  • 데미지 시스템
    • ApplyDamage, TakeDamage 함수를 사용하여 데미지 계산 및 적용.
    • GameplayEffect를 활용하여 데미지 타입, 속성 기반 데미지 등을 정의.
  • 체력 / 스탯 시스템
    • UAttributeSet을 상속받아 체력, 마나, 공격력 등 캐릭터 스탯을 정의하고 관리합니다.
    • GameplayEffect를 통해 스탯 변화를 적용합니다.

인벤토리 및 아이템 시스템

  • 아이템 정의: UDataAsset 파생 에셋이나 FTableRowBase 파생 USTRUCT를 Row Struct로 사용하는 UDataTable을 통해 아이템의 속성(이름, 설명, 아이콘, 효과)을 정의합니다.
  • 인벤토리 관리: TArray 또는 TMap을 사용하여 아이템 목록을 관리하는 컴포넌트(UInventoryComponent)를 생성하고, 아이템 추가/제거/사용 로직을 구현합니다.
  • UI 연동: UMG 위젯(UUserWidget)을 사용하여 인벤토리 화면, 아이템 슬롯, 아이템 정보 창 등을 구현하고, 인벤토리 컴포넌트의 데이터와 연동합니다.

아이템 데이터의 C++ 로드/검증 흐름은 7장 3절의 일반 파일·JSON 처리와 구분해야 합니다. UDataAsset·UDataTable 에셋 로딩은 엔진의 에셋 참조·로드 경로를 사용합니다.

UI/UX 구현

  • UMG (Unreal Motion Graphics): 언리얼 엔진의 강력한 위젯 기반 UI 시스템입니다.
    • 위젯 블루프린트 생성: HUD, 메인 메뉴, 인벤토리, 설정 창 등 필요한 UI 요소를 위젯 블루프린트로 만듭니다.
    • 계층 구조: Canvas Panel, Horizontal Box, Vertical Box, Grid Panel 등을 사용하여 UI 레이아웃을 구성합니다.
    • 데이터 바인딩: 위젯에 표시될 데이터를 C++ 또는 블루프린트 변수에 바인딩하여 동적으로 업데이트합니다.
    • 이벤트 처리: 버튼 클릭, 슬라이더 변경 등 사용자 입력에 대한 이벤트를 처리하고 게임 로직과 연동합니다.
  • UI 컨트롤러: APlayerController에서 UI 표시/숨김, 입력 모드 변경(게임 전용, UI 전용, 게임 및 UI) 등을 관리합니다.

AI 시스템 구현

  • AI Controller: AAIController를 상속받는 AI 컨트롤러 클래스를 생성하고, 대상 Pawn의 AI Controller Class와 Auto Possess AI 조건을 설정하고, 소유 후 Behavior Tree를 실행합니다.
  • 행동 트리 (Behavior Tree)
    • Behavior Tree 에셋과 Blackboard 에셋을 생성합니다.
    • 블랙보드에 AI가 인지하고 추적할 데이터(목표 위치, 대상 액터, 현재 상태)의 키를 정의합니다.
    • 행동 트리를 설계하여 Selector, Sequence, Decorator, Service, Task 노드를 조합하여 AI의 행동 흐름을 정의합니다.
    • 커스텀 AI 행동이 필요하면 Blueprint에서는 UBTTask_BlueprintBase, native C++에서는 UBTTaskNode 등을 상속하여 커스텀 작업을 구현합니다.
  • 내비게이션 메시: Nav Mesh Bounds Volume을 레벨에 배치하여 AI가 이동할 수 있는 영역을 생성합니다.
  • AIPerceptionComponent: AI 컨트롤러에 AIPerceptionComponent를 추가하여 시각, 청각 등 AI의 인지 능력을 구현합니다.

멀티플레이어 시스템 구현 (선택 사항)

멀티플레이어 게임을 개발한다면 언리얼 엔진의 강력한 네트워크 기능을 활용해야 합니다.

  • 서버 권한 (Server Authority): 대부분의 중요한 게임 로직(데미지 계산, 아이템 획득, 이동 유효성 검사)은 서버에서 검증하고 처리하여 클라이언트 조작의 영향을 제한합니다. 서버 권한만으로 모든 치트가 방지되지는 않습니다.
  • 리플리케이션 (Replication)
    • UPROPERTY에 Replicated 또는 ReplicatedUsing 키워드를 사용하여 변수를 네트워크 동기화합니다.
    • UFUNCTION에 Server, Client, NetMulticast 키워드를 사용하여 함수 호출을 네트워크 동기화합니다.
    • DOREPLIFETIME 매크로를 사용하여 복제할 변수를 명시합니다.
  • RPC (Remote Procedure Call): 클라이언트가 서버에게, 또는 서버가 클라이언트에게 특정 함수를 실행하도록 요청하는 메커니즘입니다.
  • 표현 상태 동기화: 에셋 파일은 각 클라이언트의 패키지에 준비합니다. 상태·식별자·이벤트를 복제하여 로컬에서 애니메이션과 효과를 재생합니다. 에셋 자체나 모든 애니메이션·입자가 자동으로 네트워크 복제되는 것은 아닙니다.
  • 랙 보상 (Lag Compensation): 지연 시간으로 인한 문제를 완화하기 위해 서버 측에서 클라이언트의 입력 시점을 예측하거나 되돌리는 기법을 적용합니다 (주로 FPS 게임).
  • 매치메이킹 및 세션 관리: Online Subsystem을 사용하여 스팀, 에픽 온라인 서비스, 콘솔 플랫폼의 매치메이킹 및 세션 기능을 통합합니다.

콘텐츠 통합 및 아트 파이프라인

게임 시스템이 구현됨에 따라, 아트/사운드 리소스를 통합하고 관리하는 파이프라인도 중요합니다.

  • 에셋 임포트: 모델(FBX), 텍스처(TGA, PNG), 사운드(WAV, OGG), 애니메이션 등을 언리얼 엔진으로 임포트합니다.
  • 머티리얼 설정: 임포트된 텍스처를 사용하여 PBR(물리 기반 렌더링) 원칙에 따라 머티리얼을 생성하고 오브젝트에 적용합니다.
  • 레벨 디자인: 구현된 시스템과 에셋을 사용하여 레벨을 구성하고 라이팅을 설정합니다.
  • 최적화: 9장에서 다룬 성능 최적화 기법(LOD, 텍스처 압축, 나나이트, 루멘 등)을 콘텐츠에 적용합니다.

디버깅 및 테스트

구현 단계에서 지속적인 디버깅과 테스트는 필수적입니다.

  • 인게임 콘솔: stat fps, stat unit, show collision 등 디버그 명령어를 적극 활용합니다.
  • Visual Studio / Xcode 디버거: C++ 코드의 브레이크포인트, 변수 검사 등을 통해 문제를 해결합니다.
  • 블루프린트 디버거: 블루프린트 로직의 실행 흐름과 변수 값을 추적합니다.
  • 로그 시스템: UE_LOG와 Print String을 사용하여 중요한 정보나 오류 메시지를 출력하고 로그 파일을 검토합니다.
  • 유닛 테스트 / 통합 테스트: 특정 기능이나 시스템이 올바르게 작동하는지 자동화된 테스트를 작성합니다.

게임 시스템 구현은 기획과 설계를 현실로 만드는 과정입니다.

언리얼 엔진의 프레임워크 클래스, 캐릭터 컨트롤, 컴뱃, 인벤토리, UI, AI, 멀티플레이어 시스템 구현 방법을 이해하고 활용하는 것이 중요합니다.

또한, 아트 리소스 통합 및 최적화, 그리고 개발 단계 전반에 걸친 지속적인 디버깅과 테스트는 게임의 품질을 확보하는 데 필수적입니다.

게임 시스템 구현에서는 프레임워크 클래스, 컨트롤, 컴뱃, 인벤토리, UI, AI, 멀티플레이어 역할을 나누어 구현하고 반복 테스트해야 합니다.

상태·동작기준 위치네트워크에서 확인할 점
승패·스폰 규칙서버 GameMode클라이언트가 직접 확정하지 않음
공유 진행 상태GameState·PlayerState필요한 복제·권한을 명시
입력·로컬 UI소유 PlayerController다른 플레이어 Controller와 혼동하지 않음
월드 행동Pawn·Character·Component이동·공격·표현 상태의 책임 분리
아이템 정의DataAsset·DataTable정의 에셋과 변하는 인벤토리 상태 구분

기능 하나를 입력→권한 확인→상태 변경→화면 피드백까지 연결한 뒤 범위를 넓힙니다. 복제 속성 등록 외에도 Actor/Component 복제 활성화, RPC 소유권·호출 방향·relevancy를 확인합니다.