결과물

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 |