Ace Combat Zero: 유니티로 구현하기 #15 : 비행기 인공지능 (3) - 공격 기능

Lunetis·2021년 6월 6일
0

Ace Combat Zero

목록 보기
16/27
post-thumbnail

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


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

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










기계의 반란이 시작되었습니다!

블로그 주인인 저는 장렬히 싸우다가 전사하겠습니다.


개발자가 두들겨 맞는 것을 보고 싶으시면 계속 스크롤을 내려주시면 되겠습니다.




지금까지 도망다니면서 미사일과 총알을 맞아주기만 했던 인공지능이,
이제부터 플레이어를 공격하게 만들 차례입니다.

이번 포스트의 주 목표는 인공지능이 플레이어에게 미사일을 발사하는 기능을 만드는 겁니다.


추가로 플레이어에게 미사일이 날아오고 있다는 경고 시스템을 제작해보죠.



플레이어 추적

현재 인공지능은 지정한 범위 내의 랜덤한 위치를 향해서 비행하고 있습니다.

랜덤한 위치를 향하는 것 대신 플레이어의 위치를 향해서 비행하도록 만들어주면 될 것 같습니다.


그런데 지금 알고리즘은, 현재 목표 지점에 최대한 근접한 위치까지 도달한 후에야 새로운 지점을 향해 비행합니다.

매 프레임마다 비행할 위치를 지정하고 있지 않기 때문에, 게임 시작 시 플레이어의 위치를 지정해주면 플레이어는 벌써 다른 곳으로 갔는데 인공지능은 시작할 때의 플레이어 위치를 향해 비행할 것입니다.


문제가 있을법한 알고리즘이지만, 일단은 매 프레임마다 계산을 하는 건 나중에 미뤄봅시다.

먼저 새 목표 지점을 만들 때 랜덤 위치가 아닌 그 때의 플레이어 위치를 지정하도록 만들어봅시다.



AircraftAI.cs

// If you want to change waypoint selection, override this function
protected virtual Vector3 CreateWaypoint()
{
    return CreateWaypointWithinArea();
}

Vector3 CreateWaypointWithinArea()
{
    // areaCollider 내부에서 랜덤 목표 지점을 지정하는 코드
    ...

    return waypointPosition;
}

void ChangeWaypoint()
{
    if(waypointQueue.Count == 0)
    {
        currentWaypoint = CreateWaypoint();
    }
    else
    {
        currentWaypoint = waypointQueue.Dequeue().position;
    }
    
    ...
}

게임 시작시 설정된 지점인 waypointQueue가 비어있을 때는 CreateWaypoint()를 통해 새 목표 지점을 만들고 있습니다.

이전에는 CreateWaypoint() 함수 내부에서 currentWaypoint를 바로 초기화했었는데,
대신 Vector3를 반환하고 ChangeWaypoint()에서 초기화하는 방식으로 변경했습니다.

CreateWaypoint() 함수는 virtual 함수로 바뀌어서 AircraftAI를 상속받는 객체에서 오버라이딩이 가능하도록 변경했습니다.

기존에 지정된 위치 내에서 새 목표 지점을 만들어주는 함수는 CreateWaypointWithinArea()로 이름을 변경했습니다.


EnemyAircraft.cs

protected override Vector3 CreateWaypoint()
{
    return GameManager.PlayerAircraft.transform.position;
}

AircraftAI를 상속받는 EnemyAircraft에서는 CreateWaypoint()를 오버라이딩해서 플레이어의 위치를 반환하는 함수로 바꿔줍니다.


매 프레임 쫓아가는 게 아닐 때는 어떻게 기동하는지 보죠.

실행하기 전에 픽시(적 비행기)의 방향을 플레이어를 바라보는 방향으로 바꿔놨습니다.


시작하자마자 제가 있는 쪽으로 곧장 날아옵니다.

그리고 계속 꼬리에 꼬리를 무는 비행을 하게 됩니다.

이 정도만 해도 나쁘지 않아보이는데요,
그렇다면 매 프레임마다 추적하게 만들면 어떤 상황이 되는지 보겠습니다.



AircraftAI.cs

protected Vector3 currentWaypoint;

currentWaypointprotected로 놓고,


EnemyAircraft.cs

protected override void Update()
{
    base.Update();
    currentWaypoint = GameManager.PlayerAircraft.transform.position;
}

이렇게 Update()에서 현재 위치를 향해 비행하도록 설정해줍니다.


시작하자마자 플레이어 쪽으로 돌진하는 건 똑같은데, 선회를 좀 더 빨리 하는 것 같습니다.

그리고 제가 이동하는 방향을 향해서 계속 선회하고,

결국에는 또 빙빙 돌 뿐입니다.




실험 결과, 매 프레임마다 플레이어 위치로 목표 지점을 설정해줄 필요는 아직 느끼지 못했습니다.

일단은 매 프레임마다 지정하는 것 대신, 목표 지점에 도달할 때마다 플레이어 위치로 지점을 지정하는 방식으로 해보죠.



확률적으로 플레이어 추적하기

방금 전의 실험으로 알아낸 것이 하나 더 있습니다.

인공지능이 항상 플레이어를 추적하도록 놔두면,
서로 꼬리 잡으려고 빙글빙글 돌다가 게임이 끝날수도 있다는 것입니다.

그래서 랜덤으로 추적을 멈추고 다른 비행을 하다가 다시 플레이어를 추적하는 방식으로 만들어주려고 합니다.


여기서 "다른 비행"이라는 것은 이전에 사용했던 정찰 비행을 하는 것을 말합니다.




AircraftAI.cs

// If you want to change waypoint selection, override this function
protected virtual Vector3 CreateWaypoint()
{
    if(areaCollider != null)
    {
        return CreateWaypointWithinArea();
    }
    else
    {
        return CreateWaypointAroundItself();
    }
}

Vector3 CreateWaypointWithinArea()
{
    // 이미 있는 함수
}

Vector3 CreateWaypointAroundItself()
{
    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;
    }

    return waypointPosition;
}

13편에서 제거했던 "플레이어의 주변 위치에 새로운 이동 지점을 생성"하는 기능을 다시 부활시켜 CreateWaypointAroundItself()으로 만들었습니다.

그리고 CreateWaypoint()에서는 areaCollider가 지정되어 있는지를 확인해서,
areaCollider가 지정되었다면 그 영역 내의 지점을 반환하는 CreateWaypointWithinArea()를,
지정되지 않은 null이라면 주변 지점을 반환하는 CreateWaypointAroundItself()를 실행합니다.



EnemyAircraft.AI

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

protected override Vector3 CreateWaypoint()
{
    float rate = Random.Range(0.0f, 1.0f);
    if(rate < playerTrackingRate)
    {
        return GameManager.PlayerAircraft.transform.position;
    }
    else
    {
        return base.CreateWaypoint();
    }
}

이제 새 지점을 지정할 때 확률을 도입합시다.

playerTrackingRate는 새로운 지점을 지정할 때 플레이어의 위치를 지정할 확률입니다.
랜덤을 돌려서 playerTrackingRate보다 낮으면 플레이어 위치를, 높으면 기존의 CreateWaypoint()로 생성한 위치를 새 지점으로 지정해줍니다.

특정 위치가 아닌 현재 위치 주변으로 비행하게 만들기 위해 Area Collider를 없애버렸습니다.


제대로 돌아가는지 확인하기 위해 방향을 바꿀 때마다 로그를 찍어봤습니다.

Tracking 이 떴다는 것은 저를 향해 비행하고 있다는 뜻입니다.
미니맵에 있는 적 표시가 저를 향하고 있습니다.

Not Tracking은 주변의 다른 지점으로 비행하고 있다는 뜻입니다.
미니맵에 있는 적 표시가 다른 곳을 향하고 있습니다.

그러다가도 Tracking이 뜨면 다시 저를 향해 비행하게 되죠.





그냥 적당한 추적 인공지능 수준은 된 것 같지만, 정밀하게 만들기 위해서는 고려해야 할 것이 많습니다.

목표 지점을 바꿀 때마다 선회력과 속도를 랜덤으로 설정하고 있는데,
목표 지점과의 거리가 멀수록 속도를 빠르게 한다거나 하는 요소를 추가할 수도 있고요.



[SerializeField]
float minimumPlayerDistance = 2000; // 플레이어와의 거리가 이 값보다 길면 무조건 플레이어를 추적

protected override Vector3 CreateWaypoint()
{
    float rate = Random.Range(0.0f, 1.0f);
    float distance = Vector3.Distance(transform.position, GameManager.PlayerAircraft.transform.position);
    
    if(rate < playerTrackingRate || distance > minimumPlayerDistance)
    {
        Debug.Log("Tracking");
        return GameManager.PlayerAircraft.transform.position;
    }
    else
    {
        Debug.Log("Not Tracking");
        return base.CreateWaypoint();
    }
}

저는 거리가 2000 이상 멀어지면 확률에 상관없이 플레이어를 향해 비행하는 조건을 추가했습니다.
계속 "추적 안 함" 확률에 당첨되면 플레이어와 무한정으로 멀어질 수 있기 때문입니다.


여러 조건을 추가하면서 인공지능의 비행 알고리즘을 계속 가다듬겠지만,
포스트에서의 언급은 여기까지만 하겠습니다.



락온

인공지능도 미사일을 발사하기 전에 락온 과정을 거쳐야 합니다.
플레이어의 락온 알고리즘과 비슷하게 만들어보려고 합니다.

EnemyWeaponController.cs

public class EnemyWeaponController : MonoBehaviour
{
    [SerializeField]
    Missile missile;


    Transform targetTransform;

    // From Missile Data
    float targetSearchSpeed;
    float boresightAngle;
    float lockDistance;

    // Status
    float lockProgress;
    bool isLocked;

    
    [SerializeField]
    [Range(0.1f, 1)]
    float lockSpeedPenalty = 0.1f;

    void ResetLock()
    {
        isLocked = false;
        lockProgress = 0;
    }

    float GetAngleBetweenTransform(Transform otherTransform)
    {
        Vector3 direction = transform.forward;
        Vector3 diff = otherTransform.position - transform.position;
        return Vector3.Angle(diff, direction);
    }
    
    void CheckTargetLock()
    {
        // No target
        if(targetTransform == null)
        {
            ResetLock();
            return;
        }

        float distance = Vector3.Distance(targetTransform.position, transform.position);

        // Exceed lockable distance
        if(distance > lockDistance)
        {
            ResetLock();
            return;
        }
        
        float targetAngle = GetAngleBetweenTransform(targetTransform);
        
        // When the target exists, increase lockProgress
        // if lockProgress >= targetAngle, it means the target is locked
        // Missed the Target
        if(targetAngle > boresightAngle)
        {
            ResetLock();
        }

        // Locking...
        else
        {
            // Lock Progress
            if(isLocked == false)
            {
                lockProgress += targetSearchSpeed * Time.deltaTime;
            }

            // Locked!
            if(lockProgress >= targetAngle)
            {
                isLocked = true;
                lockProgress = boresightAngle;
            }
            // Still Locking...
            else
            {
                isLocked = false;
            }
        }
    }

    // Start is called before the first frame update
    void Start()
    {
        boresightAngle = missile.boresightAngle;
        targetSearchSpeed = missile.targetSearchSpeed * lockSpeedPenalty;
        lockDistance = missile.lockDistance;

        ResetLock();

        targetTransform = GameManager.PlayerAircraft.transform;
    }

    // Update is called once per frame
    void Update()
    {
        CheckTargetLock();
    }
}

대부분의 코드는 플레이어 락온에서 사용했던 코드들을 가져왔습니다.

플레이어 락온 코드와는 다르게 float lockSpeedPenalty가 있는데,
플레이어만큼 락온을 빨리 해서 발사하는 것을 막기 위해 락온 속도에 패널티를 붙이는 용도로 사용합니다.

픽시에 락온 스크립트를 붙이고, 발사할 미사일을 추가합니다.


플레이어를 향해 비행할 때 실시간으로 각도를 계산하고,
미사일의 탐지 각도 내에 들어가면 lockProgress를 증가시킵니다.

lockProgress가 플레이어와의 각도 targetAngle을 넘어서면 락온 상태가 됩니다. (Locked = true)



미사일 발사하기

일단 발사부터

우선 락온 상태에 상관없이 저를 향해 미사일을 발사하도록 만들어보겠습니다.



[SerializeField]
Missile missile;

TargetObject targetObject;
AircraftAI aircraftAI;

void LaunchMissile()
{
    // Get from Object Pool and Launch
    GameObject missile = GameManager.Instance.missileObjectPool.GetPooledObject();
    missile.transform.position = missileLaunchTransform.position;
    missile.transform.rotation = transform.rotation;
    missile.SetActive(true);

    Missile missileScript = missile.GetComponent<Missile>();
    missileScript.Launch(targetObject, aircraftAI.Speed + 15, gameObject.layer);
}

// Start is called before the first frame update
void Start()
{
    boresightAngle = missile.boresightAngle;
    targetSearchSpeed = missile.targetSearchSpeed * lockSpeedPenalty;
    lockDistance = missile.lockDistance;
    aircraftAI = GetComponent<AircraftAI>();

    ResetLock();

    targetObject = GameManager.PlayerAircraft;

    InvokeRepeating("LaunchMissile", 1, 1);
}

미사일은 LaunchMissile을 호출해서 발사합니다.

이 발사 기능을 1초마다 작동시켜보죠...


미사일을 잘 발사하고 있고, 저는 그 미사일을 잘 맞고 있습니다.

맞을 때마다 오른쪽 하단에 있는 UI의 DMG 값이 올라가는 것으로도 확인할 수 있습니다.


가까이서 봐도 미사일을 잘 발사하고 있는 것을 확인할 수 있습니다.





그러고 보니 플레이어가 날린 미사일이 아닌것도 MISSED라는 UI를 표시해주고 있었네요.
여기는 고쳐야겠습니다.



Missile.cs

void ShowMissedLabel()
{
    if(gameObject.layer == LayerMask.NameToLayer("Player"))
    {
        GameManager.UIController.SetLabel(AlertUIController.LabelEnum.Missed);
    }
}

플레이어가 발사한 미사일은 layer 값이 "Player"입니다.
layer 값으로 플레이어의 미사일인지 판단해서 UI를 표시하게 바꿔줬습니다.



미니맵에 미사일 표시하기

플레이어를 향해 다가오고 있는 흰색 막대기가 미사일입니다.

플레이어가 발사한 미사일과, 플레이어를 향해 날아오고 있는 미사일은 미니맵 UI에 보여줘야 합니다.
하지만 이 게임은 1:1 보스전을 만들려고 하는 것이니 그냥 모든 미사일을 보여주면 되겠죠.

락온이 되지 않았거나, 목표를 놓친 미사일은 UI에서 보여주지 않습니다.



Missile.cs

// UI
[Header("UI")]
...
public MinimapSprite minimapSprite;

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

    minimapSprite.SetMinimapSpriteVisible(target != null);

    ...
}

void LookAtTarget()
{
    float angle = Vector3.Angle(targetDir, transform.forward);

    if(angle > boresightAngle)
    {
        // UI
        minimapSprite.SetMinimapSpriteVisible(false);

        ...
    }
}

미니맵에 보여주는 이미지를 제어하는 스크립트인 MinimapSprite를 미사일이 들고 있게 한 다음,

발사할 때 타겟이 있으면 보여주고, 빗나가면 다시 숨깁니다.

미사일 프리팹에도 MinimapSprite를 추가합니다.
그냥 막대 하나를 그려서 붙이면 되네요.

서로를 향해 발사한 미사일이 미니맵에 잘 표시되고 있습니다.


발사 주기가 1초라서 그런지 정말 쉴새없이 쏘네요.



락온된 상태에서 랜덤으로 발사하기

발사 자체는 확인했고, 이제 자연스럽게 미사일을 발사하면 됩니다.

락온된 상태인지는 LaunchMissile() 호출 시에 검사해주면 되는데,
락온된 상태라고 해서 1초마다 미사일을 날릴수는 없잖아요?


정직하게 구현하자면 미사일 슬롯을 여러개 놓고, 그 슬롯마다 발사할 때 쿨타임을 적용하면서 그 쿨타임을 체크한 후에 발사해야 하지만,

그냥 1초마다 발사하던 걸 사용자가 딜레이를 설정해서 발사하게 시키고,
발사할 때마다 락온 상태를 확인해서 락온된 상태일때만 발사시키는 방식으로 바꿔보겠습니다.

EnemyAircraft.cs

[SerializeField]
float fireCheckDelay = 5.0f;

void LaunchMissile()
{
    if(isLocked == false) return;

    ...
}

// Start is called before the first frame update
void Start()
{
    ...
    
    InvokeRepeating("LaunchMissile", 1, fireCheckDelay);
}

InvokeRepeating의 두 번째 변수는 첫 번째 실행에 걸리는 딜레이,
세 번째 변수는 첫 실행 후의 실행 주기입니다.

fireCheckDelay 초마다 LaunchMissile()을 호출하게 됩니다.

LaunchMissile()에서는 락온 되었는지 확인하는 조건을 추가해서,
락온이 된 경우에만 실제로 미사일을 발사하는 코드를 실행합니다.



게임 실행 후 1초 후에 미사일을 발사하라고 세팅했는데,
락온 속도에 페널티가 있어 1초 안에 락온이 안 돼서 발사를 못 했군요.

페널티를 완화해봤습니다.
1초 안에 락온이 완료되면 이렇게 미사일을 발사합니다.

락온된 상태에서는 이렇게 미사일을 발사하는 것을 미니맵 UI에서 확인할 수 있습니다.




이 쯤에서 AI의 공격성을 조절하는 방법을 생각해봅시다.

  • AI가 발사하는 미사일의 성능을 조절하거나,
  • 공격 주기를 조절하거나,
  • 락온 속도를 조절하거나,
  • 특수무기를 섞어서 공격하게 하거나,
  • 플레이어의 후방을 집요하게 파고드는 기동을 하거나,
    ...

다양한 방법이 존재하겠죠.


일단 인공지능이 플레이어를 공격하게 하는 목표는 달성했으니,
인공지능의 공격성을 정밀하게 조절하는 일은 스테이지를 제작하는 과정에서 다시 다뤄보겠습니다.

게임 난이도 조절이랑 연관되어있는 요소라서, 섣불리 건드릴 필요는 없을 것 같습니다.



경고 시스템

여기서부터는 UI 영역입니다.
아, 미니맵에 미사일 표시하는 것도 UI였죠.

인공지능의 탐지 범위 내에 플레이어가 들어갈 경우 경고를 띄워주는 기능을 만들어보려고 합니다.
락온되고 있는 경우에는 붉은색 "WARNING" 이라는 문구가 떠야 합니다.



TargetObject.cs

public bool IsLocking
{
    get { return isLocking; }
    set { isLocking = value; }
}

public virtual void OnMissileAlert()
{
    ...
}

미사일의 경우에는 직접 List<Missile>을 통해 관리할 필요가 있었지만,
락온 여부는 bool로 관리해도 문제는 없을 것 같습니다.

그리고 미사일 경고 시에 호출하는 함수 이름이 OnWarning()이었는데,
이름이 겹치는 것을 방지하기 위해 OnMissileAlert()로 이름을 변경했습니다.



EnemyWeaponController.cs

void ResetLock()
{
    isLocked = false;
    lockProgress = 0;

    if(targetObject != null)
    {
        targetObject.IsLocking = false;
    }
}

void CheckTargetLock()
{
    ...
    
    if(targetAngle > boresightAngle)
    {
        ResetLock();
    }

    // Locking...
    else
    {
        targetObject.IsLocking = true;

        ...
    }
}

EnemyWeaponController에서는 isLocking 상태를 바꿔주는 코드를 추가합니다.
탐지범위 밖이라면 false, 안쪽이라면 true로 설정합니다.



PlayerAircraft.cs

public enum WarningStatus
{
    NONE,
    WARNING,
    MISSILE_ALERT,
    MISSILE_ALERT_EMERGENCY
}

public WarningStatus GetWarningStatus()
{
    if(lockedMissiles.Count > 0)
    {
        foreach(Missile missile in lockedMissiles)
        {
            float distance = Vector3.Distance(transform.position, missile.transform.position);
            if(distance < missileEmergencyDistance)
            {
                return WarningStatus.MISSILE_ALERT_EMERGENCY;
            }
        }
    
        return WarningStatus.MISSILE_ALERT;
    }

    if(IsLocking == true)
    {
        return WarningStatus.WARNING;
    }

    return WarningStatus.NONE;
}

경고 시스템은 플레이어의 비행기에만 필요하기 때문에, PlayerAircraft 스크립트에만 작성합니다.

경고 상황은 크게 4가지로 나뉩니다.

1: 경고 없음 : 평상시
2: 락온 경고 : 락온되고 있는 상태, 미사일이 날아오지는 않음
3: 미사일 접근 경고 : 미사일이 날아오고 있음
4: 미사일 접근 경고 (긴급) : 미사일이 근처에 있음

아래로 내려갈수록 우선순위가 크기 때문에, 4부터 1까지 차례대로 검사해줍니다.

4: 미사일 접근 경고 (긴급) : 날아오는 미사일이 missileEmergencyDistance 내에 있을 때
3: 미사일 접근 경고 : 날아오는 미사일이 하나 이상 있을 때
2: 락온 경고 : 락온되고 있을 때 (미사일은 없음)
1: 경고 없음 : 위 모든 상황에 해당되지 않는 경우



AlertUIController.cs

[SerializeField]
GameObject alertParent;

void HideAllAttackAlertUI()
{
    Transform alertTransform = alertParent.transform;
    for(int i = 0; i < alertTransform.childCount; i++)
    {
        alertTransform.GetChild(i).gameObject.SetActive(false);
    }
}

void BlinkAttackAlertUI()
{
    alertParent.SetActive(!alertParent.activeInHierarchy);
}

void ShowAttackAlertUI()
{
    PlayerAircraft.WarningStatus warningStatus = GameManager.PlayerAircraft.GetWarningStatus();
    if(prevWarningStatus == warningStatus) return;

    prevWarningStatus = warningStatus;
    CancelInvoke("BlinkAttackAlertUI");
    HideAllAttackAlertUI();
    alertParent.SetActive(false);

    // Missile alert
    switch(warningStatus)
    {
        case PlayerAircraft.WarningStatus.MISSILE_ALERT_EMERGENCY:
            missileAlert.SetActive(true);
            InvokeRepeating("BlinkAttackAlertUI", 0, warningBlinkTime);
            break;
            
        case PlayerAircraft.WarningStatus.MISSILE_ALERT:
            missileAlert.SetActive(true);
            InvokeRepeating("BlinkAttackAlertUI", 0, warningBlinkTime);
            break;
            
        case PlayerAircraft.WarningStatus.WARNING:
            warning.SetActive(true);
            InvokeRepeating("BlinkAttackAlertUI", 0, warningBlinkTime);
            break;
            
        case PlayerAircraft.WarningStatus.NONE:
            warning.SetActive(false);
            break;
    }
}

void Update()
{
    ShowAttackAlertUI();
    ...
}

경고 UI를 띄우는 스크립트도 수정합니다.

Update()에서 현재 경고 상태를 검사하는 함수를 호출하되,
이전 프레임의 경고 상태와 다를 경우에만 (상태가 바뀌었을 때만) UI를 변경합니다.

공격과 관련된 경고 UI는 일정 주기마다 깜빡여야 하기 때문에 InvokeRepeating()을 사용합니다.




처음에는 락온되고 있는 상태이므로 WARNING,
미사일이 날아오고 있는 동안에는 MISSILE ALERT,
근접한 거리에 있으면 MISSLE ALERT EMERGENCY (UI에서는 MISSILE ALERT와 같은 표시)
위협이 모두 사라지면 NONE으로 바뀝니다.

(중간에 WARNING은 미사일이 지나간 도중에도 락온이 되어 있는 상태라서 표시되는 겁니다.)



UI 색상 변경

미사일이 날아오고 있을 때는 UI 색상도 바꿔줘야 합니다.

이전에 UI를 만들 때, 색상이 바뀌어야 하는 UI는 Material을 따로 뒀었다고 했죠.
색상을 바꾸는 함수도 같이 만들어뒀었는데, 이제 그 함수를 사용할 때가 되었습니다.

void ShowAttackAlertUI()
{
    ...

    // Missile alert
    switch(warningStatus)
    {
        case PlayerAircraft.WarningStatus.MISSILE_ALERT_EMERGENCY:
            ...

            GameManager.UIController.SetWarningUIColor(true);
            break;
            
        case PlayerAircraft.WarningStatus.MISSILE_ALERT:
            ...

            GameManager.UIController.SetWarningUIColor(true);
            break;
            
        case PlayerAircraft.WarningStatus.WARNING:
            ...

            GameManager.UIController.SetWarningUIColor(false);
            break;
            
        case PlayerAircraft.WarningStatus.NONE:
            ...
            
            GameManager.UIController.SetWarningUIColor(false);
            break;
    }
}

그냥 switch 문의 각 항목에다가 SetWarningUIColor()를 호출해주죠.
true를 넘기면 붉은색으로 바뀌고, false를 넘기면 초록색으로 바뀝니다.


이렇게 타겟 UI를 제외한 모든 곳의 색깔을 바꿔줘야 합니다.

오른쪽 하단에 있는 비행기 상태 UI도 예외는 아닙니다.

미니맵이나 HUD처럼 마스킹되는 RawImage는 Material을 바꾸는 것만으로는 제대로 색상이 바뀌지 않는 문제가 있습니다.
버그인 것 같은데, 방법을 찾아봐야겠네요.



미사일 방향 표시

미사일 경고 상태에서는 날아오는 미사일 방향도 알려줘야 합니다

비행기 주변에 있는 짧은 빨간색 원호가 현재 날아오는 미사일의 위치를 나타내줍니다.

짧은 원호 이미지를 하나 만들어온 후에, 코드를 작성해봅시다.




MissileIndicator.cs


public class MissileIndicator : MonoBehaviour
{
    Missile missile;
    Transform missileTransform;
    Transform aircraftTransform;
    RectTransform rectTransform;

    public Missile Missile
    {
        set
        {
            missile = value;
            missileTransform = missile.transform;
        }
    }

    void Awake()
    {
        rectTransform = GetComponent<RectTransform>();
    }

    void Start()
    {
        aircraftTransform = GameManager.PlayerAircraft.transform;
    }

    void OnEnable()
    {
        rectTransform.localScale = Vector3.one;
        rectTransform.localPosition = Vector3.zero;
    }
    
    // Update is called once per frame
    void Update()
    {
        if(missile == null || aircraftTransform == null) return;

        if(missile.IsDisabled)
        {
            gameObject.SetActive(false);
        }

        Vector3 relativePos = missileTransform.position - aircraftTransform.position;

        float angle = Mathf.Atan2(-relativePos.x, relativePos.z) * Mathf.Rad2Deg;
        angle += GameManager.CameraController.GetActiveCamera().transform.eulerAngles.y;
        rectTransform.localEulerAngles = new Vector3(0, 0, angle);
    }
}

MissileIndicator는 각각의 원호 UI에 붙여지는 스크립트입니다.

Update()에서 이 원호 UI에 해당하는 미사일과 비행기의 위치 차이를 계산해서 원호의 각도를 설정해줍니다.

그리고 조이스틱으로 카메라를 돌릴 때 원호 UI도 같이 돌아가야하기 때문에,
CameraController에서 현재 카메라의 회전값을 가져와서 조정해줍니다.


MissileIndicatorController.cs

public class MissileIndicatorController : MonoBehaviour
{
    [SerializeField]
    ObjectPool mslIndicatorObjectPool;

    public void AddMissileIndicator(Missile missile)
    {
        GameObject obj = mslIndicatorObjectPool.GetPooledObject();
        obj.GetComponent<MissileIndicator>().Missile = missile;
        obj.transform.SetParent(transform);
        obj.SetActive(true);
    }
}

미사일 경고 UI를 관리하는 컨트롤러입니다. 오브젝트 풀로 UI를 관리합니다.
(사실 계속 만들어지고 사라지는 건 오브젝트 풀로 관리하는 것이 좋지만, 몇 개는 빼먹었죠)

MissileIndicator를 가지는 UI 프리팹을 생성해서, 이 오브젝트의 자식에 붙여줍니다.


PlayerAircraft.cs

public override void AddLockedMissile(Missile missile)
{
    base.AddLockedMissile(missile);
    missileIndicatorController.AddMissileIndicator(missile);
}

이 UI는 "플레이어를 향해 날아오는 미사일이 추가될 때"에만 만들어져야 합니다.
PlayerAircraftAddLockedMissile()이 호출될 때만 저 UI가 만들어지도록 코드를 작성했습니다.


미사일 UI는 그냥 RawImage 하나와 MissileIndicator 컴포넌트를 가지고 있습니다.

이 미사일 UI는 UI 캔버스 내부에 들어가게 됩니다.
특이한 점은 Rotation.x가 50으로 약간 기울어져있다는 것이죠.

오브젝트 풀도 만들어준 다음, 컨트롤러에 연결한 후 실행합니다.


미사일 위치가 잘 표시되고 있고,

카메라를 돌린 상태에서도 제대로 표시되고 있습니다.



방향 표시 UI 깜빡이기

예시 화면을 다시 보죠.
미사일이 근접한 상황에서는 UI가 깜빡이고 반경이 줄어들어야 합니다.

scale을 줄이는 것으로도 괜찮게 나오는지는 잘 모르겠지만 일단 해봅시다.

MissileIndicator.cs


[SerializeField]
float blinkTimer = 0.1f;

[SerializeField]
float shrinkScaleValue = 0.7f;
[SerializeField]
float shrinkLerpAmount = 1;

Vector3 shrinkScale;
bool isEmergency = false;
RawImage rawImage;

void Blink()
{
    rawImage.enabled = !rawImage.enabled;
}

void Awake()
{
    rectTransform = GetComponent<RectTransform>();
    rawImage = GetComponent<RawImage>();
}

void Start()
{
    aircraftTransform = GameManager.PlayerAircraft.transform;
    shrinkScale = Vector3.one * shrinkScaleValue;
}

void OnEnable()
{
    rectTransform.localScale = Vector3.one;
    rectTransform.localPosition = Vector3.zero;

    rawImage.enabled = false;
    isEmergency = false;
}

void OnDisable()
{
    CancelInvoke();
}

// Update is called once per frame
void Update()
{
    ...

    Vector3 relativePos = missileTransform.position - aircraftTransform.position;
    // Emergency check
    float distance = relativePos.magnitude;
    if(isEmergency == false && distance < GameManager.PlayerAircraft.MissileEmergencyDistance)
    {
        isEmergency = true;
        InvokeRepeating("Blink", blinkTimer, blinkTimer);
    }

    if(isEmergency == true)
    {
        rectTransform.localScale = Vector3.Lerp(rectTransform.localScale, shrinkScale, shrinkLerpAmount * Time.deltaTime);
    }

    // Set Angle
    ...

    if(rawImage.enabled == false && isEmergency == false)
    {
        rawImage.enabled = true;
    }
}

긴급 범위 내에 놓였는지를 확인하는 거리인 missileEmergencyDistancePlayerAircraft로부터 가져오고,

이 거리보다 짧으면 깜빡이는 함수인 Blink()를 실행하게 합니다.
그리고 Lerp를 이용해서 localScale을 점차적으로 0.7배로 줄여봤습니다.


OnEnable()에서 rawImage를 비활성화시키는 코드가 있는데, 그 이유는...

생성하자마자 각도 등이 설정되지 않은 채로 활성화시키다보니 이렇게 위쪽에 깜박이는 현상이 발생하더라고요.
그걸 막기 위해 OnEnable()에서 비활성화시키고 Update()에서 다시 활성화시키고 있습니다.


scale을 줄이는 게 맞는건지는 잘 모르겠습니다만, 어쨌든 실행은 잘 되고 있습니다.

이제 인공지능의 미사일 발사 딜레이를 줄여보죠...


대환장 파티가 벌어질 조짐이 보이고 있습니다.

물론 실제로는 이 정도로 미사일을 많이 뿌리지는 않을 겁니다.


의도치 않게 서로의 미사일을 격추하는 상황이 나오고는 있는데,
뭐... 가능한 일이긴 하니까요. (일부러 그렇게 설정하기도 했습니다.)

아무튼 이제는 일방적으로 미사일을 날리는 게 아닌, 제대로 된 공중전을 벌일 수 있을 것 같습니다.


이제 죽을 각오를 하면서 테스트에 임할 수 있게 되었군요.





다음 포스트 미리보기

그냥 얻어맞으면 재미없죠.

멋진(?) 데스캠을 만들어볼 때가 되었습니다.



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

0개의 댓글