결과물

이벤트 출력 기능을 구현했습니다. 그리고 스캔으로 출력된 이벤트를 제거하는 기능도 구현했습니다. 화면에 2번째 CCTV에서 쓰레기 더미가 이벤트로 출력이 돼서, 스캔이 진행된 이후 쓰레기 더미가 사라진 것을 볼 수 있습니다.
구현 내용
1. AEventManager.h / .cpp
레벨 내의 이벤트 출력을 담당하는 액터입니다. 현재는 정해진 일정 시간마다 4개의 구역 중 가중치 기반으로 하나의 구역을 선정해서 해당 구역의 이벤트를 랜덤으로 출력하도록 구현했습니다. 추후 이벤트 출력 방식은 바뀔 수 있습니다.
가중치 시스템을 활용하여 이벤트가 많이 출력된 구역의 경우 해당 구역의 이벤트가 출력될 확률을 낮추도록 구현했습니다.
AEventManager.h
더보기
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "LevelSequenceActor.h"
#include "Actor/CCTV.h"
#include "EventManager.generated.h"
USTRUCT(BlueprintType)
struct FEventInfo
{
GENERATED_BODY()
/** 구역에 해당하는 이벤트 액터 목록입니다. 레벨에 배치된 LevelSequenceActor를 직접 참조합니다. */
UPROPERTY(EditAnywhere)
TArray<TObjectPtr<ALevelSequenceActor>> Events;
/** 이벤트가 발생한 횟수를 추적하는 변수입니다. 높을수록 이벤트가 발생할 확률이 낮아집니다. */
float EventPenaltyWeight = 0.0f;
};
UCLASS()
class VIRTUALREALITY_API AEventManager : public AActor
{
GENERATED_BODY()
// Lifecycle Section
public:
virtual void BeginPlay() override;
// Member Function
public:
/** 이벤트 사이클을 시작합니다. */
void StartEventCycle();
/** 이벤트 사이클을 중단합니다. */
void StopEventCycle();
/** EntityClearLever가 끝까지 내려졌을 때 호출되는 콜백입니다. */
void OnLeverReachedEnd();
/** Monitor의 채널 전환 델리게이트 콜백입니다. */
void OnMonitorChanged(ACCTV* InWatchedCCTV);
private:
void PlayNextEvent();
/** 가중치 기반으로 다음 이벤트를 재생할 Zone의 CCTV를 반환합니다. 소진 시 nullptr을 반환합니다. */
ACCTV* SelectZone();
/** 감시 중인 구역에 발생한 이벤트가 없을 때 처리합니다. */
void HandleNoPlayedEvents();
/** 감시 중인 구역에서 재생된 이벤트의 장소 상태를 복원합니다. */
void RestoreWatchedZoneState(FEventInfo& PlayedInfo);
// Variable Section
protected:
/** CCTV를 키로, 해당 구역의 이벤트 정보를 값으로 관리합니다. */
UPROPERTY(EditInstanceOnly, BlueprintReadOnly, Category = "변수|이벤트")
TMap<TObjectPtr<ACCTV>, FEventInfo> ZoneEvents;
/** 이벤트 재생 완료 후 다음 이벤트까지의 대기 시간(초)입니다. */
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "변수|이벤트")
float EventInterval = 5.0f;
private:
FTimerHandle EventTimerHandle;
/** 현재 Monitor가 감시 중인 CCTV입니다. 해당 구역의 이벤트는 선택에서 제외됩니다. */
UPROPERTY()
TObjectPtr<ACCTV> WatchedCCTV;
/** Zone별로 재생된 이벤트 목록입니다. 장소 상태 복원 대상 추적에 사용됩니다. */
UPROPERTY()
TMap<TObjectPtr<ACCTV>, FEventInfo> PlayedEventsByZone;
};
AEventManager.cpp
더보기
#include "EventManager.h"
#include "LevelSequencePlayer.h"
#include "VirtualReality.h"
void AEventManager::BeginPlay()
{
Super::BeginPlay();
StartEventCycle();
}
void AEventManager::StartEventCycle()
{
if (ZoneEvents.IsEmpty()) return;
// EventInterval 후 첫 번째 이벤트를 재생합니다.
GetWorldTimerManager().SetTimer(EventTimerHandle, this, &AEventManager::PlayNextEvent, EventInterval, false);
LOG(TEXT("이벤트 사이클이 시작되었습니다. (인터벌: %.1f초)"), EventInterval);
}
void AEventManager::StopEventCycle()
{
GetWorldTimerManager().ClearTimer(EventTimerHandle);
LOG(TEXT("이벤트 사이클이 중단되었습니다."));
}
void AEventManager::PlayNextEvent()
{
ACCTV* SelectedCCTV = SelectZone();
if (!SelectedCCTV) return;
FEventInfo& Zone = ZoneEvents[SelectedCCTV];
// Zone 내 랜덤 이벤트를 선택합니다.
const int32 EventIndex = FMath::RandRange(0, Zone.Events.Num() - 1);
Zone.Events[EventIndex]->GetSequencePlayer()->Play();
// 패널티 가중치를 누적하고, 재생한 이벤트를 PlayedEventsByZone으로 이동합니다.
Zone.EventPenaltyWeight += 1.f;
PlayedEventsByZone.FindOrAdd(SelectedCCTV).Events.Add(Zone.Events[EventIndex]);
Zone.Events.RemoveAt(EventIndex);
LOG(TEXT("이벤트 %d를 재생합니다. (잔여: %d개)"), EventIndex, Zone.Events.Num());
// 시퀀스 완료 후 EventInterval만큼 대기하고 다음 이벤트를 예약합니다.
GetWorldTimerManager().SetTimer(EventTimerHandle, this, &AEventManager::PlayNextEvent, EventInterval, false);
}
void AEventManager::OnLeverReachedEnd()
{
if (!WatchedCCTV) return;
FEventInfo* PlayedInfo = PlayedEventsByZone.Find(WatchedCCTV);
if (!PlayedInfo || PlayedInfo->Events.IsEmpty())
{
HandleNoPlayedEvents();
}
else
{
RestoreWatchedZoneState(*PlayedInfo);
}
}
void AEventManager::HandleNoPlayedEvents()
{
LOG(TEXT("감시 중인 구역에서 발생한 이벤트가 없습니다."));
}
void AEventManager::RestoreWatchedZoneState(FEventInfo& PlayedInfo)
{
// 재생된 이벤트의 상태를 LevelSequence 재생 이전으로 복원합니다.
for (ALevelSequenceActor* Event : PlayedInfo.Events)
{
if (!Event) continue;
ULevelSequencePlayer* Player = Event->GetSequencePlayer();
if (!Player) continue;
// 재생 중인 경우 먼저 중단합니다.
if (Player->IsPlaying())
{
Player->Stop();
}
}
PlayedEventsByZone.Remove(WatchedCCTV);
LOG(TEXT("감시 중인 구역의 이벤트 장소 상태가 복원되었습니다."));
}
void AEventManager::OnMonitorChanged(ACCTV* InWatchedCCTV)
{
// 현재 감시 중인 CCTV를 캐싱합니다.
WatchedCCTV = InWatchedCCTV;
}
ACCTV* AEventManager::SelectZone()
{
// 후보 Zone(비어있지 않고 감시 중이지 않은)을 수집합니다.
TArray<TPair<ACCTV*, float>> Candidates;
float TotalWeight = 0.f;
bool bAnyNonEmpty = false;
for (auto& [CCTV, Zone] : ZoneEvents)
{
if (Zone.Events.IsEmpty()) continue;
bAnyNonEmpty = true;
if (CCTV == WatchedCCTV) continue;
const float Weight = 1.f / (1.f + Zone.EventPenaltyWeight);
Candidates.Add({ CCTV.Get(), Weight });
TotalWeight += Weight;
}
// 비어있지 않은 Zone이 전혀 없으면 소진 신호를 반환합니다.
if (!bAnyNonEmpty)
{
LOG(TEXT("모든 이벤트가 소진되었습니다. 이벤트 사이클을 중단합니다."));
return nullptr;
}
// 유효한 후보가 없으면 감시 중인 구역만 남은 것이므로 나중에 재시도합니다.
if (Candidates.IsEmpty())
{
GetWorldTimerManager().SetTimer(EventTimerHandle, this, &AEventManager::PlayNextEvent, EventInterval, false);
LOG(TEXT("모든 구역이 감시 중입니다. %.1f초 후 다시 시도합니다."), EventInterval);
return nullptr;
}
// 가중치 랜덤 선택. 마지막 항목은 폴백으로 두어 경계값 편향을 방지합니다.
const float Rand = FMath::FRandRange(0.f, TotalWeight);
float Accumulated = 0.f;
for (int32 i = 0; i < Candidates.Num() - 1; ++i)
{
Accumulated += Candidates[i].Value;
if (Rand < Accumulated) return Candidates[i].Key;
}
return Candidates.Last().Key;
}
2. AMonitor.h / .cpp
Scan이 진행되는 동안 모니터에 스캔 머티리얼을 띄우도록 구현했습니다. 일정 시간 후 원래의 머티리얼로 돌아옵니다.
AMonitor.h
더보기
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "Monitor.generated.h"
class ACCTV;
class URectLightComponent;
class UStaticMeshComponent;
class UMaterialInterface;
class UMaterialInstanceDynamic;
DECLARE_MULTICAST_DELEGATE_OneParam(FOnMonitorChangedDelegate, ACCTV*);
UCLASS()
class VIRTUALREALITY_API AMonitor : public AActor
{
GENERATED_BODY()
// Lifecycle Section
public:
AMonitor();
virtual void BeginPlay() override;
// Delegate Section
public:
FOnMonitorChangedDelegate OnMonitorChangedDelegate;
// Member Function
public:
UFUNCTION(BlueprintCallable)
void SwitchToNextCCTV();
void OnLeverReachedEnd();
private:
void SetActiveCCTV(bool bIsEnable);
void DeactivateAllCCTVs();
void ApplyNextCCTV();
void SetScreenMaterial(const float InNoisePower, const float InNoiseIntensity, UTextureRenderTarget2D* InRenderTarget);
void RestoreScreenMaterial();
// Component Section
protected:
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "변수|컴포넌트")
TObjectPtr<USceneComponent> MonitorRoot;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "변수|컴포넌트")
TObjectPtr<UStaticMeshComponent> MonitorMesh;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "변수|컴포넌트")
TObjectPtr<UStaticMeshComponent> ScreenMesh;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "변수|컴포넌트")
TObjectPtr<URectLightComponent> ScreenRectLight;
// Variable Section
protected:
/** 렌더 타겟을 표시할 화면 머티리얼입니다. */
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "변수|Screen")
TObjectPtr<UMaterialInterface> ScanningMaterial;
/** 렌더 타겟을 표시할 화면 머티리얼입니다. */
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "변수|Screen")
TObjectPtr<UMaterialInterface> ScreenMaterial;
/** 렌더 타겟 텍스처를 연결할 머티리얼 파라미터 이름입니다. */
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "변수|Screen")
FName RenderTargetParameterName = TEXT("RenderTarget");
/** CCTV 전환 시 출력할 노이즈 속도 파라미터 이름입니다. */
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "변수|Screen")
FName NoisePowerParameterName = TEXT("TV.Noise.Power");
/** CCTV 전환 시 출력할 노이즈 강도 파라미터 이름입니다. */
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "변수|Screen")
FName NoiseIntensityParameterName = TEXT("TV.Noise.Intensity");
/** 노이즈 출력 시 모니터에 적용할 텍스처입니다. */
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "변수|Screen")
UTextureRenderTarget2D* BlackRenderTarget = nullptr;
/** 채널 전환 시 노이즈를 적용할 시간입니다. */
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "변수|Screen")
float NoiseEffectDuration = 0.5f;
/** SCANNING 머티리얼을 유지할 시간입니다. */
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "변수|Screen")
float ScanningDuration = 2.0f;
/** 모니터에 출력할 CCTV 액터 목록입니다. 레벨에 배치된 CCTV 액터를 수동으로 등록해야 합니다.
*
* 0 : Locker, 1 : Corridor, 2 : EmptyLot, 3 : Exit
*/
UPROPERTY(EditInstanceOnly, BlueprintReadOnly, Category = "변수|CCTV")
TArray<TObjectPtr<ACCTV>> RegisteredCCTVs;
private:
UPROPERTY()
TObjectPtr<UMaterialInstanceDynamic> ScreenMaterialInstance;
int32 ActiveCCTVIndex = 0;
FTimerHandle SwitchTimerHandle;
FTimerHandle ScanningTimerHandle;
uint8 bIsScanning : 1 = false;
// Getter, Setter Section
public:
FORCEINLINE int32 GetActiveCCTVIndex() const { return ActiveCCTVIndex; }
};
AMonitor.cpp
더보기
#include "Monitor.h"
#include "Components/StaticMeshComponent.h"
#include "Materials/MaterialInstanceDynamic.h"
#include "Engine/TextureRenderTarget2D.h"
#include "Actor/CCTV.h"
#include "EngineUtils.h"
#include "VirtualReality.h"
#include "Components/RectLightComponent.h"
AMonitor::AMonitor()
{
MonitorRoot = CreateDefaultSubobject<USceneComponent>("MonitorRoot");
SetRootComponent(MonitorRoot);
MonitorMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("MonitorMesh"));
MonitorMesh->SetupAttachment(MonitorRoot);
MonitorMesh->SetRelativeScale3D(FVector(1.0f, 2.5f, 2.5f));
ScreenMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("ScreenMesh"));
ScreenMesh->SetupAttachment(MonitorMesh);
ScreenMesh->SetRelativeLocation(FVector(4.0f, 0.0f, 31.6f));
ScreenMesh->SetRelativeRotation(FRotator(0.0f, -90.0f, 0.0f));
ScreenMesh->SetRelativeScale3D(FVector(0.6f, 0.001f, 0.14f));
ScreenRectLight = CreateDefaultSubobject<URectLightComponent>(TEXT("ScreenLight"));
ScreenRectLight->SetupAttachment(ScreenMesh);
ScreenRectLight->SetIntensityUnits(ELightUnits::Unitless);
ScreenRectLight->SetVisibility(false);
static ConstructorHelpers::FObjectFinder<UStaticMesh> SM_Monitor(TEXT("/Game/AtmosphericHouse/Meshes/Meshes_props/Electronics/SM_Computer_screen"));
if (SM_Monitor.Succeeded())
{
MonitorMesh->SetStaticMesh(SM_Monitor.Object);
}
}
void AMonitor::BeginPlay()
{
Super::BeginPlay();
// 화면용 다이나믹 머티리얼 인스턴스를 생성합니다.
if (ScreenMaterial)
{
ScreenMaterialInstance = UMaterialInstanceDynamic::Create(ScreenMaterial, this);
ScreenMesh->SetMaterial(0, ScreenMaterialInstance);
}
ScreenRectLight->SetVisibility(true);
}
void AMonitor::SetActiveCCTV(bool bIsEnable)
{
if (RegisteredCCTVs.IsValidIndex(ActiveCCTVIndex))
{
RegisteredCCTVs[ActiveCCTVIndex]->SetCaptureEnabled(bIsEnable);
}
}
void AMonitor::DeactivateAllCCTVs()
{
for (TObjectPtr<ACCTV>& CCTV : RegisteredCCTVs)
{
if (CCTV)
{
CCTV->SetCaptureEnabled(false);
}
}
ActiveCCTVIndex = 0;
}
void AMonitor::SwitchToNextCCTV()
{
if (RegisteredCCTVs.IsEmpty() || bIsScanning) return;
// 현재 CCTV의 캡쳐를 비활성화합니다.
SetActiveCCTV(false);
SetScreenMaterial(5.f, 0.1f, BlackRenderTarget);
// 다음으로 활성화할 CCTV의 인덱스를 저장합니다.
ActiveCCTVIndex = (ActiveCCTVIndex + 1) % RegisteredCCTVs.Num();
// 다음 CCTV의 캡쳐를 미리 활성화합니다.
SetActiveCCTV(true);
// 일정 시간 후 실제 CCTV 전환을 수행합니다.
GetWorldTimerManager().SetTimer(SwitchTimerHandle, this, &AMonitor::ApplyNextCCTV, NoiseEffectDuration, false);
// 채널 전환 델리게이트를 호출합니다.
// EventManager가 다음 CCTV의 이벤트를 발생시키는 것을 비활성화합니다.
if (OnMonitorChangedDelegate.IsBound())
{
OnMonitorChangedDelegate.Broadcast(RegisteredCCTVs[ActiveCCTVIndex]);
}
}
void AMonitor::OnLeverReachedEnd()
{
bIsScanning = true;
ScreenMesh->SetMaterial(0, ScanningMaterial);
// ScanningDuration 후 원래 머티리얼로 복원합니다.
GetWorldTimerManager().SetTimer(ScanningTimerHandle, this, &AMonitor::RestoreScreenMaterial, ScanningDuration, false);
}
void AMonitor::RestoreScreenMaterial()
{
bIsScanning = false;
ScreenMesh->SetMaterial(0, ScreenMaterialInstance);
}
void AMonitor::ApplyNextCCTV()
{
// 다음 CCTV가 캡쳐하는 텍스처를 모니터에 적용합니다.
UTextureRenderTarget2D* RenderTarget = RegisteredCCTVs[ActiveCCTVIndex]->GetRenderTarget();
SetScreenMaterial(0.f, 0.f, RenderTarget);
}
void AMonitor::SetScreenMaterial(const float InNoisePower, const float InNoiseIntensity, UTextureRenderTarget2D* InRenderTarget)
{
ScreenMaterialInstance->SetScalarParameterValue(NoisePowerParameterName, InNoisePower);
ScreenMaterialInstance->SetScalarParameterValue(NoiseIntensityParameterName, InNoiseIntensity);
ScreenMaterialInstance->SetTextureParameterValue(RenderTargetParameterName, InRenderTarget);
}
3. LevelSequence
레벨 내의 출력하는 이벤트는 LevelSequence로 관리합니다. 이벤트 출력 시 Loop로 LevelSequence가 재생되고, 스캔 기능으로 이벤트 감지 시 해당 이벤트를 종료하는 방식으로 이벤트 상태를 복원합니다.

마무리
추가적으로 레벨 디자인이 변경됐는데요. 이 부분에 최적화를 진행하다가 시간을 많이 썼습니다.. DirectX 12에서 DirectX 11로 변경하는 방법을 처음 활용해봤는데, 퀄리티의 차이가 극명한 것도 아닌데 프레임이 압도적으로 높아지네요.. 앞으로 자주 활용할 것 같습니다.
레벨 디자인을 변경하긴 했지만, 직접 플레이해보니 공들인 시간에 비해 퀄리티가 잘 와닿지가 않아서 슬픈 상태입니다..
'Unreal Engine 프로젝트 > VR 공포게임' 카테고리의 다른 글
| [언리얼엔진] 13. 특수 이벤트 출력 구현 (1) | 2026.04.15 |
|---|---|
| [언리얼엔진] 11. 손전등 기능 구현 (0) | 2026.04.11 |
| [언리얼엔진] 10. 레버 중간 클래스 구현 및 오브젝트 모델링 (0) | 2026.04.10 |
| [언리얼엔진] 9. Floating Dust 파티클 구현 (0) | 2026.04.09 |
| [언리얼 엔진] 8. 채널 전환 버튼 구현 (0) | 2026.04.09 |