-
Skill 시스템 구현 및 개선게임 클라이언트 개발/MakeDNF 2024. 1. 10. 21:12
이전 프로젝트에선 스킬 사용을 위한 로직을 Skill 클래스에, 실제 피격 기능이나 이펙트와 같은 부분은 Projectile 클래스에 구현했었다. 하지만 새로운 스킬을 구현할 때마다 해당 설계에 문제점이 발견되어 어떻게 개선할지 오랜 시간 고민하였고 해당 내용들을 작성하고자 한다.
기존 Skill 시스템 구현의 문제점
피격 기능 및 이펙트 출력 기능까지 모두 Projectile 클래스에 구현한 점
스킬에는 여러 종류가 있다. 충전을 통해 범위를 증가시키는 스킬이 있고, 연속적으로 키를 입력하여 추가 스킬을 사용하는 경우가 있다. 예를 들어 여귀검사의 기본 공격의 경우, 공격 키를 계속 입력하여 최대 3회까지 연속적인 기본 공격을 사용한다.
이렇게 스킬을 사용했는지 여부나 충전, 콤보 입력, 또는 스킬 모션 등 스킬 사용과 관련된 부분은 Skill 클래스의 역할이라고 생각했다.
다음으로 피격 판정이나 이펙트 출력과 관련된 부분은 Projectile 클래스의 역할이라고 생각했다. 예를 들어 여귀검사의 기본 공격을 사용할 경우 보이지 않은 Projectile 오브젝트를 생성하여 피격 판정을 진행한다고 생각했다.
하지만 위 설계로 구현할 경우 Skill 클래스와 Projectile 클래스의 Coupling이 매우 심해졌다. 스킬 사용 중 피격당해 스킬이 캔슬되거나 역경직으로 인해 스킬이 잠깐 멈출 경우 등, Skill 클래스에선 반드시 생성한 Projectile 오브젝트를 멤버 변수로 할당하여 계속해서 참조해야 한다.
또한 Projectile이란 이름의 의미가 퇴색됐다. "투사체란 단어가 위 기본 공격과 같은 상황에 맞는 단어인가"라는 의문이 계속해서 들었고, 이는 원래 의도했던 Projectile 클래스의 역할과 다른 방향성을 갖고 있다고 판단하게 되었다.
코루틴 사용
시간의 흐름에 따라 스킬의 상황을 반영하기 위해 Update와 코루틴 중에 고민했었다. Update를 활용하여 구현할 경우 전체적인 스킬의 진행 상황을 확인할 수 없으며 Update, FixedUpdate, LateUpdate 중 구현하지 않는 함수가 있다면 빈 virtual 함수를 호출해야 했기 때문에 코루틴을 활용하여 스킬을 구현했었다.
// 코루틴으로 구현한 기본 공격 로직 중 스킬 사용 부분 public override IEnumerator UseSkill(Animator p_anim, bool p_isLeft, string p_button) { StartCoroutine(comboController.CheckComboInput(p_button)); var t_cnt = 0; while (t_cnt < numCombo) { yield return PreDelay(p_anim, delay[t_cnt], skillMotion[t_cnt]); ActivateSkill(p_anim, p_isLeft, p_button, projectile[t_cnt]); yield return PostDelay(p_anim, duration[t_cnt], delay[t_cnt], skillMotion[t_cnt]); if (comboController.NumOfClick <= ++t_cnt) break; } comboController.Flag = false; }
하지만, Skill 사용을 코루틴으로 구현함에 따라 Coupling이 있는 CharacterAttack, Character 클래스 모두 공격 로직에서 코루틴을 사용해야 했다. 이동이나 점프와 같은 다른 행동들은 Update로 구현했기에 호환성이 매우 떨어지는 결과를 맞이했다.
또한 피격되거나 다른 스킬을 사용하여 Character 클래스에서 실행 중인 코루틴을 캔슬할 경우, 연결돼 있는 CharacterAttack, Skill 클래스는 어느 시점에서 코루틴이 캔슬되었는지 모르기 때문에 추가적인 변수 검사 로직이 필요했다.
private void OnDamage(int p_damage, Vector3 p_dir, float p_hitStunTime, float p_knockBackPower) { // 진행 중인 코루틴이 존재하는지 확인 if (runningCo != null) StopCoroutine(runningCo); // CharacterAttack 클래스는 초기화가 안됬으므로 추가적인 확인 로직 if (attackController.RunningSkill != null) attackController.CancelSkill(attackController.RunningSkill); // ... 피격 이벤트 로직 작성 }
메모리 관련 문제도 발생했다. WaitFor 류의 객체들은 캐싱하여 재사용할 경우 GC를 그나마 덜 생성할 수 있었지만, 스킬을 사용할 때마다 StartCoroutine 함수를 호출할 것이고, StartCoroutine 함수 자체에서 발생하는 GC는 따로 처리할 수 있는 방법을 찾지 못했다. 추가로 코루틴과 Update를 비교했을 때 코루틴을 활용한 로직의 실행 시간이 훨씬 오래 걸렸다. 코루틴 객체를 위한 메모리를 할당하는 작업이 추가되기 때문이라고 예상되며 추후 사용하는 스킬이 많아질 경우 스파크 현상이 발생할 수 있다고 판단했다.
추상화가 아닌 구체화에 의존한 점
보통 스킬을 사용한다고 생각하면 선딜레이 -> 스킬 발동 (이펙트 출력 및 피격 판정 시작) -> 후딜레이의 순으로 진행된다고 생각했다. 물론 선딜레이와 후딜레이가 없는 스킬들도 존재하지만 모든 스킬을 대응하기 위해 함수를 작성했다.
// 선딜레이 처리 함수 protected IEnumerator PreDelay(Animator p_anim, float p_delay, string p_skillMotion = null); // 애니메이션 및 투사체 생성 등 스킬 활성화 함수 protected virtual void ActivateSkill(Animator p_anim, bool p_isLeft, string p_button, string p_projectile, Vector3? p_pos = null, float p_chargingValue = 1f); // 후딜레이 처리 함수 protected IEnumerator PostDelay(Animator p_anim, float p_duration, float p_delay, string p_skillMotion = null);
또한 다양한 스킬을 대응하기 위해 가능한 모든 멤버 변수들을 선언했었다.
// 수많은 맴버 변수 [SerializeField] private int needMana = 0; [SerializeField] protected string[] projectile = null; [SerializeField] protected string[] skillMotion = null; [SerializeField] protected float[] duration = null; [SerializeField] protected float[] delay = null; [SerializeField] protected float coolTime = 0f; protected float waitingTime = 0f;
이렇게 모든 스킬들에 대응하다 보니 새로운 스킬을 구현할 때마다 Base 클래스를 수정하는 일이 빈번했다. 새로운 기능을 추가하기 위해 Base 클래스를 수정하면 연쇄적으로 기존에 구현돼 있는 스킬들에도 변경 사항이 발생하여 계속해서 작업량이 늘어났다.
개선된 Skill 시스템
Skill 클래스와 Projectile 클래스의 역할 재정의
우선 Skill 클래스와 Projectile 클래스의 역할을 재정의했다. Skill 클래스는 스킬 사용에 전반적인 모든 기능을 작성하고, Projectile 클래스는 스킬 사용 시 캐릭터와 완전히 분리되는, 말 그대로 "투사체"에 대한 기능을 작성했다. 이로 인해 피격되거나 다른 스킬을 사용할 때 스킬이 캔슬되는 기능을 Skill 클래스에만 접근하여 스킬을 캔슬하는 로직을 작성함으로써 구현할 수 있었다. 또한 Skill 클래스에서 Projectile 오브젝트를 생성한 후 따로 접근할 필요가 없어졌다.
Update문 사용 및 상태 패턴을 적용한 추상화 의존
Skill 클래스와 Projectile 클래스의 코루틴을 사용하던 로직을 전부 Update문으로 수정했고, 모두 추상화에 의존하고 해당 클래스를 상속받은 자식 클래스에서 스킬을 구현하도록 수정했다. 비록 Update문을 사용함으로써 구현되지 않은 빈 virtual 함수들을 호출해야 하지만, 새로운 메모리 할당을 하지 않기 때문에 스파크 현상은 줄어들 것이라고 예상된다.
// Skill.cs public abstract class Skill : Monobehaviour { #region Event Methods public virtual void OnStart() { } public virtual void OnUpdate() { } public virtual void OnFixedUpdate() { } public virtual void OnLateUpdate() { } public virtual void OnCance() { } public virtual void OnComplete() { } #endregion Event Methods } // Projectile.cs public abstract class Projectile : Monobehaviour { #region Unity Events private virtual void Update() { } private virtual void FixedUpdate() { } private virtual void LateUpdate() { } #endregion Unity Events #region Event Methods public virtual void OnStart() { } public virtual void OnComplete() { } #endregion Event Methods }
또한 하나의 스킬에도 여러 State가 존재한다. 예를 들어 여귀검사의 악즉참 스킬의 경우 충전하고 난무 후 피니쉬 어택을 사용한다.
이를 위해 상태 패턴을 적용하여 각 Skill의 State를 구현할 수 있도록 설계했다. Skill 뿐 아니라 Projectile에도 State를 추가하여 해당 클래스를 상속받아 다양한 스킬을 구현할 수 있도록 설계했다.
Charging State에서 충전한 시간만큼 메테오와 폭발의 크기가 커지도록 구현했으며 메테오가 땅에 닿을 경우 폭발하도록 State를 통해 구현했다.
'게임 클라이언트 개발 > MakeDNF' 카테고리의 다른 글
Object Pooling을 위한 Tool 제작 (0) 2024.01.11 Hitbox와 Hitbox Editor 개선 (0) 2024.01.11 DNF Input System (0) 2023.12.11 DNF 충돌 시스템 (0) 2023.12.03 Hitbox with DNFTransform (0) 2023.12.02