3장이 끝났을 때 화면에는 아무도 없었습니다. 캐릭터를 고르면 고른 쪽도, 고르지 않은 쪽도 사라졌습니다. 이 장에서는 고른 캐릭터가 조작할 수 있는 플레이어가 되어 돌아옵니다.
플레이어 스크립트는 게임에서 가장 빨리 지저분해지는 코드입니다. 처음에는 "키를 읽어서 움직인다" 한 줄이던 Update 에, 죽었을 때는 움직이면 안 되니 if (isDead) return; 이 붙고, 일시정지 중에도 안 되니 if (isPaused) return; 이 붙고, 죽는 연출 중에는 타이머를 돌려야 하니 if (isDying) { … } 가 붙습니다. 2장에서 GameManager 에 bool 이 쌓이던 것과 똑같은 일이 캐릭터 안에서 다시 일어납니다.
3장 끝에서 선택 캐릭터의 "쓰러졌다 사라지기"를 상태 두 개로 만들어 봤습니다. 이번에는 같은 도구로 플레이어를 살아 있음 / 일시정지 / 죽는 중 세 상태로 나눕니다.
이 장의 결과. 고른 캐릭터가 WASD 로 걷고 마우스 쪽을 바라보며, 카메라가 따라간다
이 장에서 하는 일
1.
체력을 가진 엔티티의 공통 부모를 만든다 (TargetableObject)
2.
고른 캐릭터를 플레이어로 띄운다
3.
카메라가 플레이어를 따라가게 한다
4.
WASD 로 움직이고 마우스 쪽을 바라보게 한다
5.
플레이어의 일생을 상태 셋으로 나눈다
4.1 같은 프리팹, 다른 역할
선택 화면에 서 있던 Girl 과 이제 조작할 Girl 은 같은 프리팹입니다. 달라지는 것은 ShowEntity 의 둘째 인자, 로직 타입 하나뿐입니다.
// 3장 — 선택 화면에 세울 때
ShowEntity(id, typeof(PlayerSelectLogic), "Girl", "Player", CharacterSelectData.Create(…));
// 이 장 — 플레이어로 띄울 때
ShowEntity(id, typeof(Player), "Girl", "Player", PlayerData.Create(…));
C#
복사
3장에서 "프리팹에는 로직 스크립트를 붙이지 않는다"고 했던 이유가 여기서 완성됩니다. 프리팹은 생김새, 로직은 역할입니다. 생김새 하나에 역할을 여러 개 입힐 수 있습니다. 5장에서 만들 적도 마찬가지입니다. 프리팹은 다섯 종류지만 로직은 Enemy 하나입니다.
풀도 이 구분을 따릅니다. 풀은 주소별로 하나씩 있어서, 선택용 Girl 과 플레이어 Girl 은 같은 풀을 씁니다. 풀에서 꺼낸 인스턴스에 붙어 있는 로직이 이번에 요청한 타입과 다르면, 프레임워크는 기존 스크립트를 떼고 새 타입을 붙인 뒤 OnInit 을 부릅니다. 타입이 같으면 아무것도 하지 않고 바로 OnShow 로 갑니다.
4.2 체력을 가진 것들의 공통 부모
플레이어를 만들기 전에 한 층을 더 깝니다. 이 게임에서 맞고, 체력이 줄고, 죽는 것은 플레이어만이 아닙니다. 다음 장의 적도 똑같습니다. 그 공통 부분을 먼저 만들어 두면 플레이어와 적은 "자기만의 것"만 쓰면 됩니다.
graph TD
A["EntityLogic<br>(프레임워크)"] --> B["EntityLogicBase<br>3장 — 주문서 반납, SafeHide"]
B --> C["TargetableObject<br>이 장 — 체력, 피격, 사망"]
B --> D["PlayerSelectLogic<br>3장"]
C --> E["Player<br>이 장"]
C --> F["Enemy<br>5장"]Mermaid
복사
주문서도 같은 모양으로 층을 쌓습니다.
주문서 — TargetableObjectData 와 PlayerData
// Assets/GameMain/Entity/EntityData/TargetableObjectData.cs
namespace ToyBoxNightmare
{
/// <summary>
/// 체력을 가지는 엔티티의 데이터 베이스.
/// 피격/사망 판정은 전부 여기 담긴 <see cref="HitPoints"/> 로 이뤄진다.
/// </summary>
public abstract class TargetableObjectData : EntityData
{
/// <summary>
/// 취득 직후에는 0 이다. <b>파생 클래스의 <c>Create</c> 가 자신의 MaxHitPoints 로
/// 반드시 채워야 한다</b> — 빠뜨리면 스폰 직후 IsDead 가 true 가 된다.
/// </summary>
private int mHitPoints = 0;
/// <summary>
/// 현재 체력. 0 이하면 죽은 것으로 본다.
/// 과다 피해를 입으면 음수가 될 수 있다.
/// </summary>
public int HitPoints
{
get
{
return mHitPoints;
}
set
{
mHitPoints = value;
}
}
/// <summary>파생 클래스가 자신의 스탯에서 돌려주는 최대 체력.</summary>
public abstract int MaxHitPoints
{
get;
}
/// <summary>
/// 체력 비율(0~1). MaxHitPoints 가 0 인 데이터가 들어올 수 있어 0 나눗셈을 막는다.
/// </summary>
public float HitPointRatio
{
get
{
return MaxHitPoints > 0 ? (float)HitPoints / MaxHitPoints : 0.0f;
}
}
public override void Clear()
{
base.Clear();
mHitPoints = 0;
}
}
}
C#
복사
// Assets/GameMain/Entity/EntityData/PlayerData.cs
using GameFramework;
using UnityEngine;
namespace ToyBoxNightmare
{
/// <summary>
/// 플레이어 엔티티 데이터. SurvivalGame 이 스폰할 때 만들어 <see cref="Player"/> 의 OnShow 로 넘긴다.
/// </summary>
public class PlayerData : TargetableObjectData
{
/// <summary>
/// 이동 속도 하한. 0 이하가 되면 플레이어가 아예 움직이지 못하므로 세터에서 잘라낸다.
/// </summary>
private const float MinMoveSpeed = 1f;
// 풀에서 나온 객체는 필드 초기화식이 다시 돌지 않는다. Clear 가 이 상수로 되돌린다.
private const int DefaultMaxHP = 100;
private const float DefaultMoveSpeed = 5f;
private int mMaxHP = DefaultMaxHP;
private float mMoveSpeed = DefaultMoveSpeed;
public static PlayerData Create(int entityId, int typeId)
{
PlayerData data = ReferencePool.Acquire<PlayerData>();
data.Fill(entityId, typeId);
// TargetableObjectData 는 HitPoints 를 0 으로 두므로 여기서 반드시 채운다.
// 빠뜨리면 스폰 직후 IsDead 가 true 다.
data.HitPoints = data.mMaxHP;
return data;
}
public override int MaxHitPoints => mMaxHP;
/// <summary>초당 이동 거리. 세터는 <see cref="MinMoveSpeed"/> 아래로 내려가지 않게 막는다.</summary>
public float MoveSpeed
{
get => mMoveSpeed;
set => mMoveSpeed = Mathf.Max(MinMoveSpeed, value);
}
public override void Clear()
{
base.Clear();
mMaxHP = DefaultMaxHP;
mMoveSpeed = DefaultMoveSpeed;
}
}
}
C#
복사
코드 읽기
3장의 CharacterSelectData 와 틀이 같습니다. Create 로 꺼내 채우고, Clear 에서 되돌립니다. 새로 볼 것은 두 가지입니다.
Clear() 가 0 이 아니라 기본값으로 되돌립니다. private int mMaxHP = DefaultMaxHP; 라는 초기화식은 객체가 처음 new 될 때 한 번만 실행됩니다. 풀에서 다시 꺼낸 객체에는 실행되지 않습니다. 그래서 "기본값이 100"이라는 사실을 초기화식에만 적어 두면, 누군가 이 값을 바꾼 채 반납했을 때 다음 사람이 바뀐 값을 받습니다. 기본값을 상수로 빼고 초기화식과 Clear 양쪽에서 같은 상수를 쓰면 둘이 어긋날 일이 없습니다.
data.HitPoints = data.mMaxHP; — 주석에 적힌 대로 이 줄을 빠뜨리면 체력 0 으로 태어나 스폰 즉시 "죽은 것"으로 취급됩니다. 부모 클래스가 "자식이 반드시 채워야 하는 값"을 가질 때는 이렇게 Create 안에서 채워, 밖에서 깜빡할 수 없게 만듭니다.
로직 — TargetableObject
// Assets/GameMain/Entity/EntityLogic/TargetableObject.cs
using UnityGameFramework.Runtime;
namespace ToyBoxNightmare
{
/// <summary>
/// 체력을 가지고 피격/사망 처리를 하는 엔티티의 공통 베이스.
/// </summary>
public abstract class TargetableObject : EntityLogicBase
{
private TargetableObjectData mTargetableObjectData = null;
public bool IsDead
{
get
{
return mTargetableObjectData != null && mTargetableObjectData.HitPoints <= 0;
}
}
/// <summary>
/// 현재 체력 비율(0~1). 데이터가 없으면 0.
/// </summary>
protected float HitPointRatio
{
get
{
return mTargetableObjectData != null ? mTargetableObjectData.HitPointRatio : 0f;
}
}
public void ApplyDamage(Entity attacker, int damageHitPoints)
{
// 아직 OnShow 를 거치지 않았거나 잘못된 userData 로 뜬 인스턴스.
if (mTargetableObjectData == null) return;
// 이미 죽은 대상에 대한 추가 피격은 무시한다.
// 사망 연출이 끝나고 Hide 될 때까지 엔티티가 살아 있으므로,
// 가드가 없으면 그 사이의 피격이 OnDead 를 여러 번 호출해 Hide 가 중복된다.
if (IsDead) return;
// 회복 경로는 없다. 음수가 들어오면 체력이 도로 늘어 사망 판정이 영영 안 난다.
GameAssert.IsTrue(damageHitPoints >= 0,
"ApplyDamage 에 음수 데미지가 들어왔다. 회복은 이 경로로 처리하지 않는다.");
float ratioBeforeDamage = mTargetableObjectData.HitPointRatio;
mTargetableObjectData.HitPoints -= damageHitPoints;
float ratioAfterDamage = mTargetableObjectData.HitPointRatio;
// 사망 여부와 무관하게 매 피격 호출한다.
OnDamaged(attacker, damageHitPoints);
if (ratioBeforeDamage > ratioAfterDamage)
{
OnHitPointChanged(ratioBeforeDamage, ratioAfterDamage);
}
if (mTargetableObjectData.HitPoints <= 0)
{
OnDead(attacker);
}
}
/// <summary>피격될 때마다 호출된다. 타격 이펙트/사운드를 여기서 낸다.</summary>
protected virtual void OnDamaged(Entity attacker, int damageHitPoints)
{
}
/// <summary>체력 비율이 실제로 줄었을 때 호출된다. HUD 갱신 이벤트를 여기서 발행한다.</summary>
protected virtual void OnHitPointChanged(float fromRatio, float toRatio)
{
}
protected internal override void OnShow(object userData)
{
base.OnShow(userData);
mTargetableObjectData = userData as TargetableObjectData;
if (mTargetableObjectData == null)
{
Log.Error("Targetable object data is invalid.");
return;
}
}
/// <summary>
/// <b><see cref="mTargetableObjectData"/> 를 반드시 여기서 놓아야 한다.</b>
/// 데이터는 EntityLogicBase.OnHide 가 맨 끝에서 ReferencePool 로 반납한다.
/// 반납은 참조를 null 로 만들어 주지 않으므로, 여기서 안 지우면 이 필드는
/// 풀에 들어간 객체를 계속 가리킨다.
/// base 보다 <b>먼저</b> 지운다 — 반납이 base 체인 끝에서 일어나기 때문이다.
/// </summary>
protected internal override void OnHide(bool isShutdown, object userData)
{
mTargetableObjectData = null;
base.OnHide(isShutdown, userData);
}
protected virtual void OnDead(Entity attacker)
{
SafeHide();
}
}
}
C#
복사
예제 프로젝트의 TargetableObject.cs 에는 이 아래에 OnTriggerEnter 로 시작하는 충돌 처리 뼈대가 더 붙어 있습니다. 원작 프레임워크 예제에서 넘어온 것으로, 이 게임에서는 쓰지 않아 즉시 return 하도록 막혀 있습니다. 책에서는 생략합니다.
코드 읽기
이 클래스가 하는 일은 ApplyDamage 하나입니다. 그리고 그 안에서 자식에게 세 번 말을 겁니다.
graph LR
A["ApplyDamage(공격자, 피해량)"] --> B["체력을 깎는다"]
B --> C["OnDamaged<br>맞을 때마다"]
C --> D{"체력 비율이<br>줄었나"}
D -->|"예"| E["OnHitPointChanged"]
D --> F{"체력이<br>0 이하인가"}
E --> F
F -->|"예"| G["OnDead"]Mermaid
복사
"언제 불리는가"는 부모가 정하고, "불렸을 때 무엇을 하는가"는 자식이 정합니다. 플레이어는 OnDamaged 에서 비명을 지르고(8장), OnHitPointChanged 에서 체력 바를 깎고(7장), OnDead 에서 쓰러집니다. 적은 같은 세 곳에서 전혀 다른 일을 합니다. 하지만 체력 계산과 호출 순서는 한 곳에만 있으므로, "죽은 적이 또 맞아서 두 번 죽었다" 같은 버그는 여기 한 곳만 지키면 됩니다.
if (IsDead) return; 이 그 방어입니다. 플레이어가 쓰러지는 1초 동안에도 엔티티는 화면에 살아 있어서 계속 맞을 수 있습니다. 이 줄이 없으면 그때마다 OnDead 가 다시 불립니다.
OnHide 에서 mTargetableObjectData = null — 3장에서 SurvivalGame 이 치운 엔티티의 참조를 비웠던 것과 같은 이유입니다. 주문서는 잠시 뒤 EntityLogicBase.OnHide 가 풀에 돌려줍니다. 돌려준 뒤에도 이 변수가 그 객체를 가리키고 있으면, 그 객체를 다른 엔티티가 꺼내 쓰기 시작했을 때 남의 체력을 읽게 됩니다. 풀에 돌아갈 것을 가리키는 변수는 돌려주기 전에 비웁니다. 그래서 base.OnHide 보다 먼저입니다.
기본 OnDead 는 그냥 SafeHide() 입니다. 죽으면 바로 사라집니다. 연출이 필요한 자식은 이것을 덮어씁니다.
4.3 플레이어 띄우기
이제 플레이어입니다. 한꺼번에 다 쓰지 않고, 먼저 화면에 나타나기만 하게 합니다.
// Assets/GameMain/Entity/EntityLogic/Player.cs
using UnityEngine;
using UnityGameFramework.Runtime;
namespace ToyBoxNightmare
{
/// <summary>
/// 플레이어 엔티티 로직.
/// </summary>
[RequireComponent(typeof(Rigidbody))]
public class Player : TargetableObject
{
private PlayerData mPlayerData = null;
private Rigidbody mRigidbody = null;
private Animator mAnimator = null;
private CapsuleCollider mCollider = null;
// ─── EntityLogic 생명주기 ───
protected internal override void OnInit(object userData)
{
base.OnInit(userData);
mRigidbody = GetComponent<Rigidbody>();
// includeInactive: true 가 필수다. 3장 PlayerSelectLogic 과 같은 이유다.
mAnimator = GetComponentInChildren<Animator>(true);
mCollider = GetComponent<CapsuleCollider>();
WarnOnMissingComponents();
}
/// <summary>
/// 캐시가 비어도 사용처마다 null 가드가 있어 예외는 나지 않는다. 대신 이동이나
/// 애니메이션이 <b>조용히</b> 죽어 원인을 찾기 어려우므로 여기서 한 번 알린다.
/// </summary>
private void WarnOnMissingComponents()
{
if (mRigidbody == null)
{
Log.Error("Player '{0}': Rigidbody 가 없어 이동이 동작하지 않는다.", Name);
}
if (mAnimator == null)
{
Log.Warning("Player '{0}': Animator 를 찾지 못했다. 이동/사망 애니메이션이 재생되지 않는다.", Name);
}
if (mCollider == null)
{
Log.Warning("Player '{0}': CapsuleCollider 가 없다. 중심점이 트랜스폼 원점으로 대체된다.", Name);
}
}
protected internal override void OnShow(object userData)
{
base.OnShow(userData);
// 상태 리셋은 userData 검사보다 <b>먼저</b> 한다.
// 데이터가 없어도 인스턴스는 깨끗해야 한다.
ResetRuntimeState();
mPlayerData = userData as PlayerData;
if (mPlayerData == null)
{
Log.Error("Player data is invalid.");
return;
}
CachedTransform.position = mPlayerData.Position;
CachedTransform.rotation = mPlayerData.Rotation;
}
/// <summary>
/// 풀에서 재사용되므로 이전 판의 상태를 반드시 리셋한다.
/// </summary>
private void ResetRuntimeState()
{
if (mCollider != null)
{
mCollider.enabled = true;
}
if (mAnimator != null)
{
mAnimator.Rebind();
mAnimator.Update(0f);
}
}
protected internal override void OnHide(bool isShutdown, object userData)
{
// 데이터는 base 체인 끝(EntityLogicBase)에서 ReferencePool 로 반납된다.
mPlayerData = null;
base.OnHide(isShutdown, userData);
}
}
}
C#
복사
코드 읽기
구조는 3장의 PlayerSelectLogic 첫 버전과 같습니다. OnInit 에서 부품을 찾아 두고, OnShow 에서 주문서를 읽고, OnHide 에서 놓습니다.
플레이어에는 Instance 같은 정적 손잡이를 두지 않습니다. 다른 Unity 예제에서는 Player.Instance 를 만들어 어디서든 플레이어를 찾게 해 두는 일이 흔합니다. 하지만 엔티티는 풀에서 재사용됩니다. 손잡이를 두면 "지금 걸려 있는 것이 정말 지금의 플레이어인가"를 OnShow 와 OnHide 에서 손으로 맞춰 줘야 하고, 한 번 어긋나면 이미 풀로 돌아간 인스턴스를 가리키게 됩니다.
지금 화면에 어떤 엔티티가 떠 있는지는 프레임워크가 이미 알고 있습니다. 같은 사실을 따로 복사해 두지 않고, 필요할 때마다 프레임워크에 묻습니다. 그 창구는 4.4 절에서 카메라를 만들 때 함께 만듭니다.
[RequireComponent(typeof(Rigidbody))] 는 이 스크립트가 붙을 때 Rigidbody 가 없으면 Unity 가 자동으로 붙이게 합니다. 로직이 실행 중에 AddComponent 로 붙을 때도 적용됩니다.
주문 넣기 — SurvivalGame
SurvivalGame.cs 에 플레이어를 가리킬 변수와 공개 속성을 추가합니다.
// ─── 런타임 상태 ───
private Player mPlayer = null;
private PlayerSelectLogic mGirlSelect = null;
private PlayerSelectLogic mBoySelect = null;
C#
복사
/// <summary>
/// 현재 플레이어. 선택 전이거나 이미 회수된 뒤에는 null.
/// 엔티티는 풀에서 재사용되므로 참조가 살아 있어도 Available 을 확인해야 한다.
/// </summary>
public Player Player => (mPlayer != null && mPlayer.Available) ? mPlayer : null;
C#
복사
ResetRoundState 에 mPlayer = null; 한 줄을 추가하고, 스폰 메서드를 씁니다.
private void SpawnPlayer(string characterKey)
{
EntityComponent entityComponent = GameEntry.GetComponent<EntityComponent>();
if (entityComponent == null)
{
Log.Error("EntityComponent 를 찾을 수 없어 플레이어 '{0}' 를 스폰하지 못했다.", characterKey);
return;
}
int id = EntitySerialId.Next();
entityComponent.ShowEntity(
id,
typeof(Player),
characterKey,
PlayerEntityGroup,
PlayerData.Create(id, DefaultEntityTypeId));
}
C#
복사
3장의 SpawnSelectCharacter 와 비교하면 로직 타입과 주문서 두 곳만 다릅니다. 이것을 선택 이벤트 핸들러의 끝에서 부릅니다.
private void OnCharacterSelected(object sender, GameEventArgs e)
{
CharacterSelectedEventArgs ne = e as CharacterSelectedEventArgs;
if (ne == null)
{
GameAssert.Unreachable("CharacterSelected 핸들러에 다른 타입의 이벤트 인자가 들어왔다.");
return;
}
// 한 번만 처리한다.
UnsubscribeCharacterSelected();
Log.Info("Character selected: {0}", ne.CharacterKey);
DismissSelectCharacters(ne.CharacterKey);
SpawnPlayer(ne.CharacterKey);
}
C#
복사
플레이어가 다 뜨면 기억해 둡니다. 3장에서 만든 OnShowEntitySuccess 에 갈래를 하나 더합니다.
protected override void OnShowEntitySuccess(object sender, GameEventArgs e)
{
ShowEntitySuccessEventArgs ne = e as ShowEntitySuccessEventArgs;
if (ne == null)
{
GameAssert.Unreachable("ShowEntitySuccess 핸들러에 다른 타입의 이벤트 인자가 들어왔다.");
return;
}
if (ne.EntityLogicType == typeof(PlayerSelectLogic))
{
CacheSelectCharacter(ne.Entity);
return;
}
if (ne.EntityLogicType == typeof(Player))
{
CachePlayer(ne.Entity);
}
}
private void CachePlayer(Entity entity)
{
Player player = entity.Logic as Player;
if (player == null)
{
Log.Error("Player 로 스폰했는데 로직이 그 타입이 아니다: {0}", entity.EntityAssetName);
return;
}
mPlayer = player;
Log.Info("Player spawned: {0}", entity.EntityAssetName);
}
C#
복사
플레이어가 뜨지 못하면
3장에서는 스폰 실패를 GameBase 의 기본 처리(주문서 반납 + 경고)에 맡겼습니다. 선택 캐릭터 하나가 안 뜨는 것은 그 정도로 충분했습니다. 하지만 플레이어가 안 뜨면 이야기가 다릅니다. 이제 SurvivalGame 에서 직접 처리합니다.
/// <summary>
/// <b>base.OnShowEntityFailure 를 부르지 않는다.</b> 베이스도 스폰 데이터를 반납하므로
/// 같이 부르면 이중 Release 가 되어 코어가 예외를 던진다. 대신 반납만 여기서 직접 한다.
/// </summary>
protected override void OnShowEntityFailure(object sender, GameEventArgs e)
{
ShowEntityFailureEventArgs ne = e as ShowEntityFailureEventArgs;
if (ne == null)
{
GameAssert.Unreachable("ShowEntityFailure 핸들러에 다른 타입의 이벤트 인자가 들어왔다.");
return;
}
// 스폰이 실패하면 OnShow 가 불리지 않아 아무도 소유권을 잡지 않는다.
// 어느 분기로 빠지든 반드시 반납되도록 분기보다 먼저 처리한다.
ReleaseSpawnData(ne);
// 플레이어 실패만 Warning 이 아니라 Error 로 따로 다룬다.
if (ne.EntityLogicType == typeof(Player))
{
FailRoundOnPlayerSpawnFailure(ne);
return;
}
Log.Warning("Show entity failure: {0} ({1})", ne.ErrorMessage, ne.EntityAssetName);
}
/// <summary>
/// 플레이어 스폰 실패 처리. 판을 게임오버로 끝낸다.
///
/// <b>이 처리를 빼면 게임이 조용히 영구 정지한다.</b> mPlayer 는 영원히 null 로 남고
/// Update 가 Player == null 에서 매 프레임 조기 리턴하므로, 시계도 스포너도
/// 게임오버도 돌지 않는다. 화면은 멀쩡한데 아무 일도 일어나지 않는 상태다.
/// </summary>
private void FailRoundOnPlayerSpawnFailure(ShowEntityFailureEventArgs ne)
{
Log.Error("플레이어 '{0}' 스폰에 실패했다. 판을 진행할 수 없어 게임오버로 끝낸다. 원인: {1}",
ne.EntityAssetName, ne.ErrorMessage);
GameOver = true;
}
C#
복사
마지막으로 Update 에 한 줄을 넣습니다. 캐릭터를 고르기 전에는 시계가 가지 않게 합니다.
public override void Update(float elapseSeconds, float realElapseSeconds)
{
base.Update(elapseSeconds, realElapseSeconds);
if (GameOver) return;
// 캐릭터 선택 중에는 시계를 세워 둔다. 선택 시점부터가 '게임 시작'이다.
if (Player == null) return;
mGameTime += elapseSeconds;
}
C#
복사
코드 읽기
Player 속성이 mPlayer 를 그대로 돌려주지 않습니다. mPlayer.Available 까지 확인합니다. Available 은 프레임워크가 관리하는 값으로, 엔티티가 화면에 나와 있는 동안만 true 입니다. 플레이어가 죽어서 풀로 돌아간 뒤에도 mPlayer 변수는 그 오브젝트를 가리키고 있습니다(파괴된 것이 아니니 null 도 아닙니다). 그래서 "가리키는 것이 있는가"와 "그것이 지금 살아서 나와 있는가"를 함께 묻습니다. 이 속성을 쓰는 쪽은 null 인지만 보면 됩니다.
실패 처리에서 "조용히 멈추는 게임"을 막습니다. FailRoundOnPlayerSpawnFailure 의 주석을 읽어 보세요. 플레이어가 뜨지 못했는데 아무 처리도 하지 않으면, 에러 한 줄 없이 화면만 멈춘 상태가 됩니다. 가장 찾기 어려운 종류의 버그입니다. 실패했을 때 시끄럽게 실패하게 만들어 두면 원인이 로그에 남습니다. GameOver 를 세워 두면 9장에서 만들 게임오버 프로시저가 판을 정리하고 다시 시작해 줍니다.
base.OnShowEntityFailure 를 부르지 않는 이유는 주석 그대로입니다. 부모도 ReleaseSpawnData 를 부르기 때문에, 둘 다 부르면 주문서를 두 번 돌려주게 됩니다. 오버라이드할 때 base 를 부를지 말지는 "부모가 하는 일을 내가 대신 하는가, 추가로 하는가"로 정합니다. 여기서는 대신 합니다.
실행해 보기
Play → Girl 을 클릭합니다.
Character selected: Girl
Player spawned: Girl
Plain Text
복사
고른 캐릭터가 화면 한가운데(원점)에 다시 나타납니다. 아직 움직이지 않습니다. Hierarchy 에서 GameFramework → Entity → Entity Instances → Entity Group - Player 를 펼쳐 봅니다.
Entity Group - Player
├ Girl(Clone) ← 회색. 선택 화면에 서 있던 인스턴스 (풀에서 쉬는 중)
├ Boy(Clone) ← 회색. 쓰러졌다 사라진 인스턴스 (풀에서 쉬는 중)
└ Girl(Clone) ← 흰색. 지금의 플레이어
Plain Text
복사
Girl(Clone) 이 둘입니다. 4.1 절에서 "같은 풀을 쓴다"고 했는데 왜 하나를 재사용하지 않고 새로 만들었을까요? SafeHide 는 엔티티를 즉시 풀에 넣지 않고 그 프레임의 끝에 넣습니다. 우리는 DismissSelectCharacters 바로 다음 줄에서 SpawnPlayer 를 불렀으므로, 그 순간 풀은 아직 비어 있었습니다. 그래서 프레임워크가 하나를 더 만들었습니다.
다음 판부터는 새로 만들지 않고 이 둘을 꺼내 씁니다. 어느 쪽이 어느 역할을 맡을지는 정해져 있지 않아서, 지난 판에 플레이어였던 인스턴스가 이번에는 선택 캐릭터로 나올 수도 있습니다. 그때 4.1 절에서 말한 "스크립트 갈아 끼우기"가 일어납니다.
흰색 Girl(Clone) 을 클릭해 Inspector 를 봅니다.
플레이어가 된 Girl(Clone). 3장에서 Player Select Logic 이 있던 자리에 Player 가 붙어 있다
아래는 완성된 예제를 한참 플레이하다가 멈추고 에디터 전체를 찍은 것입니다.
플레이 중의 에디터. ① Entity Group - Player 아래의 플레이어 ② 그 Inspector — Entity 와 Player ③ 화면 속의 플레이어
위의 트리와 달리 ① 에는 Girl(Clone) 이 하나뿐입니다. 판이 시작되고 1분 넘게 지난 뒤라서, 풀에서 쉬던 두 인스턴스가 1장에서 적어 둔 Instance Expire Time(60초) 을 넘겨 해제됐기 때문입니다. 오래 안 쓰는 인스턴스는 프레임워크가 알아서 내려놓습니다.
3장의 화면과 비교해 보세요. 같은 프리팹인데 맨 아래 스크립트가 Player Select Logic 이 아니라 Player 입니다.
4.4 카메라가 따라가게 하기
지금 카메라는 선택 화면을 비추던 자리에 고정돼 있습니다. 플레이어를 움직이기 전에 카메라부터 따라오게 합니다.
카메라는 엔티티가 아닙니다. 씬에 처음부터 끝까지 하나 있습니다. 떴다 사라지는 것이 아니라 판이 바뀌어도 계속 살아 있는 전역 시스템입니다. 이런 것은 프레임워크 컴포넌트로 만듭니다. 1장에서 본 EntityComponent, EventComponent 와 같은 종류입니다. GameFrameworkComponent 를 상속하면 켜질 때 프레임워크에 스스로 등록되어, 다른 코드가 GameEntry.GetComponent<PlayerCameraFollow>() 로 닿을 수 있습니다.
플레이어를 찾는 창구 — EntityFinder
카메라가 따라가려면 "지금 플레이어가 어디 있는가"를 알아야 합니다. 4.3 절에서 미뤄 둔 창구를 여기서 만듭니다. Assets/GameMain/Utility 폴더에 둡니다.
// Assets/GameMain/Utility/EntityFinder.cs
using System.Collections.Generic;
using GameFramework.Entity;
using UnityGameFramework.Runtime;
namespace ToyBoxNightmare
{
/// <summary>
/// 지금 떠 있는 엔티티 중에서 특정 대상을 찾는다.
///
/// 정적 싱글턴(<c>Player.Instance</c> 같은 것)을 두지 않기 위한 창구다. 싱글턴은 엔티티가
/// 풀에서 재사용될 때마다 "누가 지금 진짜인가" 를 손으로 맞춰 줘야 하고, 한 번 어긋나면
/// 회수된 인스턴스를 가리킨다. 여기서는 그 답을 매번 <see cref="EntityComponent"/> 에 묻는다 —
/// 프레임워크가 이미 알고 있는 사실을 따로 복사해 두지 않는다.
///
/// 그룹 하나에 엔티티가 한두 개뿐인 대상만 찾는다. 적처럼 수십 개가 있는
/// 그룹을 매 프레임 훑는 용도가 아니다.
/// </summary>
public static class EntityFinder
{
/// <summary>조회용 재사용 버퍼. 호출마다 배열을 새로 만들지 않기 위해 둔다.</summary>
private static readonly List<IEntity> sBuffer = new List<IEntity>();
/// <summary>
/// 지금 떠 있는 플레이어. 캐릭터를 고르기 전이거나 회수된 뒤면 null 이다.
/// 죽어서 쓰러지는 중인 플레이어는 아직 떠 있으므로 그대로 돌려준다(<c>IsDead</c> 로 구분한다).
/// </summary>
public static Player FindPlayer()
{
return Find<Player>(SurvivalGame.PlayerEntityGroup);
}
private static T Find<T>(string entityGroupName) where T : EntityLogic
{
EntityComponent entityComponent = GameEntry.GetComponent<EntityComponent>();
if (entityComponent == null) return null;
IEntityGroup entityGroup = entityComponent.GetEntityGroup(entityGroupName);
if (entityGroup == null) return null;
sBuffer.Clear();
entityGroup.GetAllEntities(sBuffer);
T found = null;
for (int i = 0; i < sBuffer.Count; i++)
{
Entity entity = sBuffer[i] as Entity;
if (entity == null) continue;
// 같은 그룹에 다른 로직이 섞여 있을 수 있다(플레이어 그룹에는 선택 화면 캐릭터도 있다).
T logic = entity.Logic as T;
if (logic == null || !logic.Available) continue;
found = logic;
break;
}
// 엔티티 참조를 버퍼에 남겨 두지 않는다.
sBuffer.Clear();
return found;
}
}
}
C#
복사
SurvivalGame.cs 에서 PlayerEntityGroup 을 private 에서 public 으로 바꿉니다. EntityFinder 가 같은 그룹 이름을 써야 하기 때문입니다.
/// <summary>플레이어·선택 캐릭터가 들어가는 엔티티 그룹. GameFramework.prefab 의 mEntityGroups 와 일치해야 한다.</summary>
public const string PlayerEntityGroup = "Player";
C#
복사
GetEntityGroup 은 1장에서 인스펙터로 확인한 엔티티 그룹을 이름으로 꺼냅니다. GetAllEntities 는 그 그룹에서 지금 화면에 나와 있는 엔티티만 버퍼에 담아 줍니다. 풀에서 쉬고 있는 인스턴스는 들어오지 않습니다. 그래서 "이미 회수된 플레이어를 가리키는" 사고가 구조적으로 일어나지 않습니다.
entity.Logic as T 로 타입을 한 번 더 거릅니다. Player 그룹에는 플레이어만 있는 것이 아닙니다. 3장의 선택 화면 캐릭터(PlayerSelectLogic)도 같은 그룹에 뜹니다. 그룹은 "어느 풀을 쓰는가"이고, 로직 타입이 "지금 무슨 역할인가"입니다.
sBuffer 는 한 번 만들어 두고 계속 씁니다. 카메라가 이 메서드를 매 프레임 부르는데, 부를 때마다 리스트를 새로 만들면 쓰레기가 쌓입니다. 다 쓴 뒤 Clear() 로 비우는 것은 엔티티 참조를 버퍼에 남겨 두지 않기 위해서입니다.
카메라 — PlayerCameraFollow
Assets/GameMain/Camera 폴더를 만듭니다.
// Assets/GameMain/Camera/PlayerCameraFollow.cs
using UnityEngine;
using UnityGameFramework.Runtime;
namespace ToyBoxNightmare
{
/// <summary>
/// 플레이어를 따라다니는 카메라. MainCamera 에 붙인다.
///
/// <b>프레임워크 컴포넌트다</b>(<see cref="GameFrameworkComponent"/>). 판이 바뀌어도 살아 있는
/// 전역 시스템은 이렇게 프레임워크에 등록한다. 따라갈 플레이어도 정적 싱글턴이 아니라
/// 프레임워크에 물어서 찾는다(<see cref="EntityFinder.FindPlayer"/>).
///
/// 플레이어가 아직 없는 캐릭터 선택 단계에서는 씬에 배치된 선택 앵글에 그대로 머문다.
/// 플레이어가 스폰되면 위치와 회전을 함께 보간해 게임 앵글로 넘어간다.
/// </summary>
public class PlayerCameraFollow : GameFrameworkComponent
{
// offset 이 이보다 짧으면 방향을 구할 수 없다. 길이 0 벡터를 LookRotation 에
// 넘기면 Unity 가 에러를 뱉는다.
private const float MinOffsetSqrMagnitude = 0.0001f;
[Tooltip("플레이어 기준 카메라 위치. 원본 게임 앵글은 (0, 15, -22).")]
[SerializeField] private Vector3 offset = new Vector3(0f, 15f, -22f);
[Tooltip("추종 보간 속도. 클수록 빠르게 따라붙는다. 원본은 5.")]
[SerializeField] private float smoothing = 5f;
// offset 의 반대 방향을 바라보면 항상 플레이어를 향한다.
// 회전을 따로 하드코딩하지 않으므로 offset 만 바꾸면 각도가 함께 맞는다.
private Quaternion mTargetRotation = Quaternion.identity;
// 씬에 배치된 원본 위치/회전 = 캐릭터 선택 앵글. 플레이어가 사라지면 여기로 돌아간다.
private Vector3 mSelectPosition = Vector3.zero;
private Quaternion mSelectRotation = Quaternion.identity;
private void Start()
{
mSelectPosition = transform.position;
mSelectRotation = transform.rotation;
RecalculateRotation();
WarnIfMisconfigured();
}
private void OnValidate()
{
RecalculateRotation();
}
private void LateUpdate()
{
// 프레임 독립 보간 계수. Lerp/Slerp 가 내부에서 [0, 1] 로 클램프한다.
float smoothStep = smoothing * Time.deltaTime;
Transform followTarget = FindFollowTarget();
if (followTarget == null)
{
MoveTowardSelectAngle(smoothStep);
return;
}
MoveTowardPlayerAngle(followTarget, smoothStep);
}
/// <summary>
/// 따라갈 플레이어의 Transform. 아직 스폰 전이거나 이미 회수됐으면 null.
/// 매 프레임 프레임워크에 묻는다. 한 번 받은 참조를 들고 있지 않으므로
/// 회수된 인스턴스를 가리킬 일이 없다.
/// </summary>
private static Transform FindFollowTarget()
{
Player player = EntityFinder.FindPlayer();
if (player == null) return null;
return player.CachedTransform;
}
/// <summary>
/// 플레이어가 없다 = 캐릭터 선택 단계이거나 게임오버 직후다.
/// 여기서 아무것도 하지 않으면 카메라가 플레이어가 죽은 자리에 얼어붙고,
/// 재시작 시 선택 캐릭터가 화면 밖이라 클릭할 수 없게 된다. 반드시 선택 앵글로 되돌린다.
/// </summary>
private void MoveTowardSelectAngle(float smoothStep)
{
transform.position = Vector3.Lerp(transform.position, mSelectPosition, smoothStep);
transform.rotation = Quaternion.Slerp(transform.rotation, mSelectRotation, smoothStep);
}
private void MoveTowardPlayerAngle(Transform followTarget, float smoothStep)
{
transform.position = Vector3.Lerp(transform.position, followTarget.position + offset, smoothStep);
transform.rotation = Quaternion.Slerp(transform.rotation, mTargetRotation, smoothStep);
}
private void RecalculateRotation()
{
if (offset.sqrMagnitude <= MinOffsetSqrMagnitude)
{
// 방향을 못 구하는 상황이다. 마지막으로 성립했던 회전을 그대로 둔다.
return;
}
mTargetRotation = Quaternion.LookRotation(-offset);
}
/// <summary>
/// 인스펙터 설정 실수 방어. 둘 다 증상이 "카메라가 안 따라온다" 하나로만 보여서
/// 원인을 짚기 어렵기 때문에 기동 시점에 한 번 알린다.
/// </summary>
private void WarnIfMisconfigured()
{
if (smoothing <= 0f)
{
Log.Warning("PlayerCameraFollow: smoothing 이 {0} 이라 카메라가 제자리에 멈춘다.", smoothing);
}
if (offset.sqrMagnitude <= MinOffsetSqrMagnitude)
{
Log.Warning("PlayerCameraFollow: offset 이 사실상 0 이라 카메라가 플레이어와 겹치고 회전도 갱신되지 않는다.");
}
}
}
}
C#
복사
저장한 뒤 Hierarchy 에서 MainCamera 를 고르고 Add Component → Player Camera Follow 로 붙입니다. 인스펙터 값은 기본값 그대로 둡니다.
코드 읽기
카메라는 플레이어를 인스펙터로 연결하지 않습니다. 보통의 Unity 프로젝트라면 public Transform target; 을 두고 플레이어를 드래그해 넣을 것입니다. 여기서는 그럴 수 없습니다. 플레이어는 씬에 없고, 판마다 새로 뜨기 때문입니다. 대신 매 프레임 EntityFinder.FindPlayer() 로 프레임워크에 묻습니다. "지금 플레이어가 있니?" 있으면 따라가고, 없으면 처음 자리로 돌아갑니다.
이 한 가지 판단으로 카메라는 게임의 흐름을 전혀 모르면서도 올바르게 움직입니다. 캐릭터 선택 중(플레이어 없음) → 선택 앵글. 플레이 중(있음) → 따라감. 게임오버 후(다시 없음) → 선택 앵글로 복귀. 카메라에게 "이제 게임 시작이야"라고 알려 주는 코드는 어디에도 없습니다.
MonoBehaviour 인데 Start 를 씁니다. 3장에서 "Awake / Start 를 쓰지 않는다"고 한 것은 엔티티 로직에 한한 규칙입니다. 풀에서 재사용되기 때문이었습니다. 카메라는 재사용되지 않으니 평소처럼 쓰면 됩니다. 프레임워크 컴포넌트도 결국 MonoBehaviour 입니다. 1장에서 본 BaseComponent 가 Unity 의 Update 에서 프레임워크 전체를 굴리는 것과 같은 방식입니다. 다만 다른 컴포넌트를 찾는 일은 Awake 가 아니라 Start 에서 합니다. Awake 는 컴포넌트끼리 순서가 정해져 있지 않아, 찾는 상대가 아직 등록되기 전일 수 있습니다.
LateUpdate 는 모든 Update 가 끝난 뒤에 불립니다. 플레이어가 이번 프레임에 움직인 뒤에 카메라가 따라가야 화면이 떨리지 않습니다.
Vector3.Lerp(현재, 목표, smoothing * Time.deltaTime) 은 "남은 거리의 일정 비율만큼 매 프레임 다가간다"는 뜻입니다. 멀면 빨리, 가까우면 천천히 움직여서 부드럽게 따라붙습니다.
실행해 보기
Play → 캐릭터를 고릅니다.
캐릭터를 고르면 카메라가 1초쯤에 걸쳐 플레이어 뒤쪽 위로 돌아 들어간다
카메라가 선택 화면의 정면 앵글에서 플레이어 뒤쪽 위로 부드럽게 넘어갑니다. 선택 화면은 캐릭터의 얼굴을 보여 주려고 앞에서 비췄고, 게임 화면은 조작하기 편하도록 뒤에서 내려다봅니다.
카메라 쪽은 에디터에서 이렇게 보입니다. 씬에 놓인 MainCamera 에 우리가 만든 컴포넌트가 붙어 있고, "뒤쪽 위"는 그 Offset 값 그대로입니다.
① 씬의 MainCamera ② 여기에 붙은 PlayerCameraFollow — Offset (0, 15, -22) 만큼 떨어져 따라간다
4.5 움직이기
마우스가 가리키는 바닥 — MouseGround
플레이어는 마우스 커서 쪽을 바라봅니다. 그러려면 "화면 위의 커서"를 "게임 세계의 바닥 위 한 점"으로 바꿔야 합니다. 카메라에서 커서 방향으로 광선을 쏴서 바닥 평면과 만나는 점을 구합니다. 6장의 무기도 이 점을 조준에 쓰므로 따로 떼어 둡니다.
// Assets/GameMain/Utility/MouseGround.cs
using UnityEngine;
using UnityEngine.InputSystem;
namespace ToyBoxNightmare
{
/// <summary>
/// 마우스 커서 아래의 지면 좌표.
/// <b>필요한 쪽이 필요할 때 직접 묻는</b> 순수 함수로 둔다 — 갱신 순서에 의존하지 않고,
/// 씬에 오브젝트를 하나 더 두지 않아도 된다.
/// </summary>
public static class MouseGround
{
/// <summary>
/// 커서 아래 지면 좌표를 구한다. 못 구하면 false — 호출부가 대체값을 정한다.
/// 카메라가 없거나 마우스가 없거나 시선이 지면과 평행하면 실패한다.
/// 전부 정상적으로 일어날 수 있어 로그를 남기지 않는다.
/// </summary>
public static bool TryGetGroundPoint(out Vector3 point)
{
point = Vector3.zero;
Camera mainCamera = Camera.main;
if (mainCamera == null || Mouse.current == null) return false;
Vector2 screenPosition = Mouse.current.position.ReadValue();
Ray ray = mainCamera.ScreenPointToRay(screenPosition);
Plane groundPlane = new Plane(Vector3.up, Vector3.zero);
if (!groundPlane.Raycast(ray, out float distance)) return false;
point = ray.GetPoint(distance);
return true;
}
}
}
C#
복사
new Plane(Vector3.up, Vector3.zero) 는 "원점을 지나고 위를 향한 평면", 곧 높이 0 의 바닥입니다. 실제 바닥 콜라이더에 광선을 쏘지 않고 수학적인 평면을 쓰는 이유는, 이 게임의 바닥이 평평한 면 하나뿐이라 결과가 같고, 바닥에 콜라이더가 있든 없든 동작하기 때문입니다.
메서드 이름이 Try… 로 시작하고 bool 을 돌려주는 모양을 기억해 두세요. "실패할 수 있고, 실패가 에러는 아닌" 일에 쓰는 C# 의 관례입니다. 결과는 out 인자로 나갑니다.
입력을 읽고, 물리로 옮긴다
Player.cs 로 돌아갑니다. using 에 UnityEngine.InputSystem 을 추가하고, 클래스 위쪽에 상수와 필드를 더합니다.
// ─── 애니메이터 파라미터 이름 ───
// 이름이 틀려도 Unity 는 조용히 무시하므로 문자열을 흩뿌리지 않고 여기 모아 둔다.
private const string WalkingBoolParam = "IsWalking";
/// <summary>
/// 방향 벡터가 이보다 짧으면 "방향이 없다" 로 본다.
/// 길이 0 에 가까운 벡터를 <c>Quaternion.LookRotation</c> 에 넘기면 회전이 튄다.
/// </summary>
private const float MinLookSqrMagnitude = 0.001f;
// FixedUpdate 에 전달할 이동/회전 방향
private Vector3 mMoveDirection = Vector3.zero;
private Vector3 mLookDirection = Vector3.forward;
/// <summary>마우스가 가리키는 지면 위 월드 좌표. 지면에 안 닿으면 캐릭터 전방 한 칸으로 대체된다.</summary>
public Vector3 AimPoint { get; private set; }
/// <summary>이번 프레임 지면 레이캐스트가 실제로 성공했는지. false 면 AimPoint 는 대체값이다.</summary>
public bool HasAimPoint { get; private set; }
C#
복사
클래스 아래쪽에 입력과 이동을 추가합니다.
protected internal override void OnUpdate(float elapseSeconds, float realElapseSeconds)
{
base.OnUpdate(elapseSeconds, realElapseSeconds);
ReadInput(); // 4.6 절에서 상태 기계로 옮긴다
}
// ─── 물리 이동 (FixedUpdate) ───
private void FixedUpdate()
{
if (mRigidbody == null || mPlayerData == null || IsDead) return;
MoveByInput();
TurnToLookDirection();
UpdateWalkAnimation();
}
private void MoveByInput()
{
// normalized 는 길이 0 벡터에 대해 0 을 돌려주므로 0 나눗셈이 없다.
Vector3 step = mMoveDirection.normalized * mPlayerData.MoveSpeed * Time.fixedDeltaTime;
mRigidbody.MovePosition(mRigidbody.position + step);
}
private void TurnToLookDirection()
{
Vector3 look = mLookDirection;
look.y = 0f;
if (look.sqrMagnitude <= MinLookSqrMagnitude) return;
mRigidbody.MoveRotation(Quaternion.LookRotation(look));
}
private void UpdateWalkAnimation()
{
if (mAnimator == null) return;
mAnimator.SetBool(WalkingBoolParam, mMoveDirection.sqrMagnitude > 0f);
}
// ─── 입력 처리 ───
/// <summary>
/// 이번 프레임의 조준점과 이동 방향을 잡는다.
/// 조준을 이동보다 먼저, 그리고 키보드 유무와 <b>무관하게</b> 갱신한다.
/// </summary>
private void ReadInput()
{
UpdateAimPoint();
UpdateLookDirection();
Keyboard keyboard = Keyboard.current;
mMoveDirection = keyboard != null ? ReadMoveDirection(keyboard) : Vector3.zero;
}
/// <summary>WASD 와 방향키를 같은 축으로 합친다.</summary>
private static Vector3 ReadMoveDirection(Keyboard keyboard)
{
float horizontal = ReadAxis(
keyboard.dKey.isPressed || keyboard.rightArrowKey.isPressed,
keyboard.aKey.isPressed || keyboard.leftArrowKey.isPressed);
float vertical = ReadAxis(
keyboard.wKey.isPressed || keyboard.upArrowKey.isPressed,
keyboard.sKey.isPressed || keyboard.downArrowKey.isPressed);
return new Vector3(horizontal, 0f, vertical);
}
/// <summary>양쪽을 동시에 누르면 0 이 된다(레거시 GetAxisRaw 와 같은 의미).</summary>
private static float ReadAxis(bool positivePressed, bool negativePressed)
{
return (positivePressed ? 1f : 0f) - (negativePressed ? 1f : 0f);
}
/// <summary>
/// 프레임당 딱 한 번 도는 지면 레이캐스트. 결과가 AimPoint / HasAimPoint 로 나가고,
/// 캐릭터 회전도 여기서 나온 값을 쓴다.
/// </summary>
private void UpdateAimPoint()
{
HasAimPoint = MouseGround.TryGetGroundPoint(out Vector3 groundPoint);
AimPoint = HasAimPoint ? groundPoint : ForwardAimPoint;
}
/// <summary>스폰 시점의 조준 상태. 아직 입력을 읽기 전이므로 지금 보는 방향으로 채운다.</summary>
private void ResetAim()
{
mLookDirection = CachedTransform.forward;
HasAimPoint = false;
AimPoint = ForwardAimPoint;
}
/// <summary>조준점 쪽으로 바라볼 방향을 갱신한다.</summary>
private void UpdateLookDirection()
{
Vector3 toAim = AimPoint - CachedTransform.position;
toAim.y = 0f;
// 커서가 캐릭터와 겹치면 방향이 0 에 수렴한다. 그때는 직전 방향을 유지한다.
if (toAim.sqrMagnitude <= MinLookSqrMagnitude) return;
mLookDirection = toAim;
}
/// <summary>마우스 지면 조준점을 못 구할 때 쓰는 대체 조준점. 지금 보는 방향을 유지한다.</summary>
private Vector3 ForwardAimPoint
{
get { return CachedTransform.position + CachedTransform.forward; }
}
C#
복사
그리고 풀에서 다시 나올 때를 위해 되돌리는 코드를 두 곳에 넣습니다. ResetRuntimeState 의 맨 앞에,
mMoveDirection = Vector3.zero;
ResetAim();
C#
복사
OnShow 에서 위치와 회전을 적용한 바로 다음에 한 번 더 넣습니다.
CachedTransform.position = mPlayerData.Position;
CachedTransform.rotation = mPlayerData.Rotation;
// 회전을 적용한 뒤에 다시 잡아야 스폰 자세를 그대로 바라본다.
// 리셋 시점의 forward 는 아직 이전 판의 자세다.
ResetAim();
C#
복사
코드 읽기
입력은 OnUpdate 에서 읽고, 이동은 FixedUpdate 에서 합니다. 둘을 나누는 이유는 두 메서드가 불리는 박자가 다르기 때문입니다.
OnUpdate | FixedUpdate | |
언제 불리나 | 화면을 그릴 때마다 (프레임마다 간격이 다르다) | 물리 계산 주기마다 (간격이 일정하다. 기본 0.02초) |
여기서 할 일 | 입력 읽기. 키를 누른 순간을 놓치지 않으려면 매 프레임 봐야 한다 | Rigidbody 움직이기. 물리와 박자가 맞아야 벽에 부딪혔을 때 떨리지 않는다 |
그래서 OnUpdate 는 읽은 값을 mMoveDirection 과 mLookDirection 에 적어 두기만 하고, FixedUpdate 가 그것을 읽어 실제로 움직입니다. 두 변수가 둘 사이의 쪽지입니다.
"엔티티 로직에서 Unity 메시지를 쓰지 말라"고 했는데 FixedUpdate 는 씁니다. 금지한 것은 초기화에 Awake / Start 를 쓰는 것이었습니다. 물리 박자에 맞춰 불리는 훅은 프레임워크에 없으므로 FixedUpdate 를 그대로 씁니다. 대신 첫 줄에서 mPlayerData == null 이면 빠져나갑니다. 풀에서 쉬는 동안에는 OnHide 가 이 값을 비워 두었으므로 아무 일도 하지 않습니다.
mMoveDirection.normalized — W 와 D 를 함께 누르면 방향이 (1, 0, 1) 이 되고 길이는 약 1.41 입니다. 그대로 쓰면 대각선으로 갈 때 1.41배 빨라집니다. normalized 로 길이를 1 로 맞추면 어느 방향이든 속도가 같습니다.
mRigidbody.MovePosition / MoveRotation — transform.position 을 직접 바꾸면 물리를 무시하고 순간이동하므로 벽을 뚫습니다. Rigidbody 의 메서드로 옮기면 충돌이 반영됩니다.
조준점은 한 프레임에 한 번만 구합니다. UpdateAimPoint 가 구한 AimPoint 를 UpdateLookDirection 이 그대로 받아 씁니다. 바라보는 방향을 위해 한 번, 6장의 무기를 위해 또 한 번 광선을 쏘면 낭비일 뿐 아니라 두 결과가 미세하게 달라 보는 방향과 쏘는 방향이 어긋날 수 있습니다. 한 번 구해서 속성에 담아 두고 모두가 그것을 읽습니다.
MinLookSqrMagnitude — 커서가 정확히 캐릭터 위에 있으면 "캐릭터에서 커서까지"의 방향이 길이 0 이 됩니다. 길이 0 인 벡터로는 방향을 정할 수 없어서 LookRotation 이 에러를 내거나 회전이 튑니다. 너무 짧으면 직전 방향을 유지하도록 조기 반환합니다. magnitude 가 아니라 sqrMagnitude(길이의 제곱)를 쓰는 것은 제곱근 계산을 피하기 위한 흔한 습관입니다.
실행해 보기
Play → 캐릭터를 고른 뒤, Game 창을 한 번 클릭하고 WASD 를 누릅니다.
WASD 로 걸어 다닌다. 캐릭터는 커서 쪽을 바라보고 카메라가 따라온다
캐릭터가 걷는 애니메이션과 함께 움직이고, 마우스를 돌리면 그쪽을 바라봅니다. 카메라가 따라옵니다. 가구에 부딪히면 멈춥니다.
실제 플레이 — WASD 로 걷고, 커서 쪽을 바라보며, 카메라가 뒤따른다
키가 안 먹는다면 Game 창을 클릭했는지 확인하세요. 에디터에서는 Game 창에 포커스가 있을 때만 키보드 입력이 게임으로 갑니다. 방금 Hierarchy 나 Inspector 를 클릭했다면 포커스가 그쪽에 있습니다.
4.6 플레이어의 일생을 상태로 나누기
지금 OnUpdate 는 ReadInput() 한 줄입니다. 여기에 "죽는 중에는 입력을 받지 않고 타이머를 돌린다", "일시정지 중에는 아무것도 하지 않는다"를 if 로 넣기 시작하면 이 장 맨 앞에서 말한 그 코드가 됩니다. 3장에서 한 대로 상태로 나눕니다.
graph LR
S((" ")) --> A["Alive<br>입력을 받는다"]
A -->|"일시정지"| P["Paused<br>아무것도 하지 않는다"]
P -->|"해제"| A
A -->|"체력 0"| D["Dying<br>쓰러지고 1초 뒤 사라진다"]
P -->|"체력 0"| D
D --> E((" "))Mermaid
복사
준비물 둘
상태들이 물어볼 것 두 가지를 먼저 만듭니다.
일시정지 깃발. 일시정지 메뉴는 8장에서 만들지만, "지금 멈춰 있는가"를 물어볼 곳은 지금 필요합니다.
// Assets/GameMain/Utility/GamePause.cs
using UnityGameFramework.Runtime;
namespace ToyBoxNightmare
{
/// <summary>
/// 일시정지 상태의 단일 소유자.
///
/// <b>timeScale 은 직접 건드리지 않는다.</b> 프레임워크의
/// BaseComponent.PauseGame / ResumeGame 이 그 일을 하고,
/// 정지 직전 속도를 기억했다가 되돌려 주는 것까지 맡는다.
///
/// <b>그런데 timeScale 만으로는 부족하다.</b> GameFramework 는 정지 중에도 매 프레임
/// OnUpdate 를 부르고 elapseSeconds 만 0 이 된다 — 즉 타이머는 멈추지만
/// 입력 처리는 그대로 돈다. 그래서 입력을 읽는 쪽이 이 플래그를 직접 봐야 한다.
/// </summary>
public static class GamePause
{
/// <summary>지금 일시정지 중인가.</summary>
public static bool IsPaused { get; private set; }
public static void SetPaused(bool paused)
{
if (IsPaused == paused) return;
// 입력 게이트는 프레임워크 상태와 무관하게 반드시 서야 한다.
IsPaused = paused;
BaseComponent baseComponent = GameEntry.GetComponent<BaseComponent>();
if (baseComponent == null)
{
Log.Warning("GamePause: BaseComponent 가 없다. 입력만 막히고 시간은 계속 흐른다.");
return;
}
if (paused)
{
baseComponent.PauseGame();
return;
}
baseComponent.ResumeGame();
}
/// <summary>
/// 판을 벗어날 때 반드시 부른다. 정지 상태로 프로시저가 바뀌면
/// 게임 속도가 0 인 채로 남아 다음 판이 통째로 멈춘다.
/// </summary>
public static void Clear()
{
SetPaused(false);
}
}
}
C#
복사
주석의 둘째 문단이 이 클래스가 있는 이유입니다. 게임 시간을 멈춰도 OnUpdate 는 계속 불립니다. 2장에서 본 elapseSeconds 가 0 이 될 뿐입니다. 타이머처럼 시간을 더하는 코드는 저절로 멈추지만, "이번 프레임에 키가 눌렸는가"를 묻는 코드는 그대로 돌아갑니다. 그래서 입력을 읽는 쪽이 "지금 멈춰 있는가"를 직접 물어야 합니다.
플레이어 사망 이벤트. 3장에서 익힌 틀 그대로입니다. 실어 보낼 값이 없어서 Create 도 Clear 도 비어 있습니다. "죽었다"는 사실 하나가 소식의 전부입니다.
// Assets/GameMain/Game/PlayerDiedEventArgs.cs
using GameFramework;
using GameFramework.Event;
namespace ToyBoxNightmare
{
/// <summary>
/// 플레이어가 죽었을 때 발행된다. SurvivalGame 이 이걸 받아 GameOver 를 세운다.
///
/// 점수는 싣지 않는다. 점수를 아는 것은 게임(SurvivalGame)이지 플레이어가 아니다 —
/// 필요한 쪽이 게임에서 읽는다.
/// </summary>
public class PlayerDiedEventArgs : GameEventArgs
{
public static readonly int EventId = typeof(PlayerDiedEventArgs).GetHashCode();
public override int Id => EventId;
public static PlayerDiedEventArgs Create()
{
return ReferencePool.Acquire<PlayerDiedEventArgs>();
}
public override void Clear()
{
}
}
}
C#
복사
상태 넷
Assets/GameMain/Entity/EntityLogic/States 폴더에 만듭니다. 공통 부모부터입니다.
// States/PlayerStateBase.cs
using GameFramework.Fsm;
namespace ToyBoxNightmare
{
/// <summary>
/// 플레이어 상태의 공통 베이스.
/// <b>상태는 무상태(stateless)다.</b> 모든 값은 <c>fsm.Owner</c> 에서 읽는다.
/// </summary>
public abstract class PlayerStateBase : FsmState<Player>
{
/// <summary>
/// 어느 상태에 있든 사망이 시작되면 사망 연출로 넘어간다.
/// 전이했으면 true — 호출한 상태는 그 프레임의 나머지 일을 하지 말아야 한다.
/// </summary>
protected bool TryChangeToDying(IFsm<Player> fsm)
{
if (!fsm.Owner.IsDeathSequenceRunning) return false;
ChangeState<PlayerDyingState>(fsm);
return true;
}
}
}
C#
복사
// States/PlayerAliveState.cs
using GameFramework.Fsm;
namespace ToyBoxNightmare
{
/// <summary>
/// 살아서 조작 가능한 상태.
/// </summary>
public sealed class PlayerAliveState : PlayerStateBase
{
protected override void OnUpdate(IFsm<Player> fsm, float elapseSeconds, float realElapseSeconds)
{
base.OnUpdate(fsm, elapseSeconds, realElapseSeconds);
if (TryChangeToDying(fsm)) return;
Player owner = fsm.Owner;
// 이미 체력이 0 인데 사망 연출이 아직 시작 안 된 프레임이 있을 수 있다.
// 그 사이 입력을 받으면 시체가 한 프레임 더 움직인다.
if (owner.IsDead) return;
// 일시정지는 별도 상태로 뺀다. timeScale 0 이어도 OnUpdate 자체는 계속 불리므로
// 여기서 걸러 주지 않으면 메뉴를 띄운 채 사격이 나간다.
if (GamePause.IsPaused)
{
ChangeState<PlayerPausedState>(fsm);
return;
}
owner.TickAliveInput();
}
}
}
C#
복사
// States/PlayerPausedState.cs
using GameFramework.Fsm;
namespace ToyBoxNightmare
{
/// <summary>
/// 일시정지 상태. 아무 입력도 읽지 않는다.
///
/// 진입할 때 이동 방향을 비우는 것이 핵심이다 — 안 그러면 재개하는 순간
/// 정지 직전의 입력으로 한 걸음 튀어나간다(FixedUpdate 는 계속 돌기 때문이다).
/// </summary>
public sealed class PlayerPausedState : PlayerStateBase
{
protected override void OnEnter(IFsm<Player> fsm)
{
base.OnEnter(fsm);
fsm.Owner.ClearMoveInput();
}
protected override void OnUpdate(IFsm<Player> fsm, float elapseSeconds, float realElapseSeconds)
{
base.OnUpdate(fsm, elapseSeconds, realElapseSeconds);
if (TryChangeToDying(fsm)) return;
if (GamePause.IsPaused) return;
ChangeState<PlayerAliveState>(fsm);
}
}
}
C#
복사
// States/PlayerDyingState.cs
using GameFramework.Fsm;
namespace ToyBoxNightmare
{
/// <summary>
/// 사망 연출 상태. 즉시 사라지지 않고 사망 애니메이션이 보일 시간을 준다.
/// 회수(Hide)는 Player.OnDead 가 아니라 이 상태의 타이머가 담당한다.
/// </summary>
public sealed class PlayerDyingState : PlayerStateBase
{
protected override void OnUpdate(IFsm<Player> fsm, float elapseSeconds, float realElapseSeconds)
{
base.OnUpdate(fsm, elapseSeconds, realElapseSeconds);
fsm.Owner.TickDeathSequence(elapseSeconds);
}
}
}
C#
복사
코드 읽기
PlayerAliveState.OnUpdate 를 위에서 아래로 읽으면 한 프레임의 판단 순서가 그대로 나옵니다. 죽기 시작했나 → 사망 상태로. 이미 죽었나 → 아무것도 안 함. 멈췄나 → 정지 상태로. 셋 다 아니면 → 입력을 받는다. 3장의 IsPointerOnSelf 처럼 "안 되는 경우를 먼저 돌려보내는" 모양입니다.
이 장 맨 앞에서 걱정했던 if (isDead), if (isPaused), if (isDying) 이 사라진 것이 아닙니다. 자리를 옮겼습니다. 예전에는 Update 한 곳에 "지금 어떤 상황인가"를 묻는 if 가 쌓였다면, 이제는 각 상태가 "나에서 어디로 넘어갈 수 있는가" 만 압니다. Dying 상태는 IsPaused 를 아예 보지 않습니다. 죽는 중에 일시정지가 눌려도 신경 쓸 코드가 없습니다. 상태가 늘어나도 기존 상태의 코드를 다시 읽지 않아도 됩니다.
TryChangeToDying 은 Alive 와 Paused 둘 다 필요해서 부모로 올렸습니다. bool 을 돌려줘서 if (TryChangeToDying(fsm)) return; 한 줄로 "넘어갔으면 이 프레임은 여기서 끝"을 표현합니다.
PlayerPausedState.OnEnter 가 이동 방향을 비우는 이유는 주석대로입니다. FixedUpdate 는 상태 기계와 상관없이 계속 불리고, mMoveDirection 에는 멈추기 직전에 누르고 있던 방향이 남아 있습니다. 비우지 않으면 일시정지를 푸는 순간 그 방향으로 한 걸음 튀어나갑니다.
상태가 엔티티보다 먼저 돕니다. 한 프레임 안에서 프레임워크는 이벤트 → 상태 기계 → 엔티티 순서로 일을 처리합니다. 그래서 상태의 OnUpdate 가 Player.OnUpdate 보다 먼저 불립니다. 로직을 양쪽에 나눠 쓰면 "어느 쪽이 먼저 도는가"를 매번 따져야 하므로, 이 책은 매 프레임 할 일을 전부 상태에 두고 엔티티의 OnUpdate 는 비워 둡니다.
주인 쪽 — 완성된 Player
3장에서 한 것처럼, 주인은 상태 기계를 만들고 부수고, 상태가 부를 창구를 엽니다. 고칠 곳이 여럿이라 이 장이 끝난 시점의 파일 전체를 싣습니다.
// Assets/GameMain/Entity/EntityLogic/Player.cs
using GameFramework.Fsm;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityGameFramework.Runtime;
namespace ToyBoxNightmare
{
/// <summary>
/// 플레이어 엔티티 로직.
/// - Rigidbody 기반 이동 (FixedUpdate)
/// - 마우스 방향으로 회전
/// - WASD 이동
/// </summary>
[RequireComponent(typeof(Rigidbody))]
public class Player : TargetableObject
{
// ─── 애니메이터 파라미터 이름 ───
private const string WalkingBoolParam = "IsWalking";
private const string DieTriggerParam = "Die";
private const float MinLookSqrMagnitude = 0.001f;
private PlayerData mPlayerData = null;
private Rigidbody mRigidbody = null;
private Animator mAnimator = null;
private CapsuleCollider mCollider = null;
// FixedUpdate 에 전달할 이동/회전 방향
private Vector3 mMoveDirection = Vector3.zero;
private Vector3 mLookDirection = Vector3.forward;
// ─── 상태 기계 ───
// 상태는 무상태라 인스턴스를 풀 재사용 사이에 그대로 돌려쓴다(OnInit 에서 1회 생성).
// FSM 자체는 스폰마다 만들고 회수마다 부순다 — 회수된 엔티티의 상태가 계속 돌면 안 된다.
private IFsm<Player> mFsm = null;
private FsmState<Player>[] mStates = null;
// 사망 연출
private bool mDying = false;
private float mDeathTimer = 0f;
private bool mDeathNotified = false;
private const float DeathDelay = 1f;
public Vector3 AimPoint { get; private set; }
public bool HasAimPoint { get; private set; }
// ─── EntityLogic 생명주기 ───
protected internal override void OnInit(object userData)
{
base.OnInit(userData);
// 상태 인스턴스는 여기서 한 번만 만든다.
mStates = new FsmState<Player>[]
{
new PlayerAliveState(),
new PlayerPausedState(),
new PlayerDyingState(),
};
mRigidbody = GetComponent<Rigidbody>();
mAnimator = GetComponentInChildren<Animator>(true);
mCollider = GetComponent<CapsuleCollider>();
WarnOnMissingComponents();
}
private void WarnOnMissingComponents()
{
if (mRigidbody == null)
{
Log.Error("Player '{0}': Rigidbody 가 없어 이동이 동작하지 않는다.", Name);
}
if (mAnimator == null)
{
Log.Warning("Player '{0}': Animator 를 찾지 못했다. 이동/사망 애니메이션이 재생되지 않는다.", Name);
}
if (mCollider == null)
{
Log.Warning("Player '{0}': CapsuleCollider 가 없다. 중심점이 트랜스폼 원점으로 대체된다.", Name);
}
}
protected internal override void OnShow(object userData)
{
base.OnShow(userData);
ResetRuntimeState();
mPlayerData = userData as PlayerData;
if (mPlayerData == null)
{
Log.Error("Player data is invalid.");
return;
}
CachedTransform.position = mPlayerData.Position;
CachedTransform.rotation = mPlayerData.Rotation;
// 회전을 적용한 뒤에 다시 잡아야 스폰 자세를 그대로 바라본다.
ResetAim();
// 상태 기계는 **맨 마지막**에 세운다. 상태가 OnEnter 에서 소유자 값을 읽기 때문이다.
CreateStateMachine();
}
/// <summary>
/// FSM 이름. 코어는 (소유자 타입, 이름) 으로 FSM 을 식별하므로
/// 인스턴스마다 달라야 한다. 엔티티 Id 가 그 조건을 만족한다.
/// </summary>
private string FsmName
{
get { return Entity != null ? Entity.Id.ToString() : GetEntityId().ToString(); }
}
private void CreateStateMachine()
{
FsmComponent fsmComponent = GameEntry.GetComponent<FsmComponent>();
if (fsmComponent == null)
{
Log.Error("Player: FsmComponent 가 없다. 입력도 사망 연출도 돌지 않는다.");
return;
}
mFsm = fsmComponent.CreateFsm(FsmName, this, mStates);
mFsm.Start<PlayerAliveState>();
}
/// <summary>멱등하다. 이미 부쉈거나 프레임워크가 먼저 내려갔으면 조용히 넘어간다.</summary>
private void DestroyStateMachine()
{
if (mFsm == null) return;
FsmComponent fsmComponent = GameEntry.GetComponent<FsmComponent>();
if (fsmComponent != null)
{
fsmComponent.DestroyFsm(mFsm);
}
mFsm = null;
}
/// <summary>
/// 풀에서 재사용되므로 이전 판의 이동/애니/사망 상태를 반드시 리셋한다.
/// 빠뜨리면 두 번째 판이 죽은 포즈에 콜라이더가 꺼진 채로 시작한다.
/// </summary>
private void ResetRuntimeState()
{
mMoveDirection = Vector3.zero;
mDying = false;
mDeathTimer = 0f;
mDeathNotified = false;
ResetAim();
if (mCollider != null)
{
mCollider.enabled = true;
}
if (mAnimator != null)
{
mAnimator.Rebind();
mAnimator.Update(0f);
}
}
protected internal override void OnHide(bool isShutdown, object userData)
{
// 가장 먼저 부순다. 회수된 엔티티의 상태가 계속 도는 것을 막는다.
DestroyStateMachine();
mPlayerData = null;
base.OnHide(isShutdown, userData);
}
/// <summary>
/// 프레임 갱신은 전부 FSM 상태가 한다(FsmManager 가 이것보다 먼저 돈다).
/// 여기에 로직을 다시 넣으면 상태 밖에 숨은 분기가 생기므로 그러지 말 것.
/// </summary>
protected internal override void OnUpdate(float elapseSeconds, float realElapseSeconds)
{
base.OnUpdate(elapseSeconds, realElapseSeconds);
}
// ─── 상태가 쓰는 창구 ───
/// <summary>사망 연출이 시작됐는가. 모든 상태가 이것을 보고 사망 상태로 넘어간다.</summary>
internal bool IsDeathSequenceRunning
{
get { return mDying; }
}
/// <summary>살아있는 동안의 한 프레임.</summary>
internal void TickAliveInput()
{
ReadInput();
}
/// <summary>이동 입력을 비운다. FixedUpdate 는 계속 돌기 때문에 정지 진입 시 필요하다.</summary>
internal void ClearMoveInput()
{
mMoveDirection = Vector3.zero;
}
/// <summary>
/// 사망 연출 한 프레임. 즉시 사라지지 않고 사망 애니메이션이 보일 시간을 준다.
/// 이 타이머가 <see cref="OnDead"/> 대신 회수(Hide)를 담당한다.
/// </summary>
internal void TickDeathSequence(float elapseSeconds)
{
mDeathTimer += elapseSeconds;
if (mDeathTimer < DeathDelay) return;
NotifyDied();
SafeHide();
}
// ─── 물리 이동 (FixedUpdate) ───
private void FixedUpdate()
{
if (mRigidbody == null || mPlayerData == null || IsDead) return;
MoveByInput();
TurnToLookDirection();
UpdateWalkAnimation();
}
private void MoveByInput()
{
Vector3 step = mMoveDirection.normalized * mPlayerData.MoveSpeed * Time.fixedDeltaTime;
mRigidbody.MovePosition(mRigidbody.position + step);
}
private void TurnToLookDirection()
{
Vector3 look = mLookDirection;
look.y = 0f;
if (look.sqrMagnitude <= MinLookSqrMagnitude) return;
mRigidbody.MoveRotation(Quaternion.LookRotation(look));
}
private void UpdateWalkAnimation()
{
if (mAnimator == null) return;
mAnimator.SetBool(WalkingBoolParam, mMoveDirection.sqrMagnitude > 0f);
}
// ─── 입력 처리 ───
private void ReadInput()
{
UpdateAimPoint();
UpdateLookDirection();
Keyboard keyboard = Keyboard.current;
mMoveDirection = keyboard != null ? ReadMoveDirection(keyboard) : Vector3.zero;
}
private static Vector3 ReadMoveDirection(Keyboard keyboard)
{
float horizontal = ReadAxis(
keyboard.dKey.isPressed || keyboard.rightArrowKey.isPressed,
keyboard.aKey.isPressed || keyboard.leftArrowKey.isPressed);
float vertical = ReadAxis(
keyboard.wKey.isPressed || keyboard.upArrowKey.isPressed,
keyboard.sKey.isPressed || keyboard.downArrowKey.isPressed);
return new Vector3(horizontal, 0f, vertical);
}
private static float ReadAxis(bool positivePressed, bool negativePressed)
{
return (positivePressed ? 1f : 0f) - (negativePressed ? 1f : 0f);
}
private void UpdateAimPoint()
{
HasAimPoint = MouseGround.TryGetGroundPoint(out Vector3 groundPoint);
AimPoint = HasAimPoint ? groundPoint : ForwardAimPoint;
}
private void ResetAim()
{
mLookDirection = CachedTransform.forward;
HasAimPoint = false;
AimPoint = ForwardAimPoint;
}
private void UpdateLookDirection()
{
Vector3 toAim = AimPoint - CachedTransform.position;
toAim.y = 0f;
if (toAim.sqrMagnitude <= MinLookSqrMagnitude) return;
mLookDirection = toAim;
}
private Vector3 ForwardAimPoint
{
get { return CachedTransform.position + CachedTransform.forward; }
}
// ─── 사망 ───
/// <summary>
/// 주의: <c>base.OnDead</c> 를 부르지 않는다. base 는 즉시 SafeHide 하므로
/// 사망 연출이 통째로 생략되고, 연출 종료 시점의 Hide 와 겹쳐 중복이 된다.
/// 회수는 사망 타이머가 담당한다.
/// </summary>
protected override void OnDead(Entity attacker)
{
GameAssert.IsTrue(IsDead, "OnDead 는 체력이 0 이하가 된 뒤에만 호출되어야 한다.");
if (mDying) return;
mDying = true;
mDeathTimer = 0f;
StopMovementAndCollision();
PlayDeathAnimation();
// 사망 이벤트는 여기서 쏘지 않는다. NotifyDied() 를 볼 것.
}
/// <summary>이동/충돌을 즉시 끊는다. 적이 시체를 계속 때리지 않게 콜라이더도 끈다.</summary>
private void StopMovementAndCollision()
{
mMoveDirection = Vector3.zero;
if (mCollider != null)
{
mCollider.enabled = false;
}
}
private void PlayDeathAnimation()
{
if (mAnimator == null) return;
mAnimator.SetBool(WalkingBoolParam, false);
mAnimator.SetTrigger(DieTriggerParam);
}
/// <summary>
/// 사망을 알린다. <b>사망 연출이 끝난 뒤에 부른다</b> — OnDead 에서 바로 쏘면
/// 연출이 1프레임으로 잘린다.
/// </summary>
private void NotifyDied()
{
if (mDeathNotified) return;
mDeathNotified = true;
// Create 는 참조 풀에서 꺼내 오므로, 발행하지 못할 상황이면 만들기 전에 빠진다.
EventComponent eventComponent = GameEntry.GetComponent<EventComponent>();
if (eventComponent == null)
{
Log.Error("EventComponent 가 없어 PlayerDied 를 발행하지 못했다.");
return;
}
eventComponent.Fire(this, PlayerDiedEventArgs.Create());
}
}
}
C#
복사
예제 프로젝트의 Player.cs 는 이보다 깁니다. 무기(6장), 체력 이벤트(7장), 효과음(8장)이 더 들어 있습니다. 해당 장에서 한 덩어리씩 추가합니다.
코드 읽기
만들고 부수는 짝은 3장과 글자 하나 다르지 않습니다. OnInit 에서 상태 객체를 한 번 만들고, OnShow 의 맨 끝에서 CreateStateMachine(), OnHide 의 맨 앞에서 DestroyStateMachine(). PlayerSelectLogic 에서 익힌 틀을 그대로 옮겼습니다. 5장의 적도 똑같이 생겼습니다.
죽음은 세 단계로 나뉩니다. 이 부분이 이 장에서 가장 눈여겨볼 곳입니다.
sequenceDiagram
participant T as TargetableObject
participant P as Player
participant S as PlayerDyingState
participant G as SurvivalGame
T->>P: OnDead (체력이 0 이 된 순간)
Note over P: mDying = true<br>콜라이더 끄기, Die 애니메이션
Note over S: 다음 프레임에 Alive 가<br>IsDeathSequenceRunning 을 보고 전이
loop 1초 동안
S->>P: TickDeathSequence
end
P->>G: PlayerDied 이벤트 (NotifyDied)
P->>P: SafeHide
Note over G: GameOver = trueMermaid
복사
① OnDead — 체력이 0 이 된 그 순간. 깃발(mDying)을 세우고, 더 맞지 않게 콜라이더를 끄고, 쓰러지는 애니메이션을 겁니다. 사라지지도, 알리지도 않습니다. 4.2 절에서 본 부모의 OnDead 는 바로 SafeHide() 였지만, 여기서는 base.OnDead 를 부르지 않고 연출로 바꿨습니다.
② PlayerDyingState — 1초 동안 TickDeathSequence 가 타이머를 올립니다.
③ 1초 뒤 — NotifyDied() 로 게임 전체에 "플레이어가 죽었다"를 알리고, SafeHide() 로 사라집니다.
"죽었다"는 소식을 ①이 아니라 ③에서 보내는 것이 중요합니다. 이 소식을 받은 게임은 판을 끝내고 화면의 엔티티를 전부 치웁니다(9장). ①에서 바로 보내면 쓰러지는 애니메이션이 한 프레임도 보이기 전에 플레이어가 치워집니다. 연출이 끝난 뒤에 알린다는 순서가 코드의 구조로 강제돼 있습니다.
mDeathNotified 는 "한 번만 알린다"를 보장하는 깃발입니다. SafeHide 의 mHidden, 3장의 mCharacterSelectSubscribed 와 같은 종류입니다. 그리고 이 셋 모두 ResetRuntimeState 에서 되돌려집니다. 깃발을 하나 만들었으면 되돌리는 줄도 같이 만듭니다.
듣는 쪽 — SurvivalGame
사망 이벤트를 구독합니다. SubscribeEvents 와 UnsubscribeEvents 에 한 줄씩 추가하고,
events.Subscribe(PlayerDiedEventArgs.EventId, OnPlayerDied);
C#
복사
events.Unsubscribe(PlayerDiedEventArgs.EventId, OnPlayerDied);
C#
복사
핸들러를 씁니다.
private void OnPlayerDied(object sender, GameEventArgs e)
{
if (GameOver) return;
GameOver = true;
Log.Info("Game over. Final score: {0}", Score);
}
C#
복사
2장에서 만든 GameOver 속성이 처음으로 true 가 됩니다. 프로시저가 이 값을 보고 다음 단계로 넘어가는 것은 9장에서 만듭니다.
실행해 보기
아직 플레이어를 때려 줄 적이 없으니, 확인용으로 K 키를 누르면 50 의 피해를 입는 코드를 잠깐 넣습니다. Player.TickAliveInput 을 이렇게 바꿉니다.
internal void TickAliveInput()
{
ReadInput();
// 확인용: K 를 누르면 스스로 피해를 입는다. 확인한 뒤 지운다
Keyboard keyboard = Keyboard.current;
if (keyboard != null && keyboard.kKey.wasPressedThisFrame)
{
ApplyDamage(null, 50);
}
}
C#
복사
Play → 캐릭터를 고르고 → K 를 두 번 누릅니다. 체력이 100 이니 두 번째에 죽습니다.
K 를 두 번 누른 직후. 플레이어가 조작을 받지 않고 쓰러지는 연출에 들어간다. 1초 뒤 사라진다
Character selected: Girl
Player spawned: Girl
Game over. Final score: 0
Plain Text
복사
눈으로 확인할 것이 네 가지 있습니다.
•
두 번째 K 를 누른 뒤에는 WASD 가 먹지 않습니다. Alive 상태를 떠났기 때문입니다.
•
쓰러지는 동안 K 를 더 눌러도 아무 일도 없습니다. ApplyDamage 의 if (IsDead) return; 이 막습니다.
•
1초 뒤 플레이어가 사라지고 그제서야 Game over 로그가 찍힙니다.
•
플레이어가 사라지자 카메라가 선택 화면의 앵글로 돌아갑니다. EntityFinder.FindPlayer() 가 null 을 돌려주기 시작했기 때문입니다. 카메라에게 "게임이 끝났다"고 알려 준 코드는 없습니다.
실제 플레이 — 체력이 0 이 되면 조작이 멈추고 쓰러진다. 1초 뒤 플레이어가 사라지면 카메라가 선택 앵글로 돌아간다
화면에는 아무도 남지 않고 게임은 멈춘 듯 보입니다. 지금은 게임오버를 받아 줄 프로시저가 없어서입니다. 9장에서 이어집니다.
확인이 끝났으면 K 키 코드를 지우고 TickAliveInput 을 ReadInput(); 한 줄로 되돌립니다.
4.7 잘 안 될 때
증상 | 원인과 해결 |
캐릭터를 골라도 플레이어가 안 나온다. 콘솔에 플레이어 '…' 스폰에 실패했다 | 로그 끝의 "원인"을 읽습니다. 대부분 Addressables 주소 문제입니다 |
Player data is invalid. 또는 Targetable object data is invalid. | SpawnPlayer 에서 ShowEntity 의 마지막 인자로 PlayerData.Create(…) 가 아닌 것을 넘겼습니다 |
플레이어가 나오자마자 조작이 안 되고 1초 뒤 사라진다 | 체력 0 으로 태어났습니다. PlayerData.Create 에서 data.HitPoints = data.mMaxHP; 를 확인합니다 |
Already exist FSM '…'. 예외 | OnHide 에서 DestroyStateMachine() 을 부르지 않았습니다. 지난번 상태 기계가 같은 이름으로 남아 있습니다 |
WASD 가 안 먹는다 | ① Game 창을 클릭해 포커스를 줬는가 ② 콘솔에 Rigidbody 가 없어… 에러가 없는가 ③ mFsm.Start<PlayerAliveState>() 를 불렀는가(상태 기계는 Start 하기 전에는 돌지 않습니다) |
W 를 눌렀는데 화면 아래쪽으로 간다 | MainCamera 에 PlayerCameraFollow 를 붙이지 않았습니다. 선택 화면의 카메라는 반대편에서 비추기 때문에 방향이 뒤집혀 보입니다 |
캐릭터가 마우스를 따라 돌지 않는다 | 카메라에 MainCamera 태그가 없으면 MouseGround 가 항상 실패합니다 |
걷는 애니메이션이 안 나온다 | 콘솔에 Animator 를 찾지 못했다 경고가 있는지 봅니다. 없다면 애니메이터의 파라미터 이름이 IsWalking 인지 확인합니다. 이름이 틀려도 Unity 는 에러를 내지 않습니다 |
카메라가 안 따라온다. 콘솔에 smoothing 이 0 이라… | MainCamera 의 Player Camera Follow 인스펙터에서 Smoothing 값을 5 로 되돌립니다 |
이 장에서 만든 것
파일 | 내용 |
Entity/EntityData/TargetableObjectData.cs | 체력을 가진 엔티티의 주문서 |
Entity/EntityData/PlayerData.cs | 플레이어 주문서 — 최대 체력, 이동 속도 |
Entity/EntityLogic/TargetableObject.cs | 체력·피격·사망의 공통 부모 |
Entity/EntityLogic/Player.cs | 플레이어 로직 |
Entity/EntityLogic/States/Player…State.cs (4개) | Alive / Paused / Dying 과 공통 부모 |
Camera/PlayerCameraFollow.cs | 플레이어를 따라가는 카메라. 프레임워크 컴포넌트다 (MainCamera 에 붙인다) |
Utility/EntityFinder.cs | 지금 떠 있는 플레이어를 프레임워크에 물어 찾는 창구 |
Utility/MouseGround.cs | 커서 아래의 바닥 좌표 |
Utility/GamePause.cs | 일시정지 깃발 |
Game/PlayerDiedEventArgs.cs | "플레이어가 죽었다" 이벤트 |
Game/SurvivalGame.cs (수정) | 플레이어 스폰, 스폰 실패 처리, 사망 → GameOver |
(경로는 모두 Assets/GameMain/ 아래입니다.)
정리
•
프리팹은 생김새, 로직은 역할이다. 같은 프리팹에 ShowEntity 의 로직 타입만 바꿔 다른 역할을 입힌다
•
공통 동작은 부모에 한 번만 쓴다. 부모는 언제 부를지를, 자식은 불렸을 때 무엇을 할지를 정한다
•
풀에 돌아갈 것을 가리키는 변수는 돌려주기 전에 비운다
•
풀에서 꺼낸 객체는 필드 초기화식이 다시 돌지 않는다. 기본값은 상수로 빼고 Clear 에서 되돌린다
•
입력은 매 프레임(OnUpdate) 읽고, Rigidbody 는 물리 박자(FixedUpdate)에 움직인다
•
판이 바뀌어도 살아 있는 전역 시스템(카메라)은 프레임워크 컴포넌트(GameFrameworkComponent)로 만든다. 판마다 바뀌는 대상은 인스펙터로 연결하지 않고 매 프레임 프레임워크에 묻는다(EntityFinder)
•
엔티티에 정적 손잡이(Instance)를 두지 않는다. 누가 떠 있는지는 프레임워크가 이미 안다
•
게임 시간을 멈춰도 OnUpdate 는 불린다. 입력을 읽는 쪽이 일시정지를 직접 확인한다
•
죽음은 연출을 시작하는 순간과 알리는 순간을 나눈다. 연출이 끝난 뒤에 알린다
•
실패는 시끄럽게. 조용히 멈추는 게임이 가장 찾기 어렵다
직접 해 보기
1.
PlayerData 의 DefaultMoveSpeed 를 10f 로 바꿔 보세요. 그다음 PlayerCameraFollow 의 Smoothing 을 Inspector 에서 1 과 20 으로 바꿔 가며, 빠른 캐릭터를 따라가는 카메라의 느낌이 어떻게 달라지는지 봅니다. (Play 중에 바꾼 Inspector 값은 Play 를 멈추면 되돌아갑니다.)
2.
MoveByInput 에서 .normalized 를 지우고 W 와 D 를 함께 눌러 대각선으로 걸어 보세요. 직선으로 걸을 때보다 빠른 것이 느껴지나요? 확인한 뒤 되돌립니다.
3.
PlayerAliveState 에서 if (owner.IsDead) return; 위에 Log.Info("Alive tick"); 을 넣고 K 로 죽어 보세요. 죽은 뒤에는 로그가 멈춥니다. 상태 기계가 Dying 으로 넘어가 Alive 의 OnUpdate 가 더는 불리지 않기 때문입니다.
4.
(조금 어려움) SurvivalGame.Update 의 맨 끝에 "P 키를 누르면 GamePause.SetPaused(!GamePause.IsPaused);" 를 넣어 보세요(using UnityEngine.InputSystem; 이 필요합니다). P 를 누르면 캐릭터가 멈추고, 다시 누르면 움직입니다. 이 코드를 Player.TickAliveInput 에 넣으면 왜 한 번 멈춘 뒤 다시 풀 수 없는지도 생각해 보세요. W 를 누른 채로 P 를 눌렀다 떼 보고, PlayerPausedState.OnEnter 의 ClearMoveInput() 을 지웠을 때와 비교해 보세요.
더 읽기 —
다음 장 — : 플레이어를 쫓아오는 적을 만듭니다. 이번에는 코드가 아니라 표가 적의 종류를 정합니다.













