탑건: 매버릭 미션 개발하기 #11 : 플레어/영역 제한/난이도 조절

Lunetis·2022년 8월 19일
0

탑건: 매버릭

목록 보기
13/14
post-thumbnail

플레어

2페이즈에서 지대공 미사일 세례를 받으면서, 플레이어에게 날아오는 미사일을 모두 무효화시켜주는 플레어 기능은 꼭 넣어야겠다고 생각했습니다. (영화에서 나오기도 하고요)
개발 후순위로 계속 미루다가 이제야 넣게 됐네요.



미사일이 사라지는 마술 구현

일단은 기능부터 만듭시다.


GameManager

public void DisableAllMissiles()
{
    // Missiles
    foreach(Transform enemyMissiles in enemyMissileObjectPool.transform)
    {
        enemyMissiles.gameObject.SetActive(false);
    }
    foreach(Transform enemyMissiles in samMissileObjectPool.transform)
    {
        enemyMissiles.gameObject.SetActive(false);
    }
}

플레이어를 향해 날아오고 있든 날아오고 있지 않든 아무튼 활성화된 모든 적 미사일과 지대공 미사일들을 모두 비활성화시키는 코드입니다.

이게 왜 뜬금없이 GameManager 스크립트에 있냐면, 미션 성공/실패 시에도 써야 하거든요.
원래 쓰고 있었던 코드인데 살짝 다른 함수로 빼놓았을 뿐입니다.


FlareController

using UnityEngine.InputSystem;

public class FlareController : MonoBehaviour
{
    [SerializeField]
    int flareCnt;
    
    public void UseFlare(InputAction.CallbackContext context)
    {
        if(context.action.phase == InputActionPhase.Performed)
        {
            if(flareCnt == 0)
                return;

            GameManager.Instance.DisableAllMissiles();
            flareCnt--;
        }
    }
}

FlareController라는 코드를 새로 만들었습니다. 플레어 발사 함수는 InputSystem에서 호출되기 때문에 매개변수로 InputAction.CallbackContext context를 가집니다.

context.action.phase가 가질 수 있는 값들 중에는 여러 가지가 있는데, 짤막하게 말하자면 버튼을 눌렀을 때, 일정 시간 이상 눌렀을 때, 버튼에서 손을 떼었을 때 등이 있습니다.

위 코드에서는 Performed 상태에서만 실행하도록 제약을 걸었는데, 만약 이 if문이 없다면 이 함수는 아까 말한 모든 상황에서 다 실행됩니다. 그러니까 버튼을 눌렀다 떼는 동안 3번이나 플레어가 발사된다는 것이죠. 그걸 막기 위해서 if문을 한 번 사용해야 합니다.


이제 새 입력을 추가해보겠습니다. Player Input 컴포넌트에 등록된 Actions 에셋을 열면,

이렇게 Input Actions 창이 뜹니다. 여기서 Actions에 Flare를 추가하고, 키 바인딩으로는 키보드의 'F'키와 게임패드의 오른쪽 스틱 클릭 (듀얼쇼크/듀얼센스 기준 L3)으로 두겠습니다.

원래 에이스 컴뱃 7에서 듀얼쇼크 4 기준 플레어 사출은 L3 + R3, 즉 두 스틱을 모두 누를 때 실행됩니다. 그런데 R3는 카메라 전환에 바인딩되어 있는데, L3는 딱히 바인딩된 기능이 없는데다가 굳이 두 버튼을 동시에 눌러야 하나 싶어서 L3에 바인딩했습니다.

제 생각에는 과격한 기동을 하느라 나도 모르게 왼쪽 스틱에 힘을 주다가 플레어가 발사되는 상황을 막기 위해 두 스틱을 동시에 누를 때 플레어가 사출되도록 만든 것 같습니다. 하지만 한편으로는 간단히 L3로만 둬도 충분해보이기도 하고요.


그리고 Events 에서 Flare 이벤트에 아까 만든 함수를 등록합니다.
FlareController 컴포넌트는 비행기에 붙여놨습니다.

플레어를 발사하는 족족 모든 미사일이 사라집니다.



UI 추가

에이스 컴뱃 7의 UI를 보시면, 특수무기와 손상도 사이에 FLR라고 적혀 있습니다. 이 부분이 남은 플레어의 개수를 나타냅니다.

이제 이 쪽도 추가합시다.


특수무기 텍스트 UI가 있던 곳에 텍스트를 복사하고 나머지를 위로 올린 다음,

이렇게 적어줍시다.

TextMeshPro의 태그를 활용하면 이렇게 각 단어마다 오른쪽/왼쪽에 붙여놓을 수 있습니다.


UIController

[SerializeField]
TextMeshProUGUI flrText;

public void SetFlareText(int flares)
{
    string text = string.Format("<align=left>FLR<line-height=0>\n<align=right><mspace=18>{0}</mspace><line-height=0>", flares);
    flrText.text = text;
}

모든 UI를 담당하는 UIController에는 플레어 UI 텍스트 변수를 추가하고, 텍스트 내용을 바꿔주는 함수를 추가합니다.


FlareController

public void UseFlare(InputAction.CallbackContext context)
{
    if(context.action.phase == InputActionPhase.Performed)
    {
        ...
        GameManager.UIController.SetFlareText(--flareCnt);
    }
}

void Start()
{
    GameManager.UIController.SetFlareText(flareCnt);
}

FlareController에는 시작 시, 플레어 사출 시 UI의 텍스트를 갱신하도록 함수를 호출합니다.

마지막으로 텍스트를 컴포넌트에 등록해주면 끝납니다.

UI도 간단하게 추가가 끝났습니다.


이제 가장 큰 문제가 남았는데...



이펙트 구현

https://youtube.com/shorts/8Iw-Gzryb_A?feature=share

전투기의 플레어는 이렇게 발사됩니다.

음... 미사일 이펙트를 재탕할까요?

이전 프로젝트에서 만들었던 것 중에 산탄 미사일이라는 게 있었는데,

크기를 줄인 다음에 연기 이펙트를 붙여서 만들어보겠습니다.


FlareController

[SerializeField]
GameObject flareEffectPrefab;

[SerializeField]
int effectCnt = 5;

[SerializeField]
float effectCreateDelay = 0.3f;

[SerializeField]
Rigidbody aircraftRigidbody;

public void UseFlare(InputAction.CallbackContext context)
{
    if(context.action.phase == InputActionPhase.Performed)
    {
        ...

        StartCoroutine(CreateFlareEffect());
    }
}

IEnumerator CreateFlareEffect()
{
    for(int i = 0; i < effectCnt; i++)
    {
        GameObject effect = Instantiate(flareEffectPrefab, transform.position, transform.rotation);
        Rigidbody effectRb = effect.GetComponent<Rigidbody>();
        effectRb.velocity = (aircraftRigidbody.velocity * 0.8f) + 
                            new Vector3(Random.Range(-2f, 2f), Random.Range(-2f, 2f) - 15, Random.Range(-2f, 2f));

        yield return new WaitForSeconds(effectCreateDelay);
    }
}

플레어를 정해진 횟수만큼 사출하는 함수를 IEnumerator로 만듭니다.
이펙트를 생성하고, 그 이펙트의 속력은 비행기의 속력을 어느 정도 따라가되, 약간의 랜덤값 + 아래쪽 벡터값을 주어서 마구잡이로 사출되고 땅으로 떨어지는 플레어를 표현해보겠습니다.


일단은 플레어가 나간다는 느낌까지는 표현에 성공했습니다.


[SerializeField]
AudioClip flareAudioClip;
AudioSource audioSource;

...

IEnumerator CreateFlareEffect()
{
    for(int i = 0; i < effectCnt; i++)
    {
        ...
        audioSource.PlayOneShot(flareAudioClip);

        yield return new WaitForSeconds(effectCreateDelay);
    }
}

void Start()
{
    audioSource = GetComponent<AudioSource>();
    ...
}

내친 김에 사운드도 넣죠.

이제 플레어가 사출될 때마다 효과음이 출력됩니다.



영역 제한

영역 표시 UI

제가 만든 맵은 이렇게 생겼습니다.

섬에서 어느 정도 벗어나도 수평선만 보일 수 있도록 물 부분을 굉장히 넓게 만들어놨지만, 그래도 한 쪽으로만 계속 비행하면 언젠가는 맵을 벗어날 수 있습니다.

에이스 컴뱃에는 영역 제한이 존재하며 (빨간색 사각형 부분) 영역을 벗어나면 자동으로 미션 실패 처리됩니다. 이 UI와 함께 영역을 벗어나면 미션 실패로 처리해봅시다.


일단 저 빨간색으로 표시될 영역부터 만들어보려고 하는데, 저 사각형은 미니맵 부분에서만 보여야 하고 일반 렌더링 카메라에 잡히면 안 됩니다.

Sprite Renderer를 사용해서 일단 영역을 표시해주는 사각형을 배치했습니다.

이 사각형은 Sliced 스프라이트라서, Sprite Renderer의 Size를 조절할 때 두께를 그대로 유지시킬 수 있습니다. 사각형의 두께를 늘리려면 Size를 줄이고 Scale을 늘리면 됩니다.

하지만 이대로 넣기에는 또 애매한데,

미니맵에서 표시되는 넓이가 좁을수록 경계선이 두꺼워진다는 문제가 있습니다.

Size * Scale 값만 일정하면 되니까, 미니맵 상태에 따라 크기를 조정해주는 코드를 작성해야겠습니다.


MinimapBorder

public class MinimapBorder : MonoBehaviour
{
    [SerializeField]
    float width;
    [SerializeField]
    float height;

    [SerializeField]
    float borderThickness = 10;

    SpriteRenderer spriteRenderer;

    // Start is called before the first frame update
    void Awake()
    {
        spriteRenderer = GetComponent<SpriteRenderer>();
        transform.position = Vector3.zero;
    }

    public void SetMinimapBorderSize(float cameraViewWidth)
    {
        // 100000f: Just a base value
        float size =  100000f / cameraViewWidth / borderThickness;
        float scale = width / size;
        transform.localScale = new Vector3(scale, scale, scale);
        spriteRenderer.size = new Vector3(width, height) / scale;
    }
}

카메라가 어떻게 보여주든 경계선의 두께가 일정하도록 만들겠습니다.

카메라가 넓게 보여줄수록, 설정하려는 두께값이 클수록 Sprite Renderer의 size 값은 작아야 하므로 어떤 큰 값에서 두 값을 나누는 방식으로 size를 계산합니다.
그리고 설정하려는 사각형의 너비와 높이, 경계선의 두께를 설정하면 나머지는 알아서 계산해줍니다.


MinimapController

public MinimapBorder minimapBorder;

public void SetCamera()
{
    ...
    minimapBorder.SetMinimapBorderSize(minimapCamera.orthographicSize);
}

미니맵 표시 UI를 제어하는 스크립트에서는 경계선 UI를 설정하는 함수를 호출해줍니다.


설정할 영역이 24000 x 24000이니까, width와 height에 그 값을 적어주고 두께는 마음대로 설정해보겠습니다.


영역 표시 문제는 해결되었습니다.



밖으로 나갈 시 게임 오버시키기

경계선 근처까지 가게 될 경우 "CAUTION ALERT"라는 경고음과 함께 경고 UI가 깜빡이며,
경계선을 넘어가게 되면 미션 실패 처리됩니다.

경고 UI는 이미 만들어져 있는데, 지금 경고음을 "DESCEND"만 출력하도록 만들어놓았습니다.
이 부분부터 손을 보죠.


AlertUIController

public void SetCautionUI(bool enable, bool isDescend = false)
{
    if(caution.activeSelf == enable) return;
    
    caution.SetActive(enable);
    if(enable == true)
    {
        InvokeRepeating("BlinkAttackAlertUI", 0, warningBlinkTime);
        
        if(isDescend == true)
        {
            InvokeRepeating("PlayDescendVoiceAudio", 0, voiceAlertRepeatTime);
        }
        else
        {
            InvokeRepeating("PlayCautionVoiceAudio", 0, voiceAlertRepeatTime);
        }
        
    }
    else
    {
        CancelInvoke("BlinkAttackAlertUI");
        CancelInvoke("PlayCautionVoiceAudio");
        CancelInvoke("PlayDescendVoiceAudio");
    }
}

경고 UI를 표시할 때 하강하라는 경고음을 출력해야 하는지, 일반 경고음을 출력해야 하는지 선택할 수 있도록 매개변수를 추가하고, 매개변수에 따라 반복적으로 호출할 경고음 함수를 선택합니다.


MinimapBorder

[SerializeField]
float warningDistance = 500;
[SerializeField]
AlertUIController alertUIController;

bool isCautionEnabled;

// Start is called before the first frame update
void Awake()
{
    ...
    isCautionEnabled = false;
}


void CheckPlayerPosition()
{
    Vector3 pos = GameManager.PlayerAircraft.transform.position;
    Vector3 center = transform.position;
    float upperLimit = center.z + height * 0.5f;
    float lowerLimit = center.z - height * 0.5f;
    float leftLimit = center.x - width * 0.5f;
    float rightLimit = center.x + width * 0.5f;
    
    // Warning
    if(pos.x > upperLimit - warningDistance || pos.x < lowerLimit + warningDistance || 
        pos.z < leftLimit + warningDistance || pos.z > rightLimit - warningDistance)
    {
        // Fail
        if(pos.x > upperLimit || pos.x < lowerLimit || pos.z < leftLimit || pos.z > rightLimit)
        {
            GameManager.Instance.GameOver(false, false, true);
            SetCautionUI(false);
        }
        else
        {
            SetCautionUI(true);
        }
    }
    else
    {
        SetCautionUI(false);
    }
}

void SetCautionUI(bool enabled)
{
    if(isCautionEnabled == enabled)
        return;

    isCautionEnabled = enabled;
    alertUIController.SetCautionUI(enabled);
}

void Update()
{
    CheckPlayerPosition();
}

경계선의 좌표를 구한 다음, 플레이어가 경계선으로부터 warningDistance 이내에 있으면 경고음을 출력하도록 만들고, 경계선 바깥을 나가면 게임 오버 함수를 출력합니다.

경고 범위에서 벗어나게 되면 경고를 해제합니다.
isCautionEnabled라는 변수가 추가되었는데, 경고 범위에 있을 때는 UI 활성화 함수를 한 번만 호출하고, 범위 밖에 있을 때도 비활성화 함수를 한 번만 호출할 수 있도록 제약을 걸기 위함입니다. 경고 범위 확인 함수는 매 프레임마다 실행되기 때문에 자칫 잘못하다가는 매 프레임마다 함수가 호출될 수 있기 때문입니다.

경고 범위에 들어가면 UI가 깜빡이고,

완전히 나가버리게 되면 미션 실패 판정이 나게 됩니다.



난이도 조절

이 게임에는 4가지 난이도가 있습니다.

  • EASY: 낮은 적 미사일 성능, 플레이어 체력 증가, 적 비행기 선회력 및 회피율 감소
  • NORMAL: 기본
  • HARD: 높은 적 미사일 성능, 플레이어 체력 감소, 적 비행기 선회력 및 회피율 증가
  • ACE: 매우 높은 적 미사일 성능, 플레이어 체력 대폭 감소, 적 비행기 선회력 및 회피율 증가

미사일의 성능이나 선회력 등은 숫자로 표현되며, 난이도별 수치 조정값을 가지는 XML 파일에 관련 데이터를 적어놓았습니다. 이 값은 계수처럼 원래 값에다 곱해지는 방식으로 성능이 조정됩니다.

이런 식으로 XML 데이터를 불러와서 값을 곱하고, 조정된 값이 일정 수준을 넘어서면 곤란하기 때문에 Clamp로 최소/최대 성능에 제한을 둡니다. (예를 들어, smartTrackingRate가 1에 가까울수록 선회력만 뒷받침된다면 거의 백발백중의 미사일이 만들어지게 됩니다. 1을 초과하게 되면 미사일이 오브젝트의 미래 위치를 계산할 때 훨씬 먼 미래를 예측하게 됩니다.)

그리고 여기에 더해서 이 미션 전용으로 설정해줘야 하는 값이 몇 가지 있습니다.


협곡 돌파 시간 제한 추가

제 마음대로 설정한 시간은 다음과 같습니다.

  • EASY: 4분 (영화에서 대안으로 제시된 계획에서의 시간)
  • NORMAL: 3분 15초 (EASY와 HARD 사이의 시간)
  • HARD: 2분 30초 (주인공이 제시하는 시간)
  • ACE: 2분 15초 (주인공이 훈련 중 전속력으로 돌파하는 데에 걸린 시간)

난이도에 따라서 제한 시간을 조정하는 코드를 만들어보겠습니다.

MissionMaverick

void AdjustValuesByDifficulty()
{
    switch(GameSettings.difficultySetting)
    {
        case GameSettings.Difficulty.EASY:      timeLimit = 240; break;
        case GameSettings.Difficulty.NORMAL:    timeLimit = 195; break;
        case GameSettings.Difficulty.HARD:      timeLimit = 150; break;
        case GameSettings.Difficulty.ACE:       timeLimit = 135; break;
        default:                                timeLimit = 210; break;
    }
}

protected override void Start()
{
    AdjustValuesByDifficulty();
    ...
}

메인 화면에서 선택한 난이도는 GameSettings.difficultySetting에 저장됩니다. 그 값에 따라서 제한 시간을 달리하는 코드를 작성하면,

여기서 선택한 난이도에 따라 협곡 진입 시 설정되는 제한시간이 달라집니다.

EASY로 설정하면 4분,

ACE로 설정하면 2분 15초로 설정됩니다.



지대공 미사일 개수 조정

2페이즈에서 등장하는 적 기체 수는 난이도 불문 5기로 놓아도 큰 문제는 없을 것 같습니다. 난이도가 낮을수록 허수아비에 가까워지기 때문이죠.
그 대신 지대공 미사일은 숫자를 약간 조정해보려고 합니다. ACE의 경우에만 기지 쪽에 있는 지대공 미사일 개수를 조금 더 늘려보죠.

[SerializeField]
GameObject[] additionalSAMs;

void AdjustValuesByDifficulty()
{
    ...

    if(GameSettings.difficultySetting == GameSettings.Difficulty.ACE)
    {
        foreach(var SAM in additionalSAMs)
        {
            SAMControllers.Add(SAM.GetComponent<EnemyWeaponController>());
            SAMScripts.Add(SAM.GetComponent<SAM>());
            targetsEnableOnPhase2.Add(SAM.GetComponent<TargetObject>());
            SAM.SetActive(true);
        }
    }
    else
    {
        foreach(var SAM in additionalSAMs)
        {
            SAM.SetActive(false);
        }
    }
}

추가로 할당할 지대공 미사일 포대 배열을 선언하고, 난이도에 따라서 활성화/비활성화시키는 코드를 작성합니다. 코드를 작성한 이후에는 Inspector 창에서 컴포넌트에 등록할 지대공 미사일들을 등록합니다.


EASY, NORMAL, HARD까지는 지대공 미사일이 이렇게 배치되어 있지만,

ACE는 거의 두 배 이상 배치됩니다.

심지어 ACE 난이도에서는 지대공 미사일의 성능도 올라가는데다 미사일에 한 방만 맞아도 격추되기 때문에 살아남기는 아주 어려울 겁니다.


적 기체 체력 조정

EASY와 NORMAL은 미사일 두 방에 적 기체가 격추되고, HARD 이상에서는 세 방에 적 기체가 격추되도록 만듭시다.


방법은 두 가지가 있습니다. 난이도에 따라 내 미사일의 성능을 조정하거나, 적 오브젝트의 체력을 조정하거나. 개인적으로는 미사일 성능을 조정하는 것보다 오브젝트의 체력을 조정하는 편이 조금 더 직관적이라고 판단해서, HARD 이상인 경우 체력을 올려버리겠습니다.


ObjectInfo

[SerializeField]
bool variesHpByDifficulty = false;

[SerializeField]
int easyHp;
[SerializeField]
int normalHp;
[SerializeField]
int hardHp;
[SerializeField]
int aceHp;

public bool VariesHpByDifficulty
{
    get { return variesHpByDifficulty; }
}

public int EasyHp
{
    get { return easyHp; }
}
public int NormalHp
{
    get { return normalHp; }
}
public int HardHp
{
    get { return hardHp; }
}
public int AceHp
{
    get { return aceHp; }
}

ObjectInfo의 속성 중 "난이도에 따라 HP 차등 적용 여부"와 그 때 사용할 HP 값을 저장할 수 있도록 변수를 추가합니다.


TargetObject

protected virtual void AdjustValuesByDifficulty()
{
    if(Info.VariesHpByDifficulty == true)
    {
        switch(GameSettings.difficultySetting)
        {
            case GameSettings.Difficulty.EASY:
                maxhp = hp = Info.EasyHp;
                break;
                
            case GameSettings.Difficulty.NORMAL:
                maxhp = hp = Info.NormalHp;
                break;
                
            case GameSettings.Difficulty.HARD:
                maxhp = hp = Info.HardHp;
                break;
                
            case GameSettings.Difficulty.ACE:
                maxhp = hp = Info.AceHp;
                break;

            default:
                maxhp = hp = Info.NormalHp;
                break;
        }
    }
}

난이도 별로 값을 조정하는 함수는 TargetObject에도 있습니다. 비워놨을 뿐.
이제 여기도 채워줍시다.

AircraftAI

protected override void AdjustValuesByDifficulty()
{
    base.AdjustValuesByDifficulty();
    ...
}

TargetObject를 상속받는 클래스에서 이 함수를 오버라이딩하고 있었다면 꼭 부모의 함수도 호출하도록 설정해줍시다.

미션에 등장하는 적기의 데이터를 위와 같이 수정합니다.

EASY와 NORMAL에서는 두 방만 맞아도 격추되지만,
(미사일 공격력 = 10, 조정된 체력 = 20)

이제 HARD 이상 난이도에서는 미사일 두 방을 맞춰도 격추되지 않습니다.
(미사일 공격력 = 10, 조정된 체력 = 30)



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

0개의 댓글