Raycast와 Hitbox로 구성하는 격투·액션 판정 시스템

공격 판정과 피격 판정을 분리하고 Raycast와 Hitbox를 상황에 맞게 조합하는 방법을 정리합니다. Unity C# 예제로 중복 타격 방지와 공격 단계별 판정 관리까지 다룹니다.

✓ 복사 완료!
챕터게임 수학 & 물리
난이도중급
상태● 검토 완료
대상 엔진
Unity
최종 업데이트
Raycast와 Hitbox로 구성하는 격투·액션 판정 시스템

판정 시스템을 먼저 분리해야 하는 이유

격투·액션 게임의 공격 판정은 애니메이션 재생과 별개로 관리하는 편이 안전합니다. 검이 화면상으로 적에게 닿아 보여도 공격 구간이 아니면 피해가 발생하지 않아야 하고 반대로 빠르게 움직이는 무기는 프레임 사이를 건너뛰어도 타격을 놓치지 않아야 합니다.

보통은 다음 세 요소를 분리합니다.

  • Hurtbox: 공격을 받을 수 있는 영역입니다. 캐릭터 몸통, 머리, 방패 등에 둡니다.
  • Hitbox: 공격이 유효한 순간에만 켜지는 공격 영역입니다. 주먹, 발, 검날, 투사체에 둡니다.
  • Hit detection: Hitbox와 Hurtbox의 겹침 또는 Raycast 결과를 해석해 실제 타격으로 확정하는 로직입니다.

이 구분을 해 두면 애니메이터 교체, 무기 변경, 방어 판정 추가가 전투 규칙 전체를 흔들지 않습니다.

flowchart LR
    A[입력 또는 AI 결정] --> B[공격 상태 시작]
    B --> C[선딜]
    C --> D[유효 프레임]
    D --> E{Hitbox 또는 Raycast 적중?}
    E -->|예| F[대상별 중복 타격 확인]
    F --> G[피해·경직·넉백 적용]
    E -->|아니오| H[후딜]
    G --> H
    H --> I[공격 상태 종료]

핵심은 충돌 사실과 피해 적용을 같은 것으로 취급하지 않는 점입니다. 충돌은 여러 번 발생할 수 있지만 한 번의 공격이 같은 적에게 피해를 몇 번 줄지는 게임 규칙이 정합니다.

Hitbox가 적합한 경우

Hitbox는 일정 시간 동안 공간을 점유하는 공격에 잘 맞습니다. 주먹 콤보, 발차기, 넓게 휘두르는 검, 폭발 범위처럼 공격 영역이 유지되는 경우입니다.

Unity에서는 Collider를 트리거로 설정하고 공격 유효 프레임에만 활성화할 수 있습니다. Hitbox 오브젝트는 공격자와 분리해 두고 무기 뼈나 손 뼈에 붙이면 애니메이션에 따라 자연스럽게 이동합니다.

검날을 따라 배치된 Hitbox와 캐릭터 Hurtbox의 관계

공격 데이터와 Hitbox 활성화

공격의 피해량, 경직 시간, 넉백은 Hitbox가 직접 결정하기보다 공격 데이터로 묶어 두는 편이 좋습니다. 같은 검날 Hitbox라도 약공격과 강공격이 서로 다른 효과를 사용할 수 있기 때문입니다.

using System.Collections.Generic;
using UnityEngine;

public class Hitbox : MonoBehaviour
{
    [SerializeField] private Collider hitCollider;
    [SerializeField] private LayerMask hurtboxMask;

    private readonly HashSet<Hurtbox> hitTargets = new();
    private AttackData currentAttack;
    private bool isActive;

    public void Begin(AttackData attack)
    {
        currentAttack = attack;
        hitTargets.Clear();
        isActive = true;
        hitCollider.enabled = true;
    }

    public void End()
    {
        isActive = false;
        hitCollider.enabled = false;
        currentAttack = null;
    }

    private void OnTriggerEnter(Collider other)
    {
        if (!isActive || (hurtboxMask.value & (1 << other.gameObject.layer)) == 0)
        {
            return;
        }

        if (!other.TryGetComponent(out Hurtbox hurtbox) || !hitTargets.Add(hurtbox))
        {
            return;
        }

        hurtbox.ReceiveHit(currentAttack, transform.position);
    }
}

HashSet은 현재 공격 중 이미 맞은 Hurtbox를 기록합니다. 이 처리가 없으면 한 적이 Hitbox 안에 머무르는 동안 여러 충돌 이벤트가 발생하는 구성에서 의도하지 않은 연속 피해가 생길 수 있습니다.

using UnityEngine;

[CreateAssetMenu(menuName = "Combat/Attack Data")]
public class AttackData : ScriptableObject
{
    public int damage = 10;
    public float hitStunSeconds = 0.2f;
    public float knockbackForce = 4f;
}

public class Hurtbox : MonoBehaviour
{
    [SerializeField] private Health health;
    [SerializeField] private Rigidbody targetBody;

    public void ReceiveHit(AttackData attack, Vector3 hitOrigin)
    {
        health.TakeDamage(attack.damage);

        Vector3 direction = (transform.position - hitOrigin).normalized;
        targetBody.AddForce(direction * attack.knockbackForce, ForceMode.Impulse);
    }
}

공격 단계와 애니메이션 이벤트

Hitbox 활성화는 애니메이션 이벤트나 공격 상태 머신에서 제어할 수 있습니다. 애니메이션 이벤트는 타이밍을 눈으로 맞추기 쉽지만 이벤트 누락이나 클립 교체에 취약할 수 있습니다. 공격 상태 머신은 코드에서 일관되게 관리하기 좋습니다.

실무에서는 공격 데이터에 선딜, 유효 시간, 후딜을 두고 상태 머신이 시간을 관리하는 방식이 확장에 유리합니다. 애니메이션 이벤트를 쓰더라도 피해 적용 규칙과 공격 종료 처리는 상태 머신이 맡도록 역할을 나누는 것이 좋습니다.

Raycast가 적합한 경우

Raycast는 순간적인 선형 판정에 적합합니다. 총알, 레이저, 조준형 스킬, 매우 빠른 찌르기 공격이 대표적입니다. 이전 위치와 현재 위치 사이를 검사하면 빠른 무기가 대상의 몸을 통과하는 현상도 줄일 수 있습니다.

단순한 한 줄 Raycast는 좁은 판정만 확인합니다. 검날처럼 폭이 있는 물체는 SphereCast나 CapsuleCast가 더 자연스러운 경우가 많습니다.

이전 프레임과 현재 프레임의 검 위치 사이를 SphereCast로 검사하는 모습

이동한 무기 궤적 검사

아래 예제는 검날 끝의 이전 위치와 현재 위치 사이를 SphereCastAll로 검사합니다. 공격이 활성화된 동안 매 프레임 실행하며 한 공격에서 같은 대상을 한 번만 처리합니다.

using System.Collections.Generic;
using UnityEngine;

public class WeaponSweepDetector : MonoBehaviour
{
    [SerializeField] private Transform bladeTip;
    [SerializeField] private float radius = 0.12f;
    [SerializeField] private LayerMask hurtboxMask;

    private readonly HashSet<Hurtbox> hitTargets = new();
    private Vector3 previousTipPosition;
    private AttackData currentAttack;
    private bool isActive;

    public void Begin(AttackData attack)
    {
        currentAttack = attack;
        hitTargets.Clear();
        previousTipPosition = bladeTip.position;
        isActive = true;
    }

    public void End()
    {
        isActive = false;
        currentAttack = null;
    }

    private void LateUpdate()
    {
        if (!isActive)
        {
            return;
        }

        Vector3 currentTipPosition = bladeTip.position;
        Vector3 movement = currentTipPosition - previousTipPosition;
        float distance = movement.magnitude;

        if (distance > 0.001f)
        {
            Ray ray = new Ray(previousTipPosition, movement / distance);
            RaycastHit[] hits = Physics.SphereCastAll(
                ray,
                radius,
                distance,
                hurtboxMask,
                QueryTriggerInteraction.Collide);

            foreach (RaycastHit hit in hits)
            {
                if (hit.collider.TryGetComponent(out Hurtbox hurtbox) && hitTargets.Add(hurtbox))
                {
                    hurtbox.ReceiveHit(currentAttack, transform.position);
                }
            }
        }

        previousTipPosition = currentTipPosition;
    }
}

Update와 LateUpdate 중 무엇을 쓸지는 무기 위치가 갱신되는 순서에 따라 결정해야 합니다. 애니메이션으로 뼈가 움직인 뒤의 위치를 사용하려면 대체로 LateUpdate가 편합니다. 다만 물리 기반 이동과 섞는다면 FixedUpdate에서의 처리 순서도 함께 검증해야 합니다.

Raycast와 Hitbox를 함께 쓰는 기준

두 방식은 경쟁 관계가 아닙니다. 지속되는 범위는 Hitbox로 처리하고 빠른 이동 경로의 누락을 보완하는 용도로 Sweep Raycast를 추가할 수 있습니다.

예를 들어 큰 검을 휘두르는 공격이라면 검날의 Box Collider를 Hitbox로 사용해 넓은 접촉을 처리하고 프레임 간 검날 끝이 크게 이동할 때만 SphereCast를 수행할 수 있습니다. 반대로 총알은 Raycast만으로 충분한 경우가 많습니다.

판정 방식은 다음 기준으로 고르면 됩니다.

  • 공격 영역이 일정 시간 유지되면 Hitbox를 우선 검토합니다.
  • 이동 속도가 매우 빠르고 통과 누락이 문제면 SphereCast 또는 CapsuleCast를 사용합니다.
  • 공격이 벽이나 지형에 막혀야 하면 대상 레이어와 별도로 월드 레이어도 검사합니다.
  • 한 공격이 여러 적을 맞힐 수 있는지 한 적을 여러 번 맞힐 수 있는지를 공격 데이터에 명시합니다.

중복 타격과 다단 히트 설계

HashSet으로 중복 타격을 막으면 단발 공격은 안정적으로 구현할 수 있습니다. 하지만 회전 베기나 장판처럼 일정 간격으로 피해를 주는 공격에는 별도 규칙이 필요합니다.

대상마다 마지막 타격 시각을 기록하고 재타격 간격이 지났을 때만 피해를 주면 됩니다.

using System.Collections.Generic;
using UnityEngine;

public class MultiHitGate
{
    private readonly Dictionary<Hurtbox, float> lastHitTime = new();

    public bool CanHit(Hurtbox target, float interval)
    {
        if (!lastHitTime.TryGetValue(target, out float lastTime))
        {
            lastHitTime[target] = Time.time;
            return true;
        }

        if (Time.time - lastTime < interval)
        {
            return false;
        }

        lastHitTime[target] = Time.time;
        return true;
    }
}

다단 히트에서는 피해량뿐 아니라 경직도 분리하는 편이 좋습니다. 매 타격마다 큰 경직을 주면 대상이 비정상적으로 밀려나거나 조작 불능 시간이 과도해질 수 있습니다. 중간 타격은 약한 반응만 주고 마지막 타격에 큰 넉백을 적용하는 방식이 흔합니다.

구현 시 자주 생기는 문제

공격자 자신을 맞히는 문제

공격자의 Hurtbox도 검사 레이어에 포함되면 자기 자신을 맞힐 수 있습니다. 공격 시작 시 공격자 루트 오브젝트를 저장하고 적중 대상이 같은 루트에 속하면 제외하세요. 팀 게임이라면 진영 ID도 함께 확인해야 합니다.

프레임마다 순서가 달라지는 문제

SphereCastAll의 반환 순서는 게임 규칙에 적합한 우선순위를 보장하지 않습니다. 관통하지 않는 공격이라면 거리순으로 정렬한 뒤 가장 가까운 대상만 처리해야 합니다. 여러 대상을 맞히는 공격은 최대 대상 수와 처리 순서를 명확히 정해 두는 것이 좋습니다.

판정과 연출이 어긋나는 문제

타격 이펙트와 사운드는 ReceiveHit가 성공적으로 실행된 뒤에만 재생해야 합니다. 충돌을 감지했다는 이유만으로 연출을 재생하면 무적 상태, 가드, 패링에 막힌 공격에서도 피격 효과가 나올 수 있습니다.

피해 결과를 Hit, Guarded, Parried, Invulnerable처럼 구분해 반환하면 판정과 연출을 일관되게 연결할 수 있습니다.

디버깅과 테스트 체크리스트

전투 판정은 시각화 없이 맞추기 어렵습니다. 개발 중에는 Gizmo로 Hitbox, Hurtbox, Raycast 경로를 표시하고 다음 항목을 반복해서 확인하세요.

  • 공격 유효 프레임 시작과 종료가 애니메이션과 맞는가
  • 빠른 공격이 가까운 적을 통과하지 않는가
  • 한 번의 단발 공격이 같은 대상을 중복 타격하지 않는가
  • 가드, 무적, 패링 상태에서 피해와 연출이 올바르게 달라지는가
  • 벽 뒤의 적이나 공격자 자신이 잘못 맞지 않는가
  • 프레임레이트가 달라도 결과가 크게 달라지지 않는가

좋은 판정 시스템은 Collider의 모양만으로 완성되지 않습니다. 공격 단계, 대상 필터링, 중복 타격 규칙, 피해 결과를 분리해 두면 공격 종류가 늘어나도 예측 가능한 방식으로 확장할 수 있습니다.