유니티 숙련 팀 과제 - 서바이벌 (2)
1. 트러블슈팅
스크립터블 오브젝트 클래스를 한 스크립트에 여럿 넣으면 문제 발생
1) 문제 상황
제일 위의 ScriptableObject 클래스를 이용한 데이터만 제대로 출력
나머지 ScriptableObject 클래스를 이용한 데이터 파일들이 에디터 재실행 시 깨지는 현상 발생
몇번 코드를 수정하며 로딩하니 정상적으로 돌아오긴 했지만 구조상 문제가 생길 여지가 있음
2) 원인
C#에서는 문법상 괜찮으나, 유니티가 에디터에서 만든 .asset을 어떤 클래스로 연결할지 명확히 알지 못함
3) 해결
각 ScriptableObject 클래스를 개별 스크립트로 분리 및 파일명과 일치하게끔
2. 건물 건설 중 회전, 건설 취소 구현
유니티 인풋 시스템을 좀 더 잘 활용할 수 있게 되었습니다.
3. 건물 데이터 구조 개선
싱글톤, 제네릭 클래스, 스크립터블 오브젝트, 클래스 상속을 이용하여 자료 구조 개편 및 체계화
제네릭 클래스에 대한 개념은 수업에서 듣기는 했지만 이번에 처음 써보았습니다.
아주 간단히 말하자면 메서드의 매개변수와 유사합니다.
동일한 기능을 수행하는 클래스인데 몇개의 타입만 좀 아쉬울 때가 있습니다.
그 타입들을 미정으로 두고 필요에 따라 바꿔쓰고 싶을 때 사용한다고 생각하면 됩니다.
1) 제네릭 클래스를 이용한 싱글톤
using UnityEngine;
// 타입 T에 대한 싱글톤을 생성하는 제네릭 클래스
public class Singleton<T> : MonoBehaviour where T : MonoBehaviour
{
private static T instance;
public static T Instance
{
get
{
if (instance == null)
{
instance = FindAnyObjectByType(typeof(T)) as T;
if (instance == null)
{
SetupInstance();
}
DontDestroyOnLoad(instance.gameObject);
}
return instance;
}
}
private void Awake()
{
RemoveDuplicates();
}
// 중복 제거
void RemoveDuplicates()
{
if (instance == null)
{
instance = this as T;
DontDestroyOnLoad(gameObject);
}
else
Destroy(gameObject);
}
static void SetupInstance()
{
GameObject gameObj = new GameObject();
gameObj.name = typeof(T).Name;
instance = gameObj.AddComponent<T>();
}
}
2) 싱글톤 생성 + 스크립터블 오브젝트의 데이터를 접근 가능한 딕셔너리로 변환
인스펙터에서 스크립터블 오브젝트들을 등록해두면 (formList),
해당 데이터들을 딕셔너리로 만드는 스크립트입니다.
using System.Collections.Generic;
using UnityEngine;
using System;
public class FormManager : Singleton<FormManager>
{
[SerializeField] List<ScriptableObject> formList = new List<ScriptableObject>();
private Dictionary<Type, IForm> formDic = new Dictionary<Type, IForm>();
void Awake()
{
foreach (var formObj in formList)
{
if (formObj is IForm form)
{
form.CreateForm();
formDic[form.Type] = form;
}
}
}
// 등록된 데이터 테이블을 가져오는 메서드
public T GetForm<T>() where T : class
{
return formDic[typeof(T)] as T;
}
}
3) 제네릭 클래스+스크립터블 오브젝트+인터페이스를 활용한 건물 데이터의 기본형
using System;
using System.Collections.Generic;
using UnityEngine;
public interface IForm
{
public Type Type { get; }
public abstract void CreateForm();
}
public class BaseBuildingForm<T> : ScriptableObject, IForm where T : class
{
// 건물 데이터들을 넣어둘 데이터 리스트
[SerializeField] protected List<T> dataList = new List<T>();
// 해당 타입의 ID : 데이터 1:1 매칭이 되게 만든 딕셔너리
public Dictionary<int, T> DataDic { get; protected set; } = new Dictionary<int, T>();
// FormManager에서 딕셔너리의 타입을 정의할 때 필요
public Type Type { get; private set; }
public virtual void CreateForm() => Type = GetType();
// ID로부터 데이터를 찾아 반환
public virtual T GetDataByID(int id)
{
if (DataDic.TryGetValue(id, out T value))
return value;
Debug.LogError($"ID {id}를 찾을 수 없습니다.");
return null;
}
}
4) 기본형을 상속받아 건물 데이터 생성
공통된 부분을 묶어서 인스펙터에서 작업하기 좋도록 만들었습니다.
using System;
using UnityEngine;
[CreateAssetMenu(fileName = "WallOrSignalTower", menuName = "BuildingData/WallOrSignalTower")]
public class WallForm : BaseBuildingForm<BuildingData<BasicBuildingData>>
{
public override void CreateForm()
{
base.CreateForm();
foreach (var data in dataList)
{
DataDic[(int)data.ID] = data;
}
}
}
[Serializable]
public class BuildingData<T>
{
public BuildingIndex ID; // 건물 ID
public string name; // 건물 이름
public string description; // 건물 설명
public Sprite sprite; // 건물 이미지
public T[] buildingDatas; // 레벨 별 건물 데이터
}
// 레벨 별로 달라질 수 있는 데이터들의 공통된 부분만 묶은 기본형
[Serializable]
public class BasicBuildingData
{
public float time; // 건설 시간
public int hpMax; // 체력
public ResourceRequire[] resources; // 건설에 필요한 자원들 종류 및 갯수
}
// 건설에 필요한 한 종류의 자원과 양
[Serializable]
public class ResourceRequire
{
// 자원 종류
public ItemData resourceSort;
// 필요 갯수
public int amount;
}
동일한 역할의 오브젝트의 종류 : Data List의 수
레벨업 시 해당 건물 데이터 : Building Datas
5) 건물 오브젝트가 스텟 데이터로부터 스텟을 받아와서 사용
(1) 기본형
using UnityEngine;
public enum BuildingIndex
{
SignalTower,
Wall,
Turret,
SlowTurret,
Generator,
DroneManageOffice,
Refinery,
}
// 건물의 기본형
public abstract class BaseBuilding<T> : MonoBehaviour
{
protected T data;
[SerializeField] protected BuildingIndex buildingIndex;
protected int level = 0, // 건물 레벨
levelMax; // 해당 건물 종류의 최대 레벨
protected float hpCurrent, // 현재 체력
hpMax; // 현재 레벨의 최대 체력
private void Start()
{
Init();
}
// 건물 처음 생성 때 초기화가 필요한 것 모음
protected abstract void Init();
// 레벨업 후 스테이터스 적용
public virtual void BuildingLvUp()
{
if (level < levelMax)
{
// 이전 레벨의 최대 HP
float hpMaxBefore = hpMax;
// 레벨업
level++;
// 레벨업 스테이터스 반영
SetBuildingStatus();
// 최대 체력 증가 비율에 맞춰 현재 HP 변화
hpCurrent *= (hpMax / hpMaxBefore);
}
}
// 레벨에 맞는 스테이터스 부여
protected abstract void SetBuildingStatus();
// 대미지를 받아 체력 감소 및 파괴
public void Damage(float damage)
{
hpCurrent = Mathf.Clamp(hpCurrent - damage, 0, hpMax);
if (hpCurrent <= 0)
Destroy(gameObject);
}
// 수리
public void Fix(float amount) => hpCurrent = Mathf.Clamp(hpCurrent + amount, 0, hpMax);
}
(2) 기본형을 상속받아 사용
public class Wall : BaseBuilding<BuildingData<BasicBuildingData>>
{
protected override void Init()
{
// 데이터 받아오기
data = FormManager.Instance.GetForm<WallForm>().GetDataByID((int)buildingIndex);
// 최대 레벨
levelMax = data.buildingDatas.Length - 1;
SetBuildingStatus();
}
protected override void SetBuildingStatus()
{
// 해당 레벨에 맞는 데이터
var levelData = data.buildingDatas[level];
// 레벨업으로 인한 최대 HP 증가
hpMax = levelData.hpMax;
}
}
이렇게 배운 지식을 활용해 데이터 구조를 만들어 보았습니다.
제네릭 클래스에 대해 잘 몰랐으나 구조를 만들다 보니 답답한 상황이 발생하여 이를 해소하고자 공부하여 적용해 보았습니다.
오늘 구조를 만들면서 정말 중요한 건 다름이 아니라
공통적인 규칙/그 데이터만 갖는 특이한 부분을 잘 나누는 것
부터 시작된다고 생각이 들었습니다.
이번에 좀 헤매었으나 다음에는 해당 작업부터 시작하면 보다 수월하게 진행할 수 있을 것입니다.