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

Lunetis·2021년 4월 3일
0

Ace Combat Zero

목록 보기
7/27
post-thumbnail




UI는 할 일이 참 많습니다.

리소스를 구해오는 것부터 시작해서,
좌표 잡고, 색깔 맞추고, 더미 데이터 붙여서 테스트하고, 코드 작성하고, 최종 테스트하고...

하지만 플레이어 눈에 직접 보이는 요소이기 때문에 절대 소홀히 할 수 없죠.

UI 작업은 할 일이 많은 관계로 여러 편을 편성해야 할 것 같습니다.


리소스 찾기

이 스크린샷은 제가 만들려고 하는 게임인 에이스 컴뱃 제로의 UI입니다.


이 게임은 480i/480p로 출력하는 플레이스테이션 2 게임이었습니다.

그 당시(2006년) 모니터 해상도는 지금보다 작았을 뿐만 아니라,
가로:세로 비율도 16:9가 보편적이지 않았었죠. 보통 4:3이었을 겁니다.
(음... 1024 x 768?)


따라서 UI도 그 해상도에 맞춰서 만들어졌을 가능성이 높죠.

그러면 13년 후에 나온 게임의 UI를 가져와볼까요?


2021년 기준 최신작인 에이스 컴뱃 7(2019)의 UI입니다.

1080p 혹은 그 이상의 고해상도 와이드 스크린에서 표현될 UI죠.

폰트도 조금 더 세련된 디자인이고 (제로는 모르지만 7은 이 게임을 위한 폰트를 따로 만들었습니다.)
제로와 비교해서 크기도 조금 작습니다.


현재의 게임 환경을 고려해서, 저는 에이스 컴뱃 7을 기준으로 UI를 구성하려고 합니다.

그러면 화면에 표현해야 될 것들을 생각해봅시다.

  • 좌측 상단: 시간, 점수, 목표물
  • 좌측 하단: 미니맵
  • 가운데: 속도, 고도
  • 우측 하단: 무기 및 비행기 상태
  • 전체: 아군/적군

몇 가지가 생략되었지만 그 부분들은 다음 편에서 다루도록 하죠.
이번 편에서는 굵은 글씨로 표시한 것만 만들려고 합니다.

미니맵과 아군/적군은 현재 표시할 타겟이 부족한 관계로 당장은 구현하지 않겠습니다.


이 부분들을 만들기 위해 어떤 리소스가 필요한지도 생각해보죠.

  • 폰트
  • 비행기, 미사일 이미지
  • 미니맵 이미지
  • 타겟 이미지 (육각형)
  • 기타 UI에 쓸 이미지

당장 필요한 부분은 굵은 글씨로 표시해뒀습니다.
적당히 정해졌으면 리소스를 구하러 다녀봅시다.


폰트 찾기

예전에 한 번 이 게임의 폰트를 찾으려 한 적이 있는데, 자체 개발 폰트라는 사실을 안 후에는 그냥 포기하고 있었습니다.

그런데 지금 다시 검색해보니까 누가 폰트를 따놨더라고요.

https://www.reddit.com/r/acecombat/comments/i8d74k/aces07_regular_font_is_now_avaliable_on_nexusmods/

역시 레딧에 있는 분들은 능력자들이 참 많아요.


...근데 따지고 보면 이 게임을 위해 만들어진 저작권 있는 폰트 아니던가요?

문제의 소지를 최대한 없애기 위해,
개인/비영리/교육 및 연구(?) 목적으로만 사용하도록 합시다.

이 폰트의 저작권은 (아마도) NIPPON DESIGN CENTER ON-SCREEN CREATE DIVISION, BANDAI NAMCO ENTERTAINMENT에 있습니다.


비행기, 미사일 이미지

우측 하단에 있는 비행기와 무장 상태를 표현하는 부분입니다.

비행기는 피해량이 많아지면 초록색 - 노란색 - 빨간색 순으로 색깔이 변하게 되고,
미사일은 쿨타임 동안 미사일 내부가 채워지는 효과를 만들어줘야 합니다.

먼저 F-15 이미지를 찾아다녔습니다.

http://getdrawings.com/get-drawing#f-15-drawing-29.jpg

괜찮은 그림이네요.
라이센스도 확인해봅시다.

대충 저작자를 표시하면 개인 목적으로 자유롭게 사용 가능하지만 상업적인 목적으로는 이용이 불가능하다는 뜻입니다.

그리고 이 이미지를 흰색의 반투명한 이미지로 만들어줘야 합니다.

가지고 있는 그래픽 프로그램 아무거나 가져와서 처리해줍시다.

그 다음에는 미사일 이미지인데...

저 정도의 그림은 찾아봤는데 없더라고요.

그래서 그냥 그려줬습니다. 마우스로요.

미사일은 이렇게 프레임과 내부를 채우는 이미지를 따로 분리해줘야 합니다.

비행기는 그냥 합쳐서 쓰면 됩니다.



UI 배치

텍스트

일단 폰트를 가져와서 프로젝트에 넣죠.

*Verdana 폰트는 나중에 대사 출력할 때 쓸 것 같아서 일단 추가했습니다.

그 다음에 UI를 배치해봅시다. 3D에서는 조정하기 어려우므로 Scene을 2D 뷰로 설정합니다.
일단 크게 좌측 상단 (시간/점수/타겟), 가운데(속도/고도), 우측 하단(상태)으로 나눕니다.

각 부분에 맞게 Anchor와 위치도 설정한 후,
텍스트들을 하나씩 추가해보겠습니다.

모든 텍스트는 TextMeshPro를 사용하겠습니다.

먼저 폰트를 TextMeshPro에 사용하기 위해서는 Font Asset을 생성해야 합니다.

폰트를 선택한 후, Create - TextMeshPro - FontAsset 을 선택합니다.

이렇게 Font Asset이 생성되었으면, 텍스트에 폰트를 등록해줍시다.

Inspector View에서 Font Asset을 변경해줍시다.

그리고 UI에 맞게 텍스트를 추가합시다.



단순히 텍스트를 추가하는 것으로 그치지 않고, Rich Text 기능을 활용해봅시다.
html처럼 태그를 이용해서 텍스트 전체, 또는 일부의 속성을 부여해줄 수 있습니다.

유니티의 UGUI 기본 텍스트에도 Rich Text가 있지만, TextMeshPro에서는 좀 더 많은 기능을 제공합니다.


대표적으로 monospace(고정폭) 기능을 활용하려고 합니다.

시간은 매 프레임마다 값이 변하는데, 고정폭이 아니라면 시간 부분의 너비가 계속 변하면서 거슬리게 보이게 될 것입니다.

점수, 속도, 고도, 미사일 탑재량 등 기타 부분들도 마찬가지입니다.

고정폭을 사용하지 않는 상황에서는 전체 너비가 조금씩 바뀝니다.
특히 시간에 1이 들어갈 때 너비가 바뀌고 있죠.

하지만 고정폭을 사용한다면 시간이 어떻게 되든 너비를 일정하게 만들 수 있습니다.

시간 부분의 텍스트는 이렇게 되어 있습니다.
분/초/밀리초 구간을 <mspace=18>{시간}</mspace>로 감싸놓아,
18 만큼의 고정폭을 가지도록 제한시켜놓았습니다.

void SetTime()
{
    if(remainTime <= 0)
    {
        remainTime = 0;
        return;
    }

    remainTime -= Time.deltaTime;
    int seconds = (int)remainTime;

    int min = seconds / 60;
    int sec = seconds % 60;
    int millisec = (int)((remainTime - seconds) * 100);
    string text = string.Format("TIME <mspace=18>{0:00}</mspace>:<mspace=18>{1:00}</mspace>:<mspace=18>{2:00}</mspace>", min, sec, millisec);
    timeText.text = text;
}

매 프레임마다 시간 부분 텍스트를 설정하는 함수에도 mspace 태그를 붙여놓도록 작성했습니다.

우측 하단은 각 글자마다 좌측 정렬과 우측 정렬이 동시에 적용되어야 하는데,
좌/우 글자에 각각 align과 line-height를 주는 꼼수(?)를 쓰면 된다고 하네요.

아무튼, 다양한 Rich Text 태그를 이용해서 UI에 맞게 텍스트를 추가합니다.


이미지

속도/고도 부분에는 숫자를 감싸는 화살표가 들어가는데,
두 화살표의 너비가 약간 다릅니다.

그렇다고 두 화살표를 각각 만들기는 낭비이므로,
9-Slicing Sprite로 만들어서 너비가 바뀌어도 기본 틀은 유지되도록 만들었습니다.

너비가 바뀌어도 되어도 좌/우 부분은 유지되죠.

Sprite Editor로 이렇게 경계를 설정해주면 됩니다.

그리고 미사일 부분은 이렇게 Fill, Frame 두 개의 오브젝트로 나뉘어 있습니다.
그 중 Fill에 해당하는 이미지는 Image Type, Fill Method, Fill Origin을 바꿔줍니다.

기본적인 배치는 어느정도 끝났습니다.

이제 코드로 UI에 접근해서 값을 수정할 시간입니다.



코드로 제어하기

다 설명하기에는 여백이 부족하므로...

시간은 초 단위로 초기값을 준 다음에 그냥 Update() 문에서 Time.deltaTime을 빼면서 출력하면 되고,
점수, 미사일이나 무기, 체력은 변동이 생길 때마다 보유량을 출력하면 되고,

속력이나 고도도 Update()에서 값을 가져와서 출력해주면 됩니다.

UI를 제어하는 스크립트에서는 이렇게 온갖 텍스트 UI들을 연결해주고,

UI 스크립트에서는 텍스트를 수정할 수 있는 함수를 만들어준 다음,

(AircraftController에서 UIController에 접근해서 값을 변경함)

그 코드를 호출해주면 적당히 처리가 된다는 이야기죠.


대부분은 별 문제가 없었습니다.
가장 걸림돌이 되었던 부분은, 미사일 쿨타임을 표현하는 부분이었습니다.


미사일/특수무기 구조 변경

일단 UI 관련 코드를 작성하기 전에, 이전에 만들었던 미사일 코드를 수정해야 했습니다.

당시 작성했던 코드는 "내 컨트롤러 입력 잘 받아오는 거 맞지?" 를 확인하기 위해 굉장히 대충 만들어놓았던 코드였거든요.

미사일 탑재량과 쿨타임도 WeaponController에 넣어놓았는데, 사실 바람직한 구조는 아닙니다.
(일반적인 상황에서는) 탑재량과 쿨타임은 미사일에 종속된 값이어야 합니다.

public class Missile : MonoBehaviour
{
    ...
    
    float speed;

    public float cooldown;
    public int payload;

    public Sprite missileFrameSprite;
    public Sprite missileFillSprite;
    
    ...

먼저 Missile 스크립트에서 미사일의 쿨타임 (Cooldown), 탑재량 (Payload) 데이터를 수정할 수 있도록 변수를 추가합니다.

그리고 UI에 표현해줄 미사일 스프라이트도 등록할 수 있도록 Sprite 변수를 추가합니다.

WeaponController 스크립트에서도 Missile 컴포넌트를 등록할 수 있도록 추가합니다.
여기에 미사일과 특수무기 프리팹을 추가하면 됩니다.

void Awake()
{
    ...
    
    missilePool = GameManager.Instance.missileObjectPool;
    specialWeaponPool = GameManager.Instance.specialWeaponObjectPool;
    bulletPool = GameManager.Instance.bulletObjectPool;

    missilePool.poolObject = missile.gameObject;
    specialWeaponPool.poolObject = specialWeapon.gameObject;
}

그리고 미사일과 특수무기를 관리하는 오브젝트 풀에도 WeaponController에 등록한 프리팹을 가져와서 풀을 생성하도록 만들어줍니다.

이전에는 오브젝트 풀에 미사일과 특수무기를 수동으로 등록해주는 방식이었는데,
WeaponController에서 사용하는 미사일과 특수무기가 바뀌면 오브젝트 풀도 같이 바꿔줘야 했습니다.

WeaponController에 사용할 미사일과 특수무기를 등록해주면,
오브젝트 풀이 그 오브젝트를 받아와서 등록하는 식으로 변경합시다.

WeaponController : 나 미사일이랑 QAAM 쓸 거임
ObjectPool : ㅇㅋ 그거 가져오면 미리 생성해줌



쿨타임 제어 스크립트 작성하기

이제 쿨타임 제어를 위한 스크립트를 만들어보겠습니다.

각각의 무기, 그리고 발사대는 쿨타임이 모두 독립적으로 작동합니다.
미사일을 모두 쐈다고 해서 특수무기를 못 쓰는 건 아니죠.
왼쪽 미사일을 쐈다고 해서 오른쪽 미사일을 못 쓰는것도 아닙니다.

그리고 지금은 미사일 슬롯 2개, 특수무기 슬롯 2개로 고정을 시키겠지만,
실제로는 슬롯이 한 개일 수도 있고, 여러개일 수도 있습니다.
그렇게 확장될 때를 대비해서 스크립트를 작성해야 합니다.

WeaponSlot.cs

public class WeaponSlot
{
    float cooldownTime;
    float currentCooldown;
    float cooldownReciprocal;

    float lastStartCooldownTime;

    public float LastStartCooldownTime
    {
        get
        {
            return lastStartCooldownTime;
        }
    }

    public WeaponSlot(float cooldownTime)
    {
        this.cooldownTime = cooldownTime;
        currentCooldown = cooldownTime;
        cooldownReciprocal = 1 / cooldownTime;

        lastStartCooldownTime = 0;
    }

    // UI purpose
    public float GetCurrentCooldownPercent()
    {
        return currentCooldown * cooldownReciprocal;
    }
    
    // Call when using weapon
    public void StartCooldown()
    {
        currentCooldown = 0;
        lastStartCooldownTime = Time.time;
    }

    // increase 0 to cooldownTime
    public void UpdateCooldown()
    {
        if(currentCooldown < cooldownTime)
        {
            currentCooldown += Time.deltaTime;
            if(currentCooldown > cooldownTime)
                currentCooldown = cooldownTime;
        }
    }

    public bool IsAvailable()
    {
        return currentCooldown >= cooldownTime;
    }
}

쿨타임만 표현하는 역할만 수행하는 스크립트니까 간단히 작성해놓았습니다.

생성할 때 쿨타임을 넘겨주고, 미사일을 발사할 때마다 StartCooldown()을 실행해서 쿨타임 값이 천천히 채워지도록 구현합니다.


이 코드는 Monobehaviour를 상속받지 않기 때문에 Update() 문을 실행할 수 없는 관계로,

WeaponController.cs

void Update()
{
    foreach(WeaponSlot slot in mslSlots)
    {
        slot.UpdateCooldown();
    }
    foreach(WeaponSlot slot in spwSlots)
    {
        slot.UpdateCooldown();
    }
}

WeaponController에서 에서 수동으로 업데이트를 돌게 시켜줍시다.

void SetArmament()
{
    // Guns
    fireInterval = 60.0f / gunRPM;

    // Missiles
    missileCnt = missile.payload;
    missileCooldownTime = missile.cooldown;
    for(int i = 0; i < 2; i++)
    {
        mslSlots[i] = new WeaponSlot(missileCooldownTime);
    }

    // Special Weapons
    specialWeaponCnt = specialWeapon.payload;
    spwCooldownTime = specialWeapon.cooldown;
    specialWeaponName = specialWeapon.missileName;

    for(int i = 0; i < 2; i++)
    {
        spwSlots[i] = new WeaponSlot(spwCooldownTime);
    }
}

게임 시작 시 미사일과 특수무장을 세팅할 때 WeaponSlot 배열을 초기화시켜주고,

public void Fire(InputAction.CallbackContext context)
{
    if(context.action.phase == InputActionPhase.Performed)
    {
        if(useSpecialWeapon == true)
        {
            LaunchMissile(ref specialWeaponCnt, ref specialWeaponPool, ref spwSlots);
        }
        else
        {
            LaunchMissile(ref missileCnt, ref missilePool, ref mslSlots);
        }
    }
}


WeaponSlot GetAvailableWeaponSlot(ref WeaponSlot[] weaponSlots)
{
    WeaponSlot oldestSlot = null;

    foreach(WeaponSlot slot in weaponSlots)
    {
        if(slot.IsAvailable() == true)
        {
            if(oldestSlot == null)
            {
                oldestSlot = slot;
            }
            else if(oldestSlot.LastStartCooldownTime > slot.LastStartCooldownTime)
            {
                oldestSlot = slot;
            }
        }
    }

    return oldestSlot;
}


void LaunchMissile(ref int weaponCnt, ref ObjectPool objectPool, ref WeaponSlot[] weaponSlots)
{
    WeaponSlot availableWeaponSlot = GetAvailableWeaponSlot(ref weaponSlots);
    
    ...
    
    // Start Cooldown
    availableWeaponSlot.StartCooldown();

    // Get from Object Pool and Launch
    GameObject missile = objectPool.GetPooledObject();
    
    ...

    uiController.SetMissileText(missileCnt);
    uiController.SetSpecialWeaponText(specialWeaponName, specialWeaponCnt);
}

발사할 때마다 WeaponSlot[] 을 넘겨줘서 쿨타임을 조절해줍니다.
그리고 발사에 사용되는 슬롯은, 슬롯마다의 WeaponSlot.lastStartCooldownTime(발사 시각)을 비교해서 가장 이른 시간에 발사되었던 슬롯을 가져옵니다.

가장 최근에 발사한 슬롯은 제일 후순위로 밀려나게 되며, Queue와 비슷한 역할을 하게 됩니다.



쿨타임 UI 스크립트 작성하기

쿨타임을 제어하는 슬롯은 만들었고, 이제 UI에 그 쿨타임을 표현할 시간입니다.

CooldownImage.cs

public class CooldownImage : MonoBehaviour
{
    public Image frameImage;
    public Image fillImage;

    float remainCooldown;
    float maxCooldown;
    float cooldownReciprocal;

    WeaponSlot weaponSlot;

    public void SetWeaponData(WeaponSlot weaponSlot, Sprite frameSprite, Sprite fillSprite)
    {
        this.weaponSlot = weaponSlot;
        frameImage.sprite = frameSprite;
        fillImage.sprite = fillSprite;
    }

    public void SetColor(Color color)
    {
        frameImage.color = color;
        fillImage.color = color;
    }

    public void StartCooldown(float cooldown)
    {
        maxCooldown = cooldown;
        remainCooldown = 0;
        cooldownReciprocal = 1 / cooldown;
    }

    // Start is called before the first frame update
    void Start()
    {
        remainCooldown = maxCooldown = 0;
    }

    // Update is called once per frame
    void Update()
    {
        fillImage.fillAmount = weaponSlot.GetCurrentCooldownPercent();
    }
}

쿨타임에 사용될 WeaponSlot과, 그 슬롯을 표현할 Image를 가지고 있는 컴포넌트입니다.

Update() 내에서 weaponSlot.GetCurrentCooldownPercent()를 호출하여 쿨타임 퍼센트를 얻어온 후,
Image.fillAmount에 대입해서 매 프레임마다 채워지는 효과가 나오도록 구현합니다.

UIController.cs

public void SwitchWeapon(WeaponSlot[] weaponSlots, bool useSpecialWeapon, Missile missile)
{
    mslIndicator.SetActive(!useSpecialWeapon);
    spwIndicator.SetActive(useSpecialWeapon);

    // Justify that weaponSlots contains 2 slots
    leftMslCooldownImage.SetWeaponData(weaponSlots[0], missile.missileFrameSprite, missile.missileFillSprite);
    rightMslCooldownImage.SetWeaponData(weaponSlots[1], missile.missileFrameSprite, missile.missileFillSprite);
}

마지막으로 UIController에서 무기를 교체할 때마다 슬롯의 데이터를 바꿔주도록 만듭니다.

미사일/특수무기를 교체할 때, 슬롯 이미지와 쿨타임 UI도 현재 활성화된 무기에 맞는 데이터를 보여줘야 하죠.


이제 실행해보면서 시간, 속도, 고도, 무기 상태가 제대로 표현되는지 확인해봅시다.

근데 화면 가장자리와의 여백이 너무 없죠?

카메라에서 여백을 따로 조정할 수 있습니다.
여기서 여백을 조정하기 위해 일부러 UI들을 가장자리 가까이에 만들어놓았습니다.

X, Y 값은 여백,
W, H는 표현되는 너비와 높이의 비율입니다.

가령 3%의 여백을 주고 싶다고 하면 X와 Y는 0.03,
W, H는 (1 - 2X), (1 - 2Y) 인 0.94를 줘야 합니다.

3%로 설정한 UI 모습입니다.
만약 게임 내 설정 기능을 구현해서 UI 여백을 설정하는 기능을 추가한다면,
이 부분을 제어하게끔 구현하면 됩니다.


이동하면서 무기를 발사해보는 모습입니다.


미사일 UI도 원활하게 작동하는 것을 볼 수 있습니다.


(1)편은 여기까지 다루죠.


앞으로 만들 것

이제 안 만든게 뭐냐면...

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

여기까지는 모든 상황에서 나와야 합니다.


그런데 1인칭에서만 나오는 UI가 몇 개 있습니다.
HUD (Head Up Display) 에서 보여지는 부분들인데요...

스로틀/브레이크
고도계/속도계
방향계
자세계

안 그래도 복잡한데 1인칭 화면에서 제일 신경써야 할 부분이 있습니다.

시선을 돌릴때 위치가 바뀌어야 한다는 것이죠.
정밀하게 위치를 조정할 필요는 없지만, 어느정도 스크린에 고정된 것처럼 보여야 합니다.

이번에 만든 고도와 속도 UI도, 3인칭에서는 고정이지만 1인칭에서는 같이 움직여야 합니다.


과연 UI를 만드는 데 몇 편이나 써먹을까요?



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

1개의 댓글

comment-user-thumbnail
2023년 10월 23일

내용 도움 많이 되었습니다. 감사합니다.

답글 달기