TIL

TIL 20231208 / 팀프로젝트 / NPC 생성 방식 변경

도도9999 2023. 12. 8. 22:34

지난 몇주간 여러 작업을 했지만 이번 주차는 특히 짧은 기간에 많은 작업을 했다.

지난주 금요일 빌드 후 처음 사람들에게 공개했고, 그 후로부터 많은 피드백을 받으며 고칠 것들이 정말 많이 생겨났다.

그 중 가장 기초가 되던 NPC Spawner를 뒤집어 엎은게 가장 컸다.

 

- 기존의 NPC 생성 방식

기존 NPC의 생성과 움직임

 

기존 NPC는 NPC 이미지가 단순하게 움직이는 형식이었다.

그리고 그 NPC는 NPC 아래에 한 오브젝트가 존재하고, 그 오브젝트에 정보를 부여하는 식으로 관리되었다.

NPC 오브젝트와 NPC에 데이터를 부여하는 스크립트

NPC 오브젝트에 할당된 NPC 스크립트에 Scriptable Object가 가진 ID값을 전달하고,

해당 ID 값을 바로 위에 있는 NPC Image Controller가 읽어 이미지를 변경하는 방식이었다.

 

그러나 NPC와 관련된 디자인 작업이 끝난 후,

NPC는 SPUM이라는 에셋을 통해 NPC 이미지를 에셋에 집어넣어 애니메이션과 연동되는 하나의 오브젝트로 새로 태어나게 되었다.

 

팀원분께서 캐릭터를 직접 디자인해주었고
그 캐릭터를 에셋을 통해 머리부터 팔다리까지 다 분절시켜 하나의 오브젝트로 만들었고
모든 캐릭터들을 프리팹화했다.
그 후, NPC의 등장 시 이와 같은 애니메이션이 적용되도록 했다.

 

 

중요한 건, 기존에는 NPC SO의 ID, Item SO의 ID를 받아와 씬에 존재하는 한 오브젝트의 이미지만 바꾸었다면

주된 변경된 사항은 NPC 오브젝트 자체가 생성되기 때문에

1. NPC SO가 필요없게 되었고

2. 애니메이션 적용 방식이 완전히 달라졌고

3. 모든 프리팹을 Pool에 넣어서 하나씩 생성시키는 방식

으로 변경해야 했다.

 

기존 NPC Spawner와 새로 만든 NPC Spawner 스크립트이다.

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

public class NPCSpawner : MonoBehaviour
{
    public List<GameObject> npcObjects;
    public int numberOfNPCs = 5;
    private float timer;
    public List<NPCData> npcDataList;
    public List<ItemSO> allItems;
    private List<NPCData> spawnedNPCDataList = new List<NPCData>();
    private int spawnedNPCsCount = 0;
    private const int MaxNPCsPerDay = 5;

    public void Start()
    {
        Initialize();
    }

    public void Initialize()
    {
        npcDataList = new List<NPCData>(DataManager.Instance.npcSOs);
        allItems = DataManager.Instance.GetWeaponItems();

        foreach (var npc in npcObjects)
        {
            npc.SetActive(false);
        }
    }

    private void Update()
    {
        timer += Time.deltaTime;

        if (timer >= 1f && spawnedNPCsCount < MaxNPCsPerDay)
        {
            SpawnNPC();
            timer = 0;
        }
    }

    public void DeactivateAllNPCs()
    {
        foreach (var npc in npcObjects)
        {
            npc.SetActive(false);
        }
    }

    private void SpawnNPC()
    {
        foreach (var npcObject in npcObjects)
        {
            if (!npcObject.activeInHierarchy)
                if (!npcObject.activeInHierarchy)
                {
                    NPCMovement npcMovement = npcObject.GetComponent<NPCMovement>();
                    npcMovement.SetStartPosition(); 

                    NPC npcComponent = npcObject.GetComponent<NPC>();
                    NPCImageController npcImageController = npcObject.GetComponent<NPCImageController>();
                    Animator npcAnimator = npcObject.GetComponent<Animator>();
                    NPCData selectedNPCData = ChooseRandomNPCData();
                    ItemSO selectedItem = ChooseRandomItem();

                    if (npcComponent != null && npcImageController != null && selectedNPCData != null && selectedItem != null)
                    {
                        npcComponent.NPCInit(selectedNPCData, selectedItem);
                        npcImageController.Initialize(selectedNPCData.npcID, selectedItem.itemID);
                        AnimatorOverrideController overrideController = new AnimatorOverrideController(npcAnimator.runtimeAnimatorController);
                        overrideController["Base Layer.Idle"] = selectedNPCData.idleAnimation;
                        overrideController["Base Layer.Walk"] = selectedNPCData.walkAnimation;
                        npcAnimator.runtimeAnimatorController = overrideController;
                    }

                    npcObject.SetActive(true); 
                    spawnedNPCsCount++;

                }
        }
    }

    public void ResetDay()
    {
        foreach (var npcObject in npcObjects)
        {
            NPCMovement npcMovement = npcObject.GetComponent<NPCMovement>();
            if (npcMovement != null)
            {
                npcMovement.TurnAround();
                npcMovement.WalkOutOfScreen();
            }
        }
        spawnedNPCsCount = 0;
        timer = 0;
    }

    private NPCData ChooseRandomNPCData()
    {
        if (npcDataList.Count > 0)
        {
            int index = Random.Range(0, npcDataList.Count);
            return npcDataList[index];
        }
        return null;
    }

    private ItemSO ChooseRandomItem()
    {
        int forgeLevel = Player.Instance.GetForgeLevel();
        List<ItemSO> filteredItems = allItems.Where(item => item.UseLevel <= forgeLevel).ToList();

        if (filteredItems.Count > 0)
        {
            int index = Random.Range(0, filteredItems.Count);
            return filteredItems[index];
        }
        return null;
    }
}

 

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

public class NPCSpawner : MonoBehaviour
{
    public List<GameObject> npcPool;
    public Vector3 spawnPoint;
    public List<ItemSO> allItems;
    private int spawnedNPCsCount = 0;
    private const int MaxNPCsPerDay = 5;
    private List<GameObject> activeNPCs = new List<GameObject>(); 

    public void Start()
    {
        Initialize();
        TrySpawnNPC(); 
    }

    private void Initialize()
    {
        allItems = DataManager.Instance.GetWeaponItems();
        DeactivateAllNPCs();
    }

    private void ActivateNPC()
    {
        if (spawnedNPCsCount < MaxNPCsPerDay && npcPool.Count > 0)
        {
            GameObject npcPrefab = npcPool[Random.Range(0, npcPool.Count)];
            GameObject npcInstance = Instantiate(npcPrefab, spawnPoint, Quaternion.identity, transform);
            npcInstance.SetActive(true); 
            AssignRandomItemToNPC(npcInstance);
            activeNPCs.Add(npcInstance); 
            spawnedNPCsCount++;

            SoundManager.Instance.RandomSfxPlay(new Enums.SFX[]
            {
            Enums.SFX.Npc_Hello,
            Enums.SFX.Npc_WhoAreYou,
            Enums.SFX.Npc_YesYes
            });
        }
    }

    public void TrySpawnNPC()
    {
        if (activeNPCs.Count == 0 && spawnedNPCsCount < MaxNPCsPerDay)
        {
            ActivateNPC();
        }
    }


    public int GetSpawnedNPCsCount()
    {
        return spawnedNPCsCount;
    }

    public int GetMaxNPCsPerDay()
    {
        return MaxNPCsPerDay;
    }

    private GameObject GetInactiveNPCFromPool()
    {
        return npcPool.FirstOrDefault(npc => !npc.activeSelf);
    }

    private void AssignRandomItemToNPC(GameObject npcObject)
    {
        ItemSO randomItem = ChooseRandomItem();
        NPC npcComponent = npcObject.GetComponent<NPC>();
        NPCImageController imageController = npcObject.GetComponent<NPCImageController>();

        if (randomItem != null && npcComponent != null && imageController != null)
        {
            npcComponent.NPCInit(randomItem);
            imageController.Initialize(randomItem.itemID);
        }
    }

    public void NPCCompleted(GameObject completedNPC)
    {
        NPCMovement npcMovement = completedNPC.GetComponent<NPCMovement>();
        if (npcMovement != null)
        {
            npcMovement.OnExitComplete += () =>
            {
                DestroyNPC(completedNPC);
                TrySpawnNPC(); 
            };
            npcMovement.WalkOutOfScreen(); 
        }
        else
        {
            DestroyNPC(completedNPC);
            TrySpawnNPC();
        }
    }

    private void DestroyNPC(GameObject npc)
    {
        activeNPCs.Remove(npc);
        Destroy(npc);
    }

    private ItemSO ChooseRandomItem()
    {
        int forgeLevel = ForgeManager.Instance.ForgeLevel;
        List<ItemSO> filteredItems = allItems.Where(item => item.UseLevel <= forgeLevel).ToList();

        if (filteredItems.Count > 0)
        {
            int index = Random.Range(0, filteredItems.Count);
            return filteredItems[index];
        }
        return null;
    }

    public void DeactivateAllNPCs()
    {
        foreach (var npc in npcPool)
        {
            npc.SetActive(false);
        }
        spawnedNPCsCount = 0;
    }
    public List<GameObject> GetActiveNPCs()
    {
        return activeNPCs;
    }

    public void ResetDay()
    {
        StartCoroutine(ResetDayCoroutine());
    }

    private IEnumerator ResetDayCoroutine()
    {
        foreach (var npc in activeNPCs)
        {
            NPCMovement npcMovement = npc.GetComponent<NPCMovement>();
            if (npcMovement != null)
            {
                npcMovement.OnExitComplete += () => DestroyNPC(npc);
                npcMovement.WalkOutOfScreen();
                yield return new WaitForSeconds(1.0f); 
            }
            else
            {
                DestroyNPC(npc);
            }
        }
        yield return new WaitUntil(() => activeNPCs.Count == 0);
        spawnedNPCsCount = 0;
        TrySpawnNPC(); 
    }
}

 

너무 길고 정리되어 있지 못해 확인하기도 힘들지만 그냥 아예 다른 스크립트가 되어버렸다.

 

핵심은 기존에는 씬에 존재하는 하나의 오브젝트에 NPC, Item SO에 담긴 정보를 집어넣고, 그 정보를 기준으로 의뢰를 검증했다면,

변경 후에는 NPC 각각의 프리팹을 만들어, NPC가 생성되면 Item SO에 담긴 정보를 할당시키고, 그 정보를 기준으로 의뢰를 검증했다는 것이다.

즉 Scriptable Object에서 일종의 Object Pooling이 된 것이다.

 

또한 음성, 튜토리얼, 각종 버그 등 사용자들의 리뷰를 통해 어떤 점들이 많이 부족한지 깨달았고

피드백이 들어오니 할일이 정말 많이 몰아친 것 같다.

지금 생각하면 내가 담당했던 파트에서는 일단 거의 모든 구현이 끝난 것 같다.

자잘하게 튜토리얼 이미지가 변경되었다거나 한 부분이 있겠지만 그래도 잘 마무리한 것 같아 후련하다.

 

아쉬운 점은 막판에 코드를 뒤집었다보니 코드가 좀 깔끔하지 못하다는 것이다.

튜터님의 중간 피드백을 받고 한주 내내 코드를 정리했던 기간이 있었다.

그런데 그것들이 다시 다 원점으로 되돌아간 느낌..

이제는 최종 발표까지 지금까지 작업한 코드들을 정리하는 시간을 가져봐야겠다.