게임 시스템 구현
게임 프레임워크를 바탕으로 캐릭터·전투·인벤토리·UI·AI 시스템을 단계별로 통합합니다.
프로젝트 기획과 설계에서 탄약 하나가 0/30에서 픽업 하나로 10/30이 되고, 저장한 값이 새 실행에서 돌아오는 작은 목표를 정했습니다. 목표가 작아 보여도 실제로 이으려면 캐릭터, 픽업, 컨트롤러, HUD, 게임 모드, SaveGame이 모두 맞물려야 합니다. 어느 한 곳의 초기화 순서가 어긋나면 화면은 0/30인데 캐릭터는 10을 가지고 있거나, 저장 파일은 있는데 새 실행에서 적용되지 않는 일이 생깁니다.
이 글에서는 앞 장들에서 만든 코드를 하나의 프로젝트에 합쳐 이 작은 흐름을 구현합니다. 먼저 시작 상태와 재사용할 코드를 정리하고, 탄약 습득을 HUD까지 연결한 뒤, 저장과 새 실행의 복원을 더합니다. 그다음 이 작은 흐름을 넘어 프로젝트를 넓힐 때 참고할 게임 프레임워크, 전투, 인벤토리, UI, AI, 멀티플레이어 구현의 선택지를 정리합니다.
작은 탄약 프로젝트의 시작 상태
이 글은 탄약 습득과 HUD에 이어 저장과 새 실행의 복원까지 잇는 C++ 코드와 에셋 설정 순서를 제공합니다. 준비된 프로젝트에 코드를 합치고 에셋을 설정해야 하며, 완성된 프로젝트 파일을 배포하는 예제는 아닙니다.
이미 플레이어가 카메라와 이동 입력으로 조작할 수 있는 C++ 프로젝트를 다시 씁니다. 현재 레벨의 게임 모드가 실제 플레이어 폰이나 캐릭터, 플레이어 컨트롤러를 써야 합니다. 데이터 바인딩과 HUD 시스템의 AMyCharacter, AMyPlayerController, UMyPlayerHUD는 교재의 예시 이름이므로, 프로젝트에서 이름이 다르면 include, 캐스팅, 블루프린트의 부모, 클래스 지정도 실제 타입으로 일관되게 맞춥니다. HUD 예제의 AMyCharacter를 만들어 지정하는 것만으로 이동과 카메라 입력이 생기지는 않습니다.
플레이어 컨트롤러의 PlayerHUDClass에는 UMyPlayerHUD를 상속한 실제 WBP_PlayerHUD를 지정합니다. UMG 의존성과 HealthProgressBar, AmmoCountText의 이름, 타입, Is Variable 설정을 맞추고, C++에서 직접 갱신하므로 Percent와 Text의 Bind는 비워 둡니다. 화면에 붙인 뒤와 폰을 빙의한 뒤의 현재 값 조회, 캐릭터 초기화와 값 변경의 알림은 HUD 글의 연결을 그대로 둡니다.
작은 레벨의 이름은 L_AmmoLoop, 픽업 블루프린트의 이름은 BP_AmmoPickup을 제안합니다. 움직일 수 있는 공간과 픽업 하나를 두되, 플레이어는 감지 영역 밖에서 시작하게 합니다. 에셋 파일 자체는 제공하지 않으며, 아래 코드에 맞춰 저장 입력과 새 실행의 처음 읽기를 연결합니다. 고급 메시, 머티리얼, 애니메이션은 숫자를 주고받는 이 흐름에 꼭 필요하지 않습니다.
재사용할 코드와 더할 연결
각 글의 발췌는 같은 프로젝트의 실제 클래스에 합칩니다. 특히 저장과 로드에 쓰는 AMyGameModeBase의 헤더 조각은 한 클래스에 합쳐야 하며, 같은 이름의 클래스를 두 번 만들지 않습니다.
| 맡는 일 | 재사용할 글과 파일 | 지금 제공하는 부분과 더할 연결 |
|---|---|---|
| 픽업 감지 | 트리거 박스와 오버랩 이벤트의 감지 구조를 참고한 새 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 | 표시와 현재 값 조회 경로를 다시 쓰고, 실제 위젯과 컨트롤러 클래스의 지정을 맞춥니다. |
| 저장 데이터 | SaveGame 시스템의 MySaveGame.h/.cpp, UMySaveGame 설계를 참고 | 기존 예제와 구별되는 새 MyAmmoSaveGame.h/.cpp의 UMyAmmoSaveGame과 SavedAmmo를 제공합니다. 원래 클래스의 다른 저장 속성은 그대로 둡니다. |
| 저장과 로드 요청 | 같은 글의 MyGameModeBase.h/.cpp, AMyGameModeBase::SaveMyGame/LoadMyGame | 기존 함수는 두고, 새 SaveAmmoLoop(bool 반환), LoadAmmoLoopOnce, 보류 값 적용 함수로 현재 값 수집과 복원을 연결합니다. 새 AmmoSaveGameClass는 기존 SaveGameClass와 다른 속성입니다. |
| 레벨과 진입 설정 | L_AmmoLoop, BP_AmmoPickup, 실제 게임 모드, 캐릭터, 컨트롤러, HUD 에셋 | 아래 순서로 픽업, 레벨, 클래스 지정을 준비합니다. 카메라와 이동 입력은 기존 설정을 다시 쓰고, bool 입력 액션 IA_SaveAmmo를 기존 활성 매핑 컨텍스트에 더합니다. |
아래의 AddAmmo가 증가량 검사와 HUD 알림을 맡고, TryApplyLoadedAmmo는 저장된 총 탄약을 검증해 적용합니다. 게임 모드나 픽업이 보호된 CurrentAmmo를 직접 바꾸지 않으며, 처음 읽기를 아직 판정하지 않았거나, 보류 중이거나, 오류가 난 동안에는 저장과 지급을 막습니다. 앞의 SaveGame 글에 나온 체력 75.5와 아이템은 예시 데이터이고, 그 로드는 위치와 회전만 실제 폰에 적용했습니다. 그 코드를 그대로 가져오면 탄약도 저장되고 복원된다고 오해하기 쉬우니 주의합니다.
단계별 예상 결과
| 단계 | 시작 조건과 더할 연결 | 연결한 뒤의 예상 결과 | 다를 때 먼저 볼 곳 |
|---|---|---|---|
| 새 슬롯의 시작 화면 | 처음 탄약 0, 최대 30, 읽기 판정, 현재 캐릭터와 HUD 준비 | 픽업 전 Ammo: 0/30 | 생성자와 BeginPlay의 초기값, 처음 판정, 실제 폰 타입, HUD 현재 값 조회 |
| 탄약 습득 | 픽업 감지, 캐릭터 값 변경, HUD 알림 | 한 번 들어가면 0→10, HUD 10/30 | 오버랩 대상과 캡슐, 한 번만 처리하는 조건, getter 값과 표시 전달 |
| 현재 값 저장 | 현재 getter 값을 SavedAmmo에 복사해 같은 슬롯에 저장 | 저장 결과가 성공이고 저장된 탄약은 10 | 값 복사와 SaveGameToSlot의 결과를 구별하며, 성공 로그가 데이터 복사를 대신하지 않음 |
| 새 실행의 복원 | 슬롯 읽기, 타입과 값 검사, 초기화가 끝난 캐릭터에 적용, HUD | 저장값 10을 적용해 10/30, 슬롯이 없으면 새 게임 0/30 | 같은 슬롯과 사용자 값, 로드와 캐스팅, 값 검사, 실제 적용과 초기화 순서 |
픽업 하나의 지급량은 10이고, 탄약이 실제로 늘었을 때만 픽업을 한 번 소모합니다. 아래 코드는 캡슐 필터와 소모 표시, 충돌 해제를 연결하며, 탄약이 가득 차 늘지 않으면 픽업을 남깁니다. 같은 지급을 블루프린트 콜백에서 또 실행하지 않습니다. 표의 결과는 아래 코드와 실제 에셋 설정을 연결한 뒤의 예상입니다.
저장과 로드는 슬롯 AmmoLoopSlot과 사용자 인덱스 0을 함께 씁니다. 프로젝트의 다른 저장 슬롯과 겹치지 않는 이름을 고르고, 저장의 사용자 인덱스를 플레이어 컨트롤러 인덱스와 혼동하지 않습니다. 이름을 적는 것만으로 입력이 생기지는 않습니다. 아래 SaveAmmoLoop는 현재 캐릭터가 없거나 처음 읽기가 끝나지 않았다면 파일에 쓰기 전에 false로 멈춥니다.
복원할 값은 캐릭터 BeginPlay의 기본값 초기화가 끝난 뒤에 적용합니다. 로드할 때 폰이 없다면, 아래의 공통 적용 함수가 준비 경로에서 다시 불릴 때까지 값을 보류합니다. 데이터 직렬화와 역직렬화에서 본 것처럼 픽업, HUD, 폰의 포인터가 다음 실행의 객체와 상태를 저절로 되살리지는 않습니다. 이 프로젝트는 탄약 숫자만 저장하고 픽업의 소모 여부, 플레이어 위치, 월드 전체는 저장하지 않으므로, 새 실행에서 레벨의 픽업이 다시 나타날 수 있습니다. 전체 재빙의나 멀티플레이 복제도 이 작은 목표 밖입니다.
탄약 습득을 HUD까지 연결하기
이 단계의 결과는 Ammo: 0/30에서 픽업 하나를 지나 Ammo: 10/30으로 바뀌는 것입니다. 먼저 데이터 바인딩과 HUD 시스템의 스탯, getter, 위젯, 컨트롤러 연결을 같은 프로젝트에 합칩니다. 캐릭터의 이동, 카메라 컴포넌트와 입력 코드, 컨트롤러의 HUD 생성과 현재 값 조회는 그대로 둡니다. 아래의 캐릭터 코드는 이 글의 저장 게임 모드 연결까지 합친 최종 형태이므로, 기존 클래스에 멤버와 초기화 부분을 합치고 모든 선언과 정의를 갖춘 뒤 프로젝트의 기존 빌드 절차로 확인합니다.
캐릭터의 초기값과 탄약 증가 함수
MyCharacter.h의 기존 AMyCharacter 클래스 안에 다음 멤버를 더합니다. 이미 있는 클래스 선언이나 getter를 다시 만들지 않습니다. 저장 입력을 위해 class UInputAction; 전방 선언이 없으면 클래스 앞에 더하고, 기존 include와 마지막 .generated.h include의 위치는 그대로 둡니다.
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;bAmmoInitialized는 탄약 초기화가 끝났는지를 나타내는 값입니다. 객체가 유효한지만 확인하는 IsValid와는 다른 정보이며, 저장과 지급이 이 값을 보고 판단합니다.
최대 탄약은 30으로 둡니다. 기존 생성자의 CurrentAmmo = MaxAmmo; 한 줄을 다음으로 바꾸고, 다른 생성자 코드와 체력 초기값, 카메라와 이동 컴포넌트는 그대로 둡니다.
CurrentAmmo = 0;기존 BeginPlay에서도 처음 탄약을 0으로 정해야 합니다. 생성자만 바꾸면 HUD 예제의 BeginPlay가 다시 최댓값으로 채우기 때문입니다. 아래 초기화와 알림을 기존 함수에 합치고, 기존의 입력 매핑 컨텍스트 등록과 다른 준비 코드도 그대로 둡니다. Super::BeginPlay()는 기존 호출을 씁니다.
#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;
}BeginPlay는 체력과 탄약을 초기화하고 bAmmoInitialized를 참으로 바꾼 뒤, 게임 모드의 TryApplyPendingAmmo를 불러 보류된 저장값이 있으면 적용하게 하고, 그다음 두 번의 초기 알림을 보냅니다. 체력 초기화와 두 알림은 그대로 두며, HUD가 나중에 준비되면 기존 SyncHUDFromPawn이 그때의 getter 값을 읽습니다.
AddAmmo는 요청한 양이 아니라 실제로 더한 양을 돌려줍니다. 초기화되지 않았거나, 요청량이 0 이하거나, 현재 값이 범위를 벗어났으면 0을 돌려주고, 게임 모드의 세션이 준비되지 않았거나 이 캐릭터가 로컬 플레이어 0의 현재 폰이 아니어도 0을 돌려줍니다. 남은 용량을 먼저 계산해 Min으로 더할 양을 정하므로, 큰 요청량을 현재 탄약에 더하다 정수 범위를 넘기는 일도 피합니다. 예를 들어 25/30에 10을 요청하면 실제 증가량은 5이고 30/30이 되며, 이미 30/30이면 0을 돌려줍니다. 처음 읽기가 판정 전이거나, 보류 중이거나, 오류 상태여도 0을 돌려주어 픽업이 남습니다. 아래의 게임 모드가 판정을 마치고 현재 폰이 준비되어야 탄약이 늘어날 수 있습니다.
픽업은 현재 플레이어의 캡슐만 처리한다
기존 프로젝트 모듈에 MyAmmoPickup.h/.cpp를 더합니다. 아래는 새 AActor 파생 클래스의 선언과 정의입니다. MYPROJECT_API는 실제 게임 모듈의 API 매크로로 바꾸고, 캐릭터와 컨트롤러의 include와 타입도 앞에서 맞춘 실제 클래스 이름과 일치시킵니다. .generated.h는 헤더의 마지막 include로 둡니다.
#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);
};#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();
}생성자는 UBoxComponent를 루트로 만들고, 쿼리에만 참여하며 폰 채널에 Overlap으로 응답하도록 설정하고, 박스의 반길이를 100cm로 정합니다. 콜백에서는 OtherActor가 실제 캐릭터 타입인지와 OtherComp가 그 캐릭터의 캡슐인지를 함께 확인하므로, 같은 캐릭터의 메시가 겹쳐도 지급하지 않습니다. 현재 컨트롤러가 조종하는 로컬 플레이어인지와 탄약이 초기화되었는지도 검사합니다. Components/CapsuleComponent.h는 캡슐 포인터를 다른 충돌 컴포넌트와 비교할 때 필요한 타입 정의를 제공합니다.
실제 증가량이 양수이면 먼저 bConsumed를 표시하고 충돌을 끈 뒤 액터의 제거를 요청합니다. 제거가 끝나기 전에 새 콜백이 와도 같은 픽업이 다시 지급되지 않게 하기 위해서입니다. 블루프린트의 BeginOverlap에서도 탄약 증가를 실행하면 이 경로와 겹쳐 두 번 지급될 수 있으므로, 지급은 위의 C++ 콜백 하나에서만 합니다.
에셋을 준비하고 레벨에 배치하기
먼저 기존 프로젝트의 C++ 빌드 절차로 클래스를 반영한 뒤, 지금 조작하는 캐릭터에 위의 스탯, getter, 함수가 있고 실제 플레이어 컨트롤러가 HUD 글의 연결을 쓰는지 맞춥니다. 현재 레벨의 게임 모드 재정의(GameMode Override)와, 실제 게임 모드의 Default Pawn Class, Player Controller Class도 확인합니다. 기존 카메라와 이동 입력이 동작하는지 먼저 확인해야 하며, HUD 예제의 기본 캐릭터로 단순히 바꾸면 조작 코드가 빠질 수 있습니다.
다음으로 실제 WBP_PlayerHUD의 부모를 UMyPlayerHUD로, 자식의 이름과 타입을 HealthProgressBar와 AmmoCountText로 맞춥니다. Is Variable, UMG 의존성, Percent와 Text의 Bind가 비어 있는지 확인하고, 실제 컨트롤러의 PlayerHUDClass에 이 위젯 클래스를 지정합니다. 이것은 게임 모드에서 AHUD용으로 정하는 HUD Class와 다른 설정입니다.
그다음 AMyAmmoPickup을 부모로 블루프린트 BP_AmmoPickup을 만들고 Ammo Amount를 10으로 둡니다. 박스의 각 축 반길이 100cm는 스케일 1에서 전체 크기 200cm입니다. 박스 자체는 게임에서 보이지 않으므로, 필요하면 단순한 스태틱 메시를 표식으로 붙이고 그 충돌 프리셋을 NoCollision으로 둡니다. 감지는 박스 하나가 맡으며, 시작 콘텐츠나 고급 메시가 꼭 필요한 것은 아닙니다.
충돌 설정도 확인합니다. 박스와 캐릭터 캡슐 양쪽의 Generate Overlap Events를 켜고, 박스가 QueryOnly이며 폰에 Overlap으로 응답하는지, 상대 캡슐이 쿼리에 참여하고 채널 응답이 맞는지 봅니다. 프로젝트에서 프리셋을 바꿨거나 한쪽 응답을 Ignore로 두었다면, 이름이 같아도 기대한 이벤트가 오지 않을 수 있습니다.
마지막으로 조작할 수 있는 작은 레벨을 L_AmmoLoop로 준비해 바닥, 시작점, 픽업 하나를 둡니다. 캐릭터의 최대 탄약을 30으로 맞추고, 플레이어는 박스 밖에서 시작하게 합니다. 실제 게임의 기본 맵과 레벨의 게임 모드도 이 대상으로 지정합니다. 저장 입력은 뒤의 저장 단계에서 실제 입력 액션과 기존 바인딩에 합칩니다.
이 예제는 BeginPlay에서 구독한 뒤 새로 들어오는 캡슐만 처리하며, 처음부터 박스 안에 있는 플레이어를 찾는 초기 겹침 조회는 제공하지 않습니다. 트리거 박스와 오버랩 이벤트의 콜백과 점유 상태 설명처럼, 시작점은 박스 밖에 둡니다.
값과 표시가 다를 때 확인할 곳
| 조건이나 행동 | 연결한 뒤의 예상 결과 | 먼저 확인할 곳 |
|---|---|---|
| 새 슬롯 판정과 첫 준비가 끝난 뒤, 픽업 전 | 캐릭터 0/30, HUD Ammo: 0/30 | 생성자와 BeginPlay의 두 초기값, 처음 읽기와 현재 폰, HUD 현재 값 조회 |
| 새 슬롯에서 현재 플레이어 캡슐이 들어감 | 증가량 10, 캐릭터와 HUD 10/30, 픽업 소모 | 양쪽의 쿼리와 오버랩, 캡슐 필터, 처음 읽기와 초기화, getter와 알림 |
| 다른 액터나 캐릭터 메시 | 늘지 않고 픽업이 남음 | OtherActor 캐스팅과 OtherComp, 현재 조종 필터 |
| 지급 성공 뒤의 중복 콜백 | 같은 픽업의 추가 지급 없음 | bConsumed, 충돌 해제, 블루프린트의 중복 지급 |
현재 25/30에 요청 10 | 실제 5를 더해 30/30, 픽업 소모 | 남은 용량 계산과 실제 증가량 반환 |
현재 30/30에 요청 10 | 증가량 0, 픽업이 남음 | 증가량 0일 때 소모하지 않는 분기 |
| HUD가 지급 뒤에 준비됨 | 현재 값 10을 읽어 10/30 표시 | HUD를 붙인 뒤의 SyncHUDFromPawn, 캐릭터 getter |
25/30과 30/30 행은 함수의 동작을 설명하는 예상입니다. 그 상태를 만들 발사 입력이나 실제 발사체는 이 작은 목표에 더하지 않습니다. 가득 찬 채로 박스 안에 머무르다가 탄약이 줄어도 자동으로 다시 시도하지 않으며, 나갔다가 다시 들어오면 새 BeginOverlap에서 지급을 시도할 수 있습니다.
HUD가 0/0에 머문다면 그 임시 표시를 실제 탄약으로 읽지 말고, 위젯의 부모와 자식 이름, PlayerHUDClass, 현재 폰, 화면에 붙인 뒤와 빙의한 뒤의 조회를 확인합니다. getter는 10인데 화면이 0이라면 데이터를 다시 지급하지 말고, 컨트롤러의 OnAmmoChanged와 HUD 갱신, 스냅샷 연결부터 비교합니다.
캐릭터의 처음 값은 0이며, 아래 저장 단계에서는 유효한 파일 값이 있으면 초기화한 뒤 그 총 탄약을 적용합니다. 그래서 새 슬롯은 0으로, 유효한 저장값 10이 있는 새 실행은 준비된 뒤 10으로 시작합니다. 이 예제는 단일 로컬 독립 실행에서 처음 시작하는 캐릭터 하나를 다루며, 초기 점유 조회, 전체 빙의 해제와 리스폰, 네트워크 복제는 구현하지 않았습니다. 준비 순서, 충돌, 실제 에셋과 입력, C++ 빌드는 쓰는 프로젝트에서 확인해야 합니다.
저장한 탄약을 새 실행에서 복원하기
픽업이 바꾼 현재 탄약을 저장 객체에 복사하고, 다음 실행에서는 읽은 값을 준비된 캐릭터에 돌려놓습니다. SaveGame 시스템의 슬롯 입출력과 데이터 직렬화와 역직렬화의 객체 참조 설명을 떠올리면, 저장 파일을 읽는 것, 캐릭터에 값을 적용하는 것, HUD에 표시하는 것이 각각 다른 단계라는 점이 중요합니다.
이 코드는 단일 로컬 독립 실행에서 컨트롤러 인덱스 0의 현재 캐릭터 하나를 처리합니다. 저장 슬롯은 AmmoLoopSlot, 저장 사용자 값은 0입니다. 두 값은 쓸 때와 읽을 때 같아야 하며, 저장 사용자 값과 컨트롤러를 찾는 인덱스는 다른 개념입니다. 프로젝트의 다른 저장 슬롯은 쓰거나 지우지 않습니다.
탄약만 담는 새 SaveGame 클래스
기존 UMySaveGame에는 이름, 체력, 위치, 아이템 같은 속성이 있습니다. 여기에 탄약 필드만 더하면 다른 Transient가 아닌 속성도 함께 저장되므로, 진행 데이터가 탄약 하나뿐인 새 UMyAmmoSaveGame을 USaveGame에서 바로 만듭니다. 저장 파일에는 클래스와 엔진의 메타데이터도 들어가므로 int32 4바이트만 기록된다는 뜻은 아닙니다. 기본 SaveGameToSlot은 SaveGame 지정자로 다른 필드를 골라 빼지 않는다는 점도 Epic의 SaveGameToSlot API 문서에서 확인할 수 있습니다.
#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;
};#include "MyAmmoSaveGame.h"
UMyAmmoSaveGame::UMyAmmoSaveGame()
{
SavedAmmo = 0;
}기존 게임 모듈에 USaveGame 파생 클래스 MyAmmoSaveGame을 만들고 위 헤더와 소스를 반영합니다. 생성 도구가 만든 생성자가 있다면 위 선언, 정의와 맞추고 중복하지 않으며, MYPROJECT_API는 실제 모듈의 API 매크로로 바꿉니다. 별도의 SaveGame 블루프린트를 만드는 대신, 아래 게임 모드에서 이 네이티브 클래스를 기본값으로 지정합니다. 기존 MySaveGame.h/.cpp와 그 예제는 그대로 둡니다.
게임 모드에서 처음 읽기와 저장 관리하기
실제 MyGameModeBase.h에서 .generated.h를 마지막 include로 두고, 클래스 선언 앞에 class UMyAmmoSaveGame;와 class AMyCharacter;를 전방 선언합니다. 아래 멤버를 기존 게임 모드 클래스 안에 합치며, 이미 BeginPlay 선언이 있다면 그것을 씁니다. 앞 글의 SaveGameClass, SaveMyGame, LoadMyGame과는 다른 새 이름들입니다.
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 객체나 이전 폰의 포인터를 보류 데이터로 저장하지 않습니다.
다음 include와 멤버 정의도 실제 .cpp에 합칩니다. 생성자와 BeginPlay가 이미 있다면 해당 줄을 합치고 같은 함수를 두 번 정의하지 않으며, 기존 게임 모드의 폰과 컨트롤러 클래스 설정, 다른 초기화는 그대로 둡니다.
#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);
}FindCurrentAmmoCharacter는 독립 실행일 때만, 로컬 플레이어 0의 컨트롤러가 조종하고 탄약이 초기화된 캐릭터를 돌려줍니다. IsAmmoSessionReady는 처음 읽기를 했고, 오류가 없고, 보류 값이 없으며, 그 캐릭터의 최대 탄약이 30이고 현재 값이 범위 안일 때만 참입니다.
LoadAmmoLoopOnce는 한 실행에서 한 번만 실행됩니다. DoesSaveGameExist만으로는 읽기, 타입, 값이 유효한지 알 수 없으므로, 슬롯이 있으면 읽은 객체가 null인지, 탄약 전용 타입인지, 값이 0에서 30 사이인지를 각각 검사합니다. 범위를 벗어난 값을 잘라 정상 진행도로 바꾸지 않고 오류로 남깁니다. 파일이 없으면 새 게임으로 판정하고 자동으로 저장하지 않으며, 유효한 저장값이 있으면 보류에 넣고 TryApplyPendingAmmo를 부릅니다. 보류 값은 실제로 적용에 성공한 뒤에만 비웁니다.
SaveAmmoLoop는 처음 판정, 보류, 오류 상태와 현재 폰의 준비, 최대 30, 현재 값을 먼저 확인합니다. 폰이 없다고 기본값 0을 저장하지 않습니다. 저장 객체를 만드는 데 실패하면 쓰기 API까지 가지 않고, 쓰기를 불렀다면 그 실제 bool 결과를 돌려줍니다. 쓰기 API가 false를 돌려준 뒤 이전 파일의 내용이 반드시 보존된다는 보장은 없습니다. 이 예제는 동기 저장이며, 원자적 쓰기(atomic write), 백업, 롤백, 버전 변환은 구현하지 않았습니다. 원자적 쓰기는 쓰기가 도중에 실패해도 파일에 반쯤 쓴 내용이 남지 않고 이전 내용이나 새 내용 가운데 하나가 온전히 남게 하는 쓰기로, 보통 새 내용을 임시 파일에 다 쓴 뒤 이름을 바꾸어 원래 파일과 한 번에 교체하는 방식으로 만듭니다. 저장과 로드의 흐름은 Epic의 Saving and Loading Your Game 문서에서도 확인할 수 있습니다.
복원한 값 적용과 HUD 현재 값 조회
앞의 캐릭터 헤더에 선언한 TryApplyLoadedAmmo는 증가 함수와 다릅니다. 저장된 총 탄약을 검증해 그대로 넣고 HUD에 알립니다. 보류 중에는 저장과 습득을 막는 IsAmmoSessionReady가 거짓이므로, 이 적용 함수까지 그 조건을 요구하면 보류 상태를 끝낼 수 없게 됩니다. 그래서 이 함수는 초기화, 최대 30, 값의 범위, 현재 로컬 컨트롤러만 확인합니다.
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이면 소모하지 않는 픽업의 동작과 혼동하지 않습니다. 게임 모드는 이 함수가 true를 돌려줄 때만 보류를 해제합니다. 캐릭터 초기화 직후의 호출은 앞의 BeginPlay에 이미 합쳤고, 빙의 경로는 기존 컨트롤러 함수에 다음처럼 합칩니다. 실제 MyPlayerController.cpp에는 MyGameModeBase.h와 Kismet/GameplayStatics.h include도 더합니다.
void AMyPlayerController::OnPossess(APawn* InPawn)
{
Super::OnPossess(InPawn);
AMyGameModeBase* Mode = Cast<AMyGameModeBase>(
UGameplayStatics::GetGameMode(this));
if (IsValid(Mode)) Mode->TryApplyPendingAmmo();
SyncHUDFromPawn();
}같은 TryApplyPendingAmmo를 세 곳에서 부르는 셈입니다. 로드 직후, 캐릭터가 초기값 0과 준비 상태를 정한 뒤 두 번의 초기 알림을 보내기 전, 그리고 Super::OnPossess 다음 현재 값을 조회하기 전입니다. 로드가 늦으면 첫 번째가, 캐릭터 초기화가 늦으면 두 번째가, 빙의가 늦으면 세 번째가 적용을 맡습니다. 서로의 BeginPlay 순서를 고정하지 않으며, Tick이나 임의의 지연 타이머도 쓰지 않습니다.
HUD가 아직 없다고 데이터 적용을 막지는 않습니다. 적용 알림이 먼저 왔더라도, 기존 컨트롤러가 HUD를 붙인 뒤 SyncHUDFromPawn에서 현재 값 10을 읽습니다. HUD를 다시 붙이는 기능을 더한다면 그 직후에도 같은 조회가 필요하며, 이것은 파일을 다시 읽거나 탄약을 지급하는 동작이 아닙니다.
저장 입력을 기존 바인딩에 더하기
입력 처리(Input Mapping)의 향상된 입력 설정이 이미 있는 프로젝트를 씁니다. 게임 모듈의 EnhancedInput 의존성, 입력 컴포넌트, 활성 매핑 컨텍스트를 확인하고, 기존 이동과 카메라 바인딩, 컨텍스트 등록은 그대로 둡니다. 앞의 헤더에 더한 SaveAmmoAction에 연결할 IA_SaveAmmo를 bool 타입으로 만들고, 이미 등록해 쓰는 매핑 컨텍스트에 F5 키를 한 번 매핑합니다. 이 액션과 매핑에는 Hold나 Double Tap 같은 별도의 트리거를 더하지 않습니다.
실제 캐릭터 블루프린트의 클래스 기본값에서 Save Ammo Action에 IA_SaveAmmo를 지정합니다. 아래 코드는 기존 SetupPlayerInputComponent에서 UEnhancedInputComponent* EnhancedInputComponent 캐스팅이 성공한 블록 안에 덧붙이며, Super 호출과 Move, Look, Jump 바인딩은 그대로 둡니다. 같은 저장을 블루프린트 이벤트에도 연결하지 않습니다.
if (IsValid(SaveAmmoAction))
{
EnhancedInputComponent->BindAction(SaveAmmoAction,
ETriggerEvent::Started, this, &AMyCharacter::RequestSaveAmmo);
}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"));
}RequestSaveAmmo는 이 캐릭터가 로컬 플레이어 0의 현재 폰인지 확인한 뒤, 현재 게임 모드의 SaveAmmoLoop를 불러 결과를 로그로 남깁니다. 이 단순한 키 정책은 Started에서 요청하므로 누르고 있는 동안 매 프레임 저장하지 않습니다. Started는 트리거 평가가 시작된 시점일 뿐 임의 트리거의 성공을 뜻하지 않으므로, 다른 트리거를 더하면 입력 정책도 다시 정해야 합니다. 트리거 이벤트의 뜻은 Epic의 Enhanced Input 문서에서 확인할 수 있습니다.
콜백이 불리지 않는다면 실제 폰, SaveAmmoAction 지정, 활성 매핑 컨텍스트, 컴포넌트 캐스팅, 키와 입력 모드를 먼저 확인합니다. 앞 글의 BeginPlay에서 컨트롤러나 로컬 플레이어가 없으면 컨텍스트 등록을 놓칠 수 있으며, 재빙의 때의 등록과 해제는 이 예제에도 더하지 않았습니다.
두 번째 단계의 준비와 예상
실제로 쓰이는 게임 모드가 위 코드의 클래스인지, 그 블루프린트의 Ammo Save Game Class가 UMyAmmoSaveGame 네이티브 클래스로 정해졌는지 확인합니다. 기존 Save Game Class와는 다른 속성입니다. L_AmmoLoop의 게임 모드 재정의, 폰과 컨트롤러, WBP_PlayerHUD 지정, 양쪽 캡슐과 박스의 오버랩, 시작점이 박스 밖인지는 앞 단계의 설정을 유지합니다. 모든 선언과 정의를 같은 실제 클래스에 합친 뒤 프로젝트의 기존 빌드 절차로 확인하고 에셋을 설정합니다. 별도의 프로젝트 파일과 에셋 파일은 이 글에 포함되어 있지 않습니다.
처음 읽기의 상태에 따라 데이터 처리와 저장, 습득의 허용이 다음처럼 달라집니다.
| 처음 읽기의 상태 | 데이터 처리 | 저장과 습득 |
|---|---|---|
| 아직 판정 전 | 초기값 0은 임시 값 | 막음 |
| 슬롯 없음 | 준비된 캐릭터의 탄약 0, 파일을 자동으로 만들지 않음 | 준비된 뒤 허용 |
| 유효한 값을 읽었지만 폰이 준비되지 않음 | PendingAmmo에 숫자 보관 | 적용 전까지 막음 |
| 현재 준비된 폰에 적용 성공 | 총 탄약을 넣고 HUD에 알리고 보류 해제 | 허용 |
| 기존 슬롯의 읽기, 타입, 범위나 적용 설정 오류 | 실패 상태, 초기값 0을 자동 저장하지도 파일을 지우지도 않음 | 막음 |
준비가 끝나지 않은 동안 오버랩이 오면 AddAmmo가 0을 돌려주므로 픽업이 남습니다. 준비가 끝났다고 박스 안에 머무른 플레이어에게 자동으로 다시 지급하지는 않으며, 나갔다가 다시 들어와야 새 콜백에서 시도합니다. 오류가 난 뒤의 자동 재시도, 파일 삭제, 손상 복구 버튼은 구현하지 않았습니다.
새 슬롯에서 0/30 → 픽업 → 10/30 → F5 저장을 이은 뒤, 같은 슬롯과 사용자 값으로 새 실행을 시작하면 총 탄약 10이 캐릭터와 HUD에 적용되는 것이 목표입니다. 화면이 0이라고 저장 키부터 눌러 덮어쓰기보다, 다음 글 디버깅과 테스트의 예상과 실패 표에서 처음 읽기, 보류, 현재 getter, HUD 전달을 나눠 확인합니다. 이 숫자들은 연결한 뒤의 예상이며 실제로 실행해 얻은 로그가 아닙니다.
새 실행에서는 레벨의 픽업이 다시 나타납니다. 저장하는 것이 탄약뿐이라 다시 주우면 10에서 20이 될 수 있습니다. 위치, 픽업 소모, 월드 전체, 체력, 인벤토리, 포인터, 클라우드 저장과 백업은 저장하지 않습니다. 로드는 한 실행에서 처음 한 번뿐이며, 별도의 로드 키, 리스폰, 전체 재빙의와 빙의 해제, 분할 화면, 네트워크 복제는 범위 밖입니다.
준비 순서가 달라도 같은 값을 적용한다
슬롯의 SavedAmmo가 유효한 10이라고 해 봅시다. 게임 모드가 먼저 읽었지만 캐릭터 초기화나 빙의가 아직 준비되지 않았다면, PendingAmmo에 10을 남기고 저장과 습득을 막습니다. 나중에 캐릭터의 BeginPlay나 컨트롤러의 OnPossess에서 같은 적용 함수를 다시 부르고, 현재 캐릭터의 탄약 초기화, 빙의, 최댓값 조건이 맞으면 총 탄약 10을 적용하고 보류를 해제합니다. 반대로 캐릭터가 먼저 준비되었다면 로드 직후의 호출이 적용을 맡습니다. 초기화 순서를 하나로 정해 두고 타이머로 기다리는 방식이 아닙니다.
그다음 HUD가 늦게 붙어도 현재 getter의 10을 조회하면 됩니다. HUD 표시가 늦다고 파일 읽기나 픽업 지급을 다시 실행하지 않습니다. 또 AddAmmo는 남은 용량만큼 더하고 실제 증가량을 돌려주지만, TryApplyLoadedAmmo는 검증된 총량을 그대로 넣는다는 차이를 기억해 둡니다.
프로젝트를 새로 만든다면
아직 기본 프로젝트가 없다면 다음 생성 옵션을 참고합니다. 이미 조작할 수 있는 프로젝트가 있다면 그것을 다시 쓰고 레벨, 클래스, 입력 설정부터 확인합니다. 새 프로젝트를 만드는 것만으로 위의 탄약 흐름이 완성되지는 않습니다.
Epic Games Launcher에서 쓸 엔진 버전(이 교재는 5.8 기준)을 실행하고, 프로젝트 브라우저의 게임 범주에서 템플릿을 고릅니다. Blank는 모든 것을 직접 구성할 때 알맞고, First Person과 Third Person은 기본 캐릭터 조작과 입력 설정이 되어 있어 프로토타입을 빨리 만들 수 있습니다. 프로젝트 기본 설정에서는 Blueprint와 C++ 가운데 C++를 고르며, C++ 프로젝트에서도 블루프린트를 쓸 수 있습니다. 품질 프리셋(Quality Preset)은 Maximum과 Scalable 가운데 고르는데 Scalable은 여러 하드웨어에 대응하기 쉽고, 대상 플랫폼(Target Platform)은 Desktop이나 Mobile을 고르며, 필요하면 시작 콘텐츠(Starter Content)를 넣습니다. 프로젝트 이름과 저장할 폴더도 정합니다.
프로젝트 전체의 설정은 에디터의 Edit > Project Settings에서 조정합니다. Maps & Modes에서 게임을 시작할 때 불러올 Game Default Map과 기본 게임 모드를 정합니다. 입력은 향상된 입력을 쓰는 프로젝트라면 입력 액션, 입력 매핑 컨텍스트, 로컬 플레이어 서브시스템 등록을 맞추며, Engine - Input의 예전 Action, Axis 매핑 방식과 구분합니다. Engine - Rendering에서는 렌더링 품질과 나나이트, 루멘의 사용 여부를, Engine - Physics에서는 물리 시뮬레이션과 충돌 프로파일을, Platforms에서는 모바일 앱 아이콘이나 Android SDK 경로 같은 플랫폼별 설정을 합니다.
프로젝트를 넓힐 때의 구현 선택지
GDD에 정의한 핵심 게임플레이 루프와 시스템을 언리얼의 구조에 맞춰 구현합니다. 아래는 프로젝트를 넓힐 때 참고할 기술별 선택지이며, 첫 탄약 흐름에 전투, GAS, 인벤토리 전체, AI, 멀티플레이어를 꼭 넣어야 하는 것은 아닙니다.
게임 프레임워크 구성
언리얼은 게임의 기본 뼈대를 이루는 프레임워크 클래스를 제공합니다. AGameModeBase와 AGameMode는 게임의 규칙, 플레이어의 접속과 종료, 점수 계산, 게임의 시작과 끝 조건 같은 전체 흐름을 관리하며, 멀티플레이어에서는 서버에만 존재하고 실행됩니다. 여기서 Default Pawn Class, Player Controller Class, HUD Class, Player State Class, Game State Class를 지정합니다.
APlayerController는 마우스, 키보드, 게임패드의 입력을 받아 APawn이나 ACharacter로 전달해 조종하고, UI 상호작용과 카메라 제어 같은 플레이어 관련 로직을 맡습니다. 서버에는 연결된 각 플레이어의 컨트롤러가 있고, 클라이언트에는 자신이 조종하는 로컬 플레이어의 컨트롤러만 있으며, 분할 화면이라면 로컬 플레이어가 여럿일 수 있습니다.
APawn과 ACharacter는 게임 세계에 존재하는 몸체로, 플레이어가 조종할 수 있는 대상입니다. ACharacter는 캡슐 충돌과 CharacterMovementComponent처럼 사람형 캐릭터에 필요한 기능을 갖춘 폰이며, 이동, 점프, 공격 같은 캐릭터 고유의 행동을 여기에 만듭니다. APlayerState는 점수, 이름, 남은 목숨처럼 플레이어별 상태를, AGameStateBase와 AGameState는 남은 시간, 팀 점수, 게임 단계처럼 게임 전체의 상태를 저장해 네트워크로 동기화하며, 둘 다 서버와 모든 클라이언트에 존재합니다. 보통 각 프레임워크 클래스는 C++로 기본 구현을 하고, 필요하면 블루프린트 자식 클래스로 로직을 넓힙니다.
캐릭터 조작과 이동
ACharacter를 상속해 캐릭터 블루프린트나 C++ 클래스를 만듭니다. 향상된 입력에서는 입력 액션과 매핑 컨텍스트를 만들고, 조종하는 로컬 플레이어에 컨텍스트를 더한 뒤, SetupPlayerInputComponent에서 UEnhancedInputComponent에 액션을 바인딩합니다. 예전 입력 방식이라면 프로젝트 설정의 Action, Axis 매핑과 그에 맞는 바인딩 API를 씁니다. 이동과 시점 회전은 AddMovementInput, AddControllerYawInput, AddControllerPitchInput으로 만들고, 애니메이션은 UAnimInstance를 상속한 애니메이션 블루프린트에서 CharacterMovementComponent의 속도와 점프 상태 같은 값으로 스테이트 머신을 만듭니다.
전투 시스템
공격은 버튼 입력을 받았을 때 애니메이션 몽타주를 재생하고 공격 판정용 충돌 영역을 켜는 식으로 만듭니다. 복잡한 스킬, 버프, 디버프는 게임플레이 능력 시스템(Gameplay Ability System, GAS)의 UAbilitySystemComponent로 만드는 방법이 있습니다. 피해는 ApplyDamage와 TakeDamage 함수로 계산하고 적용하거나, GameplayEffect로 피해 타입과 속성 기반 피해를 정의합니다. 체력, 마나, 공격력 같은 스탯은 UAttributeSet을 상속해 정의하고, GameplayEffect로 그 변화를 적용합니다.
인벤토리와 아이템
아이템의 이름, 설명, 아이콘, 효과 같은 속성은 UDataAsset 파생 에셋이나, FTableRowBase를 상속한 USTRUCT를 행 구조체로 쓰는 UDataTable로 정의합니다. 인벤토리는 TArray나 TMap으로 아이템 목록을 관리하는 UInventoryComponent 같은 컴포넌트를 만들어 추가, 제거, 사용 로직을 구현하고, UMG 위젯으로 인벤토리 화면, 슬롯, 정보 창을 만들어 그 데이터와 연결합니다. 아이템 데이터를 C++에서 불러오고 검증하는 흐름은 파일 입출력 및 JSON 처리의 일반 파일, JSON 처리와 구분해야 하며, UDataAsset과 UDataTable 에셋은 엔진의 에셋 참조와 로드 경로로 불러옵니다.
UI와 사용자 경험
UMG는 위젯 기반의 UI 시스템입니다. HUD, 메인 메뉴, 인벤토리, 설정 창 같은 UI를 위젯 블루프린트로 만들고, Canvas Panel, Horizontal Box, Vertical Box, Grid Panel로 레이아웃을 구성합니다. 위젯에 보일 데이터는 C++나 블루프린트 변수와 연결해 갱신하고, 버튼 클릭이나 슬라이더 변경 같은 입력 이벤트를 게임 로직과 잇습니다. UI의 표시와 숨김, 게임 전용, UI 전용, 게임과 UI 같은 입력 모드 변경은 APlayerController에서 관리합니다.
AI 시스템
AAIController를 상속한 AI 컨트롤러를 만들고, 대상 폰의 AI Controller Class와 Auto Possess AI 조건을 정한 뒤, 빙의한 다음 행동 트리를 실행합니다. 행동 트리 에셋과 블랙보드 에셋을 만들어, 블랙보드에는 목표 위치, 대상 액터, 현재 상태 같은 키를 정의하고, 행동 트리에서는 선택자, 시퀀스, 데코레이터, 서비스, 태스크를 조합해 행동의 흐름을 정합니다. 직접 만든 행동이 필요하면 블루프린트에서는 UBTTask_BlueprintBase를, C++에서는 UBTTaskNode 같은 클래스를 상속합니다. 레벨에는 Nav Mesh Bounds Volume을 두어 AI가 움직일 영역을 만들고, AI 컨트롤러에 AIPerceptionComponent를 더해 시각과 청각 같은 인지를 구현합니다.
멀티플레이어를 더한다면
멀티플레이어 게임이라면 언리얼의 네트워크 기능을 씁니다. 피해 계산, 아이템 획득, 이동 검증 같은 중요한 게임 로직은 서버에서 검증하고 처리해 클라이언트 조작의 영향을 줄입니다. 다만 서버 권한만으로 모든 치트가 막히지는 않습니다.
변수는 UPROPERTY에 Replicated나 ReplicatedUsing을 붙이고 DOREPLIFETIME으로 등록해 동기화하고, 함수 호출은 UFUNCTION에 Server, Client, NetMulticast를 붙여 RPC로 보냅니다. 에셋 파일은 각 클라이언트의 패키지에 들어 있으므로, 상태, 식별자, 이벤트만 복제하고 애니메이션과 효과는 각자 로컬에서 재생합니다. 에셋 자체나 모든 애니메이션, 파티클이 저절로 네트워크로 복제되는 것은 아닙니다.
FPS 게임에서는 지연 때문에 생기는 문제를 줄이려고, 서버가 클라이언트가 입력한 시점으로 다른 플레이어의 위치를 되돌려 판정하는 지연 보정(lag compensation)을 쓰기도 합니다. 매치메이킹과 세션은 온라인 서브시스템(Online Subsystem)으로 Steam, Epic Online Services, 콘솔 플랫폼의 기능과 통합합니다.
콘텐츠와 아트 파이프라인
시스템을 구현하는 것과 함께, 아트와 사운드 자원을 통합하고 관리하는 흐름도 중요합니다. 모델(FBX), 텍스처(TGA, PNG), 사운드(WAV, OGG), 애니메이션을 언리얼로 가져오고, 가져온 텍스처로 물리 기반 렌더링(PBR) 원칙에 맞는 머티리얼을 만들어 물체에 적용합니다. 구현한 시스템과 에셋으로 레벨을 구성하고 조명을 설정하며, 9장에서 다룬 LOD, 텍스처 압축, 나나이트, 루멘 같은 최적화(렌더링 최적화 등)를 콘텐츠에 적용합니다.
디버깅과 테스트
구현하는 동안 계속 디버깅하고 시험해야 합니다. 인게임 콘솔의 stat fps, stat unit, show collision 같은 명령, Visual Studio나 Xcode 디버거의 중단점과 변수 검사, 블루프린트 디버거의 실행 흐름과 변수 추적, UE_LOG와 Print String으로 남긴 로그를 함께 씁니다. 특정 기능이나 시스템이 제대로 동작하는지 확인하는 자동화된 단위 테스트와 통합 테스트도 작성합니다.
기능을 넓힐 때는 상태와 동작마다 기준이 되는 위치를 정해 두면 좋습니다.
| 상태와 동작 | 기준 위치 | 네트워크에서 확인할 점 |
|---|---|---|
| 승패와 스폰 규칙 | 서버의 게임 모드 | 클라이언트가 직접 확정하지 않음 |
| 함께 쓰는 진행 상태 | 게임 스테이트와 플레이어 스테이트 | 필요한 복제와 권한을 분명히 함 |
| 입력과 로컬 UI | 조종하는 플레이어 컨트롤러 | 다른 플레이어의 컨트롤러와 혼동하지 않음 |
| 월드 안의 행동 | 폰, 캐릭터, 컴포넌트 | 이동, 공격, 표현 상태를 나눠 맡김 |
| 아이템 정의 | Data Asset과 Data Table | 정의 에셋과 바뀌는 인벤토리 상태를 구분 |
기능 하나를 입력, 권한 확인, 상태 변경, 화면 피드백까지 이은 뒤에 범위를 넓힙니다. 네트워크에서는 복제 속성 등록 외에도 액터와 컴포넌트의 복제 활성화, RPC의 소유권과 호출 방향, 관련성을 함께 확인합니다.
정리
- 작은 탄약 흐름은 캐릭터가 현재 값을 가지고, 픽업이 증가를 요청하고, 컨트롤러가 HUD에 전달하고, 게임 모드가 처음 읽기와 저장을 관리하는 구조로 만듭니다.
- 캐릭터는 생성자와
BeginPlay에서 탄약을 0으로 초기화하고,AddAmmo는 남은 용량만큼만 더해 실제 증가량을 돌려주며, 준비되지 않은 동안에는 0을 돌려줍니다. - 픽업은 현재 로컬 플레이어의 캡슐만 받아 실제로 늘었을 때 한 번 소모하고, 블루프린트에서 같은 지급을 중복하지 않습니다.
- 탄약만 담는
UMyAmmoSaveGame을 따로 만들고, 게임 모드는 슬롯의 존재, 읽기, 타입, 범위를 각각 검사하며 오류를 새 게임으로 덮어쓰지 않습니다. - 읽은 값은
PendingAmmo에 보류했다가, 로드 직후, 캐릭터 초기화 뒤, 빙의 뒤의 세 경로에서TryApplyPendingAmmo로 적용하므로 준비 순서에 기대지 않습니다. TryApplyLoadedAmmo는 검증한 총량을 넣고,AddAmmo는 증가량을 더한다는 차이가 있으며, HUD는 나중에 붙어도 현재 getter를 읽습니다.- 넓힐 때는 게임 프레임워크, 전투, 인벤토리, UI, AI, 멀티플레이어를 기준 위치에 맞춰 나누고, 기능 하나씩 끝까지 이은 뒤 범위를 넓힙니다.
연습 문제
1. 저장값이 0이라면
슬롯의 저장값이 유효한 0이고, 현재 캐릭터도 0/30입니다. 총량을 적용한 결과와 보류 상태는 어떻게 될까요?
해설 보기
유효한 총량 0을 적용하는 것은 성공이므로 TryApplyLoadedAmmo가 true를 돌려주고, 게임 모드는 보류를 해제할 수 있습니다. 현재 값과 같아서 바뀌는 것이 없더라도 적용은 성공입니다. 증가량이 0이면 소모하지 않는 픽업의 동작과는 다른 규칙이라는 점에 주의합니다.
2. 저장값이 31이라면
저장 파일의 값이 31로 고쳐져 있습니다. 30으로 잘라 정상 파일처럼 적용할까요?
해설 보기
그렇게 하지 않습니다. LoadAmmoLoopOnce의 범위 검사에서 0에서 30을 벗어난 값은 오류 상태가 되며, 30으로 잘라 정상 진행도처럼 적용하지 않습니다. 오류 상태에서는 저장과 지급을 모두 막아, 손상되었을 수 있는 파일을 새 값으로 덮어쓰지도 않습니다.
3. 박스 안에서 준비가 끝나면
준비가 끝나지 않은 동안 박스에 들어갔다가 지급을 거절당했고, 그 안에 머무른 채로 준비가 끝났습니다. 자동으로 10을 받을까요?
해설 보기
받지 않습니다. 준비가 끝난 것은 새 BeginOverlap 이벤트가 아니므로, 박스 안에 그대로 있으면 지급을 다시 시도하지 않습니다. 박스 밖으로 나갔다가 다시 들어와야 새 콜백에서 지급을 시도합니다. 이것은 실제 엔진에서 실행해 확인한 결과가 아니라 코드와 이벤트 조건을 따라 읽은 예상입니다.
다음 글 디버깅과 테스트에서는 이 탄약 흐름이 예상대로 동작하는지 단계별로 확인하고, 다르게 나올 때 원인을 좁히는 방법을 살펴봅니다.