버프와 디버프 시스템은 게임마다 구현 방식이 다양하기 때문에 다른 사람의 코드를 참고하기에는 요구사항이 너무 달라 난감한 경우가 있습니다. 그렇기에 설계가 잘된 베이스코드에 살을 붙여가는 게 맞는 방식이라 생각합니다. 이번 시간에는 코루틴을 이용하여 확장성이 높은 버프 및 디버프 시스템을 구현하고 설명하는 시간을 갖고자 합니다.
[SOLID 원칙이란?]
객체 지향 프로그래밍에서 사용 되는 다섯가지 설계 원칙 입니다. 이 원칙을 지켜서 코드를 설계할 경우 유확장성 및 유지 보수성이 높은 코드를 작성할 수 있다는 장점이 있습니다. 짧게만 설명 하고 넘어가겠습니다.
단일 책임 원칙(Single Responsibility Principle – SRP)
클래스는 하나의 책임만 가져야 한다는 원칙입니다. 버프를 관리하는 클래스는 이것만을 관리해야 한다는 것 입니다.
개방 폐쇄 원칙(Open-Closed Principle – OCP)
확장에는 열려있어야 하지만 수정에는 닫혀 있어야 한다는 원칙 입니다. 새로운 기능을 추가할 때 코드를 변경하지 않고 확장할 수 있어야 한다는 것 입니다. 여러 버프를 추가할 때 기존의 코드를 수정할 필요가 없어야 한다는 것 입니다.
리스코프 대체 원칙(Liskov Substitution Principle – LSP)
파생 클래스는 기본 클래스를 대체할 수 있어야 한다는 것 입니다. 자식 클래스가 부모 클래스를 어느 곳이든 대체가 가능 해야 한다는 것 입니다.
인터페이스 분리 원칙(Interface Segregation Principle – ISP)
사용하지 않는 메서드에 의존하지 않아야 합니다. 예를 들어서 버프 이펙트의 메서드를 사용하고 싶은데 해당 클래스에 디버프의 메서드까지 같이 존재하면 안된다는 것 입니다.
의존성 역전 원칙 (Dependency Inversion Principle – DIP)
상위 수준의 모듈이 하위 수준의 모듈을 직접적으로 의존하지 않아야 한다는 원칙입니다. 또한 두 모듈은 추상화에 의존해야 한다는 원칙입니다.
이렇게 원칙을 소개해도 코드를 구현하다 보면 지키기도 쉽지 않고 하나씩 생각하다 보면 머리만 더 복잡해 지니 제가 구현한 코드를 보시고 학습하는 것이 좋은 방법이라 생각 됩니다.
[구현]
기본 클래스
public abstract class StatusEffect
{
protected float duration;
protected GameObject target;
public StatusEffect(float duration, GameObject target)
{
this.duration = duration;
this.target = target;
}
public abstract void ApplyEffect();
public abstract void RemoveEffect();
}
중복되는 기능들을 추상화 하여 확장성을 높였습니다.
공격력 증가 버프
이해를 돕기 위해서 공격력을 증가 시키는 효과를 구현해 보겠습니다.
// 공격력 증가 버프
public class AttackBuff : StatusEffect
{
private float attackIncrease;
public AttackBuff(float duration, GameObject target, float attackIncrease) : base(duration, target)
{
this.attackIncrease = attackIncrease;
}
public override void ApplyEffect()
{
// 공격력을 증가시킴
// 예시로 Debug.Log를 사용하였지만, 실제로 공격력을 증가시켜야 함
Debug.Log("Attack buff applied: +" + attackIncrease + " Attack");
}
public override void RemoveEffect()
{
// 예시로 Debug.Log를 사용하였지만, 실제로 공격력을 복원해야 함
Debug.Log("Attack buff removed: -" + attackIncrease + " Attack");
}
}
StatusEffect를 상속 받아서 공격력 증가 효과를 구현 했습니다. 이런 식으로 ApplyEffect메서드와 RemoveEffect를 공통적으로 사용하면 버프나 디버프를 적용할 때 코드의 많은 추가 없이 구현이 가능합니다.
도트 데미지 디버프
이해를 돕기 위해서 도트 데미지를 예시로 코드를 작성해 보겠습니다.
// 도트 디버프
public class DamageOverTimeDebuff : StatusEffect
{
private float damagePerSecond;
private Coroutine damageCoroutine;
public DamageOverTimeDebuff(float duration, GameObject target, float damagePerSecond) : base(duration, target)
{
this.damagePerSecond = damagePerSecond;
}
public override void ApplyEffect()
{
// 주기적으로 피해를 입히는 코루틴 시작
damageCoroutine = StartCoroutine(InflictDamageOverTime());
Debug.Log("DoT debuff applied: " + damagePerSecond + " damage per second");
}
public override void RemoveEffect()
{
// 디버프 종료 시 피해 코루틴 중단
if (damageCoroutine != null)
{
StopCoroutine(damageCoroutine);
}
Debug.Log("DoT debuff removed");
}
private IEnumerator InflictDamageOverTime()
{
while (duration > 0)
{
// 피해를 입히는 부분
DealDamageToTarget(damagePerSecond);
// 주기적으로 피해를 입히는 간격(예: 1초)을 기다립니다.
yield return new WaitForSeconds(1f);
duration -= 1f;
}
}
private void DealDamageToTarget(float damage)
{
// 실제로 피해를 입히는 부분
// 이 예시에서는 Debug.Log를 사용하였지만, 실제로는 대상에게 피해를 입혀야 함
Debug.Log("Dealing " + damage + " damage to the target");
}
}
코루틴을 이용하여 초당 피해를 입히도록 설계하였습니다. 이렇게 구현할 경우 독 데미지나 화염 데미지등을 구현할 때 개별적으로 작동하게 할 수 있기때문에 효과적 입니다.
효과를 받는 캐릭터
public class Character : MonoBehaviour
{
private List<StatusEffect> activeEffects = new List<StatusEffect>();
public void ApplyBuff(StatusEffect effect)
{
effect.ApplyEffect();
activeEffects.Add(effect);
StartCoroutine(RemoveEffectAfterDuration(effect));
}
private IEnumerator RemoveEffectAfterDuration(StatusEffect effect)
{
yield return new WaitForSeconds(effect.duration);
effect.RemoveEffect();
activeEffects.Remove(effect);
}
public void RemoveAllEffects()
{
foreach (StatusEffect effect in activeEffects)
{
effect.RemoveEffect();
}
activeEffects.Clear();
}
}
핵심은 activeEffects을 캐릭터가 들고 있다는 것 입니다. 이렇게 상태효과를 관리할 경우 특정 디버프를 삭제 시켜주는 버프(정화)를 구현하기도 용이 합니다. 여러 모로 확장성을 향상 시킬 수 있는 방법 입니다.
실제 사용
Character player = GetComponent<Character>();
player.ApplyBuff(new AttackBuff(10f, player.gameObject, 20f)); // 10초 동안 공격력이 20 증가
player.ApplyBuff(new DamageOverTimeDebuff(5f, player.gameObject, 5f)); // 5초 동안 5의 도트 데미지
사용하고자 하는 곳에서 위 와 같이 ApplyBuff 메서드를 실행 시켜주면 됩니다. 슈팅 게임이라면 총알 클래스의 OnTrigger 메서드나 OnCollision메서드가 그 예시가 되겠습니다.
[이 시스템의 장점]
- 간단하기에 이해하기도 쉽고 어떤 프로젝트든 수정 및 추가하여 사용할 수 있다는 점 입니다. 또한 SOLID원칙을 준수하고 있기에 여러가지 요구사항이 필요한 시스템을 구현하기에 좋습니다.
- 지속 시간이 있는 효과를 관리하기 용이 합니다.
[이 시스템의 단점]
- 기본 클래스 이기 때문에 이외의 필요한 기능은 추가해서 구현해야 한다는 점 입니다. 예시에 없는 적의 공격을 일정시간 하지 못하게 하는 침묵 효과 같은 것 입니다.
[제공 해준 코드를 효과적으로 사용하는 방법]
제가 설명해 드린 SOLID 원칙을 이해하고 구현한 코드에 어떻게 적용돼 있는지 파악해 봐야 합니다. 그리고 제공 된 코드를 베이스로 하여 직접 필요한 기능을 구현해 보고 SOLID 원칙을 지켜서 구현 했을 때 이점들을 몸소 느껴보는 것이 중요합니다.
[마무리]
많은 기능이 추가 돼 있는 코드를 참고하여 버프 및 디버프 기능을 구현하는 것도 좋은 방법 입니다. 하지만 경험 상 그렇게 많은 기능이 있는 코드들은 이해하는 것도 시간이 걸립니다. 그렇기에 저는 핵심적인 기능이 제공돼 있는 코드를 참고 한 뒤 그 이후는 제가 직접 설계해서 구현하는 방식을 선호 합니다. 저와 같은 방법을 선호하는 분들께는 이 코드가 유용하게 쓰이실 겁니다.