지난번에 이어 게임을 만들어보자.
이번에 참고할 강의는 아래 강의이다.
https://www.youtube.com/watch?v=xvpP9U20ioQ&list=PLFt_AvWsXl0ctd4dgE1F8g3uec4zKNRV0&index=24
이번 강의에서도 지난시간에 이어 사운드를 추가해본다.
시작해보자.
지난 강의에서는 배경음악, 무기 발사 효과음을 추가했다.
이번에는 총알이 충돌하는 효과음, 적의 사망 효과음 등을 추가해본다.
상황별로 여러 효과음이 있는데, 우리는 각 상황에서 여러 효과음 중 하나가 매번 랜덤으로 재생되게 하고 싶다.
지금 사운드 재생을 위해 만들어져 있는 메소드는 오디오 클립을 전달해야 한다.
이번에는 사운드 라이브러리를 만들어 비슷한 사운드들을 배열에 담아놓은 뒤
키 값으로는 사운드 배열의 이름을 사용할 것이므로 문자열 형식으로 한다.
이제 키값을 전달받고, 비슷한 사운드를 가지고있는 배열에서 사운드를 랜덤하게 선택해 재생해보자.
추가로, 사운드 볼륨을 조절할 수 있도록 하고, 플레이어 프리팹(PlayerPrefs)를 사용하여 볼륨값을 저장한 뒤
나중에 게임을 다시 실행해도 설정했던 볼륨값을 적용할 수 있도록 하자.
AudioManager 스크립트를 수정하고 SoundLibrary 스크립트를 생성한다.
| AudioManager 스크립트
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class AudioManager : MonoBehaviour {
public enum AudioChannel {Master, Sfx, Music}; // 오디오 채널
float masterVolumePercent = 1; // 마스터 볼륨
float sfxVolumePercent = 1; // 효과음 볼륨
float musicVolumePercent = 1; // 음악 볼륨
AudioSource sfx2DSource; // 2D 효과음 오디오소스 레퍼런스
AudioSource[] musicSources; // 음악을 가져올 오디오소스 레퍼런스 배열
int activeMusicSourceIntdex; // 재생중인 음악 인덱스
public static AudioManager instance; // 싱글톤 패턴
Transform audioListener; // 오디오 리스너 위치 레퍼런스
Transform playerT; // 플레이어 위치 레퍼런스
SoundLibrary library; // 사운드 라이브러리 레퍼런스
void Awake() {
if(instance != null) { // 인스턴스가 생성되어 있다면
Destroy(gameObject); // 현재 게임 오브젝트 파괴
}
else {
instance = this; // 인스턴스 설정
DontDestroyOnLoad(gameObject); // 게임 로드 시 이 게임 오브젝트가 파괴되지 않도록 한다
library = GetComponent<SoundLibrary>(); // 사운드 라이브러리 할당
musicSources = new AudioSource[2]; // 크기 설정하여 할당
for(int i = 0; i < 2; i++) {
GameObject newMusicSource = new GameObject("Music Source " + (i + 1)); // 오디오소스를 가질 오브젝트 생성
musicSources[i] = newMusicSource.AddComponent<AudioSource>(); // 오디오소스 할당
newMusicSource.transform.parent = transform; // 부모 오브젝트 설정
}
GameObject newSfxSource = new GameObject("2D sfx Source"); // 2D 오디오소스를 가질 오브젝트 생성
sfx2DSource = newSfxSource.AddComponent<AudioSource>(); // 오디오소스 할당
newSfxSource.transform.parent = transform; // 부모 오브젝트 설정
audioListener = FindObjectOfType<AudioListener>().transform; // 오디오 리스너가 있는 오브젝트의 위치를 저장
playerT = FindObjectOfType<Player>().transform; // 플레이어 오브젝트의 위치 저장
// PlayerPrefs 를 사용해 저장한 볼륨을 불러와 게임에 적용한다.
masterVolumePercent = PlayerPrefs.GetFloat("master vol", masterVolumePercent);
sfxVolumePercent = PlayerPrefs.GetFloat("sfx vol", sfxVolumePercent);
musicVolumePercent = PlayerPrefs.GetFloat("music vol", musicVolumePercent);
}
}
void Update() {
if(playerT != null) { // 플레이어가 있는 경우
audioListener.position = playerT.position; // 오디오 리스너가 있는 오브젝트 위치를 플레이어 위치로 설정
}
}
// ■ 사운드 볼륨 조절 메소드
public void SetVolume(float volumePercent, AudioChannel channel) {
switch(channel) {
case AudioChannel.Master: // 마스터 볼륨 설정
masterVolumePercent = volumePercent;
break;
case AudioChannel.Sfx: // 효과음 볼륨 설정
sfxVolumePercent = volumePercent;
break;
case AudioChannel.Music: // 음악 볼륨 설정
musicVolumePercent = volumePercent;
break;
}
musicSources[0].volume = musicVolumePercent * masterVolumePercent; // 설정한 첫 번째 음악의 볼륨 설정
musicSources[1].volume = musicVolumePercent * masterVolumePercent; // 설정한 두 번째 음악의 볼륨 설정
// PlayerPrefs 를 사용해 볼륨을 저장하고 다음번에 게임을 실행할 때도 적용되도록 한다.
PlayerPrefs.SetFloat("master vol", masterVolumePercent);
PlayerPrefs.SetFloat("sfx vol", sfxVolumePercent);
PlayerPrefs.SetFloat("music vol", musicVolumePercent);
}
// ■ 음악 재생 메소드
public void PlayMusic(AudioClip clip, int fadeDuration = 1) {
activeMusicSourceIntdex = 1 - activeMusicSourceIntdex; // 재생할 음악 인덱스 설정
musicSources[activeMusicSourceIntdex].clip = clip; // 재생할 음악 클립 설정
musicSources[activeMusicSourceIntdex].Play(); // 음악 재생
StartCoroutine(AnimateMusicCrossFade(fadeDuration)); // 음악 크로스페이드 코루틴 실행
}
// ■ 오디오 클립과 위치를 받아 해당 위치에 사운드를 재생하는 메소드
public void PlaySound(AudioClip clip, Vector3 pos) {
if(clip != null) { // 오디오 클립이 있는 경우
AudioSource.PlayClipAtPoint(clip, pos, sfxVolumePercent * masterVolumePercent);
// pos 위치에 효과음 * 마스터 볼륨 크기로 clip 재생
}
}
// ■ 오디오 이름과 위치를 받아 해당 위치레 사운드를 재생하는 메소드
public void PlaySound(string soundName, Vector3 pos) {
PlaySound(library.GetClipFromName(soundName), pos); // 사운드 재생 메소드 호출
}
// ■ 오디오 이름과 위치를 받아 2D 기준 사운드를 재생하는 메소드
public void PlaySound2D(string soundName) {
sfx2DSource.PlayOneShot(library.GetClipFromName(soundName), sfxVolumePercent * masterVolumePercent);
// 효과음을 2D 사운드로 출력
}
// ■ 음악을 크로스페이드(부드럽게 변환) 코루틴
IEnumerator AnimateMusicCrossFade(int duration) {
float percent = 0; // 음악 퍼센트
while(percent < 1) {
percent += Time.deltaTime * 1 / duration; // 퍼센트 계산
musicSources[activeMusicSourceIntdex].volume = Mathf.Lerp(0, musicVolumePercent * masterVolumePercent, percent);
musicSources[1 - activeMusicSourceIntdex].volume = Mathf.Lerp(musicVolumePercent * masterVolumePercent, 0, percent);
// 음악 볼륨 설정, Lerp 를 통해 활성화하는 음악은 점점 커지게, 비활성와 음악은 점점 작아지게 설정.
yield return null; // 다음 프레임으로 스킵
}
}
}
| SoundLibrary 스크립트
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class SoundLibrary : MonoBehaviour
{
public SoundGroup[] soundGroups; // 사운드 그룹 객체 배열
Dictionary<string, AudioClip[]> groupDictionary = new Dictionary<string, AudioClip[]>();
// Dictionary 를 통해 각 사운드 배열(그룹)에 키를 주고 해당 키값으로 사운드 그룹을 찾을 수 있다.
void Awake() {
foreach(SoundGroup soundGroup in soundGroups) {
groupDictionary.Add(soundGroup.groupID, soundGroup.group); // 딕셔너리에 그룹 사운드 추가
}
}
// ■ 이름으로 사운드 배열을 받아 랜덤 사운드를 반환하는 메소드
public AudioClip GetClipFromName(string name) {
if(groupDictionary.ContainsKey(name)) { // name 을 Key로 하는 Value 가 있다면
AudioClip[] sounds = groupDictionary[name]; // name 을 Key로 하는 Value 를 sounds 오디오클립 배열에 저장
return sounds[Random.Range(0, sounds.Length)]; // sounds 의 사운드 중 랜덤으로 하나를 반환
}
return null;
}
// ■ 사운드 그룹(배열) 클래스, 직렬화
[System.Serializable]
public class SoundGroup {
public string groupID; // 사운드 배열 인덱스
public AudioClip[] group; // 사운드 배열
}
}
Audio Manager 오브젝트에 SoundLibrary 스크립트를 추가하고
Sound Group 을 추가해 다운받은 폴더별로 그룹을 만들고, 각 사운드들을 할당해준다.

이제 사운드 랜덤 재생은 해결했으니 각 상황에 맞게 사운드가 재생되도록 하자.
먼저 적 타격, 사망, 공격 시 사운드를 추가하기 위해 Enemy 스크립트를 수정하자.
| Enemy 스크립트
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
[RequireComponent(typeof(UnityEngine.AI.NavMeshAgent))]
// 현재 오브젝트에 NavMeshAgent 를 포함
public class Enemy : LivingEntity
{
public enum State { Idle, Cashing, Attacking };
// 적의 상태, { 기본(아무것도 안함), 추적, 공격 }
State currentState; // 현재 상태
public ParticleSystem deathEffect; // 사망 이펙트 레퍼런스
UnityEngine.AI.NavMeshAgent pathfinder; // 내비게이션 레퍼런스
Transform target; // 적의 타겟(플레이어) 트랜스폼
LivingEntity targetEntity; // 타겟(플레이어) 레퍼런스
Material skinMatreial; // 현재 오브젝트의 마테리얼
Color originalColor; // 현재 오브젝트의 기존 색상
float attackDistanceThreshold = 0.5f;
// 공격 거리 임계값(사거리). 유니티에서 단위 1은 1 meter 이다..!!
float timeBetweenAttacks = 1;
// 공격 시간 간격
float nextAttackTime;
// 실제 다음 공격 시간
float damage = 1;
// 적의 공격 데미지
float myCollisionRadius; // 자신의 충돌 범위 반지름
float targetConllisionRadius; // 타겟의 충돌 범위 반지름
bool hasTarget; // 타겟이 있는지 확인
void Awake()
{
pathfinder = GetComponent<UnityEngine.AI.NavMeshAgent>();
// NavMeshAgent 레퍼런스 생성
if (GameObject.FindGameObjectWithTag("Player") != null)
// 적 스폰 후 플레이어 오브젝트가 있는(플레이어가 살아있는) 경우
{
hasTarget = true; // 타겟(플레이어) 있음으로 설정
target = GameObject.FindGameObjectWithTag("Player").transform;
// 타겟으로 "Player" 라는 태그를 가진 오브젝트의 트랜스폼을 저장
targetEntity = target.gameObject.GetComponent<LivingEntity>();
// 위에서 저장 한 플레이어 오브젝트의 LivingEntity 컴포넌트를 저장
myCollisionRadius = GetComponent<CapsuleCollider>().radius;
// 자신의 충돌체(collider)의 반지름을 저장
targetConllisionRadius = target.GetComponent<CapsuleCollider>().radius;
// 타겟의 충돌체(collider)의 반지름을 저장
}
}
protected override void Start()
// override 로 부모 클래스의 메소드를 재정의.
{
base.Start(); // base 를 통해 부모 클래스의 기존 메소드를 호출.
if(hasTarget)
// 적 스폰 후 플레이어 오브젝트가 있는(플레이어가 살아있는) 경우
{
currentState = State.Cashing; // 기본 상태를 추적 상태로 설정
targetEntity.OnDeath += OnTargetDeath;
// 타겟 사망 메소드 추가.
StartCoroutine(UpdatePath());
// 지정된 시간마다 목적지 쪽으로 위치를 갱신하는 코루틴 실행
}
}
// ■ 정보 수정 메소드
public void SetCharactoristics(float moveSpeed, int hitsToKillPlayer, float enemyHealth, Color skinColour) {
pathfinder.speed = moveSpeed; // 적의 이동속도 설정
if (hasTarget) { // 목표 (플레이어)가 있다면
damage = Mathf.Ceil(targetEntity.startingHealth / hitsToKillPlayer); // 적의 공격력 설정
}
startingHealth = enemyHealth; // 적의 체력 설정
skinMatreial = GetComponent<Renderer>().sharedMaterial; // 현재 오브젝트의 마테리얼 저장.
skinMatreial.color = skinColour; // 적의 색상 설정
originalColor = skinMatreial.color; // 현재 색상 저장
}
// ■ 타격 메소드 오버라이딩
public override void TakeHit(float damage, Vector3 hitPoint, Vector3 hitDirection) {
AudioManager.instance.PlaySound("Impact", transform.position); // 현재 위치에 타격 사운드 재생
if(damage >= health) { // 데미지가 현재 체력 이상인 경우
AudioManager.instance.PlaySound("Enemy Death", transform.position); // 현재 위치에 사망 사운드 재생
Destroy(Instantiate(deathEffect.gameObject, hitPoint, Quaternion.FromToRotation(Vector3.forward, hitDirection)) as GameObject, deathEffect.startLifetime);
// 이펙트(파티클)을 인스턴스화 하여 생성(FromToRotation 으로 방향 설정), 설정한 시간경과 후 파괴
}
base.TakeHit(damage, hitPoint, hitDirection);
// 부모 클래스의 기존 TakeHit 메소드 호출
}
void OnTargetDeath() // 타겟(플레이어)이 죽었을 때 호출되는 메소드
{
hasTarget = false; // 타겟 없음으로 설정
currentState = State.Idle; // 현재 상태를 기본(정지)상태로 변경
}
void Update()
{
if (hasTarget) // 타겟(플레이어)가 있는 경우
{
if (Time.time >= nextAttackTime) // 공격 시간이 지나면
{
float sqrDstToTarget = (target.position - transform.position).sqrMagnitude;
/* 자신(적)과 목표(플레이어) 사이의 거리를 저장.
두 오브젝트 사이의 거리를 측정하기 위해 Vector3 의 Distace 메소드를
쓰는 방법도 있지만 벡터에 제곱 연산이 들어가다 보니 연산 수가 많다.
두 오브젝트 사이의 실제 거리가 필요한 것이 아닌 비교값이 필요 한
것이므로 위와 같이 연산을 줄여 볼 수 있다. */
if (sqrDstToTarget <= Mathf.Pow(attackDistanceThreshold + targetConllisionRadius + myCollisionRadius, 2))
// 거리의 제곱과 임계 거리의 제곱을 비교하여 공격 범위 내인지 확인
{
nextAttackTime = Time.time + timeBetweenAttacks;
// 다음 공격 시간 지정
AudioManager.instance.PlaySound("Enemy Attack", transform.position); // 현재 위치에 공격 사운드 재생
StartCoroutine(Attack());
// 공격 코루틴 실행
}
}
}
}
IEnumerator Attack() // 적의 공격 코루틴
{
currentState = State.Attacking; // 공격 상태로 변경
pathfinder.enabled = false; // 공격 중 플레이어 추적 중지
Vector3 originalPosition = transform.position; // 자신(적)의 위치
Vector3 dirToTarget = (target.position - transform.position).normalized;
// 타겟으로의 방향 벡터 계산
Vector3 attackPosition = target.position - dirToTarget * (myCollisionRadius);
// 타겟(플레이어)의 위치
float attackSpeed = 3; // 공격 속도
float percent = 0;
skinMatreial = GetComponent<Renderer>().material; // 현재 오브젝트의 마테리얼 저장.
skinMatreial.color = Color.magenta; // 공격 시 색상 지정
bool hasAppliedDamage = false; // 데미지를 적용 중인지 확인
while(percent <= 1) // 찌르는 거리가 1 이하일 때 루프
{
if(percent >= .5f && hasAppliedDamage == false)
// 적이 공격 지점에 도달했고 데미지를 적용중이지 않은 경우
{
hasAppliedDamage = true; // 데미지 적용 중으로 설정
targetEntity.TakeDamage(damage); // 타겟(플레이어)에게 데미지 적용
}
percent += Time.deltaTime * attackSpeed;
float interpolation = (-Mathf.Pow(percent, 2) + percent) * 4;
/* 대칭 함수를 사용
원지점->공격지점 이동에서 보간 값을 참조한다. */
transform.position = Vector3.Lerp(originalPosition, attackPosition, interpolation);
/* Lerp 메소드는 내분점을 반환한다.
원점, 공격지점, 보간 값을 참조값으로 전달한다
보간 값이 0이면 원점에, 1이면 공격지점에 있게 된다. */
yield return null;
/* while 루프의 처리 사이에서 프레임을 스킵합니다.
이 지점에서 작업이 멈추고 Update 메소드 작업이 끝나고
다음 프레임으로 넘어가면 이 아래의 코드나 루프가 실행 */
}
skinMatreial.color = originalColor; // 공격이 끝나면 기존 색상으로 변경
currentState = State.Cashing; // 공격이 끝나면 추적 상태로 변경
pathfinder.enabled = true; // 공격이 끝나면 다시 플레이어 추적
}
IEnumerator UpdatePath()
// 해당 코루틴 실행 시 지정한 시간마다 루프문 내부 코드 반복
{
float refreshRate = .25f; // 루프 시간
while(hasTarget) // 타겟이 있을 때 반복
{
if(currentState == State.Cashing)
// 상태가 추적 상태인 경우
{
Vector3 dirToTarget = (target.position - transform.position).normalized;
// 타겟으로의 방향 벡터 계산
Vector3 targetPosition = target.position - dirToTarget * (myCollisionRadius + targetConllisionRadius + attackDistanceThreshold / 2);
/* 자신의 위치에서 자신과 타겟의 반지름 길이에 공격 사거리의 절반을 더하고, 방향 벡터를 곱한 값을 뺀다.
즉, 타겟(플레이어) 공격 가능한 위치를 타겟 위치로 정한다. */
if (!dead) // 죽지 않은 경우
{
pathfinder.SetDestination(targetPosition);
// 내비게이션의 목적지를 타겟(플레이어)의 위치로 설정
}
}
yield return new WaitForSeconds(refreshRate);
// refreshRate 시간만큼 기다림
}
}
}
이번엔 웨이브 클리어와 플레이어가 죽었을 때의 사운드도 추가해보자

상황별로 Sound Group 을 만들고 사운드를 할당해준다.
| Spawner 스크립트
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Spawner : MonoBehaviour
{
public bool devMode; // 개발자 모드 여부
public Wave[] waves; // 웨이브들을 저장할 배열 생성
public Enemy enemy; // 스폰할 적 레퍼런스
LivingEntity playerEntity; // 플레이어 레퍼런스
Transform playerT; // 플레이어 위치
Wave currentWave; // 현재 웨이브 레퍼런스
int currentWaveNumber; // 현재 웨이브 번호
int enemiesRemainingToSpawn; // 남아있는 스폰 할 적 수
int enemiesRemainingAlive; // 살아있는 적의 수
float nextSpawnTime; // 다음 스폰까지의 시간
MapGenerator map; // 맵 생성기 레퍼런스
float timeBetweenCampingChecks = 2; // 캠핑 방지 체크 딜레이
float nextCampingCheckTime; // 다음 캠핑 체크 시간
float campThresholdDistance = 1.5f; // 캠핑 방지를 위해 이동해야 하는 거리
Vector3 campPositionOld; // 이전 플레이어 위치
bool isCamping; // 캠핑중인지
bool isDisable; // 플레이어 생존 여부
public event System.Action<int> OnNewWave; // 웨이브 번호를 매개변수로 갖는 웨이브 이벤트 추가
void Start() {
playerEntity = FindObjectOfType<Player>(); // 플레이어 오브젝트 할당
playerT = playerEntity.transform; // 플레이어 위치
nextCampingCheckTime = Time.time + timeBetweenCampingChecks;
// 다음 캠핑 체크 시간 설정
campPositionOld = playerT.position;
// 플레이어 위치 설정
map = FindObjectOfType<MapGenerator>(); // 맵 생성기
NextWave(); // 시작 시 웨이브 실행
playerEntity.OnDeath += OnPlayerDeath; // 플레이어가 죽을 때 위 메소드를 추가
}
void Update()
{
if (!isDisable) { // 비활성화(플레이어 죽음)가 아닌 경우
if (Time.time > nextCampingCheckTime)
{ // 캠핑 체크 시간이 지난 경우
nextCampingCheckTime = Time.time + timeBetweenCampingChecks;
// 다음 캠핑 체크 시간 설정
isCamping = (Vector3.Distance(playerT.position, campPositionOld) < campThresholdDistance);
// 캠핑중인지 확인
campPositionOld = playerT.position;
// 위치 갱신
}
if ((enemiesRemainingToSpawn > 0 || currentWave.infinite) && Time.time >= nextSpawnTime)
// 적 수가 남아있거나 무한모드이고, 현재 시간이 다음 스폰 시간보다 큰 경우
{
enemiesRemainingToSpawn--; // 적 수를 하나 줄임
nextSpawnTime = Time.time + currentWave.timeBetweenSpawns;
// 다음 스폰 시간을 현재시간 + 스폰 간격 으로 저장
StartCoroutine("SpawnEnemy"); // 적 스폰 코루틴 시작
}
}
if (devMode) { // 개발자 모드인 경우
if(Input.GetKeyDown(KeyCode.Return)) { // 버튼을 누른 경우
StopCoroutine("SpawnEnemy"); // 적 스폰 코루틴 중지
foreach(Enemy enemy in FindObjectsOfType<Enemy>()) { // Enemy 오브젝트를 모두 찾고
GameObject.Destroy(enemy.gameObject); // 해당 게임 오브젝트 파괴
}
NextWave(); // 다음 웨이브 실행
}
}
}
// ■ 적 스폰 코루틴
IEnumerator SpawnEnemy() {
float spawnDelay = 1;
// 적 스폰 대기시간
float tileFlashSpeed = 4;
// 초당 몇번 타일이 깜빡일지 설정
Transform spawnTile = map.GetRandomOpenTile();
// 랜덤 오픈 타일 가져오기
if (isCamping) { // 플레이어가 캠핑중인 경우
spawnTile = map.GetTileFromPosition(playerT.position);
// 타일 위치를 플레이어 위치로 설정
isCamping = false;
// 캠핑 해제
}
Material tileMat = spawnTile.GetComponent<Renderer>().material;
// 가져온 타일의 마테리얼 가져오기
Color initialColour = tileMat.color;
// 기존 타일 색상 저장
Color flashColour = Color.red;
// 변경할 색상 저장
float spawnTimer = 0;
// 적 소환 시간 타이머
while(spawnTimer < spawnDelay) { // 적 스폰 대기시간이 안된 경우
tileMat.color = Color.Lerp(initialColour, flashColour, Mathf.PingPong(spawnTimer * tileFlashSpeed, 1));
// 보간값을 통해 색상을 설정하여 깜빡이도록, PingPong 함수로 설정한 횟수만큼 깜빡이도록 속도를 설정.
spawnTimer += Time.deltaTime; // 타이머에 시간 추가
yield return null; // 한 프레임 대기
}
tileMat.color = initialColour; // 타일 색상 초기화
Enemy spawnedEnemy = Instantiate(enemy, spawnTile.position + Vector3.up, Quaternion.identity) as Enemy;
// 적을 인스턴스화를 통해 생성, 랜덤 타일 위치에 회전값 없이 배치
spawnedEnemy.OnDeath += OnEnemyDeath;
// 적이 죽을 때 위 메소드를 추가
spawnedEnemy.SetCharactoristics(currentWave.moveSpeed, currentWave.hitsToKillPlayer, currentWave.enemyHealth, currentWave.skinColour);
// 적 정보 설정 메소드 호출
}
// ■ 플레이어가 죽을 때 처리하는 메소드
void OnPlayerDeath() {
isDisable = true;
// 생존 여부 죽음(비활성화)으로 설정.
}
void OnEnemyDeath()
/* 적이 죽을 때 처리하는 메소드
적이 죽으면 LivingEntity 에서 OnDeath 를 호출하고,
OnDeath 는 이 메소드를 호출해서 적이 죽을 때 알려준다. */
{
enemiesRemainingAlive--; // 적의 수를 1 줄임
if(enemiesRemainingAlive == 0) // 적의 수가 0이 되면
{
NextWave(); // 다음 웨이브 실행
}
}
// ■ 플레이어 위치 리셋 메소드
void ResetPlayerPosition() {
playerT.position = map.GetTileFromPosition(Vector3.zero).position + Vector3.up * 1.5f;
// 플레이어 위치를 가운데 타일 위치로 설정
}
// ■ 다음 웨이브 실행 메소드
void NextWave()
{
if(currentWaveNumber > 0) { // 첫 번째 웨이브가 아닌 경우
AudioManager.instance.PlaySound2D("Level Complete"); // 웨이브 클리어 시 클리어 사운드 재생
}
currentWaveNumber++; // 웨이브 숫자 증가
if(currentWaveNumber - 1 < waves.Length) { // 배열 인덱스 예외 없도록 처리
currentWave = waves[currentWaveNumber - 1];
/* 현재 웨이브 레퍼런스 참조
(웨이브 숫자는 1부터 시작 할 것이므로 -1 하여 배열의 인덱스에 맞게 참조) */
enemiesRemainingToSpawn = currentWave.enemyCount;
// 이번 웨이브의 적 수 저장
enemiesRemainingAlive = enemiesRemainingToSpawn;
// 살아있는 적의 수를 스폰 할 적의 수로 저장
if (OnNewWave != null) { // 이벤트 구독자가 있는 경우
OnNewWave(currentWaveNumber); // 웨이브 번호 전달
}
ResetPlayerPosition(); // 플레이어 위치 초기화
}
}
[System.Serializable]
/* 스크립트 직렬화, 직렬화를 통해 객체, 변수 등을 선언 할 때의
접근 제한(private 등)은 유지되지만 인스펙터에서 값을 변경 가능하게 함 */
public class Wave
// 적의 주기, 스폰 주기 등 웨이브 정보를 저장 할 클래스 생성
{
public bool infinite; // 무한 모드 설정
public int enemyCount; // 적의 수
public float timeBetweenSpawns; // 적의 스폰 주기
public float moveSpeed; // 적의 속도
public int hitsToKillPlayer; // 적의 공격력(설정된 횟수만큼 플레이어 공격 시 플레이어 사망)
public float enemyHealth; // 적의 체력
public Color skinColour; // 적의 색상
}
}
| Player 스크립트
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
[RequireComponent(typeof(GunController))] // GunController 스크립트를 포함
[RequireComponent(typeof(PlayerController))] // PlayerController 스크립트를 포함
public class Player : LivingEntity
{
public float moveSpeed = 5.0f; // 플레이어 이동속도
public Crosshair crosshairs; // 조준점 레퍼런스
Camera viewCamera; // 카메라
PlayerController controller; // 플레이어 컨트롤러
GunController gunController; // 총 컨트롤러
protected override void Start() { // override 로 부모 클래스의 메소드를 재정의.
base.Start(); // base 를 통해 부모 클래스의 기존 메소드를 호출.
}
void Awake() {
controller = GetComponent<PlayerController>();
// 현재 오브젝트의 플레이어 컨트롤러를 가져옴
gunController = GetComponent<GunController>();
// 현재 오브젝트의 총 컨트롤러를 가져옴
viewCamera = Camera.main;
// 카메라
FindObjectOfType<Spawner>().OnNewWave += OnNewWave;
// Spawner 의 새 웨이브 시작 이벤트 구독
}
// ■ 새 웨이브 시작 시 메소드
void OnNewWave(int waveNumber) {
health = startingHealth; // 체력 초기화
gunController.EquipGun(waveNumber - 1); // 장착할 총 설정
}
void Update() {
// 이동 입력
Vector3 moveInput = new Vector3(Input.GetAxisRaw("Horizontal"), 0, Input.GetAxis("Vertical"));
// GetAxisRaw 로 스무딩 X
Vector3 moveVelocity = moveInput.normalized * moveSpeed;
// normalized 로 단위벡터로 변환, 이동 속도를 계산
controller.Move(moveVelocity);
// 이동 속도를 이동 메소드로 전달
// 방향 입력
Ray ray = viewCamera.ScreenPointToRay(Input.mousePosition);
// 카메라 -> 마우스 커서 위치로 레이 발사.
Plane groundPlane = new Plane(Vector3.up, Vector3.up * gunController.GunHeight);
// 평면의 법선벡터 생성.
float rayDistance;
// 위에서 쏜 레이와 평면의 교차지점까지 거리
if (groundPlane.Raycast(ray, out rayDistance)) {
// 레이가 평면과 교차하는 경우 교차지점까지의 거리를 rayDistance 에 저장.
Vector3 point = ray.GetPoint(rayDistance);
// GetPoint 와 교차지점까지의 거리로 교차지점의 위치를 저장.
// 카메라부터 교차지점까지 선으로 표시 : Debug.DrawLine(ray.origin, point, Color.red);
controller.LookAt(point);
// 플레이어 컨트롤러의 방향 전환 메소드에 교차 지점(방향) 전달.
crosshairs.transform.position = point;
// 조준점 위치 설정
crosshairs.DetectTarget(ray);
// 조준점 레이캐스트
if((new Vector2(point.x, point.z) - new Vector2(transform.position.x, transform.position.z)).sqrMagnitude > 1) {
// 조준점(커서)와 플레이어 사이 거리가 1 이상일 때
gunController.Aim(point);
// 총 에임 보정 위치 전달
}
}
// 무기 조작 입력
if (Input.GetMouseButton(0)) // 마우스 왼쪽 버튼 클릭 시
{
gunController.OnTriggerHold(); // 방아쇠 당김 메소드 호출
}
if (Input.GetMouseButtonUp(0)) // 마우스 왼쪽 버튼 클릭 후 손을 뗄 시
{
gunController.OnTriggerRelease(); // 방아쇠 놓음 메소드 호출
}
if (Input.GetKeyDown(KeyCode.R)) // 키보드 R 버튼 누를 경우
{
gunController.Reload(); // 재장전 메소드 호출
}
}
// ■ 플레이어 사망 시 처리 메소드 오버라이딩
public override void Die() {
AudioManager.instance.PlaySound("Player Death", transform.position); // 플레이어 사망 효과음 재생
base.Die();
}
}
| LivingEntity 스크립트
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class LivingEntity : MonoBehaviour, IDamageable // 인터페이스 상속
{
public float startingHealth; // 시작 체력
protected float health; // 체력
/* protected 를 통해 상속 관계가 없는 클래스에서 사용할 수 없게 한다.
인스펙터에서도 보이지 않음. */
protected bool dead; // 캐릭터가 죽었는지
public event System.Action OnDeath;
/* System.Action 은 델리게이트 메소드이다.
여기서 델리게이트란 C++ 의 함수 포인터와 유사하게
메소드의 위치를 가르키고 불러올 수 있는 타입이다.
void 를 리턴하고 입력을 받지 않는다. */
protected virtual void Start()
// 상속 받는 클래스에서 같은 메소드를 재정의 할 수 있도록 virtual 로 선언.
{
health = startingHealth; // 시작 체력 설정
}
// ■ 타격 메소드
public virtual void TakeHit(float damage, Vector3 hitPoint, Vector3 hitDirection) {
TakeDamage(damage); // 데미지 받는 메소드 호출
}
// ■ 데미지 받는 메소드
public virtual void TakeDamage(float damage) {
health -= damage; // 체력에서 데미지만큼 감소
if (health <= 0 && !dead) // 체력이 0 이하고 죽은 경우
{
Die(); // 죽음 메소드 호출
}
}
// 인스펙터에서 스크립트 컴포넌트 우클릭 시 자체 파괴 버튼 추가
[ContextMenu("Self-Destruct")]
public virtual void Die() // 죽음 메소드
{
dead = true; // 죽은 판정으로 변경.
if(OnDeath != null) // 이벤트가 있는 경우
{
OnDeath(); // 메소드를 호출
}
GameObject.Destroy(gameObject); // 현재 오브젝트 파괴
}
}
웨이브 클리어 시 재생되는 사운드는 2D 사운드로 재생되도록 했다.
이제 한번 실행해보자.
총알 타격, 적 사망, 적 공격, 웨이브 클리어, 플레이어 사망시 사운드가 재생된다.
다음 강의에서는 메뉴 UI 를 추가해본다.
'Unity > 게임개발' 카테고리의 다른 글
[Unity] 게임 개발 일지 | 탑다운 슈팅 25 (0) | 2024.02.22 |
---|---|
[Unity] 게임 개발 일지 | 탑다운 슈팅 24 (0) | 2023.12.19 |
[Unity] 게임 개발 일지 | 탑다운 슈팅 22 (0) | 2023.12.14 |
[Unity] 게임 개발 일지 | 탑다운 슈팅 21 (0) | 2023.12.13 |
[Unity] 게임 개발 일지 | 탑다운 슈팅 20 (1) | 2023.12.12 |
지난번에 이어 게임을 만들어보자.
이번에 참고할 강의는 아래 강의이다.
https://www.youtube.com/watch?v=xvpP9U20ioQ&list=PLFt_AvWsXl0ctd4dgE1F8g3uec4zKNRV0&index=24
이번 강의에서도 지난시간에 이어 사운드를 추가해본다.
시작해보자.
지난 강의에서는 배경음악, 무기 발사 효과음을 추가했다.
이번에는 총알이 충돌하는 효과음, 적의 사망 효과음 등을 추가해본다.
상황별로 여러 효과음이 있는데, 우리는 각 상황에서 여러 효과음 중 하나가 매번 랜덤으로 재생되게 하고 싶다.
지금 사운드 재생을 위해 만들어져 있는 메소드는 오디오 클립을 전달해야 한다.
이번에는 사운드 라이브러리를 만들어 비슷한 사운드들을 배열에 담아놓은 뒤
키 값으로는 사운드 배열의 이름을 사용할 것이므로 문자열 형식으로 한다.
이제 키값을 전달받고, 비슷한 사운드를 가지고있는 배열에서 사운드를 랜덤하게 선택해 재생해보자.
추가로, 사운드 볼륨을 조절할 수 있도록 하고, 플레이어 프리팹(PlayerPrefs)를 사용하여 볼륨값을 저장한 뒤
나중에 게임을 다시 실행해도 설정했던 볼륨값을 적용할 수 있도록 하자.
AudioManager 스크립트를 수정하고 SoundLibrary 스크립트를 생성한다.
| AudioManager 스크립트
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class AudioManager : MonoBehaviour {
public enum AudioChannel {Master, Sfx, Music}; // 오디오 채널
float masterVolumePercent = 1; // 마스터 볼륨
float sfxVolumePercent = 1; // 효과음 볼륨
float musicVolumePercent = 1; // 음악 볼륨
AudioSource sfx2DSource; // 2D 효과음 오디오소스 레퍼런스
AudioSource[] musicSources; // 음악을 가져올 오디오소스 레퍼런스 배열
int activeMusicSourceIntdex; // 재생중인 음악 인덱스
public static AudioManager instance; // 싱글톤 패턴
Transform audioListener; // 오디오 리스너 위치 레퍼런스
Transform playerT; // 플레이어 위치 레퍼런스
SoundLibrary library; // 사운드 라이브러리 레퍼런스
void Awake() {
if(instance != null) { // 인스턴스가 생성되어 있다면
Destroy(gameObject); // 현재 게임 오브젝트 파괴
}
else {
instance = this; // 인스턴스 설정
DontDestroyOnLoad(gameObject); // 게임 로드 시 이 게임 오브젝트가 파괴되지 않도록 한다
library = GetComponent<SoundLibrary>(); // 사운드 라이브러리 할당
musicSources = new AudioSource[2]; // 크기 설정하여 할당
for(int i = 0; i < 2; i++) {
GameObject newMusicSource = new GameObject("Music Source " + (i + 1)); // 오디오소스를 가질 오브젝트 생성
musicSources[i] = newMusicSource.AddComponent<AudioSource>(); // 오디오소스 할당
newMusicSource.transform.parent = transform; // 부모 오브젝트 설정
}
GameObject newSfxSource = new GameObject("2D sfx Source"); // 2D 오디오소스를 가질 오브젝트 생성
sfx2DSource = newSfxSource.AddComponent<AudioSource>(); // 오디오소스 할당
newSfxSource.transform.parent = transform; // 부모 오브젝트 설정
audioListener = FindObjectOfType<AudioListener>().transform; // 오디오 리스너가 있는 오브젝트의 위치를 저장
playerT = FindObjectOfType<Player>().transform; // 플레이어 오브젝트의 위치 저장
// PlayerPrefs 를 사용해 저장한 볼륨을 불러와 게임에 적용한다.
masterVolumePercent = PlayerPrefs.GetFloat("master vol", masterVolumePercent);
sfxVolumePercent = PlayerPrefs.GetFloat("sfx vol", sfxVolumePercent);
musicVolumePercent = PlayerPrefs.GetFloat("music vol", musicVolumePercent);
}
}
void Update() {
if(playerT != null) { // 플레이어가 있는 경우
audioListener.position = playerT.position; // 오디오 리스너가 있는 오브젝트 위치를 플레이어 위치로 설정
}
}
// ■ 사운드 볼륨 조절 메소드
public void SetVolume(float volumePercent, AudioChannel channel) {
switch(channel) {
case AudioChannel.Master: // 마스터 볼륨 설정
masterVolumePercent = volumePercent;
break;
case AudioChannel.Sfx: // 효과음 볼륨 설정
sfxVolumePercent = volumePercent;
break;
case AudioChannel.Music: // 음악 볼륨 설정
musicVolumePercent = volumePercent;
break;
}
musicSources[0].volume = musicVolumePercent * masterVolumePercent; // 설정한 첫 번째 음악의 볼륨 설정
musicSources[1].volume = musicVolumePercent * masterVolumePercent; // 설정한 두 번째 음악의 볼륨 설정
// PlayerPrefs 를 사용해 볼륨을 저장하고 다음번에 게임을 실행할 때도 적용되도록 한다.
PlayerPrefs.SetFloat("master vol", masterVolumePercent);
PlayerPrefs.SetFloat("sfx vol", sfxVolumePercent);
PlayerPrefs.SetFloat("music vol", musicVolumePercent);
}
// ■ 음악 재생 메소드
public void PlayMusic(AudioClip clip, int fadeDuration = 1) {
activeMusicSourceIntdex = 1 - activeMusicSourceIntdex; // 재생할 음악 인덱스 설정
musicSources[activeMusicSourceIntdex].clip = clip; // 재생할 음악 클립 설정
musicSources[activeMusicSourceIntdex].Play(); // 음악 재생
StartCoroutine(AnimateMusicCrossFade(fadeDuration)); // 음악 크로스페이드 코루틴 실행
}
// ■ 오디오 클립과 위치를 받아 해당 위치에 사운드를 재생하는 메소드
public void PlaySound(AudioClip clip, Vector3 pos) {
if(clip != null) { // 오디오 클립이 있는 경우
AudioSource.PlayClipAtPoint(clip, pos, sfxVolumePercent * masterVolumePercent);
// pos 위치에 효과음 * 마스터 볼륨 크기로 clip 재생
}
}
// ■ 오디오 이름과 위치를 받아 해당 위치레 사운드를 재생하는 메소드
public void PlaySound(string soundName, Vector3 pos) {
PlaySound(library.GetClipFromName(soundName), pos); // 사운드 재생 메소드 호출
}
// ■ 오디오 이름과 위치를 받아 2D 기준 사운드를 재생하는 메소드
public void PlaySound2D(string soundName) {
sfx2DSource.PlayOneShot(library.GetClipFromName(soundName), sfxVolumePercent * masterVolumePercent);
// 효과음을 2D 사운드로 출력
}
// ■ 음악을 크로스페이드(부드럽게 변환) 코루틴
IEnumerator AnimateMusicCrossFade(int duration) {
float percent = 0; // 음악 퍼센트
while(percent < 1) {
percent += Time.deltaTime * 1 / duration; // 퍼센트 계산
musicSources[activeMusicSourceIntdex].volume = Mathf.Lerp(0, musicVolumePercent * masterVolumePercent, percent);
musicSources[1 - activeMusicSourceIntdex].volume = Mathf.Lerp(musicVolumePercent * masterVolumePercent, 0, percent);
// 음악 볼륨 설정, Lerp 를 통해 활성화하는 음악은 점점 커지게, 비활성와 음악은 점점 작아지게 설정.
yield return null; // 다음 프레임으로 스킵
}
}
}
| SoundLibrary 스크립트
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class SoundLibrary : MonoBehaviour
{
public SoundGroup[] soundGroups; // 사운드 그룹 객체 배열
Dictionary<string, AudioClip[]> groupDictionary = new Dictionary<string, AudioClip[]>();
// Dictionary 를 통해 각 사운드 배열(그룹)에 키를 주고 해당 키값으로 사운드 그룹을 찾을 수 있다.
void Awake() {
foreach(SoundGroup soundGroup in soundGroups) {
groupDictionary.Add(soundGroup.groupID, soundGroup.group); // 딕셔너리에 그룹 사운드 추가
}
}
// ■ 이름으로 사운드 배열을 받아 랜덤 사운드를 반환하는 메소드
public AudioClip GetClipFromName(string name) {
if(groupDictionary.ContainsKey(name)) { // name 을 Key로 하는 Value 가 있다면
AudioClip[] sounds = groupDictionary[name]; // name 을 Key로 하는 Value 를 sounds 오디오클립 배열에 저장
return sounds[Random.Range(0, sounds.Length)]; // sounds 의 사운드 중 랜덤으로 하나를 반환
}
return null;
}
// ■ 사운드 그룹(배열) 클래스, 직렬화
[System.Serializable]
public class SoundGroup {
public string groupID; // 사운드 배열 인덱스
public AudioClip[] group; // 사운드 배열
}
}
Audio Manager 오브젝트에 SoundLibrary 스크립트를 추가하고
Sound Group 을 추가해 다운받은 폴더별로 그룹을 만들고, 각 사운드들을 할당해준다.

이제 사운드 랜덤 재생은 해결했으니 각 상황에 맞게 사운드가 재생되도록 하자.
먼저 적 타격, 사망, 공격 시 사운드를 추가하기 위해 Enemy 스크립트를 수정하자.
| Enemy 스크립트
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
[RequireComponent(typeof(UnityEngine.AI.NavMeshAgent))]
// 현재 오브젝트에 NavMeshAgent 를 포함
public class Enemy : LivingEntity
{
public enum State { Idle, Cashing, Attacking };
// 적의 상태, { 기본(아무것도 안함), 추적, 공격 }
State currentState; // 현재 상태
public ParticleSystem deathEffect; // 사망 이펙트 레퍼런스
UnityEngine.AI.NavMeshAgent pathfinder; // 내비게이션 레퍼런스
Transform target; // 적의 타겟(플레이어) 트랜스폼
LivingEntity targetEntity; // 타겟(플레이어) 레퍼런스
Material skinMatreial; // 현재 오브젝트의 마테리얼
Color originalColor; // 현재 오브젝트의 기존 색상
float attackDistanceThreshold = 0.5f;
// 공격 거리 임계값(사거리). 유니티에서 단위 1은 1 meter 이다..!!
float timeBetweenAttacks = 1;
// 공격 시간 간격
float nextAttackTime;
// 실제 다음 공격 시간
float damage = 1;
// 적의 공격 데미지
float myCollisionRadius; // 자신의 충돌 범위 반지름
float targetConllisionRadius; // 타겟의 충돌 범위 반지름
bool hasTarget; // 타겟이 있는지 확인
void Awake()
{
pathfinder = GetComponent<UnityEngine.AI.NavMeshAgent>();
// NavMeshAgent 레퍼런스 생성
if (GameObject.FindGameObjectWithTag("Player") != null)
// 적 스폰 후 플레이어 오브젝트가 있는(플레이어가 살아있는) 경우
{
hasTarget = true; // 타겟(플레이어) 있음으로 설정
target = GameObject.FindGameObjectWithTag("Player").transform;
// 타겟으로 "Player" 라는 태그를 가진 오브젝트의 트랜스폼을 저장
targetEntity = target.gameObject.GetComponent<LivingEntity>();
// 위에서 저장 한 플레이어 오브젝트의 LivingEntity 컴포넌트를 저장
myCollisionRadius = GetComponent<CapsuleCollider>().radius;
// 자신의 충돌체(collider)의 반지름을 저장
targetConllisionRadius = target.GetComponent<CapsuleCollider>().radius;
// 타겟의 충돌체(collider)의 반지름을 저장
}
}
protected override void Start()
// override 로 부모 클래스의 메소드를 재정의.
{
base.Start(); // base 를 통해 부모 클래스의 기존 메소드를 호출.
if(hasTarget)
// 적 스폰 후 플레이어 오브젝트가 있는(플레이어가 살아있는) 경우
{
currentState = State.Cashing; // 기본 상태를 추적 상태로 설정
targetEntity.OnDeath += OnTargetDeath;
// 타겟 사망 메소드 추가.
StartCoroutine(UpdatePath());
// 지정된 시간마다 목적지 쪽으로 위치를 갱신하는 코루틴 실행
}
}
// ■ 정보 수정 메소드
public void SetCharactoristics(float moveSpeed, int hitsToKillPlayer, float enemyHealth, Color skinColour) {
pathfinder.speed = moveSpeed; // 적의 이동속도 설정
if (hasTarget) { // 목표 (플레이어)가 있다면
damage = Mathf.Ceil(targetEntity.startingHealth / hitsToKillPlayer); // 적의 공격력 설정
}
startingHealth = enemyHealth; // 적의 체력 설정
skinMatreial = GetComponent<Renderer>().sharedMaterial; // 현재 오브젝트의 마테리얼 저장.
skinMatreial.color = skinColour; // 적의 색상 설정
originalColor = skinMatreial.color; // 현재 색상 저장
}
// ■ 타격 메소드 오버라이딩
public override void TakeHit(float damage, Vector3 hitPoint, Vector3 hitDirection) {
AudioManager.instance.PlaySound("Impact", transform.position); // 현재 위치에 타격 사운드 재생
if(damage >= health) { // 데미지가 현재 체력 이상인 경우
AudioManager.instance.PlaySound("Enemy Death", transform.position); // 현재 위치에 사망 사운드 재생
Destroy(Instantiate(deathEffect.gameObject, hitPoint, Quaternion.FromToRotation(Vector3.forward, hitDirection)) as GameObject, deathEffect.startLifetime);
// 이펙트(파티클)을 인스턴스화 하여 생성(FromToRotation 으로 방향 설정), 설정한 시간경과 후 파괴
}
base.TakeHit(damage, hitPoint, hitDirection);
// 부모 클래스의 기존 TakeHit 메소드 호출
}
void OnTargetDeath() // 타겟(플레이어)이 죽었을 때 호출되는 메소드
{
hasTarget = false; // 타겟 없음으로 설정
currentState = State.Idle; // 현재 상태를 기본(정지)상태로 변경
}
void Update()
{
if (hasTarget) // 타겟(플레이어)가 있는 경우
{
if (Time.time >= nextAttackTime) // 공격 시간이 지나면
{
float sqrDstToTarget = (target.position - transform.position).sqrMagnitude;
/* 자신(적)과 목표(플레이어) 사이의 거리를 저장.
두 오브젝트 사이의 거리를 측정하기 위해 Vector3 의 Distace 메소드를
쓰는 방법도 있지만 벡터에 제곱 연산이 들어가다 보니 연산 수가 많다.
두 오브젝트 사이의 실제 거리가 필요한 것이 아닌 비교값이 필요 한
것이므로 위와 같이 연산을 줄여 볼 수 있다. */
if (sqrDstToTarget <= Mathf.Pow(attackDistanceThreshold + targetConllisionRadius + myCollisionRadius, 2))
// 거리의 제곱과 임계 거리의 제곱을 비교하여 공격 범위 내인지 확인
{
nextAttackTime = Time.time + timeBetweenAttacks;
// 다음 공격 시간 지정
AudioManager.instance.PlaySound("Enemy Attack", transform.position); // 현재 위치에 공격 사운드 재생
StartCoroutine(Attack());
// 공격 코루틴 실행
}
}
}
}
IEnumerator Attack() // 적의 공격 코루틴
{
currentState = State.Attacking; // 공격 상태로 변경
pathfinder.enabled = false; // 공격 중 플레이어 추적 중지
Vector3 originalPosition = transform.position; // 자신(적)의 위치
Vector3 dirToTarget = (target.position - transform.position).normalized;
// 타겟으로의 방향 벡터 계산
Vector3 attackPosition = target.position - dirToTarget * (myCollisionRadius);
// 타겟(플레이어)의 위치
float attackSpeed = 3; // 공격 속도
float percent = 0;
skinMatreial = GetComponent<Renderer>().material; // 현재 오브젝트의 마테리얼 저장.
skinMatreial.color = Color.magenta; // 공격 시 색상 지정
bool hasAppliedDamage = false; // 데미지를 적용 중인지 확인
while(percent <= 1) // 찌르는 거리가 1 이하일 때 루프
{
if(percent >= .5f && hasAppliedDamage == false)
// 적이 공격 지점에 도달했고 데미지를 적용중이지 않은 경우
{
hasAppliedDamage = true; // 데미지 적용 중으로 설정
targetEntity.TakeDamage(damage); // 타겟(플레이어)에게 데미지 적용
}
percent += Time.deltaTime * attackSpeed;
float interpolation = (-Mathf.Pow(percent, 2) + percent) * 4;
/* 대칭 함수를 사용
원지점->공격지점 이동에서 보간 값을 참조한다. */
transform.position = Vector3.Lerp(originalPosition, attackPosition, interpolation);
/* Lerp 메소드는 내분점을 반환한다.
원점, 공격지점, 보간 값을 참조값으로 전달한다
보간 값이 0이면 원점에, 1이면 공격지점에 있게 된다. */
yield return null;
/* while 루프의 처리 사이에서 프레임을 스킵합니다.
이 지점에서 작업이 멈추고 Update 메소드 작업이 끝나고
다음 프레임으로 넘어가면 이 아래의 코드나 루프가 실행 */
}
skinMatreial.color = originalColor; // 공격이 끝나면 기존 색상으로 변경
currentState = State.Cashing; // 공격이 끝나면 추적 상태로 변경
pathfinder.enabled = true; // 공격이 끝나면 다시 플레이어 추적
}
IEnumerator UpdatePath()
// 해당 코루틴 실행 시 지정한 시간마다 루프문 내부 코드 반복
{
float refreshRate = .25f; // 루프 시간
while(hasTarget) // 타겟이 있을 때 반복
{
if(currentState == State.Cashing)
// 상태가 추적 상태인 경우
{
Vector3 dirToTarget = (target.position - transform.position).normalized;
// 타겟으로의 방향 벡터 계산
Vector3 targetPosition = target.position - dirToTarget * (myCollisionRadius + targetConllisionRadius + attackDistanceThreshold / 2);
/* 자신의 위치에서 자신과 타겟의 반지름 길이에 공격 사거리의 절반을 더하고, 방향 벡터를 곱한 값을 뺀다.
즉, 타겟(플레이어) 공격 가능한 위치를 타겟 위치로 정한다. */
if (!dead) // 죽지 않은 경우
{
pathfinder.SetDestination(targetPosition);
// 내비게이션의 목적지를 타겟(플레이어)의 위치로 설정
}
}
yield return new WaitForSeconds(refreshRate);
// refreshRate 시간만큼 기다림
}
}
}
이번엔 웨이브 클리어와 플레이어가 죽었을 때의 사운드도 추가해보자

상황별로 Sound Group 을 만들고 사운드를 할당해준다.
| Spawner 스크립트
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Spawner : MonoBehaviour
{
public bool devMode; // 개발자 모드 여부
public Wave[] waves; // 웨이브들을 저장할 배열 생성
public Enemy enemy; // 스폰할 적 레퍼런스
LivingEntity playerEntity; // 플레이어 레퍼런스
Transform playerT; // 플레이어 위치
Wave currentWave; // 현재 웨이브 레퍼런스
int currentWaveNumber; // 현재 웨이브 번호
int enemiesRemainingToSpawn; // 남아있는 스폰 할 적 수
int enemiesRemainingAlive; // 살아있는 적의 수
float nextSpawnTime; // 다음 스폰까지의 시간
MapGenerator map; // 맵 생성기 레퍼런스
float timeBetweenCampingChecks = 2; // 캠핑 방지 체크 딜레이
float nextCampingCheckTime; // 다음 캠핑 체크 시간
float campThresholdDistance = 1.5f; // 캠핑 방지를 위해 이동해야 하는 거리
Vector3 campPositionOld; // 이전 플레이어 위치
bool isCamping; // 캠핑중인지
bool isDisable; // 플레이어 생존 여부
public event System.Action<int> OnNewWave; // 웨이브 번호를 매개변수로 갖는 웨이브 이벤트 추가
void Start() {
playerEntity = FindObjectOfType<Player>(); // 플레이어 오브젝트 할당
playerT = playerEntity.transform; // 플레이어 위치
nextCampingCheckTime = Time.time + timeBetweenCampingChecks;
// 다음 캠핑 체크 시간 설정
campPositionOld = playerT.position;
// 플레이어 위치 설정
map = FindObjectOfType<MapGenerator>(); // 맵 생성기
NextWave(); // 시작 시 웨이브 실행
playerEntity.OnDeath += OnPlayerDeath; // 플레이어가 죽을 때 위 메소드를 추가
}
void Update()
{
if (!isDisable) { // 비활성화(플레이어 죽음)가 아닌 경우
if (Time.time > nextCampingCheckTime)
{ // 캠핑 체크 시간이 지난 경우
nextCampingCheckTime = Time.time + timeBetweenCampingChecks;
// 다음 캠핑 체크 시간 설정
isCamping = (Vector3.Distance(playerT.position, campPositionOld) < campThresholdDistance);
// 캠핑중인지 확인
campPositionOld = playerT.position;
// 위치 갱신
}
if ((enemiesRemainingToSpawn > 0 || currentWave.infinite) && Time.time >= nextSpawnTime)
// 적 수가 남아있거나 무한모드이고, 현재 시간이 다음 스폰 시간보다 큰 경우
{
enemiesRemainingToSpawn--; // 적 수를 하나 줄임
nextSpawnTime = Time.time + currentWave.timeBetweenSpawns;
// 다음 스폰 시간을 현재시간 + 스폰 간격 으로 저장
StartCoroutine("SpawnEnemy"); // 적 스폰 코루틴 시작
}
}
if (devMode) { // 개발자 모드인 경우
if(Input.GetKeyDown(KeyCode.Return)) { // 버튼을 누른 경우
StopCoroutine("SpawnEnemy"); // 적 스폰 코루틴 중지
foreach(Enemy enemy in FindObjectsOfType<Enemy>()) { // Enemy 오브젝트를 모두 찾고
GameObject.Destroy(enemy.gameObject); // 해당 게임 오브젝트 파괴
}
NextWave(); // 다음 웨이브 실행
}
}
}
// ■ 적 스폰 코루틴
IEnumerator SpawnEnemy() {
float spawnDelay = 1;
// 적 스폰 대기시간
float tileFlashSpeed = 4;
// 초당 몇번 타일이 깜빡일지 설정
Transform spawnTile = map.GetRandomOpenTile();
// 랜덤 오픈 타일 가져오기
if (isCamping) { // 플레이어가 캠핑중인 경우
spawnTile = map.GetTileFromPosition(playerT.position);
// 타일 위치를 플레이어 위치로 설정
isCamping = false;
// 캠핑 해제
}
Material tileMat = spawnTile.GetComponent<Renderer>().material;
// 가져온 타일의 마테리얼 가져오기
Color initialColour = tileMat.color;
// 기존 타일 색상 저장
Color flashColour = Color.red;
// 변경할 색상 저장
float spawnTimer = 0;
// 적 소환 시간 타이머
while(spawnTimer < spawnDelay) { // 적 스폰 대기시간이 안된 경우
tileMat.color = Color.Lerp(initialColour, flashColour, Mathf.PingPong(spawnTimer * tileFlashSpeed, 1));
// 보간값을 통해 색상을 설정하여 깜빡이도록, PingPong 함수로 설정한 횟수만큼 깜빡이도록 속도를 설정.
spawnTimer += Time.deltaTime; // 타이머에 시간 추가
yield return null; // 한 프레임 대기
}
tileMat.color = initialColour; // 타일 색상 초기화
Enemy spawnedEnemy = Instantiate(enemy, spawnTile.position + Vector3.up, Quaternion.identity) as Enemy;
// 적을 인스턴스화를 통해 생성, 랜덤 타일 위치에 회전값 없이 배치
spawnedEnemy.OnDeath += OnEnemyDeath;
// 적이 죽을 때 위 메소드를 추가
spawnedEnemy.SetCharactoristics(currentWave.moveSpeed, currentWave.hitsToKillPlayer, currentWave.enemyHealth, currentWave.skinColour);
// 적 정보 설정 메소드 호출
}
// ■ 플레이어가 죽을 때 처리하는 메소드
void OnPlayerDeath() {
isDisable = true;
// 생존 여부 죽음(비활성화)으로 설정.
}
void OnEnemyDeath()
/* 적이 죽을 때 처리하는 메소드
적이 죽으면 LivingEntity 에서 OnDeath 를 호출하고,
OnDeath 는 이 메소드를 호출해서 적이 죽을 때 알려준다. */
{
enemiesRemainingAlive--; // 적의 수를 1 줄임
if(enemiesRemainingAlive == 0) // 적의 수가 0이 되면
{
NextWave(); // 다음 웨이브 실행
}
}
// ■ 플레이어 위치 리셋 메소드
void ResetPlayerPosition() {
playerT.position = map.GetTileFromPosition(Vector3.zero).position + Vector3.up * 1.5f;
// 플레이어 위치를 가운데 타일 위치로 설정
}
// ■ 다음 웨이브 실행 메소드
void NextWave()
{
if(currentWaveNumber > 0) { // 첫 번째 웨이브가 아닌 경우
AudioManager.instance.PlaySound2D("Level Complete"); // 웨이브 클리어 시 클리어 사운드 재생
}
currentWaveNumber++; // 웨이브 숫자 증가
if(currentWaveNumber - 1 < waves.Length) { // 배열 인덱스 예외 없도록 처리
currentWave = waves[currentWaveNumber - 1];
/* 현재 웨이브 레퍼런스 참조
(웨이브 숫자는 1부터 시작 할 것이므로 -1 하여 배열의 인덱스에 맞게 참조) */
enemiesRemainingToSpawn = currentWave.enemyCount;
// 이번 웨이브의 적 수 저장
enemiesRemainingAlive = enemiesRemainingToSpawn;
// 살아있는 적의 수를 스폰 할 적의 수로 저장
if (OnNewWave != null) { // 이벤트 구독자가 있는 경우
OnNewWave(currentWaveNumber); // 웨이브 번호 전달
}
ResetPlayerPosition(); // 플레이어 위치 초기화
}
}
[System.Serializable]
/* 스크립트 직렬화, 직렬화를 통해 객체, 변수 등을 선언 할 때의
접근 제한(private 등)은 유지되지만 인스펙터에서 값을 변경 가능하게 함 */
public class Wave
// 적의 주기, 스폰 주기 등 웨이브 정보를 저장 할 클래스 생성
{
public bool infinite; // 무한 모드 설정
public int enemyCount; // 적의 수
public float timeBetweenSpawns; // 적의 스폰 주기
public float moveSpeed; // 적의 속도
public int hitsToKillPlayer; // 적의 공격력(설정된 횟수만큼 플레이어 공격 시 플레이어 사망)
public float enemyHealth; // 적의 체력
public Color skinColour; // 적의 색상
}
}
| Player 스크립트
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
[RequireComponent(typeof(GunController))] // GunController 스크립트를 포함
[RequireComponent(typeof(PlayerController))] // PlayerController 스크립트를 포함
public class Player : LivingEntity
{
public float moveSpeed = 5.0f; // 플레이어 이동속도
public Crosshair crosshairs; // 조준점 레퍼런스
Camera viewCamera; // 카메라
PlayerController controller; // 플레이어 컨트롤러
GunController gunController; // 총 컨트롤러
protected override void Start() { // override 로 부모 클래스의 메소드를 재정의.
base.Start(); // base 를 통해 부모 클래스의 기존 메소드를 호출.
}
void Awake() {
controller = GetComponent<PlayerController>();
// 현재 오브젝트의 플레이어 컨트롤러를 가져옴
gunController = GetComponent<GunController>();
// 현재 오브젝트의 총 컨트롤러를 가져옴
viewCamera = Camera.main;
// 카메라
FindObjectOfType<Spawner>().OnNewWave += OnNewWave;
// Spawner 의 새 웨이브 시작 이벤트 구독
}
// ■ 새 웨이브 시작 시 메소드
void OnNewWave(int waveNumber) {
health = startingHealth; // 체력 초기화
gunController.EquipGun(waveNumber - 1); // 장착할 총 설정
}
void Update() {
// 이동 입력
Vector3 moveInput = new Vector3(Input.GetAxisRaw("Horizontal"), 0, Input.GetAxis("Vertical"));
// GetAxisRaw 로 스무딩 X
Vector3 moveVelocity = moveInput.normalized * moveSpeed;
// normalized 로 단위벡터로 변환, 이동 속도를 계산
controller.Move(moveVelocity);
// 이동 속도를 이동 메소드로 전달
// 방향 입력
Ray ray = viewCamera.ScreenPointToRay(Input.mousePosition);
// 카메라 -> 마우스 커서 위치로 레이 발사.
Plane groundPlane = new Plane(Vector3.up, Vector3.up * gunController.GunHeight);
// 평면의 법선벡터 생성.
float rayDistance;
// 위에서 쏜 레이와 평면의 교차지점까지 거리
if (groundPlane.Raycast(ray, out rayDistance)) {
// 레이가 평면과 교차하는 경우 교차지점까지의 거리를 rayDistance 에 저장.
Vector3 point = ray.GetPoint(rayDistance);
// GetPoint 와 교차지점까지의 거리로 교차지점의 위치를 저장.
// 카메라부터 교차지점까지 선으로 표시 : Debug.DrawLine(ray.origin, point, Color.red);
controller.LookAt(point);
// 플레이어 컨트롤러의 방향 전환 메소드에 교차 지점(방향) 전달.
crosshairs.transform.position = point;
// 조준점 위치 설정
crosshairs.DetectTarget(ray);
// 조준점 레이캐스트
if((new Vector2(point.x, point.z) - new Vector2(transform.position.x, transform.position.z)).sqrMagnitude > 1) {
// 조준점(커서)와 플레이어 사이 거리가 1 이상일 때
gunController.Aim(point);
// 총 에임 보정 위치 전달
}
}
// 무기 조작 입력
if (Input.GetMouseButton(0)) // 마우스 왼쪽 버튼 클릭 시
{
gunController.OnTriggerHold(); // 방아쇠 당김 메소드 호출
}
if (Input.GetMouseButtonUp(0)) // 마우스 왼쪽 버튼 클릭 후 손을 뗄 시
{
gunController.OnTriggerRelease(); // 방아쇠 놓음 메소드 호출
}
if (Input.GetKeyDown(KeyCode.R)) // 키보드 R 버튼 누를 경우
{
gunController.Reload(); // 재장전 메소드 호출
}
}
// ■ 플레이어 사망 시 처리 메소드 오버라이딩
public override void Die() {
AudioManager.instance.PlaySound("Player Death", transform.position); // 플레이어 사망 효과음 재생
base.Die();
}
}
| LivingEntity 스크립트
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class LivingEntity : MonoBehaviour, IDamageable // 인터페이스 상속
{
public float startingHealth; // 시작 체력
protected float health; // 체력
/* protected 를 통해 상속 관계가 없는 클래스에서 사용할 수 없게 한다.
인스펙터에서도 보이지 않음. */
protected bool dead; // 캐릭터가 죽었는지
public event System.Action OnDeath;
/* System.Action 은 델리게이트 메소드이다.
여기서 델리게이트란 C++ 의 함수 포인터와 유사하게
메소드의 위치를 가르키고 불러올 수 있는 타입이다.
void 를 리턴하고 입력을 받지 않는다. */
protected virtual void Start()
// 상속 받는 클래스에서 같은 메소드를 재정의 할 수 있도록 virtual 로 선언.
{
health = startingHealth; // 시작 체력 설정
}
// ■ 타격 메소드
public virtual void TakeHit(float damage, Vector3 hitPoint, Vector3 hitDirection) {
TakeDamage(damage); // 데미지 받는 메소드 호출
}
// ■ 데미지 받는 메소드
public virtual void TakeDamage(float damage) {
health -= damage; // 체력에서 데미지만큼 감소
if (health <= 0 && !dead) // 체력이 0 이하고 죽은 경우
{
Die(); // 죽음 메소드 호출
}
}
// 인스펙터에서 스크립트 컴포넌트 우클릭 시 자체 파괴 버튼 추가
[ContextMenu("Self-Destruct")]
public virtual void Die() // 죽음 메소드
{
dead = true; // 죽은 판정으로 변경.
if(OnDeath != null) // 이벤트가 있는 경우
{
OnDeath(); // 메소드를 호출
}
GameObject.Destroy(gameObject); // 현재 오브젝트 파괴
}
}
웨이브 클리어 시 재생되는 사운드는 2D 사운드로 재생되도록 했다.
이제 한번 실행해보자.
총알 타격, 적 사망, 적 공격, 웨이브 클리어, 플레이어 사망시 사운드가 재생된다.
다음 강의에서는 메뉴 UI 를 추가해본다.
'Unity > 게임개발' 카테고리의 다른 글
[Unity] 게임 개발 일지 | 탑다운 슈팅 25 (0) | 2024.02.22 |
---|---|
[Unity] 게임 개발 일지 | 탑다운 슈팅 24 (0) | 2023.12.19 |
[Unity] 게임 개발 일지 | 탑다운 슈팅 22 (0) | 2023.12.14 |
[Unity] 게임 개발 일지 | 탑다운 슈팅 21 (0) | 2023.12.13 |
[Unity] 게임 개발 일지 | 탑다운 슈팅 20 (1) | 2023.12.12 |