Ace Combat Zero: 유니티로 구현하기 #13 : 비행기 인공지능 (1) - 자동 비행

Lunetis·2021년 5월 23일
3

Ace Combat Zero

목록 보기
14/27
post-thumbnail

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

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

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




올 것이 왔습니다.

비행기의 인공지능을 구현할 시간입니다.

알아서 날아다니고, 플레이어를 공격하고, 미사일을 피하고, 가끔 맞아주는 인공지능을 말이죠.


요즘 4차 산업혁명이다 뭐다 해서 AI와 함께 딥러닝이니 머신러닝이니 여러 곳에서 다양하게 말이 참 많은데,

일단 제가 이 프로젝트에서 구현할 인공지능은 그런 것과는 아무런 상관이 없습니다.

스스로 학습하는 거요? 그 정도의 스케일을 필요로 하는 프로젝트는 아닙니다.

그냥 재밌는 게임을 간략하게 만들어볼 뿐이지, 정교한 알고리즘을 필요로 하진 않습니다.
물론 있으면 좋겠지만요.


역시 이것과도 관련이 없습니다.


잠시만요, 택배가 온 것 같은데 잠깐 나갔다 올게요.



구현 목표

구현 목표는 크게 3가지가 있습니다.

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

세부적으로 하나씩 살펴보죠.



1. 알아서, 또는 특정 지점을 향해 비행하기

일단 스스로 비행을 할 줄 알아야 합니다.

어느 위치를 단순히 돌아다니거나, 정해진 웨이포인트를 따라 이동하는 정찰 비행을 할 수도 있고,
플레이어를 향해 달려드는 비행을 할 수도 있습니다.

여기서 고려해야 하는 사항들은,

  • 맵을 벗어나지 않기
  • 땅에 추락하거나 물체/구조물에 충돌하지 않기
  • 지정된 목적지나 웨이포인트가 있을 경우 따라서 비행하기

정도가 있겠네요.



2. 날아오는 미사일을 '적절히' 피하기

여기서 중요한 건 '적절히' 피한다는 겁니다.

미사일이 날아오고 있다면 비행기는 선회하면서 회피기동을 하게끔 인공지능을 만들겠지만,
모든 미사일을 피한다면 플레이어가 게임에서 이길 수 있을까요?

경우에 따라서는 랜덤으로 회피기동을 하지 않고 순순히 맞아주거나,
회피기동의 선회력을 약하게 해서 아슬아슬하게 빗나가거나 맞추는 상황을 만들어야 할 수도 있습니다.

여기에 난이도에 따른 회피력도 조정해야 할 수도 있고,
에이스 기체보스 기체는 일반 기체보다 미사일을 더 잘 피해야 합니다.

그렇다면 고려해야 하는 사항은,

  • 미사일을 피하면서도 맵을 벗어나거나 어딘가에 충돌하지 않기
  • 가끔은 일부러 맞아주기
  • 난이도, 기체 속성에 따른 회피력 변수 추가

정도로 생각하면 될 것 같습니다.



3. 목표물을 향해 공격하기

플레이어를 공격하지 않는다면 그냥 날아다니는 연습용 타겟밖에 되지 않습니다.

게임 이름부터가 에이스 '컴뱃'(전투)입니다.
미사일과 총알이 신나게 날아다니는 공중전을 구현하는 것이 이 게임의 목표죠.


일단, 대부분의 경우 상대방은 미사일을 바로 발사하지는 않습니다.
상대도 엄연히 락온 시스템이 존재하고, 발사하기까지 시간이 걸리기 때문입니다.

상대방으로부터 락온되는 동안에는 플레이어에게 소리와 경고 UI로 알려주고,
실제로 날아오면 미사일 경고가 UI에 표시되어야 합니다.

가끔씩 락온 경고 없이 상대방이 바로 미사일을 쏴버리는 경우가 있는데,
헤드온 상황(서로를 향해 달려가는 상황)같이 상대방과의 각도가 0에 가까울 때 이런 일이 발생합니다.


그리고 여기서도 난이도를 고려해야 하는데요,
플레이어가 적을 맞추기 힘들게 하는 것 뿐만 아니라, 게임 오버가 될 위험도 높게 만들어야 합니다.

원작에서는 보스 기체가 일반 기체보다 미사일을 더 정교하게 각을 맞춰서 발사한다라기보다는,
보스의 미사일의 성능을 조금 더 높게 설정해놓고, 더 자주 발사하게 구현이 된 것으로 보입니다.

일반 미사일과 보스 전용 미사일을 구분지을 수도 있고, 뭔가 매개변수로 더 높은 값을 전달할 수도 있죠.


그리고 보스는 경우에 따라서 미사일 뿐만 아니라 특수무기를 장착해서 사용하기도 합니다.

대표적으로 제가 만들려고 하는 보스전에서는,

1페이즈에서는 레이저 무기를 가져와서 광선검마냥 휘두르고,

2페이즈에서는 작은 핵폭발을 일으키는 것 같은 산탄 미사일을 날립니다.

에이스 컴뱃 7에서는 MPBM (Multi-Purpose Burst Missile) 라고 이름을 붙여놨더군요.


아무튼 고려할 사항을 생각해봅시다.

  • 락온 딜레이를 기다린 다음에 발사, 딜레이 동안 플레이어는 경고 UI 표시
  • 플레이어와 밀착한 상태로 발사해서 100% 맞게 하는 반칙성 플레이는 막아놓기
  • 난이도와 발사 주체에 따라 미사일 성능을 조절할 수 있는 변수 추가
  • 미사일 재장전 딜레이
  • 특수무기 구현

생각만 해도 머리가 슬슬 아파오려고 하지만, 언젠가는 다 만들어야 합니다.


그러면 1단계부터 할까요?



비행하기

먼저 비행 인공지능 관련 스크립트만 실행될 환경을 만들기 위해서,
인공지능 테스트용 씬(Scene)을 하나 만들어서 테스트를 해보려고 합니다.

환경은 이 정도만 구축해 놓았습니다.

이전 씬에서 지형과 비행기를 가져오고, 카메라는 비행기에 종속시키고 뒤쪽에 배치하겠습니다.


상상 속의 알고리즘 개요

제가 구상중인 인공지능을 대충 설명해보면,

일단 비행기는 특정 지점 (파란색 점)을 향해서 나아갑니다.
그 때의 비행 경로는 붉은색 선과 같겠죠.

그 지점에 다가가면, 근처에 새로운 지점을 만듭니다.

비행기는 그 지점을 향해 날아가고,
그 과정에서 비행기는 자연스럽게 선회합니다.

여기서 선회력은 랜덤으로 지정합니다.


위 그림처럼 완만하게 선회할 수도 있고,

이런 식으로 급격하게 선회할 수도 있고요.


그리고 비행기가 특정 지점으로 이동하는 동안 플레이어가 발사한 미사일이 날아온다면,

미사일과의 거리가 어느정도 가까워지면 (회색 원) 새로운 지점을 생성해서 그 위치로 선회합니다.
새로운 목적지를 지정해서 회피기동을 하는 방식입니다.

이 선회도 랜덤으로 발생하고, 선회력 또한 랜덤으로 설정됩니다.
(가끔 회피하지 않고 그냥 맞아줄 수도 있다는 것이죠.)

보스, 에이스 기체는 선회 확률과 선회력 평균을 높게 지정할 것입니다.



특정 지점으로 이동하기

먼저 게임 시작 시에 특정 지점을 지정해서 그 방향으로 비행하도록 프로그래밍을 해보려고 합니다.

지금 선택된 Sphere 오브젝트가 이 비행기의 목표 지점입니다.
비행기가 바라보고 있는 방향과는 꽤 차이가 있습니다.

public class AircraftAI : MonoBehaviour
{
    [SerializeField]
    float maxSpeed;
    [SerializeField]
    float minSpeed;
    [SerializeField]
    float defaultSpeed;

    float speed;
    
    [SerializeField]
    float speedLerpAmount;
    [SerializeField]
    float turningForce;

    [SerializeField]
    List<Transform> initialWaypoints;
    Queue<Transform> waypointQueue;

    Transform currentWaypoint;
    
    float prevWaypointDistance;
    float waypointDistance;
    bool isComingClose;

    float prevRotY;
    float currRotY;
    float rotateAmount;
    float zRotateValue;


    void ChangeWaypoint()
    {
        if(waypointQueue.Count == 0)
        {
            currentWaypoint = null;
            return;
        }

        currentWaypoint = waypointQueue.Dequeue();
        waypointDistance = Vector3.Distance(transform.position, currentWaypoint.position);
        prevWaypointDistance = waypointDistance;

        isComingClose = false;
    }

    void CheckWaypoint()
    {
        if(currentWaypoint == null) return;
        waypointDistance = Vector3.Distance(transform.position, currentWaypoint.position);

        if(waypointDistance >= prevWaypointDistance) // Aircraft is going farther from the waypoint
        {
            if(isComingClose == true)
            {
                ChangeWaypoint();
            }
        }
        else
        {
            isComingClose = true;
        }

        prevWaypointDistance = waypointDistance;
    }

    void Rotate()
    {
        if(currentWaypoint == null)
            return;

        Vector3 targetDir = currentWaypoint.position - transform.position;
        Quaternion lookRotation = Quaternion.LookRotation(targetDir);

        float delta = Quaternion.Angle(transform.rotation, lookRotation);
        if (delta > 0f)
        {
            float lerpAmount = Mathf.SmoothDampAngle(delta, 0.0f, ref rotateAmount, turningTime);
            lerpAmount = 1.0f - (lerpAmount / delta);
            transform.rotation = Quaternion.Slerp(transform.rotation, lookRotation, lerpAmount);
        }
    }

    void ZAxisRotate()
    {
        currRotY = transform.eulerAngles.y;
        float diff = prevRotY - currRotY;
        if(diff < -180) diff += 360;
        else if(diff > 180) diff -= 360;

        zRotateValue = Mathf.Lerp(transform.eulerAngles.z, Mathf.Clamp(diff / Time.deltaTime, -90, 90), turningForce);

        transform.rotation = Quaternion.Euler(transform.eulerAngles.x, transform.eulerAngles.y, zRotateValue);
        prevRotY = transform.eulerAngles.y;
    }

    void Move()
    {
        transform.Translate(new Vector3(0, 0, speed) * Time.deltaTime);
    }


    void Start()
    {
        speed = defaultSpeed;
        
        turningTime = 1 / turningForce;

        waypointQueue = new Queue<Transform>();
        foreach(Transform t in initialWaypoints)
        {
            waypointQueue.Enqueue(t);
        }
        ChangeWaypoint();
    }

    void Update()
    {
        CheckWaypoint();
        Rotate();
        ZAxisRotate();
        Move();
    }
}

이동할 지점은 처음에 Inspector View에서 List형태로 지정할 수 있고,
게임 실행 시 Queue waypointQueue에 넣습니다.

ChangeWaypoint()는 이동할 지점을 설정하는 함수입니다.
호출될 때마다 waypointQueue에서 하나씩 꺼내서 다음 목표 지점을 설정합니다.


CheckWaypoint()는 특정 지점까지 이동했는지 판단합니다.

목표 지점까지 정확히 이동하는 것은 선회력에 제한이 있어 불가능할 수 있기 때문에,
지점과의 거리를 재면서 현재 프레임에서의 거리가 이전 프레임에서의 거리보다 길면 해당 지점을 지났다고 판단하여 ChangeWaypoint()를 실행해서 다음 목표 지점을 설정합니다.

그러나 목표 지점이 비행기의 뒤에 있는 경우에는 지점이 설정되자마자 멀어지기 때문에 목표 지점으로 이동하지 않을 수 있습니다.

그래서 가까워지기 시작했는지 확인하는 변수인 isComingClose가 있고, 이 값까지 true인 경우에만 다음 목표 지점을 설정합니다.


Rotate()에서는 목표 지점을 향해 회전하는 코드가 있습니다.
Mathf.SmoothDampAngle을 이용해서 선회력을 설정합니다.
선회력은 turningTime (= 1 / turningForce)에 따라 결정되며, turningForce 값이 클수록 선회력도 같이 커지게 됩니다.

회전량에 따라서 비행기의 Z축을 회전하는 코드 (ZAxisRotate()) 또한 추가되었습니다.

값을 설정하고, 이동 경로를 보기 위해 TrailRenderer를 달아줬습니다.


적당히 목표물을 향해 회전하고 있는 모습입니다.


여러 개의 웨이포인트 지정

이번에는 여러 개의 지점을 모두 통과하는 기능을 테스트해봅시다.

웨이포인트를 모두 지정한 다음 실행해보죠.

1번 지점을 지나면 2번 지점을 향해 회전합니다.

2번 지점을 지나면 3번으로 향하고요.

약간 Z축 회전이 이상하게 돌아가는 것 같기도 합니다.

Scene 뷰에서 본 모습입니다.

최종 이동경로는 이렇게 되네요.



선회력 테스트

이번에는 설정한 선회력에 따라서 비행기의 궤도가 확실히 바뀌는지 확인해보려고 합니다.

선회력은 turningForce로 조정한다고 했었는데요,

1에서 0.5로 낮추면 어떤 궤적을 보일까요?

일단 첫 번째 이동 지점을 가는것도 선회력이 딸려서 정확히 도달하지 못하는 모습을 보이는데요,

두 번째 지점을 통과하는 순간...















이 Z축 회전 코드를 잠시 집어치워야겠군요.



일단 선회력을 0.5로 두었을 때의 궤적입니다.

빨간 공으로 표시된 지점을 정확히 통과하지 못하네요.



다시 선회력을 1로 두었을 때입니다.
정해진 지점을 제대로 통과하고 있습니다.

궤적을 봤을 때 확실히 0.5일 때보다 선회력이 향상되었다는 것이 느껴집니다.



과연 2로 두면 어떻게 될까요?

보스에 걸맞는 선회력을 보여줄지, 아니면 게임이 너무 안드로메다로 갈 지 확인해봅시다.


아주 홱홱 돌아가네요.

경로도 선회력이 1일때에 비해 많이 타이트해보입니다.

보스의 선회력을 어느 정도로 둘 지는 나중에 생각합시다...



Z축 회전 다시 만들기

해당 프레임의 Y축 회전량에 따라서 Z축을 회전시키려 했지만,

뭔가 제대로 계산되지 않은 것 같습니다.

void ZAxisRotate()
{
    currRotY = transform.eulerAngles.y;
    float diff = prevRotY - currRotY;

    if(diff > 180) diff -= 360;
    if(diff < -180) diff += 360;
 
    debugText.AddText("Diff : " + diff);
    
    prevRotY = transform.eulerAngles.y;
}

디버그를 좀 해보죠.
(*debugText는 디버깅용으로 만든 스크립트입니다. 유니티 기본 제공 기능이 아닙니다.)

TurningForce = 2인 상태에서, 이전 프레임과 현재 프레임의 Y축 각도 차이는 대략 0.3까지 도달합니다.

// Z Rotate Values
[SerializeField]
float zRotateMaxThreshold = 0.5f;
[SerializeField]
float zRotateAmount = 90;

void Rotate()
{
    if(currentWaypoint == null)
        return;

    Vector3 targetDir = currentWaypoint.position - transform.position;
    Quaternion lookRotation = Quaternion.LookRotation(targetDir);

    float delta = Quaternion.Angle(transform.rotation, lookRotation);
    if (delta > 0f)
    {
        float lerpAmount = Mathf.SmoothDampAngle(delta, 0.0f, ref rotateAmount, turningTime);
        lerpAmount = 1.0f - (lerpAmount / delta);
        
        Vector3 eulerAngle = lookRotation.eulerAngles;
        eulerAngle.z += zRotateValue * zRotateAmount;
        lookRotation = Quaternion.Euler(eulerAngle);

        transform.rotation = Quaternion.Slerp(transform.rotation, lookRotation, lerpAmount);
    }
}

void ZAxisRotate()
{
    currRotY = transform.eulerAngles.y;
    float diff = prevRotY - currRotY;

    if(diff > 180) diff -= 360;
    if(diff < -180) diff += 360;
    
    prevRotY = transform.eulerAngles.y;
    zRotateValue = Mathf.Lerp(zRotateValue, Mathf.Clamp(diff / zRotateMaxThreshold, -1, 1), turningForce * Time.deltaTime);
}


void Update()
{
    CheckWaypoint();
    ZAxisRotate();
    Rotate();
    Move();
}

ZAxisRotate()는 멤버 변수인 zRotateValue만 조정하는 역할로 바꾸고,

실제로 회전값을 적용하는 건 Rotate()로 옮겨놓았습니다.

Quaternion.LookRotation() 자체도 Z축 방향으로 어느정도 회전시키는데,
여기에 ZAxisRotate()에서 계산된 양만큼 더 회전시키는 방식으로 바꿨습니다.

ZAxisRotate()에서 zRotateValue는 현재 각도에 따라 -1 ~ 1 사이의 값을 가지도록 변환되고,
Rotate()에서는 zRotateValuezRotateAmount만큼 곱한 다음 현재 Z값에 더해줍니다.

Z축 회전량을 증가시키기 위해서는 zRotateAmount 값을 올리면 됩니다.

이렇게 설정하면 이전 프레임과 현재 프레임의 Y축 각도 차이가 0.2 이상이면 기체가 135도만큼 돌아가게 되고,
그 이하면 135도에 적당히 비례한 값으로 돌아가게 됩니다.


선회력을 1로, 회전량을 135로 두었을 때의 모습입니다. 비행기가 많이 회전된 상태로 다음 목표물을 향해 이동합니다.

Scene 뷰에서 봤을 때는 나쁘지 않아 보입니다.

그러면 선회력이 약하거나 강할 때도 제대로 작동하는지 확인해봅시다.

선회력이 0.5일 때입니다. Z축 회전이 많이 이상하긴 하네요.
이 정도의 선회력은 되도록이면 설정하지 말아야 할 것 같습니다.

선회력을 2로 줬을 때입니다. 시원하게 돌아가네요.




랜덤 비행

초기에 설정한 웨이포인트를 모두 통과한 후에는 그냥 아무 생각없이 진행하던 방향으로 나아가고 있습니다.

이제 마지막 지점을 통과하면, 새 지점을 랜덤으로 생성해서 비행하도록 해보려고 합니다.

[SerializeField]
float newWaypointDistance;
[SerializeField]
float waypointMinHeight;
[SerializeField]
float waypointMaxHeight;

Vector3 currentWaypoint;

[SerializeField]
GameObject waypointObject;

void CreateWaypoint()
{
    float distance = Random.Range(newWaypointDistance * 0.7f, newWaypointDistance);
    float height = Random.Range(waypointMinHeight, waypointMaxHeight);
    float angle = Random.Range(0, 360);
    Vector3 directionVector = new Vector3(Mathf.Sin(angle * Mathf.Deg2Rad), 0, Mathf.Cos(angle * Mathf.Deg2Rad));
    Vector3 waypointPosition = transform.position + directionVector * distance;

    RaycastHit hit;
    Physics.Raycast(waypointPosition, Vector3.down, out hit);

    if(hit.distance != 0)
    {
        waypointPosition.y += height - hit.distance;
    }
    // New waypoint is below ground
    else
    {
        Physics.Raycast(waypointPosition, Vector3.up, out hit);
        waypointPosition.y += height + hit.distance;
    }

    Instantiate(waypointObject, waypointPosition, Quaternion.identity);

    currentWaypoint = waypointPosition;
}

CreateWaypoint()는 플레이어의 주변 위치에 새로운 이동 지점을 생성합니다.

플레이어와의 거리는 newWaypointDistance * 0.7 ~ newWaypointDistance 사이의 거리로 정하고,
땅에서의 높이는 waypointMinHeight ~ waypointMaxHeight 사이로 정합니다.

먼저 플레이어와 같은 높이에 미리 설정한 거리만큼 떨어진 위치에서 Ray를 발사합니다.

만약 아래로 Ray가 닿았다면 (ray.distance != 0) 그 땅의 위치로부터 height만큼 높이 있는 위치에 새 지점을 만들고,

아래로 닿지 않았다면 땅 아래에 있다는 뜻이므로 위로 다시 Ray를 발사합니다.
그리고 그 위치로부터 height만큼 높이 있는 위치에 새 지점을 만듭니다.


지금은 디버그 목적으로 Instantiate를 이용해서 새로 만든 지점에 파란 공을 놓아주려고 합니다.

void Start()
{
    ...
    for(int i = 0; i < 50; i++)
    {
        CreateWaypoint();
    }
}

새 지점들이 잘 생성되는지 확인하기 위해서 시작부터 50개의 지점을 만들어보죠.

거리와 높이 값을 설정하고 비행기를 적절한 위치에 놓아줍니다.


50개를 만들어봤는데, 땅 밑에 묻힌 지점은 없는 것 같습니다.

그리고 마지막으로 만들어진 지점으로 간 다음 그대로 비행하고 있습니다.


void ChangeWaypoint()
{
    if(waypointQueue.Count == 0)
    {
        CreateWaypoint();
    }
    else
    {
        currentWaypoint = waypointQueue.Dequeue().position;
    }
    
    waypointDistance = Vector3.Distance(transform.position, currentWaypoint);
    prevWaypointDistance = waypointDistance;
    isComingClose = false;
}

이제 ChangeWaypoint() 코드를 수정할 차례입니다.

이전까지는 waypointQueue에서 하나씩 다음 지점을 꺼내다가,queue가 비어있으면 그냥 가던 방향으로 비행하도록 했습니다.
이제는 새로운 지점을 만들어서 그 지점을 향해 비행하게 만들려고 합니다.

waypointQueue.Count == 0일 때 CreateWaypoint()를 실행하도록 만들어줍니다.


초기 경로로 설정된 빨간 공을 지난 후, 파란 공을 생성해서 그 지점으로 이동하고 있습니다.

새로 만든 파란 공을 지나면 또 다른 파란 공을 생성해서 그 지점으로 이동하고,

로직은 계속해서 돌아가고 있습니다.

10배속을 해봐도 잘 돌아가는 것 같습니다.



생성 위치 제한

비행기의 주변에 계속해서 새로운 웨이포인트를 생성하고 있는데,

가만히 놔두다가는 맵 밖으로 나가버릴 위험이 있습니다.
생성 지점의 좌표에 제한을 둬야 합니다.

대충 이 정도의 영역 내에서 놀게끔 해봅시다.

영역은 직육면체 형태인 BoxCollider 형태로 지정했습니다.

[SerializeField]
BoxCollider areaCollider;
    
public static Vector3 RandomPointInBounds(Bounds bounds)
{
    return new Vector3(
        Random.Range(bounds.min.x, bounds.max.x),
        Random.Range(bounds.min.y, bounds.max.y),
        Random.Range(bounds.min.z, bounds.max.z)
    );
}

void CreateWaypoint()

    float height = Random.Range(waypointMinHeight, waypointMaxHeight);
    Vector3 waypointPosition = RandomPointInBounds(areaCollider.bounds);

    RaycastHit hit;
    Physics.Raycast(waypointPosition, Vector3.down, out hit);

    if(hit.distance != 0)
    {
        waypointPosition.y += height - hit.distance;
    }
    // New waypoint is below ground
    else
    {
        Physics.Raycast(waypointPosition, Vector3.up, out hit);
        waypointPosition.y += height + hit.distance;
    }

    Instantiate(waypointObject, waypointPosition, Quaternion.identity);

    currentWaypoint = waypointPosition;
}

RandomPointInBounds()BoxCollider의 영역 내의 무작위 좌표를 반환하고,
CreateWaypoint()에서는 그 좌표를 기반으로 높이를 이전처럼 정해준 다음 목표 지점으로 설정합니다.

대신 현재 기체와의 거리인 distance와 관련된 코드는 사라졌습니다.
최소 거리를 확보하는 코드는 나중에 작성하자고요.

Area ColliderBoxCollider로 된 영역을 설정하고 실행하면,

영역 내에서 알아서 잘 놀고 있는 비행기 인공지능을 볼 수 있습니다.

비행기의 기동 자체는 나쁘지 않아 보입니다.


누가 와서 저게 도대체 뭐하는 거냐고 물으면, 정찰비행을 하고 있다고 둘러대죠 뭐.



랜덤으로 선회력 설정하기

새 지점을 향해 이동할 때마다 선회력을 랜덤으로 설정하려고 합니다.

왜 굳이 이런 일을 하냐면, 높은 선회력을 가진 기체라고 해도 가끔씩은 미사일에 맞아줘야 하거든요.
플레이어가 쏘는 미사일을 모조리 다 피해버리면 게임이 안 끝날 수 있다니까요.

에이스 컴뱃 7 마지막 미션 브리핑 꼴이 나서는 안 됩니다.


어쨌든, 랜덤으로 낮은 선회력이 걸리면 플레이어가 쏜 미사일에 맞기 쉽게 되겠죠.


기체마다 선회력을 설정한 상태에서, 새 지점을 향할 때마다 새롭게 랜덤으로 정해지는 선회력은
선회력 x (0.5 ~ 1) 로 설정하려고 합니다.

어떨 때는 새 지점으로 빠르게 회전하고, 어떨 때는 느리게 회전하겠죠?
낮은 선회력에 얻어걸렸을 때는 플레이어의 미사일이 쉽게 맞게 될 것입니다.

물론, 보스 기체는 기본적으로 선회력이 굉장히 높게 설정될 것이기 때문에,
플레이어의 미사일이 쉽게 맞을 정도로 낮은 선회력은 잘 나오지 않을 겁니다.


...

float currentTurningForce;

float turningTime;
float currentTurningTime;

void ChangeWaypoint()
{
    ...

    currentTurningForce = Random.Range(0.5f * turningForce, turningForce);
    turningTime = 1 / currentTurningForce;
}

void Rotate()
{
    ...

    float delta = Quaternion.Angle(transform.rotation, lookRotation);
    if (delta > 0f)
    {
        float lerpAmount = Mathf.SmoothDampAngle(delta, 0.0f, ref rotateAmount, currentTurningTime);
        ...
    }
}


void Start()
{
    ...
    currentTurningForce = turningForce;
    turningTime = 1 / turningForce;
    currentTurningTime = turningTime;
    ...
}

void Update()
{
    ...

    currentTurningTime = Mathf.Lerp(currentTurningTime, turningTime, 1);
}

선회력이 실제로 적용되는 부분은 Rotate() 내부의 SmoothDampAngle에 매개변수로 전달되는 currentTurningTime입니다.

이 값은 게임 시작 시 Start()에서 1 / turningForce로 설정되는데요,

새 지점이 생성되거나, 미리 지정해둔 지점으로 이동하는 ChangeWaypoint()에서는 currentTurningForce를 새로 설정합니다.

그리고 turningTime 값을 1 / currentTurningForce로 새로 설정한 다음,
currentTurningTimeturningTime으로 천천히 바뀌도록 Update()에서 Lerp를 먹여줍니다.


5배속으로 돌려본 결과입니다.

어느 때는 날카롭게 회전하면서 파란 공을 통과하지만,
어느 때는 파란공을 정확히 통과하지 못하고 그냥 근처를 지나갑니다.
가끔은 근처도 가지 못하고 바로 새 지점으로 방향을 틀어버리기도 하네요.

선회력을 새로 지정할 때마다 디버그도 찍어봤는데, 알아서 잘 설정되는 것 같네요.


속도도 랜덤으로 설정하기

이 비행기는 속력이 일정한 상태로 비행하고 있습니다.

이왕 회전할 때마다 선회력을 바꾸는 거, 속력도 바꿔버립시다.

[Header("Accel/Rotate Values")]
[SerializeField]
float accelerateLerpAmount = 1.0f;
[SerializeField]
float accelerateAmount = 50.0f;
float currentAccelerate;
float accelerateReciprocal;

void RandomizeSpeedAndTurn()
{
    // Speed
    targetSpeed = Random.Range(minSpeed, maxSpeed);
    isAcceleration = (speed < targetSpeed);

    // TurningForce
    ...
}

void ChangeWaypoint()
{
    ...

    RandomizeSpeedAndTurn();
}

void AdjustSpeed()
{
    currentAccelerate = 0;
    if(isAcceleration == true && speed < targetSpeed)
    {
        currentAccelerate = accelerateAmount;
    }
    else if(isAcceleration == false && speed > targetSpeed)
    {
        currentAccelerate = -accelerateAmount;
    }
    speed += currentAccelerate * Time.deltaTime;

    currentTurningTime = Mathf.Lerp(currentTurningTime, turningTime, 1);
}

void Start()
{
    speed = targetSpeed = defaultSpeed;
    accelerateReciprocal = 1 / accelerateAmount;

    ...
}

void Update()
{
    CheckWaypoint();
    ZAxisRotate();
    Rotate();
    
    AdjustSpeed();
    Move();
}

선회력을 조정하는 기능을 RandomizeSpeedAndTurn() 함수로 분리하고,

여기에 목표 속력을 조절하는 코드를 작성합니다.

플레이어가 조종하는 AircraftController 정도의 로직을 필요로 하지는 않을 것 같습니다.

플레이어의 속력은 UI에 표시되고 입력에 따라 속력이 적절하게 조절되어야 하지만,
AI의 속력은 표시할 필요도 없고 속도가 갑작스럽게 변해도 플레이어는 알아차리기 힘듭니다.

그러니까 대충 만들죠.


목표 속력 targetSpeed을 설정할 때 가속해야 하는지, 감속해야 하는지를 판별하는 isAcceleration을 현재 speed에 따라 설정하고,

Update()에서 호출하는 AdjustSpeed()에서 속력을 조절합니다.

isAcceleration의 값에 따라 속력을 올리거나 내리고, targetSpeed가 넘어가면 그냥 더 이상의 가속과 감속을 하지 않게 만들었습니다.

Accelerate... 변수의 값을 수정하고 실행합니다.

이제 목표 지점이 바뀔 때마다 속력도 같이 변하고 있습니다.

게임에 갖다놔도 될 수준인지는 다음 편에서 알아보죠.



처음에 설명했던 3가지를 다시 가져와서 보면,

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

1단계는 완료했습니다.

다음으로는 미사일이 날아오면 피하는 회피 기동을 만들면 되겠네요.


그나저나 이번에 처음으로 태그에 AI를 달아놨는데 어그로로 보이진 않겠죠?



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

4개의 댓글

comment-user-thumbnail
2022년 12월 14일

위에 있는 코딩하면서 배우고 있습니다. 저 코팅 상에 Transform currentWaypoint와 Vector3 currentWaypoint가 중복이 되어서 실행이 안되어서 댓글 남겨봅니다. 혹시 어떻게 하셨는지 알려주실 수 있으신가요?

1개의 답글
comment-user-thumbnail
2024년 3월 14일

안녕하세요 이런걸 혼자서 로직을 짜고 뚝딱 만들수 있기 까지 얼마정도 걸렸나요 대단하시네요

1개의 답글