적 구현

적은 플레이어 오브젝트를 복사 + 붙여넣기 하여 기본 틀로 사용한다.

적 스크립트 또한 플레이어 스크립트와 비슷하다.

[field: Header("References")]
    [field: SerializeField] public EnemySO Data { get; private set; }

    [field: Header("Animations")]
    [field: SerializeField] public PlayerAnimationData AnimationData { get; private set; }

    public Rigidbody Rigidbody { get; private set; }
    public Animator Animator { get; private set; }
    public ForceReceiver ForceReceiver { get; private set; }
    public CharacterController Controller { get; private set; }

    private EnemyStateMachine stateMachine;

    void Awake()
    {
        AnimationData.Initialize();

        Rigidbody = GetComponent<Rigidbody>();
        Animator = GetComponentInChildren<Animator>();
        Controller = GetComponent<CharacterController>();
        ForceReceiver = GetComponent<ForceReceiver>();

        stateMachine = new EnemyStateMachine(this);
    }

    private void Start()
    {
        stateMachine.ChangeState(stateMachine.IdlingState);
    }

    private void Update()
    {
        stateMachine.HandleInput();

        stateMachine.Update();
    }

    private void FixedUpdate()
    {
        stateMachine.PhysicsUpdate();
    }

 

적의 Scriptable Object와 상태 머신 또한 이름만 바뀌고 비슷하다.

[CreateAssetMenu(fileName = "EnemySO", menuName = "Characters/Enemy")]
public class EnemySO : ScriptableObject
{
    [field: SerializeField] public float PlayerChasingRange { get; private set; } = 10f;
    [field: SerializeField] public float AttackRange { get; private set; } = 1.5f;
    [field: SerializeField][field: Range(0f, 3f)] public float ForceTransitionTime { get; private set; }
    [field: SerializeField][field: Range(-10f, 10f)] public float Force { get; private set; }
    [field: SerializeField] public int Damage { get; private set; }
    [field: SerializeField][field: Range(0f, 1f)] public float Dealing_Start_TransitionTime { get; private set; }
    [field: SerializeField][field: Range(0f, 1f)] public float Dealing_End_TransitionTime { get; private set; }

    [field: SerializeField] public PlayerGroundData GroundedData { get; private set; }

}
public class EnemyStateMachine : StateMachine
{
    public Enemy Enemy { get; }

    // States

    public Transform Target { get; private set; }

    public EnemyIdleState IdlingState { get; }
    public EnemyChasingState ChasingState { get; }
    public EnemyAttackState AttackState { get; }

    // 이동
    public Vector2 MovementInput { get; set; }
    public float MovementSpeed { get; private set; }
    public float RotationDamping { get; private set; }
    public float MovementSpeedModifier { get; set; } = 1f;

    public EnemyStateMachine(Enemy enemy)
    {
        Enemy = enemy;
        Target = GameObject.FindGameObjectWithTag("Player").transform;

        IdlingState = new EnemyIdleState(this);
        ChasingState = new EnemyChasingState(this);
        AttackState = new EnemyAttackState(this);

        MovementSpeed = enemy.Data.GroundedData.BaseSpeed;
        RotationDamping = enemy.Data.GroundedData.BaseRotationDamping;
    }
}

 

기본 상태는 아래 플레이어를 쫓아가는 코드 외에는 전부 똑같다.

sqrMagnitude의 sqr은 제곱근으로, 제곱근을 푸는 연산이 무겁기 때문에 벡터의 크기에 한 번 더 곱해버리는 계산이다.

private Vector3 GetMovementDirection()
{
    return (stateMachine.Target.transform.position - stateMachine.Enemy.transform.position).normalized;
}

protected bool IsInChaseRange()
{
    // if (stateMachine.Target.IsDead) { return false; } // 플레이어가 죽었을 때 쫓아가지 않는다

    float playerDistanceSqr = (stateMachine.Target.transform.position - stateMachine.Enemy.transform.position).sqrMagnitude;

    return playerDistanceSqr <= stateMachine.Enemy.Data.PlayerChasingRange * stateMachine.Enemy.Data.PlayerChasingRange;
}
public class EnemyIdleState : EnemyBaseState
{
    public EnemyIdleState(EnemyStateMachine ememyStateMachine) : base(ememyStateMachine)
    {
    }

    // 플레이어의 Idle 공유
    public override void Enter()
    {
        stateMachine.MovementSpeedModifier = 0f;

        base.Enter();
        StartAnimation(stateMachine.Enemy.AnimationData.GroundParameterHash);
        StartAnimation(stateMachine.Enemy.AnimationData.IdleParameterHash);
    }

    public override void Exit()
    {
        base.Exit();
        StopAnimation(stateMachine.Enemy.AnimationData.GroundParameterHash);
        StopAnimation(stateMachine.Enemy.AnimationData.IdleParameterHash);
    }

    public override void Update()
    {
        if (IsInChaseRange())
        {
            stateMachine.ChangeState(stateMachine.ChasingState); // 적과 플레이어가 일정 거리가 되면 쫓아간다
            return;
        }
    }
}

 

적이 플레이어를 쫓는 상태

public class EnemyChasingState : EnemyBaseState
{
    public EnemyChasingState(EnemyStateMachine ememyStateMachine) : base(ememyStateMachine)
    {
    }
    public override void Enter()
    {
        stateMachine.MovementSpeedModifier = 1;
        base.Enter();
        StartAnimation(stateMachine.Enemy.AnimationData.GroundParameterHash);
        StartAnimation(stateMachine.Enemy.AnimationData.RunParameterHash);
    }

    public override void Exit()
    {
        base.Exit();
        StopAnimation(stateMachine.Enemy.AnimationData.GroundParameterHash);
        StopAnimation(stateMachine.Enemy.AnimationData.RunParameterHash);
    }

    public override void Update()
    {
        base.Update();

        if (!IsInChaseRange()) // 플레이어가 사정거리에 들어오지 않았다면
        {
            stateMachine.ChangeState(stateMachine.IdlingState);
            return;
        }
        else if (IsInAttackRange())
        {
            stateMachine.ChangeState(stateMachine.AttackState);
            return;
        }
    }

    private bool IsInAttackRange()
    {
        // if (stateMachine.Target.IsDead) { return false; }

        float playerDistanceSqr = (stateMachine.Target.transform.position - stateMachine.Enemy.transform.position).sqrMagnitude;

        return playerDistanceSqr <= stateMachine.Enemy.Data.AttackRange * stateMachine.Enemy.Data.AttackRange;
    }
}

 

적이 플레이어를 공격하는 상태

public class EnemyAttackState : EnemyBaseState
{

    private bool alreadyAppliedForce;

    public EnemyAttackState(EnemyStateMachine ememyStateMachine) : base(ememyStateMachine)
    {
    }

    public override void Enter()
    {
        stateMachine.MovementSpeedModifier = 0;
        base.Enter();
        StartAnimation(stateMachine.Enemy.AnimationData.AttackParameterHash);
        StartAnimation(stateMachine.Enemy.AnimationData.BaseAttackParameterHash);
    }

    public override void Exit()
    {
        base.Exit();
        StopAnimation(stateMachine.Enemy.AnimationData.AttackParameterHash);
        StopAnimation(stateMachine.Enemy.AnimationData.BaseAttackParameterHash);

    }

    public override void Update()
    {
        base.Update();

        ForceMove();

        float normalizedTime = GetNormalizedTime(stateMachine.Enemy.Animator, "Attack");
        if (normalizedTime < 1f)
        {
            if (normalizedTime >= stateMachine.Enemy.Data.ForceTransitionTime)
                TryApplyForce();

        }
        else
        {
            if (IsInChaseRange()) // 플레이어가 쫓아가는 거리에 있다면
            {
                stateMachine.ChangeState(stateMachine.ChasingState);
                return;
            }
            else
            {
                stateMachine.ChangeState(stateMachine.IdlingState);
                return;
            }
        }

    }

    private void TryApplyForce()
    {
        if (alreadyAppliedForce) return;
        alreadyAppliedForce = true;

        stateMachine.Enemy.ForceReceiver.Reset();

        stateMachine.Enemy.ForceReceiver.AddForce(stateMachine.Enemy.transform.forward * stateMachine.Enemy.Data.Force);

    }
}

 

플레이어의 애니메이션 스크립트를 기반으로 동작하기 때문에 플레이어 애니메이션 스크립트에 적의 기본 공격을 추가한다.

[SerializeField] private string baseAttackParameterName = "BaseAttack";

public int BaseAttackParameterHash { get; private set; }

public void Initialize()
{
    BaseAttackParameterHash = Animator.StringToHash(baseAttackParameterName);
}

 

유니티로 돌아와서 적의 Scriptable Object를 생성하고 적 스크립트에 연결한다.

 

기본 공격에 대한 애니메이션을 연결하면 적이 플레이어를 쫓아와서 때린다.

 

 

피격 및 처치

피격을 위해 HP와 무기 스크립트를 만든다.

public class Health : MonoBehaviour
{
    [SerializeField] private int maxHealth = 100;
    private int health;
    public event Action OnDie;

    public bool IsDead => health == 0;

    private void Start()
    {
        health = maxHealth;
    }

    public void TakeDamage(int damage)
    {
        if (health == 0) return;
        health = Mathf.Max(health - damage, 0);

        if (health == 0)
            OnDie?.Invoke();

        Debug.Log(health);
    }
}
public class Weapon : MonoBehaviour
{
    [SerializeField] private Collider myCollider;

    private int damage;
    private float knockback;

    private List<Collider> alreadyColliderWith = new List<Collider>();

    private void OnEnable()
    {
        alreadyColliderWith.Clear();
    }

    private void OnTriggerEnter(Collider other)
    {
        if (other == myCollider) return;
        if (alreadyColliderWith.Contains(other)) return;

        alreadyColliderWith.Add(other);

        if(other.TryGetComponent(out CharacterHealth health))
        {
            health.TakeDamage(damage);
        }

        if(other.TryGetComponent(out ForceReceiver forceReceiver))
        {
            Vector3 direction = (other.transform.position - myCollider.transform.position).normalized;
            forceReceiver.AddForce(direction * knockback);
        }
    }

    public void SetAttack(int damage, float knockback)
    {
        this.damage = damage;
        this.knockback = knockback;
    }
}

 

공격 애니메이션이 처리 될 때, 데미지를 줄 것인지 처리하기 위해 적 공격 상태 스크립트에 코드를 추가한다.

private bool alreadyAppliedDealing;

public override void Enter()
{
    alreadyAppliedForce = false;
    alreadyAppliedDealing = false;

    stateMachine.MovementSpeedModifier = 0;
    base.Enter();
    StartAnimation(stateMachine.Enemy.AnimationData.AttackParameterHash);
    StartAnimation(stateMachine.Enemy.AnimationData.BaseAttackParameterHash);
}

public override void Update()
{
    base.Update();

    ForceMove();

    float normalizedTime = GetNormalizedTime(stateMachine.Enemy.Animator, "Attack");
    if (normalizedTime < 1f)
    {
        if (normalizedTime >= stateMachine.Enemy.Data.ForceTransitionTime)
            TryApplyForce();

        if (!alreadyAppliedDealing && normalizedTime >= stateMachine.Enemy.Data.Dealing_Start_TransitionTime) // 딜 안했을 때
        {
            stateMachine.Enemy.Weapon.SetAttack(stateMachine.Enemy.Data.Damage, stateMachine.Enemy.Data.Force);
            stateMachine.Enemy.Weapon.gameObject.SetActive(true);
            alreadyAppliedDealing = true;
        }

        if (alreadyAppliedDealing && normalizedTime >= stateMachine.Enemy.Data.Dealing_End_TransitionTime) // 딜 했을 때
        {
            stateMachine.Enemy.Weapon.gameObject.SetActive(false);
        }

    }
    else
    {
        if (IsInChaseRange()) // 플레이어가 쫓아가는 거리에 있다면
        {
            stateMachine.ChangeState(stateMachine.ChasingState);
            return;
        }
        else
        {
            stateMachine.ChangeState(stateMachine.IdlingState);
            return;
        }
    }
}

 

적 스크립트에 무기와 플레이어가 죽었는지에 대한 코드를 추가한다.

[field: SerializeField] public Weapon Weapon { get; private set; }
public Health Health { get; private set; }

void Awake()
{
    AnimationData.Initialize();

    Rigidbody = GetComponent<Rigidbody>();
    Animator = GetComponentInChildren<Animator>();
    Controller = GetComponent<CharacterController>();
    ForceReceiver = GetComponent<ForceReceiver>();

    Health = GetComponent<Health>();

    stateMachine = new EnemyStateMachine(this);
}

private void Start()
{
    stateMachine.ChangeState(stateMachine.IdlingState);
    Health.OnDie += OnDie;
}

void OnDie()
{
    Animator.SetTrigger("Die");
    enabled = false;
}

 

유니티로 돌아와서 적 오른쪽 손에 무기 Collider와 Rigidbody를 걸어주고 무기 스크립트를 붙여준다.

 

적 오브젝트에 붙은 스크립트에 무기를 넣어준다.

 

플레이어에도 동일하게 스크립트와 무기에 Collider, Rigidbody를 추가한다.

주석 처리했던적 기본 상태 스크립트에서 주석 처리했던 죽음 처리를 풀고 적 상태 머신에서 임시로 처리한 Transform을 Health로 바꾼다.

public class EnemyStateMachine : StateMachine
{
    public Enemy Enemy { get; }

    // States

    public Health Target { get; private set; }
    
    public EnemyStateMachine(Enemy enemy)
	{
    Enemy = enemy;
    Target = GameObject.FindGameObjectWithTag("Player").GetComponent<Health>();

    IdlingState = new EnemyIdleState(this);
    ChasingState = new EnemyChasingState(this);
    AttackState = new EnemyAttackState(this);

    MovementSpeed = enemy.Data.GroundedData.BaseSpeed;
    RotationDamping = enemy.Data.GroundedData.BaseRotationDamping;
	}
}

 

 

+ Recent posts