오늘은 기존 PlayerCharacter 중심 구조를 정리하고, 스태미나를 활용한 레벨 기반 점수 시스템을 만들기 위한 기반 작업을 진행했다.
기존 PlayerCharacter에는 입력, 스태미나, 아이템 효과, UI 이벤트 등 여러 책임이 몰려 있었다.
이를 줄이기 위해 일부 기능을 컴포넌트와 인터페이스로 분리했다.
분리한 내용은 다음과 같다.
UStaminaComponent
UPlayerInputConfigComponent
IItemEffectReceiver
APlayerCharacter에 직접 의존하지 않도록 인터페이스화SpeedUp, Jumpup, StaminaRecovery 아이템이 이 인터페이스를 통해 효과 적용이 리팩토링을 통해 PlayerCharacter는 직접 모든 상태를 관리하기보다, 각 컴포넌트를 조립하고 연결하는 역할에 가까워졌다.
기존 스태미나 로직은 캐릭터 내부에 있었지만, 별도의 StaminaComponent로 이동했다.
현재 스태미나 동작은 다음과 같다.
OnStaminaDepleted 이벤트 발생OnStaminaChanged 이벤트를 통해 스태미나 바를 갱신또한 레벨별 스태미나 소모 배율을 적용하기 위해 DrainMultiplier를 추가했다.
SetCurrentStamina(CurrentStamina - ConsumePerSecond * DrainMultiplier * DeltaTime);
레벨 1, 2, 3의 규칙을 코드에 하드코딩하지 않고 DataAsset으로 관리하도록 설계했다.
ULevelConfigDataAsset에는 다음 값들이 들어간다.
예상 구조는 다음과 같다.
DA_Level_01
- TimeLimit = 60
- StaminaDrainMultiplier = 1.0
DA_Level_02
- TimeLimit = 60
- StaminaDrainMultiplier = 1.5
DA_Level_03
- TimeLimit = 60
- StaminaDrainMultiplier = 2.0
이렇게 분리하면 레벨별 밸런싱을 코드 수정 없이 에디터에서 조정할 수 있다.
레벨 진행, 제한 시간, 점수는 플레이어 개인 상태가 아니라 게임 규칙에 가깝기 때문에 PlayerCharacter가 아니라 AGameLevelManager에서 관리하도록 했다.
GameLevelManager의 주요 역할은 다음과 같다.
점수는 Sprint 상태일 때만 증가한다.
if (bIsScoringActive)
{
Score += ScorePerSecond * DeltaTime;
}
처음에는 PlayerCharacter가 GameLevelManager를 직접 참조하는 방식을 고려했지만, 설계상 좋지 않다고 판단했다.
최종 구조는 이벤트 기반으로 잡았다.
PlayerCharacter
- Sprint 시작/종료 이벤트 발행
GameLevelManager
- Sprint 이벤트 구독
- 점수 증가 활성화/비활성화
PlayerCharacter는 다음 이벤트만 발행한다.
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnSprintStateChanged, bool, bIsSprinting);
Sprint 시작 시:
OnSprintStateChanged.Broadcast(true);
Sprint 종료 또는 스태미나 고갈 시:
OnSprintStateChanged.Broadcast(false);
GameLevelManager는 이 이벤트를 구독해서 점수 증가 상태를 바꾼다.
void AGameLevelManager::HandleSprintStateChanged(bool bIsSprinting)
{
SetScoringActive(bIsSprinting);
}
Score가 증가하지 않는 문제가 있었다.
첫 번째 원인은 BindPlayerEvents() 내부 조건문 실수였다.
잘못된 코드:
if (PlayerCharacter)
{
return;
}
이 코드는 플레이어를 정상적으로 찾았을 때 바로 return 해버리기 때문에 이벤트 바인딩이 실행되지 않았다.
수정 후:
if (!PlayerCharacter)
{
return;
}
이후에도 안정성을 위해 GameLevelManager가 플레이어를 찾을 때까지 일정 간격으로 재시도하도록 변경했다.
GetWorldTimerManager().SetTimer(
BindPlayerEventsTimerHandle,
this,
&AGameLevelManager::BindPlayerEvents,
0.1f,
true
);
플레이어를 찾으면 타이머를 정지하고 이벤트를 바인딩한다.
GetWorldTimerManager().ClearTimer(BindPlayerEventsTimerHandle);
또한 바인딩 직후 현재 스태미나 소비 상태를 기준으로 점수 활성 상태를 동기화했다.
SetScoringActive(StaminaComponent->GetCurrentConsuming());
WBP_UserWidget에 다음 텍스트를 추가했다.
Text_LevelText_ScorePlayerHUDWidget에서는 GameLevelManager의 진행 이벤트를 받아 UI를 갱신한다.
void UPlayerHUDWidget::OnLevelProgressChanged(
int32 CurrentLevelIndex,
float RemainingTime,
float Score
)
{
if (Text_Level)
{
Text_Level->SetText(
FText::FromString(FString::Printf(TEXT("Level %d"), CurrentLevelIndex))
);
}
if (Text_Score)
{
Text_Score->SetText(
FText::FromString(FString::Printf(TEXT("Score : %f"), Score))
);
}
}
PlayerHUDPresenter는 GameLevelManager를 찾아 OnGameLevelProgressChanged 이벤트에 바인딩한다.
오늘 가장 중요한 판단은 다음이었다.
PlayerCharacter가 모든 것을 직접 관리하지 않도록 만들자.
특히 레벨, 점수, 제한 시간, 아이템 스폰 수량은 플레이어의 속성이 아니라 게임 규칙에 가깝다.
따라서 GameLevelManager로 분리한 것은 좋은 방향이었다.
현재 구조는 다음과 같이 정리된다.
PlayerCharacter
- 입력 처리
- Sprint 상태 이벤트 발행
- 이동/카메라 처리
StaminaComponent
- 스태미나 소모/회복
- 스태미나 고갈 이벤트
GameLevelManager
- 레벨 진행
- 제한 시간 관리
- 점수 관리
- 레벨별 스태미나 배율 적용
LevelConfigDataAsset
- 레벨별 밸런스 데이터
PlayerHUDWidget / Presenter
- Level, Score, Stamina UI 표시
다음 단계에서는 LevelConfigDataAsset의 아이템 스폰 수량을 기반으로 실제 아이템 스폰 시스템을 연결할 예정이다.
필요한 작업은 다음과 같다.