[패스트캠퍼스 수강 후기] 올인원 패키지 : 유니티 포트폴리오 완성 100% 환급 챌린지 19회차 미션 시작합니다.

02 디아블로 게임 - 33. 34 챕터를 진행합니다.




PlayerInventory를 만들어서 등록 가능한 아이템들의 데이터 정보를 확인할 수 있습니다. 아직 등록된 아이템이 없기 때문에 내용은 없고 id도 -1인 상태로 등록된 아이템이 없음을 나타냅니다.




인벤토리에 사용하는 이미지들도 위와 같이 등록하여 설정해 줍니다.





현재까지 기본적인 Interface UI는 구현이 된 상태이고, DynamicInventoryUI를 구현해 보도록 하겠습니다.

public class DynamicInventoryUI_New : InventoryUI_New
{
    [SerializeField]
    protected GameObject slotPrefab; // slot에 대한 UI 오브젝트 Prefab
    
    // 격자 형태의 slot 구성.
    [SerializeField]
    protected Vector2 start;
    [SerializeField]
    protected Vector2 size;
    [SerializeField]
    protected Vector2 space;
    
    [Min(1), SerializeField]
    protected int numberOfColumn = 4;
    
    public override void CreateSlotUIs() {
        slotUIs = new Dictionary<GameObject, InventorySlot_New>();
        
        for (int i = 0; i < inventoryObject.Slots.Length; i++) {
            GameObject go = Instantiate(slotPrefab, Vector3.zero, Quaternion.identity, transform);
            go.GetComponent<RectTransform>().anchoredPosition = CalculatePosition(i);
            
            // 마우스 이벤트 트리거들 추가.
            AddEvent(go, EventTriggerType.PointerEnter, delegate { OnEnterSlot(go); });
            AddEvent(go, EventTriggerType.PointerExit, delegate { OnExitSlot(go); });
            AddEvent(go, EventTriggerType.BeginDrag, delegate { OnStartDrag(go); });
            AddEvent(go, EventTriggerType.EndDrag, delegate { OnEndDrag(go); });
            AddEvent(go, EventTriggerType.Drag, delegate { OnDrag(go); });
            
            inventoryObject.Slots[i].slotUI = go;
            slotUIs.Add(go, inventoryObject.Slots[i]);
            
            go.name += ": " + i;
        }
    }
    
    public void CalculatePosition(int i) {
        float x = start.x + ((space.x + size.x) * (i % numberOfColumn));
        float y = start.y + (-(start.y) + size.y) * (i / numberOfColumn));
        return new Vector3(x, y, 0f);
    }
}


CreateSlotUIs()가 가장 중요한 함수라고 볼 수 있겠습니다.

이와 관련하여 SlotPrefab의 구성은 다음과 같아야 합니다.





Slot의 배경 이미지와 Icon이 들어갈 위치, 그리고 하단에 Text가 배치되어 있어야 합니다. 왜냐면 소스 코드 구성시 Slot의 구성이 Icon과 Text가 무조건 존재해야만 구동이 되도록 프로그래밍되어 있기 때문입니다.


그리고 위에 구성한 DynamicInventoryUI_New 스크립트를 Unity UI 상에서 설정해줍니다.



DynamicInventory에다가 DynamicInventoryUI_New 스크립트를 추가하여주고,
+ Inventory Object = "PlayerInventory"
+ Slot Prefab = "SlotPrefab"
+ Start = -70, 120
+ Size = 50, 50
+ Space = 1, 1
+ Number Of Column = 4
으로 설정하여 줍니다.




플레이를 해보면 위와 같이 인벤토리 구역 내에 Slot 24개가 잘 배치되어 나타나는 것을 확인할 수 있습니다.

이것이 잘 동작하는지를 확인하는 TestItems 스크립트를 작성하면 쉽게 동작여부를 확인하기 수월합니다.




AddNewItem() 함수는 databaseObject에서 랜덤하게 아이템을 하나 얻어와서 AddItem()으로 추가해주고,
CreateInventory() 함수는 등록된 inventoryObject를 모두 제거해주는 함수입니다.




플레이를 진행해 봅니다.
Add Item을 클릭하면 랜덤하게 하나씩 추가되고, Slot간 아이템 이동과, 아이템 교체, 인벤토리가 아닌 영역에 드래그하여 아이템 버리기, Cleate Items를 클릭하여 모두 비우기 등의 모든 테스트를 해볼 수 있습니다.




물약과 음식과 같은 겹쳐질 수 있는 아이템 테스트입니다.
그림과 같이 동일한 아이템이 추가되는 경우 Stackable[v]이 켜져 있는 아이템의 경우에는 비어 있는 Slot에 또 추가되는 것이 아니라 기존에 있던 아이템에 동일하게 추가를 하고 Text로 개수를 표시하는 방식으로 구현되었습니다.



이번에는 장비 창 인벤토리를 구현해 보도록 하겠습니다.


public class StaticInventoryUI_New : MonoBehaviour
{
    public GameObject[] staticSlots = null; // 고정된 Slot.
    
    public override void CrateSlotUIs() {
        slotUIs = new Dictionary<GameObject, FastCampus.InventorySystem.Inventory.InventorySlot_New>();
        for (int i = 0; i < inventoryObject.Slots.Length; i++) {
            GameObject go = staticSlots[i]; // Scene에 등록된 고정된 UI를 사용함이 다른점.
            go.GetComponent<RectTransform>().anchoredPosition = CalculatePosition(i);
            
            // 마우스 이벤트 트리거들 추가.
            AddEvent(go, EventTriggerType.PointerEnter, delegate { OnEnterSlot(go); });
            AddEvent(go, EventTriggerType.PointerExit, delegate { OnExitSlot(go); });
            AddEvent(go, EventTriggerType.BeginDrag, delegate { OnStartDrag(go); });
            AddEvent(go, EventTriggerType.EndDrag, delegate { OnEndDrag(go); });
            AddEvent(go, EventTriggerType.Drag, delegate { OnDrag(go); });
            
            inventoryObject.Slots[i].slotUI = go;
            slotUIs.Add(go, inventoryObject.Slots[i]);
            
            go.name += ": " + i;
        }
    }
}





여기까지 작업하고 플레이를 해봅니다.
보시는 것과 같이 장비 착용 인벤토리에 아이템을 드래그하여 설정이 가능하고, 또한 동일한 타입이 아닌 아이템은 착용이 되지 않는 것도 확인할 수 있습니다.





이제는 캐릭터 장비 교체를 구현해보는 시간입니다 ^^~
캐릭터는 보통 다수의 Skinned mesh와 Static mesh로 구현되어 있습니다.

Skinned mesh는 그림의 사람처럼 Bone이라는 뼈대로 구성된 것 위에 영향을 받는 Vertex들이 구성되어 있는 mesh 구조입니다. 투구나 갑옷 등 몸에 착용되는 아이템은 1개의 뼈대를 가지는 아이템으로 구성되는 경우가 많습니다.
Static mesh는 우측 하단 그림의 칼처럼 Bone이 없이 Vertex들로만 구성된 Mesh Object 구조입니다.

Skinned mesh, 즉 사람 GameObject에 아이템을 입히는 효과를 처리하려면, Human Bone 구조를 가지는 것 하나에 아이템들을 얻혀서 하나처럼 조합시키는 작업을 해주어야 합니다.




EquipmentCombiner가 뼈대 정보를 가지고 있다고 보면 됩니다.


public class EquipmentCombiner_New
{
    private readonly Dictionary<int, Transform> rootBoneDictionary = new Dictionary<int, Transform>(); // 모든 뼈대 정보
    private readonly Transform transform;
    
    public EquipmentCombiner_New(GameObject rootGO) {
        transform = rootGO.transform;
        TraverseHierachy(transform);
    }
    
    public Transform AddLimb(GameObject itemGO, List<string> boneNames) {
        Transform limb = ProcessBoneObject(itemGO.GetComponentInChildren<SkinnedMeshRenderer>(), boneNames);
        limb.SetParent(transform);
        return limb;
    }
    
    // 새로운 게임 오브젝트에 기존의 Skinned Mesh를 복사.
    private Transform ProcessBoneObject(SkinnedMeshRenderer renderer, List<string> boneNames) {
        Transform itemTransform = new GameObject().transform;
        SkinnedMeshRenderer meshRenderer = itemTransform.gameObject.AddComponent<SkinnedMeshRenderer>();
        Transform[] boneTransforms = new Transform[boneNames.Count];
        for (int i = 0; i < boneNames.Count; i++) {
            boneTransforms[i] = rootBoneDictionary[boneNames[i].GetHashCode()];
        }
        
        meshRenderer.bones = boneTransforms;
        meshRenderer.sharedMesh = renderer.sharedMesh;
        meshRenderer.materials = renderer.sharedMaterials;
        
        return itemTransform;
    }
   
    // Bone을 포함하지 않는 Static Mesh 처리
    public Transform[] AddMesh(GameObject itemGO) {
        Transform[] itemTransforms = ProcessMeshObject(itemGO.GetComponentsInChildren<MeshRenderer>());
        return itemTransforms;
    }
    
    public Transform[] ProcessMeshObject(MeshRenderer[] meshRenderers) {
        List<Transform> itemTransforms = new List<Transform>();
        foreach (MeshRenderer renderer in meshRenderers) {
            if (renderer.transform.parent != null) {
                Transform parent = rootBoneDictionary[renderer.transform.parent.name.GetHashCode()];
                GameObject itemGO = GameObject.Instantiate(renderer.gameObject, parent);
                itemTransforms.Add(itemGO.transform);
            }
        }
        return itemTransforms.ToArray();
    }
    
    private void TraverseHierachy(Transform root) {
        foreach (Transform child in root) {
            rootBoneDictionary.Add(child.name.GetHashCode(), child);
            TraverseHierachy(child);
        }
    }
}




public class PlayerEquipment_New : MonoBehaviour
{
    public InventoryObject equipment;
    private EquipmentCombiner_New combiner;
    private ItemInstances_New[] itemInstances = new ItemInstances_New[8];
    public ItemObject[] defaultItemObjects = new ItemObject[8];
}

여기까지가 구현의 일부입니다. 다음 시간에 이어서 구현을 합니다.



오늘은 굉장히 중요한 내용들이 많았습니다. 작성하기도 힘들었네요 헉헉..
인벤토리 구성을 위한 스크립트와 Unity 설정까지 모두 알아보았으며,
인벤토리에서 장비장착 인벤토리로 아이템을 설정할 때 실제 캐릭터에 장착되는 구현을 하고 있습니다.
그러다보니 중요한 내용도 많고 코드도 많고, Unity 설정도 엄청 많습니다. ㅎㅎ
실제로 처음부터 백지에서 시작해서 구현하려면 엄청난 시간이 필요할 것 같네요 ㅠ.,ㅜ;





<위의 코드들은 제가 보면서 주요한 함수, 코드를 확인하기 위해 타이핑한 용도로, 전체 소스코드가 아님에 주의해 주세요. 전체 코드는 교육 수강을 하면 완벽하게 받으실 수가 있답니다 ^^>

패스트캠퍼스 - 올인원 패키지 : 유니티 포트폴리오 완성 bit.ly/2R561g0

 

유니티 게임 포트폴리오 완성 올인원 패키지 Online. | 패스트캠퍼스

게임 콘텐츠 프로그래머로 취업하고 싶다면, 포트폴리오 완성은 필수! '디아블로'와 '배틀그라운드' 게임을 따라 만들어 보며, 프로그래머 면접에 나오는 핵심 개념까지 모두 잡아 보세요!

www.fastcampus.co.kr

 


[패스트캠퍼스 수강 후기] 올인원 패키지 : 유니티 포트폴리오 완성 100% 환급 챌린지 18회차 미션 시작합니다.


02 디아블로 게임 - 31. 인벤토리와 아이템 구현하기 - 02 인벤토리 시스템 구현하기
02 디아블로 게임 - 32. 인벤토리와 아이템 구현하기 - 03 플레이어와 상점을 위한 인벤토리 UI 구현하기




ItemDatabaseobject는 모든 아이템을 가지는 형태로 고려되어 있기때문에 InventoryObject에 할당될 때 문제가 없지만, 칼/투구/장갑 등 종류별로 ItemDatabaseObject를 구성하고자 하는 경우에는 id의 충돌이 발생하게 됩니다.
이를 간단히 피하기 위해서 baseId를 추가하여 처리하는 방식을 고려할 수 있습니다. 칼은 100번대, 투구는 200번대, 장갑은 300번대 baseId를 고정시켜두고 내부 id가 baseId에서 증가되는 형태로 제작할 수 있는 것입니다.





InventoryObject_New 스크립트를 생성합니다. UI와 관련이 높은 클래스입니다.

public enum InterfaceType_New { // 인벤토리 종류
    Inventory,
    Equipment,
    QuickSlot,
    ChestBox,
}

[CreateAssetMenu(fileName = "New Inventory", menuName = "Inventory System/Inventory_New")]
public class InventoryObject_New : ScriptableObject
{
    public ItemObjectDatabase_New database;
    public InterfaceType_New type;
    
    [SerializeField]
    private Inventory_New container = new Inventory_New();
    
    public InventorySlot[] Slots => container.slots;
    
    public int EmptySlotCount {
        get {
            int counter = 0;
            foreach (InventorySlot_New slot in Slots)
                if (slot.item.id < 0)
                    counter++;
            return counter;
        }
    }
    
    public bool AddItem(Item_New item, int amount) {
        if (EmptySlotCount <= 0) return false;
        InventorySlot_New slot = FindItemInInventory(item);
        if (!database.itemObjects[item.id].stackable || slot)
            GetEmptySlot().AddItem(item, amount);
        else
            slot.AddAmount(amount);
        return true;
    }
    
    public InventorySlot_New FindItemInInventory(Item_New item) {
        return Slots.FindOrDefault(i => i.item.id == item.id);
    }
    
    public InventorySlot_New GetEmptySlot() {
        return Slots.FirstOrDefault(i => i.item.id < 0);
    }
    
    public bool IsContainItem(ItemObject_New itemObject) {
        return Slots.FirstOrDefault(i => i.item.id == itemObject.data.id) != null;
    }
    
    public void SwapItems(InventorySlot_New itemSlotA, InventorySlot_New itemSlotB) {
        if (itemSlotA == itemSlotB) return;
        if (itemSlotB.CanPlaceInSlot(itemSlotA.ItemObject) && itemSlotA.CanPlaceInSlot(itemSlotB.ItemObject)) {
            InventorySlot tempSlot = new InventorySlot_New(itemSlotB.item, itemSlotB.amount);
            itemSlotB.UpdateSlot(itemSlotA.item, itemSlotA.amount);
            itemSlotA.UpdateSlot(tempSlot.item, tempSlot.amount);
        }
    }
}




이제 InventoryUI를 구현해볼 차례입니다.



UI 시스템은 다른 입력시스템이나 이벤트 트리거 등과 연관이 많이 되는 부분이라 복잡해지는 경향이 있습니다.

여기에서 InventoryUI는 실제 InventorySlot UI와 EventManager를 중재해주는 컨트롤러의 역할입니다.

즉, InventoryObject라는 Model, InventorySlot & EventManager라는 View, InventoryUI라는 Controller로 구분하여 MVC 모델을 구현한 것입니다.

StaticInventoryUI는 형태가 고정된 인벤토리 UI이며, DynamicInventoryUI는 형태가 고정되지 않은 인벤토리 UI입니다.
StaticInventoryUI는 장비창, DynamicInventoryUI는 인벤토리창으로 구현합니다.


 


public static class MouseData_New {
    public static InventoryUI interfaceMouseIsOver; // 어느 UI에 있는가?
    public static GameObject slotHoveredOver; // 어느 Slot 위에 있는가?
    public static GameObject tempItemBeingDragged;
}

[RequireComponent(typeof(EventTrigger))]
public abstract class InventoryUI_New : MonoBehaviour
{
    public InventoryObject_New inventoryObject;
    private InventoryObject_New previousInventoryObject;
    
    public Dictionary<GameObject, InventorySlot_New> slotUIs = new Dictionary<GameObject, InventoryObject_New>();
    
    private void Awake() {
        CreateSlotUIs();
        for (int i = 0; i < inventoryObject.Slots.Length; i++) {
            inventoryObject.Slots[i].parent = inventoryObject; // 초기화
            inventoryObject.Slots[i].OnPostUpdate += OnPostUpdate;
        }
        
        AddEvent(gameObject, EventTriggerType.PointerEnter, delegate { OnEnterInterface(gameObject); });
        AddEvent(gameObject, EventTriggerType.PointerExit, delegate { OnExitInterface(gameObject); });
    }
   
    protected virtual void Start() {
        for (int i = 0; i < inventoryObject.Slots.Length; i++) {
            inventoryObject.Slots[i].UpdateSlot(inventoryObject.Slots[i].item, inventoryObject.Slots[i].amount);
        }
    }
    
    public abstract CreateSlotUIs();
    
    protected void AddEvent(GameObject go, EventTriggerType type, UnityAction<BaseEventData> action) {
        EventTrigger trigger = go.GetComponent<EventTrigger>();
        if (!trigger) {
            Debug.LogWarning("No EventTrigger component found!");
            return;
        }
        
        EventTrigger.Entry eventTrigger = new EventTrigger.Entry { eventID = type };
        EventTrigger.callback.AddListener(action);
        trigger.triggers.Add(eventTrigger);
    }
    
    public void OnPostUpdate(InventorySlot_New slot) {
        slot.slotUI.transform.GetChild(0).GetComponent<Image>().sprite = slot.item.id < 0 ? null : slot.ItemObject.icon;
        slot.slotUI.transform.GetChild(0).GetComponent<Image>().color = slot.item.id < 0 ? new Color(1, 1, 1, 0) : new Color(1, 1, 1, 1);
        slot.slotUI.GetComponentInChildren<TextMeshProUGUI>().text = slot.item.id < 0 ? string.Empty : (slot.amount == 1);
    }
    
    public void OnEnterInterface(GameObject go) {
        MouseData_New.interfaceMouseIsOver = go.GetComponent<InventoryUI>();
    }
    
    public void OnExitInterface(GameObject go) {
        MouseData_New.interfaceMouseIsOver = null;
    }
    
    public void OnEnterSlot(GameObject go) {
        MouseData_New.slotHoveredOver = go;
    }
    
    public void OnExitSlot(GameObject go) {
        MouseData_New.slotHoveredOver = null;
    }
    
    public void OnStartDrag(GameObject go) {
        MouseData_New.tempItemBeingDragged = CreateDragImage(go);
    }
   
    public void CreateDragImage(GameObject go) {
        if (slotUIs[go].item.id < 0) return null;
        GameObject dragImageGo = new GameObject();
        RectTransform rectTransform = dragImageGo.AddComponent<RectTransform>();
        rectTransform.sizeDelta = new Vector2(50, 50);
        dragImageGo.transform.SetParent(transform.parent);
        Image image = dragImageGo.AddComponent<Image>();
        image.sprite = slotUIs[go].ItemObject.icon;
        image.raycastTarget = false;
        dragImageGo.name = "Drag Image";
        return dragImageGo;
    }
   
    public void OnDrag(GameObject go) {
        if (MouseData_New.tempItemBeingDragged == null) return;
        MouseData_New.tempItemBeingDragged.GetComponent<RectTransform>().position = Input.mousePosition;
    }
    
    public void OnEndDrag(GameObject go) {
        Destroy(MouseData_New.tempItemBeingDragged);
        if (MouseData_New.interfaceMouseIsOver == null)
            slotUIs[go].RemoveItem();
        else if (MouseData_New.slotHoveredOver) {
            InventorySlot_New mouseHoverSlotData = MouseData_New.interfaceMouseIsOver.slotUIs[MouseData_New.slotHoveredOver];
            inventoryObject.SwapItems(slotUIs[go], mouseHoverSlotData);
        }
    }
}


마우스가 아이콘을 클릭하여 드래그를 시작하면 임시 이미지를 만들어서 드래그중에는 마우스포인터 위치에 임시 이미지가 따라다니도록 하는 루틴입니다.
마우스를 특정 위치에서 놓았을 때에는 특정 UI에 있지 않은 경우라면 아이템을 버리는 것으로 보고 삭제합니다.
인터페이스위 슬롯 위에 있다면 아이템을 서로 교체하는 로직이 구동되게 됩니다.

 

 

오늘도 역시 눈은 즐겁지만 머리는 뱅글뱅글 도는 시간이 되었네요..



<위의 코드들은 제가 보면서 주요한 함수, 코드를 확인하기 위해 타이핑한 용도로, 전체 소스코드가 아님에 주의해 주세요. 전체 코드는 교육 수강을 하면 완벽하게 받으실 수가 있답니다 ^^>

패스트캠퍼스 - 올인원 패키지 : 유니티 포트폴리오 완성 bit.ly/2R561g0

 

유니티 게임 포트폴리오 완성 올인원 패키지 Online. | 패스트캠퍼스

게임 콘텐츠 프로그래머로 취업하고 싶다면, 포트폴리오 완성은 필수! '디아블로'와 '배틀그라운드' 게임을 따라 만들어 보며, 프로그래머 면접에 나오는 핵심 개념까지 모두 잡아 보세요!

www.fastcampus.co.kr

 


[패스트캠퍼스 수강 후기] 올인원 패키지 : 유니티 포트폴리오 완성 100% 환급 챌린지 17회차 미션 시작합니다.




이제부터는 게임내에 인벤토리 시스템을 구현하고, 장비 시스템과 아이템 시스템을 구현합니다.
오늘은 아이템 시스템 구현하기와 플레이어와 상점을 위한 인벤토리 시스템 구현하기의 시작입니다.

인벤토리 시스템은 Scriptable Object를 사용합니다. 이를 사용하면 Game내 데이터 관리와 전역적 접근을 쉽게 해줍니다.




+ 아이템 시스템 구현하기
+ 인벤토리 시스템 구현하기 - 구현된 아이템 시스템 이용.
+ 인벤토리 UI 구현하기 - 인벤토리 시스템에 UI 구현
+ 캐릭터 장비 교체 구현하기
+ 아이템 획득/사용 구현하기





가장 기본이되는 것은 Item 클래스입니다. 캐릭터의 스텟을 변화시키는 기능을 해주는 것이고, Item의 buffs에 ItemBuff 오브젝트를 생성하여 Attach하는 방식으로 구현할 수 있습니다.
ItemObject는 Item을 가지는 데이터 오브젝트입니다.
Item은 캐릭터에 대한 행동을 명시하고, ItemObject는 캐릭터의 행동과 표현방법, 설명 등을 포함하는 것입니다.
그리고 이를 List화하여 가지고 있는 ItemDatabaseObject를 구현하게 됩니다.

먼저 Item 행동에 해당하는 ItemBuff와 Item, ItemObject 컴포넌트 스크립트를 작성합니다.



ItemBuff Script

public enum CharacterAttribute
{
    Agility,
    Intellect,
    Stamina,
    Strength
}

[Serializable]
public class ItemBuff
{
    public CharacterAttribute stat;
    public int value;
    
    [SerializeField]
    private int min;
    [SerializeField]
    private int max;
    
    public int Min => min;
    public int Max => max;
    
    public ItemBuff(int min, int max) {
        this.min = min;
        this.max = max;
        
        GenerateValue();
    }
    
    public void GenerateValue() {
        value = UnityEngine.Random.Range(min, max); // 획득 아이템 랜덤 값
    }
    
    public void AddValue(ref int v) {
        v += value;
    }
}



Item Script

[Serializable]
public class Item
{
    public int id = -1;
    public string name;
    public ItemBuff[] buffs;
    
    public Item() {
        id = -1; // 비어있는 아이템
        name = "";
    }
    
    public Item(ItemObject itemobject) {
        name = itemObject.name;
        id = itemobject.data.id;
        buffs = new ItemBuff[itemObject.data.buffs.Length];
        for (int i = 0; i < buffs.Length; i++) {
            buffs[i] = new ItemBuff(itemobject.data.buffs[i].Min, itemObject.data.buffs[i].Max) {
                stat = itemObject.data.buffs[i].stat;
            };
        }
    }
}




ItemObject Script

public enum ItemType: int
{
    Helmet = 0,
    Chest = 1,
    Pants = 2,
    Boots = 3,
    Pauldrons = 4,
    Gloves = 5,
    LeftWeapon = 6,
    RightWeapon = 7,
    Food = 8,
    Default,
}

[CreateAssetMenu(fileName = "New Item", menuName = "Inventory System/Items/New Item")]
public class ItemObject : ScriptableObject
{
    public ItemType type;
    public bool stackable;
    public Sprite icon;
    public GameObject modelPrefab;
    public Item data = new Item();
    public List<string> boneNames = new List<string>();
    
    [TextArea(15, 20)]
    public string description;
   
    private void OnValidate() {
        boneNames.Clear();
        if (modelPrefab == null || modelPrefab.GetComponentInChildren<SkinnedMeshRenderer>() == null) {
            return;
        }
        
        SkinnedMeshRenderer renderer = modelPrefab.GetComponentInChildren<SkinnedMeshRenderer>();
        Transform[] bons = renderer.bones;
        
        for (Transform t in bones) {
            boneNames.Add(t.name);
        }
    }
    
    public Item CrateItem() {
        Item newItem = new Item(this);
        return newItem;
    }
}



여기까지하여 컴파일이 정상적으로 되면 아래와 같이 Create - Inventory System - Items - New Item 과 같이 추가된 것을 확인할 수 있습니다. 허거거걱... 머리가 핑핑... 눈도 핑핑 @v@~



 

 




이렇게 추가한 아이템들을 관리하는 ItemObjectDatabase 스크립트를 작성합니다. ㅠ


[CreateAssetMenu(fileName = "New Item Database", menuName = "Inventory System/Items/Database_New")]
public class ItemObjectDatabase : ScriptableObject
{
    public ItemObject[] itemObjects;
    
    public void OnValidate() {
        for (int i = 0; i < itemObjects.Length; i++) {
            itemObjects[i].data.id = i;
        }
    }
}


어마어마하네요.. CreateAssetMenu() 함수를 통해서 Unity에 원하는 형태로 Menu를 등록하고, Item 등의 컴포넌트도 생성할 수 있도록 구성한데다가, ItemObjectData를 구현해서 Unity UI 상에서 그냥 드래그해서 자동 추가가 되어버리다니... 헐...
무언가 할당하는 개념이 없고 UI에서 생성, 할당하는 부분들이 많아서 적응은 잘 되지 않고, 언제 왜 써야하는지 애매하긴 하지만 일단 어마한 기능이라고 밖에 말할 수 없겠네요.






이제 Inventory 시스템 구현입니다.
여기서의 메인은 InventorySlot 입니다. 이것은 아이템이 놓이는 공간이지요. 여기서 아이템이 추가, 삭제될 때마다 갱신 메시지를 발생시켜서 Action으로 관리하고, type 지정으로 해당하는 위치에만 놓이도록 하게 만들 예정입니다.

Inventory 부분은 Inventory UI와 연결되는 부분입니다

 



Item을 ItemObject에서 분리하고, Inventory를 InventoryObject와 구분하여 만든 이유는,
Item과 ItemBuff 내용을 그대로 Json으로 저장하기 편하며, Inventory도 클래스 그대로 Json으로 저장하기 편하기 때문입니다.
ItemObject나 InventoryObject는 데이터를 뽑아내는 과정을 거쳐야 처리할 수 있는데 말이지요.
Json으로 저장하지 않는다면 한 클래스로 구현하는 것이 관리상의 편의점은 있겠습니다.


[Serializable]
public class InventorySlot
{
    public ItemType[] allowedItems = new ItemType[0];
    
    [NonSerialized] // Json 저장시 제외
    public InventoryObject parent;
    [NonSerialized]
    public GameObject slotUI;
    [NonSerialized]
    public Action<InventorySlot> OnPreUpdate;
    [NonSerialized]
    public Action<InventorySlot> OnPostUpdate;
    
    public Item item;
    public int amount;
    public ItemObject ItemObject {
        get {
            return item.id >= 0 ? parent.database.itemObjects[item.id] : null;
        }
    }
    
    public InventorySlot() => UpdateSlot(new Item(), 0);
    public InventorySlot(Item item, int amount) => UpdateSlot(item, amount);
   
    public void RemoveItem() => UpdateSlot(new Item(), 0);
    public void AddAmount(int value) => UpdateSlot(item, amount += value);
    
    public void UpdateSlot(Item item, int amount) {
        OnPreUpdate?.Invoke(this);
        this.item = item;
        this.amount = amount;
        OnPostUpdate?.Invoke(this);
    }
    
    public bool CanPlaceInSlot(ItemObject itemObject) {
        if (allowedItems.Length <= 0 || itemObject == null || itemObject.data.id < 0) {
            return true;
        }
        
        foreach (ItemType type in allowedItems) {
            if (itemObject.type == type) {
                return true;
            }
        }
        
        return false;
    }
}



[Serializable]
public class Inventory
{
    public InventorySlot[] slots = new InventorySlot[24];
    
    public void Clear() {
        foreach (InventorySlot slot in slots) {
            slot.RemoveItem(); // or slot.UpdateSlot(new Item(), 0);
        }
    }
   
    public bool IsContain(ItemObject itemObject) {
        return IsContain(itemObject.data.id);
    }
    
    public bool IsContain(int id) {
        return slots.FirstOrDefault(i => i.item.id == id) != null;
    }
}





<위의 코드들은 제가 보면서 주요한 함수, 코드를 확인하기 위해 타이핑한 용도로, 전체 소스코드가 아님에 주의해 주세요. 전체 코드는 교육 수강을 하면 완벽하게 받으실 수가 있답니다 ^^>

패스트캠퍼스 - 올인원 패키지 : 유니티 포트폴리오 완성 bit.ly/2R561g0


 

유니티 게임 포트폴리오 완성 올인원 패키지 Online. | 패스트캠퍼스

게임 콘텐츠 프로그래머로 취업하고 싶다면, 포트폴리오 완성은 필수! '디아블로'와 '배틀그라운드' 게임을 따라 만들어 보며, 프로그래머 면접에 나오는 핵심 개념까지 모두 잡아 보세요!

www.fastcampus.co.kr

 

[패스트캠퍼스 수강 후기] 올인원 패키지 : 유니티 포트폴리오 완성 100% 환급 챌린지 16회차 미션 시작합니다.


전투 시스템의 NPC 전투 UI 구현하기 입니다.




항상 카메라를 바라보는 Billboard 기법라는 것을 사용하여 만들어지게 됩니다.
캐릭터와 UI가 회전을 하더라도 항상 카메라를 바라보게 해서 유저가 항상 동일한 형태의 UI를 볼 수 있도록 하는 기법입니다.

이러한 Billboard를 구현하기 위해 Camera Facing이라는 컴포넌트 스크립트를 구현하도록 합니다.


public class CameraFacing_New : MonoBehaviour
{
    Camera referenceCamera;
    public bool reverseFace = false; // 고정이되는 축과 향하는 방향이 반대로 보여주는 flag
    
    public enum Axis // 특정 축을 기준으로 회전.
    {
        up, down, left, right, forward, back
    }
    public Axis axis = Axis.up;
    
    public Vector3 GetAxis(Axis refAxis)
    {
        switch (refAxis) {
            case Axis.down: return Vector3.down;
            case Axis.forward: return Vector3.forward;
            case Axis.back: return Vector3.back;
            case Axis.left: return Vector3.left;
            case Axis.right: return Vector3.right;
        }
        return Vector3.up;
    }
    
    private void Awake()
    {
        if (!referenceCamera) {
            referenceCamera = Camera.main;
        }
    }
    
    private void LateUpdate()
    {
        Vector3 targetPos = transform.position + referenceCamera.transform.rotation * (reverseFace ? Vector3.forward : Vector3.back);
        Vector3 targetOrientation = referenceCamera.rotation * GetAxis(axis);
        
        transform.LookAt(targetPos, targetOrientation);
    }
}





UI에서 "Slider"를 추가하면 "Canvas"의 자식으로 "Slider"가 추가됩니다.
+ Render Mode를 "Screen Space - Overlay"에서 "World Space"로 변경합니다.
+ Pos X, Y, Z도 모두 "0"으로 지정합니다.
+ Scale X, Y, Z를 모두 "0.05"로 설정합니다.
+ Width = 100, Height = 20




일단 실행해 보면 캐릭터이 회전에 따라 체력바가 따라서 회전하는 것을 볼 수 있는데, 어색하지요? 아직 CameraFacing을 연동하지 않았기 때문입니다.

Canvas에 Camera Facing 컴포넌트를 추가하고 실행하면 아래와 같이 캐릭터가 회전하더라도 Y축으로 고정된 체력바가 고정되어 표시되는 것을 확인할 수 있습니다.





이제 여기 NPC에 HP를 변경하는 스크립트를 추가하도록 하겠습니다.


public class NPCBattleUI_New : MonoBehaviour
{
    private Slider hpSlider;
    
    public float MaximunValue
    {
        get => hpSlider.maxValue;
        set {
            hpSlider.maxValue = value;
        }
    }
    
    public float MinimunValue
    {
        get => hpSlider.minValue;
        set {
            hpSlider.minValue = value;
        }
    }
    
    public float Value
    {
        get => hpSlider.value;
        set {
            hpSlider.value = value;
        }
    }
    
    public void Awake() {
    {
        hpSlider = gameObject.GetComponentInChildren<Slider>();
    }
    
    private void OnEnable()
    {
        GetComponent<Canvas>().enabled = true;
    }
    
    private void OnDisble()
    {
        GetComponent<Canvas>().enabled = false;
    }
}


위와 같이 작업하고 EnemyController_Range_Original.cs에서 battleUI 인스턴스를 사용하여 최대, 최소, 현재 체력값을 설정하도록 수정해 줍니다. 그리고 데미지가 입었을 때 HP가 변화하는 것을 처리하기 위해 아래와 같이 추가해줍니다.

public void TakeDamage(int damage, GameObject hitEffectPrefab)
{
    // 기존 코드 유지
    
    health -= damage;
    
    if (battleUI) {
        battleUI.Value = health;
    }
    
    // 기존 코드 유지
}





현재까지 상태로 실행을 해보면 적캐릭터가 데미지를 입을 때마다 HP가 주는 것을 확인할 수 있습니다. 단지 HP나 숫자가 반대로 표시되는 것을 볼 수 있습니다.

카메라를 UI가 바라보는 상황이 되어 유저가 바라보는 방향이 Flip 상태로 되기 때문에 "Reverse Face [v]"를 체크하여 유저가 바라보는 방향으로 맞추어진 UI로 만들어줍니다.




그 상태로 플레이를 진행하면 정상적으로 표시되는 것을 확인할 수 있습니다.



데미지 관련하여서도.. (캐릭터 위에 표시되는 숫자값) 동일하게 NPC UI를 활용하여 구현하면 됩니다.



TextMeshPro를 사용하여 보도록 하겠습니다. Damage값을 애니메이션으로하여 위로 튀는 것처럼 보여주면 좀더 재미있는 효과가 되므로 해당 애니메이션을 추가하도록 합니다.

 

 



Text 애니메이션 작업은 시간이 걸리는 관계로 미리 제작된 내용으로 진행합니다.
여튼 동작은 그렇습니다. Text가 위로 통통 튀는 애니메이션이고, 위로 올라갈때 Text Alpha값이나, Scale값등이 적용된 애니메이션이라고 보면 됩니다.

동일하게 NPCBattleUI 로 구현한 것이므로 해당 클래스에서 처리하면 됩니다. 추가된 코드만 작성합니다.


public class NPCBattleUI_New : MonoBehaviour
{
    [SerializeField]
    private GameObject damageTextPrefab;
    
    public void CrateDamageText(int damage)
    {
        if (damageTextPrefab != null) {
            GameObject damageTextGO = Instantiate(damageTextPrefab, transform);
            DamageText damageText = damageTextGO.GetComponent<DamageText>();
            if (damageText == null) {
                Destroy(damageTextGO);
            }
            
            damageText.Damage = damage;
        }
    }
}

// 특정 시간후에 DamageText가 사라지도록 구현
public class DamageText_New : MonoBehaviour
{
    public float delayTimeToDestroy = 1.0f;
    
    void Start()
    {
        Destroy(gameObject, delayTimeToDestroy);
    }
}




실행을 해보면 TakeDamage()가 발생할 때마다 DamageText가 애니메이션으로 잘 동작하는 것을 확인할 수 있습니다.
여기까지가 NPC UI를 사용하여 적의 HP바와 DamageText를 표시하도록 구현이 완료되었습니다.

 




이제 캐릭터의 이동지점을 클릭하였을 때 클릭 지점을 표시하는 기능을 만들어 보겠습니다.
Pointer를 하나 추가하고 Position Y 값을 -100으로 설정합니다. Pointer의 Enable/Disable을 사용하여 보였다 안 보였다 하는 형태를 취할 수도 있고, 여기에서와 같이 -100 위치에 있다가 클릭 위치로 순간 이동시켜서 표시하는 방법도 있습니다.

해당 Pointer에 Image를 추가하여 아래와 같이 설정한 상태로 진행합니다.




이제 클릭 지점에 해당 Pointer Image가 표시되도록 하는 컴포넌트 스크립트를 작성하면 되겠지요~


public class PlaceTargetWithMouse : MonoBehaviour
{
    public float surfaceOffset = 1.5f; // gameObject의 위치에서 조금 높여주기 위해.. 지면과 겹치는 문제 해결위해
    public Transform target = null;
    
    private void Update()
    {
        if (target) {
            transform.position = target.position + Vector3.up * surfaceOffset;
        }
    }
    
    // 마우스 클릭 hit 정보
    public void SetPosition(Raycast hit)
    {
        target = null;
        transform.position = hit.point + hit.normal * surfaceOffset;
    }
}


그리고 PlayerCharactor에서 마우스 클릭을 할 때 적이 아니라면,

if (picker) picker.SetPosition(hit);

코드를 추가해 주면 되고,

만약 적이라면,

if (picker) picker.target = hit.collider.transform;

코드를 추가해 주면 됩니다.




실행해보면 잘 동작하는 것을 확인할 수 있는데요..
여기서의 문제점은 굴곡이 있는 지형이나 벽에서 클릭을 할 경우 파묻히는 현상이 나타날 수 있습니다.

파묻힘을 해결하기 위해서는 데칼 기법이나 프로젝터 기법을 사용하면 됩니다.
이런 구현은 유니티 기본 샘플에 포함되어 있으니 필요한 경우 가져다 사용하면 됩니다.



<위의 코드들은 제가 보면서 주요한 함수, 코드를 확인하기 위해 타이핑한 용도로, 전체 소스코드가 아님에 주의해 주세요. 전체 코드는 교육 수강을 하면 완벽하게 받으실 수가 있답니다 ^^>

패스트캠퍼스 - 올인원 패키지 : 유니티 포트폴리오 완성 bit.ly/2R561g0


 

유니티 게임 포트폴리오 완성 올인원 패키지 Online. | 패스트캠퍼스

게임 콘텐츠 프로그래머로 취업하고 싶다면, 포트폴리오 완성은 필수! '디아블로'와 '배틀그라운드' 게임을 따라 만들어 보며, 프로그래머 면접에 나오는 핵심 개념까지 모두 잡아 보세요!

www.fastcampus.co.kr

 

[패스트캠퍼스 수강 후기] 올인원 패키지 : 유니티 포트폴리오 완성 100% 환급 챌린지 15회차 미션 시작합니다.


원거리 공격 구현입니다.

projectile이라는 "발사체"를 구현해야 합니다. 게임개발에서는 굉장히 많이 사용하는 용어입니다. GameObject를 원하는 방향으로 지속적으로 움직이게하는 컴포넌트라고 보면 됩니다.

 

 



위와 같이 Projectile 컴포넌트 스크립트를 생성하여 코드 작업에 진입합니다. 코드 작업이 바로바로.. ㅎㅎ

public class Projectile : MonoBehaviour
{
    // 변수들
    public float speed;
    public GameObject muzzlePrefab; // muzzle flash라고도 하며, 총 발사시 총구에서 불꽃이 튀는 등의 이팩트를 처리.
    public GameObject hitPrefab; // hit 이펙트. 총알이 벽에 맞았을 때 튀는 형태의 이팩트 처리.
    public AudioClip shotSFX;
    public AudioClip hitSFX;
    private bool collided; // 적에게 부딪혔는지를 확인
    private Rigidbody rigidbody;
    [HideInInspector]
    public AttackBehaviour attackBehaviour; // 공격 파워 등의 정보를 재사용.
    [HideInInspector]
    public GameObject owner; // 발사체의 소유자. 누가 쏜겨!?
    [HideInInspector]
    public GameObject target; // 발사체를 맞은 Gameobject. 누가 맞은겨?
    
    void Start()
    {
        // 타겟 바라보는 방향으로 설정
        if (target != null) {
            Vector3 dest = target.transform.position;
            dest.y += 1.5f;
            transform.LookAt(dest);
        }
        
        if (owner) {
            Collider projectileCollider = GetComponent<Collider>();
            Collider[] ownerColliders = owner.GetComponentsInChildren<Collider>();
            
            foreach (Collider collider in ownerColliders) {
                Physics.IgnoreCollision(projectileCollider, collider);
            }
        }
        
        rigidbody = GetComponent<Rigidbody>();
        
        if (muzzlePrefab != null) {
            GameObject muzzleVFX = Instantiate(muzzlePrefab, transform.position, Quaternion.identity);
            muzzleVFX.transform.forward = gameObject.transform.forward; // 발사 방향 지정
            PaticleSystem particleSystem = muzzleVFX.GetComponent<ParticleSystem>();
            if (particleSystem) {
                Destroy(muzzleVFX, particleSystem.main.duration); // 이팩트 삭제
            }
            else {
                // 자식노드에 달려있는 이팩트도 처리
                ParticleSystem childParticleSystem = muzzleVFX.transform.GetChild(0).GetComponent<ParticleSystem>();
                if (childParticleSystem) {
                    Destroy(muzzleVFX, childSystem.main.duration); // 이팩트 삭제
                }
            }
        }
        
        if (shotSFX != null && GetComponent<AudioSource>()) {
            GetComponent<AudioSource>().PlayOneShot(shotSFX);
        }
    }
    
    private void FixedUpdate()
    {
        if (speed != 0 && rigidbody != null) {
            rigidbody.position += (transform.forward) * (speed * Time.deltaTime);
        }
    }
   
    private void OnCollisionEnter(Collision collision)
    {
        // 여러번 부딪히는 경우를 방지
        if (collided) return;
        collided = true;
        
        Collider projectileCollider = GetComponent<Collider>();
        projectileCollider.enabled = false;
        
        if (hitSFX != null && GetComponent<AudioSource>()) {
            GetComponent<AudioSource>().PlayOneShot(hitSFX);
        }
        
        speed = 0;
        rigidbody.isKinematic = true; // 발사체가 더이상 rigidbody에 의해 위치가 결정되지 않기 때문에 물리엔진 사용하지 않겠다는 의미이고, 더이상 OnCollisionEnter()가 호출되지 않게됨.
        
        ContactPoint contact = collision.contacts[0]; // 첫번째 충돌 지점 얻기.
        Quaternion contactRotation = Quaternion.FromToRotation(Vector3.up, contact.normal);
        Vector3 contactPosition = contact.point;
        
        if (hitPrefab) {
            GameObject hitVFX = Instantiate(hitPrefab, contactPosition, contactRotation);
            PaticleSystem particleSystem = hitVFX.GetComponent<ParticleSystem>();
            if (particleSystem) {
                Destroy(hitVFX, particleSystem.main.duration);
            }
            else {
                // 자식노드에 달려있는 이팩트도 처리
                ParticleSystem childParticleSystem = hitVFX.transform.GetChild(0).GetComponent<ParticleSystem>();
                if (childParticleSystem) {
                    Destroy(hitVFX, childSystem.main.duration);
                }
            }
            
            IDamagable_Original damageable = collision.gameObject.GetComponent<IDamagable_Original>();
            if (damageable != null) {
                damageable.TakeDamage(attackBehaviour?.damage ?? 0, null);
            }
            
            StartCoroutine(DestroyParticle(3.0f));
        }
    }
   
    public IEnuerator DestroyParticle(float waitTime) {
        if (transform.childCount > 0 && waitTime != 0) {
            List<Transform> childs = new List<Transform>();
            foreach (Transform t in transform.GetChild(0).transform) {
                childs.Add(t);
            }
            while (transform.GetChild(0).localScale.x > 0) {
                yield return new waitForSeconds(0.01f);
                transform.GetChild(0).localScale -= new Vector3(0.1f, 0.1f, 0.1f); // 점점 사라지는 효과
                // 자식노드도 동일하게
                for (int i = 0; i < childs.Count; ++i) {
                    childs[i].localScale -= new Vector3(0.1f, 0.1f, 0.1f);
                }
            }
        }
        
        yield return new WaitForSeconds(waitTime);
        Destroy(gameObject);
    }
}





위의 내용중 IgnoreCollision() 함수를 사용한 이유입니다. projectile이 발사하는 GameObject의 뒤쪽에서 시작되는 경우 발사체가 소유자에게 맞아서 이벤트가 끝나버리는 상황이 발생할 수 있기 때문에 모든 경우에 대해 충돌을 무시하도록 처리한 것입니다.





발사체를 구현하기 위해 Sphere GameObject를 만들어 처리합니다. 해당 Sphere에 Projectile 컴포넌트를 드래그하여 가져다 놓으면 위와 같은 설정 내용을 확인 할 수 있습니다.





OnCollisionEnter() 부분을 디버깅 확인하는 방법입니다. Speed값을 올려 Sphere가 벽에 부딪히면 지정된 시간 후에 사라지는 연출이 잘 되고 있는 것을 확인할 수 있습니다.

 



AttackBehaviour_Projectile 컴포넌트 스크립트를 하나 만듭니다.

public class AttackBehaviour_Projectile : AttackBehaviour
{
    public override void ExecuteAttack(GameObject target = null, Transform startPoint = null)
    {
        if (target == null) return;
        
        Vector3 projectilePosition = startPoint?.position ?? transform.position;
        if (effectPrefab) {
            GameObject projectileGO = GameObject.Instantiate<GameObject>(effectPrefab, projectilePosition, Quaternion.identity);
            projectileGO.transform.forward = transform.forward;
            
            Projectile projectile = projectileGO.GetComponent<Projectile>();
            if (projectile) {
                projectile.owner = this.gameObject;
                projectile.target = target;
                projectile.attackBehaviour = this;
            }
            
            calcCoolTime = 0.0f;
        }
    }
}





플레이를 실행해보면 적이 발사체를 발사하는 것을 확인할 수 있고, 열심히 피하면 됩니다만.. ㅋㅋ
한 번 맞아보면 녹색으로 파티클 이팩트가 발생하는 것을 확인할 수 있습니다. 적의 근처로 이동하면 근접공격을 하는 것도 확인할 수 있습니다.



플레이어를 따라다니는 발사체로 변경해보기입니다. 일정시간을 따라오다가 사라지는 발사체입니다.

public class FollowProjectile : Projectile
{
    public float destroyDelay = 5.0f;
    
    protected override void Start()
    {
        base.Start();
        
        StartCoroutine(DestroyParticle(destroyDelay));
    }
    
    protected override void FixedUpdate()
    {
        if (target) {
            Vector3 dest = target.transform.position;
            dest.y += 1.5f;
            transform.LookAt(dest); // 현재 캐릭터의 위치로 다시 설정
        }
        
        base.FixedUpdate();
    }
}




이제 따라다니는 Projectile을 적용해야겠지요?

"Projectile Attack Behaviour"에 있는 "Effect Prefab" 값을 "Projectile" -> "Projectile_Follow"로 변경합니다. 이렇게하여 플레이를 진행하면 아래와 같이 따라오는 발사체가 처리된 것을 확인할 수 있습니다.





여기까지가 한 캐릭터가 여러 공격 행동을 가지는 기능을 구현해 본 것입니다. 이러한 Behaviour 구현 방식은 게임의 여러 요소에서 적용할 수 있는 범위가 다양합니다. 응용범위가 엄청나다고 보면 됩니다. ^^~




<위의 코드들은 제가 보면서 주요한 함수, 코드를 확인하기 위해 타이핑한 용도로, 전체 소스코드가 아님에 주의해 주세요. 전체 코드는 교육 수강을 하면 완벽하게 받으실 수가 있답니다 ^^>

패스트캠퍼스 - 올인원 패키지 : 유니티 포트폴리오 완성 bit.ly/2R561g0

 

유니티 게임 포트폴리오 완성 올인원 패키지 Online. | 패스트캠퍼스

게임 콘텐츠 프로그래머로 취업하고 싶다면, 포트폴리오 완성은 필수! '디아블로'와 '배틀그라운드' 게임을 따라 만들어 보며, 프로그래머 면접에 나오는 핵심 개념까지 모두 잡아 보세요!

www.fastcampus.co.kr

 

[패스트캠퍼스 수강 후기] 올인원 패키지 : 유니티 포트폴리오 완성 100% 환급 챌린지 14회차 미션 시작합니다.


저번에 이어 전투시스템 구현입니다.
IAttackable에 대한 인터페이스 함수 구현을 완료하였고, 이번에는 IDamageable에 대한 인터페이스 함수를 구현합니다.

구성되어 있던 IDamageable 인터페이스는 다음과 같았습니다.
public interface IDamageable
{
    bool IsAlive { get; }
    void TakeDamage(int damage, GameObject hitEffectPrefabs);
}


이를 EnemyController_New 스크립트에 구현을 해줍니다.

public class EnemyController_New : EnemyController, IAttackable, IDamageable
{
    // 데미지 관련 체력 변수 및, 초기화 등을 해줍니다.
    
    public Transform hitTransform; // Hit Effect가 발생했을 때의 위치값.
    public int maxHealth = 100; // 최대 체력. 레벨 올라감에 따른 최대 체력 증가 고려.
    public int health
    {
        get;
        private set;
    }
    
    protected override void Start()
    {
        // 기존 구현부..
        
        health = maxHealth;
    }
    

    // IDamageable 구현
    bool IsAlive => health > 0; // 체력 0이상으로 살아있음을 체크
    
    void TakeDamage(int damage, GameObject hitEffectPrefabs)
    {
        if (!IsAlive) return;
        
        health -= damage;
        if (hitEffectPrefabs)
        {
            Instantiate(hitEffectPrefabs, hitTransform);
        }
        
        if (IsAlive)
            animator?.SetTrigger(hitTriggerHash); // 피격 애니메이션 실행
        else
            stateMachine.ChangeState<DeadState>();
    }
}



AttackState_New.cs

public class AttackState_New : State<EnemyController>
{
    private Animator animator;
    private AttackStateController attackStateController;
    private IAttackable attackable;
    
    protected int attackTriggerHash = Animator.StringToHash("AttackTrigger");
    protected int attackIndexHash = Animator.StringToHash("AttackIndex");
    
    pubilc override void OnInitialized()
    {
        animator = context.GetComponent<Animator>();
        attackStateController = context.GetComponent<AttackStateController>();
        attackable = context.GetComponent<IAttackable>();
    }
    
    public override void OnEnter()
    {
        // IAttackable이 구현되어 있지 않았거나, 구현되어 있지만 CoolTime이 안되었다면..
        if (attackable == null || attackable.CurrentAttackBehaviour == null) {
            statemachine.ChangeState<IdleState>();
            return;
        }
        
        attackStateController.enterAttackStateHandler += OnEnterAttackState();
        attackStateController.exitAttackStateHandler += OnExitAttackState();

        animator?.SetInteger(attackIndexHash, attackable.CurrentAttackBehaviour.animationIndex);
        animator?.SetTrigger(attackTriggerHash);
    }
    
    public void OnEnterAttackState()
    {
        // 해당 애니메이션만 변경되므로 처리할 내용은 없음.
    }
    
    public void OnExitAttackState()
    {
        stateMachine.ChangeState<IdleState>();
    }
}




StateMachine이 구현된 상태입니다.




원거리 공격을 위한 projectilePoint이 설정한 화면입니다.

해당 적 캐릭터는 근거리, 원거리 공격 둘 다 가능하므로 Projectile Attack Behaviour와 Melee Attack Behaviour를 둘다 구현해 주면 됩니다.

내용이 완전 쉽지는 않고 캡슐화를 위해 여러 컴포넌트 스크립트와 클래스, 인터페이스들로 인해 좀 정신이 없기는 합니다. 사실 이런 부분은 해당 내용 및 코드를 완벽히 이해한 상태에서 직접 구현해봐야 이해할 수 있습니다.



근접 공격에 대한 내용입니다.
근접 공격에 대한 충돌은 ManualCollision 컴포넌트 스크립트로 자체 구현합니다.


ManualCollision_New.cs

public class ManualCollision_New : MonoBehaviour
{
    public Vector3 boxSize = new Vector3(3, 2, 2);
    
    public Collider[] CheckOverlapBox(LayerMask layerMask)
    {
        return Physics.OverlapBox(transform.position, boxSize * 0.5f, transform.rotation, layerMask);
    }
    
    private void OnDrawGizmos()
    {
        Gizmos.matrix = transform.localToWorldMatrix; // Gizmos의 상태 위치를 World 위치로..
        Gizmos.color = Color.yellow;
        Gizmos.DrawWireCube(Vector3.zero, boxSize);
    }
}




MeleeAttackCollision을 Amphisbaena라는 적 캐릭터의 자식 컴포넌트로 등록하고, 그 안에서 "Manual Collision" 컴포넌트를 등록하여 사용합니다. 자식 노트로 한 이유는 캐릭터를 이동할 때 Collision 영역이 캐릭터를 따라다녀야 편하기 때문이지요.
이제 근접 공격 스크립트를 작성합니다.

AttackBehaviour_Melee.cs

public class AttackBehaviour_Melee : AttackBehaviour
{
    public manualCollision attackCollision;
    
    public override void ExecuteAttack(GameObject target = null, Transform startPoint = null)
    {
        Collider[] colliders = attackCollision?.CheckOverlapBox(targetMask);
        
        foreach (Collider collider in colliders)
        {
            collider.gameObject.GetComponent<IDamagable_Original>()?.TakeDamage(damage, effectPrefabs);
        }
    }
}




드디어 유니티 실행입니다. 플레이를 시작하면 적캐릭터가 따라오며 근접공격이 가능한 경우 근접 공격 애니메이션이 발생됨을 볼 수 있습니다.



실제 해당 공격이 발생되는 시점에 Manual Collision을 보면 캐릭터가 해당 영역 (boxSize)내에 있기 때문에 Damage를 받아 체력이 감소하는 처리를 하게 됩니다.

다음 번에는 원거리 공격을 구현할 예정입니다.



<위의 코드들은 제가 보면서 주요한 함수, 코드를 확인하기 위해 타이핑한 용도로, 전체 소스코드가 아님에 주의해 주세요. 전체 코드는 교육 수강을 하면 완벽하게 받으실 수가 있답니다 ^^>

패스트캠퍼스 - 올인원 패키지 : 유니티 포트폴리오 완성 bit.ly/2R561g0

 

유니티 게임 포트폴리오 완성 올인원 패키지 Online. | 패스트캠퍼스

게임 콘텐츠 프로그래머로 취업하고 싶다면, 포트폴리오 완성은 필수! '디아블로'와 '배틀그라운드' 게임을 따라 만들어 보며, 프로그래머 면접에 나오는 핵심 개념까지 모두 잡아 보세요!

www.fastcampus.co.kr

 

[패스트캠퍼스 수강 후기] 올인원 패키지 : 유니티 포트폴리오 완성 100% 환급 챌린지 13회차 미션 시작합니다.




어느덧 챕터 5 진입이네요.

전투 시스템에 대하여 알아보는 시간입니다.

배우게 될 전체 구성 순서는 다음과 같습니다.

1. 전투 시스템 구성 알아보기
2. 전투 시스템 구현하기
3. 근접 전투 로직 구현하기
4. 원거리 전투 로직 구현하기
5. NPC의 HP UI 구현하기




EnemyController는 기존에 구현하였던 플레이어 캐릭터나 적캐릭터라고 보면 됩니다. 여기서는 Idle, Attack, Move State만 가지는 Controller를 구현합니다.
AttackStateController는 Animator에서 공격중인지 상태를 알게해주고, 이를 확인하여 EnemyController Logic에 전달하여 상태를 확인하도록 구현합니다. 물론 EnemyController 컴포넌트에서 직접 작업하여도 되지만, AttackStateController를 재활용할 수 있도록 별도의 컴포넌트로 제작을 하는 것입니다.
IAttackable은 Animator에서 공격이 발생하는 시점에 호출이되는 이벤트 인터페이스입니다. IAttackable 인터페이스를 따로 구현한 이유 역시 공격가능한 GameObject에 쉽게 부착하여 구현할 수 있도록 구성한 것입니다. 정말 좋은 내용입니다.
IDamageable도 마찬가지이지요. 데미지를 받아 에너지가 줄어드는 GameObject인 경우 적용합니다.
인터페이스의 장점은 클래스 상속과 상관없이 Attach/Dettach가 가능하고, 식별이 용이하다는 것이 장점입니다. 단점이라면 구조가 복잡해질 수 있다는 점입니다. Interface를 컴포넌트로 구성할 수도 있지만 참조가 많이 발생하게 되어 복잡성이 높아질 수 있어서 여기서는 Interface로만 구현합니다.
AttackBehaviour_xx는 공격 종류라고 보면 됩니다. EnemyController에서 여러 공격의 종류 중 하나를 선택하여 공격이 발생하는 시스템입니다.


AttackStateController와 Animator를 먼저 구성해 봅니다.



StateMachine은 위와 같이 구성합니다.


AttackStateController.cs

public class AttackStateController : MonoBehaviour
{
    public delegate void OnEnterAttackState();
    public delegate void OnExitAttackState();
    
    public OnEnterAttackState enterAttackStateHandler;
    public OnExitAttackState exitAttackStatehandler;
    
    public bool IsInAttackState
    {
        get;
        private set; // 외부에서 값 설정 불가.
    }
    
    void Start()
    {
        enterAttackStateHandler = new OnEnterAttackState(EnterAttackState);
        exitAttackStateHandler = new OnExitAttackState(ExitAttackState);
    }
    
    public void OnStartOfAttackState()
    {
        IsInAttackState = true;
        enterAttackStateHandler();
    }
    
    public void OnEndOfAttackState()
    {
        IsInAttackState = false;
        exitAttackStateHandler();
    }
    
    private void EnterAttackState()
    {
    }
    
    private void ExitAttackState()
    {
    }
    
    // Animator에서 호출할 함수
    public void OnCheckAttackCollider(int attackIndex)
    {
        GetComponent<IAttackable>()?.OnExecuteAttack(attackIndex);
    }
}

 



AttackStateMachineBehaviour.cs

public class AttackStateMachineBehaviour : StateMachineBehaviour
{
    public override void OnStateEnter(Animator animator, AnimatorStateInfo stateInfo, int layerIndex) {
        animator.getObject.GetComponent<AttackStateController>()?.OnStartOfAttackState();
    }
    
    public override void OnStateExit(Animator animator, AnimatorStateInfo stateInfo, int layerIndex) {
        animator.getObject.GetComponent<AttackStateController>()?.OnEndOfAttackState();
    }
}

 



IAttackable.cs

public interface IAttackable
{
    AttackBehaviour CurrentAttackBehaviour
    {
        get;
    }
    
    void OnExecuteAttack(int attackIndex);
}


IDamageable.cs

public interface IDamageable
{
    bool IsAlive
    {
        get;
    }
    
    void TakeDamage(int damage, GameObject hitEffectPrefabs);
}



AttackBehaviour.cs

public abstract class AttackBehaviour : MonoBehaviour
{
// 개발자가 어떤 컴포넌트인지 확인할 수 있도록 주석 만들기.
#if UNITY_EDITOR
    [Multiline]
    public string developmentDescription = "";
#endif

    public int animationIndex;
    public int priority; // 여러 공격중 어떤 것을 선택할지에 대한 우선순위
    public int damage = 10;
    public float range = 3f;
    
    [SerializedField]
    protected float coolTime; // 공격후 대기 시간?
    protected float calcCoolTime; = 0.0f;
    
    public GameObject effectPrefab;
    
    [HideInInspector]
    public LayerMask targetMask;
    
    void Start()
    {
        calcCoolTime = coolTime; // 바로 공격 가능 상태
    }
    
    void Update()
    {
        if (calcCoolTime < coolTime)
        {
            calcCoolTime += Time.deltaTime;
        }
    }
    
    // startPoint는 발사체가 발사되는 지점용
    public abstract void ExecuteAttack(GameObject target = null, Transform startPoint = null);
}

 


EnemyController_New.cs

public EnemyController_New : EnemyController, IAttackable, IDamageable
{
    public Transform projectilePoint;
    
    [SerializedField]
    private List<AttackBehaviour> attackBehavious = new List<AttackBehaviour>();
    
    protected override void Start()
    {
        base.Start();
        stateMachine.AddState(new MoveState());
        stateMachine.AddState(new AttackState());
        stateMachine.AddState(new DeadState());
        
        InitAttackBehaviour();
    }
    
    protected override void Update()
    {
        CheckAttackBehaviour();
        base.Update();
    }
    
    private void InitAttackBehaviour()
    {
        foreach (AttackBehaviour behaviour in attackBehavious)
        {
            if (CurrentAttackBehaviour == null)
                CurrentAttackBehaviour = behaviour;
                
            behaviour.targetMask = TargetMask;
        }
    }
    
    private void CheckAttackBehaviour()
    {
        if (CurrentAttackBehaviour == null || !CurrentAttackBehaviour.IsAvailable)
        {
            CurrentAttackBehaviour = null;
            foreach (AttackBehaviour behaviour in attackBehavious)
            {
                if (behaviour.IsAvailable)
                {
                    // 가장 우선순위가 높은 공격 설정
                    if (CurrentAttackBehaviour == null || CurrentAttackBehaviour.priority < behaviour.priority)
                    {
                        CurrentAttackBehaviour = behaviour;
                    }
                }
            }
        }
    }
    
    public AttackBehaviour CurrentAttackBehaviour
    {
        get;
        private set; // 내부에서만 set 가능하도록..
    }
    
    public void OnExecuteAttack(int attackIndex)
    {
        if (CurrentAttackBehaviour != null && Target != null)
        {
            CurrentAttackBehaviour.ExecuteAttack(Target.gameObject, projectilePoint); // Target은 공격가능 거리내에 있는 타겟중 가장 가까운 것
        }
    }
}

 

 



적의 Animation 구동중에 OnCheckAttackCollider() 함수를 호출하도록 구현되어 있습니다.

캐릭터의 전투 시스템 구성은 C# 코딩으로 모두 이루어진다고 할 수 있겠네요.
이젠 배우는 내용들이 많아지므로 이전에 배운 내용은 코드 재사용을 하므로 정말 빠르게 진행됩니다.
이번에 배운 중요한 부분은 IAttackable이라고 봐야겠네요. 다음시간에는 IDamageable에 대해 구현하면 마무리가 될 것 같습니다.



<위의 코드들은 제가 보면서 주요한 함수, 코드를 확인하기 위해 타이핑한 용도로, 전체 소스코드가 아님에 주의해 주세요. 전체 코드는 교육 수강을 하면 완벽하게 받으실 수가 있답니다 ^^>

패스트캠퍼스 - 올인원 패키지 : 유니티 포트폴리오 완성 bit.ly/2R561g0

 

유니티 게임 포트폴리오 완성 올인원 패키지 Online. | 패스트캠퍼스

게임 콘텐츠 프로그래머로 취업하고 싶다면, 포트폴리오 완성은 필수! '디아블로'와 '배틀그라운드' 게임을 따라 만들어 보며, 프로그래머 면접에 나오는 핵심 개념까지 모두 잡아 보세요!

www.fastcampus.co.kr

 

[패스트캠퍼스 수강 후기] 올인원 패키지 : 유니티 포트폴리오 완성 100% 환급 챌린지 12회차 미션 시작합니다.


저번 시간에 이어 FOV 마무리하고, 동적 AI 캐릭터 구현하기입니다.




FieldOfView를 Editor 기능으로 확장하여 적 캐릭터의 시야 반지름이 원으로 표시되는 것을 확인할 수 있고, View Angle값을 변경하여 시야각을 조절하면 Editor 상에서 Realtime으로 변하며 확인할 수 있습니다.

이제 SearchEnemy() 관련 부분을 수정합니다.


EnemyController_New.cs

public class EnemyController_New : MonoBehaviour
{
    protected StateMachine_New<EnemyController_New> stateMachine;
    public StateMachine_new<EnemyController_New> StateMachine => stateMachine;
    
    private FieldOfView_New fov; // FOV 코드 추가.
    
    // 기존 코드 제거. -> FOV에서 처리하므로 불필요.
    //public LayerMask targetMask;
    //public Transform target;
    //public float viewRadius;
    public float attackRange;
    public Transform Target => fov?.NearestTarget; // FieldOfView_New.cs에 public Transform NearestTarget => nearestTarget; 추가해줍니다.
    
    private void Start()
    {
        stateMachine = new StateMachine_New<EnemyController_New>(this, new IdleState_New());
        stateMachine.AddState(new MoveState_New());
        stateMachine.AddState(new AttackState_New());
        
        fov = GetComponent<FieldOfView_New>();
    }
    
    private void Update()
    {
        stateMachine.Update(Time.deltaTime);
    }
    
    public bool IsAvailableAttack
    {
        get
        {
            if (!Target) // target -> Target property
            {
                return false;
            }
            
            float distance = Vector3.Distance(transform.position, Target.position); // target -> Target
            return (distance <= attackRange);
        }
    }
    
    public Transform SearchEnemy()
    {
        return Target; // FOV에서 검색된 Target return

        // FOV의 Target 사용으로 불필요.
        //target = null;
        //Collider[] targetInViewRadius = Physics.OverlapSphere(transform.position, viewRadius, targetMask);
        //if (targetInViewRadius.Length > 0)
        //{
        //    target = targetInViewRadius[0].transform;
        //}
    }
}


여기까지 구현을 하면 적을 찾는 루틴 SearchEnemy() 함수의 기존 직접 적을 찾는 코딩 방식에서, Editor에 설정된 FOV를 이용하여 적을 찾는 방식으로 변경하여 구현을 할 수 있게 되었습니다.

대단합니다. 결국 기존에는 저러한 값들이 바뀌면 항상 프로그램 소스 코드를 무조건 변경해야 했고, 관련 코드들도 모두 수정하는 일이 빈번할텐데.. FOV 컴포넌트를 추가하여 Editor 상에서도 실시간 수정을 할 수가 있게 되고, 어떻게 보면 다른 컴포넌트들에서도 FOV 컴포넌트를 공통으로 사용하게 될 것이므로 코드의 집중화까지 되는 효과가 있다고 할 수 있겠습니다.

FieldOfView_New.cs의 Update()시 매번 FindVisibleTargets()를 호출하는 것은 부하가 많이 걸리므로 delay를 사용하는 방식으로 조금 수정합니다.


FieldOfView_New.cs 수정 부분들

public float delay = 0.2f;

void Start()
{
    StartCoroutine("FindTargetsWithDelay", delay); // delay 시간으로 자동호출되는 함수 등록.
}

void Update()
{
    //FindVisibleTargets(); // Update시마다 매번 호출하지 않고 Start()에서 자동호출되는 함수 등록.
}

// delay 시간을 가지고 FindVisibleTargets()을 호출하는 함수.
IEnumerator FindTargetsWithDelay(float delay)
{
    while(true)
    {
        yield return new WaitForSeconds(delay);
        FindVisibleTargets();
    }
}

 



Update()는 매 Frame마다 호출되므로 너무 빈번하게 호출됩니다. FindTargetsWithDelay() 함수를 구현하여 0.2초마다 호출되는 함수 방식을 구현하였습니다.

기존 코드들을 정리해 줍니다. target => fov?.Target을 사용하는 형태로.. OnDrawGizmos()와 같은 불필요해진 함수들도 삭제를 합니다.




Unity에서 BarbarianWarrior_FOV (적 FOV)의 설정을 해줍니다.
+ View Radius = 5
+ View Angle = 90
+ Delay = 0.2
+ Target Mask = Player
+ Obstacle Mask = Ground, Wall
  => Obstacle Mask에 Ground와 Wall을 설정해 줌으로써 해당 GameObject들에서는 시야가 무시되도록 처리한 것입니다.
  



현재까지 상태에서 구동을 해보면 위의 화면과 같이 실행이 됩니다.
기존과 다르게 적의 반경 내에 있다고 하더라도 시야각에 들어가지 않으면 적이 MoveState로 Transition 되지 않기 때문에 캐릭터를 공격하러 오지 않는 것을 확인할 수 있습니다.

이런 것들을 이용하면 적의 뒤에서 공격하는 게임 형식이라던지, 잠입 방식의 게임 방식도 응용하여 개발이 가능해집니다.



이제 FSM을 좀더 확장하여 캐릭터가 2지점 사이를 오가는 Patrol 기능을 구현해 보도록 하겠습니다.



패트롤중 적발견하고 공격거리 이내면 Attack State로 Transition하고,
패트롤중 적발견하고 공격거리 밖이면 Move State로 Transition하고,
패트롤중 적을 발견하지 못하면 랜덤하게 Idle State로 Transition하는 방식을 취할 예정입니다.

Idle, Attack, Move State는 기존 코드를 사용하며, Patrol State만 추가로 구현을 하면 됩니다. 단지 Patrol 상태에 따른 Random Idle 처리를 위해 Idle State는 약간 변경을 해주어야 합니다.

Patrol State를 추가하는 루틴을 공부하는 이유는 기존 Attack - Idle - Move 상태만 처리하던 시스템에서 Patrol 상태만 추가를 해줌으로서 쉽게 캐릭터의 상태를 추가하고 제어할 수 있다는 것을 보여주기 위함입니다.

와우.. 상태 State 개념... 대박 좋은 거 같습니다. 어떻게 저런 아이디어를 생각해내고 구현해 낼 수 있는지.. ㅎㅎ 이건 게임이 아니라 다른 개발 프로젝트에서도 충분히 응용할 수 있고.. 꼭 그렇게 해야할 것 같은 중압감을 느낄 정도네요 ㅠ.,ㅜ;

 

 



Patrol waypoint를 구현하기 위해 Unity 상에 Sphere 2개를 놓았고, 해당 위치를 반복하며 이동하는 기능을 "MoveToWaypoint_New" 스크립트 컴포넌트를 추가하여 구현합니다.


MoveToWaypoint_New.cs

public class MoveToWaypoints : StateMachine_New<EnemyController_New>
{
    private Animator animator;
    private CharacterController controller;
    private NavMeshAgent agent;
    protected int hashMove = Animator.StringToHash("Move");
    protected int hashMoveSpeed = Animator.StringToHash("MoveSpeed");
    
    public override void OnInitialized()
    {
        animator = context.GetComponent<Animator>();
        controller = context.GetComponent<CharacterController>();
        agent = context.GetComponent<NavMeshAgent>();
    }
    
    public override void OnEnter()
    {
        if (context.targetWaypoint == null)
            context.FindNextWaypoint(); // Patrol 위치 설정
         
        if (context.targetWaypoint)
        {
            agent?.SetDestination(context.targetWaypoint.position);
            animator?.SetBool(hashMove, true);
        }
    }
   
    public override void Update(float deltaTime)
    {
        Transform enemy = context.SearchEnemy();
        if (enemy)
        {
            if (context.IsAvailableAttack)
                statemachine.ChangeState<AttackState_New>();
            else
                stateMachine.ChangeState<MoveState_New>();
        }
        else
        {
            // pathPending : NavMeshAgent가 이동해야할 경로가 존재하는지 체크
            if (!agent.pathPending && (agent.remainingDistance <= agent.stoppingDistance))
            {
                // 이동해야할 경로도 없고, 도착지점에 도착했다면 다음 목표지점 검색
                Transform nextDest = context.FindNextWaypoint();
                if (nextDest)
                {
                    agent.SetDestination(nextDest.position);
                }
                stateMachine.ChangeState<IdleState_New>(); // 잠시 Idle 상태로 Transition
            }
            else
            {
                // 경로가 남았다면 이동
                controller.Move(agent.velocity * deltaTime);
                animator.SetFloat(hashMoveSpeed, agent.velocity.magnitude / agent.speed, .1f, deltaTime);
            }
        }
    }
    
    public override void OnExit()
    {
        animator?.SetBool(hashMove, false);
        agent.ResetPath();
    }
}



EnemyController_New.cs 수정 부분들

public Transform[] waypoints; // Unity상의 Patrol 위치점들
[HideInInspector]
public Transform targetWaypoint = null;
private int waypointIndex = 0;

public Transform FindNextWaypoint()
{
    targetWaypoint = null;
    if (waypoints.Length > 0)
    {
        targetWaypoint = waypoints[waypointIndex];
    }
    
    waypointIndex = (waypointIndex + 1) % waypoints.Length; // Index Cycling..
}



IdleState_New.cs 수정 부분들

bool isPatrol = false;
private float minIdleTime = 0.0f;
private float maxIdleTime = 3.0f;
private float idleTime = 0.0f;

public override void OnEnter()
{
    animator?.SetBool(hashMove, false);
    animator?.SetFloat(hashMoveSpeed, 0);
    controller?.Move(Vector3.zero);
    
    if (isPatrol)
        idleTime = Random.Range(minIdleTime, maxIdleTime);
}

public override void Update(float deltaTime)
{
    Transform enemy = context.SearchEnemy();
    if (enemy)
    {
        if (context.IsAvailableAttack) stateMachine.ChangeState<AttackState_New>();
        else stateMachine.ChangeState<MoveState_New>();
    }
    else if (isPatrol && stateMachine.ElapsedTimeInState > idleTime)
    {
        stateMachine.ChangeState<MoveToWaypoints>();
    }
}




위와 같은 FSM 상태 구현은 나중에 디아블로 게임 제작시 구현할 체력이 떨어졌을 때 특정 지점으로 회피하였다가 다시 상태가 변경되어 다른 루틴을 구현하는 방식으로 활용될 것입니다.

캐릭터 AI를 위한 FSM 모델을 구현하고 이를 토대로 여러 상태를 가진 캐릭터 구현이 완료되었습니다.

 

<위의 코드들은 제가 보면서 주요한 함수, 코드를 확인하기 위해 타이핑한 용도로, 전체 소스코드가 아님에 주의해 주세요. 전체 코드는 교육 수강을 하면 완벽하게 받으실 수가 있답니다 ^^>


패스트캠퍼스 - 올인원 패키지 : 유니티 포트폴리오 완성 bit.ly/2R561g0

 

유니티 게임 포트폴리오 완성 올인원 패키지 Online. | 패스트캠퍼스

게임 콘텐츠 프로그래머로 취업하고 싶다면, 포트폴리오 완성은 필수! '디아블로'와 '배틀그라운드' 게임을 따라 만들어 보며, 프로그래머 면접에 나오는 핵심 개념까지 모두 잡아 보세요!

www.fastcampus.co.kr

 

[패스트캠퍼스 수강 후기] 올인원 패키지 : 유니티 포트폴리오 완성 100% 환급 챌린지 11회차 미션 시작합니다.



적 캐릭터 AI 구현의 캐릭터 가시선 시뮬레이션 구현에 대한 내용입니다.

캐릭터 시야 뷰에 대한 FOV 등을 컴포넌트로 따로 구현하여 사용하도록 합니다.

 



대략 위의 그림과 같이 캐릭터의 시야에 대한 처리를 하게 되고, 오른쪽 그림은 실제로는 반지름을 가진 부채꼴 형태로 인지하게 될 것입니다.

별도 컴포넌트로 제작하기 위해서

Project - Scripts - FieldOfView_New 컴포넌트를 추가합니다.


FieldOfView_New.cs

public class FieldOfView_New : MonoBehaviour
{
    public float viewRadius = 5f;
    [Range(0, 360)]
    public float viewAngle = 90f;
    
    public LayerMask targetMask; // 적을 검색하기 위한 레이어마스크
    public LayerMask obstacleMask; // 캐릭터와 적 사이의 장애물 레이어마스크
    
    private List<Transform> visibleTargets = new List<Transform>(); // 탐색된 적들을 리스트로 관리
    
    private Transform nearestTarget; // 가장 가까이 있는 적
    private float distanceToTarget = 0.0f; // 가장 가까운 적까지의 거리
    
    void Start()
    {
    }
   
    void Update()
    {
        FindVisibleTargets();
    }
    
    // 보이는 적 찾기
    void FindVisibleTargets()
    {
        distanceToTarget = 0.0f;
        nearestTarget = null;
        visibleTargets.Clear();
        
        Collider[] targetsInViewRadius = Physics.OverlapSphere(transform.position, viewRadius, targetMask);
        for (int i = 0; i < targetsInViewRadius.Length; ++i)
        {
            Transform target = targetsInViewRadius[i].transform;
            
            Vector3 dirToTarget = (target.position - transform.position).normalized; // 방향 검색
            if (Vector3.Angle(transform.forward, dirToTarget) < viewAngle / 2)
            {
                float dstToTarget = Vector3.Distance(transform.position, target.position);
                // 장애물이 있는지 검사
                if (!Physics.Raycast(transform.position, dirToTarget, dstToTarget, obstacleMask))
                {
                    visibleTargets.Add(target);
                    if (nearestTarget == null || (distanceToTarget > dstToTarget))
                    {
                        nearestTarget = target;
                        distanceToTarget = dstToTarget;
                    }
                }
            }
        }
    }
}

 

 


역시 Physics.OverlapSphere() 함수를 사용하여 특정 거리 내의 충돌 GameObject들을 걸러 냅니다.
자기 시야각에 있는 적들을 지속 검색하고 가장 가까운 적을 찾는 것까지입니다.
사진에 보이는 것 처럼 FOV내 모든 적을 검색하고 장애물에 가리지 않고 시야에 들어오는 적만 찾아내는 것입니다.


여기에다가 FieldOfView Editor의 Debugging을 위해서 Editor 기능을 추가해보도록 합니다.

 

 


위와 같이 FieldOfView_NewEditor 스크립트를 추가해 줍니다.


FieldOfView_NewEditor.cs

[CustomEditor(typeof(FieldOfView_New))]
public class FieldOfView_NewEditor : Editor
{
    private void OnSceneGUI()
    {
        private FieldOfView_New fov = (FieldOfView_New)target;
        
        // 시야거리 그리기
        Handles.color = Color.white;
        Handles.DrawWireArc(fov.transform.position, Vector3.up, Vector3.forward, 360, fov.viewRadius);
        
        Vector3 viewAngleA = fov.DirFromAngle(-fov.viewAngle / 2, false); // 왼쪽 꼭지점
        Vector3 viewAngleB = fov.DirFromAngle(fov.viewAngle / 2, false); // 오른쪽 꼭지점
       
        Handles.DrawLine(fov.transform.position, fov.transform.position + viewAngleA * fov.viewRadius);
        Handles.DrawLine(fov.transform.position, fov.transform.position + viewAngleB * fov.viewRadius);
        
        Handles.color = Color.red;
        foreach (Transform visibleTarget in fov.VisibleTargets)
        {
            Handles.DrawLine(fov.transform.position, visibleTarget.position);
        }
    }
}


 


시야반경과 시야각 등을 그리기 위해서 삼각함수 계산에 대해 설명해주십니다.


FieldOfView_New.cs에 아래의 함수를 추가합니다.

    public Vector3 DirFromAngle(float angleInDegrees, bool angleIsGlobal)
    {
        if (!angleIsGlobal)
        {
            angleInDegrees += transform.eulerAngles.y;
        }
        
        return new Vector3(Mathf.Sin(angleInDegrees * Mathf.Deg2Rad), 0, Mathf.Cos(angleInDegrees * Mathf.Deg2Rad));
    }

    

 

 


컴파일이 잘 되면 이렇게 구현이 되어 나타나고, SearchEnemy 부분을 수정해주어야 최종적으로 시야를 체크하는 부분이 완성될텐데 내일 이어서 보도록 하지요.



패스트캠퍼스 - 올인원 패키지 : 유니티 포트폴리오 완성 bit.ly/2R561g0

 

유니티 게임 포트폴리오 완성 올인원 패키지 Online. | 패스트캠퍼스

게임 콘텐츠 프로그래머로 취업하고 싶다면, 포트폴리오 완성은 필수! '디아블로'와 '배틀그라운드' 게임을 따라 만들어 보며, 프로그래머 면접에 나오는 핵심 개념까지 모두 잡아 보세요!

www.fastcampus.co.kr

 

[패스트캠퍼스 수강 후기] 올인원 패키지 : 유니티 포트폴리오 완성 100% 환급 챌린지 10회차 미션 시작합니다.



저번에 이어지는 적 캐릭터를 위한 AI 구현입니다.



사실 구현 내용이 대부분이라 캡쳐 화면도 필요없었지만 너무 없어서 ㅋㅋ 제일 유용한 부분을 넣었습니다.
IdleState -> MoveState -> IdleState가 언제 어떻게 발생하는지, 그리고 그러한 이유때문에 스크립트 소스코드를 그렇게 짠 것을 이해하기 위함입니다.

 



EnemyController_New.cs

public LayserMask targetaMask; // targer Layer를 체크하기 위함.
public float viewRadius; // 적이 접근해 있는 반경 체크하기
public Transform target; // 적에 대한 위치
public float attackRange;

public bool IsAvailableAttack
{
    get
    {
        if (!target) return false;
        float distance = Vector3.Distance(transform.position, target.position);
        return (distance <= attackRange);
    }
}

 

public Transform SearchEnemy()
{
    target = null;
    Collider[] targetInViewRadius = Physics.OverlapSphere(transform.position, viewRadius, targetMask);
    if (targetInViewRadius.Length > 0)
    {
        target = targetInViewRadius[0].transform;
    }
    return target;
}

// 적의 시야 반경과 공격 거리를 디버깅 해보기 위해서 그려줍니다.
private void OnDrawGizmos()
{
    Gizmos.color = Color.red;
    Gizmos.DrawWireSphere(transform.position, viewRadius);

    Gizmos.color = Color.green;
    Gizmos.DrawWireSphere(transform.position, attackRange);
}

 




적의 시야 반경과 공격 거리를 각각 빨강색과 초록색으로 그리고 있는 것을 확인할 수 있습니다.


Physics.OverlapSphere()를 사용하여 특정 Object가 특정 반경내에 있는지를 체크합니다.
적이 있다고 판단이 되었을 때 공격 거리 안에 있는지를 검사하도록 합니다.


이건 왜 이렇게 하냐면, 캐릭터마다 적이 있다고 판단하는 거리는 동일하지만 공격이 가능한지는 다른 이슈라는 것입니다. 예를 들어 근접 공격 유닛이 있다면 적이 있다고 판단은 했지만 공격은 할 수 없으므로 적에게 이동하여 공격을 해야할 것이고, 원거리 공격 유닛이라면 적이 있다고 판단되었을 때 바로 화살 등을 쏴서 공격할 수 있을 것이기 때문입니다.

 

여기서는 캐릭터의 시야 거리는 동일하다고 보는 것입니다. 대신 공격 거리만 차이가 있다고 생각하고 프로그래밍을 하는 것이지요. 원거리 유닛은 바로 attackState로 transform되어 공격 상태로 이전합니다. 반면 근거리 유닛은 moveState로 transform되어 이동 상태로 전이되는 것입니다.

 

 


MoveState_New.cs

public class MoveState_New : State_New<EnemyController_New>
{
    private Animator animator;
    private CharacterController controller;
    private NavMeshAgnet agent;

    private int hashMove = Animator.StringToHash("Move");
    private int hashMoveSpeed = Animator.StringToHash("MoveSpeed");

    public override void OnInitialized()
    {
        animator = context.GetComponent<Animator>();
        controller = context.GetComponent<CharacterController>();
        agent= context.GetComponent<NavMeshAgnet>();
    }


    public override void OnEnter()
    {
        agent?.SetDestination(context.target.position);
        animator?.SetBool(hashMove, true);
    }

    public override void Update(float deltaTime)
    {
        Transform enemy = context.SearchEnemy(); // 적에게 지속 접근
        if (enemy) // 적이 지속 존재한다면
        {
            agent.SetDestination(context.target.position);
            if (agent.remainingDistance > agent.stoppingDistance) // 해당 거리만큼 지속 이동
            {
                controller.Move(agnet.velocity * deltaTime);
                animator.SeFloat(hashMoveSpeed, agnet.velocity.magnitude / agent.speed, 1f, deltaTime);
            }
        }

        if (!enemy && agent.remainingDistance <= agnet.stoppingDistance) // 적이 시야에서 벗어났다면
        {
            stateMachine.ChangeState<IdleState_New>(); // IdleState로 전환
        }
    }

    public override void OnExit()
    {
        animator?.SetBool(hashMove, false);
        animator?.SetFloat(hashMoveSpeed, 0f);
        agent.ResetPath(); // 길찾기 더이상 하지 않도록 초기화
    }
}


AttackState_New도 비슷한 루틴으로 구현되겠죠.. 하지만 오히려 지속 이동이 아니라 공격을 하는 애니메이션이 주가 되므로 Animation 처리가 주 업무가 됩니다. MoveState 보다 구현할 내용이 간단하다는 것입니다 ^^

 



AttackState_New.cs

public class AttackState_New : State_New<EnemyController_New>
{
    private Animator animator;
    private int hashAttack = Animator.StringToHash("Attack");
    public override void OnInitialized()
    {
        animator = context.GetComponent<Animator>();
    }

    public override void OnEnter()
    {
        if (context.IsAvailableAttack)
        {
            animator?.SetTrigger(hashAttack);
        }
        else
        {
            stateMachine.ChangeState<IdleState_New>();
        }
    }

    public override void Update(float deltaTime)
    {
    }
}




실행을 해보면 적이 시야에 인지가 되었을 때 이동해 오는 것을 확인할 수 있고, 공격거리 내에 진입하게 되면 공격 애니메이션이 구동되는 것을 확인할 수 있습니다.

여기서 한가지 문제점은 AttackState 상태에서 다시 IdleState로 전환해주는 것이 필요한데 이를 위해서는 스크립트를 하나 추가로 작성해 주어야 합니다.

 


EndOfAttackStateMachineBehavior.cs

public class EndOfAttackStateMachineBehavior : StateMachineBehaviour
{
    public override void OnStateExit(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
    {
        animator.GetCompoent<EnemyController_New>()?.StateMachine.ChangeState<IdleState_New>();
    }
}


animator와 자신이 구현한 FSM을 연동할 때 정보가 한쪽 방향으로만 흐르도록 해야한다는 것에 주의해야 합니다.
요구사항에 의해 이렇게도 저렇게도 구현되어야 한다면 로직이 꼬이게 되는 경우가 많고 버그도 많이 발생할 수 있기 때문이지요.




아래 내용은 교육과 무관한 미션 제출 관련 내용이에요. ㅎㅎ
지금까지 진행해보니 미션 제출할 때 불편한 점이 몇개 있습니다.
패캠에서 보실지는 모르지만 작성해 둘께요. 보신다면 개선해주시면 좋을 듯합니다.

1. 학습통계 - 이게 하나도 맞질 않아요. ㅋㅋ 왜 있는건지.. 2~3시간을 들어도 20, 30분으로 나와있는게 대부분 -_-;
2. 미션2개 - 강의 2개를 듣고 작성하면 되는데, 오늘 어떤 강의를 들었는지 확인할 수 있는게 없어요. 1개를 들었는지 2개를 들었는지..
3. 제출시 - 시작일이 정해져 있는 것이라 회차가 분명한데, 몇회차를 제출하는지 알수가 없어요.

10회차까지 오니 어떤 강의를 언제 했는지 헛갈리기 시작하네요 ㅠ.,ㅜ; 물론 지난 작성한거 보고 확인도 하고, 캡쳐해놓은것 보고 확인 또 재차 확인하고 있지만, 조금만 직관적이면 좋을텐데.. 라는 아쉬움에 남깁니다. 확인하는데도 시간이 걸리니 ㅎㅎ 그래도 많은 내용 공부할 수 있어 너무 좋고 강추합니다.



패스트캠퍼스 - 올인원 패키지 : 유니티 포트폴리오 완성 bit.ly/2R561g0

 

유니티 게임 포트폴리오 완성 올인원 패키지 Online. | 패스트캠퍼스

게임 콘텐츠 프로그래머로 취업하고 싶다면, 포트폴리오 완성은 필수! '디아블로'와 '배틀그라운드' 게임을 따라 만들어 보며, 프로그래머 면접에 나오는 핵심 개념까지 모두 잡아 보세요!

www.fastcampus.co.kr

 

+ Recent posts