2022. 10. 4. 17:47ㆍProjects/Charon
모듈화의 필요성을 느끼다
기존 PlayerController 스크립트를 통해 기능을 추가하려면, 기존 소스코드를 수정해야 해서 확장이 어려웠습니다.
저희 게임에는 대시, 대시 공격, 차지 공격, 기본 공격 콤보 3타 등등 한 상태에서 다른 상태로 분기되는 동작들이 많았습니다. 그래서 지금 코드 구조로 계속 확장하다가는 세계 제일의 스파게티를 만들 것 같아서 구조 변경이 필요하다고 생각했습니다.
이전에 디자인 패턴(Degisn Pattern)들을 보던 중, 상태 패턴(State Pattern)을 적용하면 Controller의 기능을 추가하는 게 굉장히 쉬울 것 같았습니다. 제가 어떤 식으로 구조를 바꾸었는지 아래에서 보면서 천천히 설명해 드릴게요.
1. 모든 상태들의 기원이 되는 부모 추상 클래스, BaseState 정의하기
각 상태들은 다음과 같은 기능들을 필요로 합니다.
- 현재 상태에서 행동의 주체가 되는 Controller
- 상태에 진입했을 때, 실행되는 OnEnterState()
- 현재 상태에서 계속 갱신되어야 하는 정보를 업데이트하는 OnUpdateState()
- 현재 상태에서 물리와 관련하여 업데이트하는 OnFixedUpdate()
- 현재 상태를 종료할 때, 실행되는 OnExitState()
위와 같이 정리를 하고, 다음과 같이 추상 클래스를 정의해 주었습니다.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace CharacterController
{
public abstract class BaseState
{
protected PlayerController Controller { get; private set; }
public BaseState(PlayerController controller)
{
this.Controller = controller;
}
public abstract void OnEnterState();
public abstract void OnUpdateState();
public abstract void OnFixedUpdateState();
public abstract void OnExitState();
}
}
이제 이 BaseState 클래스를 상속 받아 이동(Move), 대시(Dash), 공격(Attack)과 같은 각 상태 클래스를 만들어서 추상 메소드들을 구현하면 되겠네요! 각 상태들을 구현하는 건 뒤에 하기로 하고, 이제 상황에 따라 상태를 전환시켜주는 클래스인 StateMachine이 필요합니다.
2. StateMachine 클래스 정의하기
StateMachine 클래스는 필요한 상태들을 Dictionary로 관리하게 됩니다.
namespace CharacterController
{
public enum StateName
{
MOVE = 100,
DASH,
ATTACK,
}
}
using System.Collections.Generic;
using System.Diagnostics;
namespace CharacterController
{
public class StateMachine
{
public BaseState CurrentState { get; private set; } // 현재 상태
private Dictionary<StateName, BaseState> states =
new Dictionary<StateName, BaseState>();
public StateMachine(StateName stateName, BaseState state)
{
AddState(stateName, state);
CurrentState = GetState(stateName);
}
public void AddState(StateName stateName, BaseState state) // 상태 등록
{
if (!states.ContainsKey(stateName))
{
states.Add(stateName, state);
}
}
public BaseState GetState(StateName stateName) // 상태 꺼내오기
{
if (states.TryGetValue(stateName, out BaseState state))
return state;
return null;
}
public void DeleteState(StateName removeStateName) // 상태 삭제
{
if (states.ContainsKey(removeStateName))
{
states.Remove(removeStateName);
}
}
public void ChangeState(StateName nextStateName) // 상태 전환
{
CurrentState?.OnExitState(); //현재 상태를 종료하는 메소드를 실행하고,
if (states.TryGetValue(nextStateName, out BaseState newState)) // 상태 전환
{
CurrentState = newState;
}
CurrentState?.OnEnterState(); // 다음 상태 진입 메소드 실행
}
public void UpdateState()
{
CurrentState?.OnUpdateState();
}
public void FixedUpdateState()
{
CurrentState?.OnFixedUpdateState();
}
}
}
이제 준비는 다 했으니, Player와 PlayerController 클래스를 조금 정리해보도록 하겠습니다.
3. Player와 PlayerController 클래스 정리하기
Player 클래스
Player 클래스는 싱글톤(Singleton) 패턴으로 구현되어 있습니다. 게임하는 내내 데이터가 유지되어야 하기 때문이죠.
싱글톤이기에 다른 클래스 어디에서도 전역적으로 접근할 수 있습니다. 그래서 각 상태 클래스에서 행동을 구현하는데 필요한 컴포넌트 및 클래스들은 여기에 두었습니다. Rigidbody, Animator, StateMachine 등등 말이죠. 다만 외부에서 수정은 함부로 못하게 읽기 전용 프로퍼티로 두었습니다.
이제 Player 클래스는 말 그대로 플레이어의 정보들만 담고 있는 클래스가 된 것입니다.
*상태 패턴 진행하고, 이것 저것 좀 많이 만들고 수정한 후에 글 쓰는 거라 필요한 부분만 걸러내기가 힘드네요..ㄷㄷ
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using CharacterController;
public class Player : MonoBehaviour
{
public static Player Instance { get { return instance; } }
public StateMachine stateMachine { get; private set; }
public Rigidbody rigidBody { get; private set; }
public Animator animator { get; private set; }
public CapsuleCollider capsuleCollider { get; private set; }
private static Player instance;
#region #캐릭터 스탯
public float MaxHP { get { return maxHP; } }
public float CurrentHP { get { return currentHP; } }
public float Armor { get { return armor; } }
public float MoveSpeed { get { return moveSpeed; } }
public int DashCount { get { return dashCount; } }
[Header("캐릭터 스탯")]
[SerializeField] protected float maxHP;
[SerializeField] protected float currentHP;
[SerializeField] protected float armor;
[SerializeField] protected float moveSpeed;
[SerializeField] protected int dashCount;
#endregion
#region #Unity 함수
void Awake()
{
if(instance == null)
{
instance = this;
rigidBody = GetComponent<Rigidbody>();
animator = GetComponent<Animator>();
capsuleCollider = GetComponent<CapsuleCollider>();
DontDestroyOnLoad(gameObject);
return;
}
DestroyImmediate(gameObject);
}
void Start()
{
InitStateMachine();
}
void Update()
{
stateMachine?.UpdateState();
}
void FixedUpdate()
{
stateMachine?.FixedUpdateState();
}
#endregion
public void OnUpdateStat(float maxHP, float currentHP, float armor, float moveSpeed, int dashCount)
{
this.maxHP = maxHP;
this.currentHP = currentHP;
this.armor = armor;
this.moveSpeed = moveSpeed;
this.dashCount = dashCount;
}
private void InitStateMachine()
{
// 아직 상태들을 안 만들었으니, 만들고 여기에서 등록하도록 합시다.
...
}
}
각 상태에서 업데이트 되어야 하는 부분들은 각 Unity 내장 함수들에서 실행시켜 주었습니다.
void Update()
{
stateMachine?.UpdateState();
}
void FixedUpdate()
{
stateMachine?.FixedUpdateState();
}
PlayerController 클래스
PlayerController 클래스는 기존에는 입력도 받고, 거기에 따른 행동도 수행했기 때문에 클래스 규모가 좀 컸습니다.
이동, 대시 동작을 수행하는 부분의 코드들도 여기에 있었기 때문이죠. 이제 PlayerController 클래스는 동작은 수행하지 않고, 사용자의 입력 정보만 받아 관리합니다.
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.InputSystem.Interactions;
using CharacterController;
[RequireComponent(typeof(Player))]
public class PlayerController : MonoBehaviour
{
public Player player { get; private set; }
public Vector3 inputDirection { get; private set; } // 키보드 입력으로 들어온 이동 방향
public Vector3 calculatedDirection { get; private set; } // 경사 지형 등을 계산한 방향
public Vector3 gravity { get; private set; }
#region #경사 체크 변수
[Header("경사 지형 검사")]
[SerializeField, Tooltip("캐릭터가 등반할 수 있는 최대 경사 각도입니다.")]
float maxSlopeAngle;
[SerializeField, Tooltip("경사 지형을 체크할 Raycast 발사 시작 지점입니다.")]
Transform raycastOrigin;
private const float RAY_DISTANCE = 2f;
private RaycastHit slopeHit;
private bool isOnSlope;
#endregion
#region #바닥 체크 변수
[Header("땅 체크")]
[SerializeField, Tooltip("캐릭터가 땅에 붙어 있는지 확인하기 위한 CheckBox 시작 지점입니다.")]
Transform groundCheck;
private int groundLayer;
private bool isGrounded;
#endregion
#region #UNITY_FUNCTIONS
void Start()
{
player = GetComponent<Player>();
groundLayer = 1 << LayerMask.NameToLayer("Ground");
}
void Update()
{
calculatedDirection = GetDirection(player.MoveSpeed * MoveState.CONVERT_UNIT_VALUE);
ControlGravity();
}
#endregion
public void OnDashInput(InputAction.CallbackContext context)
{
if (context.performed)
{
// 상태를 추가한 후, 여기서는 상태 전환만 해주도록 합니다.
}
}
protected Vector3 GetMouseWorldPosition()
{
Vector3 mousePosition = Mouse.current.position.ReadValue();
Ray ray = Camera.main.ScreenPointToRay(mousePosition);
Debug.DrawRay(ray.origin, ray.direction * 1000f, Color.red, 5f);
if (Physics.Raycast(ray, out RaycastHit HitInfo, Mathf.Infinity))
{
Vector3 target = HitInfo.point;
Vector3 myPosition = new Vector3(transform.position.x, 0f, transform.position.z);
target.Set(target.x, 0f, target.z);
return (target - myPosition).normalized;
}
return Vector3.zero;
}
protected Vector3 GetDirection(float currentMoveSpeed)
{
isOnSlope = IsOnSlope();
isGrounded = IsGrounded();
Vector3 calculatedDirection =
CalculateNextFrameGroundAngle(currentMoveSpeed) < maxSlopeAngle ?
inputDirection : Vector3.zero;
calculatedDirection = (isGrounded && isOnSlope) ?
AdjustDirectionToSlope(calculatedDirection) : calculatedDirection.normalized;
return calculatedDirection;
}
protected void ControlGravity()
{
gravity = Vector3.down * Mathf.Abs(player.rigidBody.velocity.y);
if (isGrounded && isOnSlope)
{
gravity = Vector3.zero;
player.rigidBody.useGravity = false;
return;
}
player.rigidBody.useGravity = true;
}
private float CalculateNextFrameGroundAngle(float moveSpeed)
{
// 다음 프레임 캐릭터 앞 부분 위치
Vector3 nextFramePlayerPosition =
raycastOrigin.position + inputDirection * moveSpeed * Time.fixedDeltaTime;
if (Physics.Raycast(nextFramePlayerPosition, Vector3.down,
out RaycastHit hitInfo, RAY_DISTANCE, groundLayer))
{
return Vector3.Angle(Vector3.up, hitInfo.normal);
}
return 0f;
}
public bool IsGrounded()
{
Vector3 boxSize = new Vector3(transform.lossyScale.x, 0.4f, transform.lossyScale.z);
return Physics.CheckBox(groundCheck.position, boxSize, Quaternion.identity,
groundLayer);
}
public bool IsOnSlope()
{
Ray ray = new Ray(transform.position, Vector3.down);
if (Physics.Raycast(ray, out slopeHit, RAY_DISTANCE, groundLayer))
{
var angle = Vector3.Angle(Vector3.up, slopeHit.normal);
return angle != 0f && angle < maxSlopeAngle;
}
return false;
}
public void OnMoveInput(InputAction.CallbackContext context)
{
Vector2 input = context.ReadValue<Vector2>();
inputDirection = new Vector3(input.x, 0f, input.y);
}
public void LookAt(Vector3 direction)
{
if (direction != Vector3.zero)
{
Quaternion targetAngle = Quaternion.LookRotation(direction);
transform.rotation = targetAngle;
}
}
protected Vector3 AdjustDirectionToSlope(Vector3 direction)
{
Vector3 adjustVelocityDirection =
Vector3.ProjectOnPlane(direction, slopeHit.normal).normalized;
return adjustVelocityDirection;
}
}
우리가 이전 글들에서 만들었던 경사로 지형 검사, 땅에 붙어 있는지 검사, 중력 적용은 여기에서 기본적으로 적용되도록 두었습니다. 캐릭터에 PlayerController 스크립트만 붙이면 자동적으로 해당 기능들이 지원되도록 말이죠.
그리고 사용자의 입력을 받아서 받은 정보들만 가지고 있는 모습을 볼 수 있습니다. 이제 이동, 대시와 같은 상태를 정의하여 거기에서 행동을 구현하되, 행동에 필요한 정보는 여기에서 가져올 겁니다.
4. 상태 정의하여 등록하기
이동 상태(MoveState) 정의하여 등록하기
우리가 기존에 PlayerController에서 이동에 필요했던 정보들 있죠? 그걸 이 클래스에서 선언하고 똑같이 적어줄 겁니다.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using static UnityEditor.Experimental.GraphView.GraphView;
namespace CharacterController
{
public class MoveState : BaseState
{
public const float CONVERT_UNIT_VALUE = 0.01f;
public const float DEFAULT_CONVERT_MOVESPEED = 3f;
public const float DEFAULT_ANIMATION_PLAYSPEED = 0.9f;
private int hashMoveAnimation;
public MoveState(PlayerController controller) : base(controller)
{
hashMoveAnimation = Animator.StringToHash("Velocity");
}
protected float GetAnimationSyncWithMovement(float changedMoveSpeed)
{
if (Controller.inputDirection == Vector3.zero)
{
return -DEFAULT_ANIMATION_PLAYSPEED;
}
// (바뀐 이동 속도 - 기본 이동속도) * 0.1f
return (changedMoveSpeed - DEFAULT_CONVERT_MOVESPEED) * 0.1f;
}
public override void OnEnterState()
{
// 필요없는 부분이지만, 추상 메소드는 구현해야 하므로 비어 둔다.
}
public override void OnUpdateState()
{
// 필요없는 부분이지만, 추상 메소드는 구현해야 하므로 비어 둔다.
}
// 이동은 Rigidbody 기반이므로, FixedUpdate()에서 구현해줍니다.
public override void OnFixedUpdateState()
{
float currentMoveSpeed = Controller.player.MoveSpeed * CONVERT_UNIT_VALUE;
float animationPlaySpeed = DEFAULT_ANIMATION_PLAYSPEED + GetAnimationSyncWithMovement(currentMoveSpeed);
Controller.LookAt(Controller.inputDirection);
Player.Instance.rigidBody.velocity = Controller.calculatedDirection * currentMoveSpeed + Controller.gravity;
Player.Instance.animator.SetFloat(hashMoveAnimation, animationPlaySpeed);
}
// 이동 상태를 종료할 때는 애니메이션과 물리 속도를 초기화 해줘야 한다.
public override void OnExitState()
{
Player.Instance.animator.SetFloat(hashMoveAnimation, 0f);
Player.Instance.rigidBody.velocity = Vector3.zero;
}
}
}
PlayerController에서 이동 구현했던 걸 그대로 가져와서 적은 겁니다. 이제 이걸 StateMachine에 상태를 등록하여 줍시다.
// Player 클래스
private void InitStateMachine()
{
PlayerController controller = GetComponent<PlayerController>();
stateMachine = new StateMachine(StateName.MOVE, new MoveState(controller)); // 등록
}
이렇게 등록해줬으면 이제 끝입니다. 실행해서 한 번 잘 되는지 테스트 해보죠.
당연히 기능적으로 변한 건 없습니다. 단지 유지보수 및 확장을 용이하게 하기 위해 적용하는 것이지요. 대시도 한 번 똑같이 적용하여 봅시다.
대시 상태(DashState) 정의하여 등록하기
DashState 클래스도 정의하여 행동을 구현해주도록 합시다.
코드를 쓰다보니, 대시(Dash)에 필요한 변수들이 저렇게 많더군요. 저게 다 PlayerController 클래스 안에 있었으니 얼마나 클래스가 힘들었겠습니까... 아, 그리고 Animator의 Apply RootMotion을 대시 중일 때는 끄고 평상 시에는 켜는 걸로 구현해주었습니다. 나중에 게시할 공격 애니메이션들이 RootMotion이 필요한 애들이라 이렇게 조치를 취했어요!
이제 PlayerController에서 대시 입력을 받으면 대시 상태로 전환해주는 코드만 추가해주면 되겠네요.
N단 대시도 구현을 해줘야 하는데, 저는 자기 상태에서 자기 상태로의 전환을 막아놓지 않았습니다.
대신 입력에서 조건을 보고 만족하면 상태를 전환하도록 해놨죠.
위의 코드를 보면 isAvailableDash 변수에 담아놓은 조건들이 바로 그 부분입니다. StateMachine에 등록하고 한 번 테스트 해보죠. 이동 상태(MoveState)와 대시 상태(DashState)를 매끄럽게 오고 가는 것을 볼 수 있습니다.
대시 중에는 이동 키 입력이 먹히면 안 되는데, 대시 상태일 때는 이동 상태 코드들이 실행되지 않으니 자연스럽게 적용되었습니다. 이동 상태를 구현하였고, 거기에 대시 상태를 추가하여 기능 확장을 하였지만 이동 상태 코드들은 수정할 필요가 없었습니다. 확장이 얼마나 편해졌는지 체감할 수 있었네요.
기존의 대시 상태는 하드 코딩적인 부분이 많았고, 부자연스럽고, 입력 버퍼가 없어 사용자가 N단 대시를 사용하기 어려웠습니다. 이에 따라 리팩토링을 진행하였고, 🔗해당 게시글 링크를 여기에 첨부할 예정입니다.
이것으로 상태 패턴을 이용하여 코드 기능 확장을 편하게 할 수 있도록 적용한 내용들을 글로 써보았습니다. 다음 글에는 무기 탈장착 기본 틀과 기본 3타 콤보 공격이 주제가 되겠네요.
긴 글 읽어주셔서 감사합니다!
'Projects > Charon' 카테고리의 다른 글
[Charon] #7. 무기 기본 3타 콤보 공격 구현하기 (긴 글 주의) (0) | 2022.10.06 |
---|---|
[Charon] #6. 휴머노이드 애니메이션 수정과 무기 탈장착 시스템 기반 만들기 (1) | 2022.10.05 |
[Charon] #4. RigidBody를 이용한 3D 캐릭터 N단 대시(Dash) 구현하기 (4) | 2022.09.21 |
[Charon] #3. RigidBody를 사용한 3D 캐릭터의 경사로(Slope) 지형 이동 구현하기 (9) | 2022.08.30 |
[Charon] #2. 이동 애니메이션(Move Animation) 적용 및 싱크(Sync) 맞추기 (0) | 2022.08.18 |