이전 포스팅에서 만들어둔 맵들을 이용해서 Map을 랜덤하게 생성하게 해보자.
아이디어 구상
절차적 맵 생성에 대해 검색하면 여러 알고리즘이 나오지만 막상 적용하기 어렵거나 큰 틀만 작성되어있어 내용이 부실한 경우가 많았다. (한국어로만 검색하면 그러하다)
BSP 알고리즘
검색해봤을 때 가장 많이 나오는 알고리즘은 BSP 알고리즘 이다.
이는 재귀적으로 특정한 크기의 공간을 계속 나누고 나누어 일정 크기가 되었을 때, 방을 생성시킨 후, 차례대로 돌아가면서 길을 잇는 방식으로 주로 구현된다.
구현 자체는 생각보다 어렵지 않았다. 특정한 공간 벡터의 비율을 정한 뒤 그 공간의 가로 길이와 세로 길이를 비교하며 수평과 수직으로 나누는 작업을 반복하면 된다.
하지만, 이번 프로젝트에 적용하기엔 무리가 있었다. 이미 정해준 룸과 복도 에셋이 고정되어 있고 크기가 각각 다르기 때문이다. 또한 각 방들은 입구와 출구가 존재하고 이는 룸마다 갯수가 다르기 때문에 복도를 어떻게 이어야 효율적이지 라는 문제가 발생하여 폐기하였다.
MST 알고리즘
MST 알고리즘을 사용해서 맵을 랜덤하게 만드는 방식이다. 원하는 위치를 중심으로 정한 크기의 원을 스폰시킨 뒤 그 위치 안에서 랜덤한 위치에 룸을 스폰한다. 스폰된 방들이 안겹치에 펼친뒤 랜덤으로 액터를 지우는 것이다.
그 뒤로는 각각의 방들을 알고리즘을 이용해 최적 거리르 계산하고 그 곳에 복도를 스폰하는 방식이다.
이 방식도 결국엔 위의 BSP와 같은 고민이 들었다. 방을 랜덤하게 생성하는 것 까진 좋지만 복도의 크기가 정해져 있기에 복도를 이을때 축소되거나 확대 되는 경우가 발생할 것이다.
따라서 이 방식도 사용하지 않을 것이다.
결정된 알고리즘 : Growing Tree, BFS로 연결성 정리
Growing Tree 알고리즘을 변형한다.
모든 방과 복도는 입구와 출구들이 존재한다.
출구들 중 하나를 선택해서 이어지는 랜덤한 방을 스폰한다. 이때, 스폰되는 방은 콜리전 테스트를 해 제대로 스폰이 가능한지 체크한다.
증분 배치를 통해 정해진 맵 리스트 안에서 랜덤하게 스폰하는 형식이다.
그러다 맵을 만들 수 없다고 판단 되는 경우 모두 파괴후 다시 재시작하는 방식이다.
핵심은 다음과 같다.
- 시드(seed) 기반 랜덤 증분 배치
- 연속 실패 시 전체 재시작(제한된 백트래킹)
- BFS를 이용한 그래프 연결성 정리
코드를 보면 이해가 빠르다.
주요 알고리즘 흐름
주요 알고리즘의 흐름은 다음과 같다.
flowchart TD
A[BeginPlay] --> B[SetSeed()]
B --> C[SpawnMainRoom()]
C --> D[GenerateMap()]
D --> E{SpawnNextChunk 성공?}
E -- 예 --> D
E -- 아니오 --> F[실패 카운트 증가]
F --> G{카운트 > MaxRestartFailures?}
G -- 예 --> H[전체 초기화 → SpawnMainRoom]
G -- 아니오 --> D
D --> I[RemoveNonConnectedChunks()]
I --> J[FinishMapGenerate()]
- BeginPlay: UE4 생명주기 시작 지점
- SetSeed(): 고정 시드 또는 랜덤 시드 초기화
- SpawnMainRoom(): 시작 룸 생성 후 출구(Doors) 수집
- GenerateMap(): 남은 출구가 있을 때까지 증분 배치 반복
- RemoveNonConnectedChunks(): 고립된 복도 제거
- FinishMapGenerate(): 최종 후처리(예: 이동식 문 설정)
헤더 파일은 다음과 같다.
// Fill out your copyright notice in the Description page of Project Settings.
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "Data/RASMapType.h"
#include "RASMapGenerator.generated.h"
USTRUCT(BlueprintType)
struct FExitInfo
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, Category="Generate")
ERASMapType MapType;
UPROPERTY(EditAnywhere, Category="Generate")
TObjectPtr<class ARASDoor> Door;
};
UCLASS()
class PROJECTRAS_API ARASMapGenerator : public AActor
{
GENERATED_BODY()
public:
ARASMapGenerator();
bool GetDungeonComplete() { return bCompleteMapGenerate; }
protected:
virtual void BeginPlay() override;
void SetSeed(); // 시드 설정
void SpawnMainRoom(); // 메인 룸 스폰
void StartGeneratorTimer(); // 청크 스폰 타이머 시작
void GenerateMap(); // 맵 루프 시작
bool SpawnNextChunk(); // 다음 청크(룸 or 복도) 스폰
bool SpawnAtExit(UWorld* World, const FExitInfo& ExitInfo); // 스폰할 룸 or 복도 선택
// 복도 생성
bool SpawnCorridor(UWorld* World, const FExitInfo& ExitInfo, const FActorSpawnParameters& Params);
// 룸 생성
bool SpawnRoom(UWorld* World, const FExitInfo& ExitInfo, const FActorSpawnParameters& Params);
// 충돌 검사 후 재시도 or 확정
bool CheckForOverlap(class ARASChunk* InChunk);
void CheckToMapGenerateComplete(); // 맵 생성 완료 체크
void RemoveNonConnectedChunks(); // 연결되지 않은 청크 삭제
void FinishMapGenerate(); // 맵 생성 완료
FRandomStream Stream;
UPROPERTY(EditAnywhere, Category = Generate)
TArray<FExitInfo> ExitsList;
UPROPERTY(EditAnywhere, Category = Generate)
TArray<TObjectPtr<class ARASChunk>> SpawnedChunks;
UPROPERTY(EditAnywhere, Category = Generate)
TObjectPtr<class URASMapGenerateData> MapGenerateData;
UPROPERTY(EditAnywhere, Category = Generate)
TArray<ERASRoomType> SpawnRoomTypeList; // 스폰할 룸 타입 리스트
private:
FTimerHandle GenerateMapTimerHandle;
bool bCompleteMapGenerate = false; // 맵 생성 완료 여부
};
코드 상세 분석
SpawnMainRoom
NPC, Boss, Normal 룸 타입 리스트 구성해 초기 룸들을 맞춰준다. 랜덤하게 셔플 후 StartRoomClass 위치에 스폰(0,0,0)한다.
스폰된 룸의 Doors → ExitsList에 추가하여 다음번 ExitsList에 들어있는 것들을 기준으로 방을 스폰할 것이다.
void ARASMapGenerator::SpawnMainRoom()
{
if (!MapGenerateData || !MapGenerateData->StartRoomClass)
return;
// 스폰 할 방 타입 리스트 초기화
SpawnRoomTypeList.Empty();
int32 NPCRoomCount = Stream.RandRange(MapGenerateData->MinNpcRoomAmount, MapGenerateData->MaxNpcRoomAmount);
for (int32 i = 0; i < NPCRoomCount; i++)
{
SpawnRoomTypeList.Add(ERASRoomType::NPC);
}
SpawnRoomTypeList.Add(ERASRoomType::Boss);
for (int32 i = SpawnRoomTypeList.Num(); i < MapGenerateData->MaxRoomAmount; i++)
{
SpawnRoomTypeList.Add(ERASRoomType::Normal);
}
RASUtils::ShuffleTArray(SpawnRoomTypeList, Stream);
UWorld* World = GetWorld();
if (!World)
return;
FActorSpawnParameters Params;
Params.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
ARASChunk* Main = Cast<ARASChunk>(World->SpawnActor<AActor>(MapGenerateData->StartRoomClass,
GetActorLocation(), GetActorRotation(), Params));
if (!Main)
return;
Main->SetupDoor();
TArray<TObjectPtr<ARASDoor>> Doors = Main->GetDoors();
RASUtils::ShuffleTArray(Doors, Stream);
int32 CorridorCount = Stream.RandRange(MapGenerateData->MinMainCorridorAmount, MapGenerateData->MaxMainCorridorAmount);
for (int32 i = 0; i < CorridorCount; ++i)
{
ExitsList.Add({ Main->GetMapType(), Doors[i]});
}
SpawnedChunks.Add(Main);
UE_LOG(LogTemp, Log, TEXT("메인 탈출구 %d개"), ExitsList.Num());
}
GenerateMap
맵을 랜덤하게 스폰하는 함수이다. 모든 맵이 다 올바르게 생성될 때까지 루프를 돌며 루프를 다 돌았음에도 맵을 불가능하게 생성했을 경우 전부 다 초기화 하고 다시 처음부터 맵을 만든다.
void ARASMapGenerator::GenerateMap()
{
int32 ConsecutiveFailures = 0;
const int32 MaxFailuresBeforeRestart = MapGenerateData->MaxRestartFailures;
while(SpawnRoomTypeList.Num() > 0 && ExitsList.Num() > 0)
{
if (!SpawnNextChunk())
{
++ConsecutiveFailures;
UE_LOG(LogTemp, Warning, TEXT("GenerateMap: SpawnNextChunk failures %d/%d"), ConsecutiveFailures, MaxFailuresBeforeRestart);
if (ConsecutiveFailures >= MaxFailuresBeforeRestart)
{
UE_LOG(LogTemp, Warning, TEXT("GenerateMap: Too many consecutive failures, restarting generation"));
for (ARASChunk* Chunk : SpawnedChunks)
{
if (Chunk)
Chunk->Destroy();
}
SpawnedChunks.Empty();
ExitsList.Empty();
SpawnRoomTypeList.Empty();
SpawnMainRoom();
ConsecutiveFailures = 0;
continue;
}
RASUtils::ShuffleTArray(SpawnRoomTypeList, Stream);
continue;
}
ConsecutiveFailures = 0;
}
bCompleteMapGenerate = true;
RemoveNonConnectedChunks();
FinishMapGenerate();
}
Spawn
맵을 스폰할 때 맵이 올바르게 스폰이 되지 않았을 경우(콜리전 겹칠경우)를 대비해 정해진 횟수만큼 재 시도한다.
bool ARASMapGenerator::SpawnNextChunk()
{
UWorld* World = GetWorld();
if (!World || SpawnRoomTypeList.Num() == 0 || ExitsList.Num() == 0)
return false;
UE_LOG(LogTemp, Log, TEXT(" 스폰 시작"));
for (int32 Attempt = 0; Attempt < MapGenerateData->MaxSpawnAttempts; ++Attempt)
{
int32 ExitIndex = Stream.RandRange(0, ExitsList.Num() - 1);
FExitInfo ExitInfo = ExitsList[ExitIndex];
if (SpawnAtExit(World, ExitInfo))
{
ExitsList.RemoveAt(ExitIndex);
UE_LOG(LogTemp, Log, TEXT("스폰된 후 남은 탈출구 %d개"), ExitsList.Num());
return true;
}
RASUtils::ShuffleTArray(SpawnRoomTypeList, Stream);
if (ExitsList.Num() == 0)
break;
}
return false;
}
맵을 스폰할 때는 랜덤하게 복도와 룸을 고르지만, 룸은 무조건 복도를, 복도는 확률적으로 룸과 복도중 하나를 선택한다.
이때, 룸을 선택했는데 룸을 스폰할 수 없다면 복도를 무조건 적으로 스폰하게 한다.
bool ARASMapGenerator::SpawnAtExit(UWorld* World, const FExitInfo& ExitInfo)
{
FActorSpawnParameters Params;
Params.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
switch (ExitInfo.MapType)
{
case ERASMapType::Room:
UE_LOG(LogTemp, Log, TEXT(" 룸 타입 선택됨 , Vector : %s"), *ExitInfo.Door->GetActorLocation().ToString());
return SpawnCorridor(World, ExitInfo, Params);
break;
case ERASMapType::Corridor:
UE_LOG(LogTemp, Log, TEXT(" 복도 타입 선택됨, Vector : %s"), *ExitInfo.Door->GetActorLocation().ToString());
if (Stream.RandRange(0, 2) < 2)
{
UE_LOG(LogTemp, Log, TEXT("룸 선택"));
if (SpawnRoom(World, ExitInfo, Params))
{
return true;
}
UE_LOG(LogTemp, Warning, TEXT("SpawnRoom 실패, 복도로 폴백"));
return SpawnCorridor(World, ExitInfo, Params);
}
else
{
UE_LOG(LogTemp, Log, TEXT("복도 선택"));
return SpawnCorridor(World, ExitInfo, Params);
}
default:
UE_LOG(LogTemp, Warning, TEXT("SpawnAtExit: invalid MapType"));
return false;
}
}
룸 또는 복도를 스폰하게 되면 콜리전을 검사하고 올바르게 스폰됐다면 스폰된 청크의 출구를 등록해주어야 증분이 가능하다.
또한 어떤 청크끼리 연결되어 있는지 정보도 업데이트 해준다.
bool ARASMapGenerator::SpawnCorridor(UWorld* World, const FExitInfo& ExitInfo, const FActorSpawnParameters& Params)
{
TArray<int32> CorridorTypes = { 0, 1, 2 };
RASUtils::ShuffleTArray(CorridorTypes, Stream);
for (int32 CorridorType : CorridorTypes)
{
TSubclassOf<AActor> CorridorClass;
switch (CorridorType)
{
case 0:
CorridorClass = MapGenerateData->CorridorList[ERASCorridorType::Straight];
break;
case 1:
CorridorClass = MapGenerateData->CorridorList[ERASCorridorType::BentRight];
break;
case 2:
CorridorClass = MapGenerateData->CorridorList[ERASCorridorType::BentLeft];
break;
default:
continue;
}
FVector SpawnLocation = ExitInfo.Door->GetActorLocation();
FRotator SpawnRotation = ExitInfo.Door->GetActorRotation();
ARASChunk* Chunk = Cast<ARASChunk>(
World->SpawnActor<AActor>(CorridorClass, SpawnLocation, SpawnRotation, Params));
if (!Chunk)
continue;
if (!CheckForOverlap(Chunk))
{
Chunk->Destroy();
continue;
}
Chunk->SetupDoor();
Chunk->GetStartDoor()->SetConnectedChunk(ExitInfo.Door->GetOwnerChunk());
ExitInfo.Door->SetConnectedChunk(Chunk);
UE_LOG(LogTemp, Log, TEXT("복도 타입 %d 스폰 성공"), CorridorType);
for (ARASDoor* Door : Chunk->GetDoors())
ExitsList.Add({ Chunk->GetMapType(), Door });
return true;
}
UE_LOG(LogTemp, Warning, TEXT("SpawnCorridor: 모든 복도 타입 실패"));
return false;
}
bool ARASMapGenerator::SpawnRoom(UWorld* World, const FExitInfo& ExitInfo, const FActorSpawnParameters& Params)
{
if (SpawnRoomTypeList.Num() == 0)
return false;
ERASRoomType Type = SpawnRoomTypeList[0];
if (Type == ERASRoomType::None)
return false;
UE_LOG(LogTemp, Log, TEXT("%d 룸 스폰 시도"), Type);
FVector SpawnLocation = ExitInfo.Door->GetActorLocation();
FRotator SpawnRotation = ExitInfo.Door->GetActorRotation();
ARASChunk* Chunk = Cast<ARASChunk>(
World->SpawnActor<AActor>(MapGenerateData->RoomList[Type], SpawnLocation, SpawnRotation, Params));
if (!Chunk || !CheckForOverlap(Chunk))
{
if (Chunk) Chunk->Destroy();
return false;
}
Chunk->SetupDoor();
Chunk->GetStartDoor()->SetConnectedChunk(ExitInfo.Door->GetOwnerChunk());
ExitInfo.Door->SetConnectedChunk(Chunk);
UE_LOG(LogTemp, Log, TEXT("%d 룸 스폰 완료"), Type);
SpawnRoomTypeList.RemoveAt(0);
TArray<TObjectPtr<ARASDoor>> Doors = Chunk->GetDoors();
RASUtils::ShuffleTArray(Doors, Stream);
int32 CorridorCount = Stream.RandRange(2, 3);
CorridorCount = FMath::Min(CorridorCount, Doors.Num());
for (int32 i = 0; i < CorridorCount; ++i)
{
ExitsList.Add({ Chunk->GetMapType(), Doors[i]});
}
return true;
}
CheckForOverlap
청크와 청크간 충돌을 감지하는 함수이다. 만들어둔 콜리전 박스끼리 interesct 하는지 확인한다.
bool ARASMapGenerator::CheckForOverlap(class ARASChunk* InChunk)
{
if (!InChunk)
return false;
FBox NewBox = InChunk->CollisionBox->Bounds.GetBox();
for (ARASChunk* Existing : SpawnedChunks)
{
if (Existing)
{
FBox ExistingBox = Existing->CollisionBox->Bounds.GetBox();
if (NewBox.Intersect(ExistingBox))
{
UE_LOG(LogTemp, Warning, TEXT("CheckForOverlap: Overlapping with existing chunk"));
return false;
}
}
}
SpawnedChunks.Add(InChunk);
return true;
}
RemoveConnectedChunk
이 함수는 불필요하게 생긴 복도들을 전부 없애는 함수이다.
룸이랑 이어지지 않는 복도는 전부 없애주고 커넥션들을 끊어주는 것이다.
알고리즘은 기존 N^2을 이용하던 반면 BFS로 수정해 N+E로 수정됐다. 주석이 그러한 부분이다.
// 시간 복잡도 O(N+E)
void ARASMapGenerator::RemoveNonConnectedChunks()
{
// Corridor 청크만 수집해서 인접 리스트 초기화
TMap<ARASChunk*, TArray<ARASChunk*>> CorrAdj;
TSet<ARASChunk*> CorridorSet;
for (ARASChunk* Chunk : SpawnedChunks)
{
if (Chunk && Chunk->GetMapType() == ERASMapType::Corridor)
{
CorridorSet.Add(Chunk);
CorrAdj.FindOrAdd(Chunk);
}
}
// Corridor↔Corridor 간 인접 정보 구축
for (ARASChunk* C : CorridorSet)
{
TArray<ARASDoor*> AllDoors = C->GetDoors();
if (C->GetStartDoor())
AllDoors.Add(C->GetStartDoor());
for (ARASDoor* D : AllDoors)
{
if (!D) continue;
ARASChunk* N = D->GetConnectedChunk();
if (N && N->GetMapType() == ERASMapType::Corridor)
{
CorrAdj[C].AddUnique(N);
}
}
}
// 각 Corridor 연결 을 BFS로 순회
TSet<ARASChunk*> Visited;
TSet<ARASChunk*> ToRemove;
TQueue<ARASChunk*> Q;
for (ARASChunk* Start : CorridorSet)
{
if (Visited.Contains(Start))
continue;
TArray<ARASChunk*> Component;
TSet<ARASChunk*> AdjacentRooms;
Visited.Add(Start);
Q.Enqueue(Start);
while (!Q.IsEmpty())
{
ARASChunk* Cur;
Q.Dequeue(Cur);
Component.Add(Cur);
TArray<ARASDoor*> AllDoors = Cur->GetDoors();
if (Cur->GetStartDoor())
AllDoors.Add(Cur->GetStartDoor());
for (ARASDoor* D : AllDoors)
{
if (!D) continue;
ARASChunk* N = D->GetConnectedChunk();
if (N && N->GetMapType() == ERASMapType::Room)
{
AdjacentRooms.Add(N);
}
}
for (ARASChunk* Neigh : CorrAdj[Cur])
{
if (!Visited.Contains(Neigh))
{
Visited.Add(Neigh);
Q.Enqueue(Neigh);
}
}
}
// 룸에 인접한 개수가 2개 미만이면, 이 성분의 모든 corridor를 제거 대상
if (AdjacentRooms.Num() < 2)
{
for (ARASChunk* C : Component)
ToRemove.Add(C);
}
}
// 제거 Corridor들만 Disconnect → SpawnedChunks 제거 → Destroy
auto DisconnectChunk = [&](ARASChunk* ChunkToRemove)
{
auto DisconnectDoor = [&](ARASDoor* Door)
{
if (!Door) return;
ARASChunk* Neigh = Door->GetConnectedChunk();
if (!Neigh) return;
if (Neigh->GetStartDoor() &&
Neigh->GetStartDoor()->GetConnectedChunk() == ChunkToRemove)
{
Neigh->GetStartDoor()->SetConnectedChunk(nullptr);
}
for (ARASDoor* ND : Neigh->GetDoors())
{
if (ND && ND->GetConnectedChunk() == ChunkToRemove)
ND->SetConnectedChunk(nullptr);
}
};
DisconnectDoor(ChunkToRemove->GetStartDoor());
for (ARASDoor* D : ChunkToRemove->GetDoors())
DisconnectDoor(D);
};
for (ARASChunk* Chunk : ToRemove)
{
DisconnectChunk(Chunk);
SpawnedChunks.RemoveSingleSwap(Chunk);
UE_LOG(LogTemp, Log, TEXT("RemoveNonConnectedChunks: Removed %s"), *Chunk->GetName());
Chunk->Destroy();
}
}
// 이전 코드 시간 복잡도 N^2
//void ARASMapGenerator::RemoveNonConnectedChunks()
//{
// bool bRemovedAny = true;
//
// while (bRemovedAny)
// {
// bRemovedAny = false;
// TArray<ARASChunk*> ToRemove;
//
// for (ARASChunk* Chunk : SpawnedChunks)
// {
// if (!Chunk || Chunk->GetMapType() != ERASMapType::Corridor)
// continue;
//
// int32 ConnectionCount = 0;
// if (Chunk->GetStartDoor() && Chunk->GetStartDoor()->GetConnectedChunk())
// ConnectionCount++;
// for (ARASDoor* Door : Chunk->GetDoors())
// {
// if (Door && Door->GetConnectedChunk())
// ConnectionCount++;
// }
//
// if (ConnectionCount <= 1)
// {
// ToRemove.Add(Chunk);
// }
// }
//
// if (ToRemove.Num() > 0)
// {
// bRemovedAny = true;
//
// for (ARASChunk* Chunk : ToRemove)
// {
// auto DisconnectNeighbor = [&](ARASDoor* Door)
// {
// if (!Door) return;
// ARASChunk* Neighbor = Door->GetConnectedChunk();
// if (!Neighbor) return;
//
// if (Neighbor->GetStartDoor() &&
// Neighbor->GetStartDoor()->GetConnectedChunk() == Chunk)
// {
// Neighbor->GetStartDoor()->SetConnectedChunk(nullptr);
// }
//
// for (ARASDoor* ND : Neighbor->GetDoors())
// {
// if (ND->GetConnectedChunk() == Chunk)
// ND->SetConnectedChunk(nullptr);
// }
// };
//
// DisconnectNeighbor(Chunk->GetStartDoor());
// for (ARASDoor* Door : Chunk->GetDoors())
// DisconnectNeighbor(Door);
//
// SpawnedChunks.RemoveSingleSwap(Chunk);
//
// UE_LOG(LogTemp, Log, TEXT("RemoveNonConnectedChunks: Removed %s"), *Chunk->GetName());
//
// Chunk->Destroy();
// }
// }
// }
//}
결과
이 영상은 실제로 보여주기 위해 제작된 딜레이가 있는 맵 생성이다. 청크마다 0.25초의 텀을 두고 시각화 한것이다. 맵 크기 20
실제 동작은 다음과 같다. 맵 크기 10
- 장점
- 재현 가능한 던전 생성(Seed 고정)
- 실시간 증분 배치로 초기 맵 생성 즉시 가능
- BFS 연결성 정리로 불필요·고립 구역 자동 제거
- 단점
- 연속 실패 시 전체 재시작 → 큰 맵에서 성능 저하 가능
- 구조 제어가 비교적 단순 → 복잡 미로형 던전엔 부적합
- 룸 간 “플로우” 보장은 미약(임의 배치)
목적에 맞게 잘 생성한 것 같다.
'Unreal Engine 5 > ProjectRAS' 카테고리의 다른 글
[UE5] ProjectRAS - UI 스킬 쿨타임 과 포션 마시기 (0) | 2025.05.21 |
---|---|
[UE5] ProjectRAS - 보스 패턴 제작하기 (0) | 2025.05.16 |
[UE5] ProjectRAS - 맵 디자인 및 블프화 (0) | 2025.04.16 |
[UE5] ProjectRAS - 스테미나 추가, 패링 성공 시 Blur, 캐릭터 죽음 (0) | 2025.04.02 |
[UE5] ProjectRAS - 패링으로 적 밀격 하기, UI 만들기 (0) | 2025.03.20 |