오디오

  • 소리를 구현하기 위해서는 크게 두 가지가 필요하다. 소리를 내는 근원, 소리를 듣는 물체
  • 유니티는 소리를 재생하는 오브젝트소리를 듣는 오브젝트로 나누어 직관적으로 소리를 표현한다

오디오 소스

  • Audio Source. 컴포넌트. 소리를 내는 오브젝트
  • AudioClip을 재생한다. 재생 볼륨, 리스너의 거리에 따른 음량 감쇄 등을 설정할 수 있고, 여러 객체로 관리하며 배경음과 효과음을 동시에 재생하는 연출도 가능

오디오 클립

  • Audio Clip. 소리 파일을 뜻한다
  • 오디오 소스에 추가해서 사용한다

오디오 리스너

  • Audio Listener. 컴포넌트. 소리를 듣는 오브젝트
  • 오디오 소스로부터 재생되는 소리를 인식하고 사용자에게 송출한다
  • 이와같이 구현하면, 배경음과 효과음을 동시에 컨트롤하기 좋다

오디오 컨트롤러 예시 코드

  • 오디오를 컨트롤 하기 위한 스크립트
using System.Collections.Generic;
using UnityEngine;

public class AudioController : MonoBehaviour
{
   [field: SerializeField] public Audio Bgm { get; private set; } = new Audio();
   [field: SerializeField] public Audio Se { get; private set; } = new Audio();
}

[System.Serializable] public class Audio
{
   [SerializeField] private AudioSource _source;
   [SerializeField] private List<AudioClip> _clips = new List<AudioClip>();

   public void Play(int index)
   {
       if(index < 0 || _clips.Count <= index)
       {
           return;
       }

       _source.Stop();
       _source.clip = _clips[index];
       _source.Play();
   }

   public void Stop()
   {
       _source.Stop();
   }
}

TPS 게임 - 2

  • 오늘은 캐릭터 모델 변경 및 애니메이션 적용, 블렌드 트리로 부드러운 이동을 구현한다
  • 플레이어가 공격 시 총을 발사하고 그와 관련한 애니메이션 및 효과음을 재생한다

캐릭터 변경

Asset Store - Robot hero

  • 기존의 캡슐로 표현된 캐릭터 말고, 휴머노이드로 된 캐릭터로 변경한다

  • 간단하게 기존의 플레이어 프리팹에서 Avatar 부분에 새로 받은 캐릭터 모델 프리팹을 넣는다

  • 프리팹을 Override로 저장한다

  • 리지드바디, 콜라이더, 스크립트는 그대로 놔둔다

  • Animator 컴포넌트를 추가하고, Animation Controller를 새로 만든 후 참조시킨다. Avatar는 이름과 같이 검색해서 참조한다


애니메이션 적용

  • 하반신의 블렌드 트리를 위한 LowerAvatar는 이렇게 세팅

애니메이터

  • 아래와 같은 상태들을 추가한다

UpperLayer

  • 상반신을 담당한다
  • bool 파라매터를 통해 애니메이션 전환이 이루어진다. 예를들어 Move에서 AimMove로 가는 상태 조건은 IsAim : true, 그 반대는 false. AimMove에서 Aim으로 가는 상태 조건은 IsMove : false, 그 반대는 true

LowerLayer

  • 하반신을 담당한다

  • Idle : 가만히 있는 상태

  • Move : 조준 없이 이동키를 입력하는 상태. 무조건 전진 이동이라서 단순하게 뛰는 애니메이션만 넣으면 된다

  • Walk Blend Tree : 조준을 할 때는 걷는 상태가 된다. 그리고 카메라 시점을 기준으로 좌우로는 게걸음, 후로는 뒤로걸음을 하기 때문에 블렌드를 한다


Blend Tree

  • 상태로 블렌드 트리를 추가한다. 위의 사진의 Walk Blend Tree를 더블 클릭한다
  • Parameterfloat X,Z로 지정한다. 사용자의 입력값을 기준으로 애니메이션 방향을 정할 수 있게 한다
  • 4 방향에 해당하는 애니메이션들을 추가한 뒤 2D 블렌드로 360도 이동을 자연스럽게 해본다
  • 2D Simple Directional을 선택한다
  • ParameterX,Z로 정해준다
  • Motion들을 추가해 각각의 애니메이션을 넣어주고 Pos X, Pos Y 값들을 위와 같이 적어준다. 각각 전, 후, 좌, 우 이다
  • 재생 해보면 이제 원하는 대로 걷는 중에는 360도 게걸음을 할 수 있다

  • 문제가 있다. 아바타의 애니메이션 리깅에서 총이 골반쪽으로 연결 되어 있다보니 상반신과 하반신의 애니메이션 싱크에 차이가 나면, 총이 싱크가 엇나간 만큼 따로 노는 문제가 생긴다

문제 해결

  • 아바타 마스크를 추가로 설정한다
  • Transform에서 체크하는 것으로 세부적으로 애니메이션 리깅 조인트를 정할 수 있다
  • Leg1을 제외하고 모두 체크 해제한다. Hips는 하위 항목들도 체크해제 해줘야 한다. 총이 골반에 묶여 있기 때문에 해제하는 것이다

게임 매니저

  • 오디오 관리를 위해 추가적으로 수정한다
public class JYL_GameManager : JYL_SingleTon<JYL_GameManager>
{
    public JYL_AudioManager audioManager {  get; private set; }
    private void Awake()
    {
        SingletonInit();
        // 자식에서 구체화된 컴포넌트를 가져와야 한다
        // 프리팹으로 게임 매니저 하위로 오디오 매니저 프리팹을 넣어야 한다
        audioManager = GetComponentInChildren<JYL_AudioManager>();
    }
}


오브젝트 풀

  • 총알을 발사하는 것과 같이 여러 번 오디오 클립을 재생하는 것이라면 오브젝트 풀 패턴으로 구현하는 것이 메모리에 이로울 것이다

ObjectPool 스크립트

  • 저장하고자 하는 각각의 오브젝트인 PooledObject들을 모아 담을 스크립트로 ObjectPool 작성
public class JYL_ObjectPool
{
    private Stack<JYL_PooledObject> stack;
    // PooledObject 타입으로 된 프리팹
    // = PooledObject 컴포넌트를 가지고 있는 프리팹
    // 오늘의 오디오의 경우에는 SFXController가 PooledObject를 상속받아 구현된다
    // 그래서 SFXController를 상속받은 프리팹이 된다
    private JYL_PooledObject targetPrefab;
    // 스택에 넣기 위해 임시로 만드는 게임 오브젝트
    // 이후 PooledObject에 해당하는 컴포넌트를 참조시켜서 반환한다
    private GameObject poolObject;

    // 생성자로 인스턴스 생성
    public JYL_ObjectPool(Transform parent, JYL_PooledObject targetPrefab, int initSize = 5) => Init(parent,targetPrefab, initSize);

    private void Init(Transform parent, JYL_PooledObject targetPrefab, int initSize)
    {
        stack = new Stack<JYL_PooledObject>(initSize);
        this.targetPrefab = targetPrefab;

        // 생성되는 PooledObject들의 부모가 될 오브젝트다
        // 여기서 이름과 부모를 설정 한다
        poolObject = new GameObject($"{this.targetPrefab.name} Pool");
        poolObject.transform.parent = parent;

        // PooledObject를 사이즈만큼 생성하고 objectPool인 stack으로 push
        for (int i = 0; i < initSize; i++)
        {
            CreatePooledObject();
        }
    }

    public JYL_PooledObject PopPool()
    {
        // 오브젝트 풀이 비엇을 경우
        if (stack.Count == 0) CreatePooledObject();

        JYL_PooledObject pooledObject = stack.Pop();
        pooledObject.gameObject.SetActive(true);
        return pooledObject;
    }

    public void PushPool(JYL_PooledObject target)
    {
        // 굳이 이렇게까지 할 필요는 없지만, 시각적으로 보여주기 위함
        // PooledInit으로 poolObject가 모두 같은 오브젝트로 되어 있다
        target.transform.parent = poolObject.transform;
        target.gameObject.SetActive(false);
        stack.Push(target);
    }
    private void CreatePooledObject()
    {
        // 프리팹을 기반으로 게임오브젝트 새로 생성
        JYL_PooledObject obj = MonoBehaviour.Instantiate(targetPrefab);
        // 본인이 돌아가야할 풀을 지정
        // 여기서 this는 현재 이 인스턴스다
        obj.PooledInit(this);
        PushPool(obj);
    }
}

PooledObject 스크립트

  • 오브젝트 풀에 담길 요소가 된다
  • 다만, 이 컴포넌트를 직접 달지는 않고 이 PooledObject 클래스를 상속받는 식으로 구현된 컴포넌트를 추가한다
  • 이번 시간의 경우에는 SFXController가 해당한다
// 추상클래스로 상속시킨다
public abstract class JYL_PooledObject : MonoBehaviour
{
    public JYL_ObjectPool objPool {  get; private set; }
    public void PooledInit(JYL_ObjectPool objPool)
    {
        // 나중에 만들어진 오브젝트에
        // .objPool할 때 참조를 하기 위한 방법
        // 본인이 되돌아 가야 할 오브젝트 풀을 참조하는 것
        // 이 함수는 ObjectPool 클래스에서 수행된다
        // ObjectPool의 인스턴스를 돌아가야 할 풀로 지정한다
        // 오늘의 경우에는 AudioManager의 sfxPool의 sfxPrefab Pool이다
        this.objPool = objPool;
    }

    public void ReturnPool()
    {
        objPool.PushPool(this);
    }
}

오디오

유니티 - 오디오 클립

  • 플레이어가 총을 발사할 때 발사음이 들릴 수 있게 해보자

오디오 매니저

  • 오브젝트 풀을 요소로 가진다. 오브젝트 풀에는 발사음들이 담기게 된다
public class JYL_AudioManager : MonoBehaviour
{
    // 오디오 소스 컴포넌트를 가진다
    // 게임오브젝트에 컴포넌트를 추가해줘야 한다
    private AudioSource bgmSource;
    private JYL_ObjectPool sfxPool;

    // 오디오 클립을 리스트로 가져서 재생할 수 있게 한다
    [SerializeField] private List<AudioClip> bgmList = new();
    // SFXController는 PooledObject를 상속받는다
    [SerializeField] private JYL_SFXController sfxPrefab;




    private void Awake() => Init();
    private void Init()
    {
        bgmSource = GetComponent<AudioSource>();
        // 이 인스턴스의 게임오브젝트를 부모로 하는 sfxPrefab Pool이 만들어진다
        sfxPool = new JYL_ObjectPool(transform,sfxPrefab,10);
    }

    // 인덱스를 매개변수로 오디오 클립을 재생할 수 있는 함수
    public void BgmPlay(int index)
    {
        if (0 <= index && index < bgmList.Count)
        {
            bgmSource.Stop();
            bgmSource.clip = bgmList[index];
            bgmSource.Play();
        }
    }

    // 오브젝트 풀에서 하나 꺼내온다
    public JYL_SFXController GetSFX()
    {
        // 풀에서 꺼내서 반환
        JYL_PooledObject sfxPooled = sfxPool.PopPool();
        return sfxPooled as JYL_SFXController;

        // 이렇게도 가능. 단, GetComponent는 쪼금 무겁기 때문에
        // 탐색시간을 줄이는 방법이 권장됨
        //JYL_SFXController sfxPooled= sfxPool.PopPool().GetComponent<JYL_SFXController>();
        // 가능 2
        //PooledObject sfxPooled = sfxPool.PopPool() as PooledObject;
        // 가능 3
        // return sfxPool.PopPool() as PooledObject;
    }
}

SFX Controller

// PooledObject를 상속받는다
public class JYL_SFXController : JYL_PooledObject
{
    // 오디오 소스 컴포넌트를 가진다
    // 프리팹에 오디오 소스 컴포넌트를 추가 해줘야 한다
    private AudioSource audioSource;
    private float timer;
    private void Awake() => Init();
    private void Update()
    {
        // timer로 설정된 시간 만큼만 클립을 재생한다
        timer -= Time.deltaTime;
        if(timer<=0)
        {
            audioSource.Stop();
            audioSource.clip = null;
            // 재생이 완료되면 오브젝트 풀로 되돌린다
            ReturnPool();
        }
    }
    private void Init()
    {
        audioSource = GetComponent<AudioSource>();
    }

    // 클립을 재생시키는 함수. 재생이 시작된 이후 Update에서 타이머가 돌아간다
    public void PlayClip(AudioClip clip, bool loop =false, bool onAwake = false)
    {
        // 루프설정
        // audioSource.loop = loop;
        // 깨어날 때 플레이
        // audioSource.playOnAwake = onAwake;

        audioSource.Stop();
        audioSource.clip = clip;
        audioSource.Play();

        // 만약 클립이 4.67초이면 clip.length의 값은 float 4.67
        timer = clip.length;
    }
}

TIP
자주 반복이 일어나는 작업의 경우, 코루틴을 쓰지 않는 것이 좋다
코루틴은 클래스 인데 StartCoroutine() 마다 코루틴 클래스 객체가 생성되어 힙 메모리에 저장되기 때문


Weapon

  • 이제 무기를 구현 해보자
  • 총을 발사하면 오디오 클립이 재생된다
  • 총이 가리키고 있는 방향(플레이어의 정면)에 레이캐스트를 해서 닿는 충돌체의 정보를 불러온다. 이를 통해 데미지를 입힐 수 있다

스크립트 작성

public class JYL_Weapon : MonoBehaviour
{
    //레이캐스트 범위
    [SerializeField][Range(0,100)]private float attackRange;
    [SerializeField] private int shootDamage;
    [SerializeField] private float shootDelay;
    // 여기 들어가는 오디오 클립이 플레이 중에 재생된다
    [SerializeField] private AudioClip shootSFX;
    // 레이어 마스크로 타겟을 지정한다
    [SerializeField] private LayerMask targetLayer;

    // 임펄스 소스 컴포넌트를 게임 오브젝트에 추가한다. 화면에 떨림을 줄 수 있다
    // 그리고 임펄스 리스너를 카메라에 컴포넌트로 추가한다
    private CinemachineImpulseSource impulse;
    private Camera cam;
    // timer가 0보다 같거나 작으면 true -> 쏠 수 있다
    private bool canShoot { get => timer <= 0; }
    private float timer;

    private void Awake() => Init();
    private void Update() => HandleCanShoot();
    private void Init()
    {
        // 메인 카메라는 우선순위에 의해서
        // Idle, Aim 카메라로 상황에 따라 달라진다
        cam = Camera.main;
        impulse = GetComponent<CinemachineImpulseSource>();
    }

    public bool Shoot()
    {
        // shootDelay 시간의 쿨타임 동안 쏠 수 없다
        if (!canShoot) return false;
        PlayShootSound();
        PlayCameraEffect();
        PlayShootEffect();
        // 이때부터 Update마다 타이머가 줄어든다
        timer = shootDelay;
        // TODO : Ray 발사 -> 반환받은 대상에게 데미지 부여
        // 몬스터 구현 시 같이 구현한다
        GameObject target = RayShoot();
        if (target == null) return true;

        Debug.Log($"총에 맞음 : {target.name}");
        // -----------
        return true;
    }

    private GameObject RayShoot()
    {
        Ray ray = new Ray(cam.transform.position, cam.transform.forward);
        RaycastHit hit;
        if(Physics.Raycast(ray,out hit,attackRange,targetLayer))
        {
            // 타겟으로 설정한 레이어를 가진 게임 오브젝트가
            // 레이캐스트로 맞았을 때 반환한다
            return hit.transform.gameObject;
            //TODO: 몬스터 구현방식에 따라 달라짐
            //------------------
        }
        return null;
    }

    // 타이머를 업데이트마다 감소시키는 함수
    private void HandleCanShoot()
    {
        if (canShoot) return;
        timer -= Time.deltaTime;
    }

    private void PlayShootSound()
    {
        // 풀에서 꺼내온다
        JYL_SFXController sfx = JYL_GameManager.Instance.audioManager.GetSFX();
        // 드래그&드롭으로 참조시킨 사운드를 재생시킨다
        sfx.PlayClip(shootSFX);
        // 활성화 되는 순간부터 타이머가 설정되고, 타이머 동안 재생된다
        // 타이머의 길이는 클립의 길이
    }

    // 임펄스를 발생시킨다
    private void PlayCameraEffect()
    {
        impulse.GenerateImpulse();
    }
    private void PlayShootEffect()
    {
        // TODO : 총구 화염 효과 : 파티클 시스템
    }

}

총 발사 테스트

  • 이제 무기와 관련한 구현은 완료했다. 제대로 발사가 되는지 테스트 해본다

PlayerController 수정

  • 총을 조작하는 기능 구현
private void HandleShooting()
{
    if(status.IsAiming.Value && Input.GetKey(fireKey))
    {
        // Shoot의 반환 값이 false이면 쏠 수 없다
        status.IsAttacking.Value = weapon.Shoot();
    }
    else
    {
        status.IsAttacking.Value = false;
    }
}
private void SetAttackAnimation(bool value) => animator.SetBool("IsAttack",value);
 
public void SubscribeEvents()
{
    status.IsAttacking.Subscribe(SetAttackAnimation);
}

public void UnsubscribeEvents()
{
    status.IsAttacking.UnSubscribe(SetAttackAnimation);
}

private void HandlePlayerControl()
{
    HandleShooting();
}
  • 조준 중이고 발사 키를 눌렀을 때 IsAttacking.Valuetrue이면 이벤트에 의해서 발사 애니메이션 함수가 수행된다
  • Shoot()은 발사 딜레이 시간이 지났을 때 수행하면 true로 반환한다. 반환과 함께 발사 관련 로직을 수행한다

발사 테스트

  • 벽 설정
  • 타겟 설정
  • 플레이어 캐릭터 컴포넌트 설정
  • 리지드바디Constraints를 설정하는 것을 잊지말자
  • IdleCamera에는 오디오 리스너를 추가한다. 총에 의해서 생긴 오디오 클립 소리를 얘가 듣는다
  • AimCamera에는 임펄스 리스너를 추가한다. 총에 의해서 생긴 임펄스를 얘가 듣는다(화면이 떨림)
  • Weapon 프리팹은 위와 같이 설정한다. Shoot SFXTarget Layer 설정을 잊지말자!
  • Impulse Source 컴포넌트에서 ImpulseChannel -> Edit을 누른다
  • 그러면 요런게 뜨는데 추후에 배울 내용으로서 스크립터블 오브젝트 이라고 한다
  • 임펄스 채널을 하나 늘려서 Shoot이라고 하고, 다시 Impulse Source 컴포넌트로 돌아가서, Impulse Channel을 위 사진과 같이 맞춰준다
  • Game Manager 프리팹
  • Audio Manager 프리팹이다. 게임 매니저 프리팹의 하위로 들어가야 한다. 자세항 내용은 게임매니저 스크립트를 참조하자
  • SFX Prefab을 잊지말고 넣어줘야한다
  • SFX Prefab에 들어가는 프리팹 설정이다. SFX Controller(오브젝트 풀)을 컴포넌트로 추가하자

실행

  • 정상적으로 동작한다. 소리도 발사할 때 제대로 나온다
  • 오브젝트 풀도 제 역할을 한다

레이캐스트 테스트

  • 만들어둔 과녁과 벽에 레이어 설정을 해 두었다. Weapon 프리팹에서 Target LayerWallTarget으로 설정하고, 발사해본다
  • 레이캐스트는 플레이어의 정면으로 발사된다
  • 레이어를 잘 인식하는 것을 볼 수 있다
profile
개발 박살내자

0개의 댓글