[언리얼엔진] 13. 특수 이벤트 출력 구현

2026. 4. 15. 19:39·Unreal Engine 프로젝트/VR 공포게임

결과물

The Entity Watcher의 핵심적인 오브젝트인 "인형" 특수 이벤트를 추가했습니다. 해당 이벤트는 모든 구역 내에 한 곳에서만 발생할 수 있습니다.

 


 

구현 내용

1. Entity

핵심 오브젝트인 인형 클래스입니다. 에셋의 파츠가 분리되어 있기 때문에 파츠를 하나의 SkeletalMesh로 병합하도록 구현했습니다.

AEntity.h

더보기
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "Entity.generated.h"

UCLASS()
class VIRTUALREALITY_API AEntity : public AActor
{
	GENERATED_BODY()

public:
	AEntity();
	virtual void BeginPlay() override;

private:
	/** BodyMesh와 BodyParts를 병합하여 Body 컴포넌트에 적용합니다. */
	void MergeBodyParts();

public:
	/** 병합 결과를 표시할 스켈레탈 메시 컴포넌트입니다. */
	UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "변수")
	TObjectPtr<USkeletalMeshComponent> Body;

	/** 병합의 베이스가 되는 바디 스켈레탈 메시 에셋입니다. */
	UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "변수")
	TObjectPtr<USkeletalMeshComponent> ClothMesh;

	/** Body에 병합할 파츠 스켈레탈 메시 에셋 목록입니다. */
	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "변수")
	TArray<TObjectPtr<USkeletalMesh>> BodyParts;
};

AEntity.cpp

더보기
#include "Entity.h"
#include "VirtualReality.h"
#include "SkeletalMergingLibrary.h"

AEntity::AEntity()
{
	// Body 컴포넌트를 루트로 설정합니다.
	Body = CreateDefaultSubobject<USkeletalMeshComponent>(TEXT("Body"));
	SetRootComponent(Body);
	
	// Body의 옷을 담는 SkeletalMeshComponent를 초기화합니다.
	ClothMesh = CreateDefaultSubobject<USkeletalMeshComponent>(TEXT("ClothMesh"));
	ClothMesh->SetupAttachment(Body);
}

void AEntity::BeginPlay()
{
	Super::BeginPlay();

	MergeBodyParts();
}

void AEntity::MergeBodyParts()
{
	USkeletalMesh* MergedMesh = NewObject<USkeletalMesh>(GetTransientPackage(), NAME_None, RF_Transient);

	TArray<FSkelMeshMergeSectionMapping> EmptySectionMappings;
	FSkeletalMeshMerge Merger(MergedMesh, BodyParts, EmptySectionMappings, 0);

	if (Merger.DoMerge())
	{
		// 병합 결과는 ClothMesh에 적용, Body(SKM_Body)는 건드리지 않음
		ClothMesh->SetSkeletalMesh(MergedMesh);
		
		// SKM_Body의 포즈를 따라감
		ClothMesh->SetLeaderPoseComponent(Body);
	}
}

 


 

2. EventManager

Normal 이벤트를 일정 횟수만큼 스폰했다면 Entity 이벤트를 출력하는 구조로 구현했습니다. 다만, 고정적으로 3회마다 Entity를 출력하는 식으로 구현하면, Entity가 생성되는 타이밍을 예측할 수 있기 때문에 랜덤성을 부여했습니다. 즉, MinEntityEventCycle ~ MaxEntityEventCycle 사이의 랜덤한 수를 추출하여 해당 랜덤한 수마다 Entity 이벤트를 출력합니다. Entity 이벤트를 출력할 때마다 갱신합니다.

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()
	
	/** 구역에 해당하는 Entity 이벤트 액터 목록입니다. 레벨에 배치된 LevelSequenceActor를 직접 참조합니다. */
	UPROPERTY(EditAnywhere)
	TArray<TObjectPtr<ALevelSequenceActor>> EntityEvents;

	/** 구역에 해당하는 일반 이벤트 액터 목록입니다. 레벨에 배치된 LevelSequenceActor를 직접 참조합니다. */
	UPROPERTY(EditAnywhere)
	TArray<TObjectPtr<ALevelSequenceActor>> NormalEvents;

	/** 이벤트가 발생한 횟수를 추적하는 변수입니다. 높을수록 이벤트가 발생할 확률이 낮아집니다. */
	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();

	/** 랜덤 인터벌로 다음 이벤트 타이머를 예약하고, 설정된 인터벌(초)을 반환합니다. */
	void ScheduleNextEvent();

	/** 감시 중인 구역에 발생한 이벤트가 없을 때 처리합니다. */
	void HandleNoPlayedEvents();

	/** 감시 중인 구역에서 재생된 이벤트의 장소 상태를 복원합니다. */
	void RestoreWatchedZoneState();
	
	/** 현재가 Entity 이벤트를 출력할 턴인지 여부를 반환하는 함수입니다. */
	uint8 IsEntityTurn();

	/** 이벤트를 출력할 Zone을 가중치 기반으로 선정하여 반환합니다. 감시 중인 구역만 남은 경우 재시도 후 nullptr을 반환합니다. */
	ACCTV* PickEventZone();

	/** 선정된 구역에 Entity 이벤트를 출력합니다. */
	void PlayEntityEvent(ACCTV* SelectedCCTV);

	/** 선정된 구역에 Normal 이벤트를 출력합니다. */
	void PlayNormalEvent(ACCTV* SelectedCCTV);


// Variable Section
protected:
	/** CCTV를 키로, 해당 구역의 이벤트 정보를 값으로 관리합니다. */
	UPROPERTY(EditInstanceOnly, BlueprintReadOnly, Category = "변수|이벤트")
	TMap<TObjectPtr<ACCTV>, FEventInfo> ZoneEvents;

	/** 다음 이벤트까지 대기 시간의 최솟값(초)입니다. */
	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "변수|이벤트")
	float MinEventInterval = 3.0f;

	/** 다음 이벤트까지 대기 시간의 최댓값(초)입니다. */
	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "변수|이벤트")
	float MaxEventInterval = 8.0f;

	/** Entity 이벤트 발생까지 필요한 최소 이벤트 횟수입니다. */
	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "변수|이벤트")
	int32 MinEntityEventCycle = 3;

	/** Entity 이벤트 발생까지 필요한 최대 이벤트 횟수입니다. */
	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "변수|이벤트")
	int32 MaxEntityEventCycle = 6;

private:
	FTimerHandle EventTimerHandle;

	/** 총 이벤트 재생 횟수를 추적합니다. */
	int32 EventCallCount = 0;

	/** 다음 Entity 이벤트가 발생할 EventCallCount 기준값입니다. */
	int32 NextEntityEventThreshold = 0;

	/** 현재 Monitor가 감시 중인 CCTV입니다. 해당 구역의 이벤트는 선택에서 제외됩니다. */
	UPROPERTY()
	TObjectPtr<ACCTV> WatchedCCTV;

	/** Zone별로 현재 재생 중인 이벤트입니다. */
	TMap<TObjectPtr<ACCTV>, TObjectPtr<ALevelSequenceActor>> PlayedEventsByZone;

	/** Entity 이벤트가 활성화된 구역입니다. nullptr이면 Entity 이벤트가 없는 상태입니다. */
	UPROPERTY()
	TObjectPtr<ACCTV> EntityEventActiveCCTV;

};

AEventManager.cpp

더보기
#include "EventManager.h"
#include "LevelSequencePlayer.h"
#include "VirtualReality.h"

void AEventManager::BeginPlay()
{
	Super::BeginPlay();

	StartEventCycle();
}

void AEventManager::StartEventCycle()
{
	if (ZoneEvents.IsEmpty()) return;

	// 첫 Entity 이벤트 발생 기준값을 랜덤 설정합니다.
	NextEntityEventThreshold = FMath::RandRange(MinEntityEventCycle, MaxEntityEventCycle);
	
	// MinEventInterval, MaxEventInterval 사이의 랜덤한 인터벌로 이벤트를 스폰합니다.
	ScheduleNextEvent();
}

void AEventManager::StopEventCycle()
{
	GetWorldTimerManager().ClearTimer(EventTimerHandle);

	LOG(TEXT("이벤트 사이클이 중단되었습니다."));
}

void AEventManager::ScheduleNextEvent()
{
	const float Interval = FMath::FRandRange(MinEventInterval, MaxEventInterval);
	GetWorldTimerManager().SetTimer(EventTimerHandle, this, &AEventManager::PlayNextEvent, Interval, false);
	LOG(TEXT("%f 후에 이벤트가 발생합니다."), Interval);
}

void AEventManager::PlayNextEvent()
{
	// 이벤트를 출력할 구역을 선정합니다. 감시 중인 구역만 남은 경우 다음 기회에 재시도 합니다.
	ACCTV* SelectedCCTV = PickEventZone();
	if (!SelectedCCTV) return;

	// Entity 턴이고 해당 구역에 Entity 이벤트가 존재할 때만 Entity 이벤트를 출력합니다.
	const bool bHasEntityEvents = !ZoneEvents[SelectedCCTV].EntityEvents.IsEmpty();
	if (IsEntityTurn() && bHasEntityEvents)
	{
		PlayEntityEvent(SelectedCCTV);
	}
	else
	{
		PlayNormalEvent(SelectedCCTV);
	}
}

void AEventManager::PlayEntityEvent(ACCTV* SelectedCCTV)
{
	// 선정된 구역의 랜덤한 Entity 이벤트를 선택합니다.
	FEventInfo& Zone = ZoneEvents[SelectedCCTV];
	const int32 EventIndex = FMath::RandRange(0, Zone.EntityEvents.Num() - 1);
	
	// 이벤트 출력 가중치를 더하고, PlayedEventsByZone에 이벤트 정보를 저장한 후 해당 이벤트를 출력 후 원본 배열에서 제거합니다.
	PlayedEventsByZone.Add(SelectedCCTV, Zone.EntityEvents[EventIndex]);
	Zone.EntityEvents[EventIndex]->GetSequencePlayer()->Play();
	Zone.EventPenaltyWeight += 1.f;
	Zone.EntityEvents.RemoveAt(EventIndex);

	// Entity 이벤트 활성 구역을 기록하고 다음 Entity 기준값을 갱신합니다.
	EntityEventActiveCCTV = SelectedCCTV;
	NextEntityEventThreshold = ++EventCallCount + FMath::RandRange(MinEntityEventCycle, MaxEntityEventCycle);

	LOG(TEXT("Entity 이벤트 %d를 재생합니다. (잔여: %d개)"), EventIndex, Zone.EntityEvents.Num());
	ScheduleNextEvent();
}

void AEventManager::PlayNormalEvent(ACCTV* SelectedCCTV)
{
	// 선정된 구역의 랜덤한 Normal 이벤트를 선택합니다.
	FEventInfo& Zone = ZoneEvents[SelectedCCTV];
	const int32 EventIndex = FMath::RandRange(0, Zone.NormalEvents.Num() - 1);

	// 이벤트 출력 가중치를 더하고, PlayedEventsByZone에 이벤트 정보를 저장한 후 해당 이벤트를 출력 후 원본 배열에서 제거합니다.
	PlayedEventsByZone.Add(SelectedCCTV, Zone.NormalEvents[EventIndex]);
	Zone.NormalEvents[EventIndex]->GetSequencePlayer()->Play();
	Zone.EventPenaltyWeight += 1.f;
	Zone.NormalEvents.RemoveAt(EventIndex);

	// 이벤트 총 출력 횟수를 더합니다.
	++EventCallCount;

	LOG(TEXT("Normal 이벤트 %d를 재생합니다. (잔여: %d개)"), EventIndex, Zone.NormalEvents.Num());
	ScheduleNextEvent();
}

void AEventManager::OnLeverReachedEnd()
{
	if (!WatchedCCTV) return;

	if (!PlayedEventsByZone.Contains(WatchedCCTV))
	{
		HandleNoPlayedEvents();
	}
	else
	{
		RestoreWatchedZoneState();
	}
}

void AEventManager::HandleNoPlayedEvents()
{
	LOG(TEXT("감시 중인 구역에서 발생한 이벤트가 없습니다."));
}

void AEventManager::RestoreWatchedZoneState()
{
	// 재생 중인 이벤트를 중단합니다.
	ALevelSequenceActor* PlayedEvent = PlayedEventsByZone[WatchedCCTV];
	if (IsValid(PlayedEvent))
	{
		ULevelSequencePlayer* Player = PlayedEvent->GetSequencePlayer();
		if (Player && Player->IsPlaying())
		{
			Player->Stop();
		}
	}

	// 이 구역에 Entity 이벤트가 있었다면 활성 상태를 해제합니다.
	if (EntityEventActiveCCTV == WatchedCCTV)
	{
		EntityEventActiveCCTV = nullptr;
	}

	PlayedEventsByZone.Remove(WatchedCCTV);

	LOG(TEXT("감시 중인 구역의 이벤트 장소 상태가 복원되었습니다."));
}

uint8 AEventManager::IsEntityTurn()
{
	return (EventCallCount >= NextEntityEventThreshold) && (EntityEventActiveCCTV == nullptr);
}

void AEventManager::OnMonitorChanged(ACCTV* InWatchedCCTV)
{
	// 현재 감시 중인 CCTV를 캐싱합니다.
	WatchedCCTV = InWatchedCCTV;
}

ACCTV* AEventManager::PickEventZone()
{
	// 이벤트를 출력할 수 있는 구역의 후보군을 선정합니다. 실제 이벤트 출력 로직이 아닙니다.
	TArray<TPair<ACCTV*, float>> Candidates;
	float TotalWeight = 0.f;
	for (auto& [CCTV, Zone] : ZoneEvents)
	{
		// 현재 감시 중인 구역 및 이벤트가 이미 출력된 구역은 후보군에서 제외합니다.
		if (CCTV == WatchedCCTV || PlayedEventsByZone.Contains(CCTV)) continue;

		const float Weight = 1.f / (1.f + Zone.EventPenaltyWeight);
		Candidates.Add({ CCTV.Get(), Weight });
		TotalWeight += Weight;
	}

	// 감시 중인 구역만 남은 경우 재시도합니다.
	if (Candidates.IsEmpty())
	{
		ScheduleNextEvent();
		return nullptr;
	}

	// 가중치 랜덤으로 실제 이벤트를 출력할 구역을 선택합니다.
	ACCTV* Selected = Candidates.Last().Key;
	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) { Selected = Candidates[i].Key; break; }
	}

	return Selected;
}

레벨에 미리 배치해둔 LevelSequenceActor를 디테일 패널에서 할당하는 방식입니다.

 


 

마무리

게임의 메인인 이벤트 출력 시스템을 구현했네요 ! 이제 플레이어 사망 조건을 처리해야 하는데 고민이 되네요. 각 구역마다 이벤트가 최대 1개씩만 스폰될 수 있다보니, 유저가 이벤트를 찾지 못 해도 최소 1/4 확률로 Scan을 성공할 수 있단 말이죠 .. 그래서 구역을 2개 더 늘리고 이벤트가 3개 이상인 상태에서 이벤트가 출력될 때마다 목숨 카운트를 하나씩 빼는 방식이면 어떨까 하는 생각이 드네요. 물론 틀리게 Scan을 할 때에도 목숨 카운트를 빼야겠죠 ! 흠 .. 한 번 고민좀 해봐야겠습니다 ..

'Unreal Engine 프로젝트 > VR 공포게임' 카테고리의 다른 글

[언리얼엔진] 12. 이벤트 출력 및 스캔 기능 구현  (0) 2026.04.13
[언리얼엔진] 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 공포게임' 카테고리의 다른 글
  • [언리얼엔진] 12. 이벤트 출력 및 스캔 기능 구현
  • [언리얼엔진] 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
[언리얼엔진] 13. 특수 이벤트 출력 구현
상단으로

티스토리툴바