스파르타코딩클럽_Unity개발과정

유니티 숙련 팀 과제 - 서바이벌 (2)

ybbro 2025. 5. 27. 23:51

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;
    }
}

 

 

이렇게 배운 지식을 활용해 데이터 구조를 만들어 보았습니다.

제네릭 클래스에 대해 잘 몰랐으나 구조를 만들다 보니 답답한 상황이 발생하여 이를 해소하고자 공부하여 적용해 보았습니다.

 

오늘 구조를 만들면서 정말 중요한 건 다름이 아니라

공통적인 규칙/그 데이터만 갖는 특이한 부분을 잘 나누는 것

부터 시작된다고 생각이 들었습니다.

 

이번에 좀 헤매었으나 다음에는 해당 작업부터 시작하면 보다 수월하게 진행할 수 있을 것입니다.