캐릭터 스폰 구조 생각해 보기
캐릭터가 스폰되려언 어떤 과정을 거쳐야 하는지 생각해 보자.
단순하게 [버튼을 클릭하면 해당 캐릭터를 소환한다.]로 끝내는것이 아닌 실제 시스템의 동작을 생각해 봐야 한다.
- 유저가 스폰할 캐릭터를 캐릭터 선택 UI에서 고르고 UI를 클릭하여 소환요청을 보낸다.
- 스폰 시스템에서 어떤 캐릭터에 대한 소환 요청인지 확인한다.
- 재화 시스템에 해당 캐릭터의 소환에 드는 비용을 청구한다.
청구 결과 :
- 재화 부족 : 로그를 출력하고 클릭을 무시한다.
- 재화 충분 : 다음 단계로 넘어 간다. - 소환할 캐릭터의 기본형태를 가져와 캐릭터 데이터에 캐릭터 업그레이드 값을 적용한다.
- 유저의 스폰 지점에 캐릭터를 소환한다.
- 소환 된 캐릭터를 관리할 수 있도록 스폰 시스템의 소환된 캐릭터 리스트에 저장한다.
이 순서는 유저가 캐릭터를 소환하는 방식으로, AI가 소환하는 방식은 1번의 소환 요청이 UI를 클릭하지 않는것만 다르고, 나머지 부분은 같다.
위 순서를 보면 필요한 시스템에 대한 중요 키워드를 볼 수 있다.
자세히 한번 보기 위해 나열해 보자.
- 캐릭터 선택 UI : 소환 가능한 캐릭터들이 나열되어 있고 유저는 이곳에서 소환할 캐릭터를 고를 수 있다.
- 소환 요청 : 어떤 캐릭터를 어떤 상태로 소환 해야 하는지에 대한 정보를 담아야 한다.
- 스폰 시스템 : 소환 요청을 확인하고 캐릭터를 소환하며, 스폰된 캐릭터를 저장해 관리한다.
- 재화 시스템 : 게임내 재화(경험치, 골드 등)를 관리한다.
- 캐릭터의 기본 형태 : Prefab 으로 저장 된 캐릭터의 기본 형태이다.
- 캐릭터의 데이터 : 캐릭터가 가지는 공격력, HP, 방어력 등에 대한 고유 능력치다.
- 캐릭터 업그레이드 값 : 캐릭터를 강화시키는데 사용되는 능력치 레벨 (업그레이드 UI에서 유저가 선택)
- 스폰 지점 : 맵 위에 존재하는 캐릭터가 소환되어 나타나는 지점.
업그레이드 시스템은 없어도 캐릭터를 소환 할 수 있음으로, 나중에 작업하는것으로 하고...
캐릭터를 화면에 띄우기 위해 Prefab을 만들어 보자.
유니티의 Prefab(프리팹)은
게임오브젝트와 그 게임 오브젝트의 컴포넌트에 설정해 둔 내용을 함께 저장해서
재사용 가능한 템플릿(복사본) 에셋 파일을 만드는 기능이다.
프리팹 만드는 법
씬 위에 올려진 GameObject를 Project창으로 끌어낸다. 그러면 완성!
여기서 화면을 자세히 보면 Prefab을 저장한 폴더가 이름이 다른 것을 알 수 있는데, 이는 단순하게 파일 정리를 잘하기 위해서가 아니라 유니티에서 코드로 에셋들을 가져올 수 있게하는 Resources 폴더 기능을사용하기 위해서이다.
리소스 폴더 기능을 이용하면 인스펙터에 일일히 에셋을 제공하지 않고 파일이 위치한 주소만 알려 주면 되어서 매우 편하다.
다만 파일 위치를 바꾸는 일이 생기면 코드도 같이 수정해야해서 불편한데, 이 경우엔 Addressable System를 사용하면 되지만...
이건 따로 글을 쓸 생각이라 나중에 해보도록 하겠다.
코드로 작성해서 사용하는 방법은 간단하다.
string prefabFilePath = "Prefab/SomeFile";
GameObject prefab = Resources.Load<GameObject>(prefabFilePath);
Resources.Load("주소") 형식으로 그냥 써도 되고, 내가 자주 사용하는 스타일 처럼 주소를 별도의 변수에 저장해 두고 나중에 수정할 일이 생기면 해당 변수에 저장하는 값만 바꾸도록 해도 좋다. (그런데 보안을 생각하면 하지 말자... 메모리 변조공격에 매우 취약하다)
GameObject형식으로 로딩하고 싶다면 Generic형태의 함수도 마련되어 있으니 이를 이용하자.
물론 리소스 폴더 기능이 제공하는 함수들은 이게 다가 아니니... 공식 문서를 참조해 보자.
프리팹을 메모리에 로드해 두고 관리해 보자
CharacterDataContainer
using System.Collections.Generic;
using UnityEngine;
public class CharacterDataContainer
{
//Singleton pattern용 인스턴스
public static CharacterDataContainer Instance;
//프리팹의 저장 위치
public const string CharacterPrefabPath = "Prefab/Characters";
//프리팹을 캐싱하는 저장소
public Dictionary<string, GameObject> characterPrefabs;
private void Awake()
{
if (Instance is null) Instance = this;
else Debug.LogError("Singleton 인스턴스가 이미 존재합니다. 초기화에 실패했습니다.");
characterPrefabs = new Dictionary<string, GameObject>();
LoadCharacterPrefabs();
}
// 이 함수로 프리팹을 메모리에서 로드합니다.
private void LoadCharacterPrefabs()
{
List<GameObject> prefabList = new List<GameObject>();
prefabList.AddRange( Resources.LoadAll<GameObject>(CharacterPrefabPath) );
if (prefabList is null || prefabList.Count == 0)
throw new System.NullReferenceException($"Prefab이 {CharacterPrefabPath}에 없어요!");
foreach (GameObject prefab in prefabList)
{
characterPrefabs.Add(prefab.name, prefab);
}
}
// 캐싱된 프리팹 저장소를 검색하여 targetID에 해당하는 프리팹 오브젝트를 가져 옵니다.
public GameObject GetPrefab(string targetID)
{
if (characterPrefabs.Count == 0)
throw new System.NullReferenceException("캐릭터 프리팹이 로드되지 않았거나 없습니다.");
else if (characterPrefabs.TryGetValue(targetID, out GameObject temp))
return temp;
else
throw new System.ArgumentException($"Target Prefab ID ({targetID}) 가 데이터 리스트에 존재하지 않습니다.");
}
}
이 class는 앞으로 캐릭터의 초기 능력치와 프리팹 원본을 관리하게 될 것이다.
이 class가 없다면 필요할 때 마다 메모리에서 데이터와 프리팹을 일일이 불러 오고 저장하는 작업은 반복해야 한다.
그리고 애초에 Resources.Load() 의 성능은 무척 빠르다고는 할 수 없어서 같은 내용을 불러오는데 여러번 사용하는것은 피해야 한다. 이런 비효율을 개선하기 위해선 게임 시작 시 한번에 캐릭터 원본을 로딩해 두고 가져와서 사용하는 방식이 필요하다.
캐릭터의 데이터는 캐릭터의 이름을 기준으로 관리, 제공 하도록 구조를 생각했고, 이를 위해 Key - Value형식으로 자료를 저장하는 Dictionary를 사용했다. 딕셔너리는 Awake() 함수에서 초기화된다.
프리랩 파일을 앞에서 Resources/Prefab/Characters 폴더에 저장했음으로 Resources.Load() 함수에 주소를 전달하기 위한 변수는 Resources폴더 이름을 제외한 "Prefab/Characters"로 했다.
프리팹을 가져 오고 싶다면 CharacterDataContainer.Instance.GetPrefab() 을 이용하면 된다.
매개변수로 찾을 캐릭터의 이름(ID)을 인자로 받아서 프리팹들을 저장했었던 characterPrefabs 딕셔너리에 검색하여 값을 가져 오는것을 시도한다. ID를 키로서 값을 찾을 수 있었다면 그대로 반환, 그렇지 못하다면 에러를 띄우도록 했다.
캐릭터프리팹은 딕셔너리가 초기화 되고 나면 저장 프로세스(LoadCharacterPrefabs())를 거쳐 메모리에 오르게 된다.
Key는 프리팹 파일의 이름을 기준으로, Value는 프리팹 파일 그 자체를 기준으로 잡았다.
프리팹 데이터들이 중복되어 저장 되는 것을 피하고, 좀더 쉽게 사용하기 위해 싱글톤 패턴을 사용했다.
외부에서 이 class에 접근하고자 한다면 static 타입으로 선언 된 CharacterDataContainer.Instance 를 사용하면 된다.
싱글톤 패턴에 대해선 다른 글에 적어 두도록 하겠다.
캐릭터 소환 과정 설계
캐릭터의 스폰 과정은 다음과 같다.
경제 시스템은 아직 구현이 되지 않았기 때문에 지금 시점에서는 생략한다.
(넣는다면 2번과정에서 소환 가능여부 검사를, 3번과정에서 재화를 소모하도록 할 것이다.)
0. 유저 혹은 AI가 캐릭터를 소환할 시점을 판단하여 캐릭터를 소환하는 이벤트를 발생시킨다.
1. 이벤트는 이벤트발생자(유저 또는 AI)가 원하는 캐릭터에 대한 소환 요청을 생성해서 SpawnManager에 전달한다.
2. SpawnManager는 캐릭터가 알맞은 위치에서 소환 되도록 팀 정보를 분석하고 해당 팀의 스포너에 소환 요청을 전달한다.
3. 스포너는 요청을 분석하여 알맞은 캐릭터를 선택하고, 자신의 팀에 해당하는 업그레이드 정보를 스탯에 적용하여 소환한다.
4. 캐릭터가 소환부터 전투, 사망까지의 생애를 유지하도록 스포너는 SpawnManager에 소환한 캐릭터를 반환하여 관리한다.
즉 필요하게 되는 것은 소환 정보를 담을 구조체(SpawnRequest), 소환 관련 제어를 담당하는 클래스(SpawnManager), 실제 위치에 소환을시켜 주는 스포너(CharacterSpawner)가 필요하다.
스포너의 경우 [소환한다] 라는 개념은 같지만, 유저와 인공지능이 소환시 필요한 처리과정이 서로 다르므로 인터페이스를 활용한 추상화를 진행하여 AISpawner와 UserSpawner로 분리할 필요가 있다.
소환 이벤트를 일으키는 버튼이나 AI의 자동 스폰 시스템은 일단 나중에 만들어 두도록 하고 위의 3가지 먼저 보도록 하자.
SpawnRequest(소환요청)
public struct SpawnRequest
{
// 소환할 대상의 ID입니다.
public string TargetID { get => _targetID; }
// 소환할 대상의 소속 팀입니다.
public Team TeamInfo { get => _team; }
private string _targetID;
private Team _team;
public SpawnRequest(string spawnTargetID,Team spawnTargetTeam)
{
_targetID = spawnTargetID;
_team = spawnTargetTeam;
}
}
소환 후 캐릭터의 정보 초기화에 필요한 캐릭터의 소속 팀과 캐릭터 종류에 관한 ID를 갖고 있다.
객체 생성 후 실수로 생성된 객체 값을 변경하는 것을 원하지 않기 때문에 TargetID값과 TeamInfo 값을 get만 가능하도록 제한해 두었다. 값을 변경 하고자 하면 새롭게 객체를 생성해서(new 키워드를 사용) 값을 저장해야 한다.
TargetID는 앞에서 만들었던 CharacterDataContainer의 GetPrefab()에 적용하는 것을 의도하고 만들었다.
TargetID는 즉, 소환할 대상이 되는 캐릭터의 이름(고유ID)이다.
SpawnManager
using System.Collections.Generic;
using UnityEngine;
public class SpawnManager : MonoBehaviour
{
private const string UserSpawnerTag = "userSpawner";
private const string AISpawnerTag = "aiSpawner";
public static SpawnManager Instance;
private List<CharacterUnit> PlayerUnits;
private List<CharacterUnit> AIUnits;
private ICharacterSpawner userSpawner;
private ICharacterSpawner aiSpawner;
private void Awake()
{
if (Instance is null) Instance = this;
else
{
Debug.LogError("SpawnManager 가 두개 이상으로 싱글톤 패턴을 구현 할 수 없습니다.");
}
userSpawner = GameObject.FindGameObjectWithTag(UserSpawnerTag)?.GetComponent<ICharacterSpawner>();
aiSpawner = GameObject.FindGameObjectWithTag(AISpawnerTag)?.GetComponent<ICharacterSpawner>();
if (userSpawner is null || aiSpawner is null) throw new System.NullReferenceException("스포너 찾기 실패!");
PlayerUnits = new List<CharacterUnit>();
AIUnits = new List<CharacterUnit>();
}
/// <summary>
/// 스포너에 스폰 요청을 전달합니다.
/// </summary>
/// <param name="request">소환할 캐릭터에대한 요청 정보입니다.</param>
public void SendSpawnRequest(SpawnRequest request)
{
if (request.Equals(default(SpawnRequest))) Debug.LogWarning("spawn request가 초기화 되지 않은 것 같아요.");
// 소환 된 캐릭터를 관리하기 위해 소환요청에 해당하는 팀의 리스트에 추가합니다.
GetUnitList(request.TeamInfo).Add(SelectSpawner().Spawn(request));
ICharacterSpawner SelectSpawner()
{
switch (request.TeamInfo)
{
case Team.Red:
return aiSpawner;
case Team.Blue:
return userSpawner;
default:
throw new System.NotSupportedException($"팀 정보 {request.TeamInfo}에 대한 유닛리스트는 지원되지 않습니다.");
}
}
}
public List<CharacterUnit> GetUnitList(Team friendlyTeamData)
{
switch (friendlyTeamData)
{
case Team.Red:
return AIUnits;
case Team.Blue:
return PlayerUnits;
default:
throw new System.NotSupportedException($"팀 정보 {friendlyTeamData}에 대한 유닛리스트는 지원되지 않습니다.");
}
}
}
외부에서 스폰 매니저를 불러 올 일이 많으므로, 이 class또한 싱글톤 패턴을 이용하도록 구현했다.
스포너는 씬에 존재하는 게임 오브젝트의 컴포넌트로 캐릭터가 나타날 적당한 위치에 게임 시작 전 씬에 배치 후 사용한다. 게임이 시작되면 스폰 매니저는 자동으로 이 스포너들을 찾아 필요할 때마다 SendSpawnRequest() 에 의해 소환 요청을 전달하게 될 것이다.
이 함수에서 요청에서 제공된 팀 정보에 맞추어 알맞은 스포너로 소환 요청을 전달하고, 스포너가 반환한 캐릭터 정보를 저장한다.
캐릭터 스포너
캐릭터 스포너는 개릭터를 소환한다는 관점에서 기능은 같지만 소환을 할 시점을 정의하는데엔 유저와 AI가 작동하는 방식을 다르게 할 것이다. 때문에 인터페이스를 활용한 추상화가 필요하다고 판단 했다.
하지만 이번 포스트에서는 이 방식의 차이를 따로 두지 않고, 기본 class만으로도 작동할 수 있게 구현한다.
ICharacterSpawner
public interface ICharacterSpawner
{
/// <summary>
/// 소환 요청을 받아 캐릭터를 씬에 소환하고 소환된 캐릭터를 반환합니다.
/// </summary>
CharacterUnit Spawn(SpawnRequest);
}
CharacterSpawner
using UnityEngine;
public class CharacterSpawner : MonoBehavior, ICharacterSpawner
{
public Team TeamInfo { get => _teamInfo }
[SerializeField] private Team _teamInfo = Team.None;
public virtual CharacterUnit Spawn(SpawnRequest request)
{
GameObject prefab = CharacterDataContainer.Instance.GetPrefab(request.TeargetID);
if(prefab is null) Debug.LogError("프리팹이 비정상적으로 로드되어 가져 올 수 없었습니다.");
GameObject spawnedUnit = Instanciate(prefab, transform.position, Quternion.Identity);
CharacterUnit characterUnitComponent = spawnedUnit.GetComponent<CharacterUnit>();
if(characterUnitComponent is null)
Debug.LogError($"{spawnedUnit.name}의 캐릭터 유닛 컴포넌트를 찾을 수 없었습니다.");
characterUnitComponent.SetTeam(request.TeamInfo);
// 여기에 추가적으로 캐릭터의 공격력, 방어력의 스탯등을 적용하는 코드들을 작성한다.
// 업그레이드 시스템을 아직 안만들었음으로 일단은 팀 정보만 적용했다.
return characterUnitComponent;
}
}
캐릭터를 소환 시 캐릭터에 대한 초기화를 수행하는 구문에서 필요한 팀을 설정하는 코드도 추가 했다.
캐릭터의 스탯의 초기화도 이 코드 아래에서 수행하면 되지만, 스탯관련 구현은 다른 글에서 다룰 것이기 때문에 여기 까지만...!
using UnityEngine;
public enum LookDirection { Left, Right }
[RequireComponent(typeof(SpriteRenderer))]
public class CharacterUnit : Unit
{
public string CharacterName { get => _characterName; }
public float MoveSpeed { get => _moveSpeed; }
public float ATK { get => _atk; }
public float EnemyDetectionRadious { get => _enemyDetectRadius; }
public float NormalAttackCooldown { get => _normalAttackCooldown; }
public float NormalAttackCastingTime { get => _noramlAttackCastingTime; }
public float SpawnWaitingTime { get => _spawnWaitingTime; }
/// <summary>
/// 기본 시선 방향
/// </summary>
[SerializeField]
public LookDirection defaultLookDirection;
protected Transform AttackTargetPos
{
get
{
if (AttackTarget is null)
throw new System.NullReferenceException("공격 대상의 좌표를 가져 오려 했으나 현재 이 유닛은 공격대상이 비어 있습니다.");
return AttackTarget.transform;
}
}
protected Unit AttackTarget;
private string _characterName = "Unit";
private float _moveSpeed = 1f;
private float _atk = 1f;
private float _enemyDetectRadius = 1f;
private float _normalAttackCooldown = 1f;
private float _noramlAttackCastingTime = 0.25f;
private float _spawnWaitingTime = 1f;
public override void SetTeam(Team settingValue)
{
if (settingValue == Team.Blue && defaultLookDirection == LookDirection.Left
|| settingValue == Team.Red && defaultLookDirection == LookDirection.Right)
{
GetComponent<SpriteRenderer>().flipX = true;
}
base.SetTeam(settingValue); //부모클래스인 Unit class에선 팀 값을 저장하는 코드만 있다.
}
}
SetTeam() 의 경우 캐릭터 이미지 원본은 항상 한 방향으로 시선을 두고 있으므로 이미지를 좌우로 뒤집지 않으면 한 팀의 캐릭터는 뒤로 걷는 것 처럼 보인다. 따라서 Prefab에 CharacterUnit 컴포넌트를 추가 할 때 이미지 원본의 시선 방향을 지정할 수 있도록 했다. 그리고 팀을 설정하기 위해 들어온 인자를 검사하여 기본 시선방향에 따라 이미지의 좌우 방향을 변경하도록 만들었다.
컴포넌트 조립
코드가 잘 작동하는지 확인할 시간이다.
지금까지 만들었던 컴포넌트들을 씬에 비치함으로서 확인해보자.
SpawnUnitButton
using UnityEngine;
using UnityEngine.EventSystems;
public class SpawnUnitButton : MonoBehaviour, IPointerClickHandler
{
[SerializeField]
public string CharacterID;
public bool aiSpawnTest = false;
public void OnPointerClick(PointerEventData eventData)
{
SpawnRequest spawnRequest =
new SpawnRequest(CharacterID, aiSpawnTest ? Team.Red : Team.Blue);
SpawnManager.Instance.SendSpawnRequest(spawnRequest);
}
}
테스트를 하기 위해서 임시로 소환 버튼 역할을 하는 컴포넌트를 만들었다.
private void Update()
{
Debug.Log($"ai:{AIUnits.Count} / user: {PlayerUnits.Count}");
}
버튼이 정상 동작되고, SpawnManager에 소환된 캐릭터가 잘 추가되었는지 확인하기 위해 SpawnManager에 위 코드를 추가했다.
최종 테스트
로그가 의도한대로 잘 표시 되었고 원하는 위치에 캐릭터가 소환된 것을 볼 수 있다.
'개발 > 게임 개발' 카테고리의 다른 글
간단한 유니티 2D 타워 디펜스 게임 만들기 5편 - 캐릭터 이동 (0) | 2022.10.03 |
---|---|
간단한 유니티 2D 타워 디펜스 게임 만들기 3편 - 캐릭터 유닛의 행동에 관한 고려 (0) | 2022.09.13 |
간단한 유니티 2D 타워 디펜스 게임 만들기 2편 - 기초 쌓기 (0) | 2022.09.09 |
간단한 유니티 2D 타워 디펜스 게임 만들기 1편 - 개요 (0) | 2022.08.21 |