플레이어 공격 시스템 및 판정 구조

목차

1. 요구 사항

2. 설계 목표

3. 흐름도

4. 구현

        4.1. 공격 입력 처리

       4.2. 공격 모션 코루틴

       4.3. 애니메이션 이벤트 기반 판정 구간 제어

       4.4. 히트박스 수집 구조

       4.5. 데미지 적용

5. 개발 의도

1. 시스템 요구 사항

플레이어는 입력에 따라 공격 애니메이션을 실행할 수 있어야 하며, 동일 공격 동작이 중복 실행되어서는 안 된다.

공격은 단순 즉시 데미지 적용이 아니라, 애니메이션 구간과 판정 구간이 일치해야 한다.

공격 판정은 특정 프레임에서 시작되고 종료되어야 하며, 해당 구간 동안 무기 콜라이더와 겹친 몬스터를 수집하고, 공격 종료 시점에 일괄적으로 데미지를 적용하는 구조를 가져야 한다.

이 방식은 시각적 타격 타이밍과 논리적 판정을 정확히 일치시키기 위함이다.

데미지는 고정 값이 아니라 PlayerStatus에서 계산된 ATK 결과를 기반으로 결정되어야 한다.

공격 시스템은 공격력 계산 공식을 소유하지 않으며, 단지 계산 결과를 조회하여 적용하는 실행 계층이어야 한다.

공격은 UI가 활성화된 상태에서는 차단되어야 하며, 공격 애니메이션은 이동 애니메이션과 충돌하지 않도록 별도 레이어에서 제어되어야 한다.

핵심 전제는 이동 시스템과 동일하다.

PlayerController는 공격을 실행하는 계층이며, 스탯 계산은 PlayerStatus가 담당한다.

2. 설계  목표

- 공격 중복 실행 방지

- 애니메이션 레이어 기반 공격 표현

- 히트박스 수집 후 종료 시점 일괄 데미지 처리

- 데미지 값은 PlayerStatus.ATK 계산 결과 사용

- 실행 계층과 계산 계층 분리 유지

3. 흐름도

[Input(Update)]

       ▼

[PlayerController.Attack()]

       ├─ UI 상태 검사

       ├─ 공격 중 여부 검사

       ├─ 애니메이션 레이어 활성화

       ├─ AttackMotion 코루틴 실행

       ▼

[Animator Event]

       ├─ AtkStart()

       │     └─ Equipment.ListClear()

       ├─ 공격 중 OnTriggerStay로 몬스터 수집

       └─ AtkEnd()

             ├─ PlayerStatus.ATK 조회

             ├─ 수집된 몬스터에 데미지 적용

             └─ 리스트 초기화

이 구조는 '입력 → 애니메이션 실행 → 판정 수집 → 종료 시점 데미지 적용' 이라는 전투 파이프라인을 가진다.

4. 구현

4.1. 공격 입력 처리
// PlayerController.cs
void Attack()
{
    if (UiManager_.instance.uiAllDeactive)
    {
        if (Input.GetMouseButton(0) && isAttack == false)
        {
            anime.SetLayerWeight(1, 1);
            StartCoroutine(AttackMotion("Attack"));
        }

        if (anime.GetCurrentAnimatorStateInfo(1).IsName("Attack") 
            || anime.GetCurrentAnimatorStateInfo(1).IsName("Aim"))
        {
            anime.SetLayerWeight(1, 1);
        }
        else if (anime.GetLayerWeight(1) > 0)
        {
            anime.SetLayerWeight(1, anime.GetLayerWeight(1) - 0.1f);
        }
    }
}

공격 입력은 Update에서 처리한다.

Update는 유니티의 MonoBehaviour 클래스에서 제공되는 생명주기 메서드로, 매 프레임마다 호출되며 물리 시뮬레이션과 무관한 로직에 적합하다.

입력 처리는 프레임 기반으로 즉각 반응해야 하며 FixedUpdate를 사용하면 고정 틱 간격으로 인해 입력이 지연되거나 누락될 수 있기 때문에 Update에서 처리하도록 하였다.

Update의 장점은 사용자 입력에 대한 높은 응답성을 제공하지만, 단점은 프레임 레이트 변동에 따라 실행 빈도가 달라질 수 있다는 점인데, 이 시스템에서는 입력이 프레임 단위로 자연스럽게 처리되므로 적합했다.

Input.GetMouseButton(0)은 유니티의 Input 클래스에서 제공되는 정적 메서드로, 왼쪽 마우스 버튼이 눌린 상태를 지속적으로 확인하며 true를 반환한다.

이 기능은 홀드 투 어택 스타일을 지원하지만 매 프레임 실행될 위험이 있어서 isAttack이라는 bool 플래그를 게이트처럼 사용해 중복 실행을 막는다.

이 플래그는 공격이 진행 중일 때 입력을 무시함으로써 게임플레이에서 버튼 매싱을 방지하고, 공정한 전투 리듬을 유지한다.

uiAllDeactive 조건은 UiManager 싱글톤 인스턴스를 통해 UI가 비활성화된 상태를 확인하며, 인벤토리나 대화창 같은 UI가 열려 있을 때 월드 입력을 차단한다.

이는 UI 우선 처리 원칙을 따르며, 우발적 공격을 피하는 안전 메커니즘이다.

anime.SetLayerWeight(1, 1)은 유니티의 Animator 컴포넌트 메서드로, 레이어 1의 가중치를 설정하며 상체 공격 애니메이션을 하체 이동 애니메이션과 분리한다.

레이어 블렌딩의 장점은 이동 중에도 독립적인 상체 모션을 재생할 수 있게 해 자연스러운 액션을 제공하지만, 단점은 레이어 관리가 복잡해질 수 있다는 점인데, 여기서는 공격 표현의 유연성을 위해 선택했다.

anime.GetCurrentAnimatorStateInfo(1)은 Animator의 메서드로 레이어 1의 현재 상태 정보를 반환하며, "Attack"이나 "Aim" 상태일 때 가중치를 유지한다.

이 기능은 문자열 기반 이름 검사를 사용하므로 오타 위험이 있지만, 효율적인 상태 쿼리를 위해 사용하였다.

가중치를 0.1f씩 감소시키는 부분은 즉시 끄지 않고 부드럽게 블렌딩을 해제함으로써 시각적 자연스러움을 더했다.

4.2. 공격 모션 코루틴
// PlayerController.cs
IEnumerator AttackMotion(string motionName)
{
    if (!isAttack)
    {
        isAttack = true;
        anime.SetBool(motionName, true);

        yield return new WaitForSeconds(1.5f);

        anime.SetBool(motionName, false);
        isAttack = false;
    }
}

IEnumerator는 C#의 인터페이스로, 유니티에서 비동기 흐름을 처리하며 yield return을 통해 실행을 일시 중지하고 엔진에 제어를 양도한다.

이 기능은 메인 스레드를 막지 않고 타이밍 기반 로직을 쉽게 구현할 수 있는 장점이 있지만, 단점은 MonoBehaviour의 생명주기에 의존하며 과도 사용 시 관리가 어렵다는 점인데, 여기서는 간단한 공격 지속 시간을 위해 적합했다.

isAttack 확인으로 중복 진입을 방지하며, anime.SetBool(motionName, true)은 Animator의 메서드로 bool 파라미터를 설정해 상태 머신을 트리거한다.

이 메서드는 코드와 애니메이션 로직을 분리하는 장점이 있지만, 파라미터 이름 오타가 발생할 수 있다는 단점이 있는데, 공격 모션을 모듈화하기 위해 사용하였다.

yield return new WaitForSeconds(1.5f)는 유니티의 CoroutineYieldInstruction으로, 지정 시간만큼 대기하며 Time.timeScale의 영향을 받는다.

이는 슬로우 모션 효과와 통합되는 장점이 있지만, 애니메이션 길이가 변하면 하드코딩된 시간을 수정해야 한다는 단점이 있어서, 더 정밀한 구현에서는 normalizedTime 기반으로 대체할 수 있다.

이 코드는 공격 지속 시간을 정의하며, 게임플레이에서 쿨다운 없이 리듬을 부여한다.

종료 시 SetBool을 false로 설정하고 isAttack을 해제함으로써 다음 입력을 허용한다.

4.3. 애니메이션 이벤트 기반 판정 구간 제어
// PlayerController.cs
public void AtkStart()
{
    isAtk = true;

    if (equipments[WeaponChange.instance.curWeapon] != null)
    {
        equipments[WeaponChange.instance.curWeapon]
            .GetComponent<Equipment>()
            .ListClear();
    }
}

public void AtkEnd()
{
    isAtk = false;

    if (equipments[WeaponChange.instance.curWeapon] != null)
    {
        equipments[WeaponChange.instance.curWeapon]
            .GetComponent<Equipment>()
            .GiveDamage();

        equipments[WeaponChange.instance.curWeapon]
            .GetComponent<Equipment>()
            .ListClear();
    }
}

// Equipment.cs
public void ListClear()
{
    hitMons.Clear();
    hitMons = new List<GameObject>();
}

AtkStart와 AtkEnd는 애니메이션 이벤트로 호출된다.

애니메이션 이벤트는 유니티의 Animator 클립에서 특정 프레임에 연결된 콜백 메서드로, 시각 타이밍과 로직을 정확히 동기화하는 장점이 있지만, 에디터에서 수동 설정해야 한다는 단점이 있는데, 공격의 액티브 프레임을 프레임 단위로 맞추기 위해 썼다.

AtkStart는 이벤트로 호출되며 isAtk를 true로 설정해 공격 구간을 시작하고, ListClear를 호출해 이전 히트를 초기화한다.

equipments는 C# 배열로 Equipment 객체를 저장하며, WeaponChange 싱글톤의 curWeapon 인덱스를 통해 현재 무기를 참조한다.

GetComponent()은 유니티의 메서드로 GameObject에서 스크립트를 동적으로 가져오며, 런타임 유연성을 제공하지만 빈번 호출 시 성능 비용이 발생할 수 있어서 캐싱을 고려할 수 있다.

ListClear는 C#의 List 메서드 Clear를 사용해 요소를 제거하고 새 리스트를 할당하는데, 이는 메모리 관리를 명확히 하지만 불필요한 할당을 피하기 위해 Clear만 사용할 수도 있다.

AtkEnd는 구간 종료를 알리며 GiveDamage를 호출한 후 다시 ListClear를 실행한다.

이 구조는 히트 수집을 구간으로 제한하며, 게임에서 공격이 시각적으로 일치하는 공정한 판정을 의미한다.

4.4. 히트박스 수집 구조
// Equipment.cs
private void OnTriggerStay(Collider other)
{
    if (other.tag == "Monster" || other.tag == "MonsterHitBox")
    {
        if (!hitMons.Contains(other.gameObject))
        {
            hitMons.Add(other.gameObject);
        }
    }
}

OnTriggerStay는 유니티의 Collider 이벤트 메서드로, 트리거 영역 내 콜라이더가 지속될 때 매 프레임 호출되며 지속 오버랩을 처리하는 장점이 있지만, 콜라이더가 많으면 성능 부하가 될 수 있어서 Contains 검사를 추가했다.

Debug.Log는 C#의 UnityEngine 메서드로 콘솔에 로그를 출력하며 디버깅에 유용하지만 릴리스 빌드에서 제거해야 한다.

tag 비교는 유니티의 문자열 기반 태깅 시스템으로, 유연하지만 오타나 성능 이슈가 있을 수 있어서 레이어로 대체할 수도 있다.

hitMons는 C#의 List로, Contains는 O(n) 검색을 하며 중복 추가를 방지한다.

List의 장점은 동적 크기지만 대규모 시 HashSet이 더 빠를 수 있다.

OnTriggerStay는 공격 구간 동안 유일한 몬스터를 수집하며, 스위핑 공격을 시뮬레이션해 게임에서 다중 히트를 공정하게 처리한다.

4.5. 데미지 적용
// Equipment.cs
public void GiveDamage()
{
    float dmg = StatusManager.instance.PlayerStatus.ATK;

    foreach (GameObject monster in hitMons)
    {
        if (monster != null)
        {
            if (monster.transform.tag == "Monster")
            {
                monster.GetComponent<Monster>().GetDamage(dmg);
            }
            else if (monster.transform.tag == "MonsterHitBox")
            {
                monster.GetComponentInParent<Monster>().GetDamage(dmg);
            }
        }
    }
}

데미지는 PlayerStatus.ATK 계산 결과를 사용한다.

데미지 적용은 foreach 루프로 구현했다.

foreach는 C#의 반복 구문으로 컬렉션을 순회하며 간결하지만, 수정 중 컬렉션 변경 시 예외가 발생할 수 있어서 여기서는 읽기 전용으로 적합하다.

dmg는 StatusManager 싱글톤의 PlayerStatus.ATK를 조회하며, 싱글톤의 장점은 글로벌 액세스지만 커플링 위험이 있어서 의존성 주입으로 대체할 수 있다.

null 체크는 몬스터가 공격 중 파괴될 수 있어서 안전성을 더한다.

tag 확인 후 GetComponent()나 GetComponentInParent()를 사용하며, 후자는 유니티의 계층 탐색 메서드로 자식 콜라이더를 처리하는 장점이 있지만 약간의 성능 비용이 있다.

GetDamage는 몬스터 스크립트의 메서드로 데미지를 적용한다.

이 코드는 배치 처리를 통해 효율성을 높이며, 게임에서 애니메이션 종료와 데미지 타이밍을 일치시켜 만족스러운 피드백을 준다.

5. 개발 의도

이 공격 시스템은 단순히 클릭 시 데미지를 주는 구조가 아니다.

공격은 '입력 → 애니메이션 실행 → 판정 수집 → 종료 시점 일괄 데미지' 라는 전투 파이프라인을 가진다.

애니메이션 이벤트를 사용해 판정 구간을 제어함으로써 시각적 타이밍과 논리적 판정을 일치시켰다.

공격력 계산은 PlayerStatus가 담당하고, 공격 실행은 PlayerController/Equipment가 수행한다.

이 구조는 책임 분리를 명확히 유지한다.

또한 중복 공격 방지, 널 안전성, UI 입력 차단 등 실제 게임 상황에서 발생할 수 있는 문제를 고려한 구조다.

이 시스템은 이후 콤보 공격, 차징 공격, 무기별 판정 확장, 인터페이스 기반 데미지 처리(IDamageable) 등으로 자연스럽게 확장할 수 있는 기반을 제공한다.