7장까지 만든 게임에는 뼈대가 다 들어 있습니다. 프로시저가 판을 열고, 게임 객체가 판을 굴리고, 엔티티가 나타났다 사라지고, 이벤트가 그 사이를 잇고, UI 가 그것을 듣습니다.
이 장에서는 그 뼈대 위에 기능 둘을 얹습니다. 점수를 모아 양을 부르면 적들이 플레이어 대신 양을 쫓아갑니다. 그리고 조용하던 게임에 소리를 입힙니다.
새로 배우는 개념은 거의 없습니다. 이 장의 목적은 다른 데 있습니다. 기능 하나를 추가할 때 기존 코드를 몇 줄 고쳐야 하는가를 세어 보는 것입니다. 구조가 좋은지 나쁜지는 처음 만들 때가 아니라 무언가를 추가할 때 드러납니다.
30점을 모으면 화면 아래에 양 아이콘이 뜬다
1 을 누르면 양이 나타나고 적들이 양을 쫓는다
이 장에서 하는 일
1.
아군 엔티티 Ally 를 만든다 — 지금까지 중 가장 단순한 엔티티
2.
소환 포인트와 지속 시간을 관리하는 AllySquad 를 만든다
3.
적이 쫓는 대상을 한 줄로 바꾼다
4.
HUD 에 소환 아이콘을 한 줄로 붙인다
5.
모든 소리를 프레임워크의 사운드 기능으로 낸다 — 효과음, 반복음, 배경음악
시작하기 전에 확인할 것 — 예제 프로젝트에는 이미 되어 있습니다.
•
양 프리팹이 Addressables 에 주소 Sheep 으로 등록돼 있다. 프리팹에 NavMeshAgent 가 붙어 있다
•
1장에서 Entity 컴포넌트에 Ally 그룹을 만들었다
•
소리 클립 열일곱 개가 Addressables 에 등록돼 있다. 피격·사망 여덟(PlayerHurt, PlayerDeath, ZomBunnyHurt, ZomBunnyDeath, ZomBearHurt, ZomBearDeath, HellephantHurt, HellephantDeath), 무기·상태 이상 여덟(LightningFireSound, LightningHitSound, FrostConeLoopSound, StinkFireSound, StinkHitSound, SlimeHitSound, EnemyFreezeSound, EnemySlimeLoopSound), 배경음악 하나(BackgroundMusic)
•
1장에서 Sound 컴포넌트에 Audio Mixer 를 MasterMixer 로 지정하고, 사운드 그룹 Music(에이전트 1개)과 Soundeffects(에이전트 16개)를 만들었다. 그리고 프리팹과 씬 어디에도 AudioSource 가 직접 붙어 있지 않다(소리는 전부 이 장에서 만드는 창구로 낸다)
8.1 양의 규칙
항목 | 값 |
소환 비용 | 30 포인트. 점수를 얻을 때마다 같은 양이 포인트로 쌓인다 |
입력 | 숫자 1 |
동작 | 방 한쪽의 정해진 지점에 나타나, 소환한 순간 플레이어가 서 있던 자리로 한 번 걸어간다 |
지속 | 10초. 그동안 적은 플레이어 대신 양을 쫓는다 |
끝나면 | 양이 사라지고 포인트가 0 으로 돌아간다. 다시 30점을 모아야 한다 |
양은 공격하지 않습니다. 맞지도 않습니다. 걸어와서 서 있을 뿐입니다. 양은 미끼입니다.
숫자는 이번에도 표에 모읍니다.
// Assets/GameMain/Game/AllyTable.cs
using UnityEngine;
namespace ToyBoxNightmare
{
/// <summary>
/// 아군(양) 튜닝값.
/// </summary>
public static class AllyTable
{
/// <summary>Addressables 주소.</summary>
public const string Asset = "Sheep";
/// <summary>엔티티 그룹. GameFramework.prefab 의 mEntityGroups 와 이름이 같아야 한다.</summary>
public const string Group = "Ally";
/// <summary>소환 비용(점수).</summary>
public const int Cost = 30;
/// <summary>소환 후 유지 시간(초).</summary>
public const float Duration = 10f;
/// <summary>소환 위치.</summary>
public static readonly Vector3 SpawnPosition = new Vector3(29.93f, 0f, 4.61f);
/// <summary>EntityData 의 TypeId. 아직 타입 테이블이 없어 프로젝트 전체가 1 이다.</summary>
public const int EntityTypeId = 1;
}
}
C#
복사
8.2 가장 단순한 엔티티 — Ally
주문서와 로직입니다. 3장부터 다섯 번째 만드는 엔티티이니 이제 설명 없이도 읽힐 것입니다.
// Assets/GameMain/Entity/EntityData/AllyData.cs
using GameFramework;
using UnityEngine;
namespace ToyBoxNightmare
{
/// <summary>
/// 아군 스폰 데이터.
///
/// 아군은 소환 시점에 목적지를 <b>한 번만</b> 받고 그리로 걸어간다.
/// 그래서 목적지가 런타임 상태가 아니라 스폰 인자다.
/// </summary>
public class AllyData : EntityData
{
/// <summary>소환 직후 걸어갈 목적지. 소환 시점의 플레이어 위치를 넣는다.</summary>
public Vector3 MoveDestination { get; private set; }
public static AllyData Create(int entityId, int typeId, Vector3 position, Vector3 moveDestination)
{
var data = ReferencePool.Acquire<AllyData>();
data.Fill(entityId, typeId);
data.Position = position;
data.MoveDestination = moveDestination;
return data;
}
public override void Clear()
{
base.Clear();
MoveDestination = Vector3.zero;
}
}
}
C#
복사
// Assets/GameMain/Entity/EntityLogic/Ally.cs
using UnityEngine;
using UnityEngine.AI;
using UnityGameFramework.Runtime;
namespace ToyBoxNightmare
{
/// <summary>
/// 아군(양). 소환되면 목적지로 한 번 걸어갈 뿐 아무것도 하지 않는다 —
/// <b>미끼다.</b> 적이 아군을 쫓게 만드는 것은 아군 자신이 아니라
/// 적이 <see cref="EntityFinder.FindAlly"/> 로 아군을 찾아 그 좌표를 쫓기 때문이다.
/// </summary>
public class Ally : EntityLogicBase
{
private NavMeshAgent mAgent = null;
private AllyData mAllyData = null;
/// <summary>적이 쫓을 좌표. 회수된 뒤에는 의미가 없으므로 호출부가 Available 을 먼저 볼 것.</summary>
public Vector3 LurePosition => CachedTransform.position;
protected internal override void OnInit(object userData)
{
base.OnInit(userData);
mAgent = GetComponent<NavMeshAgent>();
if (mAgent == null)
{
Log.Warning("Ally: NavMeshAgent 가 없다. 소환 위치에 그대로 서 있게 된다.");
}
}
protected internal override void OnShow(object userData)
{
base.OnShow(userData);
mAllyData = userData as AllyData;
if (mAllyData == null)
{
Log.Error("Ally: AllyData 가 아니다. 회수한다.");
SafeHide();
return;
}
PlaceOnNavMesh(mAllyData.Position);
MoveTo(mAllyData.MoveDestination);
}
protected internal override void OnHide(bool isShutdown, object userData)
{
StopAgent();
mAllyData = null;
base.OnHide(isShutdown, userData);
}
/// <summary>
/// 스폰 좌표로 옮기고 NavMesh 폴리곤 위로 스냅한다.
/// (Enemy 와 같은 절차다 — 에이전트가 켜진 채 transform 을 옮기면 Unity 가 경고를 낸다.)
/// </summary>
private void PlaceOnNavMesh(Vector3 position)
{
if (mAgent == null)
{
CachedTransform.position = position;
return;
}
mAgent.enabled = false;
CachedTransform.position = position;
mAgent.enabled = true;
mAgent.Warp(position);
if (mAgent.isOnNavMesh)
{
mAgent.isStopped = false;
}
}
/// <summary>목적지를 한 번만 준다. 이후 재조준하지 않는다.</summary>
private void MoveTo(Vector3 destination)
{
if (mAgent == null || !mAgent.enabled || !mAgent.isOnNavMesh) return;
mAgent.SetDestination(destination);
}
/// <summary>회수 전에 반드시 멈춘다. 켜진 채로 풀에 들어가면 다음 소환에서 옛 경로를 이어 간다.</summary>
private void StopAgent()
{
if (mAgent == null || !mAgent.enabled) return;
if (mAgent.isOnNavMesh)
{
mAgent.isStopped = true;
mAgent.ResetPath();
}
mAgent.enabled = false;
}
}
}
C#
복사
코드 읽기
OnUpdate 가 없습니다. 상태 기계도 없습니다. 양은 나타날 때 "저기로 가"를 한 번 듣고, 그 뒤로는 아무 결정도 하지 않습니다. 매 프레임 할 일이 없으니 OnUpdate 를 덮어쓰지 않았고, 상태가 하나뿐이니 FSM 도 필요 없습니다. 지금까지 만든 엔티티를 놓고 보면 이렇습니다.
엔티티 | 상태 기계 | 이유 |
PlayerSelectLogic | 상태 2 | 클릭 대기 / 쓰러지는 중 |
Player | 상태 3 | 살아 있음 / 일시정지 / 죽는 중 |
Enemy | 상태 4 | 추격 / 대기 / 쓰러짐 / 침하 |
HitEffect, 투사체 | 없음 | 타이머 하나면 충분하다 |
Ally | 없음 | 아무 결정도 하지 않는다 |
도구는 필요한 만큼만 씁니다. FSM 을 배웠다고 모든 엔티티에 붙이지 않습니다.
수명을 스스로 정하지도 않습니다. HitEffect 는 시간이 차면 스스로 SafeHide() 했습니다. 양은 10초 뒤에 사라져야 하는데 그 타이머가 여기에 없습니다. "양이 사라지면 포인트가 0 이 된다"는 규칙 때문입니다. 사라지는 것과 포인트를 깎는 것은 한 사건이라, 둘을 함께 아는 쪽(다음 절의 AllySquad)이 타이머를 듭니다. 타이머를 누가 들지는 "그 시간이 끝났을 때 무슨 일이 함께 일어나야 하는가"로 정합니다.
NavMeshAgent 다루는 법은 5장의 적과 같습니다. 나타날 때 끄고 → 옮기고 → 켜고 → Warp. 사라질 때 ResetPath 후 끕니다.
8.3 소환을 관리하는 쪽 — AllySquad
포인트가 얼마나 쌓였는지, 양이 지금 나와 있는지, 몇 초 남았는지. 이것은 한 판 동안의 상태입니다. 5장의 EnemySpawner 처럼 SurvivalGame 이 들고 굴리는 평범한 C# 객체로 만듭니다.
먼저 HUD 에 알릴 이벤트입니다.
// Assets/GameMain/Game/AllyAvailabilityChangedEventArgs.cs
using GameFramework;
using GameFramework.Event;
namespace ToyBoxNightmare
{
/// <summary>
/// 아군을 소환할 수 있는지가 바뀌었을 때 발행된다. HUD 의 아군 아이콘이 구독한다.
///
/// 매 점수 획득마다 쏘지 않고 <b>가부가 실제로 뒤집힐 때만</b> 발행한다 —
/// 적 하나 잡을 때마다 같은 값을 반복 통보하면 HUD 가 매번 헛일을 한다.
/// </summary>
public class AllyAvailabilityChangedEventArgs : GameEventArgs
{
public static readonly int EventId = typeof(AllyAvailabilityChangedEventArgs).GetHashCode();
public override int Id => EventId;
/// <summary>지금 소환할 수 있는가.</summary>
public bool CanSummon { get; private set; }
public static AllyAvailabilityChangedEventArgs Create(bool canSummon)
{
var args = ReferencePool.Acquire<AllyAvailabilityChangedEventArgs>();
args.CanSummon = canSummon;
return args;
}
public override void Clear()
{
CanSummon = false;
}
}
}
C#
복사
// Assets/GameMain/Game/AllySquad.cs
using UnityEngine;
using UnityGameFramework.Runtime;
namespace ToyBoxNightmare
{
/// <summary>
/// 아군 소환 경제. 점수를 소환 포인트로 쌓고, 한 마리를 정해진 시간만 유지한다.
///
/// <see cref="SurvivalGame"/> 이 소유하는 순수 객체다.
/// 판 단위 상태라 게임 객체가 들고 있는 편이 수명이 맞는다.
///
/// <b>미끼 역할은 여기서 하지 않는다.</b> 적은 <see cref="EntityFinder.FindAlly"/> 로 떠 있는
/// 아군 엔티티를 직접 찾아 그 좌표(<see cref="Ally.LurePosition"/>)를 쫓는다.
/// </summary>
public class AllySquad
{
/// <summary>"소환된 아군 없음" 을 뜻하는 엔티티 Id. EntitySerialId 는 1부터 발급한다.</summary>
private const int NoAllyId = 0;
private int mPoints = 0;
private int mAllyEntityId = NoAllyId;
private Ally mAlly = null;
private float mRemainingSeconds = 0f;
/// <summary>
/// 로드가 끝나기 전에 회수 요청이 들어왔는지. ShowEntity 는 비동기라
/// 소환 직후 판이 끝나면 아군이 <b>다음 판에</b> 나타날 수 있다.
/// </summary>
private bool mUnsummonRequested = false;
/// <summary>마지막으로 통보한 가부. 값이 뒤집힐 때만 이벤트를 쏜다.</summary>
private bool mNotifiedCanSummon = false;
/// <summary>지금 소환할 수 있는가.</summary>
public bool CanSummon => mPoints >= AllyTable.Cost && !HasAlly;
/// <summary>소환된(또는 소환 중인) 아군이 있는가.</summary>
public bool HasAlly => mAllyEntityId != NoAllyId;
// ─── 판 생명주기 ───
/// <summary>새 판 시작. 포인트와 아군 상태를 전부 지운다.</summary>
public void Reset()
{
mPoints = 0;
mAllyEntityId = NoAllyId;
mAlly = null;
mRemainingSeconds = 0f;
mUnsummonRequested = false;
// 판이 바뀌면 HUD 아이콘도 반드시 꺼져야 하므로 강제로 통보한다.
mNotifiedCanSummon = true;
NotifyAvailability();
}
/// <summary>점수를 얻을 때마다 같은 양이 소환 포인트로 쌓인다.</summary>
public void AddPoints(int amount)
{
if (amount <= 0) return;
mPoints += amount;
NotifyAvailability();
}
// ─── 소환 / 회수 ───
/// <summary>
/// 소환을 시도한다. 성공하면 true.
/// destination 은 아군이 걸어갈 목적지이며, 소환 시점의 플레이어 위치를 넣는다.
/// </summary>
public bool TrySummon(Vector3 destination)
{
if (!CanSummon) return false;
EntityComponent entityComponent = GameEntry.GetComponent<EntityComponent>();
if (entityComponent == null)
{
Log.Error("AllySquad: EntityComponent 가 없다. 아군을 소환할 수 없다.");
return false;
}
int id = EntitySerialId.Next();
// 아군이 실제로 뜨기 전에 상태를 먼저 세운다. ShowEntity 가 비동기라
// 여기서 세우지 않으면 로드가 끝나기 전에 한 번 더 소환될 수 있다.
mAllyEntityId = id;
mAlly = null;
mRemainingSeconds = AllyTable.Duration;
mUnsummonRequested = false;
entityComponent.ShowEntity(
id,
typeof(Ally),
AllyTable.Asset,
AllyTable.Group,
AllyData.Create(id, AllyTable.EntityTypeId, AllyTable.SpawnPosition, destination));
NotifyAvailability();
return true;
}
/// <summary>
/// <see cref="SurvivalGame.OnShowEntitySuccess"/> 가 아군 엔티티를 넘겨준다.
/// 로드 중에 회수 요청이 들어왔다면 여기서 즉시 되돌린다.
/// </summary>
public void CacheAlly(Entity entity)
{
if (entity == null) return;
Ally ally = entity.Logic as Ally;
if (ally == null)
{
GameAssert.Unreachable("AllySquad: Ally 로직이 아닌 엔티티가 넘어왔다.");
return;
}
// 이 판의 소환이 아니다(이전 판의 잔여 로드). 바로 돌려보낸다.
if (entity.Id != mAllyEntityId || mUnsummonRequested)
{
ally.SafeHide();
return;
}
mAlly = ally;
}
/// <summary>
/// 아군을 거둔다. <b>포인트도 함께 몰수된다.</b>
/// 한 번 부르면 다시 30점을 모아야 한다.
/// </summary>
public void Unsummon()
{
if (!HasAlly) return;
if (mAlly != null && mAlly.Available)
{
mAlly.SafeHide();
}
else
{
// 아직 로드 중이다. 도착하는 순간 CacheAlly 가 되돌린다.
mUnsummonRequested = true;
}
mPoints = 0;
mAllyEntityId = NoAllyId;
mAlly = null;
mRemainingSeconds = 0f;
NotifyAvailability();
}
// ─── 갱신 ───
public void OnUpdate(float elapseSeconds)
{
if (!HasAlly) return;
// 판 정리 등으로 아군이 밖에서 회수됐을 수 있다. 그 경우 타이머를 기다리지 않고 정리한다.
if (mAlly != null && !mAlly.Available)
{
Unsummon();
return;
}
mRemainingSeconds -= elapseSeconds;
if (mRemainingSeconds > 0f) return;
Unsummon();
}
// ─── HUD 통보 ───
private void NotifyAvailability()
{
bool canSummon = CanSummon;
if (canSummon == mNotifiedCanSummon) return;
mNotifiedCanSummon = canSummon;
EventComponent events = GameEntry.GetComponent<EventComponent>();
if (events == null) return;
events.Fire(this, AllyAvailabilityChangedEventArgs.Create(canSummon));
}
}
}
C#
복사
코드 읽기
지금까지 배운 것들이 한 클래스에 모여 있습니다. 하나씩 짚어 봅니다.
"오는 중"을 다시 만납니다. 5장의 스포너는 "오는 중인 적"을 숫자로 셌습니다. 여기서는 한 마리뿐이라 번호 하나(mAllyEntityId)로 표현합니다. TrySummon 은 ShowEntity 를 부르기 전에 이 번호를 채웁니다. 그 순간부터 HasAlly 가 참이 되어, 양이 아직 화면에 나타나지 않았어도 1 을 연타해서 두 마리를 부를 수 없습니다.
그래서 양의 상태는 셋입니다.
상태 | mAllyEntityId | mAlly | 적이 쫓는 것 |
없다 | 0 | null | 플레이어 |
오는 중 (요청했지만 아직 안 떴다) | 번호 있음 | null | 플레이어 |
나와 있다 | 번호 있음 | 있음 | 양 |
"오는 중에 취소"를 다룹니다. 소환한 직후, 양이 뜨기도 전에 판이 끝나면 어떻게 될까요? ShowEntity 요청은 이미 나갔고 취소되지 않습니다. 잠시 뒤 양이 뜹니다. 다음 판의 화면에. mUnsummonRequested 가 이것을 막습니다. 양이 도착했을 때(CacheAlly) 이 깃발이 서 있거나 번호가 지금 기다리는 것과 다르면, 받자마자 돌려보냅니다. 비동기로 무언가를 요청하는 코드는 "요청한 것이 필요 없어진 뒤에 도착하면?" 을 한 번은 생각해야 합니다.
mAlly.Available 을 매번 확인합니다. Unsummon 과 OnUpdate 가 그렇습니다. mAlly 는 여러 프레임에 걸쳐 들고 있는 참조이고, 그 사이에 다른 경로로 회수될 수 있기 때문입니다(9장에서 판을 정리할 때 화면의 엔티티를 전부 치웁니다). 6장의
"참조로 들 것인가, 번호로 들 것인가"에서 말한 중간 방법입니다.
"참조로 들 것인가, 번호로 들 것인가"에서 말한 중간 방법입니다.바뀔 때만 알립니다. NotifyAvailability 를 보세요. 포인트가 오를 때마다 불리지만, "소환할 수 있는가"라는 답이 직전과 달라졌을 때만 이벤트를 쏩니다. 10점 → 20점은 둘 다 "불가"이므로 조용하고, 20점 → 30점에서 "가능"으로 한 번, 소환하는 순간 "불가"로 한 번 쏩니다. 7장의 점수 이벤트는 값이 바뀔 때마다 쐈지만, 듣는 쪽이 예/아니오만 필요하다면 그 예/아니오가 뒤집힐 때만 쏘면 됩니다.
Reset() 의 마지막 두 줄은 이 규칙을 일부러 우회합니다. mNotifiedCanSummon 을 거짓말로 true 로 바꾼 뒤 통보를 부르면, 실제 답(false)과 달라서 반드시 이벤트가 나갑니다. 새 판이 시작될 때 HUD 의 아이콘이 확실히 꺼지게 하려는 것입니다. 7장의 "지금 값을 알리는 첫 통보"와 같은 목적입니다.
8.4 게임에 꽂기
이제 지금까지 만든 것들에 연결합니다. 고치는 줄 수를 세어 보세요.
SurvivalGame
파일 맨 위에 using UnityEngine.InputSystem; 을 추가하고, 필드를 하나 더합니다.
/// <summary>아군 소환 경제. 판 단위 상태라 게임 객체가 소유한다.</summary>
private readonly AllySquad mAllySquad = new AllySquad();
C#
복사
ResetRoundState 의 끝에 mAllySquad.Reset(); 을 추가합니다. 그리고 새 메서드 하나입니다.
/// <summary>
/// 아군 소환 입력. 숫자 1.
/// 무기 전환(Tab)·발사(좌클릭)와 겹치지 않는다.
/// </summary>
private void ReadAllySummonInput()
{
if (GamePause.IsPaused) return;
// 키보드가 없을 수 있다(패드만 연결된 경우 등).
Keyboard keyboard = Keyboard.current;
if (keyboard == null || !keyboard.digit1Key.wasPressedThisFrame) return;
Player player = Player;
if (player == null) return;
// 소환 시점의 플레이어 위치를 목적지로 준다. 아군은 그리로 한 번 걸어갈 뿐이다.
mAllySquad.TrySummon(player.CachedTransform.position);
}
C#
복사
이미 있던 메서드 셋에 한 줄씩 넣습니다. Update,
mGameTime += elapseSeconds;
ReadAllySummonInput();
mAllySquad.OnUpdate(elapseSeconds);
mSpawner.OnUpdate(elapseSeconds);
C#
복사
AddScore,
Score += delta;
// 점수는 곧 소환 포인트다.
mAllySquad.AddPoints(delta);
C#
복사
OnShowEntitySuccess 입니다.
if (ne.EntityLogicType == typeof(Ally))
{
mAllySquad.CacheAlly(ne.Entity);
return;
}
C#
복사
EntityFinder
4장에서 플레이어를 찾으려고 만든 EntityFinder 에 메서드 하나를 더합니다. 찾는 일 자체(Find<T>)는 그때 만들어 두었으므로 그룹 이름과 타입만 다릅니다.
/// <summary>지금 떠 있는 아군. 소환하지 않았거나 아직 로드 중이면 null 이다.</summary>
public static Ally FindAlly()
{
return Find<Ally>(AllyTable.Group);
}
C#
복사
Enemy
적은 한 줄을 고칩니다. 6장에서 TickChase 에 이렇게 써 두었습니다.
mAgent.SetDestination(GetDestination(playerPosition, playerPosition));
C#
복사
둘째 인자가 "쫓을 좌표"였고, 그때는 플레이어 위치를 그대로 넣었습니다. 이것을 프레임워크에 물어본 값으로 바꿉니다. 양이 떠 있으면 양의 자리, 아니면 플레이어의 자리입니다.
// 추적 목적지는 발밑(트랜스폼 원점)이다. 공격 판정만 콜라이더 중심을 쓴다.
Vector3 playerPosition = player.CachedTransform.position;
mAgent.SetDestination(GetDestination(playerPosition, ResolveChasePosition(playerPosition)));
C#
복사
/// <summary>
/// 지금 쫓을 좌표. 아군이 소환돼 있으면 아군이고, 아니면 플레이어다.
///
/// 0.5초마다 <b>폴링</b>한다. 이벤트로 바꾸지 않는다 —
/// 소환 직후 적이 한 박자 늦게 방향을 트는 그 지연이 손맛이다.
/// </summary>
private static Vector3 ResolveChasePosition(Vector3 playerPosition)
{
// 아군이 실제로 화면에 서 있을 때만 아군을 쫓는다. 소환 직후 로드가 끝나기 전에는
// 플레이어를 그대로 쫓는다.
Ally ally = EntityFinder.FindAlly();
if (ally == null) return playerPosition;
return ally.LurePosition;
}
C#
복사
HUDForm
7장에서 아이콘을 찾아서 꺼 두기까지 해 놓았습니다. 구독과 해지에 한 줄씩, 그리고 핸들러 하나입니다.
events.Subscribe(AllyAvailabilityChangedEventArgs.EventId, OnAllyAvailabilityChanged);
C#
복사
events.Unsubscribe(AllyAvailabilityChangedEventArgs.EventId, OnAllyAvailabilityChanged);
C#
복사
/// <summary>소환 가부가 뒤집힐 때만 온다. 매 점수마다 오지 않는다.</summary>
private void OnAllyAvailabilityChanged(object sender, GameEventArgs e)
{
AllyAvailabilityChangedEventArgs ne = e as AllyAvailabilityChangedEventArgs;
if (ne == null)
{
GameAssert.Unreachable("HUDForm: AllyAvailabilityChanged 핸들러에 다른 타입이 들어왔다.");
return;
}
SetAllyButtonVisible(ne.CanSummon);
}
C#
복사
무엇을 고쳤는지 세어 보기
파일 | 고친 것 |
SurvivalGame.cs | 필드 1, 새 메서드 1, 기존 메서드 4곳에 한 줄씩 |
EntityFinder.cs | 새 메서드 1 |
Enemy.cs | 한 줄 수정, 새 메서드 1 |
HUDForm.cs | 두 줄 추가, 새 메서드 1 |
Player.cs, 무기 전부, EnemySpawner.cs, 상태 클래스 전부, ProcedureMain.cs | 없음 |
"적이 쫓는 대상이 바뀐다"는 게임의 규칙을 흔드는 큰 변화처럼 들립니다. 그런데 적 쪽에서 바뀐 것은 좌표를 얻는 한 줄입니다. 적은 처음부터 "플레이어를 쫓는다"가 아니라 "이 좌표로 간다"로 만들어져 있었기 때문입니다. 냉기의 얼음도, 악취의 도주도 6장에서 그 좌표와 속도에 끼어드는 방식으로 넣었습니다. 양도 같은 자리에 끼어듭니다.
좌표만 읽고, 참조는 들고 있지 않습니다. ResolveChasePosition 이 Vector3 를 돌려주는 데 주목하세요. "적이 양의 Transform 을 한 번 받아 두고 그것을 따라가게 하면" 더 간단해 보입니다. 하지만 적이 그 Transform 을 들고 있는 동안 양이 사라지면? 파괴된 것이 아니라 풀에 들어간 것이므로 null 도 아닙니다. 적들은 풀 속에 꺼져 있는 양이 있던 자리로 계속 가다가, 그 인스턴스가 다음 소환으로 다른 곳에 나타나는 순간 우르르 방향을 틉니다. 좌표는 값이라 낡지 않습니다. 0.5초마다 EntityFinder 로 새로 물어보면 그만입니다. 프레임워크는 풀에서 쉬고 있는 양을 돌려주지 않습니다.
일시정지와 맞물리는 곳이 하나 있습니다. ReadAllySummonInput 의 첫 줄 if (GamePause.IsPaused) return; 입니다. SurvivalGame.Update 는 프로시저가 매 프레임 부르고, 4장에서 배운 대로 게임 시간이 멈춰 있어도 불립니다. 이 줄이 없으면 일시정지 메뉴를 띄워 놓고 1 을 눌러 양을 부를 수 있습니다. 입력을 읽는 곳이 하나 늘 때마다 이 질문도 한 번 늘어납니다.
실행해 보기
Play → 캐릭터를 고르고 적 셋을 잡아 30점을 넘깁니다. 화면 아래 가운데에 양 아이콘이 뜹니다. 숫자 1 을 누릅니다.
•
아이콘이 꺼지고, 방 오른쪽에서 양이 나타나 1 을 누른 순간 플레이어가 서 있던 자리로 걸어옵니다.
•
적들이 0.5초 안에 방향을 틀어 양에게 몰려갑니다. 양을 둘러싸고 서 있을 뿐 때리지는 못합니다(적의 공격은 플레이어와의 거리로만 판정합니다).
•
그동안 플레이어는 자유롭습니다. 등을 보인 적들을 번개로 쏘면 됩니다.
•
10초 뒤 양이 사라지고 적들이 다시 플레이어를 쫓습니다. 점수는 그대로지만 아이콘은 뜨지 않습니다. 포인트가 0 으로 돌아갔기 때문에 30점을 더 모아야 합니다.
•
일시정지(Esc) 중에 1 을 눌러 봅니다. 아무 일도 없습니다.
실제 플레이 — 30점을 넘기고 1 을 누르면 양이 나타나 걸어오고, 적들이 방향을 틀어 양에게 몰려간다
8.5 소리 입히기
지금까지 만든 게임은 조용합니다. 번개가 나가도, 적이 쓰러져도 아무 소리가 없습니다. 일부러 미뤄 둔 것입니다.
Unity 에서 소리를 내는 가장 익숙한 방법은 프리팹에 AudioSource 를 붙여 두고 Play() 를 부르는 것입니다. 원작 Zombie Toys 도 그렇게 합니다. 번개 프리팹에 발사음이, 착탄 이펙트 프리팹에 폭발음이, 씬의 Background Music 오브젝트에 배경음악이 붙어 있습니다. 이 책은 그렇게 하지 않습니다. 그렇게 낸 소리는 프레임워크가 모르는 소리이기 때문입니다. 동시에 몇 개가 울리는지 아무도 세지 않고, 프리팹마다 볼륨과 믹서 연결을 따로 맞춰야 하고, 하나라도 빠뜨리면 그 소리만 볼륨 슬라이더를 따라오지 않습니다.
그래서 규칙을 하나로 정합니다. 게임의 소리는 전부 프레임워크의 사운드 기능으로 냅니다. 한 번 울리는 효과음도, 누르고 있는 동안 계속 나는 분사음도, 배경음악도 같은 창구를 지납니다. 엔티티를 Instantiate 하지 않고 ShowEntity 로만 띄우는 것과 같은 약속입니다.
프레임워크의 사운드는 어떻게 생겼나
엔티티, UI 폼과 같은 모양입니다. 주소와 그룹을 주면 프레임워크가 알아서 읽어 와 틉니다.
soundComponent.PlaySound("PlayerHurt", "Soundeffects", worldPosition);
// ↑ Addressables 주소 ↑ 사운드 그룹 ↑ 어디서 나는 소리인가
C#
복사
사운드 그룹이 하는 일은 둘입니다.
Music | Soundeffects | |
에이전트 수 (= 동시에 낼 수 있는 소리의 수) | 1 | 16 |
연결된 믹서 그룹 | MasterMixer 의 Music | MasterMixer 의 Soundeffects |
에이전트는 AudioSource 하나를 가진 재생기입니다. 그룹이 미리 만들어 두고 돌려씁니다. 엔티티의 풀과 같은 생각입니다. Soundeffects 에는 열여섯 개가 있어서 효과음이 열여섯 개까지 겹칩니다. 열일곱 번째가 들어오면 우선순위가 가장 낮은 것, 같다면 가장 오래된 것이 끊기고 그 자리를 씁니다. 적 열세 마리가 한꺼번에 울어도 소리가 무한히 쌓이지 않습니다.
그리고 각 그룹은 같은 이름의 믹서 그룹에 연결됩니다. 7장의 일시정지 메뉴에서 만든 효과음 볼륨 슬라이더가 MasterMixer 의 값을 바꿨던 것을 기억하나요? 사운드 그룹으로 낸 소리는 그 믹서를 거치므로 슬라이더가 그대로 먹습니다. AudioSource.PlayClipAtPoint 같은 것으로 직접 틀면 믹서를 거치지 않아 슬라이더와 따로 놉니다.
주소 모으기와 재생 창구
// Assets/GameMain/Game/SoundTable.cs
namespace ToyBoxNightmare
{
/// <summary>
/// 사운드 그룹 이름과 Addressables 주소를 모아 둔다.
///
/// <b>그룹 이름은 AudioMixer 의 그룹 이름과 정확히 같아야 한다.</b>
/// SoundComponent 가 <c>Master/{그룹이름}</c> 으로 믹서 그룹을 찾고,
/// 못 찾으면 조용히 Master 로 떨어진다 — 그러면 볼륨 슬라이더가 안 먹는다.
/// MasterMixer 의 그룹은 Master / Music / Soundeffects 다.
/// </summary>
public static class SoundTable
{
// ─── 사운드 그룹 ───
/// <summary>배경음악. 동시에 한 곡만 나오면 되므로 에이전트 1개.</summary>
public const string MusicGroup = "Music";
/// <summary>효과음. 피격·사망이 겹치므로 에이전트를 여러 개 둔다.</summary>
public const string SfxGroup = "Soundeffects";
// ─── Addressables 주소 ───
public const string BackgroundMusic = "BackgroundMusic";
// 무기·상태 이상. 주소가 엔티티 프리팹의 주소(LightningHit, StinkHit …)와 겹치지 않게 Sound 를 붙였다 —
// Addressables 주소는 에셋 종류와 무관하게 하나의 이름공간이다.
public const string LightningFire = "LightningFireSound";
public const string LightningHit = "LightningHitSound";
public const string FrostConeLoop = "FrostConeLoopSound";
public const string StinkFire = "StinkFireSound";
public const string StinkHit = "StinkHitSound";
public const string SlimeHit = "SlimeHitSound";
public const string EnemyFreeze = "EnemyFreezeSound";
public const string EnemySlimeLoop = "EnemySlimeLoopSound";
public const string PlayerHurt = "PlayerHurt";
public const string PlayerDeath = "PlayerDeath";
// 적 종별 클립. 클립이 있는 종은 셋뿐이라(ZomBunny / ZomBear / Hellephant)
// 나머지 둘은 체급이 비슷한 쪽을 빌려 쓴다. 전용 클립이 생기면 여기만 고치면 된다.
private const string ZomBunnyHurt = "ZomBunnyHurt";
private const string ZomBunnyDeath = "ZomBunnyDeath";
private const string ZomBearHurt = "ZomBearHurt";
private const string ZomBearDeath = "ZomBearDeath";
private const string HellephantHurt = "HellephantHurt";
private const string HellephantDeath = "HellephantDeath";
/// <summary>적 종의 Addressables 주소로 피격음 주소를 얻는다. 모르는 종이면 null.</summary>
public static string GetEnemyHurtSound(string enemyAssetName)
{
switch (enemyAssetName)
{
case "Zombunny": return ZomBunnyHurt;
case "ZombieDuck": return ZomBunnyHurt; // 대역 — 전용 클립 없음
case "ZomBear": return ZomBearHurt;
case "Clown": return ZomBearHurt; // 대역 — 전용 클립 없음
case "Hellephant": return HellephantHurt;
default: return null;
}
}
/// <summary>적 종의 Addressables 주소로 사망음 주소를 얻는다. 모르는 종이면 null.</summary>
public static string GetEnemyDeathSound(string enemyAssetName)
{
switch (enemyAssetName)
{
case "Zombunny": return ZomBunnyDeath;
case "ZombieDuck": return ZomBunnyDeath; // 대역
case "ZomBear": return ZomBearDeath;
case "Clown": return ZomBearDeath; // 대역
case "Hellephant": return HellephantDeath;
default: return null;
}
}
}
}
C#
복사
// Assets/GameMain/Utility/GameSound.cs
using GameFramework.Sound;
using UnityEngine;
using UnityGameFramework.Runtime;
namespace ToyBoxNightmare
{
/// <summary>
/// 게임이 소리를 내는 유일한 창구. 전부 <see cref="SoundComponent"/> 로 간다.
///
/// <b>프리팹에 AudioSource 를 박아 두고 직접 <c>Play()</c> 하지 않는다.</b> 그렇게 낸 소리는
/// 프레임워크의 사운드 그룹을 거치지 않아서 볼륨 설정·동시 재생 수 제한이
/// 하나도 먹지 않는다. 클립은 Addressables 에 등록하고 주소(<see cref="SoundTable"/>)로 재생한다.
/// </summary>
public static class GameSound
{
/// <summary>재생 중인 소리가 없음을 뜻하는 일련번호. 프레임워크는 1부터 발급한다.</summary>
public const int NoSound = 0;
/// <summary>반복음의 우선순위. 에이전트가 모자랄 때 한 번 울리고 끝나는 효과음보다 늦게 밀려난다.</summary>
private const int LoopPriority = 1;
/// <summary>
/// 배경음악을 틀어 준 <see cref="SoundComponent"/>. 음악은 판이 바뀌어도 끊지 않으므로,
/// "이 프레임워크 인스턴스에서 이미 틀었는가" 만 기억하면 된다.
/// </summary>
private static SoundComponent sMusicOwner = null;
/// <summary>
/// 한 번 울리고 끝나는 효과음.
///
/// 주소가 비어 있거나 사운드 시스템이 없으면 조용히 넘어간다 —
/// 소리가 안 나는 것이 게임을 멈출 이유는 되지 않는다.
/// </summary>
public static void PlaySfx(string assetName, Vector3 worldPosition)
{
if (string.IsNullOrEmpty(assetName)) return;
SoundComponent sound = GameEntry.GetComponent<SoundComponent>();
if (sound == null) return;
sound.PlaySound(assetName, SoundTable.SfxGroup, worldPosition);
}
/// <summary>
/// 멈출 때까지 반복하는 효과음(냉기 분사, 슬라임 지속음). 돌려준 일련번호를
/// <see cref="Stop"/> 에 넘겨 끈다. 재생하지 못했으면 <see cref="NoSound"/>.
/// </summary>
public static int PlayLoop(string assetName, Vector3 worldPosition, float volume)
{
if (string.IsNullOrEmpty(assetName)) return NoSound;
SoundComponent sound = GameEntry.GetComponent<SoundComponent>();
if (sound == null) return NoSound;
// PlaySoundParams 는 참조 풀 객체다. 넘기고 나면 프레임워크가 반납한다.
PlaySoundParams playSoundParams = PlaySoundParams.Create();
playSoundParams.Loop = true;
playSoundParams.VolumeInSoundGroup = volume;
return sound.PlaySound(assetName, SoundTable.SfxGroup, LoopPriority, playSoundParams, worldPosition);
}
/// <summary>
/// <see cref="PlayLoop"/> 로 낸 소리를 끈다. 아직 로드 중이어도 된다 —
/// 프레임워크가 로드가 끝나는 순간 재생하지 않고 버린다.
/// </summary>
public static void Stop(int serialId)
{
if (serialId == NoSound) return;
SoundComponent sound = GameEntry.GetComponent<SoundComponent>();
if (sound == null) return;
// 이미 끝난 소리면 false 가 돌아올 뿐이다.
sound.StopSound(serialId);
}
/// <summary>
/// 배경음악을 튼다. 이미 틀었으면 아무것도 하지 않는다 —
/// 판을 다시 시작할 때마다 불려도 음악이 처음으로 돌아가지 않는다.
/// </summary>
public static void PlayMusic()
{
SoundComponent sound = GameEntry.GetComponent<SoundComponent>();
if (sound == null) return;
// 프레임워크가 다시 만들어지면(에디터에서 재생을 다시 누르는 경우 등) 예전 컴포넌트는
// 파괴된 뒤라 지금 것과 같지 않다. 그때는 다시 튼다.
if (sMusicOwner == sound) return;
sMusicOwner = sound;
PlaySoundParams playSoundParams = PlaySoundParams.Create();
playSoundParams.Loop = true;
sound.PlaySound(SoundTable.BackgroundMusic, SoundTable.MusicGroup, playSoundParams);
}
}
}
C#
복사
한 번 울리는 소리 — 무기와 투사체
6장에서 만든 무기와 투사체에 한 줄씩 넣습니다. LightningWeapon.cs 의 ApplyHitscanHit 과 PlayBolt 입니다.
WeaponUtil.SpawnEffect(typeof(HitEffect), WeaponTable.LightningHitAsset,
hit.point, Quaternion.identity, WeaponTable.LightningHitLifetime);
GameSound.PlaySfx(SoundTable.LightningHit, hit.point);
C#
복사
private void PlayBolt(Vector3 endPoint)
{
GameSound.PlaySfx(SoundTable.LightningFire, MuzzleOrigin);
if (mBolt != null)
{
mBolt.Play(endPoint);
}
}
C#
복사
투사체는 "발사음"이라는 공통점이 있으므로 부모에 훅을 하나 둡니다. ProjectileLogicBase.cs 에 속성을 추가하고, PlayEffects 에 한 줄을 넣습니다.
/// <summary>
/// 발사음의 Addressables 주소. 없으면 null. 소리는 프리팹에 박아 두지 않고
/// <see cref="GameSound"/> 로 낸다.
/// </summary>
protected virtual string LaunchSound => null;
C#
복사
protected void PlayEffects()
{
PlayParticles();
GameSound.PlaySfx(LaunchSound, CachedTransform.position);
}
C#
복사
StinkProjectile.cs 는 그 훅을 덮어쓰고, 터지는 자리(Explode)에서 가스 구름을 띄운 다음 줄에 착탄음을 냅니다.
protected override string LaunchSound => SoundTable.StinkFire;
C#
복사
GameSound.PlaySfx(SoundTable.StinkHit, impactPoint);
C#
복사
SlimeProjectile.cs 는 적에게 붙는 순간 한 줄입니다. 슬라임은 발사음이 없어서 LaunchSound 를 덮어쓰지 않았습니다. 기본값 null 은 PlaySfx 가 조용히 흘려보냅니다.
GameSound.PlaySfx(SoundTable.SlimeHit, CachedTransform.position);
C#
복사
멈출 때까지 나는 소리 — 반복음
냉기의 분사음은 버튼을 누르고 있는 동안 계속 나야 합니다. FrostWeapon.cs 에 필드 둘을 추가하고,
/// <summary>분사음의 볼륨.</summary>
private const float ConeSoundVolume = 0.85f;
/// <summary>재생 중인 분사음. 없으면 <see cref="GameSound.NoSound"/>.</summary>
private int mConeSoundId = GameSound.NoSound;
C#
복사
콘을 켜고 끄던 SetConeActive 의 끝에서 소리도 같이 켜고 끕니다.
private void SetConeActive(bool active)
{
if (mFrostCone != null && mFrostCone.activeSelf != active)
{
mFrostCone.SetActive(active);
}
SetConeSoundActive(active);
}
/// <summary>
/// 콘이 켜져 있는 동안 분사음을 반복한다.
/// 켜고 끄는 호출이 매 프레임 올 수 있으므로 상태가 바뀔 때만 프레임워크에 요청한다.
/// </summary>
private void SetConeSoundActive(bool active)
{
bool isPlaying = mConeSoundId != GameSound.NoSound;
if (isPlaying == active) return;
if (!active)
{
GameSound.Stop(mConeSoundId);
mConeSoundId = GameSound.NoSound;
return;
}
mConeSoundId = GameSound.PlayLoop(SoundTable.FrostConeLoop, CachedTransform.position, ConeSoundVolume);
}
C#
복사
적이 얼 때와 슬라임이 붙어 있는 동안에도 소리가 납니다. 6장에서 Enemy.cs 에 만든 두 메서드를 고칩니다. 얼음은 켜지는 순간 한 번, 슬라임은 붙어 있는 동안 계속입니다.
/// <summary>빙결 VFX 토글. 프리팹에 FreezeFx 자식이 없으면 조용히 무시된다.</summary>
internal void SetFreezeFxActive(bool active)
{
// 소리는 연출이 꺼져 있다가 켜지는 순간에만 한 번 낸다.
bool turnsOn = active && mFreezeFx != null && !mFreezeFx.activeSelf;
SetFxActive(mFreezeFx, active);
if (turnsOn)
{
GameSound.PlaySfx(SoundTable.EnemyFreeze, CachedTransform.position);
}
}
/// <summary>재생 중인 슬라임 지속음. 없으면 <see cref="GameSound.NoSound"/>.</summary>
private int mSlimeLoopSoundId = GameSound.NoSound;
/// <summary>슬라임 VFX 토글. 프리팹에 SlimeFx 자식이 없으면 조용히 무시된다.</summary>
internal void SetSlimeFxActive(bool active)
{
SetFxActive(mSlimeFx, active);
// 슬라임이 붙어 있는 동안 지속음을 낸다. 떼어질 때(만료·사망·회수) 반드시 끈다 —
// 이 함수는 ClearAll 을 통해 그 모든 경로에서 false 로 불린다.
if (!active)
{
GameSound.Stop(mSlimeLoopSoundId);
mSlimeLoopSoundId = GameSound.NoSound;
return;
}
if (mSlimeLoopSoundId != GameSound.NoSound) return;
mSlimeLoopSoundId = GameSound.PlayLoop(SoundTable.EnemySlimeLoop, CachedTransform.position, 1f);
}
C#
복사
배경음악
ProcedureMain.OnEnter 에 한 줄입니다.
Log.Info("ProcedureMain: Enter");
// 배경음악은 SoundComponent 로 튼다. 이미 나오고 있으면 그대로 둔다.
GameSound.PlayMusic();
C#
복사
맞고 죽는 소리 — 4장의 훅
Player.cs 에 훅 하나를 덮어쓰고,
/// <summary>피격음. 사망 여부와 무관하게 매 피격 울린다.</summary>
protected override void OnDamaged(Entity attacker, int damageHitPoints)
{
base.OnDamaged(attacker, damageHitPoints);
GameSound.PlaySfx(SoundTable.PlayerHurt, CenterPosition);
}
C#
복사
OnDead 의 깃발을 세운 바로 다음 줄에 넣습니다.
mDying = true;
mDeathTimer = 0f;
GameSound.PlaySfx(SoundTable.PlayerDeath, CenterPosition);
C#
복사
Enemy.cs 도 같습니다. 6장에서 솜뭉치를 튀기던 OnDamaged 에 한 줄,
protected override void OnDamaged(Entity attacker, int damageHitPoints)
{
if (mHitParticles != null)
{
mHitParticles.Play();
}
GameSound.PlaySfx(SoundTable.GetEnemyHurtSound(AssetName), CachedTransform.position);
}
/// <summary>
/// 사운드 주소를 고르는 데 쓰는 종 이름. Addressables 주소와 같다("Zombunny" 등).
/// </summary>
private string AssetName => Entity != null ? Entity.EntityAssetName : string.Empty;
C#
복사
OnDead 에 한 줄입니다.
mDeathTimer = 0f;
GameSound.PlaySfx(SoundTable.GetEnemyDeathSound(AssetName), CachedTransform.position);
C#
복사
코드 읽기
4장에서 비워 둔 훅이 다 찼습니다. TargetableObject 에 OnDamaged, OnHitPointChanged, OnDead 세 훅을 만들어 두고 "언제 불리는가는 부모가, 불렸을 때 무엇을 하는가는 자식이"라고 했습니다. 6장에서 적의 솜뭉치가, 7장에서 플레이어의 체력 이벤트가, 이 장에서 소리가 그 자리에 들어갔습니다. 체력을 계산하는 코드는 4장 이후 한 번도 고치지 않았습니다.
GameSound.PlaySfx 는 실패해도 조용합니다. 주소가 null 이면 그냥 돌아갑니다. SoundTable.GetEnemyHurtSound 는 모르는 적 이름에 null 을 돌려주므로, 5장의 연습 문제에서 만든 BigBunny 처럼 표에 없는 적은 소리만 안 나고 나머지는 정상입니다. 7장에서 "UI 의 문제가 게임을 멈추게 두지 않는다"고 한 것과 같은 태도입니다. 소리와 화면은 게임의 겉입니다.
적 다섯 종에 클립은 세 벌입니다. 오리와 광대는 전용 소리가 없어서 토끼와 곰의 것을 빌립니다. 이 사실을 아는 곳은 SoundTable 의 switch 두 개뿐입니다. Enemy 는 "내 이름에 맞는 소리를 달라"고만 합니다. 나중에 오리 전용 클립이 생기면 표의 한 줄을 고칩니다.
소리를 내는 것은 일을 일으킨 쪽입니다. 착탄 이펙트(HitEffect)는 소리를 내지 않습니다. 번개가 맞았을 때의 소리는 LightningWeapon 이, 악취가 터질 때의 소리는 StinkProjectile 이 냅니다. HitEffect 는 번개와 슬라임이 함께 쓰는 로직이라, 거기서 소리를 내려면 "나는 지금 무슨 이펙트인가"를 알아야 하기 때문입니다. 이펙트를 띄우는 쪽은 이미 그것을 알고 있습니다.
반복음은 번호로 쥐고 있다가 끕니다. PlayLoop 는 int 를 돌려줍니다. 프레임워크가 그 소리에 붙인 일련번호입니다. 6장에서 엔티티를 참조가 아니라 번호로 들던 것과 같은 방식이고 이유도 같습니다. 소리 에이전트도 돌려쓰는 것이라, 에이전트를 직접 들고 있으면 그 사이에 다른 소리가 그 자리를 차지했을 수 있습니다. 번호는 한 번 쓰이면 다시 쓰이지 않으므로 Stop(번호) 는 내가 켠 바로 그 소리만 끕니다. 이미 끝난 소리의 번호를 넘기면 아무 일도 일어나지 않습니다. NoSound(0) 는 "지금 켜 둔 것이 없다"는 표시입니다. 프레임워크가 번호를 1 부터 주기 때문에 0 이 비어 있습니다.
켠 것은 반드시 끕니다. 반복음은 끄지 않으면 계속 납니다. 그래서 끄는 자리가 중요합니다. 냉기의 분사음은 SetConeActive 에 묶었습니다. 6장에서 이 메서드는 버튼을 뗄 때, Tab 으로 무기를 바꿀 때, 플레이어가 죽을 때, 무기가 풀로 돌아갈 때 전부 false 로 불리게 만들어 두었습니다. 분사 연출이 꺼지는 모든 길에서 소리도 같이 꺼집니다. 슬라임 지속음도 같은 방식으로 SetSlimeFxActive 에 묶었고, 이것은 EnemyDebuffs.ClearAll() 을 거쳐 적이 죽거나 회수될 때도 불립니다. 소리의 수명을 따로 관리하지 않고, 이미 수명이 관리되고 있는 것에 묶습니다.
PlaySoundParams.Create() 로 만든 것은 우리가 돌려주지 않습니다. 반복 여부나 볼륨처럼 기본값과 다르게 틀고 싶을 때 쓰는 옵션 묶음이고, 주문서처럼 참조 풀에서 꺼냅니다. PlaySound 에 넘기면 프레임워크가 다 쓴 뒤 스스로 반납합니다. 3장에서 ShowEntity 에 넘긴 주문서와 같은 약속입니다.
배경음악도 같은 창구로 틉니다. 씬에 AudioSource 를 하나 놓고 Play On Awake 를 켜 두면 코드 한 줄 없이 음악이 나옵니다. 그래도 PlayMusic() 으로 트는 것은, 그래야 음악이 Music 사운드 그룹을 지나고 프레임워크가 그 소리를 알기 때문입니다. 나중에 판마다 다른 곡을 틀거나 곡을 부드럽게 바꾸고 싶어질 때 고칠 곳이 GameSound 한 군데입니다. PlayMusic() 은 몇 번 불러도 한 번만 틉니다. ProcedureMain.OnEnter 는 재시작할 때마다 불리는데(9장), 그때마다 음악이 처음으로 돌아가면 안 되기 때문입니다. 이 책은 "열었으면 닫는다"를 계속 지켜 왔지만 음악은 일부러 닫지 않았습니다. 판이 끝나도 계속 흘러야 하는 것이라 OnLeave 에서 끄지 않고, 프레임워크가 내려갈 때 사운드 에이전트와 함께 꺼집니다.
실행해 보기
Play 를 누르면 배경음악이 흐르기 시작합니다. 번개를 쏘면 발사음과 착탄음이 나고, 냉기는 누르고 있는 동안만 분사음이 납니다. 적을 쏘면 맞을 때마다 짧은 울음이, 쓰러질 때 긴 울음이 납니다. 토끼, 곰, 코끼리의 소리가 다릅니다. 적에게 맞으면 플레이어가 신음하고, 죽을 때 다른 소리가 납니다.
•
Esc → Effects 슬라이더를 내리면 방금 넣은 소리와 번개 소리가 함께 작아집니다. 배경음악은 그대로입니다.
•
Music 슬라이더는 배경음악만 움직입니다.
•
실행 중에 Hierarchy 의 GameFramework → Sound → Sound Group - Soundeffects 를 펼치면 에이전트 열여섯 개가 보입니다. 소리가 날 때 그중 하나의 AudioSource 에 클립이 걸렸다가 빠집니다.
•
냉기를 누른 채로 Tab 을 눌러 무기를 바꿉니다. 분사음이 바로 끊깁니다. 분사를 끄는 메서드가 소리도 같이 끄기 때문입니다.
•
Hierarchy 검색창에 t:AudioSource 를 넣어 봅니다. Sound Group - … 아래의 에이전트만 나옵니다. 게임의 다른 어디에도 AudioSource 가 없습니다.
아래는 플레이어가 맞는 순간 게임을 멈추고 에이전트 하나를 골라 본 것입니다. (화면이 붉은 것은 7장의 피격 플래시입니다.)
사운드 에이전트. ① Sound Instances — Music 그룹에 1개, Soundeffects 그룹에 16개 ② 프레임워크가 만든 Default Sound Agent Helper ③ 그 AudioSource — 방금 울린 Player Hurt 클립과, 믹서의 Soundeffects 그룹으로 나가는 출력
③ 의 Output 이 Soundeffects (MasterMixer) 인 것이 중요합니다. 일시정지 메뉴의 Effects 슬라이더가 이 소리에 먹는 이유가 이 한 칸입니다. 우리는 이 값을 어디에도 적지 않았습니다. 사운드 그룹 이름이 믹서 그룹 이름과 같아서 프레임워크가 알아서 연결했습니다.
8.6 잘 안 될 때
증상 | 원인과 해결 |
30점을 넘겨도 양 아이콘이 안 뜬다 | ① AddScore 에서 mAllySquad.AddPoints(delta) 를 불렀는가 ② HUDForm 이 AllyAvailabilityChangedEventArgs 를 구독했는가 |
1 을 눌러도 양이 안 나온다. Entity group 'Ally' is not exist. | Entity 컴포넌트에 Ally 그룹이 없습니다 |
양은 나오는데 적이 계속 플레이어를 쫓는다 | Enemy.TickChase 가 아직 playerPosition 을 그대로 쓰고 있거나, AllyTable.Group 의 이름이 Entity 컴포넌트의 그룹 이름과 달라 EntityFinder.FindAlly() 가 null 을 돌려줍니다 |
양이 나타난 자리에서 움직이지 않는다 | 소환 위치가 NavMesh 밖이거나 프리팹에 NavMeshAgent 가 없습니다. 콘솔의 Ally: NavMeshAgent 가 없다 를 확인합니다 |
양이 10초가 지나도 안 사라진다 | SurvivalGame.Update 에서 mAllySquad.OnUpdate(elapseSeconds) 를 부르지 않았거나, OnShowEntitySuccess 에 Ally 갈래가 없어 mAlly 가 null 로 남았습니다 |
1 을 빠르게 두 번 누르면 양이 두 마리 나온다 | TrySummon 에서 mAllyEntityId = id 를 ShowEntity 뒤에 두었습니다 |
둘째 소환에서 양이 엉뚱한 방향으로 걷다가 돌아온다 | Ally.OnHide 에서 StopAgent() 를 부르지 않아 지난번 경로가 남았습니다 |
소리가 전혀 안 난다 | ① 콘솔에 사운드 로드 실패 경고가 없는가(주소) ② Sound 컴포넌트에 Soundeffects 그룹이 있는가 ③ 에디터 Game 창의 Mute Audio 가 켜져 있지 않은가 |
소리는 나는데 Effects 슬라이더가 안 먹는다 | 사운드 그룹 이름이 믹서의 그룹 이름(Soundeffects)과 다릅니다. 이름이 다르면 Master 로 연결됩니다 |
오리와 광대만 소리가 안 난다 | SoundTable 의 switch 에 그 이름이 없습니다. 대소문자까지 EnemyTable 의 이름과 같아야 합니다 |
냉기 버튼을 떼도 분사음이 계속 난다 | SetConeSoundActive 의 끄는 쪽에서 mConeSoundId = GameSound.NoSound; 를 빠뜨렸거나, 소리를 SetConeActive 가 아닌 다른 곳에서 켰습니다. 켜는 곳과 끄는 곳이 같은 메서드여야 합니다 |
판을 다시 시작할 때마다 배경음악이 처음으로 돌아간다 | GameSound.PlayMusic 의 if (sMusicOwner == sound) return; 이 없습니다 |
적이 많을 때 일부 효과음이 안 난다 | Soundeffects 그룹의 에이전트가 모자랍니다. Sound 컴포넌트의 Inspector 에서 에이전트 수를 늘립니다 |
이 장에서 만든 것
파일 | 내용 |
Game/AllyTable.cs | 양의 숫자 |
Game/AllySquad.cs | 소환 포인트, 지속 시간, 소환·회수 |
Game/AllyAvailabilityChangedEventArgs.cs | "소환할 수 있는지가 바뀌었다" |
Entity/EntityData/AllyData.cs, Entity/EntityLogic/Ally.cs | 양 엔티티 |
Game/SoundTable.cs | 사운드 그룹 이름과 소리 주소 |
Utility/GameSound.cs | 게임의 소리가 전부 지나는 창구 — 효과음, 반복음, 배경음악 |
SurvivalGame.cs, Enemy.cs, HUDForm.cs, Player.cs, EntityFinder.cs, 무기·투사체, ProcedureMain.cs (수정) | 각각 몇 줄 |
정리
•
구조는 무언가를 추가할 때 평가된다. 기능 하나에 고칠 곳이 몇 군데인지 세어 본다
•
도구는 필요한 만큼만 쓴다. 결정할 것이 없는 엔티티에는 OnUpdate 도 FSM 도 없다
•
타이머는 그 시간이 끝났을 때 함께 일어나야 할 일을 아는 쪽이 든다
•
비동기로 요청한 것은 "필요 없어진 뒤에 도착하면?" 을 생각한다. 도착했을 때 지금도 기다리는 것인지 확인하고, 아니면 돌려보낸다
•
듣는 쪽이 예/아니오만 필요하면 그 답이 뒤집힐 때만 알린다
•
다른 엔티티의 Transform 을 들고 있지 않는다. 필요할 때 프레임워크에 물어 좌표(값) 만 읽는다. 값은 낡지 않는다
•
적을 "플레이어를 쫓는다"가 아니라 "이 좌표로 간다"로 만들어 두면, 쫓는 대상을 바꾸는 일이 좌표를 얻는 한 줄이 된다
•
게임의 소리는 전부 주소와 사운드 그룹으로 낸다. 프리팹에 AudioSource 를 붙여 직접 틀지 않는다. 그룹은 동시 재생 수를 정하고 믹서에 연결한다
•
반복음은 번호로 쥐고 있다가 끈다. 끄는 자리는 이미 수명이 관리되는 것(연출을 끄는 메서드)에 묶는다
•
소리와 화면은 게임의 겉이다. 겉의 실패가 게임을 멈추게 하지 않는다
직접 해 보기
1.
AllyTable.Cost 를 10, Duration 을 3f 로 바꿔 양을 자주 짧게 부르는 게임으로 만들어 보세요.
2.
AllySquad.NotifyAvailability 의 if (canSummon == mNotifiedCanSummon) return; 을 지우고, HUDForm.OnAllyAvailabilityChanged 에 Log.Info("ally: {0}", ne.CanSummon); 을 넣어 보세요. 한 판 동안 로그가 몇 번 찍히나요? 줄을 되돌리면 몇 번으로 줄어드나요?
3.
지금 양은 소환한 순간의 플레이어 위치로 한 번 갑니다. 0.5초마다 플레이어를 따라오게 바꾸려면 어디를 고쳐야 할까요? Ally 에 OnUpdate 와 타이머가 필요해집니다. 5장의 Enemy.TickChase 가 본보기입니다. 바꾼 뒤 게임이 더 쉬워지는지 어려워지는지도 느껴 보세요.
4.
Soundeffects 그룹의 에이전트 수를 Sound 컴포넌트의 Inspector 에서 1 로 줄이고 적 여러 마리를 한꺼번에 쏴 보세요. 소리가 어떻게 달라지나요?
5.
(조금 어려움) 게임오버가 되면 배경음악이 서서히 멈추고, 다시 시작하면 처음부터 흐르게 바꿔 보세요(9장을 마친 뒤에 합니다). GameSound.PlayMusic 이 PlaySound 가 돌려주는 번호를 기억하게 하고 StopMusic() 을 추가합니다. "한 번만 튼다"를 지키던 sMusicOwner 는 어떻게 바뀌어야 할까요? SoundComponent.StopSound(번호, 초) 의 둘째 인자로 서서히 줄어드는 시간을 줄 수 있습니다. 음악에도 열고 닫는 짝이 생깁니다.
6.
(조금 어려움) 양을 소환할 때 소리가 나게 해 보세요. AllySquad.TrySummon 에 GameSound.PlaySfx 를 넣어도 되지만, 그보다 AllySummonedEventArgs 를 만들어 쏘고 소리를 내는 쪽이 듣게 하면 어떨까요? 두 방법의 장단점을 비교해 보세요.
더 읽기 —
다음 장 — : 4장부터 미뤄 온 것을 끝냅니다. 플레이어가 죽으면 판을 정리하고, 처음부터 다시 시작합니다.








