구현된 인벤토리

ItemDB.Json을 통하여 ItemSlot들을 생성하여 인벤토리를 구현하였고 , ItemSlot은 현재 생성/파괴를 하고있기에 , 오브젝트 풀링을 이용하여 리팩토링 해야된다. 

 

해당 구조도 MVP패턴을 이용하여 구현하였다.

 

 

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

public class InventoryController : UIController
{
    private InventoryModel inventoryModel;
    private InventoryView inventoryView;

    public InventoryModel Model { get => inventoryModel; set => inventoryModel = value; }
    public InventoryView View { get => inventoryView; set => inventoryView = value; }

    public override void Initialize(IUIBase view, UIModel model)
    {
        inventoryModel = model as InventoryModel;
        inventoryView = view as InventoryView;

        base.Initialize(inventoryView, inventoryModel);
    }

    public override void OnShow()
    {
        view.ShowUI();
        UpdateView();   // 초기 View 갱신
    }

    public override void OnHide()
    {
        view.HideUI();
    }

    public override void UpdateView()
    {
        // Model 데이터를 기반으로 View 갱신
        view.UpdateUI();
    }
}

 

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

public class InventoryModel : UIModel
{
    public List<Item> Items = new List<Item>(); // 소지하고있는 아이템 리스트 

    public void Initilaize()
    {
        foreach (ItemDB Data in DataManager.Instance.ItemDB.ItemsDict.Values)
        {
            Item itemObj = new Item();
            itemObj.Initialize(Data);
            Items.Add(itemObj);
        }
    }

    public void AddItem(string item)
    {

    }

    public void RemoveItem(string item)
    {

    }
}
using System.Collections;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.InputSystem.XR;

public class InventoryView : MonoBehaviour, IUIBase
{
    [SerializeField] private Transform itemSlotParent;
    [SerializeField] private ItemSlot itemSlotPrefab;
    [SerializeField] private RectTransform itemSlotBoundary;
    public InventoryController Controller;

    private List<ItemSlot> itemSlots = new List<ItemSlot>();

    public void Initialize()
    {
        if (itemSlotPrefab == null)
        {
            itemSlotPrefab = Resources.Load<ItemSlot>("Prefabs/Item/ItemSlot");
        }

        for (int i = 0; i < Controller.Model.Items.Count; i++)
        {
            itemSlots.Add(Instantiate(itemSlotPrefab, itemSlotParent));

            itemSlots[i].Initiliaze(Controller.Model.Items[i]);
        }
    }

    public void ShowUI()
    {
        Vector2 size = itemSlotBoundary.sizeDelta;
        size.y = 135 * ((itemSlots.Count / 4) + 1);
        itemSlotBoundary.sizeDelta = size;
    }

    public void HideUI()
    {
    }

    public void UpdateUI()
    {
        Debug.LogAssertion("인벤토리 UI 업데이트");
    }
}
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

public class UIInventory : MonoBehaviour
{
    public string UIKey;

    private InventoryModel model;
    [SerializeField] private InventoryView[] views;
    private InventoryController controller;

    private void Start()
    {
        //Model(Data) 초기화
        model = new InventoryModel();
        model.Initilaize();
        GameManager.Instance._player.Inventory = model;

        //컨트롤러  초기화 및 View 등록
        controller = new InventoryController();
        for (int i = 0; i < views.Length; i++) 
        {
            views[i].Controller = controller;
            controller.Initialize(views[i], model);
        }

        //UI매니저에 UI 등록
        UIManager.Instance.RegisterController(UIKey, controller);
        gameObject.SetActive(false);
    }

    public void UIShow()
    {
        controller.OnShow();
    }
    
}

 

현재 로직만 구현 중이고 , 아직 Player 캐릭터와는 연결이 되어있지 않다. ItemStatus UI와 연결후 PlayerStat과 연동해서 값을 변경 해야 될것 같다. 

https://01149.tistory.com/148

 

TIL : 2024-12-09(월) :: 최종프로젝트 개발 일지(11) - Scene을 로드하는경우 파괴된 Object에 접근되는 상

Enemy 및 Projectile 을 ObjectPool Class에 넣어 관리를 하는데,Scene을 로드하는 경우 ObjectPollManager는 DontDestroy로 파괴되지 않아 데이터가 유지되지만Enemy Object들은 Scene이 로드되면서 파괴되기에 ObjectPoolM

01149.tistory.com

해당 관련된 글의 트러블 슈팅이다.

트러블 슈팅의 대처법으로 

 

[ 결론은 Scene 재로드시에 ObjectPool을 비우는 구조를 설계를 해야만 한다.  ] 

 

였기에 , 우선 호출시점을 오브젝트 풀링을 셋팅하기 전으로 설계를 해야되고 , 각 클래스별로 오브젝트 풀링세팅하는것을 한 곳으로 모아서 작업을 하였으며 , 경로 같은 고정 문자열은 따로 Const 라는 고정 상수,문자열을 저장하는 클래스를 따로 만들어 관리하기로 하였다.

 

이렇게 한 이유는 

첫번째 : 오브젝트 풀링 세팅은 한 Scene에서 세팅 후에 호출 및 반납만하기에 한 곳에서 해도 된다고 판단하였고

Scene 로드 할때 처음으로 오브젝트 풀을 비우면 중복 생성이나 파괴된 오브젝트에 접근하지 않을거라고 판단하였다.

두번째 : 경로는 보통 문자열로 이루어져있고 수정이 되지 않아야되고 접근이 가능해야되기에 Static Class로 생성 한뒤 readonly 기능을 이용하어 수정을 막았다.

 

GameSceneTrigger.cs

using System;
using UnityEngine;

public class GameSceneTigger : MonoBehaviour
{
    private void Start()
    {
        ObjectPoolManager.Instance.ObjectPoolAllClear();

        PlayerObjectPoolSetting();
        EnemyObjectPoolSetting();
        InventoryObjectPoolSetting();

        GameManager.Instance.StartGame();
    }

    private void InventoryObjectPoolSetting()
    {
    }

    private void PlayerObjectPoolSetting()
    {
        ObjectPool playerProjectilePool = new ObjectPool(Const.POOL_KEY_PLAYERPROJECTILE, Const.PLAYER_INITIAL_POOL_SIZE, Const.PLAYER_PROJECTILE_ENERGYBOLT_PATH);
        ObjectPoolManager.Instance.AddPool(Const.PLAYER_PROJECTILE_ENERGYBOLT_KEY, playerProjectilePool);
    }

    private void EnemyObjectPoolSetting()
    {
        ObjectPool goblinPool = new ObjectPool(5000, 60, Const.ENEMY_PREFEB_GOBLIN_PATH);
        ObjectPool goblinMagicianPool = new ObjectPool(5001, 60, "Prefabs/Enemy/GoblinMagician");

        ObjectPool slashPool = new ObjectPool(6000, 60, "Prefabs/Enemy/Effects/Slash");
        ObjectPool energyBoltPool = new ObjectPool(6001, 60, "Prefabs/Enemy/Effects/EnergyBolt");
        ObjectPool slashBossPool = new ObjectPool(6002, 60, "Prefabs/Enemy/Effects/SlashBoss");
        ObjectPool skillBoss1Pool = new ObjectPool(6003, 10, "Prefabs/Enemy/Effects/SkillBoss1");

        ObjectPool goblinBossPool = new ObjectPool(5500, 3, "Prefabs/Enemy/GoblinBoss");

        ObjectPoolManager.Instance.AddPool(Const.ENEMY_POOL_KEY, goblinPool);
        ObjectPoolManager.Instance.AddPool(Const.ENEMY_POOL_KEY, goblinMagicianPool);

        ObjectPoolManager.Instance.AddPool(Const.ENEMY_EFFECT_POOL_KEY, slashPool);
        ObjectPoolManager.Instance.AddPool(Const.ENEMY_EFFECT_POOL_KEY, energyBoltPool);
        ObjectPoolManager.Instance.AddPool(Const.ENEMY_EFFECT_POOL_KEY, slashBossPool);
        ObjectPoolManager.Instance.AddPool(Const.ENEMY_EFFECT_POOL_KEY, skillBoss1Pool);

        ObjectPoolManager.Instance.AddPool(Const.ENEMY_BOSS_POOL_KEY, goblinBossPool);
    }
}

해당 코드의 기능은 GameScene을 로드시에 필요한 곳에서 ObjectPool을 세팅하는 Class입니다.

SceneManager의 SceneLoad를 사용해도 되지만, 세팅해야될 데이터가 많고 , 확장성을 대비해서 Class로 만든뒤 오브젝트에 컴포넌트로 적용시켜 사용 중입니다. 

Enemy 및 Projectile 을 ObjectPool Class에 넣어 관리를 하는데,

Scene을 로드하는 경우 ObjectPollManager는 DontDestroy로 파괴되지 않아 데이터가 유지되지만

Enemy Object들은 Scene이 로드되면서 파괴되기에 ObjectPoolManager에는 파괴된 Object만 가지고 있게 된다.

 

해당 트러블 슈팅을 겪고 있어 두가지 방법을 생각하고 있다.

1. Scene이 로드 될때마다 ObjectPool을 비우기 

이 방법을 사용하면 처리비용은 높지만 문제는 깔끔하게 처리가 될것이다. 

하지만 우리 프로젝트는 Stage마다 Scene을 재 로드 해서 사용하기로 했으나 , 이렇게되면

최적화에 좋지 않아 고민 중이다. 

 

2.Scene 로드 할때마다 ObjectPool을 비우지만 Stage 로드시에는 비우지 않기 

Scene로드 시에만 ObjectPool을 비우고 Stage 에서는 생성되어있는 ObjectPoll을 재활용 하여 다음 Stage로 넘어갈떄 비동기로 데이터 세팅을 하는싞으로 할지 고민 중이다.

 

결론은 Scene 재로드는 구현을 해야되기에 비우는 구조를 설계를 해야만 한다. 

해당 Stage 진행도를 나타내주는 UI

 

Stage 진행도를 나타내주는 기능은 아무래도 동적으로 값이 계속 변하다보니 MVC 패턴을 이용하여 UIManager에 등록하고 사용하는 방식으로 진행 하였다., 

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.InputSystem.XR;
using UnityEngine.UI;

//Model : GameManager의 처치한 Enemy Count가 Model이됨
//View : UIStageProgressBarView
//Controller : UIStageProgressBarController

public class UIStageProgressBar : MonoBehaviour
{
    public string UIKey;

    private UIStageProgressBarModel model;
    [SerializeField]private UIStageProgressBarView view;
    private UIStageProgressBarController controller;

    private void Start()
    {
        model = new UIStageProgressBarModel();
        GameManager.Instance.StageProgressModel = model;
        controller = new UIStageProgressBarController();
        controller.Initialize(view, model);

        UIManager.Instance.RegisterController(UIKey, controller);
    }

}

해당 기능의 Model,View,Controller를 멤버변수로 가지고있어 초기화및 관리를 하고있다. 오브젝트에 컴포넌트로 적용하여 사용하는 클래스이다. 

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.InputSystem;
using DG.Tweening;

//Model : GameManager의 처치한 Enemy Count가 Model이됨
//View : UIStageProgressBarView
//Controller : UIStageProgressBarController

public class UIStageProgressBarView : MonoBehaviour, IUIBase
{
    [SerializeField] private RectTransform curStageProgress;

    public void Initialize()
    {
        if (curStageProgress == null)
        {
            curStageProgress = GetComponent<RectTransform>();
        }

        curStageProgress.localScale = new Vector3(0, 1, 1);

    }

    public void ShowUI()
    {
        //Boss 몬스터 등장 조건이 되지 않으면 UI 출력
        gameObject.SetActive(true);
    }

    public void HideUI()
    {
        //Boss 몬스터 등장 조건이 되면 사라지기
        Utils.StartFadeOut(this.GetComponent<CanvasGroup>(), Ease.OutBounce, 1.0f);
        gameObject.SetActive(false);
    }

    public void UpdateUI()
    {

    }

    public void UpdateUIProgree(float resultProgress)
    {
        curStageProgress.localScale = new Vector3(resultProgress, 1, 1);
    }
}

Controller로부터 데이터를 받아 Scene에 출력(업데이트)하는 클래스 영역이다. 

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

//Model : GameManager의 처치한 Enemy Count가 Model이됨
//View : UIStageProgressBarView
//Controller : UIStageProgressBarController

[System.Serializable]
public class UIStageProgressBarModel : UIModel
{
    public event Action OnEventCurEnemyAddCount;
    private int curEnemySlayerCount; //처지한 Enemy 카운트
    private int bossTriggerEnemySlayerCount; //Boss가 등장에 필요한 쓰러트린 Enemy 카운트

    public int CurEnemySlayerCount { get => curEnemySlayerCount; private set => curEnemySlayerCount = value; }
    public int BossTriggerEnemySlayerCount { get => bossTriggerEnemySlayerCount; private set => bossTriggerEnemySlayerCount = value; }

    public void Initialize(int bossTriggerCount)
    {
        bossTriggerEnemySlayerCount = bossTriggerCount;
        curEnemySlayerCount = 0;
    }

    public void CurCountDataClear()
    {
        curEnemySlayerCount = 0;
    }

    public void AddCurEnemyCount(int slayEnemyCount)
    {
        curEnemySlayerCount += slayEnemyCount;
        Debug.Log($"현재 처치한 적의 갯수 : {curEnemySlayerCount} \" {bossTriggerEnemySlayerCount}");
        OnEventCurEnemyAddCount?.Invoke();
    }


}

Data(Enemy처치 갯수)를 관리하는 곳으로 적을 처치할때마다 EnemyCount가 올라간다. 

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

//Model : GameManager의 처치한 Enemy Count가 Model이됨
//View : UIStageProgressBarView
//Controller : UIStageProgressBarController

public class UIStageProgressBarController : UIController
{
    private UIStageProgressBarModel stageProgressBarModel;
    private UIStageProgressBarView stageProgressBarView;

    public override void Initialize(IUIBase view, UIModel model)
    {
        base.Initialize(view, model);

        stageProgressBarModel = model as UIStageProgressBarModel;
        stageProgressBarView = view as UIStageProgressBarView;

        stageProgressBarModel.OnEventCurEnemyAddCount += UpdateView;
        stageProgressBarModel.OnEventCurEnemyAddCount += BossTriggerCheck;
    }

    public override void OnShow()
    {
        view.ShowUI();
        UpdateView();   // 초기 View 갱신
    }

    public override void OnHide()
    {
        view.HideUI();
    }

    public override void UpdateView()
    {
        float resultProgress = (stageProgressBarModel.CurEnemySlayerCount / (float)stageProgressBarModel.BossTriggerEnemySlayerCount);
        resultProgress = Mathf.Min(1,resultProgress);

        // Model 데이터를 기반으로 View 갱신
        //view.UpdateUI();
        stageProgressBarView.UpdateUIProgree(resultProgress);
    }

    public void BossTriggerCheck()
    {
        if (stageProgressBarModel.CurEnemySlayerCount >= stageProgressBarModel.BossTriggerEnemySlayerCount)
        {
            Debug.Log($"보스 등장 조건을 만족 합니다.");
            GameManager.Instance.isTryBoss = true;
            OnHide();
        }
    }
}

Controller로 Data값이 등록되면 해당 Controller에 알려주고 Controller는 해당 데이터가 들어옴에 따라 로직대로 동작하게된다. 해당 로직은 현재 잡은 Enemy수 / 목표 Enemy 처치수를 하여 백분율로 나타내고 해당 데이터를 이용하여 Boss 등장 조건을 체크하는 Class 이다. 

Player,Enemy의 콜라이더를 IsTrigger 체크를 하여 사용하니 투사체에 있는 OnCollison 메서드가 동작하지 않았고

Collison의 ContactPoint를 OnTrigger 메서드 내에서 필요했다.

  protected void ProjectileCollison(Collision collision)
  {
      //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;
   	
    //.....
  }

해당 코드는 OnCollison 메서드 내부의 있는 메서드인데 해당 함수에서 Collison.contacts를 호출해서 접촉한 면의 각도와 방향을 가져와서 투사체가 사라질때 피격 효과를 내고있었다.

 

이 피격효과를 내기 위해서는 충돌하는 객체의 데이터가 필요했다. 

 

그래서 Collider의 Docs에서 찾아보니 ClosestPoint 프로퍼티를 사용하면

 트리거와 충돌하는 물체의 가장 가까운 지점을 계산해주는 기능이 있었다. 

https://docs.unity3d.com/ScriptReference/Collider.ClosestPoint.html

 

Unity - Scripting API: Collider.ClosestPoint

This method computes the point on the Collider that is closest to a 3D location in the world. In the example below closestPoint is the point on the Collider and location is the point in 3D space. If location is in the Collider the closestPoint is inside. I

docs.unity3d.com

 

그래서 해당 프로퍼티를 사용하여 충돌시 동작하는메서드를 변경하여 사용하였다. 

 protected void ProjectileCollison(Collider other)
 {
     //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);

     Vector3 closetPoint = other.ClosestPoint(other.transform.position);

     //Spawn hit effect on collision
     if (hit != null)
     {
         //hit.transform.rotation = rot;
         hit.transform.position = closetPoint;
         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); }
         else { hit.transform.LookAt(closetPoint); }
         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();
 }

 

객체에 접촉하는 면의 데이터를 가져오는게 아니라 법선벡터라던가 각도를 제대로 측정하지는 못하지만

OnTrigger 메서드로 피격 효과의 좌표값 조정이 가능해졌다.

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에 의해 근접 기본 공격 , 원거리 기본 공격을 구현해야된다. 

+ Recent posts