
게임을 하다보면 이미 적을 죽일 수 있는 투사체가 날라가고 있음에도 해당 적에게 투사체를 날려서 낭비되는 경우가 있습니다. 이를 '오버킬' 이라고 합니다.
예를 들면 스타크래프트 에서 시즈탱크가 동시에 시즈모드를 설정할 때에나, 몇몇 모바일 게임에서도 심심치않게 확인할 수 있습니다.
저는 이러한 상황을 방지할 수 있는 Auto Battler System을 만들어, 항상 최적의 자동 배틀을 할 수 있도록 해보겠습니다.
public interface IDamagable
{
public bool IsAbleToTargeted();
/// <summary>
/// 데미지를 예약함.
/// 이를 통해 상대는 죽을 타겟이라는걸 미리 알고 다른 타겟을 찾음
/// </summary>
public void ReserveDamage(DamageType type, float damage, float duration);
/// <summary>
/// def 상관없이 hp 감소
/// </summary>
public void GetDelayedDamage(DamageType type, float trueDamage);
/// <summary>
/// 데미지를 입력으로 받아서 defense 로직 처리 후 hp 감소
/// </summary>
public void GetImmediateDamage(DamageType type, float damage);
public void CheckIfDied();
}
데미지를 받는 쪽에서 구현해야하는 인터페이스입니다.
일반적인 IDamagable 인터페이스와는 다르게 ReserveDamage 함수를 통해 damage를 예약할 수 있고, 그러한 예약 시스템 때문에 공격하기 직전에 타겟이 죽는지를 판단하여 투사체를 낭비하지 않을 수 있습니다.
public bool IsAbleToTargeted()
{
return afterHP > 0f; // 이미 죽는게 예약된 상황이면 Target되지 않음
}
public void ReserveDamage(DamageType type, float damage, float duration)
{
if (isEnemyDead) return;
// 데미지 계산해서 afterHP에 반영
float trueDamage = Calculation.CalculateDamage(unitData.StatsByLevel[0], type, damage);
afterHP -= trueDamage;
// UniTask를 취소하기위한 CancellationTokenSource를 Dictionary에 저장
var cts = new CancellationTokenSource();
reservedDamage[damageId] = cts;
// UniTask 호출
DelayedDamage(type, trueDamage, duration, cts.Token).Forget();
}
private async UniTaskVoid DelayedDamage(DamageType type, float damage, float duration, CancellationToken ct)
{
try
{
// duration만큼 기다렸다가
await UniTask.Delay((int)(duration * 1000));
// DelayedDamage 적용
if (!ct.IsCancellationRequested)
GetDelayedDamage(type, damage);
}
catch (OperationCanceledException)
{
// UniTask 도중 취소
}
finally
{
reservedDamage.Remove(damageId);
}
}
DelayedDamage 는 UniTask 로 구현하였습니다.
간단하게 duration 만큼 기다렸다가 GetDelayedDamage 함수를 호출하여 unit의 hp에 영향을 줍니다.
public void GetDelayedDamage(DamageType type, float trueDamage)
{
if (isEnemyDead) return;
currentHP -= trueDamage;
UIManager.Instance.GameUI.ShowDamage(transform.position + Vector3.up * 1.8f, trueDamage, type, HitResultType.Normal);
CheckIfDied();
ApplyKnockback();
}
public void GetImmediateDamage(DamageType type, float damage)
{
if (isEnemyDead) return;
float trueDamage = Calculation.CalculateDamage(unitData.StatsByLevel[0], type, damage);
afterHP -= trueDamage;
currentHP -= trueDamage;
UIManager.Instance.GameUI.ShowDamage(transform.position + Vector3.up * 1.8f, trueDamage, type, HitResultType.Normal);
CheckIfDied();
ApplyKnockback();
}
public void CheckIfDied()
{
if (currentHP <= 0f)
{
OnDead();
}
}
실제로 데미지를 반영하는 부분입니다.
GetDelayedDamage 는 DelayedDamaged UniTask 함수에서만 호출하며, 이미 afterHP 를 업데이트 해주었기 때문에 currentHP 만 수정하고 그에 따른 반응을 줍니다. (넉백, UI, Damage기록 등..)
GetImmediateDamage 같은 경우에는 외부에서 즉각적인 데미지를 줄 때 호출하는 함수이며, afterHP 와 currentHP 를 모두 업데이트 해준 뒤에 반응을 처리합니다.
이를 공격관련 함수에서 다음과 같이 호출할 수 있습니다.
public override void Attack(Transform target)
{
if (target == null || target.GetComponent<IDamagable>() == null) return;
target.GetComponent<IDamagable>().ReserveDamage(unitData.DamageType,
unitData.StatsByLevel[0].AttackPower,
unitData.AttackDelay);
TrailBase tb = PoolingManager.Instance.Spawn(Utils.ProjectileType.Lightning, unitData.AttackDelay)
.GetComponent<TrailBase>();
tb.SetTrail(transform.position, target, unitData.AttackDelay);
}
ReserveDamage 함수를 통해 데미지를 예약하고, VFX 또한 duration과 같이 실행시켜줍니다.
하지만, 아직 부족한 부분이 있습니다.

위 영상처럼 이미 죽는게 예정된 적에게는 더이상 공격을 하지않기 때문에, 일시적으로 무적상태가 됩니다.
활쏘는 유닛의 입장에서는 이게 최선의 방법이지만, 옆에 공격속도가 더 빠른 유닛이 있었다면 해당 유닛을 때리지 못해 손해를 보는 상황이 생길 수도 있습니다.
따라서 afterDelay 에 더불어 죽을 시간을 계산해서 만약 자신의 공격이 죽기전에 닿을 수 있다면 타겟으로 지정될 수 있도록 수정해보도록 하겠습니다.
우선 GetDelayedDamage 를 삭제하고, 방어력이 변동될 수 있다는 점을 고려해서 HP를 닳게하기 직전에 계산하도록 수정했습니다.
추가적으로, Target될 지 여부를 판단하는데 쓰이던 afterHP 는 삭제하고, 대신에 예약된 데미지들을 담는 SortedDictionary 를 사용하여 필요할때마다 계산하도록 했습니다.
public class ReservationKey : IComparable<ReservationKey>
{
public int dID; // 데미지를 구분
public float time; // 데미지를 정렬하기 위해 Key에 넣음
public ReservationKey(int dID, float time)
{
this.dID = dID;
this.time = time;
}
public int CompareTo(ReservationKey other)
{
if (other == null) return 1;
int timeComparison = time.CompareTo(other.time);
if (timeComparison != 0)
return timeComparison;
return dID.CompareTo(other.dID);
}
}
public class DamageReservation
{
public float damage; // 방어력을 적용하기 전의 데미지
public DamageType type; // 데미지 타입 (물리/마법)
public CancellationTokenSource cancellationTokenSource; // UniTask 삭제 시 사용되는 토큰
public DamageReservation(CancellationTokenSource cancellationTokenSource, float damage, DamageType type)
{
this.cancellationTokenSource = cancellationTokenSource;
this.damage = damage;
this.type = type;
}
}
private SortedDictionary<ReservationKey, DamageReservation> reservedDamage = new();
데미지 예약을 기록해두기 위한 class와 collection입니다.
SortedDictionary 를 사용하여 삽입/삭제는 빠르게하였고, 순서대로 순회가 가능하도록 하였습니다.
또한 Key에는 ID와 Time값을 넣어 정렬은 하되 구분은 할 수 있도록 했습니다.
public bool IsAbleToTargeted(float duration)
{
if (currentHP <= 0f) return false;
if (!isTargetFlagDirty) return isAbleToTargeted;
float tmpHP = currentHP;
float lastTime = 0;
foreach (var res in reservedDamage)
{
tmpHP -= Calculation.CalculateDamage(unitData.StatsByLevel[0], res.Value.type, res.Value.damage);
if (tmpHP <= 0f)
{
lastTime = res.Key.time;
break;
}
}
isAbleToTargeted = (tmpHP > 0f || (tmpHP <= 0f && Time.time + duration < lastTime));
isTargetFlagDirty = false;
return isAbleToTargeted;
}
IsAbleToTargeted 함수입니다.
위에선 단순히 afterHP 가 0 이하인지만 확인했었지만, 지금은 직접 계산을 해야합니다.
물론, 한 Unit이라도 수십 수백명의 다른 Unit들이 해당 함수를 호출할 수 있기 때문에, DirtyFlag 패턴을 사용하여 계산을 최소화할 수 있도록 했습니다.
DirtyFlag는 해당 Dictionary를 수정하는 부분에서 켜주었습니다.
public void ReserveDamage(DamageType type, float damage, float duration)
{
if (isEnemyDead) return;
var cts = new CancellationTokenSource();
ReservationKey resKey = new ReservationKey(++damageId, Time.time + duration);
reservedDamage[resKey] = new DamageReservation(cts, damage, type);
isTargetFlagDirty = true;
DelayedDamage(type, damage, duration, resKey).Forget();
}

활 유닛의 공격으로도 이미 죽을 예정이지만, 자신의 공격으로 인해 더 빨리 죽일 수 있다면 타겟으로 설정되는 것을 확인하실 수 있습니다.
DirtyFlag 패턴 또한 적용했으니 연산은 크게 늘어나지 않을 것입니다.
플레이어의 내부 로직은 여기서 다루면 너무 길어질 것 같아서 생략했습니다.
더 자세한 코드는 Github에서 확인하실 수 있습니다.
Combat관련된 부분은 UnitController_Combat.cs 클래스에 있고, 일반적인 이동 로직은 UnitController.cs에 있습니다.