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

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

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

리지드바디, 콜라이더, 스크립트는 그대로 놔둔다
Animator 컴포넌트를 추가하고, Animation Controller를 새로 만든 후 참조시킨다. Avatar는 이름과 같이 검색해서 참조한다

LowerAvatar는 이렇게 세팅

Move에서 AimMove로 가는 상태 조건은 IsAim : true, 그 반대는 false. AimMove에서 Aim으로 가는 상태 조건은 IsMove : false, 그 반대는 true하반신을 담당한다

Idle : 가만히 있는 상태
Move : 조준 없이 이동키를 입력하는 상태. 무조건 전진 이동이라서 단순하게 뛰는 애니메이션만 넣으면 된다
Walk Blend Tree : 조준을 할 때는 걷는 상태가 된다. 그리고 카메라 시점을 기준으로 좌우로는 게걸음, 후로는 뒤로걸음을 하기 때문에 블렌드를 한다
Walk Blend Tree를 더블 클릭한다
Parameter를 float X,Z로 지정한다. 사용자의 입력값을 기준으로 애니메이션 방향을 정할 수 있게 한다2D 블렌드로 360도 이동을 자연스럽게 해본다
2D Simple Directional을 선택한다Parameter를 X,Z로 정해준다Motion들을 추가해 각각의 애니메이션을 넣어주고 Pos X, Pos Y 값들을 위와 같이 적어준다. 각각 전, 후, 좌, 우 이다
애니메이션 리깅에서 총이 골반쪽으로 연결 되어 있다보니 상반신과 하반신의 애니메이션 싱크에 차이가 나면, 총이 싱크가 엇나간 만큼 따로 노는 문제가 생긴다

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

오디오 클립을 재생하는 것이라면 오브젝트 풀 패턴으로 구현하는 것이 메모리에 이로울 것이다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 클래스를 상속받는 식으로 구현된 컴포넌트를 추가한다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;
}
}
// 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()마다 코루틴 클래스 객체가 생성되어 힙 메모리에 저장되기 때문
레이캐스트를 해서 닿는 충돌체의 정보를 불러온다. 이를 통해 데미지를 입힐 수 있다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 : 총구 화염 효과 : 파티클 시스템
}
}
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.Value가 true이면 이벤트에 의해서 발사 애니메이션 함수가 수행된다Shoot()은 발사 딜레이 시간이 지났을 때 수행하면 true로 반환한다. 반환과 함께 발사 관련 로직을 수행한다



리지드바디의 Constraints를 설정하는 것을 잊지말자
IdleCamera에는 오디오 리스너를 추가한다. 총에 의해서 생긴 오디오 클립 소리를 얘가 듣는다
AimCamera에는 임펄스 리스너를 추가한다. 총에 의해서 생긴 임펄스를 얘가 듣는다(화면이 떨림)
Weapon 프리팹은 위와 같이 설정한다. Shoot SFX와 Target Layer 설정을 잊지말자!
Impulse Source 컴포넌트에서 ImpulseChannel -> Edit을 누른다
임펄스 채널을 하나 늘려서 Shoot이라고 하고, 다시 Impulse Source 컴포넌트로 돌아가서, Impulse Channel을 위 사진과 같이 맞춰준다

Game Manager 프리팹
Audio Manager 프리팹이다. 게임 매니저 프리팹의 하위로 들어가야 한다. 자세항 내용은 게임매니저 스크립트를 참조하자SFX Prefab을 잊지말고 넣어줘야한다
SFX Prefab에 들어가는 프리팹 설정이다. SFX Controller(오브젝트 풀)을 컴포넌트로 추가하자

오브젝트 풀도 제 역할을 한다Weapon 프리팹에서 Target Layer를 Wall과 Target으로 설정하고, 발사해본다