Ace Combat Zero: 유니티로 구현하기 #24 : 엔딩 컷씬 + 메인 화면

Lunetis·2021년 8월 9일
0

Ace Combat Zero

목록 보기
25/27
post-thumbnail



이제 주 목표에서 남은 건 엔딩 컷씬, 결과 화면, 메인 화면 뿐입니다.

게임 파트를 마무리짓기 위해 엔딩 컷씬부터 만들어보죠.



엔딩 컷씬

이전 편에서 만들었던 페이즈 3 진입 전 컷씬과 비교하면,

컷씬시간컷 수
페이즈 3 컷씬50초8
엔딩 컷씬55초6

시간은 5초 더 길지만 컷 수는 2개 더 적습니다.

특히 마지막 컷이 30초나 잡아먹는 롱 테이크죠.



AWACS 모델링 구하기

미션에서 말만 하고 단 한 번도 보이지 않았다가 V2의 폭발에 휩싸이는 AWACS를 보여줘야 합니다.


하지만 다음 컷에서 멀쩡하게 나타나죠.


에이스 컴뱃 시리즈에서 대부분의 AWACS는 E-767 조기경보통제기로 등장합니다.

보잉 767기를 기반으로 한 조기경보통제기이며, 일본 항공자위대만 운용 중인 기체입니다.
에이스 컴뱃이 일본 게임이다 보니, 자국에서 운용하고 있는 기체의 라이센스를 얻기가 더 수월했는지 거의 대부분 E-767로 등장합니다.


https://3dwarehouse.sketchup.com/model/98c0c90dcce8f9ee3ba77af508a192c0/AWACS-E767

또 어딘가에서 모델링을 주워왔습니다.
일본에서만 사용되는 비행기다보니 무료 모델링이 많지는 않네요.

그래도 자세히 보여줘야 하는 비행기는 아니다보니 큰 문제는 없을 것 같습니다.


이 모델링은 SketchUp으로 만들어진 것으로 보이는데, 유니티는 SketchUp 파일 (.skp)도 그대로 임포트할 수 있으므로, 그냥 다운로드하고 드래그해서 넣어줍니다.

(최신 버전 모델로 받으면 임포트가 안 되네요.)


바로 임포트해서 F-15랑 비교한 사진입니다.

E-767의 기반인 보잉 767의 동체폭이 5m, F-15의 날개폭이 13m니까, E-767의 크기가 조금 더 작아야 합니다.

절반으로 줄이니까 얼추 맞는 것 같습니다.



컷씬 만들기

노가다 좀 하고 오겠습니다.


아주 화려한 타임라인 클립들이네요.

이전에 Default Playables 패키지를 설치하면서 써먹을 게 없다고 생각했는데,
컷씬 내 슬로모션 기능을 구현하기 위해 Time Dilation Track이라는 걸 썼습니다.

사실 3초 동안 매우 느리게 이동하도록 애니메이션을 만들었는데, 파티클은 느리게 재생할 수가 없어서 꼼수를 약간 사용했습니다.

Playable DirectorUpdate Method로 Timescale에 영향을 받을지 정할 수 있는데, 기본값은 영향을 받는 Game Time으로 되어 있습니다.
그 상태에서 Time Dilation Track을 통과하면 타임라인 자체의 시간도 느리게 갑니다.

이렇게 쓰는 것도 나쁘지 않겠지만...

원본을 그대로 재현하겠답시고 컷씬 오디오 트랙을 통째로 집어넣는 바람에 오디오 타이밍을 잡는 게 애매해져서 방법을 약간 바꿨습니다.

딜레이션 트랙을 지나갈 때 커서가 느리게 움직이는데, 오디오는 느리게 재생되지 않고 그대로 재생되어버려서 커서가 오디오 트랙에서 가리키는 부분과 실제 재생되는 부분이 달라집니다.

Update MethodUnscaled Time으로 두면 타임라인 자체는 Timescale에 영향을 받지 않지만, 그 외 파티클 이펙트 등은 Timescale의 영향을 받습니다.
그래서 슬로모션을 아예 애니메이션으로 만들어놓은 다음, Time Dilation Track으로 Timescale을 바꿔놓으면 슬로모션 + 느리게 재생되는 파티클 + 오디오 트랙 재생이 커서와 정확히 매치되도록 만들 수 있습니다.


아무튼 그렇게 엔딩 컷씬을 만들었습니다.


json 파일에서 "덤벼라!" 출력 후 2.5초 후에 엔딩 컷씬을 출력하도록 데이터를 수정합니다.


그리고 다시 픽시를 쓰러뜨리러 갑시다.

이전 포스트에서 스킵 기능을 안 만들었으면 엄청나게 귀찮았을 겁니다.
매 테스트마다 50초를 기다렸겠죠. 아니면 타임라인 창에 들어가서 일일이 커서를 맨 끝으로 옮겼거나.

이제 페이즈 3 체력을 모두 깎으면 엔딩 컷씬이 재생됩니다.



아주 간단한 결과 화면

데이터 구성하기

원래 에이스 컴뱃 시리즈에서 게임이 끝나면,

이렇게 리플레이를 보여주고,

미션 결과와 함께 플레이어, 아군, 적군의 미션 내 행적을 화살표로 보여주는 화면이 등장합니다.

마음 같아서는 이것도 만들고 싶지만, 이걸 만들기 위해 들여야 하는 시간에 비해 주목도가 낮은 기능이라 아예 안 만들거나 후순위로 미뤄두려고 합니다.

그 때는 이걸 쓰면 될 것 같긴 합니다.


어쨌든 결과는 적당히 보여줘야 하니 머리를 굴려봅시다.

S랭크를 따려면 6분 이내로 클리어해야 한다고 합니다.

아직 이 게임에 게임을 진행한 시간을 기록하는 코드는 없었는데, 작성해야겠네요.


UIController.cs

bool enableCount = true;
float elapsedTime = 0;

public float StopCountAndGetElapsedTime()
{
    enableCount = false;
    return elapsedTime;
}

public void StartCountAndResetElapsedTime()
{
    enableCount = true;
    elapsedTime = 0;
}

void SetTime()
{
    remainTime -= Time.deltaTime;
    elapsedTime += Time.deltaTime;
    ...
}

void Update()
{
    if(enableCount == true && ...) SetTime();
}

소요된 시간을 가지는 elapsedTime, 타이머를 멈추게 할 enableCount 변수를 추가하고,
타이머를 멈추고 소요된 시간을 가져오는 함수, 소요된 시간을 초기화하고 타이머를 가동시키는 함수를 추가합니다.

elapsedTime을 딱히 초기화하지 않아도 되기는 한데, 페이즈별 소요 시간을 구분 지어보기 위해 이렇게 코드를 작성해봤습니다.



MissionZERO.cs

float elapsedTimeBeforePhase3;
float elapsedTimeAfterPhase3;

public void CheckElapsedTimeBeforePhase3()
{
    elapsedTimeBeforePhase3 = GameManager.UIController.StopCountAndGetElapsedTime();
}

public void CheckElapsedTimeAfterPhase3()
{
    elapsedTimeAfterPhase3 = GameManager.UIController.StopCountAndGetElapsedTime();

    Debug.Log(elapsedTimeBeforePhase3 + " // " + elapsedTimeAfterPhase3);
}

미션 스크립트에서는 페이즈 3 이전 (컷씬 이전), 그리고 그 이후의 소요 시간을 저장하는 변수와 함수를 작성해놓습니다.

일단 마지막 페이즈가 끝날 때 페이즈 1+2, 페이즈 3 소요 시간을 로그로 찍어보죠.

픽시에 달려있는 PixyScript에 페이즈 2와 3이 끝날 때 소요 시간을 기록하는 함수를 호출하고,

MissionZERO의 페이즈 3 시작 이벤트에 소요 시간을 초기화하는 스크립트를 작성합니다.


그리고 미사일을 발사하러 갑시다.

페이즈 2와 3이 끝날 때 좌측 상단의 타이머가 멈추는 것을 확인할 수 있습니다.

남은 시간도 제대로 로그에 표시됩니다.

6분 안에 클리어하는 게 S랭크 조건이라지만 저는 2분 36초 안에 끝내버렸습니다.

물론 동일 선상에서 비교하면 안 됩니다만...


그래도 저는 에이스 컴뱃 7의 모든 미션(DLC 포함)을 ACE 난이도로 S랭크를 찍었습니다.



결과 화면 작업

결과 화면을 어떻게 만들까... 약간 고민을 했는데,

지금까지 에이스 컴뱃 7처럼 UI를 만들어왔으니 비슷하게 따라가보겠습니다.

에이스 컴뱃 7의 메인 메뉴 배경화면입니다.
이걸 배경으로 깔고 그 위에 UI를 좀 넣어보죠.


에이스 컴뱃 제로처럼 색조를 바꿔서 배경화면을 빨갛게 칠할 생각도 했는데,

게임이 끝났으니 차분해지라는 의미로 그냥 파랗게 두겠습니다.


몇 달 만에 새로운 씬을 만들고 있습니다.
옆에 있는 AIScene은 테스트용이지만 이건 정말로 게임에 들어갈 씬입니다.


대충 이런 형식으로 만들겠습니다.

시간 보너스는 15분에서 소요 시간만큼 빼서 초당 50점으로 환산하는 방식으로 하겠습니다.

만약 실제 게임처럼 6분 이내에 클리어했다면, 픽시를 잡은 5천점을 포함해서 32,000점이 S랭크 커트라인이 됩니다.
(900 - 360 = 540 // 540 x 50 = 27000 // 27000 + 5000 = 32000)

그 외 커트라인은 제 마음대로 하죠.



ResultData.cs

public class ResultData
{
    public static string missionName = "";
    
    public static int timeBonusPerSecond = 0;
    public static int maxTime = 0;
    public static int elapsedTime = 0;
    public static int score = 0;
    public static int[] rankScoreCutoff;

    public static int GetTimeBonusScore()
    {
        return (maxTime - elapsedTime) * timeBonusPerSecond;
    }
}

데이터를 담을 변수입니다. 이 변수는 게임 씬에서 초기화된 후 결과 화면 씬에서 보여줘야 합니다.
씬이 넘어가도 데이터가 유지되어야 하기 때문에, public static으로 지정해줬습니다.

DontDestroyOnLoad()을 쓸 수도 있는데, 객체를 살려둬야 할 정도로 복잡한 데이터를 취급하고 있지는 않기 때문에 아직은 이 정도면 충분하다고 생각합니다.



MissionInfo.cs

[CreateAssetMenu(fileName = "MissionInfo", menuName = "Scriptable Object Asset/MissionInfo")]
public class MissionInfo : ScriptableObject
{
    [SerializeField]
    string missionName;

    [Header("S/A/B/C Rank Score")]
    [SerializeField]
    int[] rankScoreCutoff = new int[4] {40000, 30000, 20000, 10000};
    [SerializeField]
    int timeLimit;
    [SerializeField]
    int timeBonusPerSecond;

    public string MissionName
    {
        get { return missionName; }
    }

    public int[] RankScoreCutoff
    {
        get { return rankScoreCutoff; }
    }

    public int TimeLimit
    {
        get { return timeLimit; }
    }

    public int TimeBonusPerSecond
    {
        get { return timeBonusPerSecond; }
    }
}

미션 이름, 시간 제한, 보너스 점수, 랭크 정보 등의 미션 관련 정보들을 담을 ScriptableObject를 만들었습니다.
이 오브젝트는 MissionManager에 등록되어야 합니다.


MissionManager.cs

[Header("Game Properties")]
[SerializeField]
MissionInfo missionInfo;

void Start()
{
    ResultData.missionName = missionInfo.MissionName;
    ResultData.maxTime = missionInfo.TimeLimit;
    ResultData.timeBonusPerSecond = missionInfo.TimeBonusPerSecond;
    ResultData.rankScoreCutoff = missionInfo.RankScoreCutoff;
    
    ...
}

이전에는 MissionManager에 제한시간 등을 직접 적어놓았지만, 이렇게 따로 ScriptableObject로 빼는 게 좋겠다고 판단했습니다.
각 씬의 제한 시간을 수정하기 위해서는 그 씬에 직접 들어간 다음 MissionManager를 찾아서 수정해야 했지만, 이런 식으로 파일로 관리한다면 그럴 필요가 없어집니다.

단, 페이즈 3만의 제한시간은 따로 수정해줘야 합니다. 이건 미션마다 있을 수도 있고, 없을 수도 있기 때문이죠.



ResultController.cs

public class ResultController : MonoBehaviour
{
    [SerializeField]
    TextMeshProUGUI missionNameText;

    [SerializeField]
    TextMeshProUGUI elapsedTimeText;
    [SerializeField]
    TextMeshProUGUI timeBonusText;
    [SerializeField]
    TextMeshProUGUI scoreText;
    [SerializeField]
    TextMeshProUGUI totalScoreText;
    [SerializeField]
    TextMeshProUGUI rankText;

    char GetRank(int totalScore)
    {
        if(totalScore > ResultData.rankScoreCutoff[0])      return 'S';
        else if(totalScore > ResultData.rankScoreCutoff[1]) return 'A';
        else if(totalScore > ResultData.rankScoreCutoff[2]) return 'B';
        else if(totalScore > ResultData.rankScoreCutoff[3]) return 'C';
        return 'D';
    }
    
    void Start()
    {
        missionNameText.text = string.Format("[ {0} ]", ResultData.missionName);

        int min = ResultData.elapsedTime / 60;
        int sec = ResultData.elapsedTime % 60;
        elapsedTimeText.text = string.Format("{0:00}:{1:00}", min, sec);

        timeBonusText.text = string.Format("{0:n0}", ResultData.GetTimeBonusScore());
        scoreText.text = string.Format("{0:n0}", ResultData.score);

        int totalScore = (ResultData.GetTimeBonusScore() + ResultData.score);
        totalScoreText.text = string.Format("{0:n0}", totalScore);

        rankText.text = GetRank(totalScore).ToString();
    }
}

ResultData를 참조하여 결과 화면의 텍스트들을 수정하는 스크립트를 만듭니다.


GameManager.cs

public void ShowResultScene()
{
    SceneManager.LoadScene("Result");
}

GameManager에 결과 화면으로 넘어가게 하는 함수를 추가해줍니다.




이대로 끝내면 안 되고, 몇 가지 더 생각해야 하는 부분이 있습니다.

  • 컷씬이 끝나면 화면이 바로 넘어가지지 않고 약간의 딜레이가 있음
  • 컷씬을 스킵하면 어떻게 되지...?


FadeController.cs

public void InstantFadeOut()
{
    isFadeIn = false;
    isFadeOut = false;
    color = Color.black;
    image.color = color;
}

약간의 딜레이 동안에는 아예 화면을 안 보여주게끔 즉시 페이드 아웃을 시키죠.

FadeController에 즉시 화면을 암전시키는 함수를 추가하고,

CutsceneController.cs

void Skip()
{
    audioController.OnCutsceneFadeOut();
    fadeController.OnFadeOutComplete.AddListener(playableDirector.Stop);
    
    if(playableDirector.playableAsset == phase3CutsceneAsset)
    {
        fadeController.FadeOut(FadeController.FadeInReserveType.InstantFadeIn);
    }
    else
    {
        fadeController.FadeOut();
    }
}

void OnEndingCutsceneEnded(PlayableDirector director)
{
    fadeController.InstantFadeOut();
    ...
}

CutsceneController.OnEndingCutsceneEnded()에 암전시키는 코드를 작성해놓습니다.

그리고 Skip에서는 현재 플레이중인 컷씬이 엔딩 컷씬이 아닐 경우 페이드 아웃 후에 페이드 인을 예약하고, 그렇지 않으면 화면을 가만히 두도록 만듭니다.


CutsceneControllerOnEndingCutsceneEnded에 결과 화면으로 넘겨주는 함수를 등록한 다음,

Build Settings에서 결과 화면을 추가합니다.



게임이 끝난 후에는 이렇게 화면이 암전된 후에 결과 화면으로 넘어가고,


스킵한 경우에도 화면이 페이드 아웃 된 후 결과 화면으로 넘어갑니다.
(테스트용 기능으로 실행하느라 점수랑 소요 시간이 0입니다.)



메인 화면 만들기

결과 화면을 먼저 만들고 메인 화면을 나중에 만드는 사람이 여기 있었네요.


메인 화면에 뭘 넣을지 생각해봅시다.

보통 게임 시작, 옵션, 게임 종료 이 3가지는 가지고 있죠.

  • START MISSION => 바로 게임 시작 (난이도 선택은 이후 구현)
  • LANGUAGE SETTINGS => 영어/한국어 선택
  • QUIT => 게임 종료

이렇게 만들어봅시다.

나중에는 게임 시작 내부에 난이도 선택, 옵션 선택 내부에는 언어 뿐만 아니라 키 설정이나 사운드 설정도 있어야 할 것 같습니다.



MainMenuController.cs

public class MainMenuController : MonoBehaviour
{
    private static MainMenuController instance = null;
    
    [SerializeField]
    PlayerInput playerInput;

    [SerializeField]
    FadeController fadeController;

    [SerializeField]
    GameObject mainMenuScreen;
    [SerializeField]
    GameObject settingsScreen;
    [SerializeField]
    GameObject resultScreen;

    [Header("Audios")]
    ...
    
    GameObject currentActiveScreen = null;

    ...

    void SetCurrentActiveScreen(GameObject screenObject)
    {
        currentActiveScreen?.SetActive(false);
        currentActiveScreen = screenObject;
        currentActiveScreen.SetActive(true);
    }

    public void SetLanguage(string language)
    {
        if(language.ToLower() == "en") GameSettings.languageSetting = GameSettings.Language.EN;
        if(language.ToLower() == "kr") GameSettings.languageSetting = GameSettings.Language.KR;
    }

    public void SetDifficulty(int difficulty)
    {
        GameSettings.difficultySetting = (GameSettings.Difficulty)difficulty;
    }

    public void ShowMainMenu()
    {
        SetCurrentActiveScreen(mainMenuScreen);
    }

    public void ShowSettingsMenu()
    {
        SetCurrentActiveScreen(settingsScreen);
    }

    public void ShowResultMenu()
    {
        SetCurrentActiveScreen(resultScreen);
    }

    public void StartMission()
    {
        playerInput.enabled = false;

        fadeController.OnFadeOutComplete.AddListener(ReserveLoadScene);
        fadeController.FadeOut();
    }

    public void ReserveLoadScene()
    {
        SceneManager.LoadScene("ZERO");
    }

    public void Quit()
    {
        playerInput.enabled = false;

        fadeController.OnFadeOutComplete.AddListener(QuitEvent);
        fadeController.FadeOut();
    }
    
    void QuitEvent()
    {
        Application.Quit();
    }

    void Awake()
    {
        if(instance == null)
        {
            instance = this;
        }
        
        mainMenuScreen.SetActive(false);
        resultScreen.SetActive(false);
        settingsScreen.SetActive(false);
    }

    void Start()
    {
        if(ResultData.missionName != "")
        {
            SetCurrentActiveScreen(resultScreen);
        }
        else
        {
            SetCurrentActiveScreen(mainMenuScreen);
        }
    }
}

MainMenuController는 각 메인 메뉴에서 실행되어야 하는 함수들을 가지고 있습니다.
특정 메뉴를 선택할 때 실행할 함수, 뒤로 가기 시 실행되어야 하는 함수 등을 가지고 있습니다.

게임 실행, 언어 설정 변경, 게임 종료 등의 함수들이 이 스크립트 내에 모두 들어있고,
뒤로 가거나 내부 설정으로 들어가는 등 조종해야 할 메뉴를 이동해야 하는 경우에는 선택된 메뉴만 활성화하고 이전에 조종하고 있었던 메뉴를 비활성화합니다.



MenuController.cs

public class MenuController : MonoBehaviour
{
    [SerializeField]
    List<RectTransform> selectableOptions;
    [SerializeField]
    RectTransform selectIndicator;
    
    [SerializeField]
    UnityEvent onBackEvent;

    [SerializeField]
    TextMeshProUGUI descriptionText;

    [SerializeField]
    AudioSource audioSource;

    int currentIndex;

    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;
    }


    IEnumerator ConfirmCoroutine()
    {
        MainMenuController.PlayerInput.enabled = false;
        yield return new WaitForSeconds(0.3f);
        MainMenuController.PlayerInput.enabled = true;
        GetCurrentUISelect()?.OnSelectEvent.Invoke();
    }

    public void Navigate(InputAction.CallbackContext context)
    {
        float y = context.ReadValue<Vector2>().y;

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

    public void Confirm(InputAction.CallbackContext context)
    {
        selectIndicator.GetComponent<Animation>().Play();
        MainMenuController.Instance.PlayConfirmAudioClip();

        StartCoroutine(ConfirmCoroutine());
    }
    
    public void Back(InputAction.CallbackContext context)
    {
        MainMenuController.Instance.PlayBackAudioClip();
        onBackEvent.Invoke();
    }

    void OnEnable()
    {
        if(MainMenuController.PlayerInput == null) return;

        currentIndex = 0;
        ChangeSelection();

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

    void OnDisable()
    {
        if(MainMenuController.PlayerInput == null) return;
        
        InputAction navigateAction = MainMenuController.PlayerInput.actions.FindAction("Navigate");
        navigateAction.started -= Navigate;
        InputAction submitAction = MainMenuController.PlayerInput.actions.FindAction("Submit");
        submitAction.started -= Confirm;
        InputAction backAction = MainMenuController.PlayerInput.actions.FindAction("Back");
        backAction.started -= Back;
    }
}

MenuController는 이전에 일시중지/게임 오버 UI를 만들었을 때의 코드를 재탕했습니다.
활성화/비활성화될 때 자동으로 InputAction의 확인, 이동, 뒤로 가기 액션에 바인딩될 수 있도록, OnEnable()OnDisable()에서 함수를 바인딩하거나 해제합니다.


Confirm() (확인) 에서는 StartCoroutine()을 호출해서 약간의 딜레이 후에 Confirm에 붙은 이벤트가 실행이 되도록 했는데,

이 선택 시 깜빡이는 애니메이션을 적당히 구현하기 위해서입니다.

UI 회전하면서 들썩이는 거 말고요.
그거는 시간 남으면 만들려고 합니다.



MenuUISelect.cs

[RequireComponent(typeof(TextMeshProUGUI))]
public class MenuUISelect : UISelect
{
    Animation anim;
    TextMeshProUGUI textMeshPro;
    [SerializeField]
    Image image;

    void Awake()
    {
        anim = GetComponent<Animation>();
        textMeshPro = GetComponent<TextMeshProUGUI>();
    }

    void OnEnable()
    {
        image.rectTransform.sizeDelta = textMeshPro.GetPreferredValues();
        anim.Play();
    }
}

MenuUISelect도 일시정지 화면에서 썼던 UISelect의 연장선입니다. 아예 상속을 했죠.
위의 움짤에서 볼 수 있는 텍스트 표시 이펙트 (파란색 사각형이 텍스트를 덮다가 사라지는 애니메이션) 기능이 추가됐습니다.

텍스트의 렌더링 크기를 구해서 파란색 이펙트가 텍스트 전체를 덮을 수 있도록 GetPreferredValues()를 사용했습니다.


귀찮은 할당 작업이 끝난 다음에 실행을 해봤습니다.


결과물은 대충 이렇습니다.

Start Mission을 실행하면...

오랜 시간이 지난 후에 로딩이 끝나면 게임 씬이 실행됩니다.



자막 다국어 지원

아까 언어 선택하는 화면이 있었는데, 각 언어를 선택하고 확인 버튼을 누르면,

MainMenuController.cs

public void SetLanguage(string language)
{
    if(language.ToLower() == "en") GameSettings.languageSetting = GameSettings.Language.EN;
    if(language.ToLower() == "kr") GameSettings.languageSetting = GameSettings.Language.KR;
}

이 함수가 실행됩니다.
GameSettings의 languageSettings 값을 바꿔주죠.

GameSettings.cs

public class GameSettings : MonoBehaviour
{
    public enum Language
    {
        EN,
        KR
    }
    public enum Difficulty
    {
        EASY,
        NORMAL,
        HARD,
        ACE
    }
    
    public static Language languageSetting;
    public static Difficulty difficultySetting;
}

현재 설정할 수 있는 언어와, 나중에 설정할 난이도를 저장하는 시스템입니다.
(아직은 게임을 나가도 언어 설정을 저장하는 기능은 없습니다. PlayerPrefs를 쓸 수도 있고, 설정 파일을 따로 만들 수도 있습니다.)


이 값에 따라서 게임 내 텍스트를 바꾸는 있는 기능을 추가해야 합니다.

지금은 ScriptManager에 지정해주는 XML 파일에 따라서 텍스트가 바뀝니다.
그 부분을 수정해야 하죠.


MissionInfo.cs

[SerializeField]
TextAsset scriptJson;
[SerializeField]
TextAsset script_EN;
[SerializeField]
TextAsset script_KR;

public TextAsset ScriptJSON
{
    get { return scriptJson; }
}

public TextAsset GetScriptXML()
{
    switch(GameSettings.languageSetting)
    {
        case GameSettings.Language.EN:
            return script_EN;

        case GameSettings.Language.KR:
            return script_KR;

        default:
            return script_EN;
    }
}

JSON 파일과 언어별 XML 파일은 MissionInfo에서 지정해주도록 합니다.
그리고 GameSettings.languageSetting 값에 따라서 현재 언어 설정에 맞는 XML 파일을 리턴해주는 함수도 작성합니다.



ScriptManager.cs

void Start()
{
    scriptJSONAsset = GameManager.MissionManager.MissionInfo.ScriptJSON;
    subtitleXMLAsset = GameManager.MissionManager.MissionInfo.GetScriptXML();
    
    // Load Subtitle XML
    subtitleXMLDocument = new XmlDocument();
    subtitleXMLDocument.LoadXml(subtitleXMLAsset.text);

    // Load Script JSON
    scriptData = JsonUtility.FromJson<ScriptData>(scriptJSONAsset.text);
}

ScriptManager에서 손수 설정해주지 않고, Start()에서 MissionInfo를 참조해서 JSON 파일과 XML 파일을 알아서 가져오도록 수정합니다.


MissionInfo ScriptableObject에 JSON, XML 파일을 등록한 다음,
게임 내에서 언어 설정을 바꾼 다음에 실행해봅시다.

아무것도 선택하지 않았을 때의 기본값은 영어입니다.

그리고 언어 설정에서 한국어를 선택한 다음에 실행하면 이렇게 한국어가 출력됩니다.



옵션 다국어 지원

일시정지 화면과 게임 오버 화면은 아직 다국어 지원이 안 되어 있습니다.

지금은 밑에 있는 설명 텍스트가 하드코딩 되어 있는데,
자막을 만들 때처럼 키값을 기반으로 해서 만들어줘야 할 것 같습니다...


자막처럼 XML 파일에 각 언어별 데이터를 만들고,


UISelect.cs

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

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

Description 대신 DescriptionKey로 변수명을 바꾸고,


PauseController.cs

[Header("Localization")]
[SerializeField]
TextAsset description_EN;
[SerializeField]
TextAsset description_KR;
XmlDocument descriptionXML;

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

void SetDescriptionXML()
{
    descriptionXML = new XmlDocument();

    switch(GameSettings.languageSetting)
    {
        case GameSettings.Language.EN:
            descriptionXML.LoadXml(description_EN.text);
            break;
        
        case GameSettings.Language.KR:
            descriptionXML.LoadXml(description_KR.text);
            break;

        default:
            descriptionXML.LoadXml(description_EN.text);
            break;
    }
}

string GetDescriptionText(string descriptionKey)
{
    XmlNode subtitleNode = descriptionXML.SelectSingleNode("option/" + descriptionKey);
    if(subtitleNode == null) return ""; // Exception
    return subtitleNode.InnerText;
}

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

PauseController에서는 XML에서 DescriptionKey에 해당하는 데이터를 읽어와서 출력하는 방식으로 수정합니다.

각 항목에 하드코딩 되어 있던 설명 부분을 키값으로 변경합니다.


이제 영어와 한국어 모두 대응되는...

아.


자막 데이터에 미션 목표를 추가했습니다.

subtitle 내에 들어가야 할 것 같지는 않지만 일단 넣죠.


LocalizedText.cs

[RequireComponent(typeof(TextMeshProUGUI))]
public class LocalizedText : MonoBehaviour
{
    TextMeshProUGUI textMeshPro;
    [SerializeField]
    string subtitleKey;
    
    // Start is called before the first frame update
    void Awake()
    {
        textMeshPro = GetComponent<TextMeshProUGUI>();
    }

    void Start()
    {
        textMeshPro.text = GameManager.ScriptManager.GetSubtitleText(subtitleKey);
    }
}

ScriptManager에 등록된 XML 데이터를 참조해서 그냥 갖다 붙여주는 코드를 작성하고,

키값을 추가합니다.


그리고 출력을 확인합니다.



로딩 화면

Start Mission을 누르는 순간, 음악은 계속 재생되고 화면은 계속 암전된 상태로 있다가 로딩이 끝나면 게임 씬으로 넘어가게 됩니다.

게임이 SSD에 깔려있고 적당히 성능이 받춰주는 컴퓨터라면 로딩이 빠르게 되겠지만,
HDD에 설치되어있고 성능이 좋지 않다면 렉걸려서 프리징됐다고 생각할 수도 있을 것 같습니다.


최소한 로딩중이라는 걸 인식할 수 있도록 로딩 화면을 만들어봅시다.


Without beginning or end, the ring stretches into the infinite.

마지막 미션이 로딩될 때 나오는 문구입니다.

그동안 UI를 에이스 컴뱃 7을 기준으로 했지만, 최소한 로딩 화면만큼은 원작에 맞추겠습니다.

Loading 씬으로 넘어가게 되면

  1. 페이드 인 된 후,
  2. 로딩 진행 상황을 우측 하단에 텍스트로 보여주다가,
  3. 로딩이 끝나면 페이드 아웃 후 게임 씬으로 넘겨줄 예정입니다.


LoadingController.cs

public class LoadingController : MonoBehaviour
{
    [SerializeField]
    string sceneName;

    [SerializeField]
    FadeController fadeController;
    [SerializeField]
    TextMeshProUGUI progressText;

    AsyncOperation operation;

    void StartLoadScene()
    {
        StartCoroutine(LoadAsync());
    }

    void ChangeScene()
    {
        operation.allowSceneActivation = true;
    }

    IEnumerator LoadAsync()
    {
        operation = SceneManager.LoadSceneAsync(sceneName);
        // When the scene loading completes, don't change the scene instantly
        operation.allowSceneActivation = false; 

        while(operation.progress < 0.9f)
        {
            progressText.text = (int)(operation.progress / 0.009f) + " %";
            yield return null;
        }

        progressText.text = "100 %";

        fadeController.OnFadeOutComplete.AddListener(ChangeScene);
        fadeController.FadeOut();
    }

    // Start is called before the first frame update
    void Start()
    {
        progressText.text = "";
        fadeController.OnFadeInComplete.AddListener(StartLoadScene);
    }
}

여기서도 FadeController가 사용됩니다.

씬이 보여지는 순간부터 로딩을 하지 않고, 페이드 아웃이 끝난 후에 로딩을 시작합니다.

IEnumerator LoadAsync()에서 로딩과 관련된 모든 처리를 담당하는데,
SceneManager.LoadSceneAsync()로 비동기로 씬을 로드한 다음,
allowSceneActivationfalse로 둬서 로딩이 끝난 후 바로 씬을 전환하지 않도록 설정합니다.

로딩이 끝난 상태에서 이 값이 true가 되는 순간 씬이 넘어가게 됩니다.


operation.progress는 로딩의 진행도를 나타내는데, 0에서 1 사이의 값으로 표현합니다.
단, allowSceneActivation = false일 때는 이 값이 0.9 이상으로 올라가지 않습니다.

The operation is finished when the progress float reaches 1.0 and isDone is called. If you set allowSceneActivation to false, progress is halted at 0.9 until it is set to true.

진행도가 1.0이 되면 작업이 끝나고 isDone이 호출됩니다. 만약 allowSceneActivation을 false로 두었다면, 그 값이 true로 바뀔 때까지 진행도는 0.9에서 멈추게 됩니다.

https://docs.unity3d.com/ScriptReference/AsyncOperation-progress.html


그래서 0.9를 100%의 기준으로 둬서 진행도를 텍스트로 표현해주다가,
0.9가 되면 텍스트를 100%로 두고 화면 전환 기능을 수행합니다.

페이드 아웃이 끝나면 씬을 전환할 수 있도록 operation.allowSceneActivation = true; 코드를 담고 있는 ChangeScene()FadeController.OnFadeInComplete에 추가하고, 그 다음에 페이드 아웃을 실행합니다.


진행도가 천천히 올라가지 않고 갑자기 100%를 찍어버리는데... 원래 에디터가 좀 그렇습니다.

그래도 로딩은 잘 되니까 넘어갑시다.
빌드해서 실행할 때는 이렇게 급격하게 올라가지는 않습니다.



구현 결과


이제 좀 게임이라고 말할 수 있을 정도로 만들어진 것 같습니다.
정상적으로 게임을 실행하고, 플레이하고, 결과를 보고 종료할 수 있는 상태까지 왔습니다.

그래픽과 알고리즘 쪽은 여전히 손을 많이 봐야겠지만, 뭔가 크게 건드릴 곳은 더 이상 없어 보입니다.


언젠가 미친 척 하고 원작에 있는 모든 에이스들을 이 게임판에 다 섞어먹을 수도 있겠지만...

아주 나중에요.


이제 버그 좀 더 잡고 빌드해서 어딘가에 업로드할 준비를 해야겠습니다.



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

0개의 댓글