Unity - Week 12

이응민·2025년 2월 14일

Unity

목록 보기
12/12

3D 액션 게임 만들기(4)

이어서 잡화 상점 화면인 ItemShopPopup을 마무리 짓는다. ItemShopSlot에서 버튼을 눌러 아이템의 개수를 결정하는데 그럴 때마다 개수에 따른 금액이 결정되는데 그걸 이용해서 델리게이트로 금액이 변할 때마다 델리게이트를 전달하는 것으로 구현했다. ItemShopPopup 스크립트에서는 그 델리게이트를 받아서 금액이 변하는 이벤트가 발생할 때마다 CalculateGold 함수를 호출해서 각 아이템 슬롯에서 발생한 금액들을 totalGold로 합산한다. 그리고 RefreshGold를 통해서 총 금액을 UI로 보여준다.

public void RefreshGold()
{
    tradeText.text = totalGold.ToString();
    balanceText.text = GameManager.Inst.PlayerGold.ToString();
}
public void CalculateGold()
{
    totalGold = 0;
    if(sellView.activeSelf)
    {
        for (int i = 0; i < sellSlotList.Count; i++)
        {
            if (sellSlotList[i].isActiveAndEnabled)
            {
                totalGold += sellSlotList[i].TotalGold;
            }
        }
    }
    else
    {
        for (int i = 0; i < 4; i++)
        {
            if (buySlotList[i].isActiveAndEnabled)
            {
                totalGold += buySlotList[i].TotalGold;
            }
        }
    }
    RefreshGold();
}

OnClick_Apply 함수에서는 ItemShopSlot의 GetSellInfo 함수와 GetBuyInfo 함수로 판매한 아이템과 구매한 아이템의 정보를 가져와 인벤토리에서 아이템을 판매한 아이템을 삭제하고 구매한 아이템은 추가한다.

public void OnClick_Apply()
{
    if (sellView.activeSelf)
    {
        for (int i = inventory.CurItemCount - 1; i >= 0; i--)
        {
            sellSlotList[i].GetSellInfo(out itemID, out tradeCount, out tradeGold);

            GameManager.Inst.PlayerGold += tradeGold; // totalGold

            InventoryItemData itemData = new InventoryItemData();
            itemData.itemID = itemID;
            itemData.amount = tradeCount;

            inventory.RemoveItem(itemData);
        }
        OnClick_SellTap();
    }
    else
    {
        totalGold = 0;

        for (int i = 0; i < 4; i++)
        {
            buySlotList[i].GetBuyInfo(out itemID, out tradeCount, out tradeGold);
            totalGold += tradeGold;
        }

        if (totalGold <= GameManager.Inst.PlayerGold)
        {
            GameManager.Inst.PlayerGold -= totalGold;
            for (int i = 0; i < 4; i++)
            {
                buySlotList[i].GetBuyInfo(out itemID, out tradeCount, out tradeGold);

          
                if (tradeCount > 0)
                {
                    InventoryItemData itemData = new InventoryItemData();
                    itemData.itemID = itemID;
                    itemData.amount = tradeCount;
                    
                    inventory.AddItem(itemData);
                }
            }
        }
        OnClick_BuyTap();
    }
}

RefreshSellViewData 함수와 RefreshBuyViewData 함수로 BuyTab과 SellTab을 초기화한다. RefreshSellViewData에서는 인벤토리에 있는 아이템들에 접근해서 정보를 가져와 슬롯에 출력하고 미리 18개의 슬롯을 만들어놨기 때문에 쓰지 않을 슬롯은 비활성화한다. RefreshBuyViewData에서는 아이템의 ID를 이용해서 순서대로 Data Table에서 정보를 가져와 출력한다. 이런식으로 비슷한 아이템의 ID는 순서대로 정하는 것이 좋을 것 같다.

// update sell view data
private void RefreshSellViewData()
{
    playerInven = GameManager.Inst.Inven.GetItemList(); // shallow copy
    for (int i = 0; i < inventory.MaxCount; i++)
    {
        if (i < inventory.CurItemCount && -1 < playerInven[i].itemID)
        {
            sellSlotList[i].RefreshSlot(playerInven[i]);
        }
        else
        {
            sellSlotList[i].ClearSlot();
        }
    }
    totalGold = 0;
    RefreshGold();
}
// update buy view data
private void RefreshBuyViewData()
{
    InventoryItemData itemData = new InventoryItemData();
    for (int i = 0; i < 4; i++)
    {
        itemData.itemID = 2001001 + i;
        itemData.amount = 999;
        buySlotList[i].RefreshSlot(itemData);
    }
    totalGold = 0;
    RefreshGold();
}

OnClick_SellTap과 OnClick_BuyTab은 각각 탭 버튼을 클릭시 화면의 갱신을 담당하지만 함수만 화면 갱신용으로 호출하기도 한다.




위 사진들은 아이템 샵에서 아이템을 구매하고 판매하는 과정이다.
이번에는 아이템을 강화하는 제련소 팝업창을 구현한다.

위 사진처럼 오른쪽에 자신이 갖고있는 장비 아이템이 뜨게 되고 왼쪽에 선택한 아이템으로 강화를 진행할 수 있는 강화 창이 뜨게 된다. ForgePopup또한 Popup과 slot을 따로 만들어서 slot의 정보를 Popup으로 전달한다. ForgePopup을 구현할 때 핵심은 보유하고 있는 아이템 창에 장비 아이템만 나오도록 하는 것이고 이번에는 강화하고자하는 제련소의 아이템 슬롯(ForgeItemSlot)에서 아이템이 선택되었을 때 델리게이트로 이벤트를 전달해서 ForgeItemPopup에서 이벤트를 받아서 아이템 강화 창으로 선택한 아이템의 정보를 넘겨준다. InitPopup애서 인벤토리의 정보를 가져오고 아이템 슬롯을 미리 생성한다. 그리고 슬롯이 선택되었을때 델리게이트에서 보내는 이벤트를 받을 그에 맞는 함수를 호출하도록 설정한다.
ForgePopup 스크립트에서 아이템의 정보를 저장하는 리스트를 만들어서 플레이어의 인벤토리 정보를 받아와 갖고 있는 아이템에서 장비 아이템이 아닌 것들을 삭제하고 장비 아이템만 리스트에 저장한다. 이 때 얕은 복사로 메모리를 참조하는 형식으로 인벤토리에 접근해서 장비 아이템이 아닌 것들을 제거한다면 플레이어의 인벤토리에서도 삭제되는 경우가 있다. 따라서 새로운 리스트를 만들어서 깊은 복사로 아예 새로운 메모리에 리스트를 만든다면 이러한 문제가 발생하는 것을 방지할 수 있다.

dataList = inventory.GetItemList().ToList<InventoryItemData>();

위 코드에서 ToList 함수는 리스트를 그대로 복사하는 C#의 System.Linq에 선언되어있는 함수이다. 이 함수를 이용해서 인벤토리의 정보를 똑같이 갖는 새로운 리스트를 생성해서 사용한다. 플레이어가 갖고 있는 아이템들을 보여주기 위해 오른쪽 창을 갱신하는 함수 RefreshData에서 위에서 만든 새로운 리스트에서 장비 아이템만 남기고 다른 아이템은 삭제한다. 슬롯은 미리 인벤토리의 최대 칸수 18개만큼 만들어놨기 때문에 ItemShopPopup처럼 정보가 있는 슬롯은 남기고 없는 슬롯은 ClearSlot으로 삭제한다.
아이템을 강화할 때 강화하고자하는 아이템과 똑같은 아이템이 있을 수 있다. 다른 아이템들이라면 itemID로 구별하면 되지만 똑같은 아이템들은 itemID가 똑같기 때문에 구별할 수 없다. 따라서 장비 아이템마다 고유한 아이디인 uid를 부여해서 같은 아이템이어도 uid가 다르기 때문에 구별할 수 있다. SelectItem 함수에서도 uid를 이용해 선택한 아이템의 uid와 아이템 리스트에 있는 uid를 비교해서 같다면 아이템이 선택된 것을 판단하고 강화를 진행하기 위해 왼쪽 강화 창에 아이템의 정보를 띄운다.

private void SelectItem(InventoryItemData selectItemData)
{
    for (int i = 0; i < dataList.Count; i++)
    {
        if (dataList[i].uid == selectItemData.uid)
        {
            selectItem = selectItemData;
            if (DataManager.Inst.GetItemData(selectItem.itemID, out tableData))
            {
                iconImg.enabled = true;
                iconImg.sprite = Resources.Load<Sprite>(tableData.iconImg);
            }
            else
            {
                Debug.Log("ForgePopup.cs - SelectItem() - 테이블에 해당 ID가 없음");
                iconImg.enabled = false;
            }
            if (selectItem.itemID % 1000 >= 5)
            {
                enchantInfoText.text = $"강화 -";
                enchantPriceText.text = $"강화 비용 : 0";
                playerBalanceText.text = $"보유 골드 : {GameManager.Inst.PlayerGold}";
            }
            else
            {
                enchantInfoText.text = $"강화 {selectItem.itemID % 1000} -> {selectItem.itemID % 1000 + 1}";
                enchantPriceText.text = $"강화 비용 : {(selectItem.itemID % 1000) * 500}";
                playerBalanceText.text = $"보유 골드 : {GameManager.Inst.PlayerGold}";
            }
        }
        else
        {
            slotList[i].IsSelect = false;
        }
    }   
}

강화 버튼을 누르면 OnClick_Enchant 함수가 호출되고 이 함수에서 강화를 진행하는 TryEnchant 함수를 호출한다. TryEnchant 함수에서는 강화를 하기 전에 강화를 할 수 있는 아이템인지 플레이어가 돈을 충분히 갖고 있는지 확인하는 CanEnchant 함수를 호출한다.

 public void OnClick_Enchant()
 {
     if (TryEnchant())
     {
         selectItem.itemID += 1;
         GameManager.Inst.Inven.UpdateItemInfo(selectItem);
         SelectItem(selectItem);
     }
     else
     {
         Debug.Log("강화 실패");
         SelectItem(selectItem);
     }
 }
 private bool TryEnchant()
 {
     bool isSuccess = false;
     if (CanEnchant())
     {
         isSuccess = Random.Range(0, 1000) < 300;
         GameManager.Inst.PlayerGold -= selectItem.itemID % 1000 * 500;
         RefreshData();
     }
     return isSuccess;
 }
 private bool CanEnchant()
 {
     if (selectItem.itemID % 1000 >= 5)
     {
         return false;
     }
     if (selectItem.itemID % 1000 * 500 > GameManager.Inst.PlayerGold)
     {
         return false;
     }
     return true;
 }

아이템은 강화가 성공하면 itemID가 1씩 증가하는 방식이고 강화가 실패하면 아무 일도 일어나지 않는다. 강화가 성공했을 때 증가한 itemID로 아이템의 정보를 갱신한다. TryEnchant 함수에서 강화를 진행한 후 RefreshData를 호출해 ForgePopup을 갱신한다.



위 화면은 강화를 실행하는 화면이다.
다음은 몬스터를 구현할 차례이다. 몬스터를 구현하기 위해 먼저 Package Manager에서 유니티에서 제공하는 AI 패키지를 install한다. 유니티에서 제공하는 AI 패키지는 길찾기 알고리즘을 제공한다. 길찾기 알고리즘을 위해서 NavMesh라는 오브젝트가 움직일 수 있는 맵을 만들고 그 맵을 움직일 수 있는 오브젝트들을 Agent라고 한다. 아래 링크에서 유니티의 내비게이션 시스템에 대한 메뉴얼을 볼 수 있다.

https://docs.unity3d.com/kr/2019.4/Manual/nav-NavigationSystem.html


위 사진은 Agent를 설정할 수 있는 탭이다. Agent의 타입은 기본적으로 Humanoid로 되어있으며 따로 추가를 할 수 있다. Humanoid의 설정은 높이가 2, 지름이 0.5인 원기둥으로 Agent의 영역을 설정할 수 있다. 이 영역으로 길찾기와 여러가지 상호작용들을 처리할 수 있다. 왼쪽의 0.75는 걸어올라갈 수 있는 턱의 높이이고 오른쪽의 45°\degree는 Agent가 걸어올라갈 수 있는 각도의 정도이다. 이 이상의 높이나 각도는 따로 점프를 한다던지 하는 설정을 해줘야한다. 크기가 다른 몬스터가 있다면 각 몬스터마다 Agent를 설정을 해서 몬스터의 크기가 커서 통과할 수 없는 공간도 통과할 수 있게 되는 불상사를 막아야한다.
NavMesh를 설정해가 위해서는 몬스터가 움직일 오브젝트(보통 배경 오브젝트)에 NavMeshSurface 컴포넌트를 추가하고 Agent Typr과 Default Area를 설정해준다. 그리고 Use GeoMetry를 Render Meshes로 설정해주고 Bake 버튼을 눌러주면 NavMesh가 생성된다.

Scene에서 파란색으로 되어있는 부분이 Agent가 갈 수 있는 부분이다. 만약의 NavMesh가 이상하게 생성되었다면 오브젝트의 위치를 조정하고 다시 Bake를 눌러 NavMesh를 생성한다. 그리고 몬스터가 될 Enemy 오브젝트를 생성해서 NavMeshAgent 컴포넌트를 추가한다.


NavMeshAgent 컴포넌트를 살펴보면 agent를 통해 다양한 것들을 설정할 수 있음을 알 수 있다.
적 몬스터의 구현을 위해 MonsterBase 스크립트와 MonsterAI 스크립트를 생성해서 Enemy 오브젝트에 넣어준다. MonsterBase 스크립트에서는 몬스터의 애니메이션, 몬스터의 상태(체력, 피격관리), 이동 등을 관리하고 MonsterAI 스크립트에서는 유한 상태 기계를 이용해 몬스터의 state에 따라서 행동하는 방식을 결정하게 한다.

MonsterBase에서 애니메이션을 상황에 따라 바꾸기 전에 먼저 Enemy의 애니메이션을 설정하고 transition의 parameter를 정한다. 걸을 때 parameter는 bool 값으로 설정해서 해당 parameter가 true이면 걷는 애니메이션이 출력되도록하고 공격, 피격, 죽음은 trigger로 설정해서 해당되는 일이 발생하면 한 번 애니메이션을 재생하고 Exit으로 애니메이션을 끝내는 것으로 구현한다.
MonsterAI 스크립트를 작성해서 몬스터에 상태에 따른 행동을 제어한다.

public enum AI_State
{
    Idle,
    Roaming,
    Return,
    Chase,
    Attack,
    Die,
}

몬스터의 상태를 열거형으로 정의한다. StartAI 함수에서는 몬스터가 생성되었을때 초기 상태로 초기화하는 함수이다. 현재 상태를 Roaming으로 설정하고 몬스터가 목표로하는 target은 아직 없는 것으로 설정한다. agent.isStopped를 false로 설정해서 몬스터가 움직이도록 하고 스폰 위치는 현재 몬스터의 위치로 저장한다. isStopped로 몬스터의 움직임을 설정하는 이유는 몬스터가 오브젝트 풀에서 꺼내지고 다시 저장되는데 그때 isStopped를 true나 false로 바꿔서 활성화 비활성화를 나타낼 수 있다.

 public void StartAI()
 {
     isInit = true;
     currentState = AI_State.Roaming;
     mainTarget = null;
     agent.isStopped = false;
     spawnedPos = transform.position;
 }

상태의 이름들로 코루틴을 정의하고 ChangeAIState 함수에서 기존 상태의 코루틴을 정지하고 새로운 상태를 인자로 받아 그 상태에 해당하는 코루틴을 시작한다.

public void ChangeAIState(AI_State newState)
{
    if (isInit)
    {
        StopCoroutine(currentState.ToString());
        currentState = newState;
        StartCoroutine(newState.ToString());
    }
}

Roaming 상태에서는 스폰 위치에서 정해진 구간을 무작위로 배회한다. Return 상태에서는 몬스터가 플레이어를 쫓다가 일정 거리를 벗어나면 다시 스폰 지역으로 돌아간다. 여기서 agent.remainingDistance가 쓰이는데 목표에 도달했다고 판단하는 거리를 설정하는 것이다. 스폰 지역에 도달하면 ChangeAIState 함수를 호출해서 Roaming 상태로 바꾼다. Chase 상태에서는 플레이어를 쫓아가는데 플레이어와의 거리가 충분히 가까우면 Attack으로 상태를 바꾸고 아니라면 계속해서 플레이얼르 쫓아간다. 플레이어가 멀어져서 mainTarget이 null이되면 추적을 멈추고 Return 상태가 되어서 스폰 지역으로 되돌아가게 된다.

IEnumerator Roaming()
{
    while(true)
    {
        yield return YieldInstructionCache.WaitForSeconds(Random.Range(4.0f, 6.0f));
        targetPos.x = Random.Range(-5.0f, 5.0f);
        targetPos.y = 0;
        targetPos.z = Random.Range(-5.0f, 5.0f);

        SetTargetPos(spawnedPos + targetPos);
    }
    
}
IEnumerator Return()
{
    SetTargetPos(spawnedPos);
    while(true)
    {
        yield return YieldInstructionCache.WaitForSeconds(1.0f);
        if (agent.remainingDistance < 2.0f)
        {
            ChangeAIState(AI_State.Roaming);
        }
    }
    
    
}
IEnumerator Chase()
{
    while (mainTarget != null)
    {
        if (GetDistanceToTarget() < 2.5f)
        {
            ChangeAIState(AI_State.Attack);
        }
        else
        {
            SetTargetPos(mainTarget.transform.position);
        }
        yield return YieldInstructionCache.WaitForSeconds(0.5f);
    }
    ChangeAIState(AI_State.Return);
}

몬스터가 Idle 상태이거나 Roaming 상태일 때 플레이어를 발견하면 mainTarget을 플레이어로 설정하고 Chase 상태로 바꿔서 쫓아간다. Idle 상태이거나 Roaming 상태일 때만 타겟을 변경할 수 있도록 한 이유는 타겟을 쫓아가다가 다른 플레이어가 나타났을때 타겟을 바꾸게 된다면 어색할 수 있기 때문이다. GetDistanceToTarget 함수를 통해서 타겟과의 거리를 측정한다. 이때는 Vector3.Distance 함수를 이용해서 거리를 측정한다. SetTargetPos 함수는 agent.SetDestination 함수를 통해서 agent가 이동해야하는 타겟의 위치를 설정하는 함수이다.

private void SetTargetPos(Vector3 newTarget) // SetMoveTarget
{
    agent.SetDestination(newTarget);
}
private float GetDistanceToTarget()
{
    if (mainTarget != null)
    {
        return Vector3.Distance(transform.position, mainTarget.transform.position);
    }
    return -1;
}
private void SetTarget(GameObject newTarget)
{
    if (currentState == AI_State.Idle || currentState == AI_State.Roaming)
    {
        mainTarget = newTarget;
        ChangeAIState(AI_State.Chase);
    }
}

몬스터는 맵에서 스폰지점이 있어서 그곳에서 오브젝트 풀을 통해서 생성된다. MonsterBase 스크립트에서도 스폰 지점을 저장한다. Update에서 agent.velocity.sqrMagnitude를 이용해서 일정 속도 이상일때 걷는 애니메이션을 출력하도록 애니메이션의 걷기를 담당하는 parameter의 bool값을 true로 바꿔준다.

private void Update()
{
    if (agent != null)
    {
        if (agent.velocity.sqrMagnitude > 0.2f)
        {
            anims.SetBool(animHash_Walk, true);
        }
        else
        {
            anims.SetBool(animHash_Walk, false);
        }
    }
}

InitMonster 함수는 초기화하는 함수로 몬스터의 ID와 스폰지점을 인자로 받는다. 몬스터의 ID로 Table Data를 가져와 몬스터를 그 데이터로 세팅하고 layer를 Enemy로 설정한다. MonsterBase 스크립트에서 몬스터가 사망했을때 layer를 바꿔서 몬스터가 살아있을때는 플레이어가 통과할 수 없지만 몬스터가 사망했다면 플레이어가 통과할 수 있도록 설정한다. 초기화를 할때 MosnterAI 컴포넌트를 가져와서 StartAI 함수를 호출해서 몬스터의 상태 기계를 설정한다.

virtual public void InitMonster(int tableID, MonsterSpawner spawner)
{
    DataManager.Inst.GetMonsterData(tableID, out monsterData);
    owner = spawner;

    currentHP = monsterData.maxHP;
    agent.speed = monsterData.moveSpeed;
    agent.stoppingDistance = 2.0f;
    gameObject.layer = LayerMask.NameToLayer("Enemy");
    
    if (TryGetComponent<MonsterAI>(out monsterAI))
    {
        monsterAI.StartAI();
    }
}

CalculateDamage 함수에서는 몬스터가 플레이어에게서 데미지를 받았을때 방어력같은 것들을 적용해서 최종적으로 몬스터가 받을 데미지를 계산하는 것이다. AttackTarget으로 MonsterAI에서 설정한 타겟을 공격하도록 한다. TakeDamage함수는 damage와 공격한 오브젝트를 받아와서 코루틴을 실행한다.

public void TakeDamage(float damage, GameObject attacker)
{
    if (currentHP > 0)
    {
        currentHP -= CalculateDamage(damage);
        if (currentHP <= 0)
        {
            StartCoroutine(OnDie());
        }
        else
        {
            StartCoroutine(OnHit());
        }
    }
}
IEnumerator OnDie()
{
    anims.SetTrigger(animHash_Die);
    monsterAI.ChangeAIState(AI_State.Die);
    gameObject.layer = LayerMask.NameToLayer("DieChar");

    yield return YieldInstructionCache.WaitForSeconds(2.5f);

    owner.ReturnPool(this);
}
IEnumerator OnHit()
{
    anims.SetTrigger(animHash_Hit);
    yield return null;
}

OnDie 코루틴에서는 Die 애니메이션을 출력하고 state를 Die로 설정한다. 그리고 layer를 DieChar로 바꿔서 위에서 말했듯이 플레이어가 통과할 수 있도록 한다. 그리고 MonsterSpawner가 갖고있는 오브젝트 풀로 다시 return한다.
이제는 공격 투사체인 Projectile을 만들어준다. 투사체는 Attack모션이 끝날때쯤 적절한 타이밍에 날아가는 것이 중요하다. 그리고 Particle같은 effect를 추가할 수 있는데 여러가지 설정을 할 수 있다. Asset마다 다르지만 여기서는 투사체가 상대에 닿았을때 effect가 발생하도록 Play On Awake를 끄고 effect가 발생할때 Camera가 흔들리는 Camera Shake 옵션을 끈다. 그리고 Projectile도 오브젝트 풀을 이용할 것이기 때문에 effect가 끝나고 distroy되는 것이 아니라 disable되도록 설정한다. Projectile 스크립트에서 Recode.Pools라는 것을 먼저 선언해준다. Redcode라는 곳에서 Asset으로 제공하는 Pool이다. 그리고 Projectile 클래스가 IPoolObject 인터페이스를 상속받도록 한다.
2D에서 투사체는 특정범위를 정해서 그 밖으로 나가면 사라지도록 설정했지만 3D에서는 y축이 추가되었기 때문에 특정범위를 설정하는것이 쉽지않다. 그래서 투사체에 수명(lifeTime)을 추가해서 수명이 다하면 사라지도록 설정한다.
Projectile의 멤버 선언을 할때 lifeTime과 어느 PoolManager에 의해서 제어되는지를 설정한다. Particle의 제어를 위해 ParticleSystem 클래스의 변수를 선언한다. 또 누가 쐈는지에 대해 Tag로 저장한다.
Awake 함수에서 투사체 오브젝트와 particle 오브젝트를 설정한다.

private void Awake()
{
    arrowObject = transform.GetChild(0).gameObject;
    arrowObject?.SetActive(false);
    transform.GetChild(1).TryGetComponent<ParticleSystem>(out particle);
}

InitProjectile 함수를 통해 Projectile을 초기화한다. 방향과 속도, 수명, 없어져야하는 시간, 데미지, owner의 태그, 자신의 PoolManager를 설정하고 오브젝트를 활성화한다. 초기화가 되면 isInit변수도 true로 설정한다.

public void InitProjectile(Vector3 dir, float speed, float newLifeTime, 
	float damage, string tag, PoolManager pool)
{
    moveDir = dir;
    moveSpeed = speed;
    lifeTime = newLifeTime;
    selfDestroyTime = Time.time + lifeTime;
    attackDamage = damage;
    ownerTag = tag;
    poolManager = pool;

    arrowObject?.SetActive(true);
    isInit = true;
}

투사체가 없어져야하는 시간인 selfDestroyTime은 현재 시간에 lifeTime을 더해서 저장해서 투사체가 생성되었을 때 현재 시간에서 lifeTime만큼 지났으면 스스로 사라지도록한다. 그것은 Update 함수에서 처리한다.

private void Update()
{
    if (isInit)
    {
        transform.position += moveDir * (moveSpeed * Time.deltaTime);
        if (Time.time > selfDestroyTime)
        {
            Explosion();
        }
    }
}

Update 함수에서는 투사체의 이동과 수명을 다룬다. 수명이 다했을 때는 Explosion 함수를 호출해서 사라지도록한다. Explosion 함수는 투사체에 닿았을때 데미지를 입히는 ApplyDamage 함수를 호출하고 particle이 재생되도록한다. 그리고 투사체를 비활성화한뒤 오브젝트 풀로 보낸다.

private void Explosion()
{
    ApplyDamage();
    particle.Play();
    isInit = false;
    arrowObject?.SetActive(false);

    Invoke("ReturnPool", 3.0f);
}

ApplyDamage 함수는 투사체에 부딪혔을때 주변의 오브젝트들도 스플레시 데미지를 받도록 설계했다. Physics.OverlapSphere 함수를 사용하면 지정된 위치에서 특정 범위만큼의 오브젝트들을 받아올 수 있다. 그 오브젝트들을 Collider 배열에 저장해서 그 오브젝트들에 데미지를 입힌다.

public void ApplyDamage()
{
    Collider[] cols = Physics.OverlapSphere(transform.position, 3.0f);

    for (int i = 0; i < cols.Length; i++)
    {
        if (!cols[i].CompareTag(ownerTag) && TryGetComponent<IDamaged>(out IDamaged damage))
        {
            damage.TakeDamage(attackDamage, gameObject);
        }
    }
}

Projectile을 다시 오브젝트 풀로 보내는 함수는 ReturnPool 함수인데 poolManager.TakeToPool이라는 함수를 사용해서 자기 자신을 Projectile을 관리하는 Pool로 보낸다.

public void ReturnPool()
{
    poolManager.TakeToPool<ProjectileBase>(poolManagerName, this);
}

Projectile의 오브젝트 풀은 Enemy가 관리하게 되는데 Enemy에 컴포넌트로 PoolManager를 추가한다. 그리고 Pools에다 오브젝트 풀로 제어할 컴포넌트(Recode Pool에서는 오브젝트가 아닌 컴포넌트로 오브젝트 풀을 제어한다)를 지정하고 개수와 이름을 정한다.

IPoolObject 인터페이스의 함수로 OnCreateInPool 함수와 OnGettingFromPool 함수가 있는데 이름에서 알 수 있듯이 OnCreateInPool은 오브젝트 풀에 오브젝트를 미리 만들어 놓는 것이고 OnGettingFromPool은 풀에 있는 오브젝트를 꺼내서 사용하는 것이다.
이제 몬스터를 생성할 MonsterSpawner 스크립트를 구현한다. MonsterSpawner 오브젝트는 몬스터를 오브젝트 풀로 제어하기 위해 PoolManager 컴포넌트를 갖고 있다. MonsterSpawner 오브젝트는 Player가 가까이 가면 TrySpawn이라는 코루틴을 시작해서 몬스터를 스폰하고 Palyer가 벗어나면 코루틴을 정지한다. TrySpawn 코루틴에서는 2.5초마다 Spawn 함수를 호출해서 몬스터를 생성한다. 몬스터는 풀에서 꺼내서 랜덤한 위치에 생성한다. 그리고 ReturnPool을 이용해서 몬스터를 다시 Pool에 넣는다.

private void OnTriggerEnter(Collider other)
{
    if (other.CompareTag("Player"))
    {
        StartCoroutine("TrySpawn");
    }
}
private void OnTriggerExit(Collider other)
{
    if (other.CompareTag("Player"))
    {
        StopCoroutine("TrySpawn");
    }
}
IEnumerator TrySpawn()
{
    while(true)
    {
        yield return YieldInstructionCache.WaitForSeconds(2.5f);

        if (curCount < maxCount)
        {
            // spawn monster
            Spawn();
        }
    }
}
private Vector3 spawnPos;
private MonsterBase monster;
private void Spawn()
{
    monster = Pool.GetFromPool<MonsterBase>(0);

    if (monster != null)
    {
        spawnPos = transform.position;
        spawnPos.x += Random.Range(-10.0f, 10.0f);
        spawnPos.y += Random.Range(-10.0f, 10.0f);

        monster.transform.position = spawnPos;
        monster.InitMonster(spawnMonsterTableID, this);
        curCount++;
    }
}
public void ReturnPool(MonsterBase monster)
{
    Pool.TakeToPool<MonsterBase>(monster.PoolName, monster);
    curCount--;
}

더 자세한 구현은 다음 주에 한다.

0개의 댓글