Preview를 우클릭 했을 때, 취소하는 코드를 추가했다.

private void Update()
{
    if (Input.GetButtonDown("Fire1"))
    {
        Build();
    }
    if (Input.GetButtonDown("Fire2"))
    {
        Cancle();
    }
}

private void Build()
{
    if (isPreviewActivated)
    {
        if (isPreviewActivated)
        {
            Debug.Log("Create");
            Instantiate(varBuildCraft, varPreviewCraft.transform.position, Quaternion.identity);
            Destroy(varPreviewCraft);
            isPreviewActivated = false;
            craftingWindow.SetActive(false);
            varPreviewCraft = null;
            varBuildCraft = null;
            PlayerController.instance.ToggleCursor(false);
        }
    }

}

private void Cancle()
{
    if (isPreviewActivated)
    {
        Destroy(varPreviewCraft);
    }
    isPreviewActivated = false;
    varPreviewCraft = null;
    varBuildCraft = null;

}

 

그리고 구조물을 클릭 했을 때 정보를 불러오며, 제작을 눌렀을 때 해당 버튼의 구조물을 불러온다.

public class CraftTab
{
    public CraftData craft;
}

public class CraftingTable : MonoBehaviour
{
    public GameObject craftingWindow;

    private PlayerController controller;

    [Header("Events")]
    public UnityEvent onOpenCraftingWindow;
    public UnityEvent onCloseCraftingWindow;

    public static CraftingTable instance;

    private void Awake()
    {
        instance = this;
        controller = GetComponent<PlayerController>();
    }

    private void Start()
    {
        
        craftingWindow = GameManager.Instance._UI.transform.Find("Crafting/CraftingCanvas").gameObject;
        craftingWindow.SetActive(false);
    }

    public void OnCraftingTableButton(InputAction.CallbackContext callbackContext)
    {
        if (callbackContext.phase == InputActionPhase.Started)
        {
            Toggle();
        }
    }

    public void Toggle()
    {
        if (craftingWindow.activeInHierarchy)
        {
            craftingWindow.SetActive(false);
            controller.ToggleCursor(false);
            onCloseCraftingWindow?.Invoke();
        }
        else
        {
            craftingWindow.SetActive(true);
            controller.ToggleCursor(true);
            onOpenCraftingWindow?.Invoke();
        }
    }

    public bool IsOpen()
    {
        return craftingWindow.activeInHierarchy;
    }
}
public class CraftTabUI : MonoBehaviour
{
    public Button button;
    public Image icon;
    private Outline outline;
    private CraftTab curTab;
    public int thisIcon;
    [SerializeField]private CraftBuild craftBuild;
    public TMP_Text selectedcraftname;
    public TMP_Text selectedcraftdescription;
    public TMP_Text selectedcraftingrediants;

    public bool equipped;

    public GameObject craftInforPanel;

    private void Awake()
    {
        outline = GetComponent<Outline>();
    }

    private void Start()
    {
        craftInforPanel.SetActive(false);
    }

    private void OnEnable()
    {
        outline.enabled = equipped;
    }

    public void Set(CraftTab tab)
    {
        curTab = tab;
        icon.gameObject.SetActive(true);
        icon.sprite = tab.craft.icon;

        if (outline != null)
        {
            outline.enabled = equipped;
        }
    }

    public void Clear()
    {
        curTab = null;
        icon.gameObject.SetActive(false);
    }

    public void OnButtonClick()
    {
        if (craftInforPanel.activeInHierarchy)
        {
            craftInforPanel.SetActive(false);
        }
        else
        {
            craftInforPanel.SetActive(true);
        }

        craftBuild.craftNum = thisIcon;
        selectedcraftname.text = craftBuild.crafts[thisIcon].craft.craftName;
        selectedcraftdescription.text = craftBuild.crafts[thisIcon].craft.description;
        selectedcraftingrediants.text = craftBuild.crafts[thisIcon].craft.ingrediants;

    }
}

 

 

유니티에서 연결이 헷갈리고 구조물과 상호작용을 구현하지 못해서 아쉽다. 다들 똑같은 시간에 개발했기 때문에 시간이 부족하다는건 핑계이고 코드 설계하는데 시간이 오래 걸리는걸 보면 공부량이 부족한게 맞다. 연휴에 강의 좀 재수강해야겠다.

이전에 공부했던 3D Suvival 게임을 기반으로 기능을 추가하여 만들기로 했다.

내가 맡은 부분은 건축으로, 여러 가지 구조물과 아이템을 제작하는 부분을 맡았다.

 

제작창

Input System으로 캐릭터 컨트롤러 스크립트를 불러와서 R키를 누르면 제작창이 키고 꺼지도록 하였다. 

public GameObject craftingWindow;

private PlayerController controller;

private void Awake()
{
    controller = GetComponent<PlayerController>();
}

private void Start()
{
    craftingWindow.SetActive(false);
}

public void OnCraftingTableButton(InputAction.CallbackContext callbackContext)
{
    if (callbackContext.phase == InputActionPhase.Started)
    {
        Toggle();
    }
}

public void Toggle()
{
    if (craftingWindow.activeInHierarchy)
    {
        craftingWindow.SetActive(false);
        controller.ToggleCursor(false);
    }
    else
    {
        craftingWindow.SetActive(true);
        controller.ToggleCursor(true);
    }
}

public bool IsOpen()
{
    return craftingWindow.activeInHierarchy;
}

 

제작창에서 제작할 구조물이나 아이템을 클릭하면 설명과 함께 제작 버튼이 활성화 된다.

private Button craftTabSelectBtn;
[SerializeField] private GameObject craftInforPanel;

private void Start()
{
    craftInforPanel.SetActive(false);
    craftTabSelectBtn = GetComponent<Button>();
    craftTabSelectBtn?.onClick.AddListener(() => OpenPanel());
}

public void OpenPanel()
{
    craftInforPanel.SetActive(true);
}

 

 

구조물 및 건물 건축

제작버튼을 누르면 제작창이 닫히고 미리보기 프리팹이 보이면서 플레이어를 따라가고, 좌클릭하면 프리팹이 사라지고 실제 구조물이 설치된다.

여러 개를 제작할 수 있도록 배열로 구조물들을 저장한다.

[System.Serializable]
public class Craft
{
    public string craftName;
    public GameObject buildCraft; // 실제 설치되는 구조물
    public GameObject previewCraft; // 설치 미리보기
}

public class CraftBuild : MonoBehaviour
{
    private bool isPreviewActivated = false;

    [SerializeField] private Craft[] craftCampfire;
    private GameObject varPreviewCraft; // 미리보기 변수
    private GameObject varBuildCraft; // 실제 설치되는 구조물 변수

    [SerializeField] private Transform playerPosition;
    private RaycastHit hitInfor;
    [SerializeField] private LayerMask layerMask;
    [SerializeField] private float range;

    [SerializeField] private GameObject craftingWindow;

    private void Update()
    {
        if (Input.GetButtonDown("Fire1"))
        {
            Build();
        }
    }

    private void Build()
    {
        if (isPreviewActivated)
        {
            Debug.Log("Create");
            Instantiate(varBuildCraft, varPreviewCraft.transform.position, Quaternion.identity);
            Destroy(varPreviewCraft);
            isPreviewActivated = false;
            craftingWindow.SetActive(false);
            varPreviewCraft = null;
            varBuildCraft = null;
        }
    }

    public void CreateBtnClick(int craftNum)
    {        
        varPreviewCraft = Instantiate(craftCampfire[craftNum].previewCraft, playerPosition.position + playerPosition.forward, Quaternion.identity);
        varPreviewCraft.transform.parent = playerPosition;
        varBuildCraft = craftCampfire[craftNum].buildCraft;
        isPreviewActivated = true;
        craftingWindow.SetActive(false);
        PlayerController.instance.ToggleCursor(false);
    }
}

 

 

다른 구조물도 추가할 예정인데 코드가 효율적이진 않은 것 같다. 실전과 이론을 함께 병행하며 좀 더 고민해보면 좋을 것 같은데 시간에 대한 아쉬움이 많고 이해력도 좋지 못해서 생각이 오래 걸려서 아쉽다. 구현하고 싶은 건 정말 많은데 어떤 식으로 방향을 잡아야할지 막막하다. 금방 뚝딱 만들 수 있는 경지까진 바라지 않아도 어떻게 구현할지 감을 잡을 수 있으면 좋겠다.

낮과 밤 구현

빈 오브젝트를 생성하고 해와 달 역할을 해줄 Directional Light 두 개를 하위에 넣는다.

 

낮과 밤, 시간이 바뀌게 할 스크립트를 만든다.

0부터 1까지 시간을 조절하도록 하고 게임 내의 하루 시간을 몇으로 할 것인지, 자정의 각도를 몇으로 할 것인지 정할 수 있게 해준다. AnimationCurve는 원하는 각도로 그래프를 그려서 타임 값을 가져올 수 있고, 반대로 타임 값을 주면 그 시간에 맞는 그래프를 가져온다. 시간에 따라 빛의 각도를 조절하여 실제 해가 움직이는 것처럼 만든다.

[Range(0.0f, 1.0f)]
public float time;
public float fullDayLength;
public float startTime = 0.4f;
private float timeRate;
public Vector3 noon;

[Header("Sun")]
public Light sun;
public Gradient sunColor;
public AnimationCurve sunIntensity;

[Header("Moon")]
public Light moon;
public Gradient moonColor;
public AnimationCurve moonIntensity;

[Header("Other Lighting")]
public AnimationCurve lightingIntensityMultiplier;
public AnimationCurve reflectionIntensityMultiplier;

private void Start()
{
    timeRate = 1.0f / fullDayLength;
    time = startTime;
}

private void Update()
{
    time = (time + timeRate * Time.deltaTime) % 1.0f;

    UpdateLighting(sun, sunColor,sunIntensity);
    UpdateLighting(moon, moonColor,moonIntensity);

    RenderSettings.ambientIntensity = lightingIntensityMultiplier.Evaluate(time);
    RenderSettings.reflectionIntensity = reflectionIntensityMultiplier.Evaluate(time);

}

void UpdateLighting(Light lightSource, Gradient colorGradiant, AnimationCurve intensityCurve)
{
    float intensity = intensityCurve.Evaluate(time);

    lightSource.transform.eulerAngles = (time - (lightSource == sun ? 0.25f : 0.75f)) * noon * 4.0f;
    lightSource.color = colorGradiant.Evaluate(time);
    lightSource.intensity = intensity;

    GameObject go = lightSource.gameObject;
    if(lightSource.intensity ==0&&go.activeInHierarchy)
        go.SetActive(false);
    else if( lightSource.intensity>0 &&!go.activeInHierarchy)
        go.SetActive(true);
}

 

유니티로 돌아와서 그래프로 시간에 따른 조명 색을 정해준다.

 

 

아이템 상호작용

Interactable 레이어를 추가하여 상호작용 할 수 있는 것들에 대한 처리를 준비한다. 아이템 데이터로 아이템을 생성하기 위해 스크립트를 만든다.

public enum ItemType
{
    Resource,
    Equipable,
    Consumable
}

public enum ConsumableType
{
    Hunger,
    Health
}

[System.Serializable]
public class ItemDataConsumable
{
    public ConsumableType type;
    public float value;
}

[CreateAssetMenu(fileName ="Item", menuName = "New Item")]
public class ItemData : ScriptableObject
{
    [Header("Info")]
    public string displayName;
    public string description;
    public ItemType type;
    public Sprite icon;
    public GameObject dropPrefab;

    [Header("Stacking")]
    public bool canStack;
    public int maxStackAmount;

    [Header("Consumable")]
    public ItemDataConsumable[] consumables;

}

 

아이템들을 생성한다.

 

Rigidbody와 Box Collider, Prefab를 붙이고 Prefab에 붙어있던 Capsule Collider는 지운다. 아까 생성한 Interactable 레이어로 바꿔준다.

 

Item에 ItemData를 전달하기 위해 스크립트를 붙인다.

public class ItemObject : MonoBehaviour
{
    public ItemData item;
}

 

InteractionManager 스크립트를 만들고 인터페이스를 ItemData 스크립트에 상속 시킨다.

public interface IInteractable
{
    string GetInteractPrompt();
    void OnInteract();
}

public class InteractionManager : MonoBehaviour
{
    public float checkRate = 0.05f;
    private float lastCheckTime;
    public float maxCheckDistance;
    public LayerMask layerMask;

    private GameObject curInteractGameobject;
    private IInteractable curInteractable;

    public TextMeshProUGUI promptText;
    private Camera camera;


    // Start is called before the first frame update
    void Start()
    {
        camera = Camera.main;
    }

    // Update is called once per frame
    void Update()
    {
        if (Time.time - lastCheckTime > checkRate)
        {
            lastCheckTime = Time.time;

            Ray ray = camera.ScreenPointToRay(new Vector3(Screen.width / 2, Screen.height / 2));
            RaycastHit hit;

            if (Physics.Raycast(ray, out hit, maxCheckDistance, layerMask))
            {
                if (hit.collider.gameObject != curInteractGameobject)
                {
                    curInteractGameobject = hit.collider.gameObject;
                    curInteractable = hit.collider.GetComponent<IInteractable>();
                    SetPromptText();
                }
            }
            else
            {
                curInteractGameobject = null;
                curInteractable = null;
                promptText.gameObject.SetActive(false);
            }
        }
    }

    private void SetPromptText()
    {
        promptText.gameObject.SetActive(true);
        promptText.text = string.Format("<b>[E]</b> {0}", curInteractable.GetInteractPrompt());
    }

    public void OnInteractInput(InputAction.CallbackContext callbackContext)
    {
        if (callbackContext.phase == InputActionPhase.Started && curInteractable != null)
        {
            curInteractable.OnInteract();
            curInteractGameobject = null;
            curInteractable = null;
            promptText.gameObject.SetActive(false);
        }
    }
}

 

 

public class ItemObject : MonoBehaviour, IInteractable
{
    public ItemData item;

    public string GetInteractPrompt()
    {
        return string.Format("Pickup {0}", item.displayName);
    }

    public void OnInteract()
    {
        Destroy(gameObject);
    }
}​

 

PromptText를 상태창 UI에 추가한다.

 

Player에 InteractionManager 스크립트를 연결하고 상호작용 문구를 연결한다.

 

Player에 Interact를 추가하여 상호작용 하도록 한다.

 

 

인벤토리 & 아이템 사용

인벤토리를 만들기 위해 Canvas를 만들고 클릭해서 사용할 것이기 때문에 Graphic Raycaster을 추가한다.

스크린 해상도를 설정하고 상태창과 겹쳐졌을 때, 가장 위에 와야하기 때문에 Sort Order을 1로 해준다.

 

하단에 인벤토리의 배경이 될 Image를 추가한다. 인벤토리 아이템을 선택할 수 있는 창을 만들고 아이템 슬롯을 만들어서 Prefab화 하여 Grid Layout Group으로 묶어준다.

 

인터페이스 창을 만들어준다.

 

Inventory 스크립트를 만들고 유니티에서 UI를 연결한다.Slot Prefab에 Outline을 추가하고 선택했을 때 Outline이 켜지고 아이템 정보가 뜨도록 한다. 기존에 같은 아이템을 가지고 있다면 쌓을 수 있는지 여부를 확인하고 쌓는다. 쌓지 못하거나 최대 수량으로 아이템이 쌓여있으면 다른 슬롯에 아이템을 추가한다. 인벤토리가 다 찼을 때, 아이템을 획득하면 랜덤으로 회전하며 떨어뜨린다.

public class ItemSlot
{
    public ItemData item;
    public int quantity;
}

public class Inventory : MonoBehaviour
{
    public ItemSlotUI[] uiSlots;
    public ItemSlot[] slots;

    public GameObject inventoryWindow;
    public Transform dropPosition;

    [Header("Selected Item")]
    private ItemSlot selectedItem;
    private int selectedItemIndex;
    public TextMeshProUGUI selectedItemName;
    public TextMeshProUGUI selectedItemDescription;
    public TextMeshProUGUI selectedItemStatNames;
    public TextMeshProUGUI selectedItemStatValues;
    public GameObject useButton;
    public GameObject equipButton;
    public GameObject unEquipButton;
    public GameObject dropButton;

    private int curEquipIndex;

    private PlayerController controller;
    private PlayerConditions condition;

    [Header("Events")]
    public UnityEvent onOpenInventory;
    public UnityEvent onCloseInventory;

    public static Inventory instance;
    void Awake()
    {
        instance = this;
        controller = GetComponent<PlayerController>();
        condition = GetComponent<PlayerConditions>();
    }
    private void Start()
    {
        inventoryWindow.SetActive(false);
        slots = new ItemSlot[uiSlots.Length];

        for (int i = 0; i < slots.Length; i++)
        {
            slots[i] = new ItemSlot();
            uiSlots[i].index = i;
            uiSlots[i].Clear();
        }

        ClearSeletecItemWindow();
    }

    public void OnInventoryButton(InputAction.CallbackContext  callbackContext)
    {
        if(callbackContext.phase == InputActionPhase.Started)
        {
            Toggle();
        }
    }


    public void Toggle()
    {
        if (inventoryWindow.activeInHierarchy)
        {
            inventoryWindow.SetActive(false);
            onCloseInventory?.Invoke();
            controller.ToggleCursor(false);
        }
        else
        {
            inventoryWindow.SetActive(true);
            onOpenInventory?.Invoke();
            controller.ToggleCursor(true);
        }
    }

    public bool IsOpen()
    {
        return inventoryWindow.activeInHierarchy;
    }

    public void AddItem(ItemData item)
    {
        if(item.canStack)
        {
            ItemSlot slotToStackTo = GetItemStack(item);
            if(slotToStackTo != null)
            {
                slotToStackTo.quantity++;
                UpdateUI();
                return;
            }
        }

        ItemSlot emptySlot = GetEmptySlot();

        if(emptySlot != null)
        {
            emptySlot.item = item;
            emptySlot.quantity = 1;
            UpdateUI();
            return;
        }

        ThrowItem(item);
    }

    void ThrowItem(ItemData item)
    {
        Instantiate(item.dropPrefab, dropPosition.position, Quaternion.Euler(Vector3.one * Random.value * 360f));
    }

    void UpdateUI()
    {
        for(int i = 0; i< slots.Length; i++)
        {
            if (slots[i].item != null)
                uiSlots[i].Set(slots[i]);
            else
                uiSlots[i].Clear();
        }
    }

    ItemSlot GetItemStack(ItemData item)
    {
        for (int i = 0; i < slots.Length; i++)
        {
            if (slots[i].item == item && slots[i].quantity < item.maxStackAmount)
                return slots[i];
        }

        return null;
    }

    ItemSlot GetEmptySlot()
    {
        for (int i = 0; i < slots.Length; i++)
        {
            if (slots[i].item == null)
                return slots[i];
        }

        return null;
    }

    public void SelectItem(int index)
    {
        if (slots[index].item == null)
            return;

        selectedItem = slots[index];
        selectedItemIndex = index;

        selectedItemName.text = selectedItem.item.displayName;
        selectedItemDescription.text = selectedItem.item.description;

        selectedItemStatNames.text = string.Empty;
        selectedItemStatValues.text = string.Empty;

        for(int i = 0; i< selectedItem.item.consumables.Length; i++)
        {
            selectedItemStatNames.text += selectedItem.item.consumables[i].type.ToString() + "\n"; 
            selectedItemStatValues.text += selectedItem.item.consumables[i].value.ToString() + "\n";
        }

        useButton.SetActive(selectedItem.item.type == ItemType.Consumable);
        equipButton.SetActive(selectedItem.item.type == ItemType.Equipable && !uiSlots[index].equipped);
        unEquipButton.SetActive(selectedItem.item.type == ItemType.Equipable && uiSlots[index].equipped);
        dropButton.SetActive(true);
    }

    private void ClearSeletecItemWindow()
    {
        selectedItem = null;
        selectedItemName.text = string.Empty;
        selectedItemDescription.text = string.Empty;

        selectedItemStatNames.text = string.Empty;
        selectedItemStatValues.text = string.Empty;

        useButton.SetActive(false);
        equipButton.SetActive(false);
        unEquipButton.SetActive(false);
        dropButton.SetActive(false);
    }

    public void OnUseButton()
    {
        if(selectedItem.item.type == ItemType.Consumable)
        {
            for (int i = 0; i < selectedItem.item.consumables.Length; i++)
            {
                switch (selectedItem.item.consumables[i].type)
                {
                    case ConsumableType.Health:
                        condition.Heal(selectedItem.item.consumables[i].value); break;
                    case ConsumableType.Hunger:
                        condition.Eat(selectedItem.item.consumables[i].value); break;
                }
            }
        }
        RemoveSelectedItem();
    }

    public void OnEquipButton()
    {

    }

    void UnEquip(int index)
    {

    }

    public void OnUnEquipButton()
    {

    }

    public void OnDropButton()
    {
        ThrowItem(selectedItem.item);
        RemoveSelectedItem();
    }

    private void RemoveSelectedItem()
    {
        selectedItem.quantity--;

        if(selectedItem.quantity <= 0 ) 
        {
            if (uiSlots[selectedItemIndex].equipped )
            {
                UnEquip(selectedItemIndex);
            }

            selectedItem.item = null;
            ClearSeletecItemWindow();
        }

        UpdateUI();
    }

    public void RemoveItem(ItemData item)
    {

    }

    public bool HasItems(ItemData item, int quantity)
    {
        return false;
    }
}

 

아이템의 떨어지는 위치를 지정하기 위해 Player 오브젝트에 오브젝트를 추가하고 연결한다.

 

마찬가지로 ItemUISlot 스크립트를 만들어서 Slot Prefab에 적용한다.

public Button button;
public Image icon;
public TextMeshProUGUI quatityText;
private ItemSlot curSlot;
private Outline outline;

public int index;
public bool equipped;

private void Awake()
{
    outline = GetComponent<Outline>();
}

private void OnEnable()
{
    outline.enabled = equipped;
}

public void Set(ItemSlot slot)
{
    curSlot = slot;
    icon.gameObject.SetActive(true);
    icon.sprite = slot.item.icon;
    quatityText.text = slot.quantity > 1 ? slot.quantity.ToString() : string.Empty;

    if (outline != null)
    {
        outline.enabled = equipped;
    }
}

public void Clear()
{
    curSlot = null;
    icon.gameObject.SetActive(false);
    quatityText.text = string.Empty;
}

public void OnButtonClick()
{
    Inventory.instance.SelectItem(index);
}

 

Button을 달고 Button에 자기 자신을 연결시켜준다.

 

아이템들을 Prefab화 시키고 아이템 데이터에 연결한다.

 

Player에 인벤토리를 연결하여 탭을 눌렀을 때, 인벤토리 창이 켜지도록 한다.

 

ItemObject 스크립트에 아이템을 주웠을 때, 인벤토리 창에 아이템이 추가되도록 코드를 추가한다.

public void OnInteract()
{
    Inventory.instance.AddItem(item);
    Destroy(gameObject);
}

 

인벤토리 버튼들을 연결한다.

 

아이템을 사용했을 때, 플레이어의 상태가 변하도록 한다.

 

 

2D에서 3D로 프로젝트 파일을 만들면 차이점이 하이어라키 창에 MainCamera 대신 Directional Light라는 오브젝트가 있는 것이다. Light 소스는 특정한 위치와 방향으로 오브젝트에 빛을 쏴주는 역할을 하며 여러가지 유형이 있다. 연산이 비싸고 성능에 밀접한 영향을 끼치기 때문에 적당히 사용하는 것이 좋다.

 

준비된 프리팹을 불러오고 SkyBox 용도로 사용할 Material을 만든다.

SkyBox는 게임 배경을 둘러싸는 환경 매핑 기술로, 유니티에서 씬 배경으로 사용된다. 낮과 밤 등 시간대에 맞게 변화시킬 수 있고 주로 하늘, 구름, 산 등의 자연 배경을 표현하는데 사용되며, 보통 박스나 구 형태이다.

 

유니티 우측 하단에 Auto Generate Lighting Off를 누른다. (Window - Rendering - Lighting을 눌러도 된다.)

 

Enviroment에서 Default 값으로 되어있는 Skybox에 방금 만든 SkyBox를 넣어준다.

 

 

플레이어 만들기

기본 오브젝트를 생성하여 플레이어를 만들고 하위에 눈(시야) 역할을 할 오브젝트를 추가한다. Main Camera를 하위에 넣어 좌표를 플레이어에게 고정시킨다.

 

어디부터 어디까지 보일 것인지 Main Camera의 시야 절도체를 조절한다.

 

플레이어에 Capsule Colider와 Rigidbody를 달아준다. Rigidbody의 질량(Mass)는 20 정도로 너무 가볍지 않게 주고 Freeze Rotation의 X, Y, Z축을 물리적인 회전을 하지 않게 켜준다.

 

Player의 태그를 Player로, 레이어에 Player를 추가하여 바꿔주고, Input System 패키지를 받아 플레이어의 Input Actions를 추가하여 필요한 값들을 설정한다.

 

Player 오브젝트에 Player Input을 추가하고 방금 만든 Input Actions를 넣어준다.

 

저번 2D 개발 글에서는 onMove, onAttack, onJump 등 SendMessage 상태로 바로 호출하여 사용하는 방식을 사용했다. 이번에는 Event를 이용해볼 것이다. Behavior를 Invoke Unity Events로 바꾼다. Invoke Unity Events는 Delegate의 종류 중 하나인데 유니티에서 사용하기에 더 특화되어 있다.

 

Player를 컨트롤하기 위한 스크립트를 만들어 연결한다.

마우스를 돌리면 카메라 시야를 돌릴 수 있게 하고 땅에 닿았을 때만 점프하도록 제한을 둔다.

[Header("Movement")]
public float moveSpeed;
private Vector2 curMovementInput;
public float jumpForce;
public LayerMask groundLayerMask;

[Header("Look")]
public Transform cameraContainer;
public float minXLook;
public float maxXLook;
private float camCurXRot;
public float lookSensitivity;

private Vector2 mouseDelta;

[HideInInspector]
public bool canLook = true;

private Rigidbody _rigidbody;

public static PlayerController instance;
private void Awake()
{
    instance = this;
    _rigidbody = GetComponent<Rigidbody>();
}

void Start()
{
	// 커서 안보이게
    Cursor.lockState = CursorLockMode.Locked;
}

private void FixedUpdate()
{
    Move();
}

private void LateUpdate()
{
    if (canLook)
    {
        CameraLook();
    }
}

private void Move()
{
    Vector3 dir = transform.forward * curMovementInput.y + transform.right * curMovementInput.x;
    dir *= moveSpeed;
    dir.y = _rigidbody.velocity.y;

    _rigidbody.velocity = dir;
}

// 카메라를 마우스로 볼 수 있게하는 코드
void CameraLook()
{
    camCurXRot += mouseDelta.y * lookSensitivity;
    camCurXRot = Mathf.Clamp(camCurXRot, minXLook, maxXLook);
    cameraContainer.localEulerAngles = new Vector3(-camCurXRot, 0, 0);

    transform.eulerAngles += new Vector3(0, mouseDelta.x * lookSensitivity, 0);
}

public void OnLookInput(InputAction.CallbackContext context)
{
    mouseDelta = context.ReadValue<Vector2>();
}

public void OnMoveInput(InputAction.CallbackContext context)
{
    if (context.phase == InputActionPhase.Performed)
    {
        curMovementInput = context.ReadValue<Vector2>();
    }
    else if (context.phase == InputActionPhase.Canceled)
    {
        curMovementInput = Vector2.zero;
    }
}

public void OnJumpInput(InputAction.CallbackContext context)
{
    if (context.phase == InputActionPhase.Started)
    {
        if (IsGrounded())
            _rigidbody.AddForce(Vector2.up * jumpForce, ForceMode.Impulse);

    }
}

// 땅을 밟고 있을 때만 점프
private bool IsGrounded()
{
    Ray[] rays = new Ray[4]
    {
        new Ray(transform.position + (transform.forward * 0.2f) + (Vector3.up * 0.01f) , Vector3.down),
        new Ray(transform.position + (-transform.forward * 0.2f)+ (Vector3.up * 0.01f), Vector3.down),
        new Ray(transform.position + (transform.right * 0.2f) + (Vector3.up * 0.01f), Vector3.down),
        new Ray(transform.position + (-transform.right * 0.2f) + (Vector3.up * 0.01f), Vector3.down),
    };

    for (int i = 0; i < rays.Length; i++)
    {
        if (Physics.Raycast(rays[i], 0.1f, groundLayerMask))
        {
            return true;
        }
    }

    return false;
}

private void OnDrawGizmos()
{
    Gizmos.color = Color.red;
    Gizmos.DrawRay(transform.position + (transform.forward * 0.2f), Vector3.down);
    Gizmos.DrawRay(transform.position + (-transform.forward * 0.2f), Vector3.down);
    Gizmos.DrawRay(transform.position + (transform.right * 0.2f), Vector3.down);
    Gizmos.DrawRay(transform.position + (-transform.right * 0.2f), Vector3.down);
}

public void ToggleCursor(bool toggle)
{
    Cursor.lockState = toggle ? CursorLockMode.None : CursorLockMode.Locked;
    canLook = !toggle;
}

 

 

플레이어 상태 및 UI

UI를 만들기 위해 빈 오브젝트를 만든다.

하단에 Canvas를 추가하고 해상도를 설정해준다.

 

HP 표시를 위해 Image들을 추가해준다.

 

이번엔 Slider가 아닌 Sprite로 HP를 표시해볼 것이기 때문에 Package Manager에서 2D Sprite를 설치한다.

Asset에 폴더를 만들고 그 안에 자유롭게 자르거나 늘리는 등 수정할 Sprite - Square를 만들어준다. Health 캔버스 아래 이미지 오브젝트를 하나 더 생성하고 Square를 넣어준 뒤, Image TypeFiled로 바꾼다. Sliced는 이미지를 잘라서 늘렸을 때, 늘어나야하는 방향을 설정해줘서 이미지가 깨지지 않게 한다. Tiled는 바둑 방식, Filed는 채울 때 사용한다.

 

빈 오브젝트를 추가하여 Vertical Layout Group으로 Health를 포함한 상태들을 묶어준다.

 

화면 중앙에 Image 두 개를 추가하여 Crosshair와 DamageIndicator을 만든다. DamageIndicator는 데미지를 입었을 때만 켜지도록 꺼두고 Player에 플레이어의 상태를 관리해줄 스크립트를 추가한다.

 

public으로 Max값, 시작값, 회복률, 감소율 등을 추가하고, [HideInInspector]로 Inspector창에서 수정할 수 없게 한다.

상태들을 불러오고 배고픔이 다 닳았을 때는 데미지가 감소하도록, 데미지를 받았을 때 처리할 이벤트를 받기 위해 UnityEvent [System.Serializable]로 플레이어의 상태들을 Inspector창에 노출시키고 상태 bar들을 넣어준다.

ublic interface IDamagable
{
    void TakePhysicalDamage(int damageAmount);
}

[System.Serializable]
public class Condition
{
    [HideInInspector]
    public float curValue;
    public float maxValue;
    public float startValue;
    public float regenRate;
    public float decayRate;
    public Image uiBar;

    public void Add(float amount)
    {
        curValue = Mathf.Min(curValue+ amount, maxValue);
    }

    public void Subtract(float amount) 
    {
        curValue = Mathf.Max(curValue - amount, 0.0f);
    }

    public float GetPercentage()
    {
        return curValue / maxValue;
    }

}

public class PlayerConditions : MonoBehaviour, IDamagable
{
    public Condition health;
    public Condition hunger;
    public Condition stamina;

    public float noHungerHealthDecay;

    public UnityEvent onTakeDamage;

    void Start()
    {
        health.curValue = health.startValue;
        hunger.curValue = hunger.startValue;
        stamina.curValue = stamina.startValue;
    }

    // Update is called once per frame
    void Update()
    {
        hunger.Subtract(hunger.decayRate * Time.deltaTime);
        stamina.Add(stamina.regenRate * Time.deltaTime);

        if(hunger.curValue == 0.0f)
            health.Subtract(noHungerHealthDecay * Time.deltaTime);

        if (health.curValue == 0.0f)
            Die();

        health.uiBar.fillAmount = health.GetPercentage();
        hunger.uiBar.fillAmount = hunger.GetPercentage();
        stamina.uiBar.fillAmount = stamina.GetPercentage();
    }

    public void Heal(float amount)
    {
        health.Add(amount);
    }

    public void Eat(float amount)
    {
        hunger.Add(amount);
    }

    public bool UseStamina(float amount)
    {
        if (stamina.curValue - amount < 0)
            return false;

        stamina.Subtract(amount);
        return true;
    }

    public void Die()
    {
        Debug.Log("플레이어가 죽었다.");
    }

    public void TakePhysicalDamage(int damageAmount)
    {
        health.Subtract(damageAmount);
        onTakeDamage?.Invoke();
    }

 

Campfire 오브젝트를 만들고 범위를 지정하여 닿았을 때, 데미지가 들어오도록 한다.

 

Update에서 델타 타임을 쌓아가며 지정한 시간보다 넘어가면 실행하거나 Coroutine으로 wait for second로 시간을 기다리는 코들르 사용하지 않고 InvokeRepeating로 지연 실행시킨다. 아래 코드에서는 List를 사용했는데 삽입, 삭제가 한 번에 일어나기 때문에 더 빠른 HashSet을 사용해도 된다.

public int damage;
    public float damageRate;

    private List<IDamagable> thingsToDamage = new List<IDamagable>();

    private void Start()
    {
        InvokeRepeating("DealDamage", 0, damageRate);
    }

    void DealDamage()
    {
        for(int i =0;i<thingsToDamage.Count;i++)
        {
            thingsToDamage[i].TakePhysicalDamage(damage);
        }
    }

    private void OnTriggerEnter(Collider other)
    {
        if(other.gameObject.TryGetComponent(out IDamagable damagable))
        {
            thingsToDamage.Add(damagable);
        }
    }

    private void OnTriggerExit(Collider other)
    {
        if (other.gameObject.TryGetComponent(out IDamagable damagable))
        {
            thingsToDamage.Remove(damagable);
        }
    }

 

데미지를 받았을 때, 아까 만들어둔 DamageIndicator이 켜지고 일정 시간 동안 감소되어 사라지는  스크립트를 추가한다.

public Image image;
    public float flashSpeed;

    private Coroutine coroutine;

    public void Flash()
    {
        if(coroutine != null)
        {
            StopCoroutine(coroutine);
        }

        image.enabled = true;
        image.color = Color.red;
        coroutine = StartCoroutine(FadeAway());
    }

    private IEnumerator FadeAway()
    {
        float startAlpha = 0.3f;
        float a = startAlpha;

        while(a > 0.0f)
        {
            a -= (startAlpha / flashSpeed) * Time.deltaTime;
            image.color = new Color(1.0f, 0.0f, 0.0f, a);
            yield return null;
        }

        image.enabled = false;
    }

 

플레이어가 데미지를 받았을 때, DamageIndicator가 실행하도록 한다.

 

그런데 안켜진다... image.enabled를 했기 때문에 오브젝트가 아니라 image 컴포넌트를 꺼야한다.

+ Recent posts