Godot 4 전투 판정 설계 — 발사체, 프레임 타이밍, 히트박스 분리

2026년 08월 27일 · 개발 일지

Tiny Reign에 아처를 추가하면서 전투 쪽을 손대기 시작했다. 근접 유닛만 있을 때는 대충 넘어갔던 것들이 원거리 공격이 들어오니까 전부 문제가 됐다. 정리하고 보니 질문이 셋이었다. 무엇이 날아가는가, 언제 나가는가, 어디에 맞는가. 하나씩 풀었다.

화살 하나지만 나중을 생각했다

당장 필요한 건 화살 하나였다. 그런데 나중에 마법 투사체나 다른 발사체가 생길 것 같았다. 그래서 Projectile 베이스 클래스를 만들고 Arrow가 상속받는 구조로 갔다.

Projectile은 발사체가 공통으로 가져야 할 것들만 담았다. 이동, 팀 정보, 공격력, 목표 설정, 충돌 처리가 그것이다. 충돌이 일어나면 상대방 CombatComponent를 찾아서 데미지를 넣고, 피격 처리 후 발사체를 제거한다.

이동 방식이나 애니메이션은 자식 클래스에서 구현하도록 _move() 같은 가상 함수를 열어뒀다. Arrow에서 이걸 오버라이드해서 포물선 이동을 구현했다.

포물선과 관통

화살 발사체 씬과 발사체 속도·궤도 높이·관통 횟수를 정리한 CSV

화살이 직선으로 날아가면 심심하다. 포물선을 주고 싶었는데 구현은 생각보다 단순했다. 발사 시점과 목표 지점을 기준으로 진행 비율(t)을 0에서 1로 올리면서, Y축에 포물선 오프셋을 더해준다.

오프셋은 sin(t * PI) * arc_height로 계산했다. t가 0이면 0, 0.5면 최대 높이, 1이면 다시 0이 된다. arc_height를 CSV에서 받아오니까 화살마다 다른 궤도를 줄 수 있었다.

관통도 같은 방식으로 붙였다. pierce_count를 두고 충돌할 때마다 1씩 줄이고, 0이 되면 제거한다. 기본값은 1이고 특수 화살은 2나 3으로 설정하면 여러 유닛을 관통한다. 이 로직을 베이스 클래스에 두니까 Arrow뿐 아니라 다른 발사체를 추가해도 다시 짤 필요가 없었다.

스폰은 CharacterFactory Autoload에서 발사체 풀로 관리하게 했다. 화살을 쏠 때마다 인스턴스를 새로 만들면 성능 문제가 생길 수 있어서, 사용하지 않는 발사체를 재사용하는 방식이다.

그런데 화살이 언제 나가야 하나

구조를 잡고 나니 다음 문제가 나왔다. 화살을 언제 발사할 것인가.

처음엔 공격 애니메이션이 시작되는 순간 발사했다. 그랬더니 비주얼이 이상했다. 활시위를 당기는 모션이 아직 진행 중인데 화살이 먼저 튀어나가 버렸다. 코드는 맞는데 보기에 틀린 상태였다.

애니메이션 프레임에 타이밍을 맞췄다

공격 애니메이션의 판정 시작·종료 프레임을 지정한 설정 화면

해결 방법은 애니메이션의 특정 프레임에서 발사하는 것이다. Tiny Reign에서는 AnimatedSprite2D를 쓰고 있어서 frame_changed 시그널을 활용했다. 아처 공격 애니메이션에서 활시위가 완전히 당겨지는 프레임 번호를 찾아서, 그 프레임에서만 발사 함수를 호출하도록 했다.

현재 animation이 attack이면서 frame 번호가 공격 판정 프레임일 때만 처리한다. 프레임 번호는 CSV에서 attack_frame으로 관리해서 유닛마다 다르게 설정할 수 있게 했다.

근거리 유닛도 같은 방식이다. 검을 휘두르는 애니메이션에서 칼날이 닿는 프레임에 Hitbox를 켜고 다음 프레임에 끈다. 이렇게 하면 공격 판정이 비주얼과 딱 맞아떨어진다.

같은 프레임에 두 번 발사되는 문제

주의할 점이 있다. frame_changed는 매 프레임 호출되는데, 공격 판정 프레임에서 시그널이 두 번 오는 경우가 있었다. 화살이 한 발 쏠 때마다 두 발씩 나가는 걸 보고 알았다. 이미 발사했으면 무시하는 플래그를 두거나 attack_cooldown으로 중복 발사를 막아야 한다.

맞는 쪽도 정리가 필요했다

발사 타이밍까지 맞추고 나니 마지막 문제가 남았다. 맞는 판정이다.

처음에 유닛 충돌 처리를 CharacterBody2D의 CollisionShape2D 하나로 다 했다. 이동 충돌이랑 피격 판정이 같은 도형에 섞여 있으니까 이상한 일이 계속 생겼다. 공격 범위가 이동 충돌과 간섭하거나, 팀 구분 없이 아군끼리도 피격 처리되는 식이었다.

Hurtbox와 Hitbox로 나눴다

유닛 씬에서 피격 판정 영역을 별도 노드로 분리한 모습

정리해보니 충돌에는 두 가지가 있었다. 이동할 때 벽이나 지형에 막혀야 하는 물리 충돌, 그리고 공격이 닿았을 때 데미지를 받는 피격 판정. 성격이 다른데 한 곳에서 처리하고 있었던 것이다.

CollisionShape2D는 이동 전용으로만 남겼다. 피격 판정은 Hurtbox라는 Area2D로, 공격 판정은 Hitbox라는 Area2D로 따로 만들었다. Hurtbox는 나를 때릴 수 있는 영역이고, Hitbox는 내가 때리는 영역이다.

팀 구분은 레이어로 해결했다

Area2D를 분리해도 아군끼리 피격되는 문제가 남았다. 이걸 해결한 게 collision layer와 mask 동적 설정이다.

Godot에서는 Physics Layer를 비트마스크로 관리한다. ALLY 팀은 레이어 1, ENEMY 팀은 레이어 2로 정했다. Hurtbox는 자기 팀 레이어에 올리고, Hitbox는 상대 팀 레이어만 감지하도록 설정했다. 유닛이 생성될 때 team 값에 따라 동적으로 layer와 mask를 설정해주면 된다.

이렇게 하니까 코드에서 상대방인지 확인하는 조건 없이도 Hitbox의 body_entered 시그널이 항상 적군만 감지했다. 판정 규칙을 코드가 아니라 물리 레이어에 맡긴 셈이다.

앞서 만든 화살 관통도 여기서 깔끔해졌다. Projectile에도 Hitbox를 붙이고 같은 레이어 규칙을 적용하니, 발사체가 유닛의 팀 충돌 규칙을 그대로 재사용했다.

정리하면

세 가지가 따로 놀던 문제였는데 결국 하나로 이어졌다. 발사체를 베이스 클래스로 잡아두니 관통 같은 기능을 한 곳에서 처리할 수 있었고, 프레임 기반 판정으로 바꾸니 애니메이션 속도를 바꿔도 타이밍을 다시 맞출 일이 없었고, Hurtbox와 Hitbox를 나누니 팀 판정을 코드에서 걷어낼 수 있었다.

공통점을 굳이 꼽자면 전부 책임을 나누는 작업이었다. 하나가 여러 역할을 하고 있을 때는 잘 돌아가는 것처럼 보이다가, 새 요소가 들어오는 순간 전부 터졌다. 아처 하나 추가하려다 전투 구조를 통째로 정리하게 된 것도 그래서였다.

이 시리즈의 다른 글

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다