2025. 4. 25. 20:31ㆍ[Unity] Game Programing
개발 환경: Unity 6.0 LTS
1. 캐릭터 설계 아키텍처 개요
오늘은 멀티플레이어 생존 게임을 위한 캐릭터 시스템을 설계하고 구현했습니다. 전체적인 아키텍처는 상태 패턴(State Pattern)을 중심으로 설계되었으며, 캐릭터의 다양한 행동과 상태를 모듈화하고 확장 가능하게 구현했습니다.
주요 클래스 구조는 다음과 같습니다:
AlivePlayer: 생존 상태의 플레이어 캐릭터를 표현하는 메인 클래스로, 플레이어의 핵심 속성과 기능을 포함AlivePlayerStateMachine: 플레이어의 다양한 상태를 관리하는 스테이트 머신AlivePlayerState: 모든 플레이어 상태의 기본 클래스ResourceStat: 체력, 배고픔 등 플레이어의 자원을 관리하는 클래스
설계 원칙: 단일 책임 원칙(SRP)과 개방-폐쇄 원칙(OCP)을 중심으로 설계했습니다. 각 클래스는 명확한 하나의 책임을 가지며, 새로운 상태나 기능을 추가할 때 기존 코드를 수정하지 않고 확장할 수 있도록 했습니다.
2. AlivePlayer 클래스 구현
AlivePlayer 클래스는 플레이어 캐릭터의 중심 클래스로, 다양한 상태 값과 참조를 관리합니다. 이 클래스는 MonoBehaviour를 상속받아 Unity의 생명주기에 통합됩니다.
2.1 자원 관리 시스템
생존 게임의 특성을 반영하여 다양한 자원 시스템을 구현했습니다:
#region Stats
protected ResourceStat health; // 플레이어의 체력
protected ResourceStat hungerPoint; // 배고픔 수치
protected ResourceStat waterPoint; // 수분 수치
protected ResourceStat stamina; // 스태미나
protected ResourceStat temperature; // 체온
protected ResourceStat sanity; // 정신력
public ResourceStat Health => health;
public ResourceStat HungerPoint => hungerPoint;
public ResourceStat WaterPoint => waterPoint;
public ResourceStat Stamina => stamina;
public ResourceStat Temperature => temperature;
public ResourceStat Sanity => sanity;
#endregion
각 자원은 ResourceStat 클래스를 통해 관리됩니다. 이 클래스는 현재 값, 최대 값, 그리고 자원의 변화에 대한 이벤트를 포함합니다. 자원은 시간 경과에 따라 자연적으로 감소하거나, 플레이어의 행동에 따라 변화합니다.
예를 들어, 달리기는 스태미나를 소모하고, 물을 마시면 수분 수치가 증가하는 등의 메커니즘이 구현되어 있습니다. 이러한 자원들은 네트워크를 통해 동기화되어 다른 플레이어들에게도 정확하게 표시됩니다.
2.2 필수 컴포넌트 및 참조 관리
AlivePlayer 클래스는 다양한 컴포넌트와 참조를 관리합니다:
public InteractionHandler InteractionHandler { get; private set; }
public CharacterController CharacterController { get; private set; }
public Animator Animator { get; private set; }
[field: SerializeField] public AnimatorOverrideController overrideController { get; private set; }
[field: SerializeField] public CinemachineCamera CinemachineCamera { get; private set; }
[field: SerializeField] public AlivePlayerSO AlivePlayerSO { get; private set; }
[field: SerializeField] public AlivePlayerAnimationData AnimationData { get; private set; }
private AlivePlayerStateMachine stateMachine;
InteractionHandler는 플레이어의 상호작용을 처리하는 컴포넌트로, 아이템 획득, 문 열기, NPC와의 대화 등을 담당합니다. CharacterController는 Unity의 내장 컴포넌트로 캐릭터의 물리적 움직임을 처리합니다. Animator와 AnimatorOverrideController는 캐릭터의 애니메이션을 관리합니다.
AlivePlayerSO는 ScriptableObject를 통해 플레이어의 다양한 설정 값(이동 속도, 회전 속도 등)을 외부에서 조정할 수 있게 합니다. 이를 통해 디자이너가 프로그래머의 도움 없이도 밸런싱을 조정할 수 있습니다.
2.3 생명주기 메서드 및 초기화
Awake와 Start 메서드에서 필요한 초기화 작업을 수행합니다:
public void Awake()
{
InteractionHandler = GetComponent<InteractionHandler>();
CharacterController = GetComponent<CharacterController>();
Animator = GetComponentInChildren<Animator>();
AnimationData = new AlivePlayerAnimationData();
overrideController = new AnimatorOverrideController(Animator.runtimeAnimatorController);
Animator.runtimeAnimatorController = overrideController;
}
public void Start()
{
health = new ResourceStat(100);
hungerPoint = new ResourceStat(100);
waterPoint = new ResourceStat(100);
stamina = new ResourceStat(100);
temperature = new ResourceStat(100);
stateMachine = new AlivePlayerStateMachine(this);
}
이 메서드들은 필요한 컴포넌트를 찾아 참조하고, 자원 상태를 초기화하며, 상태 머신을 설정합니다. 특히 AnimatorOverrideController를 생성하여 런타임에 애니메이션을 동적으로 변경할 수 있게 합니다.
3. 캐릭터 상태 머신 구현
AlivePlayerStateMachine 클래스는 플레이어의 다양한 상태를 관리하는 클래스입니다. 이 클래스는 기본적인 StateMachine 인터페이스를 구현하여 상태 전환 로직을 처리합니다.
public class AlivePlayerStateMachine : StateMachine
{
public AlivePlayer Player { get; private set; }
public AlivePlayerStateReusableData ReusableData { get; private set; }
public IdlingState IdlingState { get; private set; }
public InterctingState InterctingState { get; private set; }
public RunningState RunningState { get; private set; }
public SprintingState SprintingState { get; private set; }
public AttackingState AttackingState { get; private set; }
public AimingState AimingState { get; private set; }
public DamagedState DamagedState { get; private set; }
public DeadState DeadState { get; private set; }
public AlivePlayerStateMachine(AlivePlayer player)
{
Player = player;
ReusableData = new AlivePlayerStateReusableData();
IdlingState = new IdlingState(this);
InterctingState = new InterctingState(this);
RunningState = new RunningState(this);
SprintingState = new SprintingState(this);
AttackingState = new AttackingState(this);
AimingState = new AimingState(this);
DamagedState = new DamagedState(this);
DeadState = new DeadState(this);
ChangeState(IdlingState);
}
}
상태 머신은 다양한 상태 객체들을 생성하고 초기 상태를 설정합니다. ReusableData 객체는 여러 상태 간에 공유되는 데이터를 저장하는 컨테이너로, 이를 통해 상태 간 전환 시 필요한 정보가 유지됩니다.
3.1 상태 간 전환 메커니즘
상태 전환은 현재 상태의 조건에 따라 결정됩니다. 예를 들어, 플레이어가 정지 상태에서 이동 입력을 받으면 달리기 상태로 전환합니다. 각 상태는 자신의 조건을 확인하고 필요시 상태 머신에 상태 전환을 요청합니다.
주의사항: 상태 전환 시 이전 상태의 모든 정리 작업(이벤트 구독 해제 등)을 확실히 수행해야 합니다. 그렇지 않으면 메모리 누수나 예기치 않은 동작이 발생할 수 있습니다.
3.2 공유 데이터 관리
AlivePlayerStateReusableData 클래스는 상태 간에 공유되는 데이터를 관리합니다:
public class AlivePlayerStateReusableData
{
public Vector2 MovementInput { get; set; }
public float MovementSpeedModifier { get; set; } = 1f;
public bool ShouldSprint { get; set; }
public float VerticalVelocity { get; set; }
public bool IsGrounded { get; set; }
public bool IsAiming { get; set; }
public Vector3 CurrentTargetRotation { get; set; }
}
이러한 데이터 구조를 통해 상태 간 전환 시 필요한 정보가 손실되지 않고 유지됩니다. 예를 들어, 달리기에서 점프로 전환할 때 현재의 이동 방향과 속도를 유지할 수 있습니다.
4. 상태 클래스 구현 상세
모든 상태는 AlivePlayerState 추상 클래스를 상속받아 구현됩니다. 이 클래스는 모든 상태에 공통된 기능을 제공합니다.
4.1 기본 상태 클래스
public abstract class AlivePlayerState : IState
{
protected AlivePlayerStateMachine stateMachine;
public AlivePlayerState(AlivePlayerStateMachine stateMachine)
{
this.stateMachine = stateMachine;
}
#region IState Methods
public virtual void Enter()
{
Debug.Log($"Enter {GetType().Name} state");
AddInputActionCallbacks();
}
public virtual void Exit()
{
RemoveInputActionCallbacks();
}
public virtual void FixedUpdate() { }
public virtual void Update()
{
ReadMovementInput();
Move();
}
#endregion
// 기타 메서드...
}
AlivePlayerState 클래스는 IState 인터페이스를 구현하여 상태 머신에서 호출할 수 있는 기본 메서드들을 제공합니다. 모든 상태는 필요에 따라 이 메서드들을 오버라이드하여 고유한 동작을 구현합니다.
4.2 주요 상태 구현
4.2.1 IdlingState - 정지 상태
IdlingState는 플레이어가 움직이지 않고 정지해 있는 상태를 나타냅니다. 이 상태에서는 입력을 대기하고, 입력에 따라 다른 상태로 전환합니다.
public class IdlingState : GroundedState
{
public IdlingState(AlivePlayerStateMachine stateMachine) : base(stateMachine) { }
public override void Enter()
{
base.Enter();
stateMachine.ReusableData.MovementSpeedModifier = 0f;
StartAnimation(stateMachine.Player.AnimationData.IdleHash);
}
public override void Exit()
{
base.Exit();
StopAnimation(stateMachine.Player.AnimationData.IdleHash);
}
protected override void AddInputActionCallbacks()
{
base.AddInputActionCallbacks();
Managers.Input.GetInput(EPlayerInput.Move).started += OnMovementStarted;
Managers.Input.GetInput(EPlayerInput.Attack).started += OnAttackStarted;
Managers.Input.GetInput(EPlayerInput.Interaction).started += OnInteractionStarted;
}
protected override void RemoveInputActionCallbacks()
{
base.RemoveInputActionCallbacks();
Managers.Input.GetInput(EPlayerInput.Move).started -= OnMovementStarted;
Managers.Input.GetInput(EPlayerInput.Attack).started -= OnAttackStarted;
Managers.Input.GetInput(EPlayerInput.Interaction).started -= OnInteractionStarted;
}
private void OnMovementStarted(InputAction.CallbackContext context)
{
stateMachine.ChangeState(stateMachine.RunningState);
}
private void OnAttackStarted(InputAction.CallbackContext context)
{
stateMachine.ChangeState(stateMachine.AttackingState);
}
private void OnInteractionStarted(InputAction.CallbackContext context)
{
if (stateMachine.Player.InteractionHandler.TryInteract())
{
stateMachine.ChangeState(stateMachine.InterctingState);
}
}
}
4.2.2 RunningState - 달리기 상태
RunningState는 플레이어가 일반적인 속도로 이동하는 상태를 나타냅니다.
public class RunningState : MovingState
{
public RunningState(AlivePlayerStateMachine stateMachine) : base(stateMachine) { }
public override void Enter()
{
base.Enter();
stateMachine.ReusableData.MovementSpeedModifier = stateMachine.Player.AlivePlayerSO.RunningSpeed;
StartAnimation(stateMachine.Player.AnimationData.RunHash);
}
public override void Exit()
{
base.Exit();
StopAnimation(stateMachine.Player.AnimationData.RunHash);
}
public override void Update()
{
base.Update();
if (stateMachine.ReusableData.MovementInput == Vector2.zero)
{
stateMachine.ChangeState(stateMachine.IdlingState);
return;
}
if (stateMachine.ReusableData.ShouldSprint)
{
stateMachine.ChangeState(stateMachine.SprintingState);
}
}
protected override void AddInputActionCallbacks()
{
base.AddInputActionCallbacks();
Managers.Input.GetInput(EPlayerInput.Attack).started += OnAttackStarted;
}
protected override void RemoveInputActionCallbacks()
{
base.RemoveInputActionCallbacks();
Managers.Input.GetInput(EPlayerInput.Attack).started -= OnAttackStarted;
}
private void OnAttackStarted(InputAction.CallbackContext context)
{
stateMachine.ChangeState(stateMachine.AttackingState);
}
}
4.3 움직임 및 물리 구현
모든 상태에서 공통으로 사용되는 움직임 로직은 AlivePlayerState 클래스에 구현되어 있습니다:
private void ReadMovementInput()
{
stateMachine.ReusableData.MovementInput = Managers.Input.GetInput(EPlayerInput.Move).ReadValue<Vector2>();
stateMachine.ReusableData.ShouldSprint = Managers.Input.GetInput(EPlayerInput.Sprint).IsPressed();
}
private void Move()
{
stateMachine.Player.CharacterController.Move(stateMachine.ReusableData.VerticalVelocity * Time.deltaTime);
if(stateMachine.ReusableData.MovementInput == Vector2.zero || stateMachine.ReusableData.MovementSpeedModifier == 0f)
{
return;
}
Vector3 movementDirection = GetMovementInputDirection();
float targetRotationYAngle = Mathf.Atan2(movementDirection.x, movementDirection.z) * Mathf.Rad2Deg;
stateMachine.Player.transform.rotation = Quaternion.Slerp(
stateMachine.Player.transform.rotation,
Quaternion.Euler(0f, targetRotationYAngle, 0f),
stateMachine.Player.AlivePlayerSO.RotationSpeed * Time.deltaTime
);
stateMachine.Player.CharacterController.Move(
movementDirection * stateMachine.ReusableData.MovementSpeedModifier * Time.deltaTime
);
}
이 코드는 입력 값을 읽고, 그에 따라 캐릭터를 회전시키고 이동시킵니다. 회전은 Slerp를 사용하여 부드럽게 처리되며, 이동은 CharacterController를 통해 물리 계산을 수행합니다.
5. 애니메이션 시스템
캐릭터의 애니메이션은 Unity의 Animator Controller를 사용하여 관리됩니다. 또한 AnimatorOverrideController를 사용하여 런타임에 애니메이션을 동적으로 변경할 수 있습니다.
5.1 애니메이션 해시 관리
애니메이션 파라미터 접근을 효율적으로 하기 위해 해시 코드를 사용합니다:
public class AlivePlayerAnimationData
{
public int IdleHash { get; private set; }
public int RunHash { get; private set; }
public int SprintHash { get; private set; }
public int AimHash { get; private set; }
public AlivePlayerAnimationData()
{
IdleHash = Animator.StringToHash("Idle");
RunHash = Animator.StringToHash("Run");
SprintHash = Animator.StringToHash("Sprint");
AimHash = Animator.StringToHash("Aim");
}
}
5.2 애니메이션 오버라이드
AlivePlayer 클래스에서는 특정 애니메이션을 동적으로 변경할 수 있는 메서드를 제공합니다:
public void SetInteractAnimation(AnimationClip animationClip)
{
stateMachine.Player.overrideController["Interaction"] = animationClip;
}
public void SetAttackAnimation(AnimationClip animationClip)
{
stateMachine.Player.overrideController["Attack"] = animationClip;
}
이 메서드들은 장착한 무기나 상호작용하는 대상에 따라 적절한 애니메이션을 설정할 수 있게 합니다. 예를 들어, 도끼를 들고 있을 때와 칼을 들고 있을 때 다른 공격 애니메이션을 보여줄 수 있습니다.
6. 향후 개발 계획
현재 구현된 캐릭터 시스템을 기반으로 다음과 같은 기능들을 추가할 계획입니다:
- 인벤토리 시스템: 플레이어가 아이템을 수집하고 관리할 수 있는 시스템
- 장비 시스템: 무기, 도구 등을 장착하고 사용할 수 있는 시스템
- 크래프팅 시스템: 수집한 재료로 새로운 아이템을 제작할 수 있는 시스템
- 환경 영향 시스템: 날씨, 시간 등 환경 요소가 플레이어에게 미치는 영향을 구현
- AI 상호작용: NPC 및 몬스터와의 상호작용 시스템
- 퀘스트 시스템: 플레이어에게 목표와 보상을 제공하는 시스템
7. 결론 및 학습 내용
오늘의 개발을 통해 Unity에서 상태 패턴을 활용한 캐릭터 시스템 구현 방법을 익혔습니다. 특히 다음과 같은 개념과 기술을 습득했습니다:
- 상태 패턴을 사용한 모듈화된 캐릭터 시스템 설계
- CharacterController를 활용한 물리 기반 이동 구현
- AnimatorOverrideController를 활용한 동적 애니메이션 변경
- 입력 시스템과 상태 패턴의 연동
- 리소스 관리 시스템 설계 및 구현
앞으로 FishNet 네트워킹과 Steam SDK를 통합하여 완전한 멀티플레이어 환경을 구축할 예정입니다. 이를 통해 플레이어들이 함께 협력하고 경쟁할 수 있는 생존 게임을 개발할 계획입니다.
'[Unity] Game Programing' 카테고리의 다른 글
| TIL : 유니티 게임 시스템 구현 (1) | 2025.04.29 |
|---|---|
| TIL: AlivePlayer 시스템 심층 분석 (상태 머신, 데이터 관리, 컴포넌트 구조) (1) | 2025.04.28 |
| Today I Learned - Unity에서 Google Sheets 사용법 정리 (0) | 2025.04.24 |
| UniTask (0) | 2025.04.21 |
| UniRx: 유니티에서의 반응형 프로그래밍 (1) | 2025.04.08 |