목차
- 왜 리팩터링이 필요했나
- SetTime: 증분 → 절대값
- UTickableWorldSubsystem으로 자동 시간 흐름
- 레벨 전환 시 시간 유지 (GameInstance 연동)
- FTimeOfDay 구조체 + ETimeOfDay Enum
- Dynamic Multicast Delegate로 블루프린트 바인딩
- 전체 구조 정리
- 추후 확장 포인트
1. 왜 리팩터링이 필요했나
처음 만든 TimeSubsystem은 동작은 했지만 구조적인 문제가 있었다.
| 항목 | 기존 | 문제 |
|---|---|---|
| SetTime() | 증분값으로 AddActorLocalRotation | 매 호출마다 각도 누적 → 시간 지날수록 조명 각도 틀어짐 |
| 자동 흐름 | 없음 | 외부에서 계속 SetTime 호출해야 함 |
| 레벨 전환 | 고려 없음 | WorldSubsystem은 레벨 전환 시 소멸 → 시간 초기화 |
| 델리게이트 | DECLARE_MULTICAST_DELEGATE | 블루프린트에서 바인딩 불가 |
2. SetTime: 증분 → 절대값
기존 코드는 AddActorLocalRotation으로 매 호출마다 각도를 더했다. 호출 빈도나 DeltaTime 오차가 쌓이면 시간이 지날수록 조명 각도가 틀어진다.
// 기존 - 호출할 때마다 각도 누적
double Pitch = Time / 86400.f * 360;
CachedDirectionalLight->AddActorLocalRotation(FRotator(Pitch, 0, 0));
// 변경 - 절대 각도로 항상 정확하게 세팅
void UTimeSubsystem::ApplyTimeToLight(float TimeInSeconds)
{
if (!CachedDirectionalLight.IsValid()) return;
// 정오(12시)에 Pitch -90도 기준으로 오프셋 계산
const double Pitch = (TimeInSeconds / 86400.0 * 360.0) - 270.0;
CachedDirectionalLight->SetActorRotation(FRotator(Pitch, 0.0, 0.0));
}
SetActorRotation은 항상 절대 각도로 세팅하기 때문에 누적 없이 정확한 위치가 보장된다.
오프셋 계산:
- 정오(12시) = 43200초 →
43200 / 86400 * 360 = 180도 - 실제 정오 Pitch = -90도
- 오프셋 =
-90 - 180 = -270
3. UTickableWorldSubsystem으로 자동 시간 흐름
시간이 자동으로 흐르게 하려면 Tick이 필요하다. UWorldSubsystem은 Tick을 지원하지 않으므로 UTickableWorldSubsystem으로 변경한다.
// UTickableWorldSubsystem 사용 시 GetStatId() 반드시 구현해야 함
virtual TStatId GetStatId() const override
{
RETURN_QUICK_DECLARE_CYCLE_STAT(UTimeSubsystem, STATGROUP_Tickables);
}
void UTimeSubsystem::Tick(float DeltaTime)
{
// FMath::Fmod로 86400초(24시간) 넘으면 0으로 순환
CurrentTimeSeconds = FMath::Fmod(CurrentTimeSeconds + DeltaTime * TimeScale, 86400.f);
ApplyTimeToLight(CurrentTimeSeconds);
SaveToGameInstance();
TimeChanged.Broadcast(CurrentTimeSeconds / 86400.f * 360.0, GetCurrentTimeData());
}
TimeScale = 60이면 실제 1초에 게임 1분이 흐른다.
4. 레벨 전환 시 시간 유지 (GameInstance 연동)
UWorldSubsystem은 레벨 전환 시 소멸하고 새 레벨에서 새로 생성된다. 시간 값을 유지하려면 레벨과 무관하게 살아있는 GameInstance에 저장해야 한다.
// MyGameInstance.h에 변수 추가
UPROPERTY()
float SavedTimeSeconds = 43200.f; // 기본값: 낮 12시
void UTimeSubsystem::SaveToGameInstance()
{
if (auto* GI = Cast<UMyGameInstance>(GetWorld()->GetGameInstance()))
GI->SavedTimeSeconds = CurrentTimeSeconds;
}
void UTimeSubsystem::LoadFromGameInstance()
{
if (auto* GI = Cast<UMyGameInstance>(GetWorld()->GetGameInstance()))
CurrentTimeSeconds = GI->SavedTimeSeconds;
}
// OnWorldBeginPlay에서 복원 후 조명 즉시 적용
void UTimeSubsystem::OnWorldBeginPlay(UWorld& InWorld)
{
Super::OnWorldBeginPlay(InWorld);
// ... 디렉셔널라이트 캐싱 ...
LoadFromGameInstance();
ApplyTimeToLight(CurrentTimeSeconds);
}
5. FTimeOfDay 구조체 + ETimeOfDay Enum
Pitch 수치만 브로드캐스트하면 받는 쪽에서 매번 계산해야 하고 시간대 판단 로직이 여러 곳에 흩어진다.
UENUM(BlueprintType)
enum class ETimeOfDay : uint8
{
Dawn, // 새벽 0~6h
Day, // 낮 6~17h
Evening, // 저녁 17~20h
Night, // 밤 20~24h
};
USTRUCT(BlueprintType)
struct FTimeOfDay
{
GENERATED_BODY()
UPROPERTY(BlueprintReadOnly) int32 Hour = 0;
UPROPERTY(BlueprintReadOnly) int32 Minute = 0;
UPROPERTY(BlueprintReadOnly) int32 Second = 0;
UPROPERTY(BlueprintReadOnly) ETimeOfDay Period = ETimeOfDay::Day;
};
이제 LightManager는 Pitch 수치 직접 비교 대신 Enum으로 판단한다.
// 기존 - 매직 넘버 직접 비교
const bool bIsNight = Pitch >= 0.f && Pitch <= 180.f;
// 변경 - Enum으로 명확하게
const bool bIsNight = (TimeData.Period == ETimeOfDay::Night ||
TimeData.Period == ETimeOfDay::Dawn);
6. Dynamic Multicast Delegate로 블루프린트 바인딩
블루프린트에서 이벤트를 바인딩하려면 DECLARE_DYNAMIC_MULTICAST_DELEGATE를 써야 한다. 일반 MULTICAST는 C++ 전용이다.
// 기존 - C++ 전용
DECLARE_MULTICAST_DELEGATE_OneParam(FTimeChanged, double)
// 변경 - 블루프린트 바인딩 가능, 파라미터 이름 반드시 명시
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FTimeChanged, double, Pitch, FTimeOfDay, TimeData);
// UPROPERTY 없으면 블루프린트에서 못 씀
UPROPERTY(BlueprintAssignable, Category = "Time")
FTimeChanged TimeChanged;
C++에서 바인딩할 때는 AddUObject 대신 AddDynamic 사용:
// 함수에 UFUNCTION() 없으면 AddDynamic 못 씀
UFUNCTION()
void UpdateLights(double Pitch, FTimeOfDay TimeData);
// BeginPlay
GetWorld()->GetSubsystem<UTimeSubsystem>()->TimeChanged.AddDynamic(
this, &ALightManager::UpdateLights);
7. 전체 구조 정리
UMyGameInstance
└── SavedTimeSeconds ← 레벨 전환 시 시간 보존
UTimeSubsystem (UTickableWorldSubsystem)
├── OnWorldBeginPlay → LoadFromGameInstance + 조명 즉시 적용
├── Tick → CurrentTimeSeconds += DeltaTime * TimeScale
│ → ApplyTimeToLight (절대 각도)
│ → SaveToGameInstance
│ → TimeChanged.Broadcast (Pitch, FTimeOfDay)
├── SetTime(float) → 외부에서 강제 세팅
└── GetCurrentTimeData() → FTimeOfDay { Hour, Minute, Second, Period }
TimeChanged 구독자
├── ALightManager → ETimeOfDay로 가로등 ON/OFF
└── HUD Blueprint → Hour:Minute:Second 텍스트 표시
8. 추후 확장 포인트
Sky Atmosphere / Exponential Height Fog 연동TimeChanged에 구독자로 추가하기만 하면 된다. 시간대별로 안개 밀도, 하늘 색상을 Curve로 제어할 수 있다.
ETimeOfDay 전환 이벤트 분리
현재는 매 Tick마다 브로드캐스트하지만, 시간대가 바뀌는 순간에만 발생하는 OnTimeOfDayChanged 델리게이트를 추가하면 BGM 전환이나 파티클 스폰에 활용할 수 있다.
TimeScale 동적 변경
컷신 중 빠르게 돌리거나 특정 구간에서 시간을 멈추는 기능. TimeScale = 0.f만 해도 Tick에서 시간이 멈춘다.
시간대 경계값을 DataTable로 분리
현재 CalcTimeOfDay()의 경계값(0, 6, 17, 20)이 하드코딩되어 있다. DataTable로 빼면 디자이너가 직접 조정할 수 있다.
Save Game 연동
GameInstance의 SavedTimeSeconds를 USaveGame 오브젝트에 직렬화하면 게임 종료 후에도 시간이 유지된다.