1.적 객체 해당영역 랜덤 생성

    private Vector3 RandomSpawn()
    {
        int maxAttempt = 3;
        int curAttaempt = 0;
        Vector3 playerPosition = GameManager.Instance._player.transform.position;
        // 콜라이더의 사이즈를 가져오는 bound.size 사용
        float range_X = SpawnArea.bounds.size.x;
        float range_Z = SpawnArea.bounds.size.z;

        Vector3 RandomPostion;
        do
        {
            curAttaempt++;
            range_X = UnityEngine.Random.Range((range_X / 2) * -1, range_X / 2);
            range_Z = UnityEngine.Random.Range((range_Z / 2) * -1, range_Z / 2);
            RandomPostion = new Vector3(range_X, 1f, range_Z);
        }
        while (curAttaempt < maxAttempt && 3.0f >= Vector3.Distance(RandomPostion, playerPosition));

        Vector3 respawnPosition =  RandomPostion;
        return respawnPosition;
    }

 

해당 코드를 이용하여 소환 좌표를 생성하고 해당 좌표가 플레이어의 위치와 너무 가까우면(3.0f 거리) 안에 생성되는경우

다시 시도하여 원하는 소환좌표를 생성하는 코드이며 총 3번까지 시도 후에도 거리가 가까우면 그 자리에 생성하는 코드이다.(무한 루프가 될 가능성이 있기에 시도횟수 설정)

 

연두색 콜라이더 위에 소환되며 플레이어 근처로는 생성되지 않는다.


2.Player Character StatHandler 연동 

Player.cs의 Initilaize() 메서드 : 플레이어의 데이터를 초기화하는 메서드

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

    statHandler = new StatHandler(StatType.Player);
    statHandler.CurrentStat.iD = userData.UID;
    statHandler.CurrentStat.health = userData.stat.health;
    statHandler.CurrentStat.maxHealth = userData.stat.maxHealth;
    statHandler.CurrentStat.atk = userData.stat.atk;
    statHandler.CurrentStat.def = userData.stat.def;
    statHandler.CurrentStat.moveSpeed = userData.stat.moveSpeed;
    statHandler.CurrentStat.atkSpeed = userData.stat.atkSpeed;
    statHandler.CurrentStat.reduceDamage = userData.stat.reduceDamage;
    statHandler.CurrentStat.critChance = userData.stat.critChance;
    statHandler.CurrentStat.critDamage = userData.stat.critDamage;
    statHandler.CurrentStat.coolDown = userData.stat.coolDown;

    //Controller(FSM 세팅)
    playerStateMachine.ChangeState(playerStateMachine.IdleState);
}

 

StatHandler.cs -> 생성자에 등록된 메서드 PlayerStatConvert()

  public static Stat PlayerStatConvert(int key)
  {
      Stat baseStat = new Stat();

      // TODO : 플레이어 기본 스텟 수치 정보
      // 현재 Temp

      baseStat.iD = 0;

      baseStat.health = new BigInteger(10);
      baseStat.maxHealth = baseStat.health;
      baseStat.atk = new BigInteger(10);
      baseStat.def = new BigInteger(5);

      baseStat.moveSpeed = 5f;
      baseStat.atkSpeed = 5f;

      baseStat.reduceDamage = 5f;

      baseStat.critChance = 5f;
      baseStat.critDamage = 5f;
      baseStat.coolDown = 5f;

      return baseStat;
  }

 

생성자에서 기본 데이터값을 세팅후 , Player.cs에서 데이터값을 다시 초기화 하여 사용하고있다.

StatHandler는 런타임 중에 플레이어,Enemy 등 객체에서 계속 StatUpdate를 목적으로 만든 클래스이다. 

 


3.투사체 부모클래스 생성

BaseProjectile.cs 

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

public class BaseProjectile : MonoBehaviour
{
    [SerializeField] protected float speed = 15f; //투사체 속도
    [SerializeField] protected float hitOffset = 0f; //투사체 피격 오프셋 
    [SerializeField] protected bool UseFirePointRotation; //방향성 있는 투사체 각도 조정 bool 변수
    [SerializeField] protected Vector3 rotationOffset = new Vector3(0, 0, 0); //투사체 각도 오프셋
    [SerializeField] protected GameObject hit; // hit시 발생하는 이펙트 오브젝트
    [SerializeField] protected ParticleSystem hitPS;//hit시 발생하는 파티클시스템
    [SerializeField] protected GameObject flash; // 투사체의 Flash 효과
    [SerializeField] protected Rigidbody rb; 
    [SerializeField] protected Collider col;
    [SerializeField] protected Light lightSourse;
    [SerializeField] protected GameObject[] Detached; //해당 투사체에 있는 파티클 컨테이너
    [SerializeField] protected ParticleSystem projectilePS; //투사체 발사시 동작하는 파티클 시스템
    private bool startChecker = false; //투사체가 현재 사용중인지 체크하는 변수
    [SerializeField] protected bool notDestroy = false; //투사체가 파괴됬는지 체크하는 변수

    public float knockbackPower;//투사체 넉백 파워
    public Vector3 dir; //투사체가 발사되는 방향 
    public LayerMask TargetLayer; //해당 투사체를 맞추기 위한 타겟 레이어

    protected virtual void Start()
    {
        if (!startChecker)
        {
            /*lightSourse = GetComponent<Light>();
            rb = GetComponent<Rigidbody>();
            col = GetComponent<Collider>();
            if (hit != null)
                hitPS = hit.GetComponent<ParticleSystem>();*/
            if (flash != null)
            {
                flash.transform.parent = null;
            }
        }
        if (notDestroy)
            StartCoroutine(DisableTimer(5));
        else
            Destroy(gameObject, 5);
        startChecker = true;
    }
    protected virtual IEnumerator DisableTimer(float time)
    {
        yield return new WaitForSeconds(time);
        if (gameObject.activeSelf)
            gameObject.SetActive(false);
        yield break;
    }

    protected virtual void OnEnable()
    {
        if (startChecker)
        {
            if (flash != null)
            {
                flash.transform.parent = null;
            }
            if (lightSourse != null)
                lightSourse.enabled = true;
            col.enabled = true;
            rb.constraints = RigidbodyConstraints.None;
        }
    }

    protected virtual void FixedUpdate()
    {
        if (speed != 0)
        {
            rb.velocity = dir * speed;
        }
    }

    private void DamageCaculate(GameObject hitObject)
    {
        ITakeDamageAble damageable = hitObject.GetComponent<ITakeDamageAble>();
        //TODO :: 무적시간이 아닐때에도 조건에 추가해야됨
        if (damageable != null)
        {
            damageable.TakeDamage(10);//매직넘버 (플레이어나 Enemy의 Stat값을 받아와서 적용 시켜야됨)
            Vector3 directionKnockBack = hitObject.transform.position - transform.position;
            damageable.TakeKnockBack(directionKnockBack, knockbackPower);
        }
    }

    private void OnCollisionEnter(Collision collision)
    {
        if (TargetLayer == ((1 << collision.gameObject.layer) | TargetLayer))
        {
            Debug.Log($"공격이 {collision.gameObject.name}에 충돌");
            DamageCaculate(collision.gameObject);

            //Lock all axes movement and rotation
            rb.constraints = RigidbodyConstraints.FreezeAll;
            //speed = 0;
            if (lightSourse != null)
                lightSourse.enabled = false;
            col.enabled = false;
            projectilePS.Stop();
            projectilePS.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear);

            ContactPoint contact = collision.contacts[0];
            Quaternion rot = Quaternion.FromToRotation(Vector3.up, contact.normal);
            Vector3 pos = contact.point + contact.normal * hitOffset;

            //Spawn hit effect on collision
            if (hit != null)
            {
                hit.transform.rotation = rot;
                hit.transform.position = pos;
                if (UseFirePointRotation) { hit.transform.rotation = gameObject.transform.rotation * Quaternion.Euler(0, 180f, 0); }
                else if (rotationOffset != Vector3.zero) { hit.transform.rotation = Quaternion.Euler(rotationOffset); }
                else { hit.transform.LookAt(contact.point + contact.normal); }
                hitPS.Play();
            }

            //Removing trail from the projectile on cillision enter or smooth removing. Detached elements must have "AutoDestroying script"
            foreach (var detachedPrefab in Detached)
            {
                if (detachedPrefab != null)
                {
                    ParticleSystem detachedPS = detachedPrefab.GetComponent<ParticleSystem>();
                    detachedPS.Stop();
                }
            }

            if (notDestroy)
                StartCoroutine(DisableTimer(hitPS.main.duration));
            else
            {
                gameObject.SetActive(false);
            }


            ObjectPoolManager.Instance.GetPool("playerProjectile", Utils.POOL_KEY_PLAYERPROJECTILE).GetObject();

        }


    }

}

 

Player,Enemy 투사체를 사용하다보니 성질이 비슷하기에 전략패턴을 사용하여 리팩토링 하였다.

 

금일은 오전을 Asset을 탐색 및 구매하는데 일정을 보냈고 , 오후에는 해당 Asset을 코드리뷰하는 시간을 가졌다.

 

투사체 이펙트 관련 Asset에서 레퍼런스로 준 코드를 활용하여 PlayerProjectile에 적용하였다.

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

public class PlayerProjectile : MonoBehaviour
{
    [SerializeField] protected float speed = 15f;
    [SerializeField] protected float hitOffset = 0f;
    [SerializeField] protected bool UseFirePointRotation;
    [SerializeField] protected Vector3 rotationOffset = new Vector3(0, 0, 0);
    [SerializeField] protected GameObject hit;
    [SerializeField] protected ParticleSystem hitPS;
    [SerializeField] protected GameObject flash;
    [SerializeField] protected Rigidbody rb;
    [SerializeField] protected Collider col;
    [SerializeField] protected Light lightSourse;
    [SerializeField] protected GameObject[] Detached;
    [SerializeField] protected ParticleSystem projectilePS;
    private bool startChecker = false;
    [SerializeField] protected bool notDestroy = false;

    public Vector3 dir;
    public LayerMask TargetLayer;

    protected virtual void Start()
    {
        if (!startChecker)
        {
            /*lightSourse = GetComponent<Light>();
            rb = GetComponent<Rigidbody>();
            col = GetComponent<Collider>();
            if (hit != null)
                hitPS = hit.GetComponent<ParticleSystem>();*/
            if (flash != null)
            {
                flash.transform.parent = null;
            }
        }
        if (notDestroy)
            StartCoroutine(DisableTimer(5));
        else
            Destroy(gameObject, 5);
        startChecker = true;
    }
    protected virtual IEnumerator DisableTimer(float time)
    {
        yield return new WaitForSeconds(time);
        if (gameObject.activeSelf)
            gameObject.SetActive(false);
        yield break;
    }

    protected virtual void OnEnable()
    {
        if (startChecker)
        {
            if (flash != null)
            {
                flash.transform.parent = null;
            }
            if (lightSourse != null)
                lightSourse.enabled = true;
            col.enabled = true;
            rb.constraints = RigidbodyConstraints.None;
        }
    }

    protected virtual void FixedUpdate()
    {
        if (speed != 0)
        {
            rb.velocity = dir * speed;
        }
    }

    private void OnCollisionEnter(Collision collision)
    {
        if (TargetLayer == ((1 << collision.gameObject.layer) | TargetLayer))
        {
            Debug.Log($"플레이어의 공격이 {collision.gameObject.name}에 충돌");

            //Lock all axes movement and rotation
            rb.constraints = RigidbodyConstraints.FreezeAll;
            //speed = 0;
            if (lightSourse != null)
                lightSourse.enabled = false;
            col.enabled = false;
            projectilePS.Stop();
            projectilePS.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear);

            ContactPoint contact = collision.contacts[0];
            Quaternion rot = Quaternion.FromToRotation(Vector3.up, contact.normal);
            Vector3 pos = contact.point + contact.normal * hitOffset;

            //Spawn hit effect on collision
            if (hit != null)
            {
                hit.transform.rotation = rot;
                hit.transform.position = pos;
                if (UseFirePointRotation) { hit.transform.rotation = gameObject.transform.rotation * Quaternion.Euler(0, 180f, 0); }
                else if (rotationOffset != Vector3.zero) { hit.transform.rotation = Quaternion.Euler(rotationOffset); }
                else { hit.transform.LookAt(contact.point + contact.normal); }
                hitPS.Play();
            }

            //Removing trail from the projectile on cillision enter or smooth removing. Detached elements must have "AutoDestroying script"
            foreach (var detachedPrefab in Detached)
            {
                if (detachedPrefab != null)
                {
                    ParticleSystem detachedPS = detachedPrefab.GetComponent<ParticleSystem>();
                    detachedPS.Stop();
                }
            }

            if (notDestroy)
                StartCoroutine(DisableTimer(hitPS.main.duration));
            else
            {
                gameObject.SetActive(false);
            }


            ObjectPoolManager.Instance.GetPool("playerProjectile", Utils.POOL_KEY_PLAYERPROJECTILE).GetObject();

        }


    }

 

Player가 사용하는 원거리 공격의 Inspector

 

Asset이 적용된 Player의 기본 공격 구현

 

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개의 챕터를 만들고 계속 반복시키는 형식이 좋을것 같아 이런식으로 구조를 잡아보았다. 

+ Recent posts