[25.05.13] :: 가디언 앤 시커 프로젝트 04

chooha·2025년 5월 13일

가디언앤시커

목록 보기
4/25

👨‍💻 오늘의 개발 작업

오늘은 아케인 보드(룬) 시스템의 룬 배치 및 검증 로직을 구현할 생각이었음
근데 검증 로직을 구현하다가 몇 가지 설계 변경이 필요하다는 걸 깨달았음
이전에 미처 생각 못한 부분들 수정하고 최적화했음


💡 오늘의 5분 기록

1. 데이터 테이블 로드 방식 변경

처음에는 ArcaneBoardManager 생성자에서 직접 데이터 테이블 로드하려고 했음:

// 이전 접근법 (문제점 발견)
static ConstructorHelpers::FObjectFinder<UDataTable> RuneTableAsset(TEXT("/Game/Data/DT_RuneTable"));
if (RuneTableAsset.Succeeded())
{
    RuneTable = RuneTableAsset.Object;
}

근데 프로젝트가 여러 시스템이 연결되는 구조인데, 매니저가 직접 경로 알고 로드하면 유지보수 어려울거 같았음

그래서 ArcaneBoardManager를 가지고 있는 ArcaneBoardLPS에서 할당해주기로 함
이렇게 바꾸면 경로 관리도 한 곳에서 할 수 있고, 테스트할 때도 더 유연하게 할 수 있을 듯

2. 그리드 셀 접근 최적화 (성능 개선)

처음에는 단순히 배열로 셀 데이터 관리했음
그래서 특정 위치의 셀 찾으려면 매번 반복문 돌려야 했음

// 이전 코드 (비효율적)
bool IsValidCell(const FIntPoint& Pos)
{
    for (const FGridCellData& Cell : GridCells)
    {
        if (Cell.Pos == Pos && Cell.State == EGridCellState::Empty;)
        {
            return true;
        }
    }
    return false;
}

이 함수가 자주 호출되는데 O(n) 복잡도면 비효율적이라 생각해서 TMap 구조로 변경했음

// 최적화된 코드: O(1) 접근
TMap<FIntPoint, FGridCellData> CurrGridState;

bool UGS_ArcaneBoardManager::IsValidCell(const FIntPoint& Pos)
{
    if (CurrGridState.Contains(Pos))
    {
        return CurrGridState[Pos].State == EGridCellState::Empty;
    }
    return false;
}

메모리를 전보다 조금 더 쓰긴 하지만 그리드 자체가 작기 때문에 무시할 수 있을 듯

3. 직렬화 문제 해결

저장 시스템 구조잡다가 언리얼 엔진 직렬화 시스템 제한에 걸려서 빌드 오류 발생했음

error: The type 'TArray<FPlacedRuneInfo>' can not be used as a value in a TMap

TMap 값으로 FPlacedRuneInfo의 Array를 사용하면 직렬화 안 된다는 문제였음
고민하다가 중간 구조체 만들어서 해결함

// 문제 있던 코드
TMap<ECharacterClass, TArray<FPlacedRuneInfo>> SavedRunesByClass;

// 해결책: 중간 구조체 도입
USTRUCT(Atomic, BlueprintType)
struct FRunePlacementData
{
    GENERATED_BODY()
    UPROPERTY()
    TArray<FPlacedRuneInfo> PlacedRunes;
};

// 사용 방식
UPROPERTY()
TMap<ECharacterClass, FRunePlacementData> SavedRunesByClass;

이렇게 바꾸니 빌드도 잘 되고, 언리얼 직렬화 시스템 제대로 작동하게 됨

4. 런타임 상태와 원본 데이터 분리

처음에는 그리드 레이아웃 데이터 에셋 셀 상태 직접 수정하려고 했는데, 이러면 문제 생길 것 같았음
데이터 에셋은 원본 데이터고, 런타임에서는 복사본 수정해야 함

// 잘못된 접근법 (데이터 에셋 직접 수정)
CurrGridLayout->GridCells[index].State = EGridCellState::Occupied;

// 개선된 접근법: 런타임 상태 별도 관리
void InitGridState()
{
    CurrGridState.Empty();
    for (const FGridCellData& Cell : CurrGridLayout->GridCells)
    {
        // 복사본 생성
        CurrGridState.Add(Cell.Pos, Cell);
    }
}

void UpdateCellState(const FIntPoint& Pos, EGridCellState NewState)
{
    // 복사본 수정
    if (CurrGridState.Contains(Pos))
    {
        CurrGridState[Pos].State = NewState;
    }
}

이 방식으로 바꾸니 직업 변경할 때 그리드 상태 깔끔하게 초기화하고 관리할 수 있게 됨

5. 스탯 관리 방식 변경

처음에는 스탯을 단순 TMap<FName, float>으로 관리했는데, 팀원이랑 상의 후에 FCharacterStats 구조체로 변경했음

// 이전 방식
TMap<FName, float> AppliedStatEffects;

// 변경된 방식
USTRUCT(Atomic, BlueprintType)
struct FCharacterStats
{
    GENERATED_BODY()
    
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Stats")
    float MaxHP = 0.0f;
    
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Stats")
    float ATK = 0.0f;
    
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Stats")
    float DEF = 0.0f;
    
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Stats")
    float AGL = 0.0f;
    
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Stats")
    float ATS = 0.0f;
};

// 사용
FCharacterStats AppliedStatEffects;

이렇게 바꾸면 UI에서도 스탯 명확하게 표시할 수 있을 듯

6. 저장 시스템 구현 고민

SaveGame 클래스를 이용해 플레이어의 아케인 보드 시스템을 저장하기 위해서 클래스를 생성하려는데, SaveGame을 상속받은 LocalPlayerSaveGame을 발견함
두 개의 차이는

USaveGame

  • 기본 세이브 게임 클래스
  • 수동으로 슬롯 이름 관리해야 함
  • 플레이어 구분 직접 구현해야 함
  • 좀 더 유연한 저장/로드 가능

ULocalPlayerSaveGame

  • 로컬 플레이어별 저장 기능 내장
  • 플레이어 ID 기반 저장 자동화
  • LocalPlayerSubsystem과 자연스럽게 연동
  • 여러 로컬 플레이어 지원 용이함

언리얼 공식 문서 - LocalPlayerSaveGame

룬 시스템은 플레이어별로 관리해야 하니까 LocalPlayerSaveGame이 더 적합하다고 판단함.


📚 개발 참고

언리얼 C++ 객체 유효성 검사

if(객체) 사용이 적합한 경우:

  • 함수 매개변수 검사
  • 지역 변수로 방금 생성된 객체
  • 함수 내부에서 호출 즉시 사용하는 경우
  • 성능이 중요한 자주 호출되는 코드
void AMyActor::ProcessItem(UItem* Item)
{
    if (Item) // 여기서는 괜찮음 (함수 매개변수)
    {
        Item->Use();
    }
}

IsValid(객체) 사용이 더 적합한 경우:

  • 클래스 멤버 변수 참조
  • 지연된 실행(델리게이트, 타이머 등)에서 사용
  • 레벨 전환 가능성이 있는 코드
  • 비동기 작업 후 객체 참조
  • 네트워크 코드
void AMyActor::OnTimerEnd()
{
    // 타이머 콜백에서는 IsValid 사용 권장
    if (IsValid(TargetActor))
    {
        TargetActor->Activate();
    }
}

0개의 댓글