Ace Combat Zero: 유니티로 구현하기 #18 : 페이즈 시스템

Lunetis·2021년 7월 3일
0

Ace Combat Zero

목록 보기
19/27
post-thumbnail



지금까지는 게임에 필요한 기반 작업들을 해왔습니다.

입력값을 받아오는 것부터 시작해서,
비행기를 조종하고, 무기를 발사하고,
UI, 인공지능, 데미지 시스템, 사운드 등등 많은 것을 구현했습니다.


이제 이 요소들을 이용해서 제대로 게임을 만들어볼 때입니다.

게임 승리 조건과 패배 조건, 보스 페이즈 등을 구현해서 게임의 한 사이클을 완성해보겠습니다.


보스 전용 스크립트

저는 에이스 컴뱃 제로의 마지막 미션인 "ZERO" 를 구현하는 것을 목표로 프로젝트를 진행하고 있습니다.

이 미션은 1:1 보스전이며, 3개의 페이즈로 구성되어 있습니다.

보스의 이름은 래리 폴크(Larry Foulke), TAC 네임은 픽시(Pixy) 입니다.
이제부터 그냥 편하게 픽시라고 부르겠습니다.


1페이즈 : 레이저 무기를 라이트세이버 내지는 빔사벨 마냥 사방으로 휘두르면서 플레이어를 위협합니다.


2페이즈 : 특수 미사일을 날립니다. 핵폭발같지만 설정에 따르면 핵은 아니고 산탄 미사일입니다.


3페이즈 : 후방 공격을 모조리 튕겨내는 방어 시스템이 가동됩니다.
서로 마주보는 헤드온 상황에서만 공격할 수 있습니다. 뒤에서 공격하는 미사일과 기총(???)은 모두 튕겨나갑니다.



이렇게 각 페이즈마다 픽시의 행동 양식이 달라지도록 구현해야 하며,
다음 페이즈로 진입할 수 있는 조건도 만들어져야 합니다.

다행(?)스럽게도 다음 페이즈로 진입하는 방법은 그렇게 어렵지 않습니다.

그냥 픽시의 체력을 다 깎아놓으면 돼요.



페이즈 시스템

지금 비행기 스크립트는 체력이 0 이하로 떨어지면 폭발하도록 되어 있습니다.

하지만 이 보스전에서는 체력이 떨어졌을 때 폭발하지 않고 공격 불가능한 무적 상태가 된 후,
대사를 읊는 등의 연출이 끝나면 다시 공격 가능한 상태로 바뀝니다.

공격 가능 상태를 조정할 수 있는 기능을 만들어줍시다.


PixyScript.cs

public class PixyScript : EnemyAircraft
{
    [Header("PixyScript Properties")]
    bool isInvincible;
    bool isAttackable;

    public bool IsInvincible
    {
        set { isInvincible = value; }
    }

    public bool IsAttackable
    {
        set
        {
            isAttackable = value;
            SetMinimapSpriteVisible(value);
            
            if(isAttackable == false)
            {
                GameManager.Instance.RemoveEnemy(this);
                GameManager.TargetController.RemoveTargetUI(this);
                GameManager.WeaponController?.ChangeTarget();
            }

            else
            {
                GameManager.Instance.AddEnemy(this);
                GameManager.TargetController.CreateTargetUI(this);
            }
        }
    }

    public void ActivateEnemy()
    {
        Debug.Log("Activated");
        hp = objectInfo.HP;
        IsAttackable = true;
    }

    public override void OnDamage(float damage, int layer)
    {
        if(isAttackable == false) return;

        float applyDamage = (isInvincible == true) ? 0 : damage;
        base.OnDamage(applyDamage, layer);
    }

    protected override void DestroyObject()
    {
        IsAttackable = false;
    }

    // Start is called before the first frame update
    protected override void Start()
    {
        base.Start();
        isInvincible = false;
        isAttackable = true;
    }

    // Update is called once per frame
    protected override void Update()
    {
        base.Update();
        GameManager.PrintDebugText("HP : " + hp);
    }
}

먼저 보스 전용 두 가지 속성을 추가했습니다.


  1. isInvincible : 락온, 피격은 가능하지만 데미지는 받지 않습니다.

등장인물이 중요한 대사를 말할 때, 그 대사가 끝나기 전까지는 죽지 않게 만드는 경우가 있죠.

보스전에서 무적 상태로 만들어야 할 정도로 대사 연출이 많이 나오는 일이 있는지는 저도 잘 모르겠지만, 일단 무적 속성을 추가시켜놓겠습니다.


  1. isAttackable : 락온이 되지 않습니다. 우연히 총알이나 미사일로 인한 피격은 가능하지만 역시 데미지는 받지 않습니다.

실제로 컨트롤할 공격 가능/불가능 속성입니다.

체력이 모두 닳게 되면 DestroyObject() 함수를 실행하는데, 여기서 보통 파괴시키는 코드를 작성해놓지만 픽시는 파괴되지 않고 다음 페이즈로 넘어가야 하므로 IsAttackablefalse로 두는 코드만 작성합니다.

public bool IsAttackablefalse가 되면, private bool isAttackablefalse로 두는 것 뿐만 아니라 타겟을 지워버리는 함수인 TargetController.RemoveTargetUI도 호출합니다.

IsAttackable이 바뀔 때 미니맵 스프라이트의 표시 여부도 변경해줍니다.


기능 테스트를 위해 특정 키를 누르면 IsAttackable이 다시 true가 되고, 체력이 최대치로 회복되는 함수인 ActivateEnemy()를 추가했습니다.

스페이스 바를 누를 때 Activate Enemy 이벤트가 실행되도록 Input Action을 추가하고,
Activate Enemy에는 활성화 함수를 추가합니다.


피격으로 인해 체력이 0 이하로 떨어지면 이렇게 UI가 사라지고 락온이 불가능하게 됩니다.

이 상태에서 스페이스바를 눌렀을 때, 다시 활성화되고 락온 및 공격이 가능하게 됩니다.

함수를 호출하는 방식 뿐만 아니라, 이렇게 public 변수의 값을 수정하는 것도 같은 기능을 합니다.



페이즈 분리 및 승리조건 추가

이 파트에서 구현하는 기능은 최종 결과물에 적용되지 않습니다.

스페이스 바를 누르는 것 대신, 그냥 몇 초 후에 자동으로 다시 풀어버리는 상황을 만들어봅시다.

그리고 체력을 소진할 때마다 페이즈 값을 변경해서, 3페이즈 째에 체력이 소진되면 "Destroyed" 라벨을 띄워봅시다.


int phase = 1;

[SerializeField]
UnityEvent phase1EndEvents;
[SerializeField]
UnityEvent phase2EndEvents;
[SerializeField]
UnityEvent phase3EndEvents;

protected override void DestroyObject()
{
    IsAttackable = false;

    switch(phase)
    {
        case 1:
            phase1EndEvents.Invoke();
            break;
            
        case 2:
            phase2EndEvents.Invoke();
            break;
            
        case 3:
            phase3EndEvents.Invoke();
            break;
    }

    phase++;
}

public void CallDestroyFunction()
{
    CommonDestroyFunction();
}

public void InvokeActivateEnemy()
{
    Invoke("ActivateEnemy", 3.0f);
}

public void ActivateEnemy()
{
    Debug.Log("Activated");
    hp = objectInfo.HP;
    IsAttackable = true;
}

각 페이즈가 끝날 때마다 실행할 UnityEvent phase?EndEvents를 추가해주고,
DestroyObject()에서는 해당 페이즈에 맞는 UnityEvent를 실행시킨 후 phase 값을 1 증가시킵니다.

switch로 감쌀 필요 없이 UnityEvent를 배열이나 리스트로 할당하고 UnityEvent[phase].Invoke() 형식으로 실행시킬 수도 있지만,

Inspector 창에서 보여지는 편의상 각각의 변수로 만들어줬습니다.


그리고 3초 후에 다시 활성화시키는 InvokeActivateEnemy() 함수를 추가했습니다.
이 함수는 페이즈 1, 페이즈 2가 끝날 때 실행되는 이벤트에 추가시켜줄 겁니다.

CallDestroyFunction()protected 함수인 CommonDestroyFunction()을 실행하는 함수입니다.
이 함수는 페이즈 3 (최종 페이즈)가 끝날 때 "Destroyed" 라벨을 표시하고 점수를 올리는 용도로 호출합니다.


이제 각 페이즈마다 실행할 이벤트들을 등록하고 게임을 실행해보려고 합니다.
페이즈 1, 2에서는 3초 후에 다시 공격 가능한 상태로 만드는 InvokeActivateEnemy,
마지막 페이즈인 페이즈 3에서는 파괴 처리를 위한 CallDestroyFunction을 등록합니다.

그리고 디버깅용 텍스트에 현재 페이즈도 확인할 수 있도록 코드를 추가해보죠.


페이즈 1, 2에서는 체력이 0 이하가 되면 3초동안 락온이 불가능해지는 상태가 되고, 그 이후에 다시 락온과 공격이 가능한 상태로 변합니다.
여기서는 Hit 라벨만 뜨고, Destroyed 라벨은 뜨지 않습니다.

페이즈 3에서는 체력이 0 이하가 되면 Destroyed 라벨이 뜹니다.

해당 미션에서는 마지막 페이즈 공략에 성공해도 "MISSION ACCOMPLISHED"가 뜨지 않습니다.


테스트하다가 알게 된 건데, 무적 상태에서는 픽시도 공격을 멈춰야 하는데 그 처리를 하지 않았네요.


PixyScript.cs

EnemyWeaponController weaponController;

public bool IsInvincible
{
    set { isInvincible = value; }
}

public bool IsAttackable
{
    set
    {
        ...
        weaponController.enabled = value;
        ...
    }
}

// Start is called before the first frame update
protected override void Start()
{
    base.Start();
    weaponController = GetComponent<EnemyWeaponController>();
}

비활성화/활성화 시에는 EnemyWeaponController를 같이 비활성화시킵시다.


EnemyWeaponController.cs

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

그리고 EnemyWeaponControllerOnDisable()에서 플레이어를 향한 락온을 해제시켜서
플레이어 화면에 Warning 라벨과 경고음이 뜨는 것을 막아줍니다.



특수무기 추가

위에서 언급했던 특수무기를 추가해봅시다.

페이즈 1에서 사용하는 레이저, 페이즈 2에서 사용하는 산탄 미사일 특수무기와,
페이즈 3은 뒤에서 오는 모든 미사일과 기총을 모두 튕겨내는 방어 시스템을 구현해야 합니다.



페이즈 1 : 레이저

레이저 알고리즘

레이저를 빔샤벨마냥 휘두른다고 했죠?

일단 항상 플레이어를 향해 정확히 발사하도록 만들어봅시다.


PixyTLS.cs


public class PixyTLS : MonoBehaviour
{
    AudioSource audioSource;

    [SerializeField]
    LineRenderer lineRenderer;
    [SerializeField]
    Transform laserTransform;
    [SerializeField]
    float laserActivateTime;
    [SerializeField]
    float laserCooldownTime;
    
    [SerializeField]
    float distance = 1000;
    

    void ActivateTLS()
    {
        lineRenderer.enabled = true;
        Invoke("DeactivateTLS", laserActivateTime);
    }

    void DeactivateTLS()
    {
        lineRenderer.enabled = false;
        Invoke("ActivateTLS", laserCooldownTime);
    }

    // Start is called before the first frame update
    void Start()
    {
        ActivateTLS();
    }

    // Update is called once per frame
    void Update()
    {
        if(GameManager.PlayerAircraft == null) return;

        Vector3 launchPosition = laserTransform.position;
        Vector3 directionVector = (GameManager.PlayerAircraft.transform.position - launchPosition).normalized;
        lineRenderer.SetPosition(0, launchPosition);
        lineRenderer.SetPosition(1, launchPosition + directionVector * distance);
    }
}

TLS는 Tactical Laser System의 준말입니다.

레이저는 LineRenderer를 이용해서 표현해보죠.

ActivateTLS(), DeactivateTLS()는 서로 Invoke()로 함수 호출을 예약하면서 레이저를 껐다 키도록 만들어줬습니다.
그리고 Update()에서는 LineRenderer의 시작점과 끝점을 설정합니다.
시작점은 미리 정의된 레이저 발사 위치, 끝점은 플레이어의 위치입니다.

원래 이 미션에서 픽시는 F-15가 아닌 ADFX-02라는 가상의 기체를 타고,
그 기체 위에 거대한 레이저 발사 시스템이 탑재되어 있지만...


모델링이 없는 관계로 F-15의 노즈콘(Nose cone) 부분에서 발사한다고 칩시다.

그리고 픽시에 TLS 스크립트를 추가합니다.


플레이어가 어디를 향하든 간에 계속 레이저에 조사되고 있습니다.

5초동안 레이저가 발사되다가 꺼지면 2초 후에 다시 레이저가 발사됩니다.


레이저가 바로 플레이어를 맞히면 너무 사기니까, 서서히 방향을 틀도록 바꿔줘야 합니다.


void ActivateTLS()
{
    lineRenderer.enabled = true;
    laserTargetPosition = laserTransform.position + laserTransform.right * distance;
    lineRenderer.SetPosition(1, laserTargetPosition);
    Invoke("DeactivateTLS", laserActivateTime);
}

void RotateTLS()
{
    if(GameManager.PlayerAircraft == null) return;
    
    Vector3 launchPosition = laserTransform.position;

    laserTargetPosition = Vector3.Lerp(laserTargetPosition, GameManager.PlayerAircraft.transform.position, laserRotateLerpAmount * Time.deltaTime);
    Vector3 directionVector = (laserTargetPosition - launchPosition).normalized;

    lineRenderer.SetPosition(0, launchPosition);
    lineRenderer.SetPosition(1, launchPosition + directionVector * distance);
}

// Start is called before the first frame update
void Start()
{
    DeactivateTLS();
}

// Update is called once per frame
void Update()
{
    if(lineRenderer.enabled == true)
    {
        RotateTLS();
    }
}

레이저를 돌리는 기능을 RotateTLS()라는 함수로 따로 분리시키고,
레이저의 목표 지점이 플레이어의 위치를 향해 서서히 바뀌도록 Vector3.Lerp를 사용했습니다.

RotateTLS()lineRenderer가 켜져있을 때만 호출합니다.


그리고 레이저가 발사를 시작할 때는 강제로 비행기의 오른쪽을 향해 발사하도록 설정했는데,
(ActivateTLS()laserTargetPosition 초기화 부분)

실제로 미션 시작 시 픽시는 플레이어를 향해 다가오면서 (픽시 입장에서) 오른쪽으로 레이저를 발사하기 때문이죠.

그리고 앞으로도 발사를 시작할 때 그냥 오른쪽을 향해 발사하게끔 만들어주려고 합니다.

실제 헤드온 상황을 만들기 위해, 최초 목적지를 플레이어의 목적지로 설정해주겠습니다.


이렇게 오른쪽으로 레이저를 발사하면서 저에게 미사일을 날리고 있습니다.


그러고보니 1페이즈에서는 미사일을 날리면 안 되는데요...

일단 특수무기를 테스트하는 동안 비활성화시킵시다.


개발자의 목숨이 점점 위태로워지고 있습니다.



...아, 데미지 시스템을 안 만들었네요.

진짜 위태롭게 만들겠습니다.


void RotateTLS()
{
    if(GameManager.PlayerAircraft == null) return;
    
    Vector3 launchPosition = laserTransform.position;
    laserTargetPosition = Vector3.Lerp(laserTargetPosition, GameManager.PlayerAircraft.transform.position, laserRotateLerpAmount * Time.deltaTime);
    Vector3 directionVector = (laserTargetPosition - launchPosition).normalized;

    // Damage
    RaycastHit hit;
    Physics.Raycast(lineRenderer.GetPosition(0), directionVector, out hit, distance);

    float lineDistance = distance;

    if(hit.collider != null)
    {
        Debug.Log(hit.collider);
        lineDistance = hit.distance;

        if(hit.collider.gameObject.layer == LayerMask.NameToLayer("Player"))
        {
            hit.collider.GetComponent<TargetObject>()?.OnDamage(damage, gameObject.layer);
        }
    }

    lineRenderer.SetPosition(0, launchPosition);
    lineRenderer.SetPosition(1, launchPosition + directionVector * lineDistance);
}

RaycastHit을 이용해서 플레이어를 향해 Ray를 발사하되,
Ray에 맞은 대상의 layer"Player"일 때만 OnDamage()를 호출합니다.

그리고 레이저가 지면이나 오브젝트를 뚫고 들어가는 일이 없도록 lineRenderer의 길이를 Ray의 길이로 설정합니다.

이렇게 뚫고 들어가면 지형에 막히는 레이저 공격에도 피격당하는 것처럼 보일 수 있기 때문에,
(실제로는 hit.collider가 지면이나 오브젝트가 되어서 피격되지 않습니다.)

실제 피격 위치까지만 레이저가 보이도록 lineRenderer의 거리를 설정해줍니다.


데미지를 입히는 함수는 Update()에서 실행되기 때문에 약간의 조정이 필요하지만,
까짓거 즉사기로 만들어보죠.


이제 방심하면 훅 갑니다.



레이저 소리 추가

이전 포스트에서 효과음들을 여러 개 추가했는데, 그 중에는 레이저 소리도 있었습니다.

바로 써먹어봅시다.

bool isActivated;

[Header("Sounds")]
[SerializeField]
AudioSource audioSource;
[SerializeField]
float audioLerpAmount = 5;

void ActivateTLS()
{
    isActivated = true;
    ...
}

void DeactivateTLS()
{
    isActivated = false;
    ...
}

void AdjustVolume()
{
    float targetVolume = (isActivated == true) ? 1 : 0;
    audioSource.volume = Mathf.Lerp(audioSource.volume, targetVolume, audioLerpAmount * Time.deltaTime);
}

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

Lerp를 이용해서 AudioSource의 음량을 서서히 올리거나 줄입니다.

지금부터 bool isActivated 라는 변수를 두고, 레이저를 키거나 끌 때 이 값을 바꿔주려고 합니다.
목표 음량은 이 isActivated 값으로 결정합니다.


레이저 Audio Source의 설정값입니다.

초기 음량은 0, Spatial Blend는 1, Doppler Level은 0.1, Max Distance는 1000입니다.
Doppler Level의 초기값은 1인데, 실행해보니 거리에 따른 소리 왜곡값이 너무 이상하게 들리길래 0.1로 낮춰놓았습니다.



레이저 이펙트

지금 보이는 핑크색 레이저는 누가봐도 만들다 만 것 같은 느낌을 주고 있습니다.
아니면 그래픽이 깨졌거나요.


빠르게 레이저 이미지를 만들어주고,

Material도 만든 후,

Line Renderer의 Material에 붙여줬습니다.

이왕 만든 거 활성화/비활성화 시에 레이저의 너비도 조절하는 스크립트를 추가합시다.


void ActivateTLS()
{
    isActivated = true;
    lineWidth = 0.5f;
    ...
}

void AdjustWidth()
{
    float targetWidth = (isActivated == true) ? 1 : 0;
    lineWidth = Mathf.Lerp(lineWidth, targetWidth, lineWidthLerpAmount * Time.deltaTime);
    lineRenderer.startWidth = lineWidth;
    lineRenderer.endWidth = lineWidth;
}

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

isActivatedtrue면 1, 아니면 0으로 LineRendererstartWidthendWidth를 서서히 조절합니다.

예외적으로 레이저 발사를 시작할 때는 즉시 width를 0.5로 놓아서 인식을 쉽게 할 수 있도록 했습니다.


그러면 실행해서 확인해볼...


이렇게 레이저가 꺼질 때와 켜질 때 레이저의 너비가 조절되는 모습을 볼 수 있습니다.
바로 꺼지는 것 보다는 좀 더 연출이 자연스러워졌습니다.



비활성화 코드 추가

이 레이저 공격 시스템은 페이즈 1을 클리어한 후에는 비활성화되어야 합니다.

public void DisableTLS()
{
    CancelInvoke();
    isActivated = false;
}

예약된 Invoke를 모두 해제하고 isActivatedfalse로 두는 함수를 하나 만든 후,

페이즈 1이 끝날 때 실행되는 이벤트에 이 함수를 추가합니다.


페이즈 2에 진입한 이후에는 더 이상 레이저를 발사하지 않습니다.



페이즈 2 : 산탄 미사일

산탄 미사일 알고리즘

출처 : https://youtu.be/FrnK-_qAnbw


페이즈 2 전용 특수무기는 산탄 미사일 (MPBM : Multi-Purpose Burst Missile)입니다.
핵폭발처럼 거대한 화구를 만들어내는 것이 특징이며, 에이스 컴뱃 제로, 6, 7편에 등장합니다.

그런데 이 미사일은 그냥 자기가 날아가는 방향대로 쏴대는 것 같습니다.
플레이 영상을 보시면, 딱히 플레이어가 있는 위치를 향해 쏘는 것 같지는 않아 보이죠.

그러면 약식으로 만들어보겠습니다.


MPBMEffect.cs

public class MPBMEffect : MonoBehaviour
{
    [SerializeField]
    float damage;

    [SerializeField]
    float damageDistance;

    [SerializeField]
    float damageRepeatTime = 0.3f;
    float damageTimer;

    void ApplyDamage()
    {
        if(GameManager.PlayerAircraft == null) return;

        float distance = Vector3.Distance(transform.position, GameManager.PlayerAircraft.transform.position);
        if(distance < damageDistance && damageTimer <= 0)
        {
            GameManager.PlayerAircraft.OnDamage(damage, gameObject.layer);
            damageTimer = damageRepeatTime;
        }
    }

    void Start()
    {
        damageTimer = 0;
    }

    void Update()
    {
        ApplyDamage();

        if(damageTimer > 0)
        {
            damageTimer -= Time.deltaTime;
        }
    }
}

먼저 화구에 적용될 데미지 스크립트입니다.

화구는 미사일이 폭발하는 위치에 생성되며, 화구와 플레이어의 거리가 damageDistance보다 가까우면
damageRepeatTime마다 damage만큼 피해를 줍니다.

OnTriggerStay를 쓰면 되지 않느냐 할 수 있는데, 그 코드는 Collider에 "닿는 중"일 때에 호출되며, Collider 내부에 비행기가 있으면 함수가 호출되지 않습니다.



MPBM.cs

public class MPBM : MonoBehaviour
{
    Rigidbody rb;
    Vector3 targetPosition;

    [Header("Missile Properties")]
    [SerializeField]
    float speed = 100;
    [SerializeField]
    float turningForce = 1;

    [Header("MPBM Properties")]
    [SerializeField]
    GameObject effectPrefab;
    [SerializeField]
    float explosionMinTime = 1.0f;
    [SerializeField]
    float explosionMaxTime = 2.0f;
    
    // Effect
    [SerializeField]
    Transform smokeTrailPosition;
    GameObject smokeTrailEffect;

    
    public void Launch(Vector3 targetPosition)
    {
        this.targetPosition = targetPosition;
        float explosionTimer = Random.Range(explosionMinTime, explosionMaxTime);
        Invoke("Explode", explosionTimer);
        rb.velocity = transform.forward * speed;

        smokeTrailEffect = GameManager.Instance.smokeTrailEffectObjectPool.GetPooledObject();
        if(smokeTrailEffect != null)
        {
            smokeTrailEffect.GetComponent<SmokeTrail>()?.SetFollowTransform(smokeTrailPosition);
            smokeTrailEffect.SetActive(true);
        }
    }

    void Explode()
    {
        GameObject mpbmEffect = GameManager.Instance.mpbmEffectObjectPool.GetPooledObject();
        mpbmEffect.transform.position = transform.position;
        mpbmEffect.transform.rotation = transform.rotation;
        mpbmEffect.SetActive(true);
    }

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

        Quaternion lookRotation = Quaternion.LookRotation(targetDir);
        rb.rotation = Quaternion.Slerp(rb.rotation, lookRotation, turningForce * Time.fixedDeltaTime);
    }

    void DisableMissile()
    {
        gameObject.SetActive(false);
    }

    private void OnDisable()
    {
        if(smokeTrailEffect != null)
        {
            smokeTrailEffect.GetComponent<SmokeTrail>().StopFollow();
        }
        
        rb.velocity = Vector3.zero;
        rb.angularVelocity = Vector3.zero;
        CancelInvoke();
    }

    void Awake()
    {
        rb = GetComponent<Rigidbody>();
    }

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

산탄 미사일에 추가될 스크립트입니다.

여러모로 Missile.cs의 간소화 버전이라고 할 수 있습니다.

락온과 경고 기능이 없고, 속도가 점점 증가하지도 않으며,
그냥 폭발할 시간이 되면 화구 이펙트 하나 꺼내서 만들어주고 스스로 비활성화하게 됩니다.

발사할 때마다 목표를 향해 방향을 돌리기는 하지만, 대부분 목표물에 도달하기 전에 터지게 됩니다.



PixyMPBMController.cs

public class PixyMPBMController : MonoBehaviour
{
    [SerializeField]
    float cooldown;
    [SerializeField]
    Transform mpbmLaunchTransform;

    public ObjectPool MPBMObjectPool;

    void LaunchMissile()
    {
        if(GameManager.Instance.IsGameOver == true)
        {
            CancelInvoke();
            return;
        }

        GameObject mpbm = MPBMObjectPool.GetPooledObject();
        mpbm.transform.position = mpbmLaunchTransform.position;
        mpbm.transform.rotation = mpbmLaunchTransform.rotation;
        mpbm.SetActive(true);

        MPBM mpbmScript = mpbm.GetComponent<MPBM>();
        mpbmScript.Launch(GameManager.PlayerAircraft.transform.position);
    }

    void OnDisable()
    {
        CancelInvoke();
    }

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

산탄 미사일을 발사하는 스크립트입니다. 그냥 매 쿨타임마다 미사일을 발사합니다.



모델링과 이펙트

다음으로 미사일 프리팹을 제작하려...고 하는데,

이 산탄 미사일은 에이스 컴뱃 시리즈에서 독자적으로 만들어진 미사일이란 말이죠.


모델링을 구하지 못한 관계로 일단은 기존 미사일 모델링을 재탕하겠습니다.

그리고 MPBM 스크립트를 붙여서 값을 설정해줍니다.



이 이펙트는 기존에 사용하는 폭발 이펙트랑은 확연히 다르죠.

손수 만들어보겠습니다.


Duration을 4초, Start Lifetime을 1로 줘서 총 5초 길이의 파티클 애니메이션을 만들겠습니다.
Start Size를 50으로 줘서 그냥 무작정 크게 만들겠습니다.

초당 10개의 이펙트를 발산하도록 하고, 최대 파티클 개수는 10입니다.

나머지는 체크 해제하고, Size over Lifetime을 체크합니다.
서서히 커지다가 0.7초 부근에 최대 크기가 되고 작아지도록 그래프를 만듭니다.

폭발 효과에 일렁임(?)이 있도록 노이즈를 줍니다.

Strength, Frequency, Scroll Speed, Quality를 조정한 다음,
Position Amount, Size Amount를 약간 올려줍니다.

매우 강렬한 폭발인 만큼 빛 효과가 있어야 합니다.
Point Light를 하나 만들어주고 Lights 속성에 붙여줬습니다.

마지막으로 Renderer에 Material을 Embers로 등록합니다.
유니티 파티클 팩에 있는 Material을 재탕해봅시다.


그렇게 만들어진 결과물입니다.
일단 게임에 투입해봅시다. 이상하면 그 때 조정하죠.


이펙트에 화구 스크립트를 붙여서 피격 범위와 데미지를 설정하고,

미사일에 폭발 이펙트를 붙인 다음,


픽시에 MPBM 오브젝트 풀을 만들어주고, (픽시 혼자 사용하니까 별 문제는 없을 것 같습니다.)

모든 데이터를 연결했습니다.

이제 실행하죠.

음... 생각보다 작네요.

Sphere Collider로 정확한 크기를 알아낸 다음 이펙트 크기도 수정해야겠습니다.

글쎄요, 크기 커브의 최대치를 200으로 줘볼까요?

여전히 부족해보입니다.

목표 크기와는 거의 일치하긴 한데, 그냥 더 크게 만듭시다.


두 배로!
























(출처)









네, 방심하다가는 이렇게 죽습니다.



비활성화 코드 추가

산탄 미사일은 페이즈 1에서는 비활성화되고, 페이즈 2에서만 활성화되어야 합니다.

PixyScript.cs

PixyMPBMController mpbmController;
public bool MPBMController
{
    set { mpbmController.enabled = value; }
}

// Start is called before the first frame update
protected override void Start()
{
    ...
    mpbmController = GetComponent<PixyMPBMController>();
}

이렇게 PixyScript에서 PixyMPBMController를 접근할 수 있도록 만들어준 다음,

페이즈 1이 끝나면 PixyMPBMController를 활성화시키는 이벤트를 추가하고,
페이즈 2가 끝나면 PixyMPBMController를 비활성화시키는 이벤트를 추가합니다.

페이즈 1에서는 비활성화되어야 하므로 초기값은 disable된 상태로 둡니다.


이제 1페이즈에서는 TLS,

2페이즈에서는 산탄 미사일을 쏘게 되었습니다.



페이즈 3 : ECM 방어 시스템

페이즈 3에 들어가면 후방에서 하는 공격이 모두 빗나가게 됩니다.

미사일이 모조리 꺾여버리고, 총알마저도 튕겨내는 말도 안 되는 일이 발생하죠.

먼저 미사일이 꺾이는 부분부터 만들겠습니다.



Missile.cs

public void EvadeByECM(Vector3 randomPosition)
{
    if(target != null)
    {
        target.RemoveLockedMissile(this);

        if(isDisabled == false && isHit == false)
        {
            ShowMissedLabel();
        }
    }

    target = null;

    Vector3 randomDirection = randomPosition - transform.position;
    Quaternion lookRotation = Quaternion.LookRotation(randomDirection);
    rb.rotation = lookRotation;
}

지금까지 미사일의 경로를 순간적으로 바꾸는 함수는 존재하지 않았기에,
ECM에 의해 방향을 바꾸는 함수를 추가합니다.

Vector3 randomPosition을 향해 미사일의 방향이 바로 바뀌게 됩니다.



ECMSystem.cs

public class ECMSystem : MonoBehaviour
{
    TargetObject targetObject;

    [SerializeField]
    Transform ecmSystemTransform;

    [SerializeField]
    float evadeDistance;

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

    void EvadeMissile(Missile missileScript)
    {
        Vector3 randomDirection = transform.up;

        randomDirection = Quaternion.Euler(0, 0, Random.Range(0, 360)) * randomDirection;
        missileScript.EvadeByECM(transform.position + randomDirection * 100);
    }

    void CheckMissiles()
    {
        // Check Missiles' distance and angle
        foreach(Missile missile in targetObject.LockedMissiles)
        {
            float distance = Vector3.Distance(missile.transform.position, ecmSystemTransform.position);

            if(distance > evadeDistance) continue;

            float angle = Vector3.Angle(missile.transform.forward, transform.forward);
            if(angle < 90)
            {
                evadableMissiles.Add(missile);
            }
        }

        // Disable missile
        if(evadableMissiles.Count == 0) return;
        foreach(Missile missile in evadableMissiles)
        {
            EvadeMissile(missile);
        }
        evadableMissiles.Clear();
    }

    void Awake()
    {
        targetObject = GetComponent<TargetObject>();
    }

    void Update()
    {
        CheckMissiles();
    }
}

ECMSystem헤드온(서로 마주보는) 상태의 미사일을 제외하고 모두 튕겨내야 합니다.

미사일의 진행방향과 비행기의 진행방향을 가지고 각도를 계산해서,
각도가 90도 미만이면 헤드온 상태에서 발사한 미사일이 아니라고 판단하여 튕겨냅니다.

CheckMissiles()에서 픽시를 향해 날아오고 있는 미사일 리스트를 받아와서,
각 미사일의 거리가 튕겨내야 하는 거리 (evadeDistance) 이하일 경우 각도를 계산합니다.

각도가 90도 미만이면 evadableMissiles에 미사일들을 등록하고,
모든 미사일 탐색이 끝나면 evadableMissiles에 있는 모든 미사일들을 튕겨내는 작업을 실행합니다.

EvadeMissile(missile)은 무작위 방향을 설정한 다음 Missile.EvadeByEcm()을 호출합니다.


비행기 후방에 ECM System이 가동될 위치를 추가하고,

시작부터 ECM 시스템을 가동시킨 상태에서 실행해봅니다.


이렇게 헤드온 상태에서 발사한 미사일은 명중하지만,

뒤에서 발사한 미사일은 튕겨냅니다.

말도 안 된다고 생각하실텐데, 실제 게임이 그렇습니다. 저는 고증에 맞게(?) 하고 있는 거에요.



기총으로 인한 피격 무시하기

3페이즈에서는 "헤드온 상태에서 미사일로 저 놈을 잡아!" 라는 성격이 강하기 때문에,

그냥 총알로 얻은 데미지는 모두 무효화시키겠습니다.
총알까지 튕겨내는 작업은 너무 까다롭기 때문이죠.



TargetObject.cs

// Public Functions
public virtual void OnDamage(float damage, int layer, string tag = "")
{
    ...
}

OnDamage()에 세 번째 매개변수인 string tag를 추가해줍시다.
기본값은 ""이며, 추가적으로 태그 정보를 넘겨줄 때 사용할 수 있도록 만들었습니다.



Bullet.cs

void OnCollisionEnter(Collision other)
{
    ObjectPool effectPool;
    if(other.gameObject.layer == LayerMask.NameToLayer("Ground"))
    {
        ...
    }
    else
    {
        effectPool = GameManager.Instance.bulletHitEffectObjectPool;
        other.gameObject.GetComponent<TargetObject>()?.OnDamage(damage, gameObject.layer, gameObject.tag);
    }
    ...
}

총알 스크립트에 한해서 세 번째 매개변수에 tag 값을 넘겨줍니다.

총알의 Tag 값은 "Bullet"입니다.



PixyScript.cs

ECMSystem ecmSystem;
public bool ECMSystem
{
    set { ecmSystem.enabled = value; }
}

public override void OnDamage(float damage, int layer, string tag = "")
{
    if(isAttackable == false) return;
    if(ecmSystem.enabled == true && tag == "Bullet") return;

    float applyDamage = (isInvincible == true) ? 0 : damage;
    base.OnDamage(applyDamage, layer);
}

픽시에서는 ECMSystem 변수를 추가하고,
ECM 시스템이 활성화되어있고 데미지를 준 대상의 태그가 "Bullet"이면 무효화합니다.


이제 최종적으로 테스트를 해보죠.

최초 실행 시에는 ECM 시스템을 비활성화시키고, 페이즈 2가 끝나면 ECM 시스템을 켜보겠습니다.


1페이즈와 2페이즈에서는 후방에서 발사한 미사일이 명중하지만,


3페이즈에서는 미사일이 전부 빗나가버립니다.

예외 상황이 하나 있는데, 정말 가까운 거리에서 미사일을 발사하는 경우에는 미사일 경로가 바뀌기도 전에 명중합니다.

그 정도는 플레이어의 노력을 치하하기 위해 따로 예외 처리는 하지 않도록 하겠습니다.




심심한데 지금 만든 특수무기를 모두 켜볼까요?


그야말로 대환장 파티가 따로 없습니다.


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

0개의 댓글