오늘 구현한 내용은 목표로 했던 PlayerCharacter의 움직임과 카메라를 구현했다.
처음에는 너무 적나? 내일 할 것도 미리 해야지~ 하는 생각을 하고 있었는데, 예상치 못한 문제들을 만나서 생각보다 시간이 굉장히 오래 소요되었다.
첫 번째 문제는 애니메이션과 Character Controller와 관련된 문제였다.
춤을 추는 애니메이션은 Mixamo에 있는 애니메이션을 가져왔는데 애니메이션의 종료 시점에서 Idle 모션으로 돌아올 때, Rotation이 변경되지 않기 때문에 애니메이션 종류에 따라 다르지만 약간씩 캐릭터가 회전하는 이슈가 있었다.
이를 맞춰주기 위해 해당 애니메이션을 시작할 때 캐릭터의 Rotation을 저장하고, 끝나면 원래대로 되돌리는 작업을 추가로 해주었다.
이외에도 비슷한 문제로 position 또한 약간씩 움직이는 이슈가 있었다. 이도 마찬가지로 시작할 때 position을 저장하고 해당 position으로 맞춰주는 작업을 했는데, 캐릭터의 position이 전혀 움직이지 않았다. 여러 방면으로 시도를 했으나 문제는 Character Controller가 직접적인 position 변경을 막은 것이 문제였다.
_controller.enabled = false;
transform.rotation = Quaternion.Slerp(transform.rotation, _idleStartRotation, Time.deltaTime * 5);
transform.position = Vector3.Lerp(transform.position, _idleStartPosition, 0.1f);
_controller.enabled = true;
때문에 이런식으로 Character Controller의 enabled를 false로 바꿔줬다가 다시 true로 변경해주는 번거로운 작업이 필요했다. LateUpdate에서 실행을 하면 정상적으로 적용되긴 했지만, Update와 LateUpdate 두 번을 호출하면 너무 효율이 떨어질 것 같아서 이 방법을 사용했다.
두 번째 문제는 휠로 줌 인/줌 아웃을 할 때 카메라의 위치와 관련된 문제였다.
줌을 할 때, 캐릭터에게 가까워질수록 각도가 더 많이 변하는 방식으로 구현을 하고 싶었는데, 이 과정에서 문제가 조금 있었다. 카메라의 높이인 height, 카메라와 캐릭터와의 거리인 distance, 카메라의 각도인 angle 세 가지를 사용했는데, 세 가지 값의 가중치가 다르다보니 값이 크게 변하는 특정 시점에서 카메라 줌 인/줌 아웃을 반복하면 카메라 포지션이 이상해지는 문제가 있었다. 현재는 Linear한 움직임을 적용해놓았는데, 원하는대로 구현하기 위해서는 세 값의 비율을 완전히 동일하게 맞추든가, 구간 별로 정해진 값을 정해서 비율이 일그러지지 않도록 조정이 필요할 것 같다.
이외에도 크고 작은 문제들을 마주했었던 것 같은데, 가장 곤란했던 문제는 이 두 가지였던 것 같다.
이후에는 오늘 구현했던 기능들에 대해 간단히 설명해보겠다.
먼저 플레이어를 따라다니면서 캐릭터를 비추는 카메라를 구현했다. height, distance, angle 값을 가지며 이 값에 따라 카메라의 높이, 플레이어와의 거리, 비추는 각도가 변경된다. 모바일에서는 두 손을 모으거나 펼치는 식으로 줌 인/줌 아웃을 하지만 컴퓨터에서 해당 동작을 수행하기는 어렵기 때문에 휠을 사용하여 줌 인/줌 아웃을 구현하였다.
최대로 확대하면 캐릭터를 정면에서 가까운 모습으로 볼 수 있다.
그리고 캐릭터의 움직임을 구현했다. Character Controller를 활용해서 캐릭터의 이동을 구현했고, 이동할 때 캐릭터가 움직이는 애니메이션을 재생하도록 하였다. 캐릭터가 가만히 있으면 대기 모션을 취하는데, 10초간 가만히 있으면 특수한 대기 모션인 춤을 춘다. 춤은 총 2가지로 랜덤하게 재생된다.
캐릭터의 이동은 키보드 WASD키, 혹은 화면을 터치한 지점에 생성되는 조이스틱으로 움직일 수 있다.
WASD의 입력을 처리한 이후에 조이스틱 입력이 있을 경우 조이스틱 입력을 기존 값에 덧씌우기 때문에 WASD키보다 조이스틱을 통한 조작이 우선시된다.
using UnityEngine;
using UnityEngine.EventSystems;
public class JoystickController : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler
{
[SerializeField] private RectTransform _joystick;
[SerializeField] private RectTransform _handle;
[SerializeField] private PlayerCharacterController _characterController;
private Vector2 _centerPosition = new();
private Vector2 _normalizedPosition = new();
public void OnBeginDrag(PointerEventData eventData)
{
_centerPosition = eventData.position;
_joystick.position = _centerPosition;
_handle.position = _centerPosition;
_joystick.gameObject.SetActive(true);
}
public void OnDrag(PointerEventData eventData)
{
_normalizedPosition = (eventData.position - _centerPosition).normalized;
_characterController.SetJoystickInput(_normalizedPosition.x, _normalizedPosition.y);
if (Vector2.Distance(eventData.position, _centerPosition) < 75)
{
_handle.position = eventData.position;
}
else
{
_handle.position = _centerPosition + (_normalizedPosition * 75);
}
}
public void OnEndDrag(PointerEventData eventData)
{
_characterController.SetJoystickInput(0, 0);
_joystick.gameObject.SetActive(false);
}
}
조이스틱을 통한 조작은 다음과 같이 구현하였다. 드래그를 시작할 때 해당 지점에 조이스틱을 생성하고, 중심으로부터 일정 거리 이내에서 핸들이 움직일 수 있도록 구현했다. 범위를 벗어나더라도 조이스틱이 올바른 위치로 이동하도록 Vector의 normalized를 사용해주었다. 또한 이동할 때도 일정한 이동 속도로 움직이기 위해서 normalized를 사용해주었다.
using UnityEngine;
public class PlayerCharacterController : MonoBehaviour
{
private Animator _animator;
private CharacterController _controller;
private readonly int _moveHash = Animator.StringToHash("Move");
private readonly int _specialIdleHash = Animator.StringToHash("SpecialIdle");
private readonly int _specialIdleNumberHash = Animator.StringToHash("SpecialIdleNumber");
private Vector3 _moveDirection = new();
private Vector3 _idleStartPosition = new();
private Quaternion _lookRotation = new();
private Quaternion _idleStartRotation = new();
private float _defaultMoveSpeed = 7f;
private float _idleElapsedTime = 0;
private float _inputX = 0;
private float _inputZ = 0;
private float _joystickInputX = 0;
private float _joystickInputY = 0;
private bool _isPlayedSpecialIdle = false;
private const float TwistDanceTime = 9.433f;
private const float SwingDanceTime = 4.4f;
// Start is called before the first frame update
private void Start()
{
_controller = GetComponent<CharacterController>();
_animator = GetComponent<Animator>();
}
// Update is called once per frame
private void Update()
{
_inputX = Input.GetAxis("Horizontal");
_inputZ = Input.GetAxis("Vertical");
_moveDirection = new Vector3(_inputX, 0, _inputZ).normalized;
if(_joystickInputX != 0 || _joystickInputY != 0)
{
_moveDirection = new Vector3(_joystickInputX, 0, _joystickInputY);
}
if (_moveDirection == Vector3.zero)
{
_animator.SetBool(_moveHash, false);
_idleElapsedTime += Time.deltaTime;
CheckIdleElapsedTime();
}
else
{
MoveCharacter(_moveDirection);
_isPlayedSpecialIdle = false;
_animator.SetBool(_moveHash, true);
_idleElapsedTime = 0;
}
// 특수 대기 모션의 종료 지점이 원점이 아니기 때문에 시작 지점의 rotation과 position을 맞춰줌
if (_isPlayedSpecialIdle && _idleElapsedTime > 0)
{
_controller.enabled = false;
transform.rotation = Quaternion.Slerp(transform.rotation, _idleStartRotation, Time.deltaTime * 5);
transform.position = Vector3.Lerp(transform.position, _idleStartPosition, 0.1f);
_controller.enabled = true;
}
}
private void MoveCharacter(Vector3 moveDirection)
{
_controller.Move(_defaultMoveSpeed * Time.deltaTime * moveDirection);
_lookRotation = Quaternion.LookRotation(moveDirection);
transform.rotation = Quaternion.Slerp(transform.rotation, _lookRotation, Time.deltaTime * 10);
}
public void SetJoystickInput(float joystickInputX, float joystickInputY)
{
_joystickInputX = joystickInputX;
_joystickInputY = joystickInputY;
}
private void CheckIdleElapsedTime()
{
if(_idleElapsedTime > 10)
{
_isPlayedSpecialIdle = true;
_idleStartRotation = transform.rotation;
_idleStartPosition = transform.position;
int randomNumber = Random.Range(0, 2);
_animator.SetTrigger(_specialIdleHash);
_animator.SetInteger(_specialIdleNumberHash, randomNumber);
if (randomNumber == 0) _idleElapsedTime = -SwingDanceTime;
else if (randomNumber == 1) _idleElapsedTime = -TwistDanceTime;
}
}
}
플레이어의 움직임은 위와 같이 구현하였다.
조이스틱 입력을 유지하고 있어도 계속 움직이고, WASD를 통한 움직임과 조이스틱을 통한 움직임을 동일하게 하기 위해 Update에서 _moveDirection이라는 하나의 변수를 사용하였다.
원래는 플레이어의 캐릭터에도 상태 머신을 적용할 생각이었는데, 찾아보니 상태 머신은 주로 몬스터나 아군 NPC 등 AI를 가진 객체에 사용을 하는 것 같았다. 때문에 PlayerCharacterController 안에서 각종 행동들을 따로 처리해주기로 하였다. 그리고 StateMachine과 State는 기본 틀을 이전에 배웠던 내용과 거의 비슷하게 사용할 것 같다. 다만 사용되지 않은 기능들은 빼고 구현할 것이다.
오늘 구현한 부분은 다음과 같다.
테스트를 하면서 버그를 발견했는데, 키보드로 입력을 할 때, W를 입력한 상태에서 S로 방향을 바꾸면 잠시동안 대기 모션이 재생되어 애니메이션이 부자연스럽게 끊기는 현상이 발견되었다. 이 부분도 내일 수정해야겠다.
오늘 구현에 대해서 상당히 쉽게 생각했었는데, 막상 직접 해보니 어려운 부분들이 꽤나 많았다. 아마 내일 이후로도 배웠던 내용을 적용하는 과정에서 많은 어려움이 있을 것 같다. 처음엔 '너무 쉽게 완성해버리면 어떡하지?' 하는 걱정이 있었는데, 기우였던 것 같다.
내일은 계획했던 대로 플레이어의 공격과 사망 상태를 구현할 것이다. 다만 원래는 StateMachine을 사용할 생각이었지만 사용하지 않게 되어서 구현에 시간이 조금 덜 걸릴 것 같으므로 카메라 줌 관련 기능을 개선하고, 적 캐릭터 구현도 어느정도 해 놓으면 좋을 것 같다. 조금 어렵긴했지만 재미있었고, 배운 것도 많이 있었던 것 같다. 앞으로도 부지런히 개발에 몰두해서 계획한 기능들을 순조롭게 구현할 수 있으면 좋겠다.
'개발 > 개발일지' 카테고리의 다른 글
3D RPG 개발 3 - 적 캐릭터 AI, 전투, 필드 구현 (8) | 2023.04.20 |
---|---|
3D RPG 개발 2 - 캐릭터 연속 공격 모션, 사망 상태 구현 등 (0) | 2023.04.19 |
3D RPG 개발 0 - 기능, 구조 계획 & 에셋 준비 (0) | 2023.04.17 |
3D 게임 개발 공부 8 - 중간 마무리 / 인게임 UI, 다이얼로그, 퀘스트, 문, 함정 (0) | 2023.04.13 |
3D 게임 개발 공부 7 - 인벤토리, 아이템 구현 (0) | 2023.04.12 |