Ace Combat Zero: 유니티로 구현하기 #14 : 비행기 인공지능 (2) - 미사일 회피

Lunetis·2021년 5월 29일
0

Ace Combat Zero

목록 보기
15/27
post-thumbnail

혹시 전문적인 인공지능 포스트를 찾고 계신가요?

여기는 게임에 사용할 매우 간단하고 멍청한 적군 인공지능을 만드는 곳입니다.

머신러닝, 딥러닝, 인공신경망 등을 기대하시는 분들은 속히 다른 포스트를 찾아가시기 바랍니다.


(아니, 이제는 좀 덜 멍청한 것 같기도...)



1. 알아서, 또는 특정 지점을 향해 비행하기
2. 날아오는 미사일을 '적절히' 피하기
3. 목표물을 향해 공격하기

지난 편에서는 특정 영역 내부를 돌아다니는 자율비행(?) 기능을 만들었습니다.

이번에는 플레이어가 미사일을 발사하는 게임용 씬(Scene)으로 무대를 옮겨서,
플레이어가 미사일을 발사할 때마다 회피기동을 하는 기능을 만들어주려고 합니다.



비행 인공지능 적용하기

이제 AIScene에서는 그만하고 지금까지 작업했던 ZERO 씬에서 작업하죠.

이전에 만들었던 인공지능 스크립트를 가져와서 픽시에 붙여줍니다.

값도 재조정좀 하고요.


적당히 세팅이 끝났으면, 적의 뒤를 따라다니는 비행을 해봅시다.

게임에 쓰기에 나쁘지 않은 기동을 하는 것 같습니다.




...저만 그렇게 생각하는 건 아니죠?



미사일 감지하기

미사일을 적절히 피하는 것 이전에, 일단 미사일을 피할 줄 알아야 합니다.


미사일은 락온 과정을 거친 후에 발사하고, 락온된 대상을 따라갑니다.
락온된 비행기는 경고 시스템이 작동하게 되고, 미사일이 가까이 오면 회피기동을 합니다.

여기서 비행기는 미사일이 날아온다는 것을 어떻게 감지할 지 확인하는 알고리즘이 필요합니다.

알고리즘의 주체가 누가 되어야 할까요?

  1. 비행기가 주변에 날아오고 있는 미사일이 있는지 감지하기
  2. 미사일이 비행기에게 날아오고 있다는 것을 알려주기

1번의 경우에는 주변에 있는 미사일이 나에게 락온되어서 날아오고 있는 미사일인지 알아내는 과정을 거쳐야 합니다.
그러면 결국 주변에 있는 모든 미사일들의 스크립트에 접근해야 하고, 나에게 락온되지 않은 미사일까지 확인하겠죠.

제 생각에는 2번이 더 바람직한 방법으로 보입니다. 결국에는 미사일 스크립트에 접근해야 하니까요.

TargetObject.cs

List<Missile> lockedMissiles = new List<Missile>();

public void AddLockedMissile(Missile missile)
{
    lockedMissiles.Add(missile);
    Debug.Log("Missile Added");
}

public void RemoveLockedMissile(Missile missile)
{
    lockedMissiles.Remove(missile);
    Debug.Log("Missile Removed");
}

락온 가능한 오브젝트의 부모 클래스인 TargetObject"현재 락온되어 날아오고 있는 미사일 개수"를 저장하는 리스트를 추가했습니다.

미사일이 여러 개 날아올 때, 각각의 미사일들은 락온된 오브젝트에 "락온됨" 이라는 데이터를 보내주면서 발사되고,
맞추거나 빗나갈 때는 락온된 오브젝트에 "락온 풀림" 이라는 데이터를 보내줄 예정입니다.


여기서 "락온됨" 데이터를 받은 기체는 경고를 울리게 만들어놨다고 칩시다.

근데 "락온 풀림" 데이터를 받을 때 경고를 해제하면, 여러 개의 미사일이 날아올 때 하나만 해제되고 나머지가 여전히 날아오고 있는 경우에도 경고가 해제될 수 있습니다.

단 한 개의 미사일이라도 비행기를 향해 날아오고 있다면 경고를 해제해서는 안 됩니다.


리스트에 담긴 미사일이 1개 이상이면 경고를 울리고, 0개가 되면 경고를 해제하도록 구현하면 되겠죠.



AircraftAI.cs

public class AircraftAI : TargetObject

EnemyAircraft.cs

public class EnemyAircraft : AircraftAI

TargetObject > AircraftAI > EnemyAircraft 순서로 상속을 받도록 구조를 바꿔줬습니다.



Missile.cs

public void Launch(Transform target, float launchSpeed, int layer)
{
    this.target = target;

    // Send Message to object that it is locked on
    target.GetComponent<TargetObject>()?.AddLockedMissile(this);

    ...
}

void LookAtTarget()
{
    if(target == null)
        return;

    Vector3 targetDir = target.position - transform.position;
    float angle = Vector3.Angle(targetDir, transform.forward);

    if(angle > boresightAngle)
    {
        GameManager.UIController.SetLabel(AlertUIController.LabelEnum.Missed);
        isDisabled = true;
        
        // Send Message to object that it is no more locked on
        target.GetComponent<TargetObject>()?.RemoveLockedMissile(this);
        target = null;
        
        return;
    }

    ...
}

void DisableMissile()
{
    // Send Message to object that it is no more locked on
    if(target != null)
    {
        target.GetComponent<TargetObject>()?.RemoveLockedMissile(this);

        if(isDisabled == false && isHit == false)
        {
            GameManager.UIController.SetLabel(AlertUIController.LabelEnum.Missed);
        }
    }
    
    ...
}

미사일이 발사될 때 (Launch()) 목표물의 락온된 미사일 리스트에 추가하고,
빗나가거나 사라지면 목표물의 락온됨 미사일 리스트에서 해제합니다.


TargetObject에 로그를 찍는 코드를 추가해서 상태를 확인해봤습니다.
발사시 Missile Added가 뜨고, 맞추거나 빗나갈 때마다 Missile Removed가 뜹니다.

*두 번째 미사일을 피하는 것처럼 보이는데, 그냥 목적지까지 도달해서 랜덤으로 이동하는 겁니다.


어쨌든, 날아오는 미사일이 잘 처리되고 있는 것 같습니다.



미사일 피하기

이제 일정 거리만큼 미사일이 접근하면 비행기에게 회피기동을 하게 만드는 기능을 구현할 차례입니다.


ObjectInfo.cs

[SerializeField]
float warningDistance;
public float WarningDistance
{
    get { return warningDistance; }
}

미사일을 피해야 하는 거리는 오브젝트마다 다르게 설정해야 할 수 있기 때문에,
오브젝트 정보를 담는 ObjectInfo.cs에서 정하도록 합시다.

warningDistance보다 가까운 미사일이 있다면 경고 단계로 진입합니다.

이 값을 설정하지 않으면 기본값은 0이 되는데,

비행기나 미사일 등의 충돌체에는 충돌 범위가 있기 때문에,
충돌할 때 거리가 0이 될 일은 거의 없을 것입니다.

0으로 설정하면 아마 경고가 뜰 일이 없을 거에요.



TargetObject.cs

public virtual void OnWarning()
{

}

protected void CheckMissileDistance()
{
    bool existWarningMissile = false;
    foreach(Missile missile in lockedMissiles)
    {
        float distance = Vector3.Distance(missile.transform.position, transform.position);

        if(distance < Info.WarningDistance)
        {
            existWarningMissile = true;
            break;
        }
    }

    if(existWarningMissile == true)
    {
        if(isWarning == false)
        {
            OnWarning();
        }
        isWarning = true;
    }
    else
    {
        isWarning = false;
    }
}

TargetObject에는 락온되어 날아오는 미사일들의 거리를 재는 함수를 추가합니다.
경고 상태에 놓여있는지는 bool isWarning으로 설정합니다.

이전에 경고가 활성화되어있지 않았다면 OnWarning()을 호출하고,
경고 범위 내에 미사일이 없다면 경고를 해제합니다.


이 함수는 TargetObjectUpdate()에서 실행하지 않습니다.
오브젝트마다 경고 시스템이 필요할 수도 있고, 필요하지 않을 수도 있기 때문입니다.

예를 들어 땅에 붙어다니는 지대공 미사일을 만들 때도 TargetObject를 상속받는 클래스를 만들텐데,
별다른 회피기동도 하지 못하는 오브젝트에게 미사일 거리를 계산하라고 할 필요는 없으니까요.

그래서 일단 여기다 만들어놓고, 이걸 상속받는 클래스에서 호출하려고 합니다.



AircraftAI.cs

public override void OnWarning()
{
    Debug.Log("Changed Waypoint");
    ChangeWaypoint();
}

protected virtual void Update()
{
    ...

    CheckMissileDistance();
}

여기 상속받는 클래스 AircraftAI가 있네요.

OnWarning()에서는 경로를 바꾸고 "Changed Waypoint"라는 로그를 찍게 할 겁니다.

그리고 AircraftAIUpdate()에서 아까 만들었던 CheckMissileDistance()를 호출합니다.

warningDistance를 200으로 설정해보겠습니다.


미사일이 가까워질 때마다 "Changed Waypoint" 로그와 함께 비행기가 회피기동을 하고 있습니다.

맞추기는 요원해보이네요.


200보다 짧은 거리에서 발사할 때는 발사하자마자 로그와 함께 비행기가 회피기동을 하고 있습니다.


그래도 아예 맞추기가 불가능한 것은 아닙니다.

위 경우처럼 회피기동을 급격하게 하지 않는 경우에는 미사일이 다시 날아가서 맞기도 합니다.

그리고 회피기동을 하기 위해 새롭게 만들어진 경로가 기존 경로와 크게 차이가 나지 않는다면,
미사일이 맞을 확률이 높아지겠죠.





그래서 이 상태로 놔둬도 게임 클리어가 가능하긴 합니다.

회피기동 단계에서 어느정도 랜덤성이 추가되었기 때문이죠.



그리고 우리에게는 기총이 있잖아요?



확률에 따라 회피기동하기

지금은 보스에 걸맞는 회피를 보여준다고 치지만,
발에 치일 정도로 많이 돌아다니는 일반 목표물들까지 저렇게 회피를 한다 생각하면 정말 끔찍하지 않을 수 없습니다.

허수아비같이 대놓고 맞아주는 목표물도 있어야죠.


사실 1:1 보스전을 만드는 게 목표라서 굳이 이걸 넣을 필요는 없지만,
그래도 언젠가는 쓰일 수 있을 것 같아서 넣어놓겠습니다.



AircraftAI.cs

[SerializeField]
[Range(0, 1)]
float evasionRate = 0.5f;

public override void OnWarning()
{
    float rate = Random.Range(0.0f, 1.0f);
    if(rate <= evasionRate)
    {
        ChangeWaypoint();
        Debug.Log("Evaded");
    }
    else
    {
        Debug.Log("Not Evaded");
    }
}

0 - 1 사이로 조절하는 회피율 evasionRate를 추가합시다.

OnWarning()에서는 랜덤으로 0에서 1 사이의 값을 뽑아서,
그 값이 evasionRate보다 작거나 같으면 회피하고, 아니면 회피하지 않습니다.

그리고 로그를 찍어보죠.

[Range(?, ?)] 속성을 이용하면 Inspector View에 이렇게 슬라이드 바를 넣을 수 있습니다.

확률을 50%로 두어보겠습니다.


미사일을 두 발 발사했을 때, 첫 번째는 "Not Evaded"가 떴습니다.
이 때는 기존에 비행하는 경로에서 이탈하지 않고 있습니다.

두 번째는 "Evaded"가 뜨고, 경로를 바꾸는 모습을 보여주고 있습니다.

이렇게 일정 확률에 따라 피하지 않고 순한 양이 되는 비행기가 만들어졌습니다.


플레이어가 체감하는 회피 확률은 설정한 확률보다 약간 높을 수 있습니다.
피하지 않는 경우에 걸렸다고 해도, 그 때 미리 정해진 지점에 비행기가 도달하면 새 목표 지점을 만들고 선회하게 되니까요.




그리고 우리의 픽시는...




보스에 걸맞는 회피율을 보여줄 겁니다.

정말 낮은 확률로 회피하지 않고 맞거나, 회피한다고 해도 우연히 이전 경로와 크게 차이나지 않는 회피 지점이 만들어져서 맞게 되는 걸 기대하는 수밖에 없을 겁니다.

밸런스 패치요? 그 때 가서 하죠.


일단 지금 상태로도 생각보다 쉽게 클리어 가능합니다.
사실 허점이 하나 있기 때문이죠.



경고 알고리즘의 허점 고치기

참고: 이 수정사항을 적용시킬 경우, 상당히 비현실적인 움직임을 보여줄 수 있습니다.
그리고 게임이 몹시 부조리하게 보이겠죠.
이게 왜 안 맞아

비행기가 파란색 경로를 따라 가고 있는데, 뒤에서 미사일 두 개가 날아오고 있습니다.

미사일이 경고 범위 내에 들어가면 회피 기동을 합니다.

그리고 두 번째 미사일이 경고 범위에 들어가면 다시 회피 기동을 해야 하는데,

현재 알고리즘 상에서는 경고 범위에 들어간 첫 미사일이 경고 범위에서 벗어나지 않는 동안
이후의 미사일이 경고 범위에 새로 들어가더라도 OnWarning()이 호출되지 않습니다.

뒤따라오는 미사일도 OnWarning()을 호출하게 해주는 것이 이 파트의 목표입니다.


사실 여기까지 굳이 해줄 필요는 없습니다만, 게임을 더 어렵게 만들기 위해 뒤에 미사일이 몇 개가 날아오든 간에 회피기동을 하게 만들어보려고 합니다.

개발자가 사악해서 그런 건 아닙니다. 아무튼 아닙니다.





위 스크린샷은 미사일을 두 개 날려서 둘 다 빗나간 상황입니다.
OnWarning()이 두 번 호출될 것 같지만,

실제로는 한 번밖에 호출되지 않습니다.
이걸 두 번 호출되도록 바꿔봅시다.



Missile.cs

bool hasWarned = false;

public bool HasWarned
{
    get { return hasWarned; }
    set { hasWarned = value; }
}

void DisableMissile()
{
    hasWarned = false;
    ...
}

Missile 스크립트에서는 "이 미사일이 OnWarning()을 실행시킨 미사일인가?" 라는 데이터를 가지는 bool hasWarned 변수를 추가합니다.

public으로 놓아서 다른 스크립트에서 마음대로 수정할 수 있도록 두고,
초기 생성 시, 미사일이 사라질 때 다시 false로 초기화해줍니다.



TargetObject.cs

protected void CheckMissileDistance()
{
    bool existWarningMissile = false;
    bool executeWarning = false;
    foreach(Missile missile in lockedMissiles)
    {
        float distance = Vector3.Distance(missile.transform.position, transform.position);

        if(distance < Info.WarningDistance)
        {
            existWarningMissile = true;
            
            if(missile.HasWarned == false)
            {
                executeWarning = true;
                missile.HasWarned = true;
                break;
            }
        }
    }

    if(executeWarning)
    {
        OnWarning();
    }

    if(existWarningMissile == true)
    {
        isWarning = true;
    }
    else
    {
        isWarning = false;
    }
}

CheckMissileDistance()에서는 알고리즘을 약간 변경합니다.

isWarning의 조건은 그대로 두되, (경고 범위 내에 미사일이 하나라도 있는지 확인)
OnWarning()을 실행하는 조건을 변경합니다.

경고 범위 내에 미사일이 있으면서, 그 미사일의 HasWarned 값이 false OnWarning()을 실행합니다.
그리고 그 미사일은 OnWarning을 작동시켰다는 의미로 HasWarned 값을 true로 지정합니다.


이제 각각의 미사일이 경고 범위에 들어갈 때마다 OnWarning()이 호출됩니다.


이제 보스에 걸맞는 회피력을 보여주는 것 같습니다.

아니 근데 제발 좀 맞으세요 XX


하지만 유도 성능이 좋은 특수무기 QAAM은 가끔씩 맞기도 합니다.


이제 제대로 회피하도록 알고리즘이 만들어졌습니다.

이 상태에서 맞추려면 유도 성능이 좋은 특수무기를 쓰거나,
회피 경로가 기존 경로와 비슷하길 바라는 수 외에는 별로 기대할 게 없을 것 같습니다.

아니면 대응하기도 힘들 정도로 초근거리에서 발사하거나,

기총을 쏘죠.





게임이 점점 공중전다워지고 있습니다.

이걸 만드는 저도 결과물이 상당히 기대되는데요...


1. 알아서, 또는 특정 지점을 향해 비행하기
2. 날아오는 미사일을 '적절히' 피하기
3. 목표물을 향해 공격하기

다음에는 저기 날아다니는 비행기가 절 향해 미사일을 쏠 겁니다.



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

1개의 댓글

comment-user-thumbnail
2022년 11월 4일

항상 잘보고 있습니다 ㅎㅎ 감사합니다 ^^ 공부하는데 정말 유익해요

답글 달기