Unity

유니티 프로젝트 개발 일지 - 5 (무기 구현)

mins_s 2024. 12. 22. 20:00

오늘은 프로젝트 C에서 자동 공격을 하는 오브젝트를 만들어보려고 한다.

 

해당 오브젝트는 기본적으로 이전 포스팅을 통해 작업한 상태 패턴을 기반으로 동작한다.

https://lms0408.tistory.com/14

 

유니티 프로젝트 개발 일지 - 1 (상태 패턴)

오늘은 프로젝트 C에서 오브젝트의 상태 패턴을 구현해 볼 것이다. 이전 개인 프로젝트에서 상태 패턴을 구현할 땐,Player와 Montser 오브젝트가 있을 때 각기 다른 상태를 정의하고 같은 오브젝트

lms0408.tistory.com

상태는 총 3가지로 Idle(적을 탐색하는 상태), Attack, Dead를 가진다.

오브젝트가 Attack 상태일 때 공격을 할 수 있도록 코드를 작성해보고자 한다.

 

우선 무기의 기초적인 뼈대를 작성해 볼 것이다.

public interface IWeapon
{
    public bool IsAtk { get; }
    public void ReadyAttack(float attackingTime);
    public bool UpdateAttack(Vector2 pos, Vector2 dir, float attackingTime);
}

IsAtk 프로퍼티는 무기의 공격이 준비되어 공격이 가능한 상태를 알리고,

ReadyAttack 메서드는 특정 상태일 때 매 프레임마다 호출되어 공격이 가능하게끔 준비해 주는 메서드,

UpdateAttack도 특정 상태일 때 매 프레임마다 호출되어 공격을 진행해 주는 메서드로 구현할 예정이다.

 

UpdateAttack 메서드가 bool 타입 반환형으로 선언한 이유는

해당 메서드를 호출하는 오브젝트가 가지고 있는 무기가 공격을 했다는 신호를 받아 애니메이션 클립을 재생해주기 위함이다.

 

이젠 선언한 인터페이스를 바탕으로 Weapon 클래스를 구현해보려고 한다.

[System.Serializable]
public abstract class Weapon : IWeapon
{
    [SerializeField] private float atk; // 공격력

    [SerializeField] private int maxAtkCount; // 공격 횟수
    private int atkableCount; // 현재 공격 가능한 횟수

    [SerializeField] private bool isAtk;
    [SerializeField] private float atkElapsed; // 공격 경과 시간

    [SerializeField] private float coolTime = 0.5f; // 쿨타임
    [SerializeField] private float coolTimeElapsed; // 쿨타임 경과 시간
    
    public void ReadyAttack(float attackingTime)
    {
        if ((coolTimeElapsed += Time.deltaTime) < coolTime) return;

        isAtk = true;
        atkElapsed = attackingTime;
    }
    
    public bool UpdateAttack(Vector2 pos, Vector2 dir, float attckingTime)
    {
        if ((atkElapsed += Time.deltaTime) < attckingTime) return false;

        if (--atkableCount < 0)
        {
            coolTimeElapsed = 0f;
            atkableCount = atkCount;
            isAtk = false;
            return false;
        }
    
        atkElapsed = 0f;
        Attack(pos, dir);

        return true;
    }
    
    protected abstract void Attack(Vector2 pos, Vector2 dir);
}

ReadyAttack 메서드는 Idle 상태에서 호출된다.

오브젝트들은 타겟과의 거리가 공격 범위내에 존재해야 그 자리에서 공격을 할 수 있어

이동과 같은 특수한 행동 상태 중에는 호출하지 않는다.

 

내부 구현은 매우 간단하게 되어있다.

호출이 진행됨에 따라 쿨타임 경과 시간을 업데이트해주면서 공격 준비를 시켜준다.

쿨타임이 완료되었을 땐 해당 메서드를 호출한 오브젝트가 공격 상태가 되도록

isAtk true로 변경해준다.

 

ReadyAttack을 통해 오브젝트가 Attack 상태가 되었다면 UpdateAttack 메서드를 호출하여

공격을 진행해 준다.

UpdateAttack 메서드는 atkableCount(공격 가능한 횟수)가 모두 소진되기 전까지 매 프레임 호출되게 된다.

 

매개변수 attackingTime은 한 번의 공격이 진행되는 시간이며

나는 기본값으로 공격 애니메이션 클립의 Length를 대입해 줄 것이다.

(+ ReadyAttack 메서드에서 보면 공격 경과시간을 attackingTime을 대입해 주면서 공격 상태를 맞이하게 되는데

그 이유는 공격 상태가 되었을 때 곧바로 공격을 하기 위함이다.)

 

공격 진행 시간이 초과된 이후

공격 가능한 횟수가 남아있다면 다시 공격을 진행해 준다.

만약 공격 가능한 횟수가 남아있지 않다면 공격 준비 상태가 되도록 isAtkfalse로 변경해 주게 된다.

 

이 Weapon 클래스의 뼈대를 바탕으로 나는 근거리 무기와 원거리 무기로 나누어 상속을 해주었다.

현재는 원거리 무기를 테스트하고자 하니 간단하게 투사체를 발사하는 코드를 구현하면 다음과 같다.

[System.Serializable]
public class LongRangeWeapon : Weapon
{
    public Rigidbody2D pref;
    protected override void Attack(Vector2 pos, Vector2 dir)
    {
        var _obj = GameObject.Instantiate(pref, pos, 
            Quaternion.Euler(0f, 0f, Mathf.Atan2(dir.y, dir.x) * Mathf.Rad2Deg - 90f));
        _obj.velocity = dir * 5f * Time.fixedDeltaTime;
    }
}

 

이제 위의 Weapon 로직이 잘 돌아가는지 확인하기 위해 투사체를 발사하는 대포 오브젝트에 적용을 해보자.

public interface ICannonAction : IIdleAction, IAttackAction, IDeadAction { }

public class CannonController : MyObject, ICannonAction
{
    [SerializeField] private LongRangeWeapon myWeapon;
    [SerializeField] private Animator _animator;
    
    public Quaternion originRot;
    public Transform Target;
    
    // Something...
    
    public void Idle()
    {
        Quaternion _targetRot = Quaternion.identity;
        if (Target == null) _targetRot = originRot;
        else
        {
            // Cannon Position에서 타겟 Position까지의 벡터를 구해 각도를 변환
            var _dir = (weapon.Target.Position - (Vector2)weapon.Transform.position).normalized;
            var _axis = Mathf.Atan2(_dir.y, _dir.x) * Mathf.Rad2Deg;
            _targetRot = Quaternion.Euler(0f, 0f, _axis);

            myWeapon.ReadyAttack(1f);
        }
        
        var _rotSpeed = 10f;
        transform.rotation = Quaternion.Lerp(transform.rotation, _targetRot, Time.deltaTime * _rotSpeed);
    }
    
    public void Attack()
    {
        if (!myWeapon.UpdateAttack(transform.position, transform.up, 1f)) return;
        // Weapon이 공격을 했다면 애니메이션 재생
        _animator.SetTrigger("Attack");
    }
    
    // Something...
}

위 코드는 CannonController의 핵심적인 코드의 일부분이며, 위에서 설명한 설계대로 구현이 된 상태이다.

추가적인 부분은 타겟이 존재하지 않을땐 항상 처음에 셋팅한 rotation을 바라보고,

타겟이 존재하고 공격이 준비되고 있는 상태에선 타겟을 바라보도록 연산해보았다.

 

해당 스크립트 컴포넌트가 적용된 오브젝트를 적절하게 배치하여 테스트 해보면 문제없이 잘 동작하는 모습을 볼 수 있게 된다.