Ace Combat Zero: 유니티로 구현하기 #19 : 일시정지/게임 오버 UI

Lunetis·2021년 7월 10일
0

Ace Combat Zero

목록 보기
20/27
post-thumbnail



지금까지 계속 게임 내부의 로직을 구현했었는데, 잠시 쉬어가는(?) 시간을 가져보겠습니다.


게임 도중 화장실을 가야 하거나, 손님이나 택배가 와서 문 앞으로 뛰어가야 하는 일이 가끔씩 발생하죠.

실시간으로 이뤄지는 멀티플레이 게임에서는 이런 일들은 용납할 수 없지만,
싱글플레이 게임이라면 게임 내 시간을 멈춰버리는 등 아주 관대하게 처리할 수 있습니다.
(이래서 제가 리그 오브 레전드를 끊었습니다.)

그런 때를 대비해서 잠시 게임을 일시정지할 수 있는 기능을 만들어봅시다.


그리고, 지금은 게임 오버가 되었을 때 게임을 다시 시작할 수 있는 방법이 없습니다.
게임 오버 시 재시작이나 게임 종료를 선택하는 기능도 만들어보죠.



UI 개요

게임 오버 화면은 블러 처리된 정지 화면에서 좌측 하단에 UI가 나타납니다.


일시정지 화면은 블러 처리된 정지 화면에서 중앙에 UI가 나타납니다.
그리고 우측에 미니맵도 같이 표기됩니다.

(에이스 컴뱃 제로에서도 미니맵이 표시됩니다.)



일시정지 화면의 기능이 게임 오버 화면의 기능을 모두 포함하기 때문에,
일시정지 화면부터 먼저 만든 다음 게임 오버 화면을 만들겠습니다.

그리고 FLIGHT OPTION (옵션 설정) 은 구현 계획이 아직은 없으며,
RESTART FROM CHECKPOINT (체크포인트부터 시작)은 UI는 만들어놓되 구현은 나중에 할 생각입니다.

아직 체크포인트 기능이 만들어지지 않았기 때문이죠.
(페이즈 시스템과는 별개입니다.)



들어가기 전에...

Player Input 속성은 Scene 내부에 단 하나만 있어야 합니다.

두 개 이상 존재할 경우 한 쪽은 게임패드 입력만 받고, 한 쪽은 키보드 입력만 받는 현상이 생길 수 있고, 설정을 잘못하면 양쪽 다 먹통이 되어버리는 현상이 발생할 수 있기 때문입니다.

PlayerInput만 가지고 있는 InputManager라는 객체를 생성한 다음,
모든 입력 및 이벤트 관련 설정은 이 객체의 컴포넌트에만 할당하는 방식으로 변경하겠습니다.



일시정지 화면

UI

가장 먼저 할 일은 UI대로 만드는 거겠죠?

UI 패널에 공통적으로 사용할 이미지를 만들고,

크기를 조절해도 테두리가 유지되도록 Sprite Editor에서 Border를 조정합니다.

일시정지 UI를 담을 캔버스를 만들어줍니다. 이왕 만든 김에 게임 오버 캔버스도 만들죠.


미니맵 UI에서 대형 미니맵을 가져와서 여기다 붙여주겠습니다.

패널을 Image로 만들어서 추가합니다. RawImage를 사용하면 크기 조절 시 Border가 유지되지 않습니다.

그리고 항목들을 추가합니다.
비슷하게만 만들면 되기 때문에 위치 비율은 크게 신경쓰지 않았습니다.



한글 폰트

보시다시피 문제가 발생했습니다. 한글 폰트가 추가가 안 되어있어 깨지고 있네요.

한글 폰트를 그냥 추가하면 될 문제같지만, 숫자나 영문자를 표현할 때는 그 한글 폰트의 영문 폰트가 아닌 위에서 사용하고 있는 영문 폰트를 써야 합니다.
숫자/영문자와 한글의 폰트가 각각 달라야 하죠.

일단 폰트부터 찾아봅시다.

이 폰트와 그나마 비슷한 *무료* 폰트를 찾아보려고 했는데,

네이버 나눔스퀘어 폰트가 가장 비슷해보여서 이 폰트를 사용하겠습니다.

ttf나 otf 압축파일로 받을 수 있게 되어 있는데, Bold만 사용해보죠.

폰트 파일을 프로젝트로 임포트한 후 Font Asset을 생성합니다.



폰트 문제 해결

기본값으로 사용할 폰트를 선택하면, Inspector 창에 폰트 설정이 표시됩니다.

여기서 계속 밑으로 내려가다 보면 "Fallback Font Assets"라는 설정이 있습니다.

현재 폰트로 나타낼 수 없는 글자가 있을 경우, Fallback List에 있는 폰트로 대체되어서 표시됩니다.

폰트를 등록하면 한글 폰트가 깨지는 현상이 바로 사라집니다.

글씨 크기를 조절했는데, 굵기가 이게 맞는 것 같고 아닌 것 같기도 하고요.

결정적으로 게임에서 사용한 폰트와 동일한 게 아니다보니 어떻게 되었든 간에 어색하게 보일 겁니다.



블러 처리

일시정지나 게임 오버 UI가 표시되는 동안 뒤에 있는 게임 화면에 블러 처리를 해주려고 합니다.

블러 쉐이더를 어딘가에서 가져와볼까요.


https://forum.unity.com/threads/solved-dynamic-blurred-background-on-ui.345083/#post-2853442

앞서 쉐이더 코드를 올린 사람이 있었는데, 그 코드보다 조금 더 가벼울 것이라고 믿는(?) 분의 코드를 사용하겠습니다.

코드를 긁어와서 쉐이더 파일을 만들고, 그 쉐이더 파일을 참조하는 머테리얼 파일을 만든 다음,

RawImage 하나를 만들고 블러 머테리얼을 추가했습니다.

구별을 쉽게 하기 위해 일부러 블러 강도를 높였는데, 아주 만족스럽네요.

화면을 꽉 채울 수 있게끔 크기를 조절합니다.



UI 띄우기

듀얼쇼크 4를 기준으로 Options 키를 누르면 일시정지 UI가 표시되어야 합니다.

게임은 일시정지되고, UI는 모두 숨겨지며, 조이스틱이나 방향키로 컨트롤할 수 있어야 합니다.


GameManager.cs

[Header("Pause Control")]
[SerializeField]
List<GameObject> hideOnPause;
[SerializeField]
GameObject pauseUICanvas;

bool isPaused;

public bool IsPaused
{
    get { return isPaused; }
}

// Show/Hide Pause UI Canvas, Hide/Show other UIs, Set TimeScale
public void Pause(InputAction.CallbackContext context)
{
    if(context.action.phase == InputActionPhase.Performed)
    {
        Time.timeScale = (isPaused == true) ? 1 : 0;
        isPaused = (Time.timeScale == 0);

        foreach(GameObject obj in hideOnPause)
        {
            obj.SetActive(!isPaused);
        }
        pauseUICanvas.SetActive(isPaused);
    }
}

일시정지 시 숨겨야 할 UI, 보여야 할 UI를 지정하고 Pause가 호출될 때마다 토글(Toggle)합니다.

if/else도 없는 극한에 가까운 코드 축약을 보여줍니다.

그래도 주석이 있으니 이해는 하겠...죠?


기존에 보여지는 UI 캔버스를 모조리 묶어놓은 부모 객체를 Hide On Pause에 놓고,
이번에 만든 일시정지 캔버스를 Pause UI Canvas에 놓아줬습니다.

Input Action Asset에서 Pause에 해당하는 키를 바인딩합니다.
Gamepad의 Start로 설정해주면 듀얼쇼크 4의 Options 키와 엑스박스 컨트롤러나 기타 다른 컨트롤러의 유사한 기능을 하는 키를 대응시킬 수 있습니다.

Input Event에 방금 만든 Pause 함수를 넣고 실행합니다.

게임은 제대로 일시정지됩니다. 근데 효과음이 일시정지되지는 않고 있죠.


// Show/Hide Pause UI Canvas, Hide/Show other UIs, Set TimeScale
public void Pause(InputAction.CallbackContext context)
{
    if(context.action.phase == InputActionPhase.Performed)
    {
        ...
        AudioListener.pause = isPaused;
    }
}

재생되는 모든 효과음들을 멈추기 위해서는 그냥 한 줄만 추가해주면 됩니다.


근데 이 상태에서 UI 조작 효과음을 재생해야 하는데, 이러면 그 효과음도 재생 안 되는 것 아니냐고요?

괜찮습니다. 다 방법이 있거든요.
이건 효과음 집어넣을 때 다시 설명하죠.



미니맵 문제 해결

일시정지 화면 우측에 뜨는 미니맵을 보면, 최대 크기 미니맵이 아닌 가장 작은 크기의 미니맵을 그대로 보여주고 있는 것처럼 보입니다.

미니맵을 띄울 때 항상 최대 크기의 미니맵을 보여주도록 수정해야 합니다.



MinimapController.cs

int savedMinimapIndex;  // saved on pause

public void SetPauseMinimapCamera(bool isPaused)
{
    if(isPaused)
    {
        savedMinimapIndex = minimapIndex;
        minimapIndex = (int)MinimapIndex.Entire;
    }
    else
    {
        minimapIndex = savedMinimapIndex;
    }
    SetCamera();
}

미니맵과 관련된 모든 설정들은 MinimapController에서 관리하고 있습니다.

일시정지할 때마다 현재 설정된 미니맵 정보를 저장한 다음 최대 크기로 강제로 세팅한 다음,
일시정지가 풀리면 저장했던 미니맵 정보로 되돌리도록 합니다.


GameManager.cs

public void Pause(InputAction.CallbackContext context)
{
    if(context.action.phase == InputActionPhase.Performed)
    {
        ...
        uiController.MinimapController.SetPauseMinimapCamera(isPaused);
    }
}

그리고 GameManager에서는 아까 만든 함수를 호출합니다.


이제 미니맵 문제는 해결됐네요.



UI 컨트롤

이제 게임패드로 직접 UI를 컨트롤해봅시다.

Input Action 설정

먼저 컨트롤에 필요한 Input Action을 설정해봅시다.

위에서 보여드렸듯이, Player에서는 일시정지 화면을 호출하기 위해 Pause가 있었죠.

Input Action Asset을 생성할 때 기본적으로 Action Maps에 "Player"와 "UI" 2개가 만들어집니다.

좌측의 Action Map에서 UI를 클릭하면 기본적인 UI 컨트롤에 사용할 수 있는 액션들이 있는데,
저는 파란색으로 선택된 3개를 빼고 모두 삭제했고, Pause를 추가했습니다.

([DEBUG]는 신경쓰지 마세요.)


Navigate 항목은 UI를 방향키나 조이스틱 등으로 조절할 때 사용할 수 있는 속성입니다.
게임패드, 조이스틱, 키보드 입력이 이미 할당되었기 때문에 따로 건드릴 필요는 없습니다.

마찬가지로 SubmitCancel도 Submit [Any]와 Cancel [Any]로 써있지만,
각각의 컨트롤러와 키보드에 해당하는 키로 자동으로 매핑됩니다.

(예: 키보드의 Submit은 Enter 키입니다.)

플레이스테이션 컨트롤러는 Submit은 X, Cancel은 O버튼이 자동으로 매핑됩니다.
동양권에서는 O가 확인, X가 취소를 뜻한다고 생각하는 경향이 있지만, 서양권에서는 그 반대입니다. 실제로 플레이스테이션으로 동양권에서 개발된 게임과 서양권에서 개발된 게임을 할 때 O/X 버튼의 용도가 바뀌어서 헷갈리는 상황이 자주 발생하죠.
플레이스테이션 5부터는 X를 확인, O를 취소로 할당하도록 통일되었습니다.



PauseController.cs

public class PauseController : MonoBehaviour
{
    [SerializeField]
    List<RectTransform> selectableOptions;
    [SerializeField]
    RectTransform selectIndicator;

    [SerializeField]
    AudioSource audioSource;
    
    [SerializeField]
    AudioClip pauseAudioClip;
    [SerializeField]
    AudioClip scrollAudioClip;
    [SerializeField]
    AudioClip confirmAudioClip;

    int currentIndex;

    public void Navigate(InputAction.CallbackContext context)
    {
        if(context.action.phase == InputActionPhase.Started)
        {
            float y = context.ReadValue<Vector2>().y;

            if(y == -1 && currentIndex < selectableOptions.Count - 1)
            {
                ++currentIndex;
            }
            else if(y == 1 && currentIndex > 0)
            {
                --currentIndex;
            }
            else return;

            selectIndicator.anchoredPosition = new Vector2(selectIndicator.anchoredPosition.x,
                                                        selectableOptions[currentIndex].anchoredPosition.y);
                                                        
            audioSource.PlayOneShot(scrollAudioClip);
        }
    }

    public void Confirm(InputAction.CallbackContext context)
    {
        if(context.action.phase == InputActionPhase.Performed)
        {
            audioSource.PlayOneShot(confirmAudioClip);
        }
    }

    void Awake()
    {
        audioSource.ignoreListenerPause = true;
    }

    void OnEnable()
    {
        currentIndex = 0;
        audioSource.PlayOneShot(pauseAudioClip);
    }

    void OnDisable()
    {
        audioSource.PlayOneShot(pauseAudioClip);
    }
}

먼저 커서를 옮기는 Navigate()를 살펴보면,
지금까지 InputAction을 처리할 때 대부분 phase"Performed"일 때만 실행하도록 했었습니다.

하지만 이번에는 phase"Started"일 때 실행하도록 코드를 작성했는데요,

그 이유는 Performed일 때 실행하게 하면, 아날로그 스틱으로 컨트롤할 때 스틱을 위/아래로 두면 커서가 한 번만 이동하지 않고 계속 이동하기 때문입니다.

(좌측의 방향키로 컨트롤할 때는 한 칸씩만 이동합니다.)

유니티 엔진의 레거시 Input 시스템으로 치자면 GetKeyGetKeyDown의 차이라고 할까요.

아날로그 스틱으로 UI를 컨트롤할 때도 커서를 한 칸씩만 움직이게 하기 위해 Performed가 아닌 Started일 때 실행하도록 코드를 작성했습니다.


Navigate()는 아날로그 스틱, 패드의 방향키, 키보드의 방향키 등을 조작할 때마다 호출됩니다.
그 때의 입력값을 Vector2 형태로 받아오고, 위/아래 조작에 해당되는 Vector2.y 값만을 이용하여 커서를 위/아래로 움직이도록 만듭니다.


키보드의 Enter키, 듀얼쇼크의 X버튼을 누를 때 호출되는 Submit() 함수는 소리를 재생하는 기능만 추가해서 입력을 잘 받아오는지만 확인해보죠.


Input Action의 Navigate 액션을 따로 수정하지 않았다면,
게임패드의 방향키, 양쪽 아날로그 스틱, 키보드의 방향키를 이용해서 커서를 조종할 수 있습니다.



선택지에 따른 설명 변경하기

커서를 옮길 때마다 현재 선택지에 맞게 하단의 설명을 바꿔줘야 합니다.
지금은 "미션을 재개한다" 라는 설명으로 고정되어 있죠.


UISelect.cs

public class UISelect : MonoBehaviour
{
    [SerializeField]
    string description;
    [SerializeField]
    UnityEvent onSelectEvent;
    
    public string Description
    {
        get { return description; }
    }

    public UnityEvent OnSelectEvent
    {
        get { return onSelectEvent; }
    }
}

변수는 2가지입니다.

  1. description : 선택지에 대한 설명입니다.
  2. onSelectEvent : 선택했을 때 호출할 함수입니다.

이 스크립트가 붙여질 선택지가 "RETURN TO MISSION"이면, description은 "미션을 재개한다",
onSelectEvent에는 일시정지를 풀고 다시 게임으로 돌아가는 함수를 할당하도록 구현하려고 합니다.


아직은 여담인데, 저는 이 게임이 최소한 영어와 한국어를 지원하게끔 만들려는 계획을 가지고 있고,
언젠가 언어 설정을 구현한 다음에는 설정된 언어에 맞게 UI 설명과 자막을 바꾸려고 합니다.

그 때가 되면 description 항목은 설명문이 하드코딩된 게 아니라,
어떤 설명을 가지고 있는 xml이나 json 데이터의 키값을 가리키는 형식이 될 것 같네요.


GameManager.cs

// Show/Hide Pause UI Canvas, Hide/Show other UIs, Set TimeScale
public void OnPause(InputAction.CallbackContext context)
{
    if(context.action.phase == InputActionPhase.Performed)
    {
        Pause();
    }
}

public void Pause()
{
    Time.timeScale = (isPaused == true) ? 1 : 0;
    isPaused = (Time.timeScale == 0);

    foreach(GameObject obj in hideOnPause)
    {
        obj.SetActive(!isPaused);
    }
    pauseUICanvas.SetActive(isPaused);

    AudioListener.pause = isPaused;
}

public void RestartFromCheckpoint()
{
    SceneManager.LoadScene(SceneManager.GetActiveScene().name);
}

public void RestartMission()
{
    SceneManager.LoadScene(SceneManager.GetActiveScene().name);
}

public void QuitMission()
{
    Application.Quit();
}


void Awake()
{
    if(instance == null)
    {
        instance = this;
    }
    Time.timeScale = 1;
    AudioListener.pause = false;
}

GameManager에 게임 재시작과 종료 함수들을 추가합니다.
아직 체크포인트부터 다시 시작하는 기능이 없기 때문에, 게임 재시작 코드를 작성해놓겠습니다.

그리고 UnityEvent로 등록하기 위해 매개변수가 없는 Pause() 함수를 만들어서 기존 Pause(...)의 내용을 빼냈습니다.

OnPause(InputAction...)은 InputAction의 Event, Pause()는 UnityEvent에 할당되어야 합니다.


추가로 GameManager.Awake()Time.timeScaleAudioListener.pause를 원래대로 되돌리는 코드를 추가해야 합니다.
일시정지된 상태에서 게임을 재시작할 때, 위 값들을 다시 돌려놓지 않는다면 게임은 정지되고 효과음은 들리지 않는 상태로 게임을 재시작하게 됩니다.


PauseController.cs

[SerializeField]
TextMeshProUGUI descriptionText;

UISelect GetCurrentUISelect()
{
    return selectableOptions[currentIndex].GetComponent<UISelect>();
}

void ChangeSelection()
{
    // Cursor
    selectIndicator.anchoredPosition = new Vector2(selectIndicator.anchoredPosition.x,
                                                    selectableOptions[currentIndex].anchoredPosition.y);
    // Description
    descriptionText.text = GetCurrentUISelect()?.Description;
}

public void Navigate(InputAction.CallbackContext context)
{
    if(context.action.phase == InputActionPhase.Started)
    {
        ...
        ChangeSelection();
    }
}

public void Confirm(InputAction.CallbackContext context)
{
    if(context.action.phase == InputActionPhase.Performed)
    {
        ...
        GetCurrentUISelect()?.OnSelectEvent.Invoke();
    }
}

PauseController에서는 커서를 변경할 때마다 현재 선택된 선택지의 설명을 변경하고,
확인 버튼을 누르면 선택지에 바인딩된 UnityEvent를 실행하도록 코드를 작성합니다.

*RectTransform 리스트에 있는 모든 객체는 UISelect 컴포넌트를 가지고 있다고 가정합니다.


이제 UI 선택지마다 UISelect 컴포넌트를 붙여서 설명을 작성하고 이벤트를 붙입니다.

선택지마다 바뀌어야 하는 설명 텍스트 오브젝트도 PauseController에 등록한 후,


실행해서 미션 재개, 재시작 등을 테스트해봅니다.

보기에는 문제가 없어보입니다만...




실제로는 컨트롤하는 과정 전반적으로 문제가 있습니다.

X 버튼UI의 확인 버튼도 있지만, 기총을 발사하는 버튼도 할당이 되어 있는 상태죠.
왼쪽 아날로그 스틱피치를 올리는 기능 뿐만 아니라 커서를 움직이는 기능도 할당되어 있습니다.

그래서 피치를 조절할 때마다 PauseController의 UI가 안 보이는 상태에서 움직이고 있고,
총알을 발사할 때마다 현재 선택된 PauseController의 함수가 실행되고 있습니다.

미션을 재개할 때도 UI의 확인 버튼을 누르는 기능 뿐만 아니라 기총 발사 기능도 같이 작동하고 있죠.


비행기의 조작과 UI 조작은 Action Map이 나뉘어져 있는 만큼, 두 Action Map 중 하나만 사용하도록 바꿔줘야 합니다.


GameManager.cs

[SerializeField]
PlayerInput playerInput;

public void Pause()
{
    ...
    string actionMapName = (isPaused == true) ? "UI" : "Player";
    playerInput.SwitchCurrentActionMap(actionMapName);
}

void Start()
{
    ...
    
    playerInput.actions.Disable();
    playerInput.SwitchCurrentActionMap("Player");
}

PlayerInput게임 오버 연출을 만들 때 다룬 적이 있었습니다.

SwitchCurrentActionMap(...) 함수를 이용해서, PlayerInput의 현재 활성화된 액션 맵을 "UI"와 "Player" 둘 중 하나로 제한을 시킵시다.

최초로 게임을 시작할 때는 "Player", 일시정지 상태에서는 "UI", 일시정지가 풀리면 다시 "Player"로 바꿔주도록 하죠.


게임을 최초로 시작할 때는 두 Action Map이 모두 활성화되어있는 상태라서, UI Action Map을 비활성화해주는 과정이 필요합니다.
그래서 actions.Disable()로 모두 비활성화시킨 다음, SwitchCurrentActionMap("Player")를 호출해서 "Player" Action Map만 활성화되고 "UI" Action Map을 비활성화시킵니다.


그렇게 하면 끝나냐고요? 그렇지도 않습니다.

X 버튼UI의 확인 버튼기총을 발사하는 버튼이 할당이 되어 있는 상태라고 위에서 설명했었는데,
PauseControllerPerformed 내부에서 SwitchCurrnetActionMap이 실행되면,
WeaponControllerPerformed에서도 해당되는 코드를 실행하려다가 이렇게 에러가 발생합니다.

SwitchCurrnetActionMap을 실행하게 되면 버튼에 바인딩되는 함수가 바뀌면서 Performed의 콜백 함수 리스트에도 영향을 미치게 되는데, 그 과정에서 인덱스 관련 Exception이 일어나는 것으로 보입니다.



PauseController.cs

public void Confirm(InputAction.CallbackContext context)
{
    if(context.action.phase == InputActionPhase.Started)
    {
        audioSource.PlayOneShot(confirmAudioClip);
        GetCurrentUISelect()?.OnSelectEvent.Invoke();
    }
}

에러를 없애는 방법은 간단합니다.
아까랑 비슷하게 Performed 대신 Started에 Confirm 함수를 실행하게 만들면 되죠.

"버튼을 눌렸을 때" 뭔가 실행되기만 하면 되는 거지, "버튼을 뗐을 때" 실행되는 것만 아니면 되니까요.



페이드 인/아웃

게임을 최초로 실행할 때는 페이드 인,

게임을 재시작하거나 종료할 때는 페이드 아웃 효과를 넣어줍시다.

새 캔버스를 만들고 검은색 이미지로 화면을 채운 다음,
Sort Order를 다른 어떤 Canvas보다도 큰 값으로 설정합니다.


이제 검은색 이미지의 투명도를 시간에 따라 바뀌게 하는 코드를 작성해야 하는데,
일시정지 상태에서 게임을 재시작할 때는 Time.timeScale이 0인 상태라는 점을 유의해야 합니다.

즉, Time.deltaTimeTime.fixedDeltaTime은 사용할 수 없습니다.

Time.timeScale이 0이어도 Update() 함수는 매 프레임마다 실행됩니다.



FadeController.cs

public class FadeController : MonoBehaviour
{
    [SerializeField]
    Image image;

    [SerializeField]
    float fadeInTime;
    [SerializeField]
    float fadeOutTime;
    
    [SerializeField]
    float fadeOutEventWaitingTime;
    float fadeOutEventWaitingTimer;
    
    float fadeInReciprocal;
    float fadeOutReciprocal;

    Color color;

    bool isFadeOut;
    bool isFadeIn;
    bool isWaitingFadeOutEvent;
    
    // Events
    [SerializeField]
    UnityEvent onFadeInComplete;
    
    [SerializeField]
    UnityEvent onFadeOutComplete;

    public UnityEvent OnFadeInComplete
    {
        get { return onFadeInComplete; }
        set { onFadeInComplete = value; }
    }

    public UnityEvent OnFadeOutComplete
    {
        get { return onFadeOutComplete; }
        set { onFadeOutComplete = value; }
    }

    public void FadeOut()
    {
        isFadeIn = false;
        isFadeOut = true;
    }

    // Start is called before the first frame update
    void Start()
    {
        fadeInReciprocal = 1 / fadeInTime;
        fadeOutReciprocal = 1 / fadeOutTime;

        isFadeIn = true;
        isFadeOut = false;
        isWaitingFadeOutEvent = false;
        fadeOutEventWaitingTimer = fadeOutEventWaitingTime;

        color = Color.black;
        image.color = color;
    }

    // Update is called once per frame
    void Update()
    {
        if(isFadeIn == true)
        {
            color.a -= Time.unscaledDeltaTime * fadeInReciprocal;
            image.color = color;
            
            if(color.a <= 0)
            {
                color.a = 0;
                isFadeIn = false;
                onFadeInComplete.Invoke();
            }
        }

        if(isFadeOut == true)
        {
            color.a += Time.unscaledDeltaTime * fadeOutReciprocal;
            image.color = color;

            if(color.a >= 1)
            {
                color.a = 1;
                isFadeOut = false;
                isWaitingFadeOutEvent = true;
            }
        }

        if(isWaitingFadeOutEvent == true)
        {
            fadeOutEventWaitingTimer -= Time.unscaledDeltaTime;
            if(fadeOutEventWaitingTimer <= 0)
            {
                fadeOutEventWaitingTimer = fadeOutEventWaitingTime;
                isWaitingFadeOutEvent = false;
                onFadeOutComplete.Invoke();
            }
        }
    }
}

Time.deltaTime을 못 쓰면 어떻게 하냐고요? Time.unscaledDeltaTime을 쓰면 되죠.
Time.deltaTime과 동일한 기능을 하지만, Time.timeScale의 영향을 받지 않습니다.

최초 실행 시에는 페이드 인 효과를 실행합니다.
fadeInTime에 맞춰 alpha 값을 점점 줄여나가다가 0이 되면 페이드 인을 멈추고 onFadeInComplete에 등록된 함수들을 실행합니다.

에디터 상에서 실행해보니 unscaledDeltaTime은 실행에 걸리는 딜레이를 모두 포함한 시간을 첫 프레임에서 리턴하는 바람에 페이드 인 효과를 확인하기 힘들었습니다.


페이드 아웃을 실행하기 위해서는 FadeOut()을 호출하면 됩니다.

호출 후에는 fadeOutTime에 맞춰 alpha 값을 점점 높이며 페이드 아웃 효과를 주다가,
페이드 아웃이 끝나면 isWaitingFadeOutEvent를 true로 놓습니다.

fadeOutEventWaitingTime 동안 추가로 기다린 후, onFadeOutComplete에 등록된 함수들을 실행합니다.

InvokeWaitForSecondsTime.timeScale의 영향을 받기 때문에,
Time.timeScale = 0인 상황에서는 사용할 수 없습니다.


GameManager.cs

[SerializeField]
FadeController fadeController;

public void RestartFromCheckpoint()
{
    fadeController.OnFadeOutComplete.AddListener(RestartFromCheckpointEvent);
    fadeController.FadeOut();
}

void RestartFromCheckpointEvent()
{
    SceneManager.LoadScene(SceneManager.GetActiveScene().name);
}

public void RestartMission()
{
    fadeController.OnFadeOutComplete.AddListener(RestartMissionEvent);
    fadeController.FadeOut();
}

void RestartMissionEvent()
{
    SceneManager.LoadScene(SceneManager.GetActiveScene().name);
}

public void QuitMission()
{
    fadeController.OnFadeOutComplete.AddListener(QuitMissionEvent);
    fadeController.FadeOut();
}

void QuitMissionEvent()
{
    Application.Quit();
}

public void EnablePlayerInput()
{
    playerInput.enabled = true;
    playerInput.actions.Disable();
    playerInput.SwitchCurrentActionMap("Player");
}

GameManager에서 호출되는 재시작/종료 함수도 변경해줍니다.
이제 해당 함수가 호출되면, 바로 재시작이나 종료를 하지 않고 FadeController.OnFadeOutComplete에 리스너를 추가해준 다음 FadeOut()을 호출합니다.

그러면 페이드 아웃이 끝나고, 약간의 딜레이 이후에 재시작/종료 함수가 호출될 겁니다.


그리고 최초 실행 후 페이드 인 효과가 실행되는 동안에는 어떠한 입력도 받지 않게끔 하게 만들기 위해,
PlayerInput을 활성화하고 Action Map을 설정하는 EnablePlayerInput()을 추가했습니다.

이 함수를 FadeController.OnFadeInComplete에 등록시켜주면 될 겁니다.

페이드 아웃 전용 캔버스에 FadeController를 등록하고 변수를 추가합니다.
위에서 언급한 GameManager.EnablePlayerInput()을 등록해줍니다.


이제 게임을 실행할 때, 또는 재시작할 때마다 페이드 인이 실행되고,
페이드 인이 끝나면 조작이 가능해지게 됩니다.

재시작을 선택할 때는 페이드 아웃 연출 후, 일정 시간이 지난 다음 게임이 재시작됩니다.


이왕 시작 연출 만드는 김에 "START MISSION" 라벨도 띄워보죠.
*이 라벨은 페이드 인 효과가 재생되는 동안 띄워집니다.


라벨 이미지를 만들고,

ScriptableObject를 만들고,

코드도 짜고...

음... 여긴 개선의 여지가 있겠네요...

시작 시 라벨을 보여주는 속성에 체크하면?


이제 좀 게임을 시작한다는 느낌이 드네요.



게임 오버 UI

게임 오버 조건은 두 가지가 있습니다.

  1. 플레이어 기체 파괴
  2. 시간 초과 등 미션 목표 달성 실패

1번은 "Galm 1이 격추됐다!" 같은 대사가 출력되긴 하지만, 보통 한 문장으로 끝나며 화면이 암전되는 타이밍은 항상 동일합니다.

2번도 몇 개의 대사가 출력되고 난 다음에 화면이 암전되는데, 그 대사를 모두 출력하고 나서 화면이 암전됩니다. 미션 및 상황 (페이즈)마다 화면이 암전되는 타이밍이 다릅니다.

게임 오버 UI 스크린샷을 보면, 게임이 정지된 상태로 현재 활성화된 시점에 UI를 보여줍니다.

1번의 경우에는 데스캠 위에 UI가 표시되어야 하고,
2번의 경우에는 현재 활성화된 1인칭 또는 3인칭 시점 위에 UI가 표시되어야 합니다.



Input Action 구조 변경

현재 Input Action에는 일시정지 UI의 함수들이 바인딩되어 있습니다.
여기에 게임 오버 UI 함수도 바인딩하려 한다면, 두 UI에 있는 함수들이 동시에 작동될 우려가 있습니다.

일시정지 시에는 일시정지 UI의 함수만 실행되고, 게임 오버 시에는 게임 오버 UI의 함수만 실행되도록 구조를 변경해주려고 합니다.


PauseController.cs

void OnEnable()
{
    currentIndex = 0;
    
    if(playUIShowAudio == true) audioSource.PlayOneShot(pauseAudioClip);
    
    ChangeSelection();

    InputAction navigateAction = GameManager.PlayerInput.actions.FindAction("Navigate");
    navigateAction.started += Navigate;
    InputAction submitAction = GameManager.PlayerInput.actions.FindAction("Submit");
    submitAction.started += Confirm;
}

void OnDisable()
{
    audioSource.PlayOneShot(pauseAudioClip);
    
    InputAction navigateAction = GameManager.PlayerInput.actions.FindAction("Navigate");
    navigateAction.started -= Navigate;
    InputAction submitAction = GameManager.PlayerInput.actions.FindAction("Submit");
    submitAction.started -= Confirm;
}

PauseController는 일시정지 UI 뿐만 아니라 게임 오버 UI에서도 사용하려고 합니다.
각 UI가 활성화될 때 Navigate와 Submit 액션에 함수를 바인딩하고, 비활성화될 때 다시 바인딩을 푸는 방식으로 변경했습니다.

UnityEvent는 AddListener() 함수를 사용해야 하지만, InputAction은 해당 phase에 +=, -= 연산자를 사용해서 함수를 바인딩/해제할 수 있습니다.


플레이어 기체 파괴 시


먼저 플레이어의 기체가 파괴되었을 때는 (배경음이 켜져있을 때) 미션 실패 음악이 재생되고,
음악이 끝나갈 때 쯤 화면이 암전됩니다.

파괴 시에 음악을 재생하는 건 어렵지 않습니다.

데스캠에 Audio Source를 하나 얹어주면 돼요.


대충 8초 정도 후에 화면이 암전되어야 하고, 약간의 딜레이 후에 게임 오버 UI를 띄워줍시다.


우선 UI를 만들어준 다음,

컨트롤러를 붙여주고 여러 변수들을 설정합니다.

게임 오버 UI도 PauseController를 그대로 사용하면 될 것 같습니다.


FadeInController.cs

bool invokeOnFadeInEvents;

public void FadeOut(bool reserveFadeIn = false)
{
    isFadeIn = false;
    isFadeOut = true;

    if(reserveFadeIn == true)
    {
        onFadeOutComplete.AddListener(FadeIn);
    }
}

void FadeIn()
{
    isFadeIn = true;
    isFadeOut = false;
    isWaitingFadeOutEvent = false;
}

// Start is called before the first frame update
void Start()
{
    ...
    invokeOnFadeInEvents = true;
}

void Update()
{
    if(isFadeIn == true)
    {
        ...
        if(color.a <= 0)
        {
            ...
            if(invokeOnFadeInEvents == true)
            {
                onFadeInComplete.Invoke();
                invokeOnFadeInEvents = false;
            }
        }
    }
 }

FadeInController에는 페이드 인을 예약하는 기능을 추가합니다.
FadeOut()의 매개변수로 bool reserveFadeIn을 추가했으며, true를 넣어주면 페이드 아웃 후에 페이드 인이 실행됩니다.

그리고 페이드 인 시에 onFadeInComplete에 예약된 이벤트들은 게임을 최초로 실행할 때만 같이 실행되는 것을 전제로 하기 위해, invokeOnFadeInEvents라는 변수를 추가했습니다.

사실 등록된 이벤트를 모두 지워버리려고 했는데, Inspector View를 통해 등록한 이벤트는 안 지워지네요.


GameManager.cs

[Header("Game Over Control")]
[SerializeField]
GameObject gameOverUICanvas;
[SerializeField]
List<GameObject> disableOnShowGameOverUI;

public void GameOver(bool isDead, bool isInstantDeath = false)
{
    float gameOverFadeOutDelay = 3.0f;
    ...
    if(isDead)
    {
        ...
        gameOverFadeOutDelay = 7.0f;
    }
    
    Invoke("GameOverFadeOut", gameOverFadeOutDelay);
}


// Game Over Control
void GameOverFadeOut()
{ fadeController.OnFadeOutComplete.AddListener(ShowGameOverUI);
    fadeController.FadeOut(true);
}

void ShowGameOverUI()
{
    Time.timeScale = 0;
    AudioListener.pause = true;
    EnablePlayerInput(false);
    gameOverUICanvas.SetActive(true);
    
    foreach(GameObject obj in disableOnShowGameOverUI)
    {
        obj.SetActive(false);
    }
}

public void EnablePlayerInput(bool usePlayerActionMap = true)
{
    playerInput.enabled = true;
    playerInput.actions.Disable();

    string actionMapName = (usePlayerActionMap == true) ? "Player" : "UI";
    playerInput.SwitchCurrentActionMap(actionMapName);
}

게임 오버 함수를 호출할 곳은 GameOver() 내부입니다.
기본적으로는 3초로 설정하되, 기체가 파괴될 때는 7초 이후에 GameOverFadeOut() 함수를 호출합니다.

이 함수는 FadeController에 페이드 아웃을 요청하고, 페이드 아웃 후에는 ShowGameOverUI()를 호출하도록 준비하는 역할을 합니다.


게임 오버 UI가 표시될 때, 비활성화되었던 PlayerInput은 다시 활성화되어야 하지만
그 때의 Action Map은 "UI"로 고정되어야 합니다.

EnablePlayerInput 함수에 어떤 Action Map을 사용할 것인지 고르는 매개변수를 추가하고,
게임 오버 UI가 표시되면서 페이드 인 될 때는 "UI" Action Map으로 바꿔주도록 코드를 작성합니다.


"MISSION FAILED" 라벨도 계속 띄워져있기 때문에 게임 오버 UI가 표시될 때 같이 비활성화해줍니다.


public void RestartFromCheckpoint()
{
    pauseController.enabled = false;
    gameOverController.enabled = false;
    ...
}

그리고 게임을 재시작하거나 종료할 때 모든 객체가 파괴되면서 PauseController의OnDisable(), OnDestroy()가 호출되어야 하는데,
가끔씩 꼬이는 일이 발생하길래 아예 호출 즉시 모두 Disable시키는 코드를 추가했습니다.


GameManager의 게임 오버 관련 스크립트와 객체를 모두 등록해줍니다.
DisableOnShowGameOverUI에는 라벨과 자막 오브젝트가 포함됩니다.


모든 준비가 끝났으면, 이제 다양한 방법으로 죽어봅시다.


페이드 아웃 후에는 다시 페이드 인 되며 게임 오버 UI가 나타나게 되고,

재시작을 선택할 경우에도 잘 작동합니다.



미션 실패 시

지금 구현된 실패 조건은 미션 제한 시간을 초과했을 때 뿐입니다.

그거라도 테스트를 해보죠.

위에서 구현한 코드에 따르면, 미션 실패 처리 3초 후에 페이드 아웃 연출이 재생됩니다.

아무 문제없이 현재 시점에서 UI가 표시되고 있습니다.

그러면... 미션 실패 후에 기체가 파괴되면 어떻게 될까요?


겉보기에는 문제없이 UI가 잘 표시되고 있습니다.
하지만 파괴될 때 더 이상 재생될 필요가 없는 미션 실패 음악이 재생되고 있죠.

(그나저나 멈춰진 화면이 뭔가 다이나믹해보이네요.)


GameManager.cs

public void GameOver(bool isDead, bool isInstantDeath = false)
{
    ...
    if(isDead)
    {
        ...
        if(isGameOver == false)
        {
            gameOverAudioSource.Play();
        } 
    }
    isGameOver = true;
    Invoke("GameOverFadeOut", gameOverFadeOutDelay);
}

void GameOverFadeOut()
{
    CancelInvoke();
    ...
}

로직을 약간 바꿔서, 기체가 파괴될 때 이미 게임 오버된 상태가 아니라면 음악을 재생하도록 합시다.

그리고 Invoke가 두 번 호출될 수 있기 때문에, GameOverFadeOut()CancelInvoke()를 추가합니다.


이제 미션 실패 후에 기체가 파괴되어도 음악은 재생되지 않습니다.

멈춘 타이밍이 절묘하네요.



#끝이_보인다

이제 크게 세 가지가 남았습니다.

  1. 대사 출력
  2. 게임 연출
  3. 메인 화면, 결과 화면

물론 부가 목표들도 좀 있지만요.

  1. 입력에 따른 비행기 애니메이팅
  2. 맵 구현
  3. 미사일 추적 알고리즘 수정
  4. 난이도 조절
  5. 다국어 지원
  6. 최적화
    ...

최근에 산업기능요원 복무가 끝났고, 최종 출근일도 지나서 정식 퇴사 처리만 앞둔 상황입니다.
대학교 복학까지 2달 남짓 남은 상황이니, 제 때 완성할 수 있을 것 같네요.



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

0개의 댓글