[유니티 3D] 팩토리 패턴으로 몬스터와 보스 소환 해보기



플레이어가 이동해서 빈오브젝트에 있는 콜라이더에 충돌한다면, Enemy를 소환하려고합니다.
그리고 Enemy는 리자드와 오크라는 두가지 종류가 있고, 리자드에는 보스 리자드와 일반 리자드가 존재하며, 오크에는 보스 오크와 일반 오크가 존재합니다.
이 몬스터들을 소환하기 위해선 팩토리패턴을 사용할 것이기 때문에, 먼저 스크립트를 생성해보겠습니다.
먼저 IMonsterFactory라는 인터페이스를 만들어 다양한 팩토리가 구현되게 할 것 입니다.
public interface IMonsterFactory
{
Monster CreateMonster();
}
그후 MonsterFactory라는 :MonoBehaviour를 상속받는 구체적인 팩토리 클래스를 만들어 실제 몬스터 생성 로직을 구현할 것입니다. 이후 인터페이스도 구현시켜주었습니다.
using UnityEngine;
public class MonsterFactory : MonoBehaviour, IMonsterFactory
{
public GameObject lizardPrefab;
public GameObject bossLizardPrefab;
public enum MonsterType
{
Lizard,
BossLizard
}
public Monster CreateMonster(MonsterType type = MonsterType.Lizard)
{
GameObject prefab = null;
switch (type)
{
case MonsterType.Lizard:
prefab = lizardPrefab;
break;
case MonsterType.BossLizard:
prefab = bossLizardPrefab;
break;
}
if (prefab)
{
GameObject instance = Instantiate(prefab);
return instance.GetComponent<Monster>();
}
return null;
}
}
위와같이 스크립트를 작성하고 Unity에디터에서 'MonsterFactory' 컴포넌트를 빈 오브젝트를 생성해 추가하고, 필요한 프리팹을 GameObject에 연결하면 됩니다. 그런데 제 맨위에 사진을보면 ,Enemy 1, 2, 3중 1은 보스이고, 2,3은 리자드맨, 리자드우먼입니다. 이 종류를 나누고자
Lizard에 Man과 Woman의 종류를 고려해 팩토리 패턴을 확장시켰습니다.
using UnityEngine;
public class MonsterFactory : MonoBehaviour, IMonsterFactory
{
public GameObject lizardManPrefab; // MAN 타입 Lizard 프리팹
public GameObject lizardWomanPrefab; // WOMEN 타입 Lizard 프리팹
public GameObject bossLizardPrefab;
public enum MonsterType
{
LizardMan, // 추가된 Enum 항목
LizardWoman, // 추가된 Enum 항목
BossLizard
}
public Monster CreateMonster(MonsterType type = MonsterType.LizardMan) // Default값 변경
{
GameObject prefab = null;
switch (type)
{
case MonsterType.LizardMan:
prefab = lizardManPrefab;
break;
case MonsterType.LizardWoman:
prefab = lizardWomanPrefab;
break;
case MonsterType.BossLizard:
prefab = bossLizardPrefab;
break;
}
if (prefab)
{
GameObject instance = Instantiate(prefab);
return instance.GetComponent<Monster>();
}
return null;
}
이렇게 작성해주면 public Monster CreateMonster()에 인터페이스 멤버를 구현하지 않았다는 오류가 발생하게됩니다. IMonsterFactory에서 똑같이 모든 멤버를 구현해줘야합니다.
public interface IMonsterFactory
{
Monster CreateMonster(MonsterFactory.MonsterType type);
}
메서드를 똑같이 구현해줍니다. 이러면 오류가 해결될 것입니다.
처음에 Enemy를 구현할때 Lizard와 Orc 두 종류로 나뉘고, 그 나뉜곳에 일반몬스터와 보스 몬스터로 나뉠것이라 말씀드렸는데, 아래와 같이 작성해봅니다.
public enum MonsterType
{
LizardMan,
LizardWoman,
LizardBoss,
OrcBasic,
OrcBoss
}
포트폴리오용이나 테스트로 몬스터를 몇개만 만들때는 괜찮겠지만, 정식으로 몬스터의 종류가 워낙 많아질때 이렇게 작성하는 것은 좋지 않다고 생각이 들었습니다. 몬스터의 종류를 MonsterType 열거형에 포함되고, 저처럼 하위 유형을 갖는 객체라면 다른 방법을 사용하는게 옳다 생각이 들었습니다.. 그래서 다른 방식으로 조직화를 해봤습니다.
MonsterType 스크립트를 생성하는것이죠. 예시입니다. 몬스터의 주요 타입을 슬라임, 오크, 스켈레톤으로 나눴고, 그 타입마다 다른 종류의 몬스터를 생성시킨것입니다.
public static class MonsterTypes
{
public enum MainType
{
Slime,
Orc,
Skeleton
}
public enum SlimeType
{
BlueSlime,
RedSlime,
GiantSlime
}
public enum OrcType
{
WarriorOrc,
MageOrc,
ChiefOrc
}
public enum SkeletonType
{
BasicSkeleton,
ArcherSkeleton,
KnightSkeleton
}
}
이제 원하는 몬스터의 타입을 넣고, 열거형으로 몬스터의 또다른 종류를 넣으면 된다는 사실을 알게되었습니다. 이래야 코드를 조직화하는데 도움이 된다고 하더라구요. 그리고 메인타입의 로직이 필요하다면 아래와 같은 스크립트를 또 생성하면 되는것이죠.
public class Slime : MonoBehaviour
{
public MonsterTypes.SlimeType slimeType;
//슬라임 관련 로직
//...
}
이렇게 각각의 몬스터 유형마다 별도의 스크립트를 열거형으로 작성하면 코드의 가독성과 조직성이 향상된다네요.
그렇다면 제가 만들고있는 Lizard와 Orc를 위와 같은 방법으로 나누어봤습니다.
public static class MonsterTypes
{
public enum MainType
{
Lizard,
Orc
}
public enum LizardType
{
Man,
Woman,
Boss
}
public enum OrcType
{
Basic,
Boss
}
}
using UnityEngine;
public class Lizard : MonoBehaviour
{
public MonsterTypes.LizardType lizardType;
//리자드 관련 로직
}
//
public class Orc : MonoBehaviour
{
public MonsterTypes.OrcType orcType;
//오크 관련 로직
}
이제 MonsterFactory.cs로 돌아오겠습니다.
public class MonsterFactory : MonoBehaviour, IMonsterFactory
{
public GameObject lizardMan;
public GameObject lizardWoman;
public GameObject lizardBoss; // 이름을 bossLizard에서 lizardBoss로 수정
public GameObject orcBasic; // 추가: 기본 오크의 프리팹
public GameObject orcBoss; // 추가: 보스 오크의 프리팹
public Monster CreateLizard(MonsterTypes.LizardType type) //리자드소환
{
GameObject prefabs = null;
switch (type)
{
case MonsterTypes.LizardType.Man:
prefabs = lizardMan;
break;
case MonsterTypes.LizardType.Woman:
prefabs = lizardWoman;
break;
case MonsterTypes.LizardType.Boss:
prefabs = lizardBoss;
break;
}
if (prefabs)
{
GameObject instance = Instantiate(prefabs);
return instance.GetComponent<Monster>();
}
return null;
}
public Monster CreateOrc(MonsterTypes.OrcType type) //Orc 소환
{
GameObject prefabs = null;
switch (type)
{
case MonsterTypes.OrcType.Basic:
prefabs = orcBasic;
break;
case MonsterTypes.OrcType.Boss:
prefabs = orcBoss;
break;
}
if (prefabs)
{
GameObject instance = Instantiate(prefabs);
return instance.GetComponent<Monster>();
}
return null;
}
}
MonsterFactory 클래스에 구현 이름과 종류가 바뀌었습니다. 그치만 불편한 점은 if (prefabs) ~ return null 똑같은것이 반복되고있습니다. 중복된 코드를 피하기 위해 Instantiate로직을 별도의 메서드로 분리하려고 합니다.
private Monster CreateMonsterFromPrefab(GameObject prefab)
{
if (prefab)
{
GameObject instance = Instantiate(prefab);
return instance.GetComponent<Monster>();
}
return null;
}
//위와 같은 별도의 메서드를 생성하고, 원래 if (prefabs) ~ 이 붙어있던 자리에
//return CreateMonsterFromPrefab(prefab);을 넣어주면됩니다.
public Monster CreateMonster 에서 public Monster CreateLizard, Orc가 되었죠. IMonsterFactory 인터페이스도 수정해줍시다.
public interface IMonsterFactory
{
Monster CreateLizard(MonsterTypes.LizardType type);
Monster CreateOrc(MonsterTypes.OrcType type);
}
이제 인터페이스를 올바르게 구현하였습니다.
이제 소환할수있는 스폰장소를 만들어보겠습니다!
SpawnMnager 스크립트를 생성해줍시다.
public class SpawnManager : MonoBehaviour
{
public MonsterFactory monsterFactory;
public void SpawnLizard(MonsterTypes.LizardType type)
{
monsterFactory.CreateLizard(type, position);
//스폰 위치 ...
}
public void SpawnOrc(MonsterTypes.OrcType type)
{
monsterFactory.CreateOrc(type, position);
//스폰 위치 ...
}
}


이제 플레이어가 저 빈오브젝트에 충돌되는 순간 프리펩으로 넣은 Lizard와 Orc를 생성되게 만들것입니다! 충돌을 확인할수있는 TriggerZone이라는 스크립트를 생성해보겠습니다. (화면에 오브젝트는 그냥 빈오브젝트에 BoxColiider를 넣은 것입니다)
public class TriggerZone : MonoBehaviour
{
public enum ZoneType
{
LizardZone;
OrcZone;
}
public ZoneType zoneType;
public SpawnManager spawnManager;
public Transform player;
private void OnTriggerEnter(Coliider other)
{
if (other.CompareTag("Player"))
//플레이어 현재 위치에서 +- 5 랜덤 위치에 생성시키기
Vector3 spawnPosition = plyaer.position + new Vector3(Random.Range(-5, 5), 0, Random.Range(-5, 5));
switch (zoneType)
{
case ZoneType.LizardZone:
spawnManager.SpawnLizard(MonsterTyps.LizardType.Man, spawnPosition)
break;
case ZoneType.OrcZone:
spawnManager.SpawnOrc(MonsterTypes.OrcType.Basic, spawnPosition);
break;
}
}
}

첫번째로 만든 빈오브젝트(LizardZone)에 'TriggerZone' 스크립트를 부착하고 ZoneType을 LizardZone으로 설정했습니다. 두번째는 오크존으로 했구요. 나머지도 드래그해서 넣으면 됩니다.
이제 마지막으로 MonsterFactory 스크립트로가서 몬스터가 소환될 위치를 설정해주면 생성이 될것입니다.CreateLizard와 CreateOrc 메서드에서 위치를 매개변수로 받아보겠습니다.
[MonsterFactory.cs]
public Monster CreateLizard(MonsterTypes.LizardType type, Vector3 position)
{
GameObject prefab = null;
switch (type)
{
case MonsterTypes.LizardType.Man:
prefab = lizardMan;
break;
case MonsterTypes.LizardType.Woman:
prefab = lizardWoman;
break;
case MonsterTypes.LizardType.Boss:
prefab = lizardBoss;
break;
}
return CreateMonsterFromPrefab(prefab, position);
}
public Monster CreateOrc(MonsterTypes.OrcType type, Vector3 position)
{
GameObject prefab = null;
switch (type)
{
case MonsterTypes.OrcType.Basic:
prefab = orcBasic;
break;
case MonsterTypes.OrcType.Boss:
prefab = orcBoss;
break;
}
return CreateMonsterFromPrefab(prefab, position);
}
private Monster CreateMonsterFromPrefab(GameObject prefab, Vector3 position)
{
if (prefab)
{
GameObject instance = Instantiate(prefab, position, Quaternion.identity); // 위치를 지정해 생성합니다.
return instance.GetComponent<Monster>();
}
return null;
}
위처럼 MonsterFactory에도 생성 위치를 지정해준다면 Trigger에 플레이어가 닿을때마다 플레이어의 현재 위치에서 -3 ~ 3 으로 프리팹이 생성될 것입니다.

박스안으로 들어오자 LizardMan이 생성된 모습
팩토리 패턴 처음이었는데 소환까지 성공! 이제 여러 마리의 몬스터를 소환해보겠습니다.
저는 던전 형식이기 때문에, 스테이지마다 다른 수의 몬스터를 소환하게 만들 것입니다.
스테이지마다 소환될 몬스터의 수와 유형을 저장하기 위해 StageInfo 스크립트를 생성했습니다.
[System.Serializeble]
public class StageInfo
{
public int stageNumber; //스테이지 번호
public int lizardCount; //해당 스테이지에서 생성될 리자드의 수
public int orcCount; //해당 스테이지에서 생성될 오크의 수
}
직렬화까지 하기위해 Serializeble 해줬습니다.그리고 몬스터 소환 요청을 받을 SpawnManager에 배열을 생성해줍니다.
public class SpawnManager : MonoBehaviour
{
public MonsterFactory monsterFactory;
//스테이지 배열추가
public List<StageInfo> stages = new List<StageInfo>();
public int currentStage = 0;
//추가
}
public void SpawnLizard(MonsterTypes.LizardType type, Vector3 position)
{
monsterFactory.CreateLizard(type, position);
}
public void SpawnOrc(MonsterTypes.OrcType type, Vector3 position)
{
monsterFactory.CreateOrc(type, position);
}
}
배열을 추가했으면 TriggerZone.cs에서 스테이지에 따른 몬스터의 수만큼 몬스터를 소환할수있도록 하겠습니다.
[TriggerZone.cs]
private void OnTriggerEnter(Collider other)
{
if (other.CompareTag("Player"))
{
Vector3 spawnPosition = player.position + new Vector3(Random.Range(-5, 5), 0, Random.Range(-5, 5));
switch (zoneType)
{
case ZoneType.LizardZone:
//추가된 부분
for (int i = 0; i < spawnManager.stages[spawnManager.currentStage].lizardCount; i++)
{
spawnManager.SpawnLizard(MonsterTypes.LizardType.Man, spawnPosition); // 예시로 Man 유형의 리자드를 소환
}
break;
case ZoneType.OrcZone:
//추가된 부분
for (int i = 0; i < spawnManager.stages[spawnManager.currentStage].orcCount; i++)
{
spawnManager.SpawnOrc(MonsterTypes.OrcType.Basic, spawnPosition); // 예시로 Basic 유형의 오크를 소환
}
break;
}
}
}
}
case ZoneType.LizardZone: 이 부분에 for문을 추가해줬다. 이렇게 해주고, 인스펙터창에서 직렬화(Serializeble)로 생성된 배열에 StageNumber에 현재 스테이지를 넣어주고, 몬스터가 얼마나 생성되게할지는 내가 직접 넣어주면 된다.

본인은 1단계에 리자드 5마리, 오크는 2마리를 넣었다. currentStage는 0으로 설정했다.
currentStage 변수는 현재 스테이지를 나타내며, 이 변수의 값에 따라 TriggerZone 스크립트가 몬스터를 소환할때 해당 스테이지에 정의된 몬스터의 수만큼 생성되게 할것입니다.
저는 처음에 StageNumber와 CurrentStage가 현재 같은 스테이지이므로 숫자를 둘다 1로 했는데, 같은 번호를 하니
ArgumentOutOfRangeException: Index was out of range. Must be non-negative and less than the size of the collection.
Parameter name: index
System.Collections.Generic.List`1[T].get_Item (System.Int32 index) (at <605bf8b31fcb444b85176da963870aa7>:0)
TriggerZone.OnTriggerEnter (UnityEngine.Collider other) (at Assets/Scripts/Monster/TriggerZone.cs:26)
리스트 배열 범위 오류가 발생됐습니다. 이는 currentStage가 List<StageInfo> stages 리스트의 인덱스로 사용되고있으므로, currentStage는 stage보다 작아야했던 것입니다. 리스트 배열의 인덱스는 0부터 시작하는 것을 순간 잊었던 것입니다. 하하... 그래서 StageNumber는 1이지만, 이 스테이지에 접근하기 위한 리스트 인덱스 currentStage가 0으로 설정한 이유는 이것입니다.
그러나 또 하나의 문제가 발생했습니다.

분명 for문을 사용하여 원하는 수의 몬스터만큼 생성은 했는데, 겹쳐서 나오게 된것입니다.
이는 아까 테스트할때 한마리를 기준으로 했었기 때문에 신경 쓰지 않았던것인데, TriggrerZone.cs 루프안에서 spawnPosition 값을 한번만 계산 시켰었기 때문입니다. 각 몬스터들이 서로 다른 랜덤 위치에서 생성되게 하려면, Lizard나 Orc가 각 루프내에서 생성되기 전에 새로운 spawnnPosition 값을 계산해야하는 것입니다.

그러므로 충돌시에 위치 검사를 하던것을 switch 내부로 옮겨주었습니다.

이제 충돌시 플레이어 위치에서 랜덤으로 설정한 값에서 설정한 몬스터의 수만큼 생성이 됩니다.
네. 이제 플레이어가 TriggerZone에 충돌시 스테이지에 따라 입력한 Lizard와 Orc가 수만큼 생성되는 것을 보았습니다.
그렇다면, 이제 한 몬스터만 생성되는 것이 아니라 MonsterType.cs에서 나눈 LizardMan, LizardWoman, LizardBoss, OrcBasic, OrcBoss를 생성시킬것입니다.
저는 스테이지의 마지막 'TriggerZone'에서는 보스 몬스터를 등장시키고, 그 이전의 'TriggerZone'에서는 일반 몬스터만 등장 시킬 계획인 것입니다.
그렇다면 필요한것은 이 박스트리거에 충돌했을때 여기가 보스존이냐? 라고 묻는 Bool값이 필요하겠죠?
//보스 소환 구역인지 여부
public bool isBossZone;
private void OnTriggerEnter(Collider other)
{
if (other.CompareTag("Player"))
{
switch (zoneType)
{
case ZoneType.LizardZone:
if (isBossZone)
{
Vector3 spawnPosition = player.position + new Vector3(Random.Range(-10, 10), 0, Random.Range(-10, 10));
spawnManager.SpawnLizard(MonsterTypes.LizardType.Boss, spawnPosition);
}
else
{
for (int i = 0; i < spawnManager.stages[spawnManager.currentStage].lizardCount; i++)
{
Vector3 spawnPosition = player.position + new Vector3(Random.Range(-10, 10), 0, Random.Range(-10, 10));
int randomLizard = Random.Range(0, 2); // Lizard Man or Woman 소환
MonsterTypes.LizardType type = randomLizard == 0 ? MonsterTypes.LizardType.Man : MonsterTypes.LizardType.Woman;
spawnManager.SpawnLizard(type, spawnPosition);
}
}
break;
case ZoneType.OrcZone:
if (isBossZone)
{
Vector3 spawnPosition = player.position + new Vector3(Random.Range(-10, 10), 0, Random.Range(-10, 10));
spawnManager.SpawnOrc(MonsterTypes.OrcType.Boss, spawnPosition);
}
else
{
for (int i = 0; i < spawnManager.stages[spawnManager.currentStage].orcCount; i++)
{
Vector3 spawnPosition = player.position + new Vector3(Random.Range(-10, 10), 0, Random.Range(-10, 10));
spawnManager.SpawnOrc(MonsterTypes.OrcType.Basic, spawnPosition);
}
}
break;
}
}
}
}
Boss인지 확인하는 public bool isBossZone;
사실을 확인하는 if와 else
isBossZone이 true라면 보스 소환 spawnManger.SpawnLizard(MonsterTypes.LizardTYpe.Boss, spawnPosition)
false라면 일반 리자드를 소환
리자드의 종류는 2가지(man, woman) 이므로 '?' 를 사용하기로함.
int randomLizard = Random.Range(0, 2) //Lizard Man or Woman 0또는 1
MonsterTyeps.LizardType type = randomLizard == 0 ? MonsterTypes.LizardType.Man : MonsterTypes.LizardTypes.Woman;
spawnManager.SpawnLizard(type, spawnPosition);
오크는 보스 또는 일반 오크로 소환

스크립트를 수정해서 일반 리자드가 소환될 LizardZone에는 isBossZone을 체크하지 않았기에 남자와 여자 리자드가 랜덤하게 생성됩니다.
리자드 보스존 오브젝트에는 isBossZone을 체크해줬기 때문에 충돌시 보스 리자드가 생성됩니다.
근데 보스는 보통 웅장하게 등장하잖아요. 보스도 랜덤위치로 등장하게하는건 별로인 것 같아서. 오브젝트의 중심에서 소환하게 만들었습니다.
using UnityEngine;
public class TriggerZone : MonoBehaviour
{
public enum ZoneType
{
LizardZone,
OrcZone
}
public ZoneType zoneType;
public SpawnManager spawnManager;
public Transform player;
//보스 소환 구역인지 여부
public bool isBossZone;
private void OnTriggerEnter(Collider other)
{
if (other.CompareTag("Player"))
{
Vector3 bossSpawnPosition; //보스가 소환될 위치
switch (zoneType)
{
case ZoneType.LizardZone:
if (isBossZone)
{
bossSpawnPosition = transform.position; //보스의 위치는 이 위치
spawnManager.SpawnLizard(MonsterTypes.LizardType.Boss, bossSpawnPosition);
}
else
{
for (int i = 0; i < spawnManager.stages[spawnManager.currentStage].lizardCount; i++)
{
Vector3 spawnPosition = player.position + new Vector3(Random.Range(-6, 6), 0, Random.Range(-6, 6));
int randomLizard = Random.Range(0, 2); // Lizard Man or Woman 소환
MonsterTypes.LizardType type = randomLizard == 0 ? MonsterTypes.LizardType.Man : MonsterTypes.LizardType.Woman;
spawnManager.SpawnLizard(type, spawnPosition);
}
}
break;
case ZoneType.OrcZone:
if (isBossZone)
{
bossSpawnPosition = transform.position;
spawnManager.SpawnOrc(MonsterTypes.OrcType.Boss, bossSpawnPosition);
}
else
{
for (int i = 0; i < spawnManager.stages[spawnManager.currentStage].orcCount; i++)
{
Vector3 spawnPosition = player.position + new Vector3(Random.Range(-6, 6), 0, Random.Range(-6, 6));
spawnManager.SpawnOrc(MonsterTypes.OrcType.Basic, spawnPosition);
}
}
break;
}
}
}
}

네. 이제 보스가 정가운데에서 소환이 완료되었습니다!
마지막으로 트리거에 충돌할때마다 몬스터가 생성되는 것을 막기위해, if (other.CompareTag("Player") 맨 아래에ㅔ
Destroy(gameObject)를 적어주면 되겠습니다!
그렇다면 제가 여태까지 한 것을 정리해보겠습니다.
IMonsterFactory : 몬스터 팩토리의 기본 기능을 정의하는 인터페이스
MonsterFactory : 몬스터의 타입과 위치에따라 몬스터를 생성하는 몬스터 생성 책임을 가진 클래스
MonsterTypes : 게임에서 사용될 몬스터의 주요 유형과 세부 유형을 정의하는 클래스
SpawnManager : 특정 유형의 몬스터를 소환하는 요청을 MonsterFactory에 전달하고 현재 스테이지에 따라 몬스터를 소환할수 있게함.
TriggerZone : 플레이어가 특정 영역에 들어가 충돌되면 몬스터를 소환시킵니다. 이 Trigger는 'SpawnManger' 스크립트를 사용해 몬스터를 소환하고있습니다.
StageInfo : 스테이지별 몬스터의 수를 정의하는 클래스
//단일 책임 원칙을 따르려고 노력중인 나... 근데 과연 그렇게 작성 하고있는것일까?
팩토리패턴 처음이라 미숙한 코드입니다 하하..