-
2.5D 좌표계, DNFTransform게임 클라이언트 개발/MakeDNF 2023. 12. 1. 23:07
이번 글에선 유니티의 Transform 컴포넌트를 활용하여 2.5D 좌표계를 직접 구현한 DNFTransform 컴포넌트와 이를 위한 툴을 개발한 내용에 대해 작성할 것이다.
2.5D란?
2.5D란 2D 이미지를 3D로 표현한 좌표계를 뜻하며, X 축, Y 축 방향만 이동가능한 2D에 가상의 Z 축을 추가하여 3D처럼 표현하는 기법이다. 던전 앤 파이터(이후 던파라고 지칭)는 이러한 기법이 적용된 횡스크롤 게임으로, 유니티로 이를 구현하기 위해선 2.5D 좌표계를 직접 구현할 필요가 있다.
오브젝트 Hierarchy를 활용해 구현한 2.5D
기존 프로젝트에선 오브젝트 Hierarchy를 활용하여 DNFTransform 클래스를 구현했었다. 오브젝트 Hierarchy는 다음과 같이 구성되어 있다.
- XZ 평면을 표현하기 위한 Root 오브젝트
- Y 축을 표현하기 위한 Y Pos 오브젝트
- Scale 값을 표현하기 위한 Scale 오브젝트
2.5D 좌표계의 Y 축과 Z 축은 모두 2D 플랫폼의 Y 축으로 표현되기 때문에 두 축을 별개의 값으로 컨트롤하기 위해서 Y Pos 오브젝트를 추가했으며, 오브젝트의 Scale 값을 설정하고 Scale 시 Pivot을 설정하기 위해 Scale 오브젝트를 추가했다. 이렇게 구현한 DNFTransform 클래스를 통해 화면상으론 2D처럼 보이지만 실제 좌표 계산을 3D로 할 수 있게 되었다.
// Transform component used to determine the position in the XZ planes. private Transform posTransform = null; // Transform component used to determine the position in the Y axis. private Transform yPosTransform = null; // Transform component used to determine the scale value of the object. private Transform scaleTransform = null; // X component of the DNFTransform. // It changes in proportion to the x-value of the position in the posTransform. public float X { set { Vector3 pos = posTransform.position; pos.x = value; posTransform.position = pos; } get => posTransform.position.x; } // Z component of the DNFTransform. // It changes in proportion to the screen ratio for the y-value of the position in the posTransform. public float Z { set { Vector3 pos = posTransform.position; pos.y = value * GlobalDefine.ConvRate; posTransform.position = pos; } get => posTransform.position.y * GlobalDefine.InvConvRate; } // Y component of the DNFTransform. // It changes in proportion to the y-value of the local position in the yPosTransform. public float Y { set { Vector3 pos = yPosTransform.localPosition; pos.y = value; yPosTransform.localPosition = pos; } get => yPosTransform.localPosition.y; } // Scale value of the object. // Modify the x and y values of the localScale in the scaleTransform. public float LocalScale { set { scaleTransform.localScale = new Vector3(value, value, 1f); } get => scaleTransform.localScale.x; }
복잡한 Hierarchy를 갖게 된 이유
이러한 Hierarchy 없이 2.5D 좌표계를 구현한다면 어떻게 될까? DNFTransform.position을 Unity의 Transform.position으로 치환하는 로직을 살펴보자. 2.5D 좌표계의 위치 계산은 3D 좌표계를 활용하지만 플레이어에게 보이는 모습은 2D이기 때문에 3D 좌표계의 값 $(x, \ y, \ z)$를 2D 좌표계의 값 $(x, \ y)$로 치환해야 한다. 이를 위해 DNFTransform.position의 x값은 그대로 Transform.position의 x값에 대입하고, DNFTransform.position의 z값에 일정 비율을 곱하여 공중을 표현하기 위한 DNFTransform.position의 y값을 더해 Transform.position의 y값에 대입한다.
public static Vector3 ConvertDNFPosToWorldPos(Vector3 dnfPosition) { return new Vector3(dnfPosition.x, dnfPosition.y + dnfPosition.z * GlobalDefine.CONV_RATE, 0f); }
하지만 반대로 Transform.position을 DNFTransform.position으로 치환할 수 있을까? 오브젝트의 DNFTransform.position의 y값이 0일 경우에는 역으로 치환할 수 있다. 하지만 0이 아니라면? Transform 컴포넌트만으론 DNFTransform.position의 값을 구할 수 없게 된다.
개발자가 씬에 오브젝트를 배치할 때, Inspector 창에 직접 값을 대입하는 경우도 있지만, 보통은 씬에서 GUI를 통해 오브젝트의 위치를 설정한다. 하지만 씬에서 오브젝트를 배치할 때 사용하는 GUI는 Transform 컴포넌트만을 컨트롤할 수 있으며, 위에서 설명한 것처럼 Transform 컴포넌트만으론 DNFTransform.position의 값을 알 수 없기 때문에, 에디터 상에서 오브젝트를 배치할 때 공중에 떠있는 오브젝트는 GUI로 배치할 수 없었다.
이를 해결하기 위해, Root 오브젝트의 자식 오브젝트로 Y Pos 오브젝트를 추가하여 Y 축의 값을 GUI로 설정할 수 있도록 하였고, 여러 오브젝트를 Inspector 창이 아닌 에디터의 씬 GUI로 손쉽게 배치할 수 있었다.
엮이고 엮여서 계속 복잡해지는 구조
하지만 이렇게 Hierarchy 구조가 복잡해짐에 따라 여러 Side Effect가 발생했다. 첫 번째로, 애니메이션 클립을 제작할 때마다 수많은 프로퍼티가 추가되었고, 클립 하나 만드는 데도 너무나도 복잡하여 실수하는 일이 잦았다. Sprite Renderer 컴포넌트가 있는 오브젝트까지의 Hierarchy 경로가 굉장히 길어 자세히 보지 않으면 프로퍼티를 잘못 할당하여 애니메이션이 고장 났었다.
두 번째로, Y Pos 오브젝트나 Scale 오브젝트가 없는 경우를 위한 예외처리가 필수적이었다. 예를 들어, 남귀검사 소울브링어의 '냉기의 사야' 스킬과 같은 장판류의 스킬의 경우, Y 축과 상관없이 해당 영역 안에 존재하는 몬스터에게 데미지를 입힌다. 또한 무조건 지상에 (Y 축의 값이 무조건 0) 설치되기 때문에 "Y Pos Object"가 필요가 없어 추가하지 않는다. 이렇게 자식 오브젝트가 없는 경우 DNFTransform 컴포넌트를 초기화할 때 Null 체크가 필수적으로 수행되어야 했다.
// Return whether yPosTransform exists or not. public bool HasYObj => yPosTransform != null; // The world position of DNFTransform. // If yPosTransform do not exists, the y-value of DNFTransform will be fixed at 0. public Vector3 Position { set { X = value.x; Z = value.z; Y = HasYObj ? value.y : 0f; } get => new Vector3(X, HasYObj ? Y : 0f, Z); }
세 번째로, DNFTransform 컴포넌트가 필요 없는 오브젝트라도, 호환성을 위해 복잡한 Hierarchy 구조를 유지해야 했다. 캐릭터나 스킬 투사체 오브젝트와 달리 이펙트 오브젝트의 경우, 단순히 이펙트가 생성될 DNFTransform.position을 Transform.position으로 치환한 위치에 이펙트를 생성하면 된다고 생각했기 때문에, 굳이 DNFTransform 컴포넌트가 필요 없다고 생각했다.
하지만 문제는 Sprite를 정렬하는 과정에서 발생했다. 스킬 이펙트 역시 Sprite로 위치에 따라 정렬하는 기능이 필요했다. 이를 위해 DNFTransform 컴포넌트가 필요했고, 이에 따라 스킬 이펙트마다 복잡한 Hierarhcy 구조를 유지해야 했기 때문에 씬에 존재하는 오브젝트의 수가 너무 많아졌다.
// Calculate the sorting order of a sprite using the DNFTransform component public int GetSortingOrder(DNFTransform obj) { float objDist = Mathf.Abs(maxZ - obj.Position.z); return (int)(Mathf.Lerp(System.Int16.MinValue, System.Int16.MaxValue, objDist / totalZ)); }
그냥 에디터를 만들어버리자!
모든 문제점의 발생 원인은 유니티의 씬 에디터에선 복잡한 Hierarchy 구조를 유지해야만 씬에서 GUI를 통해 오브젝트의 위치를 손쉽게 배치할 수 있다는 점이었다. 그렇다면 에디터를 구현해서 복잡한 Hierarchy 구조가 없더라도 씬에서 오브젝트의 위치를 손쉽게 배치할 수 있게 한다면, DNFTransform 컴포넌트의 구조는 간단해질 것이다. 이를 위해 UnityEditor.Editor 클래스를 활용하여 DNFTransform Editor를 구현했다.
우선, DNFTransform 클래스에 선언되어 있던 Hierarchy를 구성하는 오브젝트들의 Transform 변수들을 2.5D 좌표계의 위치를 저장하기 위한 Vector3 타입의 변수와 Scale을 위한 float 타입의 변수로 변경했다. 또한 DNFTransform 클래스 내에 여러 프로퍼티를 추가하여 변수를 수정하도록 구현했다.
// The cached Transform component of the target object. private Transform cachedTransform = null; [Header("Transform variables")] [SerializeField] private Vector3 position = Vector3.zero; [SerializeField] private float localScale = 1f; // X component of the position in the DNF coordinate. // It changes in proportion to the x-value of the position of the cachedTransform. public float X { set { position.x = value; cachedTransform.position = ConvertPosToWorldCoord(position); } get => position.x; } // .. Use the same logic for the Y and Z axes as for the X axis // Scale value of the object. // Modify the x and y values of the localScale of the cachedTransform. // The scale value is always greater than 0. public float LocalScale { set { if (value <= 0f) { throw new System.Exception($"Local scale value cannot be less than 0.\nInput scale value : {value}"); } localScale = value; cachedTransform.localScale = new Vector3((IsLeft ? -1f : 1f) * localScale, localScale, 1f); } get => localScale; }
다음으로 Inspector 창을 수정하여 각 변수들을 통해 개발자가 직접 값을 대입할 수 있는 프로퍼티를 추가하고, 씬에 여러 에디터가 활성화될 경우를 대비해서 씬 GUI를 켜고 끌 수 있는 버튼을 추가했다. 또한 2D에서 작업하다 보니, Y축과 Z축의 GUI가 겹쳐서 헨들러를 제대로 컨트롤할 수 없는 상황을 방지하기 위해, XY 평면을 수정할지, 혹은 XZ 평면을 수정할지 결정할 수 있는 버튼을 추가했다.
Scene GUI로는 각 축의 값을 컨트롤할 수 있는 Arrow Slider 헨들러와, 평면을 컨트롤할 수 있는 Slider2D 헨들러를 추가했다. 이를 활용하여 인스펙터 창에 직접 값을 대입하지 않아도 Scene GUI를 통해 오브젝트의 Position 값을 설정할 수 있었다.
이렇게 에디터를 추가함에 따라 DNFTransform 컴포넌트를 사용하기 위해 Root 오브젝트 하나로 충분했고, 씬에 존재하는 오브젝트의 수를 현저히 줄일 수 있었다.
'게임 클라이언트 개발 > MakeDNF' 카테고리의 다른 글
Skill 시스템 구현 및 개선 (0) 2024.01.10 DNF Input System (0) 2023.12.11 DNF 충돌 시스템 (0) 2023.12.03 Hitbox with DNFTransform (0) 2023.12.02 프로젝트를 시작하기 전에 (0) 2023.11.29