Ace Combat Zero: 유니티로 구현하기 #21 : 대사 및 자막 (2) - 구현

Lunetis·2021년 7월 21일
0

Ace Combat Zero

목록 보기
22/27
post-thumbnail



대사 데이터 : XML
자막 데이터 : JSON
파일 동적 호출 방식 : Addressable

자막 데이터, 대사 데이터, 그리고 파일을 불러오는 방법까지 모두 준비됐습니다.
이제 게임을 진행하는 도중에 대사를 출력하도록 만들어야 합니다.



미션 시작 시 대사

일단 미션 시작부터 몇 가지 대사를 읊어야 하는데, 특정 상황에 실행할 대사를 지정하는 방법은 List<string>을 사용해서 적어놓게 만들려고 합니다.

현재 분류된 키 값으로 설명하면, 처음에 실행되어야 하는 대사는 A1_1, A1_2, P1_1, A1_3입니다.

이 값들을 Inspector 창에 적어놓기만 하면, 알아서 차례대로 실행하게 만드는거죠.



ScriptManager.cs

[Serializable]
public class ScriptData
{
    public List<ScriptInfo> scripts;
}

public class ScriptManager : MonoBehaviour
{
    ScriptData scriptData;
    
    ...

    [Header("Script Data")]
    [SerializeField]
    TextAsset scriptJSONAsset;

    [Header("Subtitle")]
    [SerializeField]
    TextAsset subtitleXMLAsset;
    XmlDocument subtitleXMLDocument;

    string subtitleFormat = "<size=24><mspace=15><color={0}><b><<</mspace=15></color=#ff4444></b><size=30> {1} <size=24><mspace=15><color={0}><b>>>";

    [Header("UI")]
    [SerializeField]
    GameObject scriptUI;
    [SerializeField]
    TextMeshProUGUI nameText;
    [SerializeField]
    TextMeshProUGUI subtitleText;

    
    // Queue
    LinkedList<ScriptInfo> scriptQueue;

    bool isPrintingScript;
    ScriptInfo currentScript;

    // Addressable
    UnityEngine.ResourceManagement.AsyncOperations.AsyncOperationHandle<AudioClip> audioClipHandle;
    UnityEngine.ResourceManagement.AsyncOperations.AsyncOperationHandle<Texture> portraitHandle;
    

    public bool AddressableResourceExists(object key, Type type = null)
    {
        foreach (var l in Addressables.ResourceLocators)
        {
            IList<UnityEngine.ResourceManagement.ResourceLocations.IResourceLocation> locs;
            if (l.Locate(key, type, out locs)) return true;
        }
        return false;
    }

    // Scripts
    ScriptInfo SearchScriptInfoByKey(string scriptKey)
    {
        foreach(ScriptInfo script in scriptData.scripts)
        {
            if(script.subtitleKey == scriptKey) return script;
        }
        return null;
    }

    public void AddScript(string scriptKey)
    {
        scriptQueue.AddLast(SearchScriptInfoByKey(scriptKey));
    }

    public void AddScript(List<string> scriptKeyList)
    {
        foreach(string scriptKey in scriptKeyList)
        {
            scriptQueue.AddLast(SearchScriptInfoByKey(scriptKey));
        }
    }

    public void AddScriptAtFront(string scriptKey)
    {
        scriptQueue.AddFirst(SearchScriptInfoByKey(scriptKey));
    }

    public void ClearScriptQueue()
    {
        scriptQueue.Clear();
    }

    Color GetColorBySide(string sideString)
    {
        switch(sideString)
        {
            case "A": return allyColor;
            case "E": return enemyColor;
            case "N": return neutralColor;
            default:  return allyColor;
        }
    }

    string GetSubtitleText(string subtitleKey)
    {
        XmlNode subtitleNode = subtitleXMLDocument.SelectSingleNode("subtitle/" + subtitleKey);

        if(subtitleNode == null) return ""; // Exception

        return subtitleNode.InnerText;
    }


    void SetScript()
    {
        // Dequeue
        currentScript = scriptQueue.First.Value;
        scriptQueue.RemoveFirst();

        string subtitleKey = currentScript.subtitleKey;

        // Name
        Color textColor = GetColorBySide(currentScript.side);
        nameText.text = currentScript.name;
        nameText.color = textColor;

        // Subtitle
        string colorHexCode = "#" + ColorUtility.ToHtmlStringRGB(textColor);
        string subtitle = GetSubtitleText(currentScript.subtitleKey);
        subtitleText.text = string.Format(subtitleFormat, colorHexCode, subtitle);

        // Portrait
        string portraitKey = currentScript.name;
        if(AddressableResourceExists(portraitKey) == true)
        {
            portraitHandle = Addressables.LoadAssetAsync<Texture>(portraitKey);
            portraitHandle.Completed += (operationHandle) =>
            {
                portraitUI.SetActive(true);
                portraitImage.texture = operationHandle.Result;
            };
        }
        else
        {
            portraitUI.SetActive(false);
        }
        

        // Get AudioClip
        audioClipHandle = Addressables.LoadAssetAsync<AudioClip>(subtitleKey);
        audioClipHandle.Completed += (operationHandle) =>
        {
            AudioClip audioClip = operationHandle.Result;
            audioSource.clip = audioClip;
        };
        
        Invoke("ShowScript", currentScript.preDelay);
    }

    void ShowScript()
    {
        scriptUI.SetActive(true);
        audioSource.Play();
        
        if(currentScript.invokeMethodName != string.Empty)
        {
            GameManager.MissionManager.InvokeMethod(currentScript.invokeMethodName, currentScript.invokeMethodDelay);
        }
        Invoke("HideScript", audioSource.clip.length);
    }

    void HideScript()
    {
        scriptUI.SetActive(false);
        isPrintingScript = false;

        Addressables.Release(audioClipHandle);

        if(portraitUI.activeSelf == true)
        {
            Addressables.Release(portraitHandle);
            portraitUI.SetActive(false);
        }

        currentScript = null;
    }

    void Awake()
    {
        scriptQueue = new LinkedList<ScriptInfo>();
        scriptUI.SetActive(false);
    }

    void Start()
    {
        // Load Subtitle XML
        subtitleXMLDocument = new XmlDocument();
        subtitleXMLDocument.LoadXml(subtitleXMLAsset.text);

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

    void Update()
    {
        if(isPrintingScript == false && scriptQueue.Count > 0)
        {
            SetScript();
            isPrintingScript = true;
        }
    }
}

ScriptManager자막 데이터와 대사 데이터를 관리하고,
ScriptInfo 데이터를 얻어와서 화면에 자막을 출력하고 함수를 실행해주는 역할을 합니다.
그리고 Addressable 에셋을 로드하고 해제하는 역할까지 담당하죠.


자막 시스템은 큐(Queue) 형식으로 실행됩니다.

예를 들어, 게임 시작 시 1, 2, 3번 자막 데이터를 차례대로 실행해야 하는 경우,
코드 상에서 ScriptManager자막 데이터 1, 2, 3을 한 번에 넣어줍니다.

그러면 ScriptManager는 1번 데이터를 출력하고, 1번 데이터 출력이 끝나면 자동으로 2, 3번 데이터를 차례대로 출력해주는 방식이죠.




하지만 가끔씩 대사가 끼어들어야 하는 상황이 발생합니다.

이 대사는 차례대로 출력되어야 합니다.
만약 픽시가 첫 번째 대사를 말하고 있는데, 그 동안 남은 제한시간이 5분 미만으로 떨어지면,

이렇게 픽시가 두 번째 대사를 말하기 전에 AWACS*가 끼어들곤 합니다.

AWACS : Airborne Warning And Control System, 조기경보통제기를 뜻합니다.


또는 첫 번째 대사를 말하는 도중에 플레이어가 사망하거나 미션에 실패할 경우,

또 AWACS가 끼어들지만 두 번째 대사를 픽시가 말하지는 않습니다. 다른 대사를 말할 수는 있죠.



이런 경우들을 모두 다루기 위해, 단순히 뒤로만 데이터를 추가할 수 있는 Queue가 아닌, 앞/뒤 모두 데이터를 추가할 수 있는 LinkedList<>로 자막 데이터 대기열을 만들었습니다.

자막 데이터를 추가할 때는 자막 데이터의 키 값(string 또는 List<string>)만 넣어줍니다.
그러면 AddScript...() 함수에서 해당 문자열의 키 값에 맞는 자막 데이터를 찾아내서 리스트에 추가해줍니다.


자막 데이터를 출력할 때는 ScriptInfo에서 이름, 자막 키 값, 실행해야 하는 함수 데이터 등을 가지고 와서 UI에 세팅해줍니다.
대사 데이터는 Start()에서 미리 XML을 로드해온 다음 키 값으로 데이터를 찾아서 가져옵니다.

SetScript()에서는 UI 설정과 Addressable 에셋을 로드하고,
preDelay만큼 기다린 후 ShowScript()를 호출해서 자막을 보여주고 음성을 재생합니다.
오디오 클립의 재생이 끝나면 HideScript()를 호출해서 자막을 가리고, 로드한 Addressable 에셋을 Release합니다.




public class MissionManager : MonoBehaviour
{
    [Header("Game Properties")]
    [SerializeField]
    int timeLimit;

    [Header("Common Scripts")]
    [SerializeField]
    List<string> onMissionStartScripts;
    [SerializeField]
    List<string> onMissionAccomplishedScripts;
    
    public void InvokeMethod(string methodName, float delay)
    {
        Invoke(methodName, delay);
    }

    void Start()
    {
        GameManager.UIController.SetRemainTime(timeLimit);
        GameManager.ScriptManager.AddScript(onMissionStartScripts);
    }
}

미션에 대한 정보는 MissionManager에서 관리하도록 수정했습니다.
미션 제한시간, 시작 시 출력할 자막, 미션 성공 시 출력할 자막 데이터의 키 값을 가집니다.

페이즈가 있는 미션이든, 없는 미션이든 꼭 가지고 있어야만 하는 데이터들을 가지고 있습니다.

여기서 InvokeMethod()라는 함수는 ScriptManager에서 ScriptInfo를 분석할 때 실행되어야 하는 함수가 있으면 호출되는 함수입니다.

그냥 Invoke()ScriptManager에서 호출할 수 있도록 만들어놓은 함수입니다.



MissionZERO.cs

public class MissionZERO : MissionManager
{
    int phase = 1;

    [Header("Phase System")]
    [Header("Phase 1")]
    [SerializeField]
    List<string> onPhase1StartScripts;
    [SerializeField]
    List<string> onPhase1EndScripts;
    
    [Header("Phase 2")]
    [SerializeField]
    UnityEvent onPhase2StartEvents;
    [SerializeField]
    List<string> onPhase2StartScripts;
    [SerializeField]
    List<string> onPhase2EndScripts;

    [Header("Phase 3")]
    [SerializeField]
    UnityEvent onPhase3StartEvents;
    [SerializeField]
    List<string> onPhase3StartScripts;
    [SerializeField]
    List<string> onPhase3EndScripts;

    [Space(10)]
    [SerializeField]
    PixyScript pixy;


    // Start is called before the first frame update
    public void OnPhaseEnd()
    {
        switch(phase)
        {
            case 1:
                GameManager.ScriptManager.AddScript(onPhase1EndScripts);
                break;

            case 2:
                GameManager.ScriptManager.AddScript(onPhase2EndScripts);
                break;

            case 3:
                GameManager.ScriptManager.AddScript(onPhase3EndScripts);
                break;
        }

        ++phase;
    }

    public void OnPhaseStart()
    {
        switch(phase)
        {
            case 1:
                GameManager.ScriptManager.AddScript(onPhase1StartScripts);
                break;

            case 2:
                GameManager.ScriptManager.AddScript(onPhase2StartScripts);
                onPhase2StartEvents.Invoke();
                break;

            case 3:
                GameManager.ScriptManager.AddScript(onPhase3StartScripts);
                onPhase3StartEvents.Invoke();
                break;
        }
    }

    public void Phase1Start()
    {
        Debug.Log("Phase 1 Start");
        OnPhaseStart();
    }
}

미션마다 페이즈나 연출이 다르기 때문에, 각 미션마다의 스크립트가 필요할 것입니다.
MissionManager를 상속받는, 이 미션만을 위한 MissionZERO라는 스크립트를 만들었습니다.

총 3개의 페이즈가 있고, 각 페이즈가 끝날 때, 시작할 때 추가되어야 할 스크립트 정보와 함수를 추가했습니다.


지금 만드는 미션은 게임 시작 시 특정한 함수를 호출해야 할 필요는 없지만,
기능 테스트용으로 일단 함수를 만들어봤습니다.

여기에 적어놓은 게 제대로 실행되는지 확인하고 싶었거든요.

*invokeFunction...을 invokeMethod... 로 이름을 변경했습니다.

Phase1Start()라는 함수는 ScriptManager에서 문자열을 인식한 다음 MissionManager.InvokeMethod()를 거쳐 호출될 겁니다.


ScriptManager, MissionManager를 추가하고 설정을 진행합니다.

최초 실행 시 A1_1, A1_2, P1_1, A1_3 데이터를 순서대로 호출시켜 보겠습니다.

JSON 파일에 따르면, 최초 실행 후 2초가 지나면 A1_1 자막 데이터를 UI에 표시해줘야 합니다.
A1_1 자막이 표시된 후 1초 (invokeMethodDelay)가 지나면 MissionZERO.Phase1Start() 함수가 호출되어야 하고요.

그리고 오디오 재생이 모두 끝나면, 0.5초 또는 미리 정해진 preDelay만큼 기다렸다가 다음 자막 데이터를 차례대로 표시되어야 합니다.


게임 시작 시 잘 표시되고 있고, 오디오 재생도 잘 되고 있습니다.

그리고 A1_1 자막이 표시된 후 1초 후에 Phase1Start() 함수도 제대로 호출되고 있습니다.

계속 기다리면 이렇게 4개의 자막이 순서대로 모두 표시되는 것을 확인할 수 있습니다.



미션 실패 시 대사 끼어들기

대사를 말하는 도중에 플레이어가 사망했거나 미션에 실패할 경우,

이렇게 AWACS의 확인사살 대사가 끼어든다고 했었죠.
이 대사도 미션마다 다 다르고, 대사를 여러 개 말할 수도 있습니다.

에이스 컴뱃 7의 경우에는 AWACS가 달라지기도 하고요.
일단은 사망 시 대사를 추가해보겠습니다.



MissionManager.cs

[SerializeField]
List<string> onMissionFailedScripts;
[SerializeField]
List<string> onDeadScripts;

public virtual void OnGameOver(bool isDead)
{
    GameManager.ScriptManager.ClearScriptQueue();
    
    if(isDead)
    {
        int index = UnityEngine.Random.Range(0, onDeadScripts.Count);
        GameManager.ScriptManager.AddScript(onDeadScripts[index]);
    }
    else
    {
        GameManager.ScriptManager.AddScript(onMissionFailedScripts);
    }
}

모든 미션은 사망 시 대사, 또는 미션 실패 시 대사가 존재합니다.
상속이 가능하게끔 MissionManager에서 virtual로 구현합니다.

일단 OnGameOver() 함수 내부로 들어갔으면 현재 대기중인 자막들은 표시될 필요가 없기 때문에, ScriptManager.ClearScriptQueue()를 실행합니다.

사망 시 onDeadScripts, 단순 미션 실패 시 onMissionFailedScripts를 추가합니다.
이 때 사망 시 대사는 보통 여러 개의 대사 중 하나를 랜덤으로 출력하고,
미션 실패 시 대사는 여러 개의 대사를 차례대로 출력합니다.

그래서 매개변수로 넘겨주는 bool isDead에 따라서 처리 방식이 갈리게 됩니다.


GameManager.cs

public void GameOver(bool isDead, bool isInstantDeath = false)
{
    ...
    scriptManager.ClearScriptQueue();
    missionManager.OnGameOver(isDead);
}

미션 실패/사망 시 호출되는 함수는 이미 GameManager에 있습니다.
여기에 ScriptManager.ClearScriptQueue()를 호출해서 현재 예약된 대사를 모두 제거한 후,
MissionManager.OnGameOver(isDead)를 호출하게 해주면 됩니다.


사망 시 호출할 스크립트를 추가합니다. 둘 중 하나 랜덤으로 하나 뽑아서 표시됩니다.


이렇게 원래 예정되었던 대사 대신 사망 전용 대사가 표시됩니다.

(엄밀히 따지면 격추는 아닙니다만)



페이즈 2 구현

지금은 미션 시작 (페이즈 1 시작) 시에 해당하는 대사만 출력되고 있습니다.

페이즈 1의 체력을 모두 깎으면, 대사 몇 개를 출력하고 페이즈 2가 시작되어야 합니다.
그리고 페이즈 2 진행 동안에 무작위 대사가 몇 개 실행되어야 하죠.


페이즈 1 종료, 페이즈 2 시작

픽시의 페이즈가 종료될 때마다 함수를 실행하게끔 이전에 Phase?EndEvents 를 만들어놨었죠.
이 기능과 이번에 만들어진 자막 데이터 내 함수 실행 기능을 활용해서 페이즈를 넘겨보겠습니다.

MissionZERO.cs

public void Phase1Start()
{
    Debug.Log("Phase 1 Start");
    OnPhaseStart();
}

MissionZERO에서 출력을 시키기 위해 만든 Phase1Start() 함수를 삭제했습니다.


함수 호출 순서를 생각해보죠.

  1. 픽시의 체력이 깎일 경우 PixyScript.OnPhase1EndEvents() 호출 (자동)
  2. MissionZERO.OnPhaseEnd() 호출
  3. MissionZERO.OnPhaseStart() 호출

MissionZERO.OnPhaseEnd()OnPhase?EndScripts를 등록해주고,
OnPhaseStart() OnPhaseStart()OnPhase?StartEvents()를 호출하고MissionZERO.OnPhase?StartScripts를 등록합니다.

그러면 순서대로 호출하도록 작업해봅시다.


PixyScript.OnPhase1EndEvents()MissionZERO.OnPhaseEnd()를 등록합니다.
이 함수가 호출되면 OnPhase1EndScriptsScriptManager에 등록됩니다.

페이즈 1이 끝날 때 A2_1, A2_2 자막 데이터를 가져와서 실행해야 하는데,
A2_2 대사 출력이 시작된 지 2초 후에 미션 스크립트의 MissionZERO.OnPhaseStart() 함수를 호출하도록 자막 데이터에 invokeMethodName, invokeMethodDelay를 추가합니다.

MissionZERO.OnPhaseEnd()는 호출될 때마다 phase 값을 올리기 때문에 2가 된 상태고,
이 때 MissionZERO.OnPhaseStart()가 호출되면 OnPhase2StartEvents()를 호출하고MissionZERO.OnPhase2StartScripts가 등록됩니다.


PixyScript의 각 페이즈별 종료 이벤트를 설정합니다.

각 페이즈가 끝날 때 다음 페이즈에서 가동할 특수무기를 활성화시키는 이벤트가 들어있었는데,
그 이벤트들을 모두 제거하고, Phase1EndEventsMizzionZERO.OnPhaseEnd()를 등록합니다.

픽시의 체력이 소진되어 페이즈 1이 끝나면, TLS(레이저 무기)가 비활성화되고 MissionZERO.OnPhaseEnd()가 호출됩니다.


OnPhase1EndScripts에는 A2_1, A2_2를 등록합니다.
P2_1, P2_2는 페이즈 2가 시작된 후에 실행하게끔 만들기 위해, OnPhase2StartScripts에 등록되었습니다.

그리고 페이즈 2가 시작되는 타이밍에 2페이즈 특수무기를 활성화할 수 있도록 OnPhase2StartEvents()에 비활성화된 픽시와 2페이즈 특수무기를 활성화시키는 이벤트를 추가합니다.



또 다시 폰트 문제 해결

자막을 테스트하는데 자꾸 글자가 깨지는 현상이 발생하네요.
일반 Text로 출력할 때는 문제가 없는데 TextMeshPro로 출력할 때 문제가 생기는 듯 합니다.

나눔스퀘어 설명을 보면 전체 11,172자 전체가 아닌 완성형 2,350자만 지원한다고 나와 있습니다.
근데 "페"이즈 1 분석 "완"료면 완성형에 당연히 들어가야 하는 단어인데 말이죠...

https://blog.naver.com/cdw0424/221641217203

위 블로그에서 도움을 조금 받았습니다.

생성된 SDF 에셋 파일을 선택하고 Update Atlas Texture 버튼을 클릭하면,
현재 어떤 글자들에 대해 폰트 아틀라스가 생성되었는지 볼 수 있습니다.

Character Set 속성이 현재 Unicode Range (Hex)로 되어 있는데,
이 부분을 Custom Characters로 바꿔보면,

어떤 글자들을 아틀라스로 만들었는지 확인할 수 있습니다.
지금은 수정되었지만, 이전에는 완성형 글자 2,350자가 들어가지 않은 상태였습니다.

완성형 2,350자 파일 : https://phlm7th.tistory.com/45

여기서 파일을 받아서 Custom Character List에 넣어준 다음,
Generate Font Atlas 버튼을 눌러서 아틀라스 파일을 다시 생성합니다.

완성되었으면 Save를 눌러줍니다.

그런데 오히려 나오던 글자도 안 나오는 경우가 발생할 수 있는데요,

글자가 너무 많아서 1024x1024 아틀라스 파일에 담지 못한 경우일 수 있습니다.
Atlas Resolution을 크게 설정한 후 다시 생성-저장해봅시다.

이제 문제없이 출력되네요.




문제 해결하는 과정이 좀 길었네요. 아까 만든 페이즈 2 진입을 확인해봅시다.

테스트하다가 알게 된 건데, 그냥 헤드온 때 미사일이랑 특수무기를 다 퍼부어주면 알아서 체력이 다 깎이더군요.

체력을 약간 낮게 잡은 것도 있지만, 뭔가 조치를 취해야 할 것 같습니다.

아무튼... 대사 출력은 잘 되고 있습니다.

그리고 대사 도중에 OnPhase2StartEvents() 에 등록된 함수들이 호출되면서, 비활성화되었던 픽시가 다시 활성화되고 2페이즈 특수무기 MPBM도 활용하는 것을 확인할 수 있습니다.


페이즈 2 랜덤 대사 출력

에이스 컴뱃 제로에서는 에이스 스타일이라는 시스템이 존재합니다.

무력화된 적이나 중립 목표물처럼 더 이상 플레이어에게 해가 되지 않는 목표물을 그냥 놔뒀느냐, 아니면 무차별적으로 파괴했느냐에 따라서 미션 내에서 마주치는 적군 편대와 대사가 달라집니다.

다시 말해 이전 행적에 따라서 현재, 그리고 앞으로의 미션 진행이 약간씩 달라진다는 거죠.


이 미션에서도 그 에이스 스타일에 따른 대사가 있고, 3가지 대사 중 하나를 띄워야 하는데,
이 프로젝트는 단 하나의 미션만을 구현하는 게 목적이라 플레이어의 이전 행적 자체가 없습니다.

그래서 그 3가지 대사는 그냥 다 출력하는 방식으로 바꾸겠습니다.



MissionZERO.cs

[SerializeField]
List<string> phase2Scripts;

Queue<string> currentScriptQueue;

public static void Shuffle<T>(ref List<T> list)  
{
    if(list.Count <= 1) return;

    int n = list.Count;
    while(n > 0)
    {
        int i = Random.Range(0, --n);
        T temp = list[n];
        list[n] = list[i];
        list[i] = temp;
    }
}

public void AddPhase2Scripts()
{
    if(phase2Scripts.Count > 0)
    {
        Shuffle<string>(ref phase2Scripts);
        currentScriptQueue = new Queue<string>(phase2Scripts);
        Invoke("PrintPhase2Script", Random.Range(5, 10));
    }
}

public void PrintPhase2Script()
{
    GameManager.ScriptManager.AddScript(currentScriptQueue.Dequeue());
    
    if(currentScriptQueue.Count > 0)
    {
        Invoke("PrintPhase2Script", Random.Range(5, 10));
    }
}

페이즈 2에서 출력해야 할 스크립트들은 phase2Scripts에 추가합니다.
List 내부 데이터를 섞는 Shuffle<>() 코드를 이용해서 phase2Scripts의 출력 순서를 섞고,

PrintPhase2Script() 함수를 Invoke로 호출을 예약하되, 그 딜레이는 무작위로 설정합니다.
(빠른 테스트를 위해 5 ~ 10초 사이로 설정했습니다.)


OnPhase2StartEvents()AddPhase2Script()를 등록하고,

phase2Scripts에 출력할 대사를 몽땅 넣어줬습니다.


이제 페이즈 2에 진입하고 안 죽이면서 질질 끌어보면 대사가 나오겠죠?


정말 말이 많아졌네요.

실제 딜레이는 각 대사마다 30 ~ 60초 사이의 딜레이를 가지도록 구현했습니다.



페이즈 3 구현

2페이즈 픽시의 체력을 모두 깎으면, 대사 하나를 읊고 컷씬 연출로 넘어갑니다.
하지만 아직 컷씬이 준비되지 않았기 때문에, 컷씬을 생략하고 바로 넘어가게 만들어보겠습니다.

(컷씬은 다음 포스트에서 다룰 예정입니다.)


컷씬이 끝난 후에는 플레이어와 픽시의 위치가 재설정되고, 미션이 업데이트됩니다.

원본 게임에서는 "Mission Start"가 표시되지만, 미션을 새로 시작한다기보다는 미션 목표가 갱신되는 것에 가까우므로 "Mission Updated"를 표시하면서 효과음을 출력하도록 바꾸겠습니다.


MissionZERO.cs

[SerializeField]
Transform phase3PixyTransform;
Transform phase3CipherTransform;

public void SetPhase3Position()
{
    GameManager.UIController.SetLabel(AlertUIController.LabelEnum.MissionUpdated);

    GameManager.AircraftController.transform.SetPositionAndRotation(
        phase3CipherTransform.position, phase3CipherTransform.rotation);
    pixy.transform.SetPositionAndRotation(
        phase3PixyTransform.position, phase3PixyTransform.rotation);
}

Mission Updated 라벨을 표시하고, 두 비행기의 위치를 재설정하는 코드를 작성합니다.


PixyScript.Phase2EndEvents에는 MissionZERO.OnPhaseEnd()를 등록합니다.
페이즈가 2에서 3으로 넘어가기 때문에 OnPhase2EndScripts에 있는 대사가 출력될 겁니다.

그리고 MissionZERO.CancelInvoke()도 등록을 해야 하는데, 아까 만들었던 2페이즈 대사 출력 함수가 Invoke로 인해 아직 호출 대기중인 상태일 수 있기 때문입니다.

2페이즈 대사가 3페이즈를 넘어간 이후에 출력되어서는 안 됩니다. 모두 지워버려야 하죠.

미리 페이즈 3 종료 연출도 추가하죠. Phase3EndEventsMissionZERO.OnPhaseEnd를 등록합니다.


OnPhase2EndScripts에는 P3_0을 등록하고,

JSON 데이터에서 P3_0가 출력된 후 2.5초가 지나면 MissionZERO.OnPhaseStart() 함수를 호출하도록 데이터를 추가합니다.

페이즈가 3이 되었기 때문에 OnPhase3StartEvents가 호출되고, OnPhase3StartScripts의 대사가 출력될 겁니다.


위치 조정 함수, 픽시와 3페이즈 특수무기 활성화 함수를 OnPhase3StartEvents()에 등록하고,

페이즈 3 시작, 종료 시 출력할 대사를 등록합니다.

그리고 플레이어와 픽시가 놓일 위치를 지정해주기 위해 빈 GameObject를 생성하고, MissionZERO 스크립트에 두 위치를 등록합니다.


이제 3페이즈에 들어가봅시다.
조건은 이전과 같이 2페이즈에서 신나게 두들겨 패면 됩니다.

순식간에 페이즈 3로 넘어갔고...

대사 출력도 모두 확인했습니다.

연출상에 에러가 하나 있는데, 3페이즈가 시작될 때 픽시는 플레이어를 향해 달려와야 합니다.
강제로 경로를 세팅해줘야겠네요.


AircraftAI.cs

public void ForceChangeWaypoint(Vector3 waypoint)
{
    currentWaypoint = waypoint;
}

오랜만에 열어보는 AircraftAI 스크립트네요.
여기다가 강제로 현재 목표 지점을 바꿔버리는 코드를 작성하고,

MissionZERO.cs

public void SetPhase3Position()
{
    ...
    pixy.ForceChangeWaypoint(phase3CipherTransform.position);
}

MissionZERO.SetPhase3Position()에 목표 지점 변경 코드를 추가합니다.


이제 3페이즈에 진입하는 순간 플레이어가 있었던 위치를 향해 달려들게 됩니다.



타이머 시스템

다시 원본 스크린샷을 보면,

페이즈 3에 진입하면 전용 타이머가 등장하며, 제한시간은 난이도에 따라 달라집니다.
일정 시간이 지날 때마다 "몇 분 남았다!"라는 AWACS의 경고와 도발하는 적의 대사가 출력되죠.

상황 설명을 드리면, 뒤에 보이는 V2 미사일이 대기권에 재돌입하기 전에 픽시를 격추해야 합니다.
타이머에 나오는 시간은 미사일이 재돌입하기까지 남은 시간을 뜻합니다.


에이스 컴뱃 7도 타이머가 등장하는 미션이 몇 개 있습니다.

차이점으로는 에이스 컴뱃 제로는 기존 타이머 UI가 숨겨지는 반면,
에이스 컴뱃 7은 숨겨지지 않고 계속 보여진다는 점입니다.

지금까지 계속 에이스 컴뱃 7의 UI에 맞춰서 만들고 있었기 때문에, 전용 타이머도 에이스 컴뱃 7에 맞춰서 만들겠습니다.


남은 시간이 4분 미만인데 저렇게 타이머가 뜨면 어떻게 될 지 모르겠는데, 그냥 같이 맞춰버리죠.


RedTimer.cs


[System.Serializable]
class TimeScript
{
    public int time;
    public string scriptKey;
}

public class RedTimer : MonoBehaviour
{
    [SerializeField]
    TextMeshProUGUI timeText;

    [SerializeField]
    List<TimeScript> remainTimeScripts;
    List<string> removableScriptKeys;

    [SerializeField]
    AudioClip beepSingleClip;
    [SerializeField]
    AudioClip beepDoubleClip;
    AudioSource audioSource;

    float remainTime;
    bool isTimeLow;

    public int RemainTime
    {
        set
        {
            GameManager.UIController.SetRemainTime(value);
            remainTime = value;
        }
    }

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

        if(isTimeLow == false && remainTime <= 31)
        {
            isTimeLow = true;
            InvokeRepeating("PlayTimeLowAudioClip", 0, 1);
        }
        
        if(remainTime <= 0)
        {
            GameManager.Instance.GameOver(false);
            remainTime = 0;
        }
        CheckTimeScripts();

        int seconds = (int)remainTime;

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

    void PlayTimeLowAudioClip()
    {
        if(GameManager.Instance.IsGameOver == true)
        {
            CancelInvoke();
            return;
        }
        
        AudioClip audioClip = (remainTime > 10) ? beepSingleClip : beepDoubleClip;
        audioSource.PlayOneShot(audioClip);
    }


    void CheckTimeScripts()
    {
        foreach(TimeScript timeScript in remainTimeScripts)
        {
            if(remainTime < timeScript.time)
            {
                GameManager.ScriptManager.AddScript(timeScript.scriptKey);
                removableScriptKeys.Add(timeScript.scriptKey);
            }
        }

        if(removableScriptKeys.Count > 0)
        {
            remainTimeScripts.RemoveAll(script => removableScriptKeys.Contains(script.scriptKey));
            removableScriptKeys.Clear();
        }
    }

    void Awake()
    {
        audioSource = GetComponent<AudioSource>();
        removableScriptKeys = new List<string>();
    }

    void OnEnable()
    {
        GameManager.UIController.IsRedTimerActive = true;
        isTimeLow = false;

        // Remove timed out scripts
        remainTimeScripts.RemoveAll(script => script.time >= remainTime);
    }

    void OnDisable()
    {
        CancelInvoke();
    }

    // Update is called once per frame
    void Update()
    {
        if(remainTime > 0) SetTime();
    }
}

대부분의 코드는 UIController의 타이머 부분에서 가져왔습니다.
거기에다가 특정 시간만큼 남으면 그 시간에 해당하는 대사 데이터를 ScriptManager에 등록해주는 기능을 가지고 있죠.

TimeScript 클래스는 시간과 자막 데이터의 키 값을 가지고, Inspector 창에서 이 클래스를 리스트로 작성해서 시간과 대사 데이터를 묶어놓습니다.

매 프레임마다 CheckTimeScripts()를 실행해서 TimeScript 리스트를 검사하고, 작성해놓은 시간 미만으로 남은 시간이 줄어들면 해당 대사 데이터를 ScriptManager에 등록합니다.

만약 남은 시간 5분에 출력되어야 하는 대사가 있는데 타이머가 4분부터 시작될 경우 그 대사는 출력되면 안 됩니다.
그 때를 대비해서 타이머가 활성화될 때 남은 시간보다 더 이전에 출력되었어야 하는 대사는 모두 삭제하는 코드를 추가했습니다.


UI를 만들어놓고,

RedTimer 스크립트를 붙인 후에 시간(초 단위), 자막 키 값을 이렇게 작성합니다.


MissionZERO.cs

[SerializeField]
RedTimer redTimer;

[SerializeField]
int phase3TimeLimit;

public void SetTimer()
{
    redTimer.RemainTime = phase3TimeLimit;
    redTimer.gameObject.SetActive(true);
}

미션 스크립트에서는 타이머의 제한시간을 설정하고 작동시키는 함수를 추가합니다.

onPhase3StartEventsSetTimer()를 등록하고, 타이머 스크립트를 연결한 후 제한시간을 설정합니다.


PixyScript에서는 페이즈 3가 끝나면 타이머를 비활성화시키도록 이벤트를 등록합시다.

이제 다시 3페이즈로 들어가기 위한 노동을 합시다.


3페이즈에 진입하면 붉은색 타이머가 표시되고, 왼쪽 위의 타이머도 시간이 동일하게 맞춰집니다.

(*실제 에이스 컴뱃 7에서는 왼쪽 위 타이머는 변경 없이 그대로 진행됩니다.)

남은 시간에 따른 대사 출력도 정상적으로 되는지 확인해봅니다.



남은 체력에 따른 대사 출력

3페이즈에서는 픽시의 체력이 매우 낮게 떨어지면 대사를 하나 던집니다.

"쏴봐, 겁쟁아!"

"나한테 미사일 한 발만 더 맞히면 네가 이긴다" 라는 뜻입니다.

친절하기도 하죠.


물론, 이 말을 듣고 내가 격추당하면 별로 기분이 좋지는 않을 겁니다.


PixyScript.cs

[SerializeField]
int phase3LowHPScriptThreshold;
[SerializeField]
string phase3LowHPScript;
bool hasPrintedLowHPScript = false;
    
public override void OnDamage(float damage, int layer, string tag = "")
{
    ...
    if(hasPrintedLowHPScript == false && phase == 3 && hp <= phase3LowHPScriptThreshold)
    {
        hasPrintedLowHPScript = true;
        GameManager.ScriptManager.AddScript(phase3LowHPScript);
    }
}

PixyScript.OnDamage()에 조건식을 하나 추가합니다.
3페이즈 상태면서 phase3LowHPScriptThreshold 이하로 체력이 떨어지면 ScriptManagerphase3LowHPScript를 등록합니다.

이 대사가 여러 번 출력되면 안 되므로, 이미 출력했는지 확인하는 bool형 변수도 추가합니다.

값 설정을 끝내고, 페이즈 3에 진입해서 겁쟁이 소리를 들으러 갑시다.


체력을 설정한 값 이하로 만들어주면, 빨리 격추시켜달라고 애원하는 모습을 볼 수 있습니다.



미션 실패 대사

페이즈 1, 2, 3의 미션 실패 조건 모두 제한시간 내에 격추시키지 못하는 경우입니다.

하지만 상황 내부를 보면 차이점이 있는데, 페이즈 1, 2는 미사일이 발사되기 전이고, 페이즈 3은 미사일이 발사된 이후라는 겁니다.

그래서 페이즈 1, 2에서 실패하면 단순한 시간 초과로 인한 미션 실패라는 말을 하지만,
페이즈 3에서 시간 초과로 미션 실패 시 그 상황에 맞는 대사가 출력됩니다.

"The V2 re-entry has started! You're too late."
"V2의 대기권 재돌입이 시작됐다! 너무 늦었어."

페이즈 1, 2에서의 실패 대사와 페이즈 3의 실패 대사를 구분해봅시다.

실제 플레이 영상을 봤는데, 페이즈 1/2에서는 실패해도 대사가 없더군요.
하지만 저는 만들어주겠습니다.

MissionZERO.cs

[SerializeField]
List<string> onPhase3FailScripts;

public override void OnGameOver(bool isDead)
{
    if(phase == 3 && isDead == false)
    {
        GameManager.ScriptManager.AddScript(onPhase3FailScripts);
    }
    else
    {
        base.OnGameOver(isDead);
    }
}

미션 스크립트에서 게임 오버 시에 실행하는 OnGameOver()virtual로 만들어놨었죠.
이걸 오버라이딩해서, 3페이즈에서 사망으로 인한 게임 오버가 아니면 시간 초과로 인한 게임오버니 onPhase3FailScripts를 등록하고,
그 외의 경우에는 부모 스크립트의 OnGameOver()를 호출하게 만들겠습니다.

대사 및 자막 스크립트를 손봐주고,

미션 스크립트에 대사를 추가한 다음 테스트를 해봅니다.


페이즈 1에서 실패했을 때입니다.
원래는 대사가 없지만 리소스를 활용해서 제가 임의로 추가했습니다.

페이즈 3에서 실패했을 때입니다. 페이즈 1에서와는 다른 대사가 출력됩니다.



AWACS 중요 메시지 전달 효과

가끔씩 AWACS가 플레이어가 꼭 알아야 하는 정보를 전달할 때,
대사가 출력되기 전에 효과음이 재생된 후 함께 이름 양 옆에 느낌표 표시가 깜빡입니다.

(에이스 컴뱃 7 UI 기준)

에이스 컴뱃 제로에서는 강조되어야 하는 대사 전체 색상이 괄호와 동일한 색상으로 바뀝니다.

이걸 표현하기 위해 JSON 파일에도 "isImportant"라는 속성을 넣어뒀죠.
이 값이 true면 대사를 강조하는 기능을 추가해보겠습니다.


느낌표 표시가 없으니 하나 만들어주죠.

RawImage를 만들어서 위치를 설정해줍니다.

사실 이 느낌표의 위치는 말하는 사람의 글씨 바로 옆에 위치해 있어야 하기 때문에,
AWACS가 아닌 다른 등장인물이 대사를 말할 때는 너비에 맞게 위치가 바뀌어야 합니다.

하지만 AWACS가 아닌 대상이 중요 대사를 말하는 일은 에이스 컴뱃 7 기준으로는 없고,
에이스 컴뱃 제로에는 가끔씩 있지만 적군 대사까지 중요 대사 연출을 재생하기에는 이상하다고 판단되어 AWACS 기준으로 위치를 고정하도록 하겠습니다.

죄송합니다만 제 프로젝트에 붉은색 It's time. 은 없습니다.

혹시나 해서 만들어봤는데 개인적으로는 별로네요.


WarningSignController.cs

public class WarningSignController : MonoBehaviour
{
    [SerializeField]
    GameObject warningSign;
    [SerializeField]
    float repeatTime = 0.3f;

    void OnEnable()
    {
        warningSign.SetActive(true);
        InvokeRepeating("Blink", repeatTime, repeatTime);
    }

    void OnDisable()
    {
        warningSign.SetActive(false);
        CancelInvoke("Blink");
    }

    void Blink()
    {
        warningSign.SetActive(!warningSign.activeSelf);
    }
}

enable될 때마다 키고, 설정한 주기만큼 깜빡이다가 disable되면 알아서 끄는 코드를 만들었습니다.


ScriptManager.cs

[SerializeField]
WarningSignController warningSignController;

[Header("Audio")]
[SerializeField]
AudioSource scriptAudioSource;
[SerializeField]
AudioSource transmissionAudioSource;

[SerializeField]
AudioClip transmissionAudioClip;


void SetScript()
{
    ...
    
    if(currentScript.isImportant == true)
    {
        Invoke("PlayTransmissionAudio", currentScript.preDelay);
    }
    else
    {
        Invoke("ShowScript", currentScript.preDelay);
    }
    
}

void PlayTransmissionAudio()
{
    transmissionAudioSource.Play();
    Invoke("ShowScript", transmissionAudioSource.clip.length);
}

void ShowScript()
{
    scriptUI.SetActive(true);
    if(currentScript.isImportant == true)
    {
        warningSignController.enabled = true;
    }
    ...
}


void HideScript()
{
    warningSignController.enabled = false;
    ...
}

ScriptManager.SetScript()에서는 현재 ScriptInfoisImportant 값이 true면 오디오 클립을 하나 재생한 후에 ShowScript()를 호출하게끔 만듭니다.
ShowScript() 내부에도 isImportant 값에 따라 느낌표 표시를 활성화시키는 코드를 추가합니다.
HideScript()에서는 느낌표 표시를 항상 비활성화시키고요.

기존에 있는 audioSource를 이용해서 효과음을 출력하려고 했는데, 이 효과음이 출력되는 시점에는 대사 전용 audioSource가 비활성화된 상태라서, audioSource를 하나 더 만들어서 연결하겠습니다.


느낌표 표시 스크립트를 추가하고,

ScriptManager에도 스크립트를 연결하고 오디오를 추가합니다.


첫 대사부터 중요 메시지 속성이 붙었기 때문에, 효과음 출력 이후에 느낌표가 깜빡입니다.
그 외의 대사는 중요 효과가 표시되지 않습니다.

중요 메시지로 표시되어야 하는몇 가지 상황도 같이 테스트를 해봤습니다.



Addressable 에셋을 빌드에 포함시키기

Addressable의 Build Script 속성을 건드리지 않았다면, 기본 상태로는 Addressable 에셋이 빌드에 포함되지 않습니다.

이 상태로 빌드하면, Addressable로 지정했던 음성 파일들과 초상화가 로드되지 않습니다.

https://docs.unity3d.com/Packages/com.unity.addressables@1.3/manual/AddressableAssetsDevelopmentCycle.html

공식 문서에 따르면 세 가지 Build Script를 지원한다고 합니다.

  1. Use Asset Database (faster) : 게임을 에디터 상에서 빠르게 테스트할 때 적합한 방식입니다. 즉시 에셋 데이터베이스에 Addressable 에셋을 등록하며, 어떠한 분석 과정이나 에셋 번들 생성도 거치지 않습니다.

  2. Simulate Groups (advanced) : 레이아웃 및 종속성에 대한 분석 과정은 거치지만 에셋 번들은 생성하지 않습니다. 에셋 로드 전략을 시뮬레이션하고 콘텐츠 그룹을 조정해서 릴리즈에 적합한 균형을 찾는 데에 도움이 됩니다.

  3. Use Existing Build (require built groups) : 애플리케이션 빌드와 유사하며 에셋 데이터를 별도로 빌드해야 합니다. 에셋을 수정하지 않는 경우, Play 모드에 들어갈 때 따로 데이터를 에셋 데이터베이스에 등록하는 과정이 없으므로 가장 빠르게 처리됩니다.

(x는 불가능이 아니라 가능하다는 뜻입니다.)

페이지에 첨부된 표의 Test/Play 항목을 보면, 1번과 2번은 에디터 상에서만 실행되며 3번은 에디터와 빌드 모두 실행이 가능하다고 나와 있습니다.
그러면 항상 3번 쓰면 되는 거 아닌가? 할 수 있지만, 에셋을 수정하거나 등록할 때마다 빌드 과정을 거쳐야 합니다.
더 이상 건들 필요가 없다면 3번으로 설정하고 테스트해도 무방합니다.


기본 설정은 Use Asset Database입니다. 빌드할 때 에셋이 로드되지 않죠.

Window - Asset Management - Addressables - Groups에 들어가면,

Addressable Groups라는 창을 띄울 수 있는데,
위쪽에 Play Mode Script라는 항목이 있습니다.

Use Existing Build로 바꿔주고,

이 모드에서는 에셋이 빌드되어야 하기 때문에 바로 옆의 Build 버튼을 눌러서 New Build - Default Build Script를 눌러줍니다.

개발자가 별도의 빌드 스크립트를 만들어서 Addressable 에셋 빌드를 실행할 수도 있습니다.
그 때는 AddressableAssetSettings.BuildPlayerContent()를 실행해야 합니다.

그룹을 선택하면 Inspector 창에서 설정을 조정할 수 있는데, 딱히 건드릴 필요는 없습니다.


Play Mode Script를 변경하고, Addressable 에셋들에 대한 빌드 과정을 거치면 비로소 빌드된 결과물에서 에셋이 로드되는 것을 확인할 수 있습니다.




이제 컷씬 연출만 남았습니다. 다시 시네머신을 다룰 준비를 해야겠네요.

물론 애니메이션, 알고리즘, 이펙트 등 손 볼 것들이 아직 많지만,
그래도 큰 틀은 잡힌거나 다름없습니다.



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

0개의 댓글