Ace Combat Zero: 유니티로 구현하기 #7 : UI (2) - 1인칭 HUD

Lunetis·2021년 4월 10일
0

Ace Combat Zero

목록 보기
8/27
post-thumbnail




전 편에 만들지 못했던 UI 요소들을 만들 시간입니다.

UI를 만들면서 참고할 거리가 있는지 봤는데, 주목할 만한 글은 찾지 못했습니다.

그 대신,








다들 유료로 HUD 에셋을 팔고 있었는데요, (무료는 없었습니다.)







어차피 github에 공개된 프로젝트인데,

에셋 스토어에 무료로 풀어서 시장교란종이 되어본다면...?

https://github.com/lunetis/AceCombatZero


1인칭 HUD

UI를 배치하기 전에, 기본 틀부터 추가합니다.

지금 만드는 부분들은 1인칭에서만 보이는 UI이므로,
이렇게 1인칭 전용 UI Canvas와 카메라를 만들어주겠습니다.

그 아래에는 클리핑 마스크,
그 아래에 HUD 회전축,
그 아래에 HUD를 표시하도록 구성했습니다.

HUD는 위/아래로 움직이고,
HUD 회전축은 고정된 채로 360도로 회전하게 됩니다.
그리고 클리핑 마스크로 대충 정사각형 모양의 범위만 보여주게 할 예정입니다.

클리핑이 필요없는 부분은 클리핑 마스크와 동일한 Depth에서 구현합니다.



자세계 (기수 표시계)

이 부분입니다. 현재 비행기의 자세와 기수를 알려줍니다.

일단 저 선들을 그려줘야겠죠?

가지고 있는 그래픽 툴 아무거나 가져와서 가운데/위/아래에 표시될 선을 그려줍시다.

가운데를 잡아준 다음 위 아래에 하나씩 배치하고,
양쪽에 기수를 나타내는 텍스트를 추가합니다.

그리고...

위 아래로 하나씩 늘려나갑시다.

제가 게임을 통해 알아낸 바에 의하면, 95도 이상으로는 UI에 표시가 안 됩니다.


미리 결과물에 대한 스포일러를 해드리면,
기수 표시계는 90도 이상이 되면 180도 돌아가거든요.


그러니까 95도까지만 추가해줍시다.




그 다음으로는 클리핑 마스크 영역입니다.

UI Image 객체를 하나 추가합니다.

그냥 정사각형으로 하기에는 살짝 튀어나오는(?) 감이 있어서
모서리가 둥근 정사각형으로 이미지를 만들어 붙였습니다.

그리고 Add Component - Mask를 선택하고,

Show Mask Graphic을 체크해제하면 뒤에 깔려있는 흰색 사각형이 보이지 않습니다.



일단 고정된 UI가 보이는 상태입니다.
(3인칭에서는 나타나지 않는 UI지만 지금은 그냥 보죠.)

비행기가 움직일때마다 HUD의 위치와 회전값에 변화를 줘야 합니다.


자세계 코드를 작성해보죠.


public class HUDController : MonoBehaviour
{
    public Transform target;

    public RectTransform hudRotationTransform;
    public RectTransform hudPositionTransform;
    RectTransform rectTransform;
    public float HUDHeight;
    float HUDPositionFactor;

    void Start()
    {
        rectTransform = GetComponent<RectTransform>();
        HUDPositionFactor = HUDHeight / 90;
    }

    // Update is called once per frame
    void Update()
    {
        float convertedRotation = (target.eulerAngles.x > 180) ? (target.eulerAngles.x - 360) : target.eulerAngles.x;
        hudPositionTransform.localPosition = new Vector3(0, (convertedRotation * HUDPositionFactor), 0);
        hudRotationTransform.rotation = Quaternion.Euler(0, 0, -target.eulerAngles.z);
    }
}

target은 어느 대상의 움직임을 가지고 HUD를 보여줄 지 정해줍니다.
여기서의 target은 우리가 조종하는 비행기를 할당해야 합니다.

그 다음 hudRotationTransform, hudPositionTransform이 있는데,

각각 HUD Rotation, HUD 를 가리킵니다.

비행기의 Roll을 조정할 때는 HUD 가 항상 지형과 평행을 이루도록 회전시켜줘야 하고,
비행기의 Pitch를 조정할 때는 현재 기수를 가리키도록 위치를 이동시켜줘야 합니다.


HUDHeight는, 비행기의 기수가 90도일 때 HUD가 어느 위치까지 이동해야 하는지를 정해줍니다. 90도를 가리키는 위치의 좌표값을 넣어주면 됩니다.

(90도가 넘어가는 순간 HUD는 180도 회전하게 됩니다.)

제 경우는 2400이네요.

모두 지정해주고 실행시킵시다.

비행기가 회전해도 UI가 지형과 수평이 되도록 잘 회전하고 있습니다.

하지만 2% 부족한 부분이 있는데요,


HUD가 돌아갈 때 기수 표시계 양 옆에 있는 숫자가 같이 돌아가고 있는데,

이 숫자는 돌아가지 않고 항상 제대로 보여줬으면 합니다.


FixUIRotation.cs

public class FixUIRotation : MonoBehaviour
{
    RectTransform rectTransform;
    public RectTransform parentTransform;

    void Start()
    {
        rectTransform = GetComponent<RectTransform>();
    }

    // Update is called once per frame
    void Update()
    {
        rectTransform.rotation = Quaternion.Euler(0, 0, parentTransform.rotation.z);
    }
}

방법은 간단합니다.

돌려주는 부모 객체 parentTransform 의 반대 방향으로 똑같은 각도만큼 돌려주면 되죠.
그냥 parentTransform만 할당해주면 됩니다.


Hierarchy 뷰에서 HUD 기수 표시 텍스트를 몽땅 선택한 다음,

방금 만든 스크립트를 추가해주고, 실행해봅시다.



이제 숫자도 회전값이 고정된 것처럼 보입니다.


조금 더 비뚤어진(?) 기동을 해도 UI가 제대로 보입니다.

기수 표시계는 이 정도로 마무리지을 수 있겠네요.



속도계와 고도계

속도 표시 UI와 고도 표시 UI 옆에 있는 눈금들 부분입니다.

이 부분은 자세계와는 다르게 구현해야 할 것 같네요.

  • 자세계 : 위/아래 UI가 다름, 스크롤되는 높이의 제한이 있음
  • 속도계 : UI가 계속 반복됨, 스크롤되는 높이의 제한이 없음 (있어도 굉장히 긺)

자세계는 대략 -95도 ~ 95도 범위까지만 표시해주면 되었고,
0도보다 아래는 점선, 0도보다 위에는 실선으로 UI가 표시되어야 했습니다.

그래서 이렇게 끝까지 다 이어붙여도 됐었습니다. (사실 텍스트 표기 문제도 있긴 했습니다.)
이미지 객체가 39개나 되었긴 하지만요.


반면에 속도계와 고도계는 정말 끝없이 올라갈 수 있습니다.

그걸 다 이어붙인다면...



화장실 휴지만큼 만들어야 할 지도 모릅니다.



이미지를 계속 반복시키되, 실제 좌표의 이동은 없도록 만들어보려고 합니다.

여기서는 UV라는 걸 이용해보죠.





UV 좌표 (텍스쳐 좌표)는 텍스쳐가 어떤 좌표에 그려지게 되는가에 대한 정보를 담습니다.

이 값을 바꾸면 텍스쳐에 재밌는 일들을 할 수 있는데,
아래 사이트에서 움짤들과 함께 간략한 설명을 볼 수 있습니다.

https://woodorl.tistory.com/41


텍스쳐 Wrap Mode 속성 변경

일단 이미지들을 배치해줬는데요, 조금 있다가 구현할 스로틀 UI까지 일단 추가해뒀습니다.

참고로 양 옆에 있는 속도계와 고도계는 이렇게 마스크가 씌워져 있습니다.

원본 이미지는 마스크보다 사이즈가 조금 더 큽니다.

이제 이미지 속성을 조금 손볼텐데,

이미지를 선택하면 Wrap Mode라는 게 있습니다.
기본값은 Clamp로 되어있습니다.

이 값은 텍스쳐 UV값이 (0 ~ 1) 사이를 벗어나게 되었을 때 (텍스쳐보다 더 큰 크기의 객체에 텍스쳐를 씌울 때) 어떻게 처리할지 선택하는 속성입니다.

이 텍스쳐를 가로/세로 4배나 되는 객체에 씌울 때,
각 속성을 선택하면 아래와 같은 결과물이 나오게 됩니다.

  1. Clamp(기본값) : 텍스쳐의 끝부분을 강제로 늘립니다.
    (원본이 제대로 나오는 곳이 UV값이 (0 ~ 1)인 부분입니다.)
  2. Repeat : 텍스쳐를 반복합니다.
  3. Mirror : 텍스쳐를 반복하되 반전시킵니다.
  4. Mirror Once : 한 번만 반전시키고 그 이후로는 Clamp시킵니다.
    (UV값이 -1 ~ 1일 때만 반전시켜서 보여줍니다.)
  5. U 축은 Clamp, V 축은 Repeat로 적용한 예시입니다.

제가 구현할 속도계와 고도계는 이미지가 계속 반복되어야 하기 때문에,
Repeat를 선택하겠습니다.



정확히 말하면 V(세로) 축만요. U(가로) 축은 그냥 Clamp로 두겠습니다.

만약 전체 축을 Repeat로 설정하면, 반복되지 않아도 되는 축 (여기서는 U축)의 경계면 부분이 약간 이상하게 나올 수 있습니다. (왼쪽에 점같은 게 보이고 있죠?)




UV 조정

속도계/고도계는 UV값을 바로 조정하기 위해 Raw Image를 사용해야 합니다.

여기서 UV Rect의 Y값을 변경해보면 자연스럽게 스크롤되는 것을 확인할 수 있습니다.

이제 현재 속도와 고도에 따라 UV값을 조절해주는 스크립트를 만들면 됩니다.



UVController.cs

public class UVController : MonoBehaviour
{
    private RawImage image;
    public float unitValue;
    public float imageUnitCnt;

    float reciprocal;

    void Awake()
    {
        image = GetComponent<RawImage>();
        reciprocal = 1 / unitValue / imageUnitCnt;
    }

    public void SetUV(float value)
    {
        // range[0 - unitValue] -> [0 - 1 / imageUnitCnt]
        float remainder = value % unitValue;
        image.uvRect = new Rect(0, remainder * reciprocal, 1, 1);
    }
}

unitValue : 몇 단위마다 한 칸 (큰 눈금 기준)을 움직이게 할 지 설정합니다.
imageUnitCnt : 눈금 이미지에 있는 마디의 개수입니다.

한 칸짜리 이미지를 여러 개 사용해도 되지만, 그러면 UV 값을 이용한 스크롤링이 어려워집니다.

한 장의 이미지를 스크롤하기 위해 마스킹되는 영역보다 크게 이미지를 만들었고,
그 결과 한 장에 4칸의 눈금 이미지가 만들어졌습니다.

속도/고도 말고도 다른 곳에서도 비슷한 기능을 사용하기 위해, 움직이는 단위와 칸 수를 자유롭게 조정할 수 있도록 구현했습니다.

예를 들어 속도는 큰 눈금 기준으로 한 칸에 속도 30을 나타내기 위해,

unitValue = 30
imageUnitCnt = 4

라고 입력해주고, 고도계는 unitValue를 다르게 입력해주면 되죠.

그 다음 UI를 컨트롤하는 객체에서 이 눈금을 조정하게끔 구현합니다.



UIController.cs

public UVController speedUV;
public UVController altitudeUV;

public void SetSpeed(int speed)
{
    string text = string.Format("<mspace=18>{0}</mspace>", speed);
    speedText.text = text;

    speedUV.SetUV(speed);
}

public void SetAltitude(int altitude)
{
    string text = string.Format("<mspace=18>{0}</mspace>", altitude);
    altitudeText.text = text;

    altitudeUV.SetUV(altitude);
}

얻어온 속도와 고도를 가지고 두 RawImage의 UV를 조정시킵니다.

Inspector View에 두 UV 컨트롤러를 등록한 후 실행해봅니다.

속도는 30, 고도는 100 단위로 큰 눈금 하나씩 이동하도록 구현했고,
잘 움직이고 있습니다.

나쁘지 않은데, 어째 속도계가 너무 재깍재깍 변한다는 느낌을 줍니다.

속도계에 Lerp를 먹여서 천천히 변하게끔 할 수도 있지만,
근본적으로 비행기 속도가 입력에 따라서 너무 빠르게 변하긴 합니다.

조금 있다 스로틀도 구현해야 하니까, 속도 변화에 약간 가/감속을 주도록 하죠.

AircraftController.cs


float throttle;

public float Throttle
{
    get
    {
        return throttle;
    }
}

void MoveAircraft()
{
    // Rotation
    ...

    // Move
    throttle = Mathf.Lerp(throttle, accelValue - brakeValue, throttleAmount * Time.deltaTime);

    if(throttle > 0)
    {
        float accelEase = (maxSpeed + (transform.position.y * 0.01f) - speed) * speedReciprocal;
        speed += throttle * accelAmount * accelEase * Time.deltaTime;
    }
    else if(throttle < 0)
    {
        float brakeEase = (speed - minSpeed) * speedReciprocal;
        speed += throttle * brakeAmount * brakeEase * Time.deltaTime;
    }

    float release = 1 - Mathf.Abs(throttle);
    speed += release * (defaultSpeed - speed) * speedReciprocal * calibrateAmount * Time.deltaTime;
    
    transform.Translate(new Vector3(0, 0, speed * Time.deltaTime));
}

accelValue - brakeValue (총합 -1 ~ 1) 값을 나타내는 throttle 변수를 추가하고,
그 값에 따라 비행기의 속력을 조종하도록 스크립트를 수정했습니다.

Inspector View에서 값도 추가하고요.

(Before)

(After)

확실히 자연스러워졌네요.

이렇게 속도계와 고도계도 마무리되었습니다.



스로틀

이어서 스로틀도 만들어봅시다.

여기는 미사일 UI와 비슷하게 Fill 을 이용해서 변화량을 표시할 예정입니다.

Image Type, Fill Method, Fill Origin을 바꿔준 후,


UIController.cs

public Image throttleGauge;
public void SetThrottle(float throttle)
{
    throttleGauge.fillAmount = (1 + throttle) * 0.5f;
}

AircraftController.cs

void SetUI()
{
    ...
    uiController.SetThrottle(throttle);
}

이미지의 fillAmount를 조절하는 코드를 추가해줍니다.
비행기는 그 함수에 throttle 값을 넘겨주고요.

현재 비행기의 throttle 값은 -1 ~ 1이고, 이걸 0 ~ 1로 변환해줘야 합니다.

왼쪽에 스로틀 상태가 잘 나오고 있습니다.


방향계

(이야 이번 포스트 분량 터진다)

여기입니다. HUD 상단에 보이고 마스킹이 적용되어 있습니다.
따로 굵은 눈금은 없네요.

눈금 표시계랑 비슷하게 하면 될 것 같지만, 한 가지 신경써서 만들어야 할 부분이 있습니다.

이미지를 보면 SW라고 표시되는 부분이 있죠.
45도 위치마다 방향이 텍스트로 표시되어야 합니다.

그리고 단순히 클리핑이 되는 것이 아니라 아예 사라지고 있고,
숫자 텍스트와 겹쳐질 때도 사라지고 있습니다.

일단 한 단계씩 진행해보죠.


UVController.cs

public class UVController : MonoBehaviour
{
    public enum ChangeAxisType
    {
        U,
        V
    }

    public ChangeAxisType axisType = ChangeAxisType.U;
    private RawImage image;
    public float unitValue;
    public float imageUnitCnt;

    float reciprocal;

    void Awake()
    {
        image = GetComponent<RawImage>();
        reciprocal = 1 / unitValue / imageUnitCnt;
    }

    public void SetUV(float value)
    {
        // range[0 - unitValue] -> [0 - 1 / imageUnitCnt]
        float remainder = value % unitValue;

        if(axisType == ChangeAxisType.U)
        {
            image.uvRect = new Rect(remainder * reciprocal, 0, 1, 1);
        }
        else if(axisType == ChangeAxisType.V)
        {
            image.uvRect = new Rect(0, remainder * reciprocal, 1, 1);
        }
    }
}

먼저 아까 만들어뒀던 UV 컨트롤러에 스크롤 방향을 추가합니다.
속도계고도계상/하 (V축) 방향이었지만,
방향계좌/우 (U축) 방향으로 스크롤되어야 합니다.

위처럼 enum을 추가해주고, 그 타입을 가지는 public 변수를 추가하면,

Inspector View에서는 이렇게 드롭다운이 추가됩니다.
기존에 만들었던 속도계와 고도계는 V로 설정해줍시다.


HeadingUIController.cs

public class HeadingUIController : MonoBehaviour
{
    public TextMeshProUGUI headingText;
    public UVController headingUV;

    // Update is called once per frame
    public void SetHeading(float heading)
    {
        // Main Text
        headingText.text = string.Format("{0:0}", heading);
        headingUV.SetUV(heading);
    }
}

UIController.cs

public void SetHeading(float heading)
{
    headingUIController.SetHeading(heading);
}

AircraftController.cs

void SetUI()
{
    ...
    uiController.SetHeading(transform.eulerAngles.y);
}

방향계는 단순히 텍스트와 이미지 UV 만 바꾸는 것 말고도 많은 일을 수행해야 하니, 코드를 따로 분리하겠습니다.

우선 방향(숫자)을 표시할 텍스트와 방항계의 UV를 컨트롤할 UVController 변수를 추가하고,
SetHeading(float heading)에서 값을 수정하도록 코드를 작성합니다.

AircraftController에서는 transform.eulerAngles.y를 넘겨주고, UIController는 그걸 받아서 다시 HeadingUIController에 넘겨줍니다.


Q : 왜 쓸데없이 값을 그대로 넘겨주는 일을 하세요?
A : 이 프로젝트에서 저는 "AircraftControllerUIController에만 접근할 수 있다" 라는 규칙을 정했기 때문입니다.
UIController는 바로 텍스트에 접근해서 넣어주기도 하지만, CooldownImage (미사일 쿨타임) 처럼 하위 UI 오브젝트에 접근해서 함수를 호출하기도 합니다. 그 때는 일종의 중개인같은 역할을 하게 된 셈이고, 여기서도 그냥 중개를 해주는 것입니다.

UI를 붙이는 방법은 여러가지입니다.
좀 더 효율적이거나 직관적인 방법을 알고 계시다면, 그 방법을 사용해서 작성하시면 됩니다.
정해진 해답은 없으니까요. (그리고 저는 제 코드를 잘 짰다고 생각하지 않습니다.)

눈금 이미지에 아까 만들었던 UVController를 붙이고, 변수들을 설정합니다.

이제 전체적으로 컨트롤할 객체에 HeadingUIController를 붙이고, 눈금과 텍스트를 연결합니다.



일단 눈금과 현재 방향까지는 잘 작동하고 있습니다.
근데 360이 보이네요? 반올림을 해서 그런가봅니다.

Mathf.FloorToInt()로 내려버립시다.

이제 문제는 없네요.



그 다음에 방향을 표시하는 텍스트를 만들어줘야 하는데,

방향 텍스트는 최대 3개까지 보인단 말이죠.
그래서 극한의 최적화를 위해 문자 3개만 만들고 돌려쓸까 생각도 했는데,

그러면 현재 방향에 맞춰서 그 텍스트 내용도 바꿔줘야 하겠죠?


그 부분을 생각하는 것보다 그냥 8방향 텍스트를 다 만들고, 위치 조정을 하는 방식을 해보겠습니다.
이걸 끝내면 최적화도 가능하겠죠.

일단 다 만들고, 각 텍스트들의 간격을 계산합시다.

// N, NE, E, SE, S, SW, W, NW
public RectTransform[] texts;
public float padding;
public RectTransform maskingArea;

public TextMeshProUGUI headingText;
public UVController headingUV;

float reciprocal;

void Start()
{
    reciprocal = (1.0f / 360.0f) * padding * texts.Length;
}

public void SetHeading(float heading)
{
    // Main Text
    headingText.text = string.Format("{0:0}", Mathf.FloorToInt(heading));
    headingUV.SetUV(heading);

    // Texts
    int index = 0;
    float passBackThreshold = padding * (texts.Length * 0.5f);
    foreach(RectTransform text in texts)
    {
        float localPosX = index * padding - heading * reciprocal;

        // Adjust Text position
        if(localPosX > passBackThreshold)
        {
            localPosX -= padding * (texts.Length);
        }
        else if(localPosX < -passBackThreshold)
        {
            localPosX += padding * (texts.Length);
        }
        text.anchoredPosition = new Vector2(localPosX, 0);

        index++;
    }
}

메인 텍스트와 UV를 조정하는 코드 아래에 방향 텍스트의 위치 조정 코드를 추가합니다.

그냥 실행 결과를 보시면 이해하실 수 있을 거에요.

현재 방향대로 텍스트를 놓아주되,
일정 범위를 넘어가면 맨 뒤 또는 맨 앞에다가 놓아주는 코드입니다.



마지막으로 마스킹 작업인데,
글자의 일부를 보여주는 게 아니라 전체를 보여줬다 안 보여줬다 할 겁니다.

여기서 TriggerEnter() 쓰기에는 몇 줄짜리 코드를 만들려고 스크립트를 생성하고 붙여주는 게 쓸데없다고 판단해서,

그냥 이 HeadingUIController에서 다 실행하겠습니다.

foreach(RectTransform text in texts)
{
    // Adjust Text position
    ...

    // Set Visible
    bool visible = (maskingArea.rect.width * -0.5f < localPosX && 
                    localPosX < maskingArea.rect.width * 0.5f) &&
                    (localPosX < textRectTransform.rect.width * -0.5f || 
                     textRectTransform.rect.width * 0.5f < localPosX);
    text.gameObject.SetActive(visible);

    index++;
}

조정된 텍스트의 X좌표가 Masking Area 너비 내부 (검은색)에 있으면서,

방향(숫자)을 표시하는 텍스트와 겹치지 않는 범위 (초록색) 에 있으면 표시해주는 겁니다.

결과적으로 파란색 부분에 X좌표가 있으면 텍스트가 표시되고,
빨간색이나 파란색에 있으면 텍스트가 표시되지 않습니다.



이제 방향계까지 모두 끝났습니다.



모두 구현하는 데에 성공했으니, 기쁨의 기동을 해봅시다.




아니 아직도 할 게 산더미라고?

전체 화면:
미니맵
아군/적군 UI
게임 내 대사
조준점
기총 UI
경고 UI
색상 변경
화면에 안 보이는 목표물의 위치를 가리키는 화살표

1인칭 화면:
스로틀/브레이크
고도계/속도계
방향계
자세계

이전 포스트에서 작성했던 추가로 구현해야 할 것들 목록입니다.

근데 전체 화면에 해당하는 건 하나도 안 했죠?
1인칭 화면만 했을 뿐이고, 그마저도 지금 1인칭에서만 보이게 하는 작업도 안 했습니다.

콕핏 시점에서 고개 돌릴 때 위치 이동하는 것도 안 했고요.




이거 3편으로 끝낼 수 있으려나 모르겠네요.



이 프로젝트의 작업 결과물은 Github에 업로드되고 있습니다.
https://github.com/lunetis/OperationZERO

0개의 댓글