2022. 1. 22. 05:03ㆍProjects/Amnyang
캐릭터 애니메이션들도 고민을 많이 했지만, 사실 그만큼 또 걱정됐던 게 문 열리고 닫히는 애니메이션이었다.
페이지 애니메이션을 사용하기엔 디자이너님이 바빴고, 2D Bone Animation으로는 Z축 방향쪽(화면 안 ~ 화면 밖)으로의 회전을 구현할 수가 없었다.
그래서 공부를 하며 찾아보다가, 그냥 Y축으로 90도 회전하는 게 제일 그럴 듯하게 구현할 수 있겠다는 결론을 내렸다.
- 문이 연결된 경첩 쪽 위치를 기준으로 Y축 90도 회전 (문 열리고 닫히는 효과 구현)
- 레이어 렌더링 우선순위 설정
1. 문의 회전축 기준 위치 설정하기
유니티 오브젝트는 기본적으로 자신의 피벗(Pivot)을 기준으로 연산을 진행한다. 위치, 회전, 크기 등등 이 피벗 포인트 위치를 시작 기준으로 설정한다는 소리다.
하지만 옅은 나의 유니티 지식으로는 이 피벗 포인트 위치를 변경할 수가 없었다. (사실 이게 가능한건지도 모른다.)
오브젝트의 중앙으로 설정된 이 피벗 포인트를 경첩 쪽으로 옮기지 않으면, 올바른 회전 결과를 얻을 수 없다.
그래서 구글느님의 힘을 좀 빌리기로 하고, 찾아봤는데 기가 막힌 지식을 얻었다. 세상에는 똑똑한 사람이 많다...
내용은 다음과 같았다.
- 빈 게임 오브젝트를 생성한 후, 회전하길 원하는 축의 위치로 설정
- 회전시킬 오브젝트를 방금 생성한 빈 오브젝트의 자식 오브젝트로 설정
부모 오브젝트의 Transform에 변화가 생기면, 자식들 또한 같이 영향을 받는다는 걸 이용한 방법인 것 같다. 바로 적용해보자.
"RightDoor_Axis", "LeftDoor_Axis"라는 각각의 이름의 빈 게임 오브젝트를 생성한 후, 오른쪽 문과 왼쪽 문의 경첩 위치에 배치했다.
그리고 오른쪽 문(Right_Door), 왼쪽 문(Left_Door) 오브젝트를 각각 배치한 빈 오브젝트의 자식으로 설정해줬다.
이렇게 한 후, 빈 오브젝트를 회전시키면, 그에 따라 당연히 자식으로 설정된 문도 회전을 하게 된다.
이제 스크립트를 작성해주면 된다.
2. Y축 회전을 제어하여 문 열고 닫기 구현하기
"GateController"라는 이름으로 스크립트를 만들었고, Gate 오브젝트에 붙여줬다.
스크립트를 제어하기 전, 사전 셋팅을 좀 해줬는데 "GateGoalKeeper"라는 빈 오브젝트를 Gate의 자식으로 넣어줬다.
"GateWall"에 대한 부분은 밑에 가서 설명할 것이다.
이 "GateGoalKeeper"는 콜라이더를 달아놓은 빈 오브젝트다.
문이 닫혀있을 때는 활성화된 상태로 있어서, 캐릭터가 문을 통과하여 지나가지 못하도록 막는다.
문이 열렸을 때는 비활성화 상태로 만들어서, 캐릭터가 문을 통과할 수 있게 만든다.
이런 로직을 스크립트 상에서 제어해 줄 것이다.
다시 오브젝트 회전 얘기로 돌아오자면, Gate 오브젝트가 회전을 하는 것이 아닌 RightDoor_Axis, LeftDoor_Axis 오브젝트가 회전을 해야 한다. 따라서, 자식의 Transform 정보를 가져와야 한다.
저번 글에서 경험한 것이지만, GetComponentInChildren() 함수가 자식의 Transform 컴포넌트는 가져오질 못했다.
왜 그런건진 모르겠지만, 일단 안 되는건 알았으니 Inspector 창에서 해당 컴포넌트를 넣어주는 방식으로 했다.
현재 문을 열게 만들 기획적인 내용(ex. 스위치를 누른다던지, 열쇠가 필요하다던지)은 정해지지 않았으므로,
테스트용으로 boolean 변수를 하나 생성해서 이걸 스위치로 사용하기로 했다.
"controlSignal"이란 이름의 bool 변수로 Inspector 창에서 클릭하여 스위치마냥 제어해줄 계획이다.
public GameObject rightDoorAxis;
public GameObject leftDoorAxis;
public Collider2D gateGoalKeeper;
public float rotationSpeed;
public bool controlSignal;
private const float maxRotationValue = 90f;
private const float minRotationValue = 0f;
private const float FLOAT_COMPARISON_VALUE = 2f;
private bool isGateOpen = false;
private WaitForSeconds gatePassWaitTime = new WaitForSeconds(0.5f);
...
문의 회전은 Mathf.LerpAngle() 함수를 사용할 예정이다. 부끄럽지만 아직 게임 수학에 대해 공부를 많이 하지 못해서, 특히나 회전에 사용되는 Quaternion, EulerAngle에 대해 잘 알지 못한다.
해당 함수를 매 프레임마다 호출하여 보간 처리를 하여 자연스럽게 문이 열리도록 구현할 수 있다고 한다.
/* Lerp() 함수와 다르게, 이건 360도보다 큰 값을 줘도 되는 것 같다. */
본인은 코루틴(Coroutine)을 사용하여 매 프레임을 양도하는 방식을 사용해서 마치 Update() 함수에서 호출하는 듯한 효과를 줬다.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class GateController : MonoBehaviour
{
public GameObject rightDoorAxis;
public GameObject leftDoorAxis;
public GameObject amulet;
public Collider2D gateGoalKeeper;
public float rotationSpeed;
public bool controlSignal;
private bool isGateOpen = false;
private const float maxRotationValue = 90f;
private const float minRotationValue = 0f;
private const float FLOAT_COMPARISON_VALUE = 2f;
private WaitForSeconds gatePassWaitTime = new WaitForSeconds(0.5f);
void Update()
{
if (!isGateOpen && controlSignal)
{
StartCoroutine(GateOpen());
}
}
IEnumerator GatePassDelay(bool isOpening)
{
yield return gatePassWaitTime;
if (isOpening)
gateGoalKeeper.gameObject.SetActive(false);
else
gateGoalKeeper.gameObject.SetActive(true);
}
IEnumerator GateOpen()
{
controlSignal = false;
amulet.SetActive(false);
StartCoroutine(GatePassDelay(true));
while (true)
{
float rDoorCurEulerY = rightDoorAxis.transform.eulerAngles.y;
float lDoorCurEulerY = leftDoorAxis.transform.eulerAngles.y;
bool isCompleteOpen =
_Approximately(rDoorCurEulerY, maxRotationValue) &&
_Approximately(lDoorCurEulerY, maxRotationValue);
if (isCompleteOpen)
break;
rightDoorAxis.transform.eulerAngles =
new Vector3(0f, Mathf.LerpAngle(rDoorCurEulerY,
maxRotationValue,
Time.deltaTime * rotationSpeed), 0f);
leftDoorAxis.transform.eulerAngles =
new Vector3(0f, Mathf.LerpAngle(lDoorCurEulerY,
maxRotationValue,
Time.deltaTime * rotationSpeed), 0f);
yield return null;
}
isGateOpen = true;
}
private bool _Approximately(float x, float y)
{
float absCalcValue = (x - y) > 0f ? x - y : y - x;
if (absCalcValue <= FLOAT_COMPARISON_VALUE)
return true;
return false;
}
}
Inspector 창에서 "controlSignal"을 체크하면, 문이 열리도록 작성했다. 코루틴이 프레임 호출 주기가 FixedUpdate()처럼 고정적인지, Update()처럼 사용자 컴퓨터에 따라 달라지는지 몰라서 우선 안전빵으로 Time.deltaTime을 곱해줬다.
controlSignal을 체크해서 문을 여는데, 어느정도 열린 다음에 캐릭터가 지나가야 할 것이다. 그래서, "GatePassDelay()" 코루틴 함수를 만들어서 문을 열고 0.5초정도 뒤에 콜라이더를 비활성화 해줬다.
그리고 Mathf.Approximately()를 사용하지 않고, "_Approximately()"를 하나 만들어서 대신 사용해줬다. 왜 이렇게 했냐면 이유가 있었다. 현재 위 코드 로직으로 보면, 문이 90도까지 회전을 다 했을 경우 while문을 탈출할 수 있게 되어 있다. 처음에는 Mathf.Approximately()를 사용해서 로직을 짰었다.
허나, Mathf.LerpAngle() 함수가 목표치에 도달할수록 증감폭이 굉장히 작아지는 걸 디버깅을 통해 알아냈다.
그래서, threshold 값을 내가 임의로 만들었고 그게 FLOAT_COMPARISON_VALUE다. 이런 이유 때문에 새로 하나 만들어서 적용했던 것이다.
닫히는 부분도 역시 같은 원리를 통해 추가해주면 된다. 중복되는 코드가 있어, 처음에는 한 함수에서 다 처리할까 하다가 그냥 기능별로 나누는 게 더 좋을 것 같아서 나눴다.
아, 그리고 문 위에 붙어있는 부적도 일단 기획적으로 어떻게 처리할 지 아직 안 정해져서 일단 문을 열면 비활성화, 닫으면 활성화하는 방식으로 구현해놨다.
✏️GateController 최종 소스코드
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class GateController : MonoBehaviour
{
public GameObject rightDoorAxis;
public GameObject leftDoorAxis;
public GameObject amulet;
public Collider2D gateGoalKeeper;
public float rotationSpeed;
public bool controlSignal;
private bool isGateOpen = false;
private const float maxRotationValue = 90f;
private const float minRotationValue = 0f;
private const float FLOAT_COMPARISON_VALUE = 2f;
private WaitForSeconds gatePassWaitTime = new WaitForSeconds(0.5f);
void Update()
{
if (!isGateOpen && controlSignal)
{
StartCoroutine(GateOpen());
}
else if (isGateOpen && controlSignal)
{
StartCoroutine(GateClose());
}
}
IEnumerator GatePassDelay(bool isOpening)
{
yield return gatePassWaitTime;
if (isOpening)
gateGoalKeeper.gameObject.SetActive(false);
else
gateGoalKeeper.gameObject.SetActive(true);
}
IEnumerator GateClose()
{
controlSignal = false;
amulet.SetActive(true);
StartCoroutine(GatePassDelay(false));
while (true)
{
float rDoorCurEulerY = rightDoorAxis.transform.eulerAngles.y;
float lDoorCurEulerY = leftDoorAxis.transform.eulerAngles.y;
bool isCompleteClose =
_Approximately(rDoorCurEulerY, minRotationValue) &&
_Approximately(lDoorCurEulerY, minRotationValue);
if (isCompleteClose)
break;
rightDoorAxis.transform.eulerAngles =
new Vector3(0f, Mathf.LerpAngle(rDoorCurEulerY,
minRotationValue,
Time.deltaTime * rotationSpeed), 0f);
leftDoorAxis.transform.eulerAngles =
new Vector3(0f, Mathf.LerpAngle(lDoorCurEulerY,
minRotationValue,
Time.deltaTime * rotationSpeed), 0f);
yield return null;
}
isGateOpen = false;
}
IEnumerator GateOpen()
{
controlSignal = false;
amulet.SetActive(false);
StartCoroutine(GatePassDelay(true));
while (true)
{
float rDoorCurEulerY = rightDoorAxis.transform.eulerAngles.y;
float lDoorCurEulerY = leftDoorAxis.transform.eulerAngles.y;
bool isCompleteOpen = _Approximately(rDoorCurEulerY, maxRotationValue) &&
_Approximately(lDoorCurEulerY, maxRotationValue);
if (isCompleteOpen)
break;
rightDoorAxis.transform.eulerAngles =
new Vector3(0f, Mathf.LerpAngle(rDoorCurEulerY,
maxRotationValue,
Time.deltaTime * rotationSpeed), 0f);
leftDoorAxis.transform.eulerAngles =
new Vector3(0f, Mathf.LerpAngle(lDoorCurEulerY,
maxRotationValue,
Time.deltaTime * rotationSpeed), 0f);
yield return null;
}
isGateOpen = true;
}
private bool _Approximately(float x, float y)
{
float absCalcValue = (x - y) > 0f ? x - y : y - x;
if (absCalcValue <= FLOAT_COMPARISON_VALUE)
return true;
return false;
}
}
이로써, 문이 열고 닫히는 것과 통과 여부를 제어하는 부분이 구현되었다.
3. 레이어 렌더링(Layer Rendering) 우선순위 결정
이 부분은 2D 게임을 처음 개발하다보니, 놓칠 수 있는 부분이라 생각되었다. 원래라면, 대문 오른쪽 기둥은 포토샵 작업을 할 때, 레이어를 따로 구성하는 것이 프로그래머한테는 편하다.
캐릭터가 문을 열고 이제 지나가야 하는데, 대문 오른쪽 기둥이 캐릭터보다 나중에 렌더링 되어야 한다.
그래야 캐릭터 위에 문이 그려져서, 캐릭터가 문을 통과하는 것처럼 보인다.
그래서 나는 이런 현상을 콜라이더와 스크립트를 통해서 문을 통과하는 그 동안만 잠깐 캐릭터의 레이어를 변경해주는 식으로 해결했다.
"GateWall"이라는 빈 오브젝트를 Gate의 자식 오브젝트로 넣어주고, 콜라이더와 "GateWallLayer" 스크립트를 달아줬다. "GateWallLayer"는 플레이어가 해당 지역을 지나갈 때 잠깐 레이어를 변경해주는 기능을 담당할 것이다.
그리고 Sprite Renderer 컴포넌트에 속성 중 Sorting Layers라는 게 있다.
적절히 중간중간 여유 레이어를 남겨둔 채, 레이어 우선순위를 결정해줬다. 현재 오른쪽 문 기둥은 "Gate"에 속한 부분으로 Object 레이어로 설정해준 상태다.
콜라이더를 Is Trigger 모드로 바꿔주고, 오브젝트가 영역 안에 들어왔다면 HiddenArea Sorting Layer로 설정.
영역 밖으로 나간다면 다시 해당 오브젝트의 본래 Sorting Layer로 설정해줬다.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class GateWallLayerController : MonoBehaviour
{
private string switChingLayerName = "HiddenArea";
private Dictionary<GameObject, string> dic = new Dictionary<GameObject, string>();
private void OnTriggerEnter2D(Collider2D collision)
{
GameObject targetObject = collision.gameObject;
SpriteRenderer[] renderers =
collision.gameObject.GetComponentsInChildren<SpriteRenderer>();
if (renderers != null && collision.gameObject != null)
{
string originName = renderers.Length > 0 ? renderers[0].sortingLayerName : null;
// 좀 더 좋은 방법은 없을까? 복제 string 가비지 많이 나올 것 같은데.. 아닌가?
if (originName != null)
{
foreach (SpriteRenderer renderer in renderers)
{
renderer.sortingLayerName = switChingLayerName;
}
dic.Add(targetObject, originName);
}
}
}
private void OnTriggerExit2D(Collider2D collision)
{
GameObject targetObject = collision.gameObject;
SpriteRenderer[] renderers =
collision.gameObject.GetComponentsInChildren<SpriteRenderer>();
if (renderers != null && targetObject != null)
{
if (dic.ContainsKey(targetObject))
{
foreach (SpriteRenderer renderer in renderers)
{
renderer.sortingLayerName = dic[targetObject];
}
dic.Remove(targetObject);
}
}
}
}
현재 개발하는 게임에는 적이 플레이어를 쫓아오는 내용도 있어서, 기존에 짰던 코드를 다시 수정했다. 기존에 짰던대로 하면 플레이어만 기둥 뒤로 가고, 적은 기둥 위로 지나갈테니,,
오늘 일찍 자려고 했는데, GetComponentsInChildren() 이 녀석이 일으킨 버그 때문에 망했다. 물론 멍청했던 내 탓도 있다..ㅎㅎ
아무튼 이로써, 모든 구현이 완료되었다. 사각형 오브젝트를 하나 생성하고 이걸 적이라고 생각하고 테스트를 해봤다.
다른 분야도 그렇지만 게임은 끊임없이 버그가 나오는 이유를 알 것 같은 하루였다. 좀 더 큰 숲을 보며 전체적으로 설계할 수 있는 능력을 길러야 할 것 같다. 결론은 오늘도 늦게 자버렸다. 빨리 수면 패턴을 바꾸자.
'Projects > Amnyang' 카테고리의 다른 글
[압량(Amnyang)] #7. 2D 주인공 제자리 점프(Sargent Jump), 착지(Landing) 애니메이션 추가하기 (0) | 2022.02.06 |
---|---|
[압량(Amnyang)] #6. 2D 주인공 캐릭터 달리기(Run) 애니메이션 추가하기 (0) | 2022.02.05 |
[압량(Amnyang)] #4. 키 입력을 받아 2D 캐릭터 좌우 이동 구현하기 (0) | 2022.01.22 |
[압량(Amnyang)] #3. 2D 주인공 캐릭터 Idle, Walk, EyeBlink 애니메이션 클립 만들기 (2) | 2022.01.21 |
[압량(Amnyang)] #2. 2D Bone Animation으로 주인공 애니메이션 제작하기 (0) | 2022.01.21 |