[언리얼엔진] 12. 이벤트 출력 및 스캔 기능 구현

2026. 4. 13. 08:41·Unreal Engine 프로젝트/VR 공포게임

결과물

이벤트 출력 기능을 구현했습니다. 그리고 스캔으로 출력된 이벤트를 제거하는 기능도 구현했습니다. 화면에 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
'Unreal Engine 프로젝트/VR 공포게임' 카테고리의 다른 글
  • [언리얼엔진] 13. 특수 이벤트 출력 구현
  • [언리얼엔진] 11. 손전등 기능 구현
  • [언리얼엔진] 10. 레버 중간 클래스 구현 및 오브젝트 모델링
  • [언리얼엔진] 9. Floating Dust 파티클 구현
Meoyoung's Development Logs
Meoyoung's Development Logs
내가 보려고 만든 블로그
  • Meoyoung's Development Logs
    이게뭐영
    Meoyoung's Development Logs
  • 전체
    오늘
    어제
    • 분류 전체보기 (289)
      • Unreal Engine 프로젝트 (36)
        • 더 퍼스트 버서커 : 카잔 (16)
        • VR 공포게임 (13)
        • Paper-ZD (7)
      • 언리얼 엔진 (72)
        • GAS (10)
        • 트러블슈팅 (27)
        • 캐릭터 (2)
        • VR (1)
        • Lighting (2)
        • 멀티스레드 (2)
        • Lyra (1)
      • C++ (31)
        • 문법 정리 (8)
        • [서적] Fundamental C++ 프로그래밍 .. (5)
        • [서적] 이것이 C++이다 (11)
        • [서적] Effective C++ (7)
      • 게임잼 (3)
      • 강의 (36)
        • [강의] 이득우의 언리얼 프로그래밍 Part1 (13)
        • [강의] 이득우의 언리얼 프로그래밍 Part2 (2)
        • [강의] 이득우의 언리얼 프로그래밍 Part3 (12)
        • [강의] 소울라이크 개발 A-Z (4)
        • [강의] Udemy-2D (5)
      • C# (1)
        • [서적] 이것이 C#이다 (1)
      • 코딩테스트 (26)
        • 프로그래머스 (6)
        • 알고리듬 (13)
        • 자료구조 (7)
      • 컴퓨터 과학 (27)
        • 운영체제 (11)
        • 데이터베이스 (0)
        • 디자인패턴 (0)
        • 자료구조 (5)
        • 네트워크 (0)
        • 컴퓨터구조 (11)
      • 면접준비 (0)
        • C++ (0)
        • 운영체제 (0)
        • 자료구조 (0)
      • 기타 (48)
        • [팀프로젝트] The Fourth Descenda.. (5)
        • GetOutOf (15)
        • [개인프로젝트] FPS 구현 맛보기 (5)
        • [서적] 인생 언리얼5 (4)
        • 스파르타코딩클럽 (15)
        • 객체지향프로그래밍 (2)
        • 컴퓨터회로 (2)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    게임잼
    삽입정렬
    참가후기
    알고리즘
    경북게임잼
    게임개발
    쉘정렬
    자료구조
    선택정렬
    버블정렬
    셸정렬
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
Meoyoung's Development Logs
[언리얼엔진] 12. 이벤트 출력 및 스캔 기능 구현
상단으로

티스토리툴바