Spine 사용

 Spine 패키지를 사용한 이유?

모바일(안드로이드) 플랫폼의 방치형 게임으로 개발을 하기에 Player 캐릭터의 스킨을 제작하거나 무기를 장착하는데에 일반 Sprite를 사용하는것보다 Spine을 사용하는것이 더욱 좋다고 생각하였기에 Player 객체에만 Spine을 적용시키기로 하였다. 

 

  • 메모리 효율성: SpriteSheet 방식보다 메모리를 절약하며 고품질 애니메이션 제공.
  • 동적 애니메이션 조합: 다양한 부위(스킨, 무기 등)를 프로그래밍적으로 조합 가능.
  • 애니메이션 재사용성: 동일한 애니메이션을 다양한 스킨이나 캐릭터에 적용 가능.

 

현재 우리 프로젝트에는 아트 직업군이 없어서 Spine의 효율을 100% 끌어올리지 못하지만 , 아트 직업군과의 협업에 분명 도움이 될것이다 판단하고 진행하였습니다. 

 

Spine SetUp

 

Spine은 각종 플랫폼에서 사용 할 수 있게 지원이 잘 되어있기에 , 세팅하는법은 어렵지 않았다.

https://ko.esotericsoftware.com/spine-runtimes

 

Spine: Runtimes

Spine 런타임은 Spine에서와 마찬가지로 게임 툴킷이 게임에서 애니메이션을 로드하고 렌더링할 수 있게 해주는 라이브러리입니다. 당사의 API는 뼈대, 부착물, 스킨 및 기타 애니메이션 데이터에

ko.esotericsoftware.com

해당 Spint 홈페이지의 GitHub에 접근하여 직접 패키지를 다운 받아도 되고

 

https://ko.esotericsoftware.com/spine-unity-download/

 

spine-unity Download

Getting Started Documentation spine-unity unitypackage spine-unity 4.2 (updated 2024-11-28, changelog) Compatible with Spine 4.2.00 or newer and Unity 2017.1-2023.1. Add package from git URL: (URLs for spine-csharp, spine-unity and examples) https://github

ko.esotericsoftware.com

해당 홈페이지에서 버전에 맞게 Import 패키지를 받아 적용시켜도 된다.

패키지를 Import하면 예제 레퍼런스를 주기에 잘 활용하는것이 좋다.

 

.Spine Asset의 구조

Spine에셋을 사용하면 해당 구조들로 구성되어야 사용가능하며 Atlas를 이용하여 최적화를 하는 방식인것같다.

Unity에서도 Sprite Atlas를 제공하는것으로 알고 있다.

 

하이어라키에서 우클릭 후 Spine 메뉴가 생겨있다.

SkeletonAnimation을 생성하면 AnimatorController처럼 사용할수 있게된다.

Spine이 컴포넌트가 적용되어있는 Insperctor의 구조

 

해당 Inspector에 Spine SkeletonData를 적용하고 코드로 호출하여 사용하면된다. 

 

PlayerAnimaitonController.cs

using System;
using Spine;
using Spine.Unity;
using UnityEngine;


public class PlayerAnimationController : MonoBehaviour
{
    #region Inspector
    // [SpineAnimation] attribute allows an Inspector dropdown of Spine animation names coming form SkeletonAnimation.
    [SpineAnimation]
    public string idleAnimationName;

    [SpineAnimation]
    public string runAnimationName;

    [SpineAnimation]
    public string MeleeAttackAnimationName;

    [SpineAnimation]
    public string ShotAttackAnimationName;

    [SpineAnimation]
    public string VictoryAnimationName;

    //[Header("Transitions")]
    //[SpineAnimation]
    //public string idleTurnAnimationName;

    //[SpineAnimation]
    //public string runToIdleAnimationName;

    //public float runWalkDuration = 1.5f;
    #endregion

    private SkeletonAnimation skeletonAnimation;
    public Spine.AnimationState spineAnimationState;
    public Spine.Skeleton skeleton;
    public void Initialize()
    {
        //spineanimation 초기화
        skeletonAnimation = GetComponent<SkeletonAnimation>();
        spineAnimationState = skeletonAnimation.AnimationState;
        skeleton = skeletonAnimation.Skeleton;
    }
    
}

해당 스크립트에서는 SkeletonAnimation 컴포넌트를 초기화하는 클래스 코드영역이다.

 

PlayerIdelState.cs 일부분

    public override void Enter()
    {
        string animName = stateMachine._Player.PlayerAnimationController.idleAnimationName;
        stateMachine._Player.PlayerAnimationController.spineAnimationState.SetAnimation(0, animName, true);
    }

애니메이션 동작법은 Unity의 AnimatorController와 유사하다. 해당 애니메이션 Index , 애니메이션이름,loop 설정을 하면된다.

Spint 패키지에서 제공하는 SetAnimation 메서드 인자값

 

PlayerBaseState.cs의 일부분

 public void FlipCharacter(bool isFacingRight)
 {
     // true이면 ScaleX를 1로 설정, false이면 -1로 설정
     stateMachine._Player.PlayerAnimationController.skeleton.ScaleX = isFacingRight ? 1f : -1f;
 }

 

SpriteRender의 Flipx,Flipy 기능은 해당 메서드를 사용하여서 캐릭터를 뒤집어 사용 할 수 있다. 

 

아직 Spine의 기능을 100프로 활용하지 못하고있다.

내일은 게임 플레이어 로직을 완성 시키고 무기 장착및 스킨 변경 구현을 해야 될것 같다.

 

원거리 공격을 구현하여

 

현재 플레이어 로직은 범위 안에 제일 가까운 Enemy를 탐색하여 이동후 공격범위 안에 있는경우 공격을 실행하는 방식을 사용하고있다. 

 

또한 장착되는 Soul에 의해 근접 기본 공격 , 원거리 기본 공격을 구현해야된다. 

런타임시 게임 저장 데이터 불러오기 및 저장 기능

 

JsonController.cs

using UnityEngine;
using System.IO;
using System.Text;

public class JsonController
{
    public UserDB LoadUserData(string path)
    {
        if (File.Exists(Application.persistentDataPath + path))
        {
            string json = File.ReadAllText(Application.persistentDataPath + path);
            Debug.Log("UserData loded to: " + path);
            return JsonUtility.FromJson<UserDB>(json);
        }

        Debug.LogWarning("UserData file not found!");
        return null;
    }

    public void SaveUserData(UserDB saveData, string path)
    {
        string jsonUserData = JsonUtility.ToJson(saveData, true);
        FileStream fileStream = new FileStream(Application.persistentDataPath + path, FileMode.Create);
        byte[] data = Encoding.UTF8.GetBytes(jsonUserData);
        fileStream.Write(data, 0, data.Length);
        fileStream.Close();

        //File.WriteAllText(path, jsonUserData);
        Debug.Log("UserData saved to: " + Application.persistentDataPath + path); 
    }
}

 

Application.persistentDataPath 메서드를 사용해야 PC,안드로이드에 빌드 했을대도 해당 경로로 이동해 데이터를 읽기/쓰기가 가능하기 때문에 해당 기능을 사용하였고 추가로 + path 기능은 "/userdata.json" 접근할 파일 경로를 적어주어 사용하면 된다. 

 

Player.cs

  public void Initialize()
  {
      //Model(UserData) 세팅
      if (DataManager.Instance.LoadUserData() == null)
      {
          //새로하기 , 기본 능력치를 제공 
          userData = new UserData(DataManager.Instance.UserDB.GetByKey(TestID));
          DataManager.Instance.SaveUserData(userData);
      }
      else
      {
          //이어하기
          userData = new UserData(DataManager.Instance.LoadUserData());
      }
  }

 

Player는 DataManager의 접근해서 저장데이터가 있을경우 해당 json을 불러 데이터를 파싱하여 userData로 변환하여 사용하고 없는경우 UserDB에 있는 기본 User값을 호출하여 새로 생성하게 설계하였다. 

 

 

Player,Enemy FSM 구조 설계

 

Player와 Enemy가 공통으로 사용하게 될 구조가 필요하기 때문에 

팀원과 회의하여 BaseCharacter 및 BaseState ,Istate를 만들어서 사용하였고 

각 State에서 사용하는 객체를 역참조하여 접근 할 수 있게 사용 하였다.

 

 BaseCharacter.cs 

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public abstract class BaseCharacter : MonoBehaviour, ITakeDamageAble
{
    protected StatHandler statHandler; //스텟 관리 클래스
	//자식클래스들에 FSM 구조를 구현하였다.
    
    public virtual void TakeDamage(float damage)
    {
    }

    public virtual void TakeKnockBack(Vector3 direction, float force)
    {
    }
}

 

Player와 Enemy의 부모클래스로 추상클래스로 사용하여 구현 하였고 , Interface ITakeDamageAble을 사용하여 데미지 적용  기능은 필수이기에 Interface로 하였다.

 

Player와 Enemy는 퍼사드 + MVC 패턴을 사용하여 구현하기로 회의하였고

Mdoel : Player 의 UserData , Enemy의 EnemyDB 데이터

View : Player,Enemy의 AnimationController Class

Controller : Player,Enemy FSM 로직을 구현 및 기타 로직 설계

 

로 구조를 잡고 진행 하였다. 

 

Istate.cs & BaseStateMachine.cs

public interface IState
{

    void Enter();
    void Exit();
    void Update();

    void FixedUpdate();

}

public class BaseStateMachine
{
    protected IState currentState;

    public void ChangeState(IState newState)
    {
        currentState?.Exit();
        currentState = newState;
        currentState?.Enter();
    }

    public void Update()
    {
        currentState?.Update();
    }

    public void FixedUpdateState()
    {
        currentState?.FixedUpdate();
    }
}

 

FSM 구조는 해당 부모 클래스를 상속해서 다형성을 확보했습니다. 

 

https://01149.tistory.com/139

 

TIL : 2024-11-27(수) :: 최종프로젝트 개발 일지(3) - 외부데이터 사용에 대한 고찰(2)

최종프로젝트 외부데이터를 어떻게 확장성을 확보할수 있을까? 하며 생각을 하며 튜터님께 질문을 했을때"고정 데이터인데 이걸 확장성을 볼 필요가있나? " , " 고정 데이터는 해당 class의 데이

01149.tistory.com

해당 글하고 연관이 되어있다.

어제 분명 CSV를 데이터 읽는데만 사용하고 Json을 이용하여 UserData를 저장/불러오기 목적으로 구조를 잡았으나

 

튜터님들의 의견과 다시 생각해보니 CSV를 쓰는 이유가 없어진것 같았다.

 

*CSV데이터를 Json으로 다 변경한 이유

1.CSV 나 Json이나 파일 쓰기/읽기는 둘다 Excel또는 txt로 관리하기에 상관이 없었다.

2.CSV는 기획자가 데이터를 바로 수정하고 확인 하기에 유용하기는 하나 결국에는 런타임시에 데이터가 적용된것을 확인 할 수 있기에 기획자와 소통목적으로 CSV를 사용한다고 말해도 애매하였다.

3.서버와 통신을 하게될 경우 Json 구조를 사용 하기에 게임이 라이브서비스로 인 경우 Json이 더욱 유리하였다.

4.해당 이유는 팀원과 작업하면서 느낀건데 CSV로 Enemy ,Item을 관리하다가 하나의 Column(컬럼)에 여러개의 데이터가 들어가게 되는 경우 관리가 엄청 어려워지고(Enemy의 DropItem , Item의 가챠확률 등등), 데이터 양이 늘어날수록 CSV는 Json처럼 계층구조로 되어있지 않기에 가독성 및 유지보수에 좋지 않다고 느껴졌다.

 

이러한 이유로 인해서 Json을 사용하게 되었고 , 그럼 Excel을 Json으로 대체하는 방법이 필요한데

좋은 Tool 이 있어서 이것을 사용하여 진행 하였다.

https://github.com/npckero/ExcelToJsonWizard

 

GitHub - npckero/ExcelToJsonWizard: A tool for converting Excel data to JSON files and generating C# loader classes for use in U

A tool for converting Excel data to JSON files and generating C# loader classes for use in Unity. - npckero/ExcelToJsonWizard

github.com

 

Excel -> Json으로 변경을 해주는 Tool 프로그램이다.

 

해당 프로그램을 사용하면 Excel 데이터를 Json으로 변경해주고 

 

Excel 헤더 데이터를 참고하여 만들어진 Class 파싱 데이터 구조
해당 Json을 읽어주는 Loader Class를 만들어준다.

 

해당 Tool을 이용해서 DataManager에서 세팅을 하고, 팀원들이 데이터가 필요할떄 꺼내 쓸수 있게 하였다.

 

DataManager.cs

public class DataManager : SingletonDDOL<DataManager>
{
    private readonly string jsonItemDBPath = "JSON/ItemDB";
    private readonly string jsonSellItemDBPath = "JSON/SellItemDB";
    private readonly string jsonEnemyDBPath = "JSON/EnemyDB";
    private readonly string jsonStageDBPath = "JSON/StageDB";
    private readonly string jsonSoulDBPath = "JSON/SoulDB";
    private readonly string jsonSkillDBPath = "JSON/SkillDB";

    private readonly string jsonUserDataPath = Application.dataPath + "/userdata.json";

    private StringBuilder strBuilder = new StringBuilder();
    public JsonController JsonController = new JsonController();

    private EnemyDBLoader enemyDB;
    private ItemDBLoader itemDB;
    private SellItemDBLoader sellItemDB;
    private StageDBLoader stageDB;
    private SoulDBLoader soulDB;
    private SkillDBLoader skillDB;

    public EnemyDBLoader EnemyDB { get => enemyDB; }
    public ItemDBLoader ItemDB { get => itemDB; }
    public SellItemDBLoader SellItemDB { get => sellItemDB; }
    public StageDBLoader StageDB { get => stageDB; }
    public SoulDBLoader SoulDB { get => soulDB; }
    public SkillDBLoader SkillDB { get => skillDB; }

    private Inventory inventory = new Inventory();
    private UserData userData = new UserData();

    public UserData UserData { get => userData;}

    public static event Action<UserData> OnEventSaveUserData;
    public static event Action OnEventLoadUserData;

    protected override void Awake()
    {
        base.Awake();

        OnEventSaveUserData += SaveUserData;
        OnEventLoadUserData += LoadUserData;
    }

    private void Start()
    {
        enemyDB = new EnemyDBLoader(jsonEnemyDBPath);
        itemDB = new ItemDBLoader(jsonItemDBPath);
        sellItemDB = new SellItemDBLoader(jsonSellItemDBPath);
        stageDB = new StageDBLoader(jsonStageDBPath);
        soulDB = new SoulDBLoader(jsonSoulDBPath);
        skillDB = new SkillDBLoader(jsonSkillDBPath);

        inventory.Items = new List<ItemDB>();
        inventory.Items.Add(itemDB.GetByKey(1000));
        inventory.Items.Add(itemDB.GetByKey(2000));

        userData.UserID = 12345;
        userData.Nickname = "지존 감자탕";
        userData.Status = new Stat();
        userData.Status.level = 1;
        userData.Gold = 999999;
        userData.Diamonds = 9999;
        userData.PlayTimeInSeconds = 72000;
        //userData.Inventory = inventory;


    }

    private void SaveUserData(UserData userData) // 유저 데이터 세이브
    {
        JsonController.SaveUserData(userData, jsonUserDataPath);
    }

    private void LoadUserData() // 유저 데이터 로드
    {
        userData = JsonController.LoadUserData(jsonUserDataPath);
    }

    //Debug
    private void Update()
    {
        if (Input.GetKeyDown(KeyCode.D)) // 데이터 갱신
        {
            userData.Nickname += "_WA";
        }
        else if(Input.GetKeyDown(KeyCode.S))
        {
            OnEventSaveUserData?.Invoke(userData);
        }
        else if( Input.GetKeyDown(KeyCode.L))
        {
            OnEventLoadUserData?.Invoke();
        }
    }

}

 

코드에서 Test 목적으로 작성해놓은게 있지만, Json 데이터를 가져와 컨테이너에 보관하는 매니저라고 보면된다. 

최종프로젝트 외부데이터를 어떻게 확장성을 확보할수 있을까? 하며 생각을 하며 튜터님께 질문을 했을때

"고정 데이터인데 이걸 확장성을 볼 필요가있나? " , " 고정 데이터는 해당 class의 데이터의 내부에서 파싱하면된다. "

라고 조언을 주셔서 , 우선 고정데이터를 파싱하는 코딩하여 작업을 하였고 , 나중에 내부 클래스에서 데이터를 파싱하는것을 리팩토링하기로 하였다.

 

CSVController.cs : CSV 데이터를 로드하는 컨트롤러 클래스

using UnityEngine;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using System.Linq;

public class CSVController
{
    static string SPLIT_RE = @",(?=(?:[^""]*""[^""]*"")*(?![^""]*""))"; //한 행에 대한 데이터 분리를 위한 세퍼레이트(분리자)
    static string LINE_SPLIT_RE = @"\r\n|\n\r|\n|\r";//가져온 CSV데이터를 엔터단위로 끊기위한 분리자
    static char[] TRIM_CHARS = { '\"' }; //

    public Dictionary<int, ItemData> ItemCSVRead(string file)
    {
        Dictionary<int, ItemData> list = new Dictionary<int, ItemData>();
        TextAsset data = Resources.Load(file) as TextAsset;

        //가져온 텍스트 덩어리를 해당 분리자를 통해 분리한다.
        string[] lines = Regex.Split(data.text, LINE_SPLIT_RE);

        //해당 CSV 데이터에 헤더(카테고리) + 해당 자료형만 입력되있는 상태이므로 List 반환
        if (lines.Length <= 2) return list;


        string[] header = Regex.Split(lines[0], SPLIT_RE);  //해당 CSV 데이터에 헤더(카테고리)입력 -> Dictonary의 키값으로 사용
        string[] typeHeader = Regex.Split(lines[1], SPLIT_RE);   //해당 CSV 데이터에 자료형 입력 -> 해당 자료의 자료형 선정시 사용됨
        for (int i = 2; i < lines.Length; i++)
        {
            string[] values = Regex.Split(lines[i], SPLIT_RE);
            if (values.Length == 0 || values[0] == "") continue; //정보가 없으면 다음 행으로 이동

            ItemData item = new ItemData();
            item.ID = (int)DataTypeCheck(typeHeader[0], values[0]);
            item.Name = (string)DataTypeCheck(typeHeader[1], values[1]);
            item.Type = (string)DataTypeCheck(typeHeader[2], values[2]);
            item.Rairty = (string)DataTypeCheck(typeHeader[3], values[3]);
            item.Descripton = (string)DataTypeCheck(typeHeader[4], values[4]);
            item.Attack = (float)DataTypeCheck(typeHeader[5], values[5]); ;
            item.AttackPercent = (bool)DataTypeCheck(typeHeader[6], values[6]); ;
            item.Defence = (float)DataTypeCheck(typeHeader[7], values[7]);
            item.DefencePercent = (bool)DataTypeCheck(typeHeader[8], values[8]);
            item.Health = (float)DataTypeCheck(typeHeader[9], values[9]);
            item.HealthPercent = (bool)DataTypeCheck(typeHeader[10], values[10]);
            item.CritChance = (float)DataTypeCheck(typeHeader[11], values[11]);
            item.CritChancePercent = (bool)DataTypeCheck(typeHeader[12], values[12]);
            item.CritDamage = (float)DataTypeCheck(typeHeader[13], values[13]);
            item.CritDamagePercent = (bool)DataTypeCheck(typeHeader[14], values[14]);
            item.Effect = (string)DataTypeCheck(typeHeader[15], values[15]);
            item.Cost = (int)DataTypeCheck(typeHeader[16], values[16]);
            item.StackMaxCount = (int)DataTypeCheck(typeHeader[17], values[17]);
            list[item.ID] = item;

        }
        return list;
    }
    public Dictionary<int, EnemyData> EnemyCSVRead(string file)
    {
        Dictionary<int, EnemyData> list = new Dictionary<int, EnemyData>();
        TextAsset data = Resources.Load(file) as TextAsset;

        //가져온 텍스트 덩어리를 해당 분리자를 통해 분리한다.
        string[] lines = Regex.Split(data.text, LINE_SPLIT_RE);

        //해당 CSV 데이터에 헤더(카테고리) + 해당 자료형만 입력되있는 상태이므로 List 반환
        if (lines.Length <= 2) return list;

        string[] header = Regex.Split(lines[0], SPLIT_RE);  //해당 CSV 데이터에 헤더(카테고리)입력 -> Dictonary의 키값으로 사용
        string[] typeHeader = Regex.Split(lines[1], SPLIT_RE);   //해당 CSV 데이터에 자료형 입력 -> 해당 자료의 자료형 선정시 사용됨
        for (int i = 2; i < lines.Length; i++)
        {
            string[] values = Regex.Split(lines[i], SPLIT_RE);
            if (values.Length == 0 || values[0] == "") continue; //정보가 없으면 다음 행으로 이동

            EnemyData enemy = new EnemyData();
            enemy.ID = (int)DataTypeCheck(typeHeader[0], values[0]);
            enemy.Name = (string)DataTypeCheck(typeHeader[1], values[1]);
            enemy.Descripton = (string)DataTypeCheck(typeHeader[2], values[2]);
            enemy.DropItemID = (List<int>)DataTypeCheck(typeHeader[3], values[3]);
            enemy.DropGold = (int)DataTypeCheck(typeHeader[4], values[4]);
            enemy.Attack = (float)DataTypeCheck(typeHeader[5], values[5]); ;
            enemy.AttackSpeed = (float)DataTypeCheck(typeHeader[6], values[6]);
            enemy.Defence = (float)DataTypeCheck(typeHeader[7], values[7]);
            enemy.Health = (float)DataTypeCheck(typeHeader[8], values[8]);
            enemy.Health = (float)DataTypeCheck(typeHeader[9], values[9]);
            enemy.CritChance = (float)DataTypeCheck(typeHeader[10], values[10]);
            enemy.CritDamage = (float)DataTypeCheck(typeHeader[11], values[11]);
            list[enemy.ID] = enemy;

        }
        return list;
    }
    public Dictionary<int, StagaData> StageCSVRead(string file)
    {
        Dictionary<int, StagaData> list = new Dictionary<int, StagaData>();
        TextAsset data = Resources.Load(file) as TextAsset;

        //가져온 텍스트 덩어리를 해당 분리자를 통해 분리한다.
        string[] lines = Regex.Split(data.text, LINE_SPLIT_RE);

        //해당 CSV 데이터에 헤더(카테고리) + 해당 자료형만 입력되있는 상태이므로 List 반환
        if (lines.Length <= 2) return list;

        string[] header = Regex.Split(lines[0], SPLIT_RE);  //해당 CSV 데이터에 헤더(카테고리)입력 -> Dictonary의 키값으로 사용
        string[] typeHeader = Regex.Split(lines[1], SPLIT_RE);   //해당 CSV 데이터에 자료형 입력 -> 해당 자료의 자료형 선정시 사용됨
        for (int i = 2; i < lines.Length; i++)
        {
            string[] values = Regex.Split(lines[i], SPLIT_RE);
            if (values.Length == 0 || values[0] == "") continue; //정보가 없으면 다음 행으로 이동

            StagaData stage = new StagaData();
            stage.ID = (int)DataTypeCheck(typeHeader[0], values[0]);
            stage.ChapterNum = (int)DataTypeCheck(typeHeader[1], values[1]);
            stage.StageNum = (int)DataTypeCheck(typeHeader[2], values[2]);
            stage.CurStageModifier = (float)DataTypeCheck(typeHeader[3], values[3]);
            stage.StageName = (string)DataTypeCheck(typeHeader[4], values[4]);
            stage.SlayEnemyCount = (int)DataTypeCheck(typeHeader[5], values[5]); ;
            stage.SummonEnemyIDList = (List<int>)DataTypeCheck(typeHeader[6], values[6]);
            list[stage.ID] = stage;

        }
        return list;
    }
    private object DataTypeCheck(string type, string value)
    {
        switch (type)
        {
            case "int":
                return int.Parse(value);  //int
            case "float":
                return float.Parse(value); //float
            case "double":
                return double.Parse(value);  //double
            case "bool":
                return bool.Parse(value);  //bool
            case "string":
                return value;  //string
            //default:
            //    throw new Exception($"지원하지 않는 자료형 타입입니다.: {type}");
        }

        if (type.StartsWith("List<"))
        {
            var itemType = type.Substring(5, type.Length - 6);

            //CSV 데이터 셀 서식이 Text이기에 \" 철자를 제거해야된다. 
            value = value.TrimStart(TRIM_CHARS).TrimEnd(TRIM_CHARS).Replace("\\", "");

            switch (itemType)
            {
                case "int":
                    return value.Split(',').Select(int.Parse).ToList();  //List<int>
                case "float":
                    return value.Split(',').Select(float.Parse).ToList(); //List<float>
                case "double":
                    return value.Split(',').Select(double.Parse).ToList(); //List<double>
                case "bool":
                    return value.Split(',').Select(bool.Parse).ToList();  //List<bool>
                case "string":
                    return value.Split(',').Select(v => v.Trim()).ToList();  //List<string>

            }
        }


        return null;
    }

}

 

CSV가 아닌 Json으로 UserData관리를 하기로 하였다.

왜 CSV를 외부데이터로 사용하고있고 쓰기도 할 수 있는데 어째서 Json을 골랐는가?

Json은 데이터가 미리 포멧팅(구조)가 잡혀있고 계층성이기에 직렬화/역직렬화에 유리하기에 선택을 하였다.

UserData에는 사용자 Class의 Inventory,Skill 등등을 쉽고 간단하게 사용할 수 있기에 Json을 사용하였다.

 

JsonController : UserData를 저장/불러오기를 담당하는 컨트롤러

using UnityEngine;
using System.IO;

public class JsonController
{
    public UserData LoadUserData(string path)
    {
        if (File.Exists(path))
        {
            string json = File.ReadAllText(path);
            Debug.Log("UserData loded to: " + path);
            return JsonUtility.FromJson<UserData>(json);
        }
        Debug.LogWarning("UserData file not found!");
        return null;
    }

    public void SaveUserData(UserData userData, string path)
    {
        string jsonUserData = JsonUtility.ToJson(userData, true);
        File.WriteAllText(path, jsonUserData);
        Debug.Log("UserData saved to: " + path);
    }
}

 

 

우리는 외부 데이터 직렬화/역직렬화를 이용하여 

 

CSV 사용 : Item , Enemy => 외부 데이터가 변경되지 않고 호출하여 오브젝트 세팅하는 목적으로 사용하기로함

Json 사용 : UserData(Player의 스탯, 클리어한 스테이지, 보유하고있는 아이템 등등..) 을 저장/불러오기때 사용

 

UserData에 포함되어있는 데이터(Inventory,Skill 등등) 계층구조로 잡혀있다보니 CSV로 접근하는것보다 Json로 데이터를 관리하는것이 좋다고 판단하였다.

 

현재 CSV 데이터 읽기 기능을 구현을 한 뒤 리팩토링 하다보니 확장성이 너무 떨어져서 다시 작업을 진행하고있다.

 

ItemDB를 CSV로 관리할 수있게 데이터 테이블작성을 한 이미지

8주간의 게임개발 최종 프로젝트가 시작되었다.

최종 프로젝트 시작전 가이드를 잡아주셨다.

 

우리 4인은 서브컬쳐 방치형 게임을 개발하기로 하였다.

메인 레퍼런스로는 <달토끼 키우기>로 선택하였고

해당 게임에서 <원신>처럼 캐릭터를 전환하여 스킬을 바꿔쓰는 방식을 채택하였다. 

 

 

해당 개발하는 게임의 와이어 프레임

 

와이어프레임 설계를 함과 동시에 팀원의 역할분배를 위해 기능별로 분리를 했을때 이런식으로 나왔다.

 

 

개발할 게임의 프레임 워크 구조

게임 싸이클은 

 

게임 싸이클 초안

 

스테이지 진입 -> 전투 시작 -> 적과의 전투 -> 해당 스테이지의 진척도 조건을 달성 -> 보스 적 등장 -> 보스적과 의 전투 승리 -> 다음 스테이지 진입

 

이라는 게임 싸이클로 구성할 예정이다.

 

만약 일반 적과의 전투중 사망한 경우 해당 스테이지를 재시작

보스 적과의 전투중 사망한 경우 해당 스테이지를 재시작하지만 언제든지 보스와 싸울수 있는 기회를 제공

 

하게 구성을 하였다. 

 

방치형 게임의 최소 개발 분류

12월 20일까지는 최소 작업량은 저 위에 초록박스 쳐진것 까진 구현이 되어야만

테스트가 원활하게 진행 될 것이다. 

 

우선 이런식으로 정한 이유는

게임의 재미요소는 플레이어와 적과의 자동사냥 및 스킬 사용이 주 재미요소라고 느꼇기 때문이다.

 

 

방치형 게임이기에 2개의 챕터를 만들고 Enemy Status 배율값을 계속 증가시키고 챕터를 Roop 시키는 방식을 사용 할 예정

 

방치형 게임은 스테이지를 계속 찍어내기에는 한계가 있어 초안으로 2개의 챕터를 만들고 계속 반복시키는 형식이 좋을것 같아 이런식으로 구조를 잡아보았다. 

https://github.com/lostwaltz/PossibleDefense

 

GitHub - lostwaltz/PossibleDefense

Contribute to lostwaltz/PossibleDefense development by creating an account on GitHub.

github.com

 

일주일간 4명의 팀원과 진행한 모바일 3D 타워 디펜스 게임에 프로토타입을 개발하였다.

결과물은 만족 스럽게 나왔으나 코드 작성시에 하드코딩이 좀 많았던것 같아 회고를 해보려고 한다. 

 

해당 파트에서 나는 Stage에 관련된 파트를 담당했다.

 

Stage에서 필요한 기능들은

-Stage에 사용될 Tile 관리

-Tile에 설치될 타워 관리 (설치 및 판매)

-해당 Stage에서 사용될 타워 업그레이드

-Stage에서 사용되는 골드 재화

-로비 -> 게임 -> 로비 로 이동시의 Data 유지와 Scene 연결

요약하면 이정도가 될것같다. 

 

해당 기능들을 크게 키워드로 해서 Class로 기능을 분리해서 작업해야 가독성이 오르는데

이번에는 하드코딩도 많고 팀원끼리 소통을 그렇게 많이 했는데도 변수명을 정하지 않았던점이 문제였던것 같다.

GameScene에서 사용된 오브젝트들과 컴포넌트들인데, 전혀 가독성이 없고 코드가 일관성이 없어 보기 힘들다.

1번 문제점 :

UI매니저가 없기에 우선 UI를 만들어두자 하고 작업을 진행하였고 Refactoring 하는 과정에서

UI매니저를 작성하여 UI매니저에 집어넣고 꺼내쓰는 식으로 사용 했어야 됬는데 결국에는 UI매니저를 사용하기 위해 UIBase 인터페이스 자체도 작성하지 않아 결국에는 동적생성을 하지 못해 이런식으로 사용하게 되었다.

 

1번 문제의 대처점 : 첫 작업시에 프레임워크 작업을 진행하고 , 해당 매니저들을 미리 작성해놓아야한다.

그러기에 팀원과 회의를 정말 많이 해야된다. 

 

2번 문제점 :

3번과 연관되는 문제점이긴한데 SpawnManager가 있지만 이 Manager는 EnemySpanwManager이다.

이름이 직관적이지 않아 직접 열어봐야만 알수있고 BulletObjectPool은 하이어라키에있고

Tile ObjectPool은 왜 Stageamanager의 안에 있는지 의문이다. ( ObjectPoolLegacy가 Tile ObjectPool)

즉, Class 및 오브젝트의 이름이 직관성 없기때문에 발생했던 문제다.

 

2번 문제의 대처점 : 해당 오브젝트 명 컴포넌트 명을 정확하게 구체적으로 작성해서 사용 할것 

 

3번 문제점

위에서 말한 큼직큼직한 기능들을 키워드 삼아서 Class별로 분리해서 컴포넌트처럼 붙여 사용하거나 따로 오브젝트화 시켰어야했는데 StageManager에 관련없는 기능을 다 넣어서 사용 한것이 문제가 되었다.

 

정말 하드코딩하고 Inspector에 다 때려 밖은 상태 절대 이렇게 작업하면 안된다.

 

StageManager의 Inspector에서 보면 밑에 컴포넌트로 부착한것들을 전부 등록하여 사용하고있다.

물론 내부에서도 해당 컴포넌트들이 비면 null체크를 해서 컴포넌트를 호출해서 사용하고 있지만

이런 Inspector 구조는 가독성이 떨어지기에이런식으로 Inspctor에 정리없이 사용하면 안될것 같다. 

또한 Scene 전환시에도 Missing이 일어날 확률이 높다.

 

3번 문제의 대처점 : Inspector에서는 최소한으로 등록하여 사용하고 내부적으로 동적생성하게 코드를 구현해야 되며 UI매니저를 사용하여 UI를 원하는 시점에서 꺼내 쓸수 있게 구현 해야된다.

 

 

p.s 코드 구조를 잡지 않고 자료구조를 막 사용한 나쁜 예시

이 코드는 제출하기 직전에 급하게 만든 코드라 최적화를 생각하지 못했다.

타일을 선택할떄마다 타일 선택 정보를 초기화 해주는 코드인데 맨처음 작성시 자료구조를 List를 쓴것부터가 문제의 시작이였고 해당 문제를 해결하려면 TowerTiled을 딕셔너리로 변경해서 사용할수있게 구조를 바꿧어야 했다. 

 

 

그래도 해당 프로젝트에서는 오전 / 오후 / 저녁으로 자기의 코드리뷰와 프로젝트 진척도를 회의하는 시간을 계속 가져왔고

작업량이 없어도 무조건 코드리뷰를 하게 하였다. 그러면서 서로 문제점이 있는부분을 서로 보완해주고 서로에 대한 정보공유를하면서 정말 실력이 많이 늘었다. 

 

다음 프로젝트에서도 팀원과의 소통 및 회의/회고를 적극 권장해야겠다.

위에 조건처럼 [어떤 동작을해도 같은 목적을 취함 + 동작이 다름] 이라는 조건하에 전략패턴커맨드패턴의 디자인패턴을 자주 사용할 것이다. 하지만 UI의 버튼이나 규모가 작은 경우에는 전략패턴을 구조로 설계하기에 리소스 낭비적인 부분이 있다. 

 

이런 경우에는 <매핑 테이블 활용>을 하여 각 기능을 정의한뒤 인자값을 다르게 설정하는 것이다. 

 

using UnityEngine;
using UnityEngine.UI;
using TMPro;
using System.Collections.Generic;
using Unity.Burst.Intrinsics;

public class UpgradesController : MonoBehaviour 
{
    [SerializeField] private SlimeTowerStatUpgradeData[] _upgradeDatas;
    [SerializeField] private Button[] buttons;

    private Dictionary<Button, TowerGrade> upgradeButtonMappings;

    private void Awake()
    {
        upgradeButtonMappings = new Dictionary<Button, TowerGrade>
        {
            { buttons[0], TowerGrade.Common},
            { buttons[1], TowerGrade.Normal},
            { buttons[2], TowerGrade.Epic},            
        };

        foreach(KeyValuePair<Button, TowerGrade> pair in upgradeButtonMappings)
        {
            pair.Key.onClick.AddListener(() => UpgradeTowerByGrade(pair.Value));
        }
    }

    //버튼 누르면 해당 등급에 따라 강화 
    public void UpgradeTowerByGrade(TowerGrade grade)
    {
        foreach (var data in _upgradeDatas)
        {
            if (data.Grade == grade)
            {
                data.OnUpgrade();
                break;
            }
        }
    }
}

위 예제 코드는 버튼이 3개가 있고 , 해당 버튼들은 해당 등급의 유닛들을 강화해주는 기능이있다.

목적은 강화기능이기에 등급이라는 인자값을 받아서 적용시켜주어야한다. 

 

초기화시 사용할 데이터를 Inspector를 통해서 초기화한뒤(스크립트 코드로 적용시켜도 됨)

버튼의 onClick.AddListener로 람다식을 이벤트로 초기화 하여 사용하는 방식을 취한다.

 


이런식으로 테이블매핑을 한뒤 각 기능에 맞춰 이벤트를 적용시켜주면 전략패턴을 사용하지않고

간단히 사용 할 수 있다.

+ Recent posts