| 졸업작품 프로젝트의 전투 시스템 설명
아군 펭귄과 적 펭귄이 싸울 때 데미지를 어떻게 주고받고, 또 그것에 따른 여러 이벤트를 어떻게 주고받는지 설명
간략하게 설명하자면, 펭귄 객체의 전투를 담당하는 컴포넌트로는 Health, EntityActionData, EntityAttackData 이렇게 세 종류가 있습니다.
Health.cs 에서는 체력을 관리하고, 데미지를 입거나 기절, 넉백 등의 피해를 입는 로직을 실행합니다.
EntityActionData.cs 에는 데미지를 입었을 때의 정보(데미지 입은 위치, 데미지의 타입 등)을 담습니다.
EntityAttackData.cs 에서는 DamageCaster의 공격 로직들을 추상화하고, 펭귄의 공격 방식(근접인지, 원거리인지)에 따라 상속받아 그 공격 로직들을 사용할 수 있게 합니다.
Feedback 시스템은 다른 글에서 따로 설명
| 코드 상세 설명
(현재 코드들이 다소 난잡하여, 핵심들만 정리하였습니다.)
Health.cs
public class Health : MonoBehaviour, IDamageable
{
public int maxHealth;
public int currentHealth;
private float armor
{
get
{
var result = _onwer.armor.GetValue() * 0.01f;
return result;
}
}
private BaseStat _onwer;
#region ActionEvent
public Action OnHit;
public Action OnDied;
public UnityEvent WaterFallEvent;
public UnityEvent OnHitEvent;
public UnityEvent OnDeathEvent;
public UnityEvent OnDashDeathEvent;
public UnityEvent<float, float> OnUIUpdate;
public UnityEvent OffUIUpdate;
#endregion
private FeedbackController feedbackCompo = null;
private EntityActionData _actionData;
public bool IsDead = false;
private void Awake()
{
IsDead = false;
_actionData = GetComponent<EntityActionData>();
feedbackCompo = GetComponent<FeedbackController>();
}
public void SetHealth(BaseStat owner)
{
_onwer = owner;
currentHealth = maxHealth = owner.GetMaxHealthValue();
}
public void SetMaxHealth(BaseStat owner)
{
maxHealth = owner.GetMaxHealthValue();
}
public void ApplyDamage(int damage, Vector3 point, Vector3 normal, HitType hitType, TargetObject hitTarget, bool isFeedback = true)
{
if (IsDead) return;
_actionData.HitPoint = point; //맞은 위치
_actionData.HitNormal = normal;
_actionData.HitType = hitType; //때린 객체의 공격 타입
_actionData.HitTarget = hitTarget; //때린 객체
float adjustedDamage = damage * (1 - armor); //방어력 계산하여 데미지 할당
currentHealth = (int)Mathf.Clamp(currentHealth - adjustedDamage, 0, maxHealth); //현재 체력에 계산된 데미지를 뺌
OnUIUpdate?.Invoke(currentHealth, maxHealth); //체력바 업데이트를 위한 델리게이트 실행
if (currentHealth <= 0)
{
Dead(); //만약 현재 체력이 0이하라면 Dead로직 실행
}
}
public void ApplyHitType(HitType hitType)
{
_actionData.HitType = hitType;
}
public void ApplyHeal(int amount)
{
currentHealth = Mathf.Min(currentHealth + amount, maxHealth);
OnUIUpdate?.Invoke(currentHealth, maxHealth);
}
private void Dead() //IsDead true로 바꿔주고 Dead와 관련된 이벤트/액션들 실행
{
OnDied?.Invoke();
OnDeathEvent?.Invoke();
OffUIUpdate?.Invoke();
IsDead = true;
}
}
먼저 최대 체력 maxHealth와 현재 체력 currentHealth를 갖는다. SetHealth 메서드를 통해 Health를 가지고 있는 객체의 Stat을 부여받습니다. EntityActionData를 갖습니다.
ApplyDamage 메서드
매개변수로 데미지, 맞은 위치, 맞은 타입, 때린 객체를 받습니다. 받은 매개변수를 EntityActionData에 각각 할당해줍니다.
매개변수로 들어온 데미지를 방어력 계산하여 할당하고, 계산된 데미지 값을 currentHealth에 빼줍니다.
EntityActionData.cs
public class EntityActionData : MonoBehaviour
{
[HideInInspector] public Vector3 HitPoint;
[HideInInspector] public Vector3 HitNormal;
[HideInInspector] public HitType HitType;
//나 때린 객체
public TargetObject HitTarget;
}
DamageCaster.cs
public class DamageCaster : MonoBehaviour
{
[SerializeField]
[Range(0.1f, 3f)]
private float _detectRange = 1f;
[SerializeField]
private HitType _hitType;
public LayerMask TargetLayer;
private TargetObject _owner;
private General General => _owner as General;
public void SetOwner(TargetObject owner, bool setPos = false)
{
_owner = owner;
if (setPos)
{
SetPosition();
}
}
public void SetPosition()
{
transform.localPosition = new Vector3(0, 0.5f, 0);
}
/// <summary>
/// 스패셜 데미지
/// </summary>
public bool CastSpecialDamage(float AfewTimes)
{
RaycastHit raycastHit;
bool raycastSuccess = Physics.Raycast(transform.position, transform.forward, out raycastHit, _detectRange, TargetLayer);
if (raycastSuccess
&& raycastHit.collider.TryGetComponent<IDamageable>(out IDamageable raycastHealth))
{
int damage = (int)(_owner.Stat.damage.GetValue() * AfewTimes);
raycastHealth.ApplyDamage(damage, raycastHit.point, raycastHit.normal, _hitType, _owner);
return true;
}
return false;
}
/// <summary>
/// 광역 데미지
/// </summary>
public void CaseAoEDamage(float knbValue = 0f, float stunValue = 0f)
{
var Colls = Physics.OverlapSphere(transform.position, _detectRange, TargetLayer);
foreach (var col in Colls)
{
RaycastHit raycastHit;
var dir = (col.transform.position - transform.position).normalized;
dir.y = 0;
bool raycastSuccess = Physics.Raycast(transform.position, dir, out raycastHit, _detectRange, TargetLayer);
if (raycastSuccess
&& raycastHit.collider.TryGetComponent<Health>(out Health health))
{
int damage = _owner.Stat.damage.GetValue();
health.ApplyDamage(damage, raycastHit.point, raycastHit.normal, _hitType, _owner);
health.Knockback(knbValue, raycastHit.normal);
health.Stun(stunValue);
}
}
}
//외부에서 설정
public void CaseAoEDamage(float range, int damage = 0, float knbValue = 0f, float stunValue = 0f)
{
var Colls = Physics.OverlapSphere(transform.position, range, TargetLayer);
foreach (var col in Colls)
{
RaycastHit raycastHit;
var dir = (col.transform.position - transform.position).normalized;
dir.y = 0;
bool raycastSuccess = Physics.Raycast(transform.position, dir, out raycastHit, _detectRange, TargetLayer);
if (raycastSuccess
&& raycastHit.collider.TryGetComponent<Health>(out Health health))
{
health.ApplyDamage(damage, raycastHit.point, raycastHit.normal, _hitType, _owner);
health.Knockback(knbValue, raycastHit.normal);
health.Stun(stunValue);
}
}
}
/// <summary>
/// 단일 데미지
/// </summary>
/// <returns> 공격 맞았나 여부</returns>
public bool CastDamage(float knbValue = 0f, float stunValue = 0f)
{
RaycastHit raycastHit;
//var dir = (_owner.CurrentTarget.transform.position - transform.position).normalized;
bool raycastSuccess = Physics.Raycast(transform.position, transform.forward, out raycastHit, _detectRange, TargetLayer);
if (raycastSuccess
&& raycastHit.collider.TryGetComponent<Health>(out Health health))
{
int damage = _owner.Stat.damage.GetValue();
float critical = _owner.Stat.criticalChance.GetValue() * 0.01f;
int criticalValue = _owner.Stat.criticalValue.GetValue();
float adjustedDamage;
float dice = UnityEngine.Random.value;
HitType originType = _hitType;
if (dice < critical)
{
_hitType = HitType.CriticalHit;
adjustedDamage = damage * (1.0f + (criticalValue * 0.01f));
damage = (int)adjustedDamage;
}
health.ApplyDamage(damage, raycastHit.point, raycastHit.normal, _hitType, _owner);
health.Knockback(knbValue, raycastHit.normal);
health.Stun(stunValue);
_hitType = originType;
return true;
}
return false;
}
public void CastWork()
{
RaycastHit raycastHit;
bool raycastSuccess = Physics.Raycast(transform.position, transform.forward, out raycastHit, _detectRange, TargetLayer);
if (raycastSuccess
&& raycastHit.collider.TryGetComponent<Health>(out Health health))
{
int damage = _owner.Stat.damage.GetValue();
float critical = _owner.Stat.criticalChance.GetValue() * 0.01f;
int criticalValue = _owner.Stat.criticalValue.GetValue();
float adjustedDamage;
float dice = UnityEngine.Random.value;
HitType originType = _hitType;
if (dice < critical)
{
_hitType = HitType.CriticalHit;
adjustedDamage = damage * (1.0f + (criticalValue * 0.01f));
damage = (int)adjustedDamage;
}
health.ApplyDamage(damage, raycastHit.point, raycastHit.normal, _hitType, _owner);
_hitType = originType;
}
/* var Colls = Physics.OverlapSphere(transform.position, _detectRange, TargetLayer);
foreach (var col in Colls)
{
if (col.TryGetComponent<Health>(out Health health))
{
int damage = _owner.Stat.damage.GetValue();
health.ApplyDamage(damage, col.transform.position, col.transform.position, _hitType, _owner);
}
}*/
}
public void CastDashDamage()
{
var Colls = Physics.OverlapSphere(transform.position, _detectRange * 2f, TargetLayer);
foreach (var col in Colls)
{
RaycastHit raycastHit;
var dir = (col.transform.position - transform.position).normalized;
dir.y = 0;
bool raycastSuccess = Physics.Raycast(transform.position, dir, out raycastHit, _detectRange * 2f, TargetLayer);
if (raycastSuccess
&& raycastHit.collider.TryGetComponent<Health>(out Health health))
{
if (health.currentHealth < health.maxHealth * 0.5f)
{
health.ApplyDamage(100, raycastHit.point, raycastHit.normal, _hitType, _owner);
if (health.IsDead)
{
health.ApplyHitType(HitType.DashHit);
health.OnDashDeathEvent?.Invoke();
General.skill.CanUseSkill = true;
_hitType = HitType.KatanaHit;
return;
}
}
else
{
General.skill.CanUseSkill = false;
int damage = _owner.Stat.damage.GetValue() * 2;
health.ApplyDamage(damage, raycastHit.point, raycastHit.normal, _hitType, _owner);
}
}
}
}
public bool CastArrowDamage(Collider coll, LayerMask targetLayer)
{
if (coll.TryGetComponent<IDamageable>(out IDamageable raycastHealth) && ((1 << coll.gameObject.layer) & targetLayer) != 0)
{
int damage = _owner.Stat.damage.GetValue();
float critical = _owner.Stat.criticalChance.GetValue() * 0.01f;
int criticalValue = _owner.Stat.criticalValue.GetValue();
float adjustedDamage;
float dice = UnityEngine.Random.value;
HitType originType = _hitType;
if (dice < critical)
{
_hitType = HitType.CriticalHit;
adjustedDamage = damage * (1.0f + (criticalValue * 0.01f));
damage = (int)adjustedDamage;
}
raycastHealth?.ApplyDamage(damage, coll.transform.position, coll.transform.position, _hitType, _owner);
_hitType = originType;
return true;
}
return false;
}
public bool CastMeteorDamage(Vector3 position, LayerMask targetLayer)
{
Collider[] colliders = Physics.OverlapSphere(position, _detectRange * 3, targetLayer);
foreach (Collider collider in colliders)
{
IDamageable damageable = collider.GetComponent<IDamageable>();
if (damageable != null)
{
int damage = _owner.Stat.damage.GetValue();
damageable.ApplyDamage(damage, position, collider.transform.position, _hitType, _owner);
}
}
return true;
}
public bool CastBombDamage()
{
Collider[] colliders = Physics.OverlapSphere(transform.position, _detectRange * 0.7f, TargetLayer);
IDamageable selfDamageable = _owner.GetComponent<IDamageable>();
if (selfDamageable != null)
{
int selfDamage = _owner.Stat.damage.GetValue();
selfDamageable.ApplyDamage(selfDamage * 2, transform.position, _owner.transform.position, _hitType, _owner); //혹시나 안죽을까봐 본인한테는 데미지 2배로
}
foreach (Collider collider in colliders)
{
IDamageable damageable = collider.GetComponent<IDamageable>();
if (damageable != null)
{
int damage = _owner.Stat.damage.GetValue();
damageable.ApplyDamage(damage, transform.position, collider.transform.position, _hitType, _owner);
}
}
return true;
}
public void SelectTypeAOECast(int damage, HitType hitType, SoundName sound, float knbValue = 0f, float stunValue = 0f, float range = 2)
{
var Colls = Physics.OverlapSphere(transform.position, range, TargetLayer);
SoundManager.Play3DSound(sound, transform.position);
foreach (var col in Colls)
{
RaycastHit raycastHit;
var dir = (col.transform.position - transform.position).normalized;
dir.y = 0;
bool raycastSuccess = Physics.Raycast(transform.position, dir, out raycastHit, range, TargetLayer);
if (raycastSuccess
&& raycastHit.collider.TryGetComponent<Health>(out Health health))
{
health.ApplyDamage(damage, raycastHit.point, raycastHit.normal, hitType, _owner);
health.Knockback(knbValue);
health.Stun(stunValue);
}
}
}
#region BuildingDamageCast
public bool CastBuildingAoEDamage(Vector3 position, LayerMask targetLayer, int damage) // 건물은 Entity 상속 안 받아서 매개변수로 데미지 받음
{
bool isHit = false;
Collider[] colliders = Physics.OverlapSphere(position, _detectRange * 3, targetLayer);
foreach (Collider collider in colliders)
{
//IDamageable damageable = collider.GetComponent<IDamageable>();
//if (damageable != null)
//{
// damageable.ApplyDamage(damage, position, collider.transform.position, _hitType, _owner);
// isHit = true;
//}
if (collider.TryGetComponent(out Health health))
{
health.ApplyDamage(damage, position, collider.transform.position, _hitType, _owner, false); // 이펙트 2개 나가서 여기서 bool로 처리. ApplyDamage에서 이펙트 하면 사라지는 것도 이상함
isHit = true;
health.Knockback(0.05f, collider.transform.position); // 내 생각에 넉백 있어야 할 것 같아서 그냥 하드코딩한 값으로 넣었음
}
}
return isHit;
}
public void CastBuildingStunDamage(Health enemyHealth, RaycastHit hit, float duration, int damage)
{
enemyHealth.ApplyDamage(damage, hit.point, hit.normal, _hitType, _owner);
enemyHealth.Stun(duration);
}
#endregion
#if UNITY_EDITOR
private void OnDrawGizmos()
{
if (UnityEditor.Selection.activeObject == gameObject)
{
Gizmos.color = Color.red;
Gizmos.DrawWireSphere(transform.position, _detectRange);
Gizmos.color = Color.white;
Gizmos.color = Color.red;
Gizmos.DrawLine(transform.position, transform.forward);
}
}
#endif
}
Raycast를 활용하여 만든 다양한 공격 로직들을 담은 스크립트입니다. 공격 로직 종류는 일반 공격, 광역공격, 원거리 공격, 마법 공격 등등 있습니다. 너무 종류가 많으니 일반 공격 메서드로 설명하겠습니다.
public bool CastDamage(float knbValue = 0f, float stunValue = 0f)
{
RaycastHit raycastHit;
bool raycastSuccess = Physics.Raycast(transform.position, transform.forward, out raycastHit, _detectRange, TargetLayer);
if (raycastSuccess
&& raycastHit.collider.TryGetComponent<Health>(out Health health))
{
int damage = _owner.Stat.damage.GetValue();
float critical = _owner.Stat.criticalChance.GetValue() * 0.01f;
int criticalValue = _owner.Stat.criticalValue.GetValue();
float adjustedDamage;
float dice = UnityEngine.Random.value;
HitType originType = _hitType;
if (dice < critical)
{
_hitType = HitType.CriticalHit;
adjustedDamage = damage * (1.0f + (criticalValue * 0.01f));
damage = (int)adjustedDamage;
}
health.ApplyDamage(damage, raycastHit.point, raycastHit.normal, _hitType, _owner);
health.Knockback(knbValue, raycastHit.normal);
health.Stun(stunValue);
_hitType = originType;
return true;
}
return false;
}
CastDamage 메서드
전방에 RayCast를 쏴 TargetLayer에 해당된 오브젝트가 맞았다면 raycastSuccess값을 true로 설정합니다. raycastSuccess값이 true이고 레이캐스트에 닿은 오브젝트에 Health 컴포넌트를 TryGetComponent하여 가져옵니다.
데미지 값은 DamageCaster를 가지고 있는 객체(주인, Owner)의 Stat에서 가져옵니다.
크리티컬 확률 (0~1)이 유니티 랜덤 밸류보다 크다면 크리티걸 데미지를 적용합니다. 크리티컬이 터지지 않았다면 일반 데미지를 적용합니다. 가져온 Health 컴포넌트에 ApplyDamage 메서드를 실행시켜 데미지를 입힙니다.
EntityAttackData.cs
public class EntityAttackData : MonoBehaviour
{
public DamageCaster DamageCasterCompo => owner.DamageCasterCompo;
protected Entity owner;
protected virtual void Awake()
{
owner = GetComponent<Entity>();
}
public virtual void AoEAttack(float knbValue, float stunValue, float range = 0)
{
}
public virtual void MeleeAttack(float knbValue, float stunValue)
{
}
public virtual void MeleeSphereAttack()
{
}
public virtual void BombAttack()
{
}
public virtual void RangeAttack(Vector3 targetPos)
{
}
public virtual void MagicAttack(Vector3 targetPos)
{
}
public virtual void SpecialAttack(float aFewTimes)
{
}
public virtual void DashAttack()
{
}
}
다양한 공격 로직들을 virtual로 선언해두고 자식 객체에서 구현합니다.
근접 공격을 하는 객체면 MeleeAttackableEntity 스크립트를 붙히고 원거리 공격을 하는 객체면 RangeAttackableEntity를 붙힙니다.
MeleeAttackableEntity 스크립트로 구현부를 설명하겠습니다.
public class MeleeAttackableEntity : EntityAttackData
{
protected override void Awake()
{
base.Awake();
}
/// <summary>
/// 몇 배 만큼 타격
/// </summary>
/// <param name="AfewTimes"> 몇 배</param>
public override void SpecialAttack(float AfewTimes)
{
DamageCasterCompo.CastSpecialDamage(AfewTimes);
}
public override void AoEAttack(float knbValue, float stunValue, float range = 0)
{
DamageCasterCompo.CaseAoEDamage(knbValue, stunValue);
}
public override void MeleeAttack(float knbValue, float stunValue)
{
DamageCasterCompo.CastDamage(knbValue,stunValue);
}
public override void BombAttack()
{
DamageCasterCompo.CastBombDamage();
}
public override void DashAttack()
{
DamageCasterCompo.CastDashDamage();
}
}
근접 공격 로직만 부모에서 상속받아 구현합니다. 부모에서 받아온 DamageCaster에 접근하여 메서드에 맞는 각 로직을 실행해줍니다.
만약 펭귄이 근접 공격을 할 때 근접 공격을 실행시키고 싶다면
애니메이션을 관리하는 스크립트에서, EntityAttackData에 접근하여 MeleeAttack 메서드를 실행합니다.
MeleeAttack 메서드는 공격 애니메이션의 event에 연결하여 공격을 정확히 행하는 시점에 실행해줍니다.
'Unity > 졸업작품' 카테고리의 다른 글
[Unity] 졸업작품<펭덤>의 UI 구조 (UIManager) (0) | 2024.05.07 |
---|---|
[Unity] 졸업작품<펭덤>의 FSM 구조 - Reflection 이용한 FSM (0) | 2024.04.21 |