Ace Combat Zero: 유니티로 구현하기 #11 : 비행기 조종 심화

Lunetis·2021년 5월 8일
0

Ace Combat Zero

목록 보기
12/27
post-thumbnail




2편에서는 게임패드 입력으로 비행기를 조종하는 것을 구현했습니다.

그 때는 입력에 따라서 비행기를 움직이는 것에 초점을 두었고,
실제로 에이스 컴뱃에 사용되는 조종법 몇 가지와 약간의 물리법칙이 생략되었습니다.

2편에서 구현하지 않았던 몇 가지 조종법, 그리고 제약조건들을 추가하려고 합니다.



오토파일럿 (수평잡기)

여기서 말하는 오토파일럿은 조종사 없이 비행기를 조종시키는 게 아닙니다.

테슬라 오토파일럿과도 아무런 상관이 없습니다.



듀얼쇼크 기준으로 L1과 R1 버튼 (Yaw 방향 조종 버튼)을 누르면 비행기를 수평으로 맞춰주는 기능이 있습니다.

이렇게 비행기 피치가 0도로 맞춰지고, UI로 AUTO PILOT이라고도 뜨죠.

두 키를 같이 누른 상태에서는 수평을 맞추도록 만들어줍시다.


Input Event 변경

원래 Yaw는 입력 방식이 1차원 축 (1D Axis)이었습니다.

(1편 스크린샷)

L1을 Negative, L2를 Positive 입력으로 설정하면, L1은 -1, L2는 1로 처리되고 둘 다 누르면 0으로 처리되는 식이었죠.

하지만 이 때는 둘 다 누르는 경우를 감지하기가 약간 귀찮습니다.

값이 0일 때 처리해야하는 건 맞는데, 그게 버튼을 둘 다 눌러서 0인건지, 아무 버튼도 누르지 않아서 0인건지 확인하는 코드는 따로 구현해줘야 합니다.

예를 들면, 버튼이 눌릴 때마다 카운트를 1 증가시키고, 버튼이 떼어질 때 1 줄어들게 한 다음,
값이 0이고 카운트가 2면 둘 다 누른 것으로 처리한다든가...


구현이 가능하긴 하지만, 저는 그 방법이 그다지 직관적이라고 생각하지는 않아서...

그냥 이렇게 각 버튼마다 이벤트를 할당하고, 각각의 버튼이 눌렸는가를 저장하는 변수를 만들어주려고 합니다.

"버튼의 누름 여부""버튼 누름 횟수" 보단 직관적이잖아요.

...아닌가요?


물론, 어떻게 구현하느냐는 여러분 자유입니다.



코드 작성


AircraftController.cs


float yawRValue;
float yawLValue;

public void YawL(InputAction.CallbackContext context)
{
    yawLValue = context.ReadValue<float>();
}

public void YawR(InputAction.CallbackContext context)
{
    yawRValue = context.ReadValue<float>();
}


void Autopilot(out Vector3 rotateVector)
{
    rotateVector = -transform.rotation.eulerAngles;
    if(rotateVector.x < -180) rotateVector.x += 360;
    if(rotateVector.z < -180) rotateVector.z += 360;

    rotateVector.x = Mathf.Clamp(rotateVector.x * 2, -pitchAmount, pitchAmount);
    rotateVector.z = Mathf.Clamp(rotateVector.z * 2, -rollAmount, rollAmount);
    rotateVector.y = 0;
}

void MoveAircraft()
{
    float accel = accelValue;
    float brake = brakeValue;
    
    // === Rotation ===
    Vector3 rotateVector;
    
    // Autopilot (Press L Shoulder + R Shoulder)
    if(yawLValue == 1 && yawRValue == 1)
    {
        Autopilot(out rotateVector);
    }
    // Rotation
    else
    {
        rotateVector = new Vector3(pitchValue * pitchAmount * highGPitchFactor, (yawRValue - yawLValue) * yawAmount, -rollValue * rollAmount);
    }
    rotateValue = Vector3.Lerp(rotateValue, rotateVector, rotateLerpAmount * Time.deltaTime);
    transform.Rotate(rotateValue * Time.deltaTime);
}

public void Yaw()를 반으로 갈랐습니다. YawLYawR로요.

bool로 저장해도 되지만, 결국 각각 -1, 1이라는 숫자로 대응해줘야하기 때문에 context.ReadValue<float>를 이용해서 버튼 누름 여부를 숫자로 변환한 다음,

yawLValueyawRValue가 모두 1이라면 Autopilot(out rotateVector)로 들어가서 rotateVector 값을 "비행기를 수평으로 맞춰주는 회전 벡터"로 바꿔줍니다.

둘 다 눌리지 않았다면, 이전처럼 yaw 방향 회전으로 처리하면 되고요. (yawRValue - yawLValue)


Autopilot()은 현재 비행기 회전값의 x축과 z축을 0으로 만들어줍니다.
현재 회전의 역방향인 -transform.eulerAngles로 해보니까 너무 느려서, Clamp할 때 2를 곱해줬습니다. (회전 속도 2배)


(하늘이랑 조명을 좀 바꿔봤습니다. 구름낀 배경이 보스전에 맞긴 한데, 너무 칙칙해서요.)
(엔진 아지랑이 이펙트는 덤입니다.)

이런 식으로 오토파일럿이 작동하면 비행기가 서서히 수평을 맞추게 됩니다.

AUTOPILOT 기능이 가동되고 있는 동안에는 화면 왼쪽 위의 디버그용 텍스트가 뜨도록 해놨습니다.

배면비행 상태도 문제없습니다.


High-G 턴

에이스 컴뱃 제로 시절에는 없었고, 그 다음 시리즈인 6편부터 추가된 기능입니다.

비행기의 가속 버튼와 감속 버튼을 누른 채로 기수를 올리면 하이 G 턴을 할 수 있습니다.

중앙에서 약간 왼쪽에 있는 출력 UI를 자세히 보시면,

출력 UI가 중앙으로 갈 때 회전 속도가 빨라지고 속력이 빠르게 감소하는 것을 볼 수 있습니다.
이 때가 가속 버튼과 감속 버튼을 모두 누르고 기수를 올려서 하이 G 턴을 하는 상황입니다.

목표물을 추적할 때 빠르게 선회해서 꼬리를 잡기 위해 자주 사용하는 기술이지만,
비행기의 속력도 매우 빠르게 줄기 때문에 실속* 상태가 되기 쉽습니다.

(*실속: 비행기의 양력이 충분하지 않아서 조종 불능 상태에 빠지는 현상. 실속은 조금 있다가 구현합니다.)


요점은 가속 버튼감속 버튼을 누른 채로 기수를 올렸을 때,
선회 속도가 증가하고 속력이 빠르게 감소해야 한다는 것입니다.




[Header("High-G Turn")]
[SerializeField]
float highGFactor = 1.5f;
[SerializeField]
float highGTurnTime = 2.0f;

float highGCooldown;
float highGReciprocal;
bool isHighGPressed;
bool isHighGEnabled;

bool isHighGTurning;
public bool IsHighGTurning
{
    get { return isHighGTurning; }
}

void CheckHighGTurn(ref float accel, ref float brake, ref float highGPitchFactor)
{
    isHighGTurning = false;
    
    // Factor decreases 2 to 1
    if(accelValue == 1 && brakeValue == 1) // Button
    {
        if(pitchValue < -0.7f)
        {
            if(isHighGEnabled == true)
            {
                isHighGEnabled = false;
                isHighGPressed = true;
            }

            if(highGCooldown < 0) isHighGPressed = false;

            if(isHighGEnabled == true || isHighGPressed == true)
            {
                accel = 0;
                brake *= highGFactor * (1 + highGCooldown * highGReciprocal);
                highGPitchFactor = highGFactor * (1 + highGCooldown * highGReciprocal);

                highGCooldown -= Time.deltaTime;
                isHighGTurning = true;
            }
        }
    }
    else // Button Released
    {
        isHighGPressed = false;
        isHighGEnabled = true;
    }

    if(isHighGPressed == false)
    {
        highGCooldown += Time.deltaTime * 2;
        if(highGCooldown >= highGTurnTime)
        {
            highGCooldown = highGTurnTime;
        }
    }
}


void Start()
{
    highGCooldown = highGTurnTime;
    highGReciprocal = 1 / highGCooldown;
    isHighGPressed = false;
    isHighGEnabled = true;
    
    ...
}

void MoveAircraft()
{
    float accel = accelValue;
    float brake = brakeValue;
    float highGPitchFactor = 1;

    // High-G Turn
    CheckHighGTurn(ref accel, ref brake, ref highGPitchFactor);
    
    // === Rotation ===
    Vector3 rotateVector;
    
    // Autopilot (Press L Shoulder + R Shoulder)
    if(yawLValue == 1 && yawRValue == 1)
    {
        Autopilot(out rotateVector);
    }
    // Rotation
    else
    {
        rotateVector = new Vector3(pitchValue * pitchAmount * highGPitchFactor, (yawRValue - yawLValue) * yawAmount, -rollValue * rollAmount);
    }
    ...

    // === Move ===
    throttle = Mathf.Lerp(throttle, accel - brake, throttleAmount * Time.deltaTime);

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

CheckHighGTurn(...)은 현재 하이 G 턴 커맨드가 입력되었는지 확인하고,

하이 G 턴을 하는 중이라면 MoveAircraft()에 사용하는 accelbrake, highGPitchFactor를 조절합니다.

기본적으로 하이 G 턴 입력은 "가속 버튼, 감속 버튼이 최대로 눌렸는가?" 입니다. 그 상

그 상태에서 기수를 올리면 하이 G 턴을 하게 되고, 한 번 하이 G 턴을 하게 되면 기수를 내렸다 올리는 것으로는 다시 하이 G 턴을 하지 않습니다. 가속 버튼이나 감속 버튼을 다시 눌러줘야 합니다.

하이 G 턴은 무한대로 지속할 수는 없고, 시간이 지날수록 선회 속도나 속력 감소량이 줄어들게 됩니다. 이를 구현하기 위해 highGCooldown이라는 변수를 사용합니다.

하이 G 턴을 하는 동안 highGCooldown이 매 프레임마다 감소하며, 이 값이 0이 되면 하이 G 턴이 종료됩니다.
하이 G 턴을 하지 않는 동안 highGCooldown은 소모량의 2배로 회복됩니다.


그 외에 버튼이 눌렸는지, 하이 G 턴이 실행되는 중인지 등을 나타내는 bool형 변수도 있습니다.
여기에 대한 설명은 생략하죠.


High G Factor는 하이 G 턴을 하는 동안 선회력과 속도 감소율을 몇 배만큼 증가시킬지 설정하는 값입니다.
High G Turn Time은 하이 G 턴을 최대 몇 초까지 할지 설정하는 값입니다.


(그냥 브레이크만 하고 있을 때)

(하이 G 턴)

배율을 1.2밖에 주지 않아도 확실히 선회력에서 큰 차이를 보입니다. 속력도 빠르게 줄어들고요.


기수에 따른 속도 조절

여기서는 중력을 조금 선택적으로(?) 적용시키려고 합니다.

비행기가 수평선과 평행하게 비행하고 있다면 비행기의 고도는 유지되어야 합니다.

아무 버튼도 안 눌렀는데 에서 비행기 고도가 계속 낮아져서 땅으로 떨어진다고 생각하면,
그다지 유쾌한 경험은 아닐 겁니다.

비행기가 기수에 변화가 생길 때만 중력의 영향을 받게 하려고 합니다.

기수를 올릴 때는, 중력을 거스르는 방향이기 때문에 고도가 느리게 상승합니다.

기수를 내릴 때는, 중력의 방향과 같이 때문에 고도가 빠르게 하락합니다.


요약하자면, 그냥 기수에 따라서 속도를 약간 조절해주려고 합니다.

[SerializeField]
float gravityFactor;

void MoveAircraft()
{
    ...
    
    // Gravity
    float gravityFallByPitch = gravityFactor * Mathf.Sin(transform.eulerAngles.x * Mathf.Deg2Rad);
    speed += gravityFallByPitch * Time.deltaTime;
        
    // Apply
    transform.Translate(new Vector3(0, 0, speed * Time.deltaTime));
}

비행기의 기수는 transform.eulerAngles.x 값으로 판단할 수 있습니다.

Mathf.Sin()에 넣어줘서 -1 ~ 1로 변환시킨 다음 현재 속도에 더해줍시다.

진행 방향 속도를 바꾸는 게 아니라 y축으로 이동해야하는 것 아니냐고요?

괜찮습니다. 어차피 이 게임은 물리학적으로 정확하지 않으니까요.
그냥 올라갈 때 느리게 올라가고, 내려갈 때 빠르게 내려가게 체감할 수 있으면 그만입니다.

Gravity Factor 는 기수에 따른 속도 가속/감속값입니다.

적당한 값을 설정하고 실행해봅시다.


아무 입력 없이 하늘을 향할 때는 속력이 대략 350 근처로 가게 됩니다.

그리고 땅을 향할 때는 속력이 대략 850 근처로 가게 됩니다.

이번에는 한 바퀴 돌아보죠.
하늘을 향할 때는 속력이 줄어들고, 땅을 향할 때는 속력이 빨라지고 있는 것을 확인할 수 있습니다.


실속

위에서 설명했듯이, 비행기의 속력이 너무 낮아 비행하기에 충분한 양력을 얻지 못하면 비행기를 통제할 수 없게 됩니다.

실속 상태에서 벗어나기 위해서는 엔진을 가동해서 속력을 올리거나, 그대로 추락하면서 중력에 의해 속력을 얻으면 됩니다.


실속 속도를 설정하고, 그 속도 미만으로 떨어지면 비행기가 땅으로 추락하게 만들어야 합니다.


참고로 에이스 컴뱃에서는 실속이 일어날 때 부드럽게 방향이 전환되는게 아니라 약간 부자연스럽게 방향이 전환됩니다. 방향이 확 꺾여버리는 느낌을 주죠.

저는 이 실속으로 인한 방향 전환을 자연스럽게 만들어보려고 합니다.


[SerializeField]
float stallSpeed;

void Stall()
{
    Quaternion targetRotation = Quaternion.Euler(90, transform.eulerAngles.y, transform.eulerAngles.z);
    Quaternion diffQuaternion = Quaternion.Inverse(transform.rotation) * targetRotation;
    
    Vector3 diffAngle = diffQuaternion.eulerAngles;

    // Adjustment
    if(diffAngle.x > 180) diffAngle.x -= 360;
    if(diffAngle.y > 180) diffAngle.y -= 360;
    if(diffAngle.z > 180) diffAngle.z -= 360;
    diffAngle.x = Mathf.Clamp(diffAngle.x, -pitchAmount, pitchAmount);
    diffAngle.y = Mathf.Clamp(diffAngle.y, -yawAmount, yawAmount);
    diffAngle.z = Mathf.Clamp(diffAngle.z, -rollAmount, rollAmount);
    
    // GameManager.Instance.debugText.AddText("Stall Value : " + diffAngle.ToString());
    rotateValue = Vector3.Lerp(rotateValue, diffAngle, rotateLerpAmount * Time.deltaTime);
}


void MoveAircraft()
{
    float accel = accelValue;
    float brake = brakeValue;
    float highGPitchFactor = 1;

    // High-G Turn
    CheckHighGTurn(ref accel, ref brake, ref highGPitchFactor);
    
    // === Rotation ===
    Vector3 rotateVector;
    if(speed < stallSpeed)
    {
        // Ignore all rotation input and head to the ground
        Stall();
    }
    else
    {
        // Autopilot (Press L Shoulder + R Shoulder)
        if(yawLValue == 1 && yawRValue == 1)
        {
            Autopilot(out rotateVector);
        }
        // Rotation
        else
        {
            rotateVector = new Vector3(pitchValue * pitchAmount * highGPitchFactor, (yawRValue - yawLValue) * yawAmount, -rollValue * rollAmount);
        }
    }
    transform.Rotate(rotateValue * Time.deltaTime);
    
    ...
}

speed 값이 stallSpeed (실속 속도) 미만으로 떨어지면 Stall() 을 실행하고,
아니면 기존의 회전 코드를 실행합니다.

Stall()은 비행기가 땅으로 향하도록 회전시킵니다.

기존에 사용하는 회전 코드가 transform.rotation에 직접 대입하는 게 아니라 transform.Rotate()를 이용하기 때문에, 회전 결과값이 아니라 현재로부터 회전해야 하는 값을 넘겨줘야 합니다.

회전값은 Quaternion으로 설정하고, 두 QuaternioneulerAngle (Vector3) 차이를 사용할 수 도 있지만, 변환된 Vector3 값은 Quaternion을 정확히 표현하지 못하기 때문에 eulerAngle 의 차이를 계산하는 것은 약간 부정확합니다.

Quaternion은 덧셈/뺄셈 연산자를 지원하지 않지만, 곱하기 연산자는 지원합니다.

Quaternion A에서 Quaterion B만큼 변화해서 Quaternion C로 바뀌었을 때,
변화량 Quaternion B의 값은 Quaternion.Inverse(A) * C 입니다.

Quaternion 계산은 행렬 계산할 때와 어느정도 비슷합니다.
AB=CA B = C
A1AB=A1CA^{-1} A B = A^{-1} C
(A1A)B=B=A1C(A^{-1} A) B = B = A^{-1} C

이렇게 비행기가 땅을 향하게 하는 변화량을 계산한 다음, 값을 보정해주고 rotateValue에 대입해주면 transform.Rotate(rotateValue * Time.deltaTime);에서 그 변화량만큼 비행기를 회전시키게 됩니다.

실속 속력을 25로 설정해보겠습니다.
UI 상에서는 250 미만으로 떨어지면 실속이 일어날 것입니다.

보정으로 인해서 약간 흔들리는 현상이 있긴 하지만, 어쨌든 땅으로 비행기가 향하고 조종이 불가능하게 됩니다.
(좌측의 "STALL" 문구로 현재 실속 상태에 들어갔는지 확인할 수 있습니다.)

속력이 빠르게 줄어드는 하이 G 턴을 하는 도중에도, 속력이 충분하지 않으면 이렇게 실속이 일어나게 됩니다.



UI 표시

9편에서 만들어놓았던 UI들이 몇 개 있었는데,

이 중에는 오토파일럿, 실속 상황에서 띄우는 UI들이 있습니다.

그 때는 그냥 만들어놓고 아무데도 쓰지 않았지만, 이제 상황에 맞게 UI를 띄워봅시다.

AircraftController.cs

bool isAutoPilot;
public bool IsAutoPilot
{
    get { return isAutoPilot; }
}

bool isStalling;
public bool IsStalling
{
    get { return isStalling; }
}

void MoveAircraft()
{
    ...
    
    // === Rotation ===
    Vector3 rotateVector;
    if(speed < stallSpeed)
    {
        // Ignore all rotation input and head to the ground
        isStalling = true;
        Stall();
    }
    else
    {
        isStalling = false;

        // Autopilot (Press L Shoulder + R Shoulder)
        if(yawLValue == 1 && yawRValue == 1)
        {
            isAutoPilot = true;
            Autopilot(out rotateVector);
        }
        // Rotation
        else
        {
            isAutoPilot = false;
            rotateVector = new Vector3(pitchValue * pitchAmount * highGPitchFactor, (yawRValue - yawLValue) * yawAmount, -rollValue * rollAmount);
        }
        rotateValue = Vector3.Lerp(rotateValue, rotateVector, rotateLerpAmount * Time.deltaTime);
    }
    
    ...
}

다른 스크립트에서 참조할 수 있게끔 AircraftController에 현재 상태를 나타내는 bool 변수를 추가합니다.
그리고 실속이나 오토파일럿 등의 상황에서 그 값을 설정해줍시다.


AlertUIController.cs


public class AlertUIController : MonoBehaviour
{
    [Header("Warning/Alert Label Objects")]
    // Attack
    [SerializeField]
    GameObject caution;
    [SerializeField]
    GameObject warning;
    [SerializeField]
    GameObject missileAlert;

    // Status
    [SerializeField]
    GameObject pullUp;
    [SerializeField]
    GameObject stalling;
    [SerializeField]
    GameObject damaged;

    // Misc
    [SerializeField]
    GameObject autopilot;
    [SerializeField]
    GameObject fire;
    [SerializeField]
    GameObject missileReloading;

    // Category : Attack

    // Category : Status

    // Misc.
    void ShowAutopilotUI()
    {
        if(GameManager.PlayerAircraft.IsAutoPilot != autopilot.activeInHierarchy)
            autopilot.SetActive(GameManager.PlayerAircraft.IsAutoPilot);
    }

    void ShowStallingUI()
    {
        if(GameManager.PlayerAircraft.IsStalling != stalling.activeInHierarchy)
            stalling.SetActive(GameManager.PlayerAircraft.IsStalling);
    }


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

경고 UI를 컨트롤하는 스크립트인 AlertUIController를 추가했습니다.

각 상황에 맞는 UI를 띄워주거나 숨기는 역할을 합니다.

지금은 오토파일럿과 실속 UI만 구현하지만, 다른 기능도 로직 자체는 비슷할겁니다.
각 함수들은 AircraftController에서 값을 참조해서 그에 맞는 UI 오브젝트를 활성화/비활성화합니다.

모두 등록하고 테스트하러 갑시다.


오토파일럿이 활성화된 동안 UI가 나오는지 확인해보고,

실속 상태에서도 UI가 나오는지 확인해봅니다.


앞으로도 기능이 구현될 때마다 경고 UI를 연결해줄 예정입니다.




최근 포스트에 비하면 내용이 꽤 짧네요.

사실 이건 이전 포스트들의 길이 비정상적으로 길었던 겁니다.
velog에서 저보다 포스트 길게 쓰는 사람 흔치 않을걸요?

그리고 웹페이지 용량도 말이죠



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

0개의 댓글