0. 들어가기에 앞서
근로자의 날로 하루 정도의 연휴가 끼었다. 우리 팀은 오늘 대략적인 기능 합치기를 하기 위해서 모이기로 결정했고, 회의를 거쳐 기능을 합치고 테스트를 진행하는 과정을 거쳤다. 이 중에서도 화의를 통해 변경된 주요 변경점에 대해 서술하고자 한다.
플레이어의 기능 구현은 내 담당이 아니긴 했지만, 전체적으로 움직임이나 달리기, 점프 등에서 아래와 같은 개선점이 있었다.
달리기를 할 때 스태미나를 소모하고, 스태미나가 0이 되면 달리기를 멈춘다는 기능은 이미 구현해 놓은 상태였다. 다만 달리기를 할 때 속도 증가 매커니즘을 변경하고, 캐릭터라 달릴 때의 움직임에 흔들림을 주는 기능이 필요하다고 판단했다.
이 부분에서 팀원 간의 소통의 부족함이 조금 있었다.
팀장님이 처음 이 기능 구현을 생각했을 때, 책상 밑에 앉아서 숨는 방식 등을 생각했었는데, 플레이어 담당자는 해당 내용을 잘못 해석하여 숨을 수 있는 물체에 다가갔을 때 숨기 버튼을 눌러 숨는 방식으로 구현하였다. 나는 플레이어 담당자의 코드 및 소통을 바탕으로 숨기가 가능한 트리거 영역을 만들었는데, 이런 의도의 기능 구현이 아님을 뒤늦게 알게 되었다.
게임으로 치면 R.E.P.O.의 숙이기를 통해서 책상 밑에 들어가는 방식으로 숨기를 구현하고자 하였으며, 이에 따라 책상의 트리거 영역도 불필요해지고 기능 부분의 개편이 있었다.
이 부분이 내가 작업한 작업물과 제일 직접적으로 연관되어 있고, 많은 개편이 이루어진 부분이다. 해당 기능을 구현할 당시 다른 작업물이 아직 완성된 상태가 아니고 인터렉션 키가 정해지지도 않은 상태였기 때문에 임시로 플레이어를 만들어 테스트를 진행하고 있었다.
다만 내가 임시로 만든 플레이어에 대해서 팀장님의 코멘트를 들었고 이후 코드의 많은 부분에서 개편이 있었다.
내가 테스트로 진행할 때에는 임시로 버튼을 이용한 이벤트 시스템으로 플레이어를 구현했으나, 이벤트 시스템으로 Interact를 구현했을 때 문제가 생길 수 있다.
Event로 Interact를 구현했을 때 상호작용할 물체가 적으면 그리 상관이 없지만, 상호작용할 물체의 수가 많아지면 메모리적으로 부담이 될 수 있다.
이와 같은 점을 반영하여 우선 '플레이어 - 상호작용 가능한 아이템과의 상호작용'을 아래와 같이 구현하였다.
문과 스위치에는 버튼으로 테스트하다가, 책상에서 서랍을 열기 위해 Raycast를 썼던 내 코드를 참고하여 팀장님이 이와 같이 플레이어 코드를 수정하기로 하였다.
private void InteractWithInteractableObject()
{
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
Debug.DrawRay(ray.origin, ray.direction * m_raycastDistance, Color.red, 0.1f);
if (Physics.Raycast(ray, out RaycastHit hitInfo))
{
if (hitInfo.distance > m_raycastDistance)
return;
Debug.Log(hitInfo.collider.gameObject.name);
if (hitInfo.collider.gameObject.CompareTag("InteractableObject"))
{
IInteractable interactableObject = hitInfo.collider.gameObject.GetComponent<IInteractable>();
interactableObject.Interact();
// 상호작용 키가 너무 빨리 눌리는 것을 방지(두 번 눌러서 키입력이 씹히는 경우 발생)
m_interactionCoroutine = StartCoroutine(InteractionDelay());
}
}
}
IEnumerator InteractionDelay()
{
yield return new WaitForSeconds(0.1f);
m_interactionCoroutine = null;
}
이와 같이 작성하면 애니메이션이 있는 상호작용 물체에만 InteractableObject 태그를 달아, 내가 이전부터 겪었던 인터렉선 불가능한 아이템에 Ray를 쐈을 때의 경우를 반응 없음 처리를 할 수 있었다.
서랍의 경우에는 더이상 Trigger 영역을 둘 필요가 없으며, 각 서랍마다 애니메이터를 저장하고 불러오는 방식으로 코드를 작성하여 기능을 더 간결하게 만들 수 있었다.
using UnityEngine;
public class DeskDrawerController : MonoBehaviour, IInteractable
{
private void Animator m_animator;
private void Awake()
{
m_animator = gameobject.GetComponent<Animator>();
}
public void Interact()
{
DrawerAnimator(m_animator);
}
private void DrawerAnimator(Animator animator)
{
bool isOpen = animator.GetBool("IsOpen");
isOpen = !isOpen;
animator.SetBool("IsOpen", isOpen);
}
}
이와 같이 작성하고 애니메이터가 있는 서랍(위쪽), 서랍(아래쪽)에 각각 컴포넌트를 달고, 상호작용 아이템 태그를 달아주면 된다.
문의 경우가 상당히 복잡했다. 문의 경우는 책상 서랍과는 달리 Trigger 영역을 없애면 안 되기 때문에 Trigger 영역을 지우지 않고 진행했다. (Trigger 영역으로 플레이어의 위치를 판별하고, 문을 시계방향으로 열 지 시계 반대 방향으로 열지 결정하기 때문.)
다만 플레이어가 상호작용을 눌렀을 때, 문이 바로 열리지 않거나, 플레이어가 문을 바라보지 않아도 열리는 문제가 발생하여 다음과 같이 이슈를 달았다.
이 부분에 대해서는 코드에 대한 변경점 대신 프리팹에 대한 수정을 통해 아래와 같이 구현하기로 되었다.
이와 같이 구조를 변경하여 플레이어와 상호 작용 가능한 문을 구현하였다.
다만 이와 같은 과제가 남았다.
Trigger 영역을 이용한 방법이 최선인가? 에 대한 논의
Raycast를 이용한 방법도 논의되었지만 더 코스트가 심하다고 하여 우선은 Trigger를 사용한 방법으로 결정되었으나, 리팩토링이 가능한지 생각해 보기로 함.
몬스터 담당자가 테스트를 진행할 때, 몬스터가 문을 통과하지 못하는 현상이 발생했다.
(해당 문제는 1차적으로 AI Navigation으로 문 부분이 통과범위로 인식되지 못함이 1차적인 문제이다.)
1차적인 문제는 몬스터 담당자가 해결해야 할 부분이나, 2차적인 문제로는 몬스터가 문을 열지 못한다는 점이 있었다. 이에 따라 몬스터가 트리거 영역에 들어가면 문을 자동으로 열리게끔 하는 방법에 대한 고민이 필요해 보인다.
이 부분에 대해서는 아무래도 팀 내에서 어떤 의도로 기능을 만들었는가에 대한 의논이 필요했다. 처음에 팀장님이 제시했던 방식은 '무슨 키를 눌러서 아이템을 먹기'와 같이 출력하려는 의도였지만, 구체적으로 어떻게 출력할지 명확한 제시가 없어 나는 아이템에 커서를 갖다댔을 때 박스 아이콘 형태로 텍스트가 출력되는 방식을 만들었다.
이 부분에 대해서는 아래와 같은 틀로 진행하기로 했다.
- 아이템에 마우스를 가져다 댔으면, '아이템을 획득하려면 [Input]키를 누르세요.'를 출력한다
- 상호작용 가능한 오브젝트에 마우스를 가져대 댔으면, '상호작용하려면 [Input]키를 누르세요'를 출력한다.
이 작업은 생각보다 쉽지 않았다. 마우스가 아이템 태그를 단 아이템은 아이템 획득으로 출력하고, 상호작용 가능 아이템에는 상호작용하기로 띄우면 되기야 하지만, Input키가 변경 가능하게 된 부분이 문제였다.
...
private void UpdateTextMessage()
{
var interactionKey = PlayerPrefs.GetString("Interaction");
if (string.IsNullOrEmpty(interactionKey))
return;
if (gameObject.CompareTag("Item"))
{
m_popupText.text= $"아이템을 얻으려면 [{interactionKey}]를 누르세요.";
}
else if (gameObject.CompareTag("InteractableObject"))
{
m_popupText.text= $"상호작용을 하려면 [{interactionKey}]를 누르세요.";
}
}
...
using UnityEngine;
using UnityEngine.InputSystem;
public class InputSetting : MonoBehaviour
{
private PlayerInput m_playerInput;
void Start()
{
m_playerInput = GetComponent<PlayerInput>();
GameManager.Instance.Input.SetPlayerInput(m_playerInput);
LoadBinding();
}
public void LoadBinding()
{
var rebinds = PlayerPrefs.GetString("rebinds");
if (!string.IsNullOrEmpty(rebinds))
m_playerInput.actions.LoadBindingOverridesFromJson(rebinds);
foreach(var action in m_playerInput.actions)
{
if (action.name == "Interaction")
{
string keyValue = action.controls[0].displayName;
PlayerPrefs.SetString("Interaction", keyValue);
break;
}
}
}
}
UI에 의도한 대로 텍스트를 출력하는 방안에 대해서 구현하는 데 꽤 고생했다. 하지만 UI를 출력함과 동시에 문제가 발생했다. UI가 갖다대는 오브젝트마다 정상 출력되는 것은 확인했지만, 이상하게 텍스트가 덜덜 떨리는 모습이 관찰된 것이다.
이 부분에 대해서는 내가 만든 기능이기도 하고 직접 해결해 보겠다고 하여 방법을 찾아보았다.
분명 이렇게 좌표가 튀는 것은 확실한데, 마우스 좌표는 문제가 없고, 캔버스 좌표도 문제가 없었다. 카메라 좌표가 미묘하게 튀는 것을 확인했으나, 다른 테스트 씬에서는 문제가 없고. 대체 이것의 원인이 뭔지 알 수 없어 고민을 해 보다가 차라리 사용하던 메소드를 변경해보기로 했다.
처음에 썼던 RectTransformUtility.ScreenPointToLocalPointInRectangle 대신 RectTransformUtility.ScreenPointToWorldPointInRectangle로 변경했더니 해당 문제가 해결되었다.
두 개의 차이가 뭔지는 모르겠는데... 그리고 애초에 왜 해결이 됐는지는 모르겠지만 아무튼 방법을 변경하고 반환을 Vector3로 받으니 떨림이 사라졌다.
추정되는 원인으로는 아무래도 처음 이 방법을 사용하면서 참고했던 글에서는, 2D 게임을 제작할 때 이 방법을 썼다는 것 정도. 3D게임이라서 메소드를 변경해야 하는 것으로 지금은 추정중이다.(다만 제대로 된 원인은 추후에 찾아볼 생각이다)
다만 또 문제가 발생했다. 이번에는 아이템의 경계선에서 UI가 깜빡거리는 현상이 발생한 것이다.
팀장님과 함께 머리를 싸매면서 계속 해결 방안을 찾아보았고 생각보다 이 작업이 오래 걸렸다.
문제를 해결하기 위해 코루틴을 이용한 지연시간을 발생시켜서 깜빡거리는 현상을 없앴다.
private void OnMouseOver()
{
m_canOn = true;
if (m_refreshCoroutine != null)
return;
if (Physics.OverlapSphere(transform.position, m_detectRadius, m_playerLayer).Length > 0)
{
m_panel.SetActive(true);
UpdateTextMessage();
Vector3 localPos = Vector3.zero;
RectTransform rectTransform = m_canvasRectTransfom.transform as RectTransform;
Vector3 mousePos = new Vector3(Input.mousePosition.x, Input.mousePosition.y, 0);
RectTransformUtility.ScreenPointToWorldPointInRectangle(rectTransform, mousePos, m_canvasRectTransfom.worldCamera, out localPos);
m_panelpos.anchoredPosition = localPos;
m_refreshCoroutine = StartCoroutine(RefreshTime());
}
else
{
m_panel.SetActive(false);
}
}
IEnumerator RefreshTime()
{
yield return new WaitForSeconds(0.5f);
m_refreshCoroutine = null;
m_panel.SetActive(m_canOn);
}
private void OnMouseExit()
{
// if (m_refreshCoroutine != null)
// return;
// m_panel.SetActive(false);
m_canOn = false;
}
private void OnDisable()
{
if (m_panel == null)
return;
m_panel.SetActive(false);
}