Ace Combat Zero: 유니티로 구현하기 #9 : UI (4) - 타겟 + 기타

Lunetis·2021년 4월 25일
0

Ace Combat Zero

목록 보기
10/27
post-thumbnail

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

이제 미니맵처럼 시간을 잡아먹지는 않을 것 같은 자잘한 것들이 남았습니다.
현실은 타겟 시스템이 시간을 엄청 잡아먹었지만

하나씩 일감을 제거해나갑시다.


적군 UI

처음에 아군/적군 UI라고 적어놓긴 했는데,

생각해보니까 1:1 보스전이면 아군이 없잖아요?

(개꿀)

저기 멀리 8398m 거리에 목표물이 있다고 알려주는 UI를 구현하는 것이 목표입니다.

일단 UI를 만들어줍시다.

빨간색 TGT는 메인 타겟일 때만 켜지는 텍스트입니다.
보스는 무조건 켜져있겠지만, 그래도 껐다 킬 수 있는 수단 정도는 남겨놓읍시다.

그리고 유동적으로 생성/삭제되기 때문에, Prefab으로 만들어줍니다.


데이터 저장 클래스

ObjectInfo.cs

[CreateAssetMenu( fileName = "ObjectInfo", menuName = "Scriptable Object Asset/ObjectInfo" )]
public class ObjectInfo : ScriptableObject
{
    [SerializeField]
    string objectName;
    [SerializeField]
    string objectNickname;
    [SerializeField]
    int score;
    [SerializeField]
    int hp;
    [SerializeField]
    bool mainTarget;

    public string ObjectName
    {
        get { return objectName; }
    }
    public string ObjectNickname
    {
        get { return objectNickname; }
    }
    public int Score
    {
        get { return score; }
    }
    public int HP
    {
        get { return hp; }
    }
    public bool MainTarget
    {
        get { return mainTarget; }
    }
}

목표물의 이름, 닉네임, 점수 등을 저장하기 위한 수단으로 ScriptableObject라는 것을 활용해보려고 합니다.
Monobehaviour를 상속받지 않고, ScriptableObject를 상속받는 클래스를 만들면 됩니다.
데이터를 에셋 형태로 저장할 수 있기 때문에, 데이터를 수정한 다음 필요할 때마다 Inspector 창에 끌어다 놓는 식으로 데이터를 할당할 수 있죠.

[ CreateAssetMenu( fileName = "ObjectInfo", menuName = "Scriptable Object Asset/ObjectInfo" )]

코드 상단에 이렇게 작성된 부분이 있는데요,

이렇게 Project 창에서 우클릭을 해서 ScriptableObject 에셋을 생성하는 기능을 추가할 수 있습니다.

에셋은 이렇게 생성되고,

데이터는 Inspector 창에서 수정할 수 있습니다.

이 데이터를 이용해서 UI에 표시해줄 겁니다.




온갖 타겟 UI 클래스

TargetUI.cs


public class TargetUI : MonoBehaviour
{
    [SerializeField]
    TargetObject targetObject;

    [Header("Texts")]
    [SerializeField]
    TextMeshProUGUI distanceText;
    [SerializeField]
    TextMeshProUGUI nameText;
    [SerializeField]
    TextMeshProUGUI nicknameText;
    [SerializeField]
    TextMeshProUGUI targetText;

    [Header("Properties")]
    [SerializeField]
    bool isMainTarget;
    
    [SerializeField]
    float hideDistance;
    
    [SerializeField]
    GameObject uiObject;
    GameObject blinkUIObject;

    bool isTargetted;
    ObjectInfo objectInfo;
    RectTransform rectTransform;

    public TargetObject Target
    {
        get
        {
            return targetObject;
        }

        set
        {
            targetObject = value;
            objectInfo = targetObject.Info;

            nameText.text = objectInfo.ObjectName;
            nicknameText.text = objectInfo.ObjectNickname;
            targetText.gameObject.SetActive(objectInfo.MainTarget);
        }
    }

    RectTransform canvasRect;
    Camera activeCamera;
    
    // Recursive search
    Canvas GetCanvas(Transform parentTransform)
    {
        if(parentTransform.GetComponent<Canvas>() != null)
        {
            return parentTransform.GetComponent<Canvas>();
        }
        else
        {
            return GetCanvas(parentTransform.parent);
        }
    }

    public void SetTargetted(bool isTargetted)
    {
        this.isTargetted = isTargetted;

        if(isTargetted == true)
        {
            InvokeRepeating("Blink", 0, 0.5f);
        }
        else
        {
            CancelInvoke("Blink");
        }
    }

    void Blink()
    {
        blinkUIObject.SetActive(!blinkUIObject.activeInHierarchy);
    }

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

        Canvas canvas = GetCanvas(transform.parent);
        if(canvas != null)
        {
            canvasRect = canvas.GetComponent<RectTransform>();
        }

        Target = targetObject;  // execute Setter code
    }

    // Update is called once per frame
    void Update()
    {
        if(targetObject == null)
            return;

        activeCamera = GameManager.Instance.cameraController.GetActiveCamera();
        Vector3 screenPosition = activeCamera.WorldToScreenPoint(targetObject.transform.position);
        float distance = GameManager.Instance.GetDistanceFromPlayer(targetObject.transform);

        // if screenPosition.z < 0, the object is behind camera
        if(screenPosition.z > 0)
        {
            // Text
            distanceText.text = string.Format("{0:0}", distance);
            // UI Position
            Vector2 screenPoint = RectTransformUtility.WorldToScreenPoint(activeCamera, targetObject.transform.position);
            rectTransform.anchoredPosition = screenPoint - canvasRect.sizeDelta * 0.5f;
        }

        uiObject.SetActive(distance < hideDistance);
    }
}

TargetUI는 화면에 표시될 UI를 제어하는 코드입니다.

목표물 UI를 만들 때 생각해야 하는 부분이 몇 가지 있습니다.

  • 목표물 위치에 맞게 UI가 화면에 표시되어야 합니다. UI의 크기는 변하지 않습니다..
  • 아날로그 스틱으로 카메라를 돌릴 때에도 화면에 맞게 표시되어야 합니다.
  • 목표물이 파괴되거나, 너무 멀리 있으면 UI가 보이지 않아야 합니다.

먼저 화면에 UI를 띄우게 하기 위해서는, 목표물이 카메라 화면 좌표계에서 어느 위치에 있는지 알아내야 합니다.
그리고 이 게임에서는 카메라 3개 (1인칭 콕핏 X, 1인칭 콕핏 O, 3인칭) 를 쓰고 있기 때문에,
각 카메라에 대해서, 거기에 아날로그 스틱으로 화면을 돌리고 있는 상태에서 화면에 보이는 위치를 얻어내야 합니다.


Update() 문의 activeCamera = ...는 현재 보고 있는 카메라를 얻어내고,

Vector3 screenPosition = ...는 현재 보고 있는 카메라에 목표물이 어느 위치에 있는지를 얻어냅니다. (화면 좌표계에서의 위치)

그리고 (screenPosition.z > 0) 조건은 물체가 카메라 뒤에 있는지 체크하는 코드입니다. 목표물이 카메라 뒤에 있다면 UI를 그려줄 필요가 없죠.

조건식 내부에 있는 Vector2 screenPoint = RectTransformUtility.WorldToScreenPoint(...) 함수는 현재 보고 있는 카메라에 목표물이 어느 위치에 있는지를 얻어내고, 그 값을 RectTransform에 맞게 변환합니다.
그 값에 약간 조정을 해서 UI의 rectTransform.anchoredPosition에 좌표를 설정합니다.


그리고 일정 거리 미만일 때만 UI를 보여주는 코드를 마지막에 추가했습니다.




이거로 됐냐고요? 아뇨, 한참 남았어요.



TargetObject.cs

public class TargetObject : MonoBehaviour
{
    [SerializeField]
    ObjectInfo objectInfo;

    public ObjectInfo Info
    {
        get
        {
            return objectInfo;
        }
    }
    
    private void Start()
    {
        if(gameObject.layer != LayerMask.NameToLayer("Player"))
        {
            GameManager.TargetController.CreateTargetUI(this);
        }
    }

    void OnDestroy()
    {
        if(GameManager.TargetController != null)
        {
            GameManager.TargetController.RemoveTargetUI(this);
        }
    }
}

TargetObjectObjectInfo 데이터를 가지고 있는, 모든 타겟/파괴 가능한 오브젝트 (플레이어 포함)의 부모 클래스입니다.
이전에 만들었던 AircraftController도 일단은 이 클래스를 상속받았습니다.

화면에 TargetUI를 띄워주도록 생성하고, 오브젝트가 파괴될 때 UI를 삭제하는 역할을 합니다.



TargetController

public class TargetController : MonoBehaviour
{
    public GameObject targetUIObject;
    List<TargetUI> targetUIs;
    TargetUI currentTargettedUI;
    
    [SerializeField]
    TargetObject lockedTarget;

    void Start()
    {
        if(lockedTarget != null)
        {
            GameManager.UIController.SetTargetText(lockedTarget.Info);
        }
    }

    public void CreateTargetUI(TargetObject targetObject)
    {
        GameObject obj = Instantiate(targetUIObject);
        TargetUI targetUI = obj.GetComponent<TargetUI>();
        targetUI.Target = targetObject;

        obj.transform.SetParent(transform, false);
    }

    public void RemoveTargetUI(TargetObject targetObject)
    {
        TargetUI targetUI = FindTargetUI(targetObject);
        if(targetUI.Target != null)
        {
            targetUIs.Remove(targetUI);
            Destroy(targetUI.gameObject);
        }
    }

    public void ChangeTarget(TargetObject lockedTarget)
    {
        GameManager.UIController.SetTargetText(lockedTarget.Info);
        
        TargetUI targetUI = FindTargetUI(lockedTarget);
        if(targetUI.Target != null)
        {
            currentTargettedUI.SetTargetted(false);
            currentTargettedUI = targetUI;
            targetUI.SetTargetted(true);
        }
    }

    public TargetUI FindTargetUI(TargetObject targetObject)
    {
        foreach(TargetUI targetUI in targetUIs)
        {
            if(targetUI.Target == lockedTarget)
            {
                return targetUI;
            }
        }

        return null;
    }
}

TargetUI들을 관리하고, 좌측 상단 UI에 현재 선택한 목표물의 정보를 보여주는 컨트롤러입니다.
TargetUI 리스트를 가지고, TargetObject에서 UI를 생성/삭제를 요청할 때마다 리스트에 추가하거나 삭제합니다.

그리고 UIController에 접근해서 현재 선택된 타겟에 대한 정보도 알려줍니다.



타겟을 표시할 UI 캔버스와 카메라를 따로 만들어줍니다.
기존에 있는 카메라들을 쓰자니 조금씩 용도나 설정이 어긋나버릴 것 같아서 그냥 새로 만들었습니다.

타겟 UI 생성 및 관리는 Canvas - Targets 내부에서 하게 됩니다.

유동적으로 생성할 TargetUI Prefab을 등록하고,
UI 테스트용으로 시작부터 락온된 오브젝트를 등록합니다. (여기서는 최종보스를 등록했습니다.)

언젠가 쓰일 보스에게 타겟 정보 (ObjectInfo)를 등록하고,
여기저기 새로 만든 코드들을 추가한 다음에 실행해봅시다.


3인칭도 실행해보고,

1인칭도 실행해보고,


거리에 따른 UI의 표시도 확인하고,

밖으로 나가면 더 이상 안 그려지는지도 확인합니다.

이 부분을 강조한 이유는, TargetUI에서 카메라 뒤쪽을 체크하는 코드를 넣지 않으면 UI가 그려질 수 있기 때문입니다.

아까는 그냥 두루뭉술하게 "뒤에 있으면 그려줄 필요가 없다" 라고 작성했는데,
좌표를 이동하는 코드 실행을 막지 않으면 뒤에 있는데도 UI가 화면상에 나타날 수 있습니다.


타겟 위치 표시

타겟이 화면 밖에 있을 경우에는 이렇게 화살표로 위치를 표시해주는 기능이 있습니다.

이 화살표를 어떻게 구현할까 고민을 많이 했습니다.

3D 모델링을 만들고 Edge만 강조하는 쉐이더를 만들까?

아니면 Edge만 있는 모델링을 만들고 쉐이더를 만들까?

...Edge를 부풀릴까?


3D 모델링 + 쉐이더를 사용했을 때 만족스러운 결과는 얻지 못했습니다.

방법은 분명 있었겠지만, 모델링 및 쉐이더 작성 실력이 부족한 탓이 크겠죠.

그러다가 하나 생각난 게 있었는데,

이거 그냥 선으로 그려주는 것 같지 않아요?

그리고 유니티에서는 GL 이라고 하는, OpenGL 생각나게 하는 로우 레벨 그래픽 라이브러리를 제공합니다.

이걸 이용해서 그냥 화면에다 그려버리죠.

모든 객체를 그리고 제일 마지막에 그리기 때문에 다른 UI보다 위에 그려진다는 문제가 있지만,
일단 어떻게 되는지 봅시다.



TargetArrow.cs

public class TargetArrow : MonoBehaviour
{
    public TargetObject targetObject;

    [Header("Arrow Transforms")]
    [SerializeField]
    Transform cameraAttachedTransform;
    [SerializeField]
    Transform arrowTransform;

    [Header("Arrow Properties")]
    [SerializeField]
    int lineWidth = 3;
    [SerializeField]
    Material lineMaterial;
    [SerializeField]
    Transform[] vertexTransforms;    
    
    [Header("Text UI")]
    [SerializeField]
    Transform textTransform;
    [SerializeField]
    RectTransform textUITransform;

    [SerializeField]
    TextMeshProUGUI targetNameText;
    [SerializeField]
    TextMeshProUGUI targetNicknameText;
    [SerializeField]
    TextMeshProUGUI mainTargetText;

    bool drawLines = true;
    private Camera cam;
    RectTransform canvasRect;

	// Recursive search
    Canvas GetCanvas(Transform parentTransform)
    {
        if(parentTransform.GetComponent<Canvas>() != null)
        {
            return parentTransform.GetComponent<Canvas>();
        }
        else
        {
            return GetCanvas(parentTransform.parent);
        }
    }

    public void SetTarget(TargetObject target)
    {
        targetObject = target;
        targetNameText.text = targetObject.Info.ObjectName;
        targetNicknameText.text = targetObject.Info.ObjectNickname;
        mainTargetText.gameObject.SetActive(targetObject.Info.MainTarget);
    }

    public void SetArrowVisible(bool visible)
    {
        drawLines = visible;
        targetNameText.gameObject.SetActive(visible);
        targetNicknameText.gameObject.SetActive(visible);
        mainTargetText.gameObject.SetActive(visible && targetObject.Info.MainTarget);
    }


	// Draw Arrow
    void OnPostRender()
    {
        if (!drawLines || vertexTransforms == null || vertexTransforms.Length < 2)
            return;
 
        float nearClip = cam.nearClipPlane + 0.00001f;
        int end = vertexTransforms.Length - 1;
        float thisWidth = 1f/Screen.width * lineWidth * 0.5f;
 
        lineMaterial.SetPass(0);

        if (lineWidth == 1)
        {
            GL.Begin(GL.LINES);
            for (int i = 0; i < end; ++i)
            {
                Vector2 linePoint = cam.WorldToViewportPoint(vertexTransforms[i].position);
                Vector2 nextlinePoint = cam.WorldToViewportPoint(vertexTransforms[i + 1].position);
                
                GL.Vertex(cam.ViewportToWorldPoint(new Vector3(linePoint.x, linePoint.y, nearClip)));
                GL.Vertex(cam.ViewportToWorldPoint(new Vector3(nextlinePoint.x, nextlinePoint.y, nearClip)));
            }
    	}
    	else
        {
            GL.Begin(GL.QUADS);
            for (int i = 0; i < end; ++i)
            {
                Vector2 linePoint = cam.WorldToViewportPoint(vertexTransforms[i].position);
                Vector2 nextlinePoint = cam.WorldToViewportPoint(vertexTransforms[i + 1].position);

                Vector3 perpendicular = (new Vector3(nextlinePoint.y, linePoint.x, nearClip) -
                                     new Vector3(linePoint.y, nextlinePoint.x, nearClip)).normalized * thisWidth;
                Vector3 v1 = new Vector3(linePoint.x, linePoint.y, nearClip);
                Vector3 v2 = new Vector3(nextlinePoint.x, nextlinePoint.y, nearClip);
                GL.Vertex(cam.ViewportToWorldPoint(v1 - perpendicular));
                GL.Vertex(cam.ViewportToWorldPoint(v1 + perpendicular));
                GL.Vertex(cam.ViewportToWorldPoint(v2 + perpendicular));
                GL.Vertex(cam.ViewportToWorldPoint(v2 - perpendicular));
            }
    	}
    	GL.End();
    }

 
    void Awake()
    {
        cam = GetComponent<Camera>();
    }

    void Start()
    {
        Canvas canvas = GetCanvas(textUITransform.parent);
        if(canvas != null)
        {
            canvasRect = canvas.GetComponent<RectTransform>();
        }
    }
    
    // Update is called once per frame
    void Update()
    {
        if(targetObject == null)
            return;
			
        cameraAttachedTransform.LookAt(targetObject.transform);
        arrowTransform.eulerAngles = cameraAttachedTransform.localEulerAngles;

        Vector2 screenPoint = RectTransformUtility.WorldToScreenPoint(cam, textTransform.position);
        textUITransform.anchoredPosition = screenPoint - canvasRect.sizeDelta * 0.5f;
    }
}

OnPostRender() 로직은 어딘가에서 긁어왔습니다.

OnPostRender()에서 화살표의 꼭지점 좌표를 얻어와서 차례대로 화살표를 그려줍니다.
꼭지점 좌표는 Transform[] vertexTransforms에 담겨 있습니다.
(좌표는 GameObject 상태로 하드코딩되어 있습니다.)

그려야 하는 화살표의 두께가 1이면 GL.LINES, 2 이상이면 GL.QUADS를 이용합니다.

현재 타겟된 오브젝트에 따라 텍스트를 세팅하는 기능, 껐다 키기 등등의 기능도 추가했습니다.

여기서도 화살표 위치에 맞게 현재 타겟된 목표물의 정보를 보여줘야 합니다.
TargetUI에서 사용했던 오브젝트 위치에 UI 그리기 로직을 가져왔습니다. (Start, Update)


참고로 GL로 그려줄 화살표의 회전값은, 카메라에 달려있는 회전값 알려주기 전용 오브젝트에서 얻어옵니다.

Update()에 있는 cameraAttachedTransform..., arrowTransform...의 역할이 이 부분인데, 이유는 후술하도록 하죠.



여기서 중요한 점이 있습니다.

현재 활성화된 카메라와 그 카메라의 회전값에 따라서 화살표가 가리키는 방향도 바뀌어야 하기 때문에,
목표물의 방향을 알려주는 객체는 카메라에 달려 있어야 합니다.

CameraController.cs

void SetCamera()
{
    for(int i = 0; i < cameras.Length; i++)
    {
        if(i == cameraViewIndex)
        {
            currentCamera = cameras[i];
        }
        cameras[i].gameObject.SetActive(i == cameraViewIndex);
    }
    
    targetArrowTransform.SetParent(currentCamera.transform, false);
    uiController.SwitchUI((CameraIndex)cameraViewIndex);
}

CameraController.cs를 꺼내와서, 화살표의 부모를 바꿔주는 코드를 추가해야 합니다.



이번에 새로 추가된 친구들입니다.

Arrow Target Text는 메인 타겟 여부와 현재 타겟된 오브젝트 정보를 담는 UI입니다.

Arrow Transform은 꼭지점들을 그리는 데 사용할 비어있는 GameObject들과, 텍스트를 표시하는 데 쓸 비어있는 GameObject를 가지고 있습니다.

위 gif에서 선택되고 있는 오브젝트들 사이를 선으로 이으면 화살표가 나올 거에요.




TargetArrow.cs는 카메라 오브젝트에 붙여주도록 구현되어 있습니다.
타겟만 모아보는 카메라에다가 붙이고, 스크립트에 온갖 데이터와 오브젝트들을 세팅합니다.

여기에는 꼭지점들을 이어주는 순서를 넣어줍니다.

정말이지 가관이 따로없습니다

뭐 어떻습니까. 이런저런 방법도 한 번씩 찍먹하는거죠...




TargetController.cs

[SerializeField]
TargetObject lockedTarget;

[SerializeField]
TargetArrow targetArrow;

void Start()
{
    if(lockedTarget != null)
    {
        GameManager.UIController.SetTargetText(lockedTarget.Info);  // Set Upper Left UI
        targetArrow.SetTarget(lockedTarget);    // Set Arrow UI
    }
}

public void ChangeTarget(TargetObject lockedTarget)
{
    targetArrow.SetTarget(lockedTarget);
    GameManager.UIController.SetTargetText(lockedTarget.Info);
    
    TargetUI targetUI = FindTargetUI(lockedTarget);
    if(targetUI.Target != null)
    {
        currentTargettedUI.SetTargetted(false);
        currentTargettedUI = targetUI;
        targetUI.SetTargetted(true);
    }
}

public void ShowTargetArrow(bool show)
{
    targetArrow.SetArrowVisible(show);
}

TargetController에서도 화살표를 제어할 수 있도록 코드를 추가합니다.

지금은 보스전밖에 없지만, 일대다 상황에서 타겟을 바꿀 때마다 화살표가 가리켜야 하는 타겟도 달라지게끔 해야하기 때문입니다.



TargetUI.cs

void Update()
{
    ...
    uiObject.SetActive(distance < hideDistance);
    GameManager.TargetController.ShowTargetArrow(isOutsideOfCamera && distance < hideDistance);
}

참, 거리가 멀어지면 타겟 UI 뿐만 아니라 화살표도 꺼야죠.


모두 세팅했으면 실행해봅시다.

역시 다양한 시점에서 실행해서 화살표가 잘 나오는지 확인합니다.

거리가 멀어지면 화살표도 같이 사라지는 것도 확인하고요.



여담으로 구현하다가 특이한 현상이 있었는데요,

TargetArrow 스크립트는 카메라에 추가되어야 한다고 이야기했었죠.
지금은 UI 전용 카메라에 붙여놨지만, 처음에는 플레이어가 컨트롤하는 3개의 카메라에다 부착해봤습니다.

그 때도 잘 그려지긴 하는데, 이상하게 GL로 그려지는 화살표가 계속 깜빡이더라고요.

해결책을 찾지 못했지만, 카메라가 움직일 때 깜빡이는 현상이 일어난다는 것을 알고 난 후에는
고정되어있는 UI 카메라에다가 그려주는 방식으로 변경했습니다.


그래서 어디까지 했죠?

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

아, 한참 남았네요.

몇 가지는 그냥 위치만 잡아주고 넘기겠습니다.
당장 사용할 수 있는 스크립트가 없기 때문이에요.


게임 내 대사

자막

일단 에이스 컴뱃 제로의 대사 UI를 볼까요?

2006년 게임인데다가 480p 해상도에서 표현하느라 글씨가 큼지막합니다. 볼드체는 기본이고요.

에이스 컴뱃 시리즈 특유의 텍스트 UI가 있습니다. 대사를 "<< >>" 로 감싸는 거죠.
그리고 아군/적군에 따라 이름과 꺽쇠 부분의 색깔을 다르게 합니다.

2019년 게임인 에이스 컴뱃 7입니다.

특유의 텍스트 UI는 어디가지 않았고, 오른쪽 위에 대화하는 사람의 초상화가 있네요.
(초상화 시스템은 에이스 컴뱃 6부터 추가되었습니다.)


초상화가 항상 표시되는 건 아닙니다.

모르는 대상이거나 (무작위 적군 등), AWACS (조기경보통제기), 컨트롤 타워 처럼
시설이나 특정할 수 없는 대상 은 초상화를 표시하지 않습니다.

그리고 초상화 부분 테두리는 경고 상태에서 빨갛게 변합니다.

글씨 부분을 조금 더 확대해서 보면, 오른쪽 아래로 살짝 그림자가 져있는 것 같네요.

다행히 TextMeshPro에서는 그림자같은 효과도 손쉽게 만들 수 있습니다.

일단은 이렇게 배치해놨습니다.

자막에 쓰이는 폰트는 알아내고 있는 중입니다... (지금은 Verdana를 써봤습니다.)

<size=24><mspace=15><color=#ff4444><b><<</mspace=15></color=#ff4444></b><size=30>
Those who survive a long time on the battlefield start to think they're invincible.
<size=24><mspace=15><color=#ff4444><b>>>

대사를 표현하는 부분에서 저 꺽쇠가 지금 쓰는 폰트보다 조금 크길래,
Rich text의 온갖 옵션을 다 붙여봤습니다.



초상화

에이스 컴뱃 제로에서는 초상화 시스템이 없었기 때문에,

이번에 제가 특별히 하나 만들어보려고 합니다.

이거 말고요.

구글에서 찾아봤는데, 괜찮게 편집된 사진은 딱히 없고,
초상화로 쓸만해보이는 첫 번째 사진은 심각하게 저해상도 사진이었습니다.

저 사진을 게임 내에서 찾아봅시다.

https://youtu.be/IHUFIYpZhxQ?t=1029

480p 게임을 최대한 업스케일링해서 플레이한 영상을 찾아봤습니다. (이 영상은 5K나 되네요.)

에이스 컴뱃 제로는 게임 내 등장인물을 직접 인터뷰한 영상들이 게임 중간에 컷신으로 재생됩니다.
다른 시리즈와는 다르게 실제 배우를 섭외해서 영상을 만들었죠.

그 인터뷰 영상의 얼굴을 따옵시다.

얼굴을 캡처하고,

배경을 제거합시다.
(머리가 약간 잘려서 살짝 애매하지만...)

무료로 사진 배경을 제거해주는 사이트를 이용했습니다. 요즘 되게 많더라고요?

그리고 UI를 만들면서 온갖 색 보정을 해줍시다.

(이야 정말 별 짓을 다 하네)

이제 UI에 붙여주면?

슬슬 에이스 컴뱃 7 냄새가 나려고 합니다.

아직 대사를 출력하는 스크립트를 만들 차례는 아니기 때문에,
대사 출력 UI는 여기까지만 만들고 비활성화해둡시다.


조준점 및 기총 UI

화면 중앙쯤에 보이는, 위가 약간 짧은 십자선이 기본 조준점,
톱니바퀴처럼 생긴 부분이 기총 조준점입니다.

이미지들을 만들고,

UI에 추가해줍니다.


이 부분의 특징은 기체의 움직임에 따라서 두 조준점도 따라서 움직인다는 것입니다.
(하지만 두 조준점이 움직이는 정도는 다릅니다.)

두 조준점은 비행기에서 일정 거리만큼 떨어져있는 오브젝트의 위치에 맞춰서 표시하는 방식으로
구현해보겠습니다.


기본 조준점

사실 이 게임에서 기본 조준점은 별 의미가 없습니다.

어차피 미사일은 락온 후에 발사하고, 락온된 대상을 따라가니까요.
중요도가 현저히 낮기 때문에 대충 만들겠습니다.

public class Crosshair : MonoBehaviour
{
    [SerializeField]
    protected Vector2 offset;
    [SerializeField]
    protected float lerpAmount;
    protected float zDistance;

    protected virtual void Start()
    {
        zDistance = transform.localPosition.z;
    }
    
    // Update is called once per frame
    protected virtual void Update()
    {
        Vector2 aircraftRotation = GameManager.PlayerAircraft.RotateValue;
        Vector3 convertedPosition = new Vector3(-aircraftRotation.y * offset.x, aircraftRotation.x * offset.y, zDistance);
        transform.localPosition = Vector3.Lerp(transform.localPosition, convertedPosition, lerpAmount);
    }
}

현재 비행기의 회전값에 따라서 조준점의 X, Y 로컬 좌표를 이동시킵니다.
각 좌표에 Offset만큼 곱하기도 하고요. (어디까지나 대충 만드는 중입니다.)

그리고 Lerp를 이용해서 서서히 조준점이 이동하게끔 구현했습니다.

조준점 UI에는 FollowTransformUI라는 스크립트를 추가했습니다.
위에서 두 번 정도 썼던 "오브젝트를 따라가는 UI"를 따로 분리해서 사용하려고요.

값을 세팅하고 테스트하러 갑시다.

비행기가 움직일 때마다 조준점도 같이 이동하는 모습을 볼 수 있습니다.

1인칭 시점에서 미사일을 발사할 때 조준점을 향해서 잘 날라가는지도 확인합니다.


기총 조준점

기총은 몇 가지 기능이 더 있습니다.

다시 스크린샷을 가져와서 보면,

기총 조준점은 목표물이 없거나, 목표물과의 거리가 충분히 좁혀지지 않으면 표시되지 않습니다.

그리고 기총 조준점 (톱니바퀴) 내부에 차오르는 듯한 원이 있는데,
이 원은 타겟팅된 목표물과의 거리를 의미합니다.

GunCrosshair.cs

public class GunCrosshair : Crosshair
{
    [SerializeField]
    Transform target;
    [SerializeField]
    float visibleDistance;


    [SerializeField]
    GameObject crosshairUI;
    [SerializeField]
    Image fillImage;

    float reciprocal;

    protected override void Start()
    {
        base.Start();
        reciprocal = 1 / visibleDistance;
    }

    // Update is called once per frame
    protected override void Update()
    {
        float distance = Vector3.Distance(GameManager.PlayerAircraft.transform.position, target.position);
        Vector2 aircraftRotation = GameManager.PlayerAircraft.RotateValue;
        Vector3 convertedPosition = new Vector3(-aircraftRotation.y * offset.x, aircraftRotation.x * offset.y, zDistance * distance * reciprocal);
        transform.localPosition = Vector3.Lerp(transform.localPosition, convertedPosition, lerpAmount);

        if(distance < visibleDistance)
        {
            crosshairUI.SetActive(true);
            fillImage.fillAmount = distance * reciprocal;
        }
        else
        {
            crosshairUI.SetActive(false);
        }
    }
}

Crosshair를 상속받아서 GunCrosshair를 구현합니다.
목표물과의 거리에 따라서 UI를 활성화/비활성화하고, 내부의 원을 채우는 기능이 추가되었고,
비행기와의 UI 표시 오브젝트 거리 또한 그에 비례해서 조절하는 기능이 추가되었습니다.

역시 값을 설정하고 실행합시다.

목표물의 근처를 날면서 기총 UI가 제대로 나오는지 확인하고,

하늘에 대고 쏘거나,

땅에 대고 쏘면서 대충 기총 UI에 맞게 총알이 발사되는지 확인합니다.

지금 UI와 탄착점이 맞지 않는 이유는, 일정 거리만큼 떨어진 대상에 맞게 UI 위치가 설정되어 있지만 지금 땅에 대고 총알을 쏠 때 맞는 위치는 그 거리보다 가깝기 때문입니다.


경고 UI

이것도 배치만 하고 넘어가죠.

가지고 있는 그래픽 툴을 이용해서 UI들을 만들어준 다음,

좌표들만 잡아주고 비활성화해줍시다.

나중에 신경써야 할 부분이 있다면, 위치가 겹치는 경고 UI들은 하나만 표시해야 한다는 점입니다.

비활성화된 걸 다 켜봤는데, 꽤 그럴듯한 개발 과정처럼 보이지 않나요?


색상 변경

지금까지 개발하면서 숨겨뒀던 사실이 있습니다.

스프라이트용 머테리얼을 어느 시점에서부터 만들어놓았었고, (Tint부터 이미 초록색이죠.)

그동안 색깔이 바뀔 수 있는 모든 스프라이트들에 대해서 이 머테리얼을 적용시켜왔습니다.

지금까지 추가되었던 스프라이트들은 모두 흰색이었죠.

폰트도 마찬가지입니다.
TextMeshPro Font Asset을 두 개 만들고,
색깔이 바뀔 수 있는 부분과 바뀌지 않는 부분은 Font Asset을 따로 사용하고 있었습니다.

여기서는 Color를 초록색으로 해놓았습니다.

그래서 머테리얼과 폰트 에셋의 색깔만 바꾸면 되게 해놓았죠.

일괄적으로 스프라이트와 폰트 머테리얼들의 색깔을 조정해봅시다.

근데 RawImage는 색깔이 안 바뀐다고요?

한 번 게임을 실행해보세요. 그 이후로는 제대로 적용됩니다.



일괄적으로 적용된다는 것을 확인했으니,
경고 상황에서 머테리얼 색깔을 바꾸는 코드만 실행하면 되겠네요.

그리고 바뀌면 안 되는 이미지나 글씨 색깔이 바뀌고 있는지도 확인하고 수정합시다.


UIController.cs

[Header("Materials")]
[SerializeField]
Material spriteMaterial;
[SerializeField]
Material fontMaterial;

[SerializeField]
Color normalColor;
[SerializeField]
Color warningColor;

public void SetWarningUIColor(bool isWarning)
{
    Color color = (isWarning == true) ? warningColor : normalColor;
    spriteMaterial.color = color;
    fontMaterial.SetColor("_FaceColor", color);
}

// Start is called before the first frame update
void Start()
{
    ...

    SetWarningUIColor(true); // TEST
}

UIController에 머테리얼의 색상을 바꾸는 코드를 추가합니다.

UI 스프라이트에 사용되는 색상은 material.color에 접근해주면 되지만,

폰트에 사용되는 색상쉐이더에 접근해서 쉐이더 내 색상을 바꿔줘야 합니다.

폰트 에셋에서 색상을 설정하는 부분은 Face - Color에 있고,

이 값은 쉐이더에서 설정해줍니다.
폰트 색상을 바꿀 때는 fontMaterial.SetColor("_FaceColor", color); 처럼 쉐이더에 접근하는 코드를 사용해야 합니다.

경고를 울리게 하는 요소가 아직 없기 때문에,
Start() 에서 SetWarningUIColor(...)를 실행하게끔 코드를 작성했습니다.

추가된 값을 설정하고 실행하면,

SetWarningUIColor(false)

SetWarningUIColor(true)

색감을 살짝 조정해줘야 하지만, 어쨌든 원하는대로 전체 UI 색상이 바뀌고 있습니다.
(타겟 UI는 경고 상태에서도 색상이 바뀌지 않습니다.)




좌표만 잡아놓은 UI도 있긴 하지만, 이제 대부분의 UI는 만들어졌습니다.
이제부터 그냥 필요하면 그때그때 만들려고요 (...)

깃허브 README에 이렇게 앞으로 할 일들을 작성해놓았습니다.

이제 미사일 락온과 기총 등등 무기랑 관련된 걸 해야 한다고 써놓았네요.

그리고 누군가 스타를 하나 더 줬습니다.
이 프로젝트가 끝났을 때의 별 개수와 트래픽이 궁금해지네요.



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

0개의 댓글