탑건: 매버릭 미션 개발하기 #10 : 팁/락온 알고리즘 수정/체크포인트

Lunetis·2022년 8월 16일
0

탑건: 매버릭

목록 보기
11/14
post-thumbnail

팁 표시

에이스 컴뱃 7에서는 위와 같이 조작법이나 팁을 알려주는 경우가 있습니다.

저도 게임을 처음 하는 분들을 위해 조작법을 알려주고 싶지만, 시작부터 아주 끔찍한 협곡을 통과시키기 때문에 이미 튜토리얼을 하기에는 늦은 시간이죠.

처음 하는 분들은 그 어떤 장애물도 없는 FREE FLIGHT 모드에서 조작법을 익히시길 바랍니다.

그렇지만 이 미션을 처음 하는 사람들에게는 무조건 알려줘야 하는 게 두 가지 있습니다.

  1. 협곡에서의 제한 고도: 이 고도를 넘어가면 미션 실패입니다
  2. 레이저 유도 폭탄의 유도 방법: 원본 게임과 다른, 고유의 방식을 사용합니다.

게임 시작 전에 알려주자니 마땅히 알려줄 만한 곳이 없어서, 저도 게임 도중에 팁을 띄우겠습니다.


일단 UI 위치부터 맞춰주고, 띄워줄 텍스트를 적어보면서 표시에 문제가 없는지 확인합니다.

너무 긴 경우에는 개행 문자(\n)을 집어넣읍시다.



다국어 지원

대사 포스트에서 보셨듯이, 보잘것 없는 게임이지만 그래도 한국어와 영어를 지원합니다.
팁도 그에 맞게 보여줄 필요가 있겠죠.


XML 파일을 꺼내서 팁 문구를 추가합니다.

완전히 새로운 기능이니까 새 스크립트를 만들어야겠네요.


TipUIController

using TMPro;

public class TipUIController : MonoBehaviour
{
    public float visibleTime = 5.0f;
    public TextMeshProUGUI text;

    public void ShowTip(string tipKey)
    {
        text.text = GameManager.ScriptManager.GetSubtitleText(tipKey);
        Invoke("HideTip", visibleTime);
    }

    void HideTip()
    {
        text.text = "";
    }
    
    void Start()
    {
        HideTip();
    }
}

ScriptManager에는 이 미션에서 사용하는 XML 데이터를 들고 있습니다.
거기에다가 팁 문구도 작성했으니, ScriptManager를 통해서 꺼내 쓰도록 작성했습니다.
가져온 텍스트를 보여준 후, visibleTime만큼 지나면 텍스트를 공백으로 만들어서 숨깁니다.

이 함수를 호출하는 스크립트도 작성해야겠죠.


MissionMaverick

[SerializeField]
TipUIController tipUIController;
    
void ShowTip1()
{
    tipUIController.ShowTip("TIP_1");
}

void ShowTip2()
{
    tipUIController.ShowTip("TIP_2");
}

팁에 대한 키값은 이미 알고 있으니까 그냥 여기다 하드코딩하겠습니다.


방금 만든 함수들은 대사가 끝나는 시점에 실행되도록 자막 데이터 파일에서 함수를 등록합니다.

마지막으로 TipUIController를 등록하고 실행해보죠.

정말이지 자막 데이터에 호출할 함수를 등록할 수 있게 만든 건 신의 한 수인 것 같습니다.



지대공 미사일 공격하기

폭격을 성공하고 분지를 빠져나오는 순간 지대공 미사일이 여러분을 반겨줄 것입니다.
그런데 이 지대공 미사일을 여러분이 공격해서 파괴할 수는 없었습니다.

실제 게임에서는 파괴가 가능한데 말이죠. 이제 복수극을 찍어봅시다.


어떤 객체를 공격 가능한 객체로 만들기 위해서는 3가지가 필요하도록 만들었습니다.

  1. 충돌체(Collider)
  2. 강체(Rigidbody)
  3. Target Object 컴포넌트

이걸 모두 추가해줍니다.

그리고 미니맵에 이 객체가 표시될 수 있도록 스프라이트를 하나 추가하고 거기에 MinimapSprite 컴포넌트를 붙여줍니다.

지대공 미사일 프리팹을 수정하면 맵에 배치된 모든 지대공 미사일들이 영향을 받습니다.
그 결과로 이렇게 하얀 점이 뜨고 타겟 UI가 뜨게 되고,

이렇게 지대공 미사일을 파괴할...

...수 있습니다.

어쨌든 공격 가능한 목표물로 바꿔버리는 일은 매우 쉽습니다.



지형에 미사일이 가로막히는 상황 완화

그런데 테스트하다보니 땅에 박혀있는 적들은 미사일의 궤적이 약간 달라야 할 것 같습니다.

기본적으로는 그냥 목표물의 위치로 돌격하고 있지만, 지형에 따라서 위와 같이 막혀버리는 상황이 발생할 수 있습니다.

그래서 이렇게, 목표물의 약간 위쪽을 향해 날아가다가 목표물과 가까워지면 정확히 목표물을 향하도록 미사일의 궤적을 설정해주겠습니다.


ObjectInfo

[SerializeField]
bool isGroundObject;

public bool IsGroundObject
{
    get { return isGroundObject; }
}

객체의 정보를 담는 클래스인 ObjectInfo에 지상에 있는 객체인지 알려주는 변수를 추가하고,


Missile

protected virtual void LookAtTarget()
{
    ...

    Vector3 targetPos = Vector3.Lerp(target.transform.position, GetPredictedTargetPosition(), smartTrackingRate);
    
    // For ground objects: give offset (0, 5, 0) if the distance between missile and the target > 100 (on unity coordinates)
    if(target.Info.IsGroundObject == true && Vector3.Distance(target.transform.position, transform.position) > 100)
    {
        targetPos.y += 5;
    }
    Vector3 targetDir = target.transform.position - transform.position;
    ...
    
}

지상에 있는 객체라면 미사일과의 거리가 100 이상일 경우 그 객체보다 5만큼 위쪽을 향하도록 만듭니다. 그리고 100 미만인 경우 정확히 그 객체를 향하도록 만들어주고요.

이렇게 하면 지형에 미사일이 가로막히는 상황을 줄일 수 있습니다.



락온 타겟 지정 알고리즘 수정

타게팅 가능한 목표물이 너무 많아서, 제가 원하는 목표물을 찾다가 미사일에 맞아 죽게 생겼습니다.
원본 게임의 타게팅 알고리즘이 어떻게 되는지는 모르겠습니다만 최대한 편리하게 수정해보겠습니다.


대충 이렇게 생겨먹은 코드에서 다음 타겟을 선택하는 알고리즘이 수행되고 있습니다.
지금 알고리즘은 다음과 같이 되어 있습니다.

  1. 거리 5000 이내의 목표물들을 탐색해서 리스트 생성, 없으면 null, 하나만 있으면 바로 지정
  2. 목표물이 여러 개 있는 경우,
    2-1. 기존에 선택된 목표물이 없었다면 0번 인덱스로 설정
    2-2. 기존에 선택된 목표물이 탐색된 목표물 배열에 존재하는 경우, 그 다음 인덱스로 설정
    (마지막 인덱스인 경우 0번 인덱스로 설정)

거리 5000 이내에 있는 목표물들을 싸그리 모아서 리스트를 만들 때, 딱히 정렬하지 않고 그냥 리스트에 담고 있었습니다. 여기서 가설을 하나 세웠는데, "플레이어가 바라보는 방향과 목표물 사이의 각도가 작은 순으로 정렬하면 괜찮지 않을까?" 입니다.

물론 비행기가 멈춰있는 것이 아니기 때문에 각도는 계속적으로 변하겠지만, 얼마나 효과가 있는지는 해봐야 알 것 같습니다. 그러니 바로 해보죠.


GameManager

public static float GetAngleBetweenTransform(Transform otherTransform)
{
    Vector3 direction = PlayerAircraft.transform.forward;
    Vector3 diff = otherTransform.position - PlayerAircraft.transform.position;
    return Vector3.Angle(diff, direction);
}

일단 플레이어가 바라보는 방향을 기준으로 목표물과의 각도를 구하는 코드는 이전에 만들어놓았습니다. 근데 왜 제가 이걸 Utils.cs같은 이름으로 만들지 않았을까요?

뭐 아무튼 static이니까 마음껏 쓸 수는 있습니다만.


public List<TargetObject> GetTargetsWithinDistance(float distance, float searchAngle = 0, bool getNearestTarget = false)
{
    ... (대충 거리 5000 안에 있는 목표물을 모두 리스트로 만드는 코드)

    objectsWithinDistance.Sort((TargetObject t1, TargetObject t2) =>
        (GetAngleBetweenTransform(t1.transform) > GetAngleBetweenTransform(t2.transform) ? 1 : -1));
        
    return objectsWithinDistance;
}

각도 기준 오름차순으로 정렬하기 위해 위와 같이 작성했습니다.

Before: 시작부터 상당히 중구난방으로 락온 타겟이 정해지고 있습니다.

After: 이제 바라보는 위치와 가까이 있는 목표물대로 락온 타겟이 정해지고 있습니다.

없는 것보다는 확실히 낫습니다.

시작 시 앞에 있는 목표물 타게팅을 먼저 시작하고, 뒤로 돌아서 그 방향에 있는 목표물을 선택하기까지 타겟을 여러 번 바꿔야 하는데, 이건 지금 목표물들이 엄청 많은 상황이니까 그렇다 칩시다.

지금 상황 기준으로는 플레이어 주변에 목표물이 14개 있는데, 12시 방향을 타게팅하고 6시로 돌려서 그 쪽 방향에 있는 오브젝트를 제대로 타게팅하기까지 4-5번 정도의 목표 변경이 필요합니다. 그 이전에는 이것보다 더 많이 타게팅해야 하는 경우도 있었으니 제 기준으로는 봐줄 만 합니다.


근데... 왜 NEXT로 설정된 목표물이 두 개 이상 뜨는 걸까요...?
(NEXT로 표시된 목표물은 다음 타겟 변경 시 지정되는 목표물입니다.)

버그를 수정합시다.

WeaponController


TargetObject GetNextTarget()
{
    List<TargetObject> targets = GameManager.Instance.GetTargetsWithinDistance(5000);
    TargetObject selectedTarget = null;

    if(targets.Count == 0)
    {
        nextTarget = null;
        return null;
    }

    else if(targets.Count == 1)
    {
        selectedTarget = targets[0];
        nextTarget = null;
        return selectedTarget;
    }
        
    else
    {
        // Reset next target indicator
        for(int i = 0; i < targets.Count; i++)
        {
            targets[i].isNextTarget = false;
        }
            
        for(int i = 0; i < targets.Count; i++)
        {
            // No current target
            if(target == null)
            {
                selectedTarget = targets[0];   // not selected
                nextTarget = targets[1];
                break;
            }

            // There's a next target in the array
            if(targets[i] == nextTarget)
            {
                // Set current target to next target
                selectedTarget = nextTarget;

                if(i == targets.Count - 1)  // if current target is the last index
                {
                    nextTarget = targets[0];    // then next target is the first index
                }
                else
                {
                    nextTarget = targets[i + 1];
                }
                break;
            }
        }

        // There is no current target in the array
        if(selectedTarget == null)
        {
            selectedTarget = targets[0];   // not selected
            nextTarget = targets[1];
        }
    }
    nextTarget.isNextTarget = true;
    return selectedTarget;
}


public void ChangeTarget()
{
    TargetObject newTarget = GetNextTarget();
    if(newTarget == null)   // No target
    {
        GameManager.CameraController.LockOnTarget(null);
        GameManager.TargetController.SetTarget(null);
        gunCrosshair.SetTarget(null);
        target = null;
        
        return;
    }

    // No change
    if(newTarget == target) return;

    // Previous Target
    if(target != null)
    {
        target.SetMinimapSpriteBlink(false);
    }

    target = newTarget;
    target.isNextTarget = false;
    target.SetMinimapSpriteBlink(true);
    GameManager.TargetController.SetTarget(target);
    gunCrosshair.SetTarget(target.transform);
}

과거의 제가 짠 코드를 들여다봤으나, 저조차도 의도를 정확히 파악할 수 없어서 갈아엎었습니다.
사실 이 코드가 제대로 돌아갈 일이 없었는데, 왜냐하면 이전 프로젝트는 적 목표물이 하나밖에 없어서 스위칭할 필요가 없었기 때문입니다.

"NEXT"로 표시된 오브젝트를 스크립트 상에서 들고 있게 하고, 다음 목표물 변경 시 그 오브젝트가 거리 내에 있다면 무조건 타겟으로 설정하도록 만들었습니다. 그리고 배열 상 그 다음 인덱스에 해당하는 오브젝트의 인덱스를 NEXT로 지정합니다.


이제는 "NEXT" 표시가 여러 개 뜰 일은 없어졌습니다.



더 나은 목표물 선택 알고리즘

아직 목표물 선택 알고리즘이 자연스럽다고 느껴지지는 않습니다. 특히 비행기를 반대 방향으로 돌려서 타게팅을 할 때 현재 바라보는 방향의 물체가 선택되기까지 여러 번 바꿔줘야 하는 문제를 완화하고 싶습니다.

한 가지 알고리즘을 생각해봤는데, 그 내용은 다음과 같습니다.

  • 목표물을 바꿀 때마다 그 때 비행기가 바라보는 방향값을 저장
  • 목표물을 바꿀 때 이전에 비행기가 바라보는 방향값과 현재의 방향값 차이를 계산, 일정 각도 이상이면 바라보는 방향과 가장 가까운 목표물이 다음 NEXT가 되도록 수정
    (현재의 NEXT는 이미 결정되었으므로)
Vector3 prevDirectionAtTargetChange;
[SerializeField]
float targetChangeAngleThreshold = 90;

TargetObject GetNextTarget()
{
    ...
    else
    {
        ...
        for(int i = 0; i < targets.Count; i++)
        {
            ...
            // There's a next target in the array
            if(targets[i] == nextTarget)
            {
                ...
                else
                {
                    // Force change when index > 1
                    if(i > 0 && Vector3.Angle(transform.forward, prevDirectionAtTargetChange) > targetChangeAngleThreshold)
                    {
                        nextTarget = targets[0];
                    }
                    else
                    {
                        nextTarget = targets[i + 1];
                    }
                }
                break;
            }
        }
        ...
    }
    nextTarget.isNextTarget = true;
    prevDirectionAtTargetChange = transform.forward;
    return selectedTarget;
}

목표물 전환 시의 플레이어 방향을 매 계산 시마다 저장합니다.

이전 방향과 현재 방향의 각도의 차이가 일정 값을 넘었을 때, 현재 NEXT로 지정된 목표물이 배열의 0번 인덱스라면 (바라보는 방향에서 가장 가까운 목표물이라면) 기존대로 진행시키고, 그게 아니라면 다음 NEXT는 0번 인덱스로 둡니다.
그나저나 if-else가 너무 많아졌네요. 이거 어떻게 정리해야 되냐...

각도는 60도로 둬봅시다.

이렇게 한 쪽 방향을 보다가 완전히 다른 쪽 방향을 보게 될 때는 꽤 직관성 있게 작동합니다.

그렇지만 서서히 돌면서 계속 목표물을 바꿀 때는 여전히 버튼을 많이 눌러야 합니다.

알고리즘에 하나를 더 추가해봅시다.
일정 각도를 넘어선 경우에만 강제로 다음 목표물을 설정해주고 있는데, 그 각도를 넘어서지 않는 경우에는 현재 각도로 갱신을 하지 않게끔 바꿔보죠.

    ...
                else
                {
                    // Force change when index > 1
                    if(i > 0 && Vector3.Angle(transform.forward, prevDirectionAtTargetChange) > targetChangeAngleThreshold)
                    {
                        prevDirectionAtTargetChange = transform.forward;
                        nextTarget = targets[0];
                    }
                    ...
                }
                break;
            }
        }
        ...
    }
    nextTarget.isNextTarget = true;
    return selectedTarget;
}

방향값을 갱신하는 revDirectionAtTargetChange = transform.forward 코드를 조건식 안쪽으로 이동시켰습니다.

그리고 각도 기준을 조금 더 낮춰보죠. (60 -> 30)

완벽하지는 않지만 이 정도면 충분히 직관적인 것 같습니다.


한 가지 더 보완을 하자면, 목표물을 선택한지 시간이 오래 경과하면 방향이 달라지지 않았더라도 현재 바라보는 방향에 가까운 목표물을 선택하도록 하는 방식입니다.
(비행기의 방향이 그대로이지면 현재 선택된, 그리고 다음 선택될 목표물이 비행기의 진행 방향과 반대 방향에 있는 경우를 대비하기 위해서입니다.)

float prevTargetChangeTime;

[SerializeField]
float targetChangeTimeThreshold = 5;

TargetObject GetNextTarget()
{
    ...
                    if(i > 0 && 
                        (Vector3.Angle(transform.forward, prevDirectionAtTargetChange) > targetChangeAngleThreshold) ||
                        (Time.time - prevTargetChangeTime > targetChangeTimeThreshold))
                    {
                        prevDirectionAtTargetChange = transform.forward;
                        nextTarget = targets[0];
                    }
                    ...
                }
                break;
            }
        }
        ...
    }
    nextTarget.isNextTarget = true;
    prevTargetChangeTime = Time.time;

    return selectedTarget;
}

조건식에 시간 관련 코드를 추가합니다.
목표물을 선택할 때마다 그 때의 시간을 저장하고, 현재 목표물을 선택할 때 이전 시간과의 차이가 targetChangeTimeThreshold 이상이면 각도를 넘어섰을 때처럼 다음 목표물을 강제로 설정합니다.

이것까지 추가하면 상당히 직관적인 알고리즘이 완성될 겁니다.


제 체감상으로는 확실히 없었을 때보다는 나았습니다.



체크포인트 시스템

자막을 만들 때는 이미 페이즈를 염두에 두고 만들기는 했죠. 협곡을 뚫고 폭격에 성공하기까지가 페이즈 1, 지대공 미사일을 뚫고 모든 적기를 격추시키는 것이 페이즈 2입니다.

하지만 여러분이 폭격을 성공하고 페이즈 2에 진입한 후 격추되면 처음부터 다시 시작해야 합니다.


에이스 컴뱃 제로는 언제 죽든 처음부터 다시 시작해야 하고, 에이스 컴뱃 7은 체크포인트 시스템이 도입되어서 특정 지점에 도달하면 체크포인트부터 다시 시작을 할 수 있게 있습니다. 이 게임의 난이도가 아주 어려운 편은 아니라고 생각하지만, 그래도 이미 돌파한 협곡을 다시 지나가야 하는 건 플레이어 입장에서는 너무나도 귀찮습니다.

이전에 만든 프로젝트에서도 원본 게임(제로) 대신 7편을 따라 체크포인트 시스템을 도입했었습니다. 게임을 일시정지할 때 나오는 "RESTART FROM CHECKPOINT" 항목이 그 증거입니다.

사실, 테스트의 편의를 위해서 저는 이미 체크포인트를 지정할 수 있는 코드를 만들어놓았습니다. 그냥 공식적으로 도입하기만 하면 됩니다.


모든 미션 클래스의 부모 클래스인 MissionManager에는 오버라이드 가능한 SetupForRestartFromCheckpoint()가 있습니다. 여기서 phase 변수에 대한 조정을 하면 됩니다.

MissionMaverick

public Transform phase1start;
public Transform phase2start;

public override void SetupForRestartFromCheckpoint()
{
    if(phase == 1)
    {
        ResultData.elapsedTime = 0;
    }
}

protected override void Start()
{
    ...

    if(phase == 1)
    {
        GameManager.PlayerAircraft.transform.SetPositionAndRotation(phase1start.position, phase1start.rotation);
        GameManager.ScriptManager.AddScript(onMissionStartScripts);
    }
    else if(phase == 2)
    {
        GameManager.ScriptManager.ClearScriptQueue();
        GameManager.PlayerAircraft.transform.SetPositionAndRotation(phase2start.position, phase2start.rotation);
    }
}

이전 프로젝트는 페이즈가 3개였고, 페이즈 1, 2에서 격추되면 페이즈 1로, 페이즈3 이면 그대로 페이즈 3에서 진행되도록 만들었습니다. 근데 이번에는 딱히 페이즈 값을 건드릴 필요가 없네요. 페이즈 1에서 시작할 때는 미션 수행에 걸린 시간을 초기화시켜주기만 하겠습니다. (미션 수행 시간은 최종 점수에 영향을 줍니다.)

그리고 Start()에서는 현재 페이즈 값에 따라 시작 위치를 지정해주는 Transform의 위치와 회전값을 비행기의 위치와 회전값에 덮어씌우고, 그 때 출력될 스크립트를 미리 지정해줍니다.

페이즈 1 시작 지점은 비행기가 최초에 놓여질 위치와 같고,

페이즈 2 시작 지점은 여기입니다.

여기서 시작하면 무수한 미사일이 날라오죠.



폭격 성공 후에는 페이즈 2에 진입하게 됩니다. 이 때 지대공 미사일에게 격추된 후,

RETRY FROM CHECKPOINT를 선택하면,

다시 미사일이 여러분을 반겨주게 됩니다.
그래도 협곡은 다시 돌파할 필요 없으니 안심하시면 됩니다.

아래에 있는 RETRY MISSION을 선택하면 처음부터 다시 시작합니다.


그렇지만 페이즈 2에 가기도 전에 파괴되거나 폭격에 실패하면 얄짤없이 처음부터 다시 시작입니다. 다시 협곡부터 뚫고 오시기 바랍니다.



빌드 테스트

배포하기 위해서는 빌드를 해서 실행 파일을 만들어야 합니다.
일반적으로는 그냥 Build 버튼을 누르고 기다린 다음 실행하면 되지만, 이 프로젝트에는 Addressable로 지정된 데이터들에 대해 작업을 해줘야 합니다.

전에 만들었던 포스트 재탕을 한 번 하겠습니다.

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

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

Use Existing Build로 바꿔주고,

바로 옆의 Build 버튼을 눌러서 New Build - Default Build Script를 눌러줍니다.

이제 실제 빌드에서도 Addressable로 지정된 에셋을 불러올 수 있습니다.



현재까지의 결과물입니다.

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

0개의 댓글