낮과 밤 구현

빈 오브젝트를 생성하고 해와 달 역할을 해줄 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 컴포넌트를 꺼야한다.

팀 프로젝트 발표를 마치며 프로젝트가 마무리 됐다.

필수 구현은 모두 했기 때문에 뿌듯하면서도 시간 부족으로 버그를 잡지 못하고 싱글톤으로 하나의 스크립트로 데이터를 관리하며 다른 곳에서 사용하도록 하지 못해서 단위 테스트가 어려웠으며 전체적으로 객체지향적인 프로그래밍이 아니었다는 점이 아쉬웠다.

비둘기가 날 때 날게 하긴 했는데 날개로 나는게 아니라 머리로 날게 만들었다는 비유가 딱 맞는 것 같다.

다음에 팀프로젝트를 진행할 때, GameManager 부분을 맡으면 전체적으로 자신이 맡은 코드 뿐만 아니라 다른 분들의 코드까지 이해하고(물론 다른 역할을 맡았을 때도 중요하지만) 전체적인 구조를 파악해야하기 때문에 공부하기에 좋을 것 같다.

최종적으로 프로젝트를 합치는 과정에 게임 완성도를 높이기 위해 추가구현으로 메인 화면에 Animation을 넣고 메인화면씬과 게임플레이 씬에 Audio를 추가했다.

 

그리고 발표를 맡게 되어 코드에 대해 전체적인 이해를 위해 다른 분들의 코드를 공부했다. 

ppt를 만들고 대본을 쓰는 중인데 완성이 늦어 리허설을 못해서 걱정이 많이 된다.

일단 대본을 완성하고 내일 오전에 팀원분들과 잠깐이라도 리허설을 해봐야겠다.

 

이번 프로젝트를 하며 사람 간의 협업과 의사소통에 대한 많은 생각을 하게 되었고 실력에 대한 고민도 많이 들었다. 이론을 이해했다고 착각한 상태로 혼자 응용해보려니까 힘들었다. 내가 맞게 공부하고 있는지, 어느 것을 모르는 상태인지 확실히 알고 공부 방법에 대해서도 다시 생각해보아야겠다.

+ Recent posts