탑건: 매버릭 미션 개발하기 #12 : 사운드/피격 효과 등

Lunetis·2022년 8월 22일
2

탑건: 매버릭

목록 보기
14/14
post-thumbnail

적 기체 비행 소리 추가

플레이어가 전투기를 조종할 때의 엔진 소리는 구현이 되어 있습니다. 그렇지만 적 비행기가 근처를 지나갈 때 들리는 효과음은 가지고 있지만 구현하지는 않았습니다.

이런 효과음들을 구현해보고 싶은데, 주어진 효과음을 어떻게 써먹어야 할지 고민이었단 말이죠.
이전 프로젝트에서는 효과음을 쓰지는 않았지만, 막상 만들어보니 공중전이 너무 밋밋합니다.

그 효과음들을 어떻게든 써먹어봅시다.



효과음 출력 알고리즘

비행기와 플레이어가 충분히 가까워졌을 때만 소리가 들리도록 만들겠습니다.

FlybySoundEffect

public class FlybySoundEffect : MonoBehaviour
{
    public float playDistance = 200;
    bool isPlaying;

    [SerializeField]
    AudioSource audioSource;

    // Start is called before the first frame update
    void Start()
    {
        isPlaying = false;  
        if(audioSource == null)
        {
            audioSource = GetComponent<AudioSource>();
        }
    }


    // Update is called once per frame
    void Update()
    {
        // Check Distance
        float distance = Vector3.Distance(transform.position, GameManager.PlayerAircraft.transform.position);

        if(distance < playDistance && isPlaying == false)
        {
            isPlaying = true;
            audioSource.PlayOneShot(SoundManager.Instance.GetFlybyClip());
        }

        if(isPlaying == true && distance < playDistance)
        {
            isPlaying = false;
        }
    }
}

200만큼의 거리 미만으로 근접했을 때만 소리를 출력하게끔 만들고,
거리 이내로 들어왔어도 비행기는 계속 플레이어와 가까워질 수 있습니다. 그렇지만 소리는 최초로 진입했을 때 단 한 번만 재생되어야 하므로 다시 거리가 200보다 멀어지기 전까지는 출력하지 않도록 만듭니다.

SoundManager

AudioClip GetClipRandomly(List<AudioClip> clips)
{
    return clips[Random.Range(0, clips.Count)];
}

public AudioClip GetFlybyClip()
{
    return GetClipRandomly(flybyClips);
}

SoundManager는 게임에서 재생될 수 있는 다양한 종류의 효과음들을 가지고 있습니다. 여기서 아무거나 효과음을 요청할 수 있도록 함수를 추가로 작성합니다.

여기다 온갖 효과음을 달아놓고,

적 기체에 빈 오브젝트를 하나 달아놓고 여기다가 효과음 재생기를 추가하겠습니다.


재생을 담당하는 Audio Source 하나와 방금 만든 컴포넌트를 붙여줍니다.

Audio Source의 설정은 위와 같습니다. 볼륨을 약간 낮추고, Spatial Blend를 3D 쪽으로 당겨서 가까울수록 효과음이 더 크게 들릴 수 있도록 설정합니다.
그 외에 Doppler Level, Spread, Volume Rolloff도 적당히 설정하고요.


완벽하지는 않지만 그래도 적 비행기가 근처에서 날고 있다는 느낌은 줄 수 있습니다.


피격 효과: 카메라 흔들기

지금 플레이어가 미사일에 맞으면,

짧게 DAMAGED라는 UI가 뜨고 연기가 납니다. 그 외에는 별 일 없습니다.
테스트하면서도 가끔 제가 피격됐다는 느낌이 하나도 들 지 않을 때도 있습니다.


실제 게임에서는 피격되면 컨트롤러 진동 효과와 함께 카메라가 흔들립니다.

피격되었다는 걸 플레이어가 확실하게 느낄 수 있도록 이 효과를 추가해봅시다.


고전적인 카메라 흔들기

카메라의 흔들림 효과는 시간이 지날수록 서서히 잦아들어야 하며, 흔들림의 정도도 마음대로 설정할 수 있어야 합니다. 일단 지속시간과 세기 변수를 추가하는 건 확정입니다.

그러면 흔들림 자체는 도대체 어떻게 만드는 게 좋을까요.
첫 번째로, 매 프레임마다 카메라의 위치를 주변의 랜덤 위치로 움직이는 방식을 사용해봅시다.

CameraController

[SerializeField]
float shakeMagnitude = 10;
[SerializeField]
float shakeDuration = 1.5f;

float currentShakeMagnitude;

public void ShakeCamera()
{
    StartCoroutine(ShakeCameraCoroutine());
}

IEnumerator ShakeCameraCoroutine()
{
    currentShakeMagnitude = shakeMagnitude;

    while(currentShakeMagnitude > 0)
    {
        currentShakeMagnitude -= shakeMagnitude * Time.deltaTime / shakeDuration;
        currentCamera.transform.localPosition = Random.insideUnitSphere * currentShakeMagnitude;
        yield return null;
    }
    currentCamera.transform.localPosition = Vector3.zero;
    currentShakeMagnitude = 0;
}

ShakeCameraCoroutine()에서는 카메라의 localPosition을 매 프레임마다 무작위로 설정해주고 있습니다. 설정해주는 위치는 Random.insideUnitSphere로 얻어온 "반지름이 1인 구 이내의 랜덤 위치"에 "현재 흔들림 세기(currentShakeMagnitude)"를 곱한 값입니다.


UIController

public void SetDamage(int damage)
{
    ...
    if(damage > 0)
    {
        ...
        GameManager.CameraController.ShakeCamera();
    }
}

그리고 카메라를 흔드는 함수는 플레이어가 피격됐을 때 호출시키도록 만들어줍니다.

값을 설정해주고 실행해보죠.

이 정도로 만족하는 분들도 있겠지만, 개인적으로는 별로 마음에 들지 않습니다.
왜냐하면 매 프레임마다 카메라가 조금씩 순간이동하는 것과 비슷하기 때문에, 제 눈에는 "흔든다"라는 느낌이 안 살아나기 때문입니다.



자연스럽게 흔드는 듯한 효과 만들기

이렇게 바꿔보면 어떨까요?

  1. 매 프레임 대신 일정 주기 (약 0.1초)마다 카메라의 새 위치 설정
  2. 카메라는 매 프레임마다 그 위치를 향해 빠르게 이동,
    지속시간이 끝날 때까지 1-2 반복

순간이동법 대신 카메라를 특정 위치로 조금씩 이동시키는 방식을 사용해보겠습니다.


[SerializeField]
float shakeDelay = 0.1f;
Vector3 cameraShakePosition;
    
IEnumerator ShakeCameraCoroutine()
{
    currentShakeMagnitude = shakeMagnitude;
    Vector3 normalizedVector;

    while(currentShakeMagnitude > 0)
    {
        currentShakeMagnitude -= shakeMagnitude * (shakeDelay / shakeDuration);
        normalizedVector = Random.insideUnitSphere.normalized;

        if(Vector3.Dot(normalizedVector, cameraShakePosition) > 0)
        {
            normalizedVector *= -1;
        }
        cameraShakePosition = normalizedVector * currentShakeMagnitude;
        yield return new WaitForSeconds(shakeDelay);
    }
    cameraShakePosition = Vector3.zero;
    currentShakeMagnitude = 0;
}

void Update()
{
    currentCamera.transform.localPosition = 
    Vector3.Lerp(currentCamera.transform.localPosition, cameraShakePosition, Time.deltaTime * 40);
}

새 변수인 shakeDelay마다 카메라가 흔드는 위치를 변경합니다.

Random.insideUnitSphere의 normalized된 값을 가지고, 현재 카메라 흔들기 위치랑 비교하기 위해 내적(Dot)을 사용합니다. 두 벡터의 내적이 0보다 크다면 두 벡터 사이의 각도는 0도 이상 90도 미만이며, 이는 곧 두 벡터가 가리키는 점 사이의 거리 차이가 그렇게 크지 않을 확률이 높다는 뜻이 됩니다.
그래서 내적값이 0보다 크면 지금 구한 벡터의 방향을 반대 방향으로 바꿔서 내적값이 0보다 작아지도록, 다시말해 각도가 90도 이상 180도 미만이 되도록 바꿔서 이동거리를 극대화시킵니다.

그리고 Update에서는 정해진 위치로 Lerp를 사용해서 이동시키고요.

멋대로 값을 설정해서 실행해보겠습니다.

그런데 제가 만들어봤는데 딱히 만족스럽지는 않네요.


Cinemachine: 사용 불가

이전 프로젝트에서 컷씬 만들 때 사용했던 유니티 내장 기능 중 하나로 Cinemachine이라는 게 있었습니다. 하지만 지금은 컷씬이 없어서 사용하지 않고 있죠.

생각해보면 거기에도 흔드는 옵션이 있긴 합니다.

아래쪽에 있는 Noise가 흔들기 역할을 담당하죠. 여기서 Frequency Gain을 고정하고 Amplitude Gain만 서서히 줄이면 적절한 흔들기 효과가 만들어질 것입니다.

하지만 여기서 사용할 수는 없는데, 이전 프로젝트와의 하위호환도 고려하고 있기 때문입니다.

CameraController를 Cinemachine을 제어하는 코드로 바꾼다고 하면, 이전 프로젝트 파일에 있는 씬 세팅도 몽땅 바꿔줘야 합니다.

그래서 이 부분은 고려하지 말죠.



외부 플러그인 찾아서 갖다 쓰기

유튜브에서 유니티 카메라 흔들기를 검색해서 맨 위에 뜨는 영상을 보면,

제가 첫 번째로 만들었던 코드와 비슷한 코드를 설명한 후 플러그인 하나를 소개합니다.

https://github.com/andersonaddo/EZ-Camera-Shake-Unity

이전에는 에셋 스토어에 무료로 배포되고 있었는데 스토어에서 내려가고 깃허브에서 공개하는 형식으로 바꿨다고 하네요. 아무튼 이걸 사용해봅시다.


다운로드 받아서 파일을 임포트하고,

3인칭 카메라와 1인칭 카메라들에 Camera Shaker를 추가한 다음,

[Header("Camera shake")]
[SerializeField]
float shakeMagnitude = 10;
[SerializeField]
float shakeDuration = 1.5f;
[SerializeField]
float shakeRoughness = 2.0f;

public void ShakeCamera()
{
        CameraShaker.Instance.ShakeOnce(shakeMagnitude, shakeRoughness, 0, shakeDuration);
}

모든 코드를 갖다버린 후 플러그인 함수 호출 코드를 작성합니다.

진작 이렇게 할 걸.


수치를 조정하고 실행해보겠습니다.
여기서 Roughness는 Frequency와 비슷한 개념인 것 같네요.

아주 마음에 드네요. 진작 이렇게 쓸 걸.

그런데 카메라를 전환하고 나면 제대로 실행이 안 되는 것 같습니다. 이 부분은 스스로 고쳐보죠.

제공되는 문서를 봅시다.

하나 이상의 카메라가 있는 경우 CameraShaker.GetInstance(name)으로 서로 다른 카메라 인스턴스에 접근할 수 있습니다. 여기서 name은 CameraShaker가 붙여진 GameObject의 이름입니다.

플레이어 시점을 보여주기 위해 사용되는 카메라는 3개가 있고, 모든 카메라들이 동시에 흔들려야 합니다. 그렇다면 3개의 인스턴스에 모두 흔들기 함수를 호출해주면 될까요?

문제가 하나 있다면, 지금 카메라 제어 코드는 한 카메라가 활성화되면 다른 카메라는 비활성화시키도록 코드를 작성해서, 아마 3개 동시에 흔드는 기능은 지금 당장은 통하지 않을 것 같다는 겁니다.


그럼 그것도 같이 수정하죠 뭐.

void SetCamera()
{
    for(int i = 0; i < cameras.Length; i++)
    {
        if(i == cameraViewIndex)
        {
            currentCamera = cameras[i];
            cameras[i].depth = 0;
            cameras[i].GetComponent<AudioListener>().enabled = true;
        }
        else
        {
            cameras[i].depth = -10;
            cameras[i].GetComponent<AudioListener>().enabled = false;
        }
    }
}

기본 카메라 depth가 0이라는 것을 확인했으니, 선택된 카메라의 depth는 0으로 두고, 현재 선택되지 않은 카메라는 depth를 0보다 낮춰주면 알아서 보여지지 않을 겁니다.

public void ShakeCamera()
{
    foreach(var cam in cameras)
    {
        CameraShaker.GetInstance(cam.name).ShakeOnce(shakeMagnitude, shakeRoughness, 0, shakeDuration);
    }
}

그리고 CameraController에 등록된 모든 시점 전용 카메라를 순회하면서 다 흔들어버립시다.


이제 시점 변경을 해도 흔들림이 잘 작동합니다.
이제는 콕핏이 보이는 시점에서 너무 흔들기 강도가 센 것 같네요.

다행히 최대 강도는 카메라마다 부착한 CameraShaker에서 조정해줄 수 있는 것 같습니다.
콕핏이 보이는 카메라 쪽만 줄여봅시다.

이 정도면 적절하겠네요.



진동 효과 추가

충돌 시에는 게임 패드에 진동이 가해져야 합니다.
진동 기능은 기총 구현 때 사용한 적이 있는데, 여기서도 사용해봐야겠네요.

그런데 신경써야 할 부분이 하나가 있습니다.

기총을 발사하는 동안에도 진동이 발생하는데, 그 때 충돌이 일어나면 충돌로 인한 진동이 게임패드에서 발생해야 하면서도 기총으로 인한 진동이 끊겨서는 안 됩니다.

이쯤 되니까 그냥 게임패드 진동 제어기를 하나 만드는 게 낫겠네요.


public class GamepadController : MonoBehaviour
{
    Gamepad gamepad;

    public bool isGunFiring;

    [Range(0, 1)]
    public float gunVibrateAmount;

    float damageVibrateDuration;

    public float DamageVibrateDuration
    {
        set
        {
            damageVibrateDuration = value;
            damageVibrateDurationReciprocal = 1 / value;
        }
    }

    float damageVibrateDurationReciprocal;

    float damageVibrateAmount;
    
    public void VibrateByDamage()
    {
        damageVibrateAmount = 1;
    }
    
    public void DisableVibrate()
    {
        damageVibrateAmount = 0;
        isGunFiring = false;
    }

    // Start is called before the first frame update
    void Start()
    {
        gamepad = Gamepad.current;

        if(gamepad == null)
        {
            this.enabled = false;
        }
    }

    // Update is called once per frame
    void Update()
    {
        float vibrateAmount = damageVibrateAmount;
        
        // If firing guns, gun vibration must go on
        if(isGunFiring == true)
        {
            vibrateAmount = Mathf.Max(damageVibrateAmount, gunVibrateAmount);
        }
        // If damage vibration is not ended
        if(damageVibrateAmount > 0)
        {
            damageVibrateAmount -= Time.deltaTime * damageVibrateDurationReciprocal;
        }
        else
        {
            damageVibrateAmount = 0;
        }

        gamepad.SetMotorSpeeds(vibrateAmount, vibrateAmount);
    }
    
    void OnDisable()
    {
        if(gamepad != null)
        {
            gamepad.SetMotorSpeeds(0, 0);
        }
    }
}

앞서 말한 조건을 만족하도록 코드를 작성했습니다. 총이 발사되는 도중이라면 그 위에 충격으로 인한 진동이 덮어씌워질 수는 있지만 총으로 인한 진동 세기 미만으로는 내려가지 않을 겁니다.

여긴 누가 클라이언트를 변조해도 딱히 손해볼 곳이 없기 때문에 외부에서 접근해야 하는 모든 변수들은 public으로 두겠습니다.

그리고 게임이 종료될 때 게임패드가 진동하고 있으면 알아서 꺼지도록 OnDisable()에서 게임패드의 진동을 끄는 기능을 추가합니다.


GameManager

[SerializeField]
GamepadController gamepadController;
    
public static GamepadController GamepadController
{
    get { return Instance?.gamepadController; }
}

어디서든 꺼내쓸 수 있도록 GameManager에서 들고 있게 만든 다음,

WeaponController

public void OnGunFire(InputAction.CallbackContext context)
{
    switch(context.action.phase)
    {
        case InputActionPhase.Started:
            ...
            GameManager.GamepadController.isGunFiring = true;
            break;

        case InputActionPhase.Canceled:
            ...
            GameManager.GamepadController.isGunFiring = false;
            break;
    }
}

총 발사 입력을 담당하는 함수에서는 isGunFiring을 설정하고,

CameraController

void Start()
{
    ...
    GameManager.GamepadController.damageVibrateDuration = shakeDuration;
}

public void ShakeCamera()
{
    ...
    // Vibrate
    GameManager.GamepadController.VibrateByDamage();
}

카메라에서 충격으로 인한 카메라 흔들기 함수에서 VibrateByDamage()를 호출하도록 만듭니다.
그리고 충격으로 인한 진동 지속시간은 카메라의 흔들림 지속시간과 동일해야 하므로 여기서 설정해줍니다.

새 컴포넌트를 만들고 기총 진동 세기를 설정한 다음,

GameManager에 등록해줍니다.


진동이라서 따로 보여드릴 수는 없습니다만, 잘 작동합니다.



폭격 성공 시 효과

성공한 것 치고는 효과가 너무 심심하니까 아주 극대화를 시켜보겠습니다.
엄청난 폭발 + 폭발음과 함께 주변이 쑥대밭이 된 것 같은 효과를 넣어보죠.


환풍구에서 연기도 피우고,

거대한 폭발도 추가로 넣어보겠습니다.
일단 위 사진처럼 이펙트 프리팹을 미리 씬에 배치합니다.


MissionMaverick

[SerializeField]
[Tooltip("first index must be explosion effect (disabled in checkpoint start)")]
List<GameObject> explosionEffects;

protected override void Start()
{
    ...
    else if(phase == 2)
    {
        ...
        foreach(var effect in explosionEffects)
        {
            // first index must be explosion effect (disabled in checkpoint start)
            if(effect != explosionEffects[0])
            {
                effect.SetActive(true);
            }
        }
    }
}

폭발 이펙트는 폭탄이 명중할 때만 활성화되어야 하고, 그 외 연기 이펙트는 페이즈 2를 찍고 체크포인트에서 다시 시작할 때도 보여야 하기 때문에 폭발 이펙트만 따로 분리해서 활성화시키는 코드를 만듭니다.

폭발 이펙트만 따로 관리해줄 수도 있지만, 편의를 위해서 저렇게 작성하겠습니다.

(혼자 만들고 관리하니까 이렇게 코드를 짜는 거지, 협업을 하는 상황이라면 이렇게 안 만드는 게 낫겠죠. 게다가 이 미션에서만 사용되는 코드라 재사용성도 0이기 때문에...)

이펙트를 비활성화시키고 여기에 모조리 등록한 다음 실행합시다. 0번 인덱스에 폭발 이펙트를 넣고요.

폭발하는 건 확인했는데, 연기 이펙트가 바로 나타나고 있네요.

재생될 때 한 번의 루프 사이클동안 이펙트를 출력한 후의 상태에서 시작하게 됩니다.

Prewarm 속성이 체크되어 있었습니다. 이미 연기 이펙트가 만들어진 상태부터 시작한다는 뜻이죠.
이 속성을 해제하고 다시 보죠.

이제 폭발 후에 연기가 제대로 나는 모습을 볼 수 있습니다.

페이즈 2에 진입한 후 체크포인트에서 다시 시작해도 이펙트는 잘 보이고요.
정확하게 하자면 페이즈 2에서는 두 이펙트의 Prewarm을 true로 놓는 것이 맞겠지만,

시작하자마자 이펙트를 보는 상황도 아니니까, 그냥 이대로 두겠습니다.



End of devlog.


지금까지의 구현 결과입니다.

프로젝트 시작 선언을 한 지 한 달을 조금 넘긴 시점에서, 제 기준에서는 충분한 결과물을 만들었다고 판단하여 이 미션을 개발하는 포스팅 작성은 이 쯤에서 멈춰도 될 것 같네요.

스스로 세운 기간 제한도 넘기지 않았고,

TODO도 선택 항목 빼고는 모두 구현을 완료했습니다.

(미션 컷씬은 게임 플레이에 그다지 중요하지 않고, 오히려 게임 몰입감을 해칠 수 있을 것 같아서 제외되었습니다.)

난이도 차등 적용도 잘 됐고 (특히 ACE 난이도는 저도 한 번에 깨지 못합니다.), 시각/촉각(?)적으로도 이전 프로젝트에서 나름 발전했고요.

이전 프로젝트에서도 구현할 필요가 있는데 안 넣은 것:

  • 플레어
  • 피격 시 효과
  • 적 비행기 근접 비행 효과음
  • ...



한편으로는 우연히 좋아요 폭격을 받은 덕분에 velog 메인 화면 상단 가까이에 보여져서 무수한 조회수와 좋아요 세례를 받기도 했습니다.

아무래도 사이트 특성상 프론트엔드/백엔드를 다루는 분이 많아서 게임 개발이라는 분야에 관심을 가지실 분이 얼마나 될 지는 몰랐는데, 움짤이 어그로를 거대하게 끌지 않았나 싶네요.


지금까지 움짤로 도배된 개발 포스트를 봐주신 분들께 모두 감사드립니다.

게임이나 소스에 관심이 있으신 분들은 하단의 링크에서 씹고 뜯고 맛보고 즐겨주시기 바랍니다.


잠깐만, 업로드한 지 15분도 안 됐는데 댓글이 달려있다고?


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

무료다운: https://lunetis.itch.io/operation-maverick

0개의 댓글