[개발일지] Combat - Optimized Auto Battler System

qweasfjbv·2025년 6월 19일

개발일지

목록 보기
8/8
post-thumbnail

개요


게임을 하다보면 이미 적을 죽일 수 있는 투사체가 날라가고 있음에도 해당 적에게 투사체를 날려서 낭비되는 경우가 있습니다. 이를 '오버킬' 이라고 합니다.

예를 들면 스타크래프트 에서 시즈탱크가 동시에 시즈모드를 설정할 때에나, 몇몇 모바일 게임에서도 심심치않게 확인할 수 있습니다.

저는 이러한 상황을 방지할 수 있는 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);
			}
		}

DelayedDamageUniTask 로 구현하였습니다.
간단하게 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();
			}
		}

실제로 데미지를 반영하는 부분입니다.

GetDelayedDamageDelayedDamaged UniTask 함수에서만 호출하며, 이미 afterHP 를 업데이트 해주었기 때문에 currentHP 만 수정하고 그에 따른 반응을 줍니다. (넉백, UI, Damage기록 등..)

GetImmediateDamage 같은 경우에는 외부에서 즉각적인 데미지를 줄 때 호출하는 함수이며, afterHPcurrentHP 를 모두 업데이트 해준 뒤에 반응을 처리합니다.


이를 공격관련 함수에서 다음과 같이 호출할 수 있습니다.

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에 있습니다.

0개의 댓글