2D Godot 2D 게임 기초
P3 충돌과 상호작용

11강충돌 모양과 레이어·마스크

그림과 콜라이더는 별개다. 누가 누구를 보는가.

이 강의 목차 (6)
  1. 보이는 것과 부딪히는 것은 다르다
  2. 레이어와 마스크
  3. 레이어에 이름 붙이기
  4. 설정표 — 이대로 하면 된다
  5. 히트박스와 허트박스
  6. 실습 — 서로를 통과하는 총알
이번 강의의 목표
  • 그림과 콜라이더가 별개임을 확실히 한다.
  • 레이어(Layer)와 마스크(Mask) 를 구분해서 설정한다.
  • 히트박스/허트박스 설계 규칙을 세운다.

보이는 것과 부딪히는 것은 다르다#

9강에서 한 번 말했지만, 여기서 못을 박습니다.

두 개의 몸
  • Sprite2D — 눈에 보이는 그림. 물리와 아무 관계없다.
  • CollisionShape2D — 실제로 부딪히는 도형. 눈에 안 보인다.

스프라이트를 2배 키워도 콜라이더는 그대로입니다. 콜라이더를 2배 키워도 그림은 그대로입니다. 둘은 서로를 모릅니다.

이게 불편해 보이지만, 사실 의도적인 설계입니다. 게임에서 "보이는 크기"와 "맞는 크기"는 일부러 다르게 만드는 경우가 훨씬 많기 때문입니다.

대상 판정 크기 이유
플레이어 피격 판정 보이는 것보다 작게 "억울하게 맞았다"를 줄인다
플레이어 공격 판정 보이는 것보다 크게 "빗나갔는데 맞았다"가 기분 좋다
적 피격 판정 보이는 것과 비슷하거나 크게 맞히는 맛
아이템 획득 훨씬 크게 줍는 스트레스 제거
정확히 시각적 어긋남이 바로 보임
슈팅 게임의 극단적인 예

탄막 슈팅 게임에서 플레이어의 피격 판정은 말 그대로 1~2픽셀입니다. 비행기 그림은 30픽셀인데 실제 판정은 조종석 한 점입니다.

플레이어에게 이걸 보여주기까지 합니다. 기체 중앙에 작은 빨간 점을 그려두죠. "보이는 크기 ≠ 판정 크기"를 게임 디자인으로 승화시킨 사례입니다.

지금 확인하기

디버그 → 충돌 도형 표시(Visible Collision Shapes) 를 켜고 실행하세요. 파란 윤곽선이 실제 판정입니다. 이 옵션은 개발 내내 켜두는 게 좋습니다.

레이어와 마스크#

이제 이 강의의 핵심입니다. 초보가 가장 많이 막히는 지점이기도 합니다.

물리 바디와 Area는 각각 두 개의 설정을 가집니다.

레이어와 마스크
  • Collision Layer (레이어)"나는 누구인가" 나 자신이 어떤 부류에 속하는지 선언한다.
  • Collision Mask (마스크)"나는 누구를 보는가" 내가 어떤 부류를 감지하고 부딪힐지 고른다.

둘 다 1~32번 칸이 있고, 여러 개를 동시에 켤 수 있습니다.

무전기 채널
  • 레이어 = 내가 송신하는 채널
  • 마스크 = 내가 수신하는 채널

3번 채널로 방송하는 사람의 말은, 3번 채널을 듣고 있는 사람에게만 들립니다.

충돌은 언제 성립하나#

반드시 외울 규칙

A가 B를 감지하려면 → A의 마스크에 B의 레이어가 켜져 있어야 한다.

그리고 이 관계는 일방적일 수 있습니다. A는 B를 보는데 B는 A를 못 볼 수 있습니다.

상황 결과
A 마스크 ⊃ B 레이어, B 마스크 ⊃ A 레이어 서로 부딪힘
A 마스크 ⊃ B 레이어, B는 A를 안 봄 A만 B를 감지
둘 다 서로 안 봄 통과

Area2D는 감지만 하므로 한 방향이면 충분하고, 물리 바디끼리 서로 밀어내려면 양쪽 다 켜져 있어야 합니다.

레이어에 이름 붙이기#

번호만 보면 3일 뒤에 잊어버립니다. Godot은 이름을 붙일 수 있습니다.

프로젝트 설정 → Layer Names → 2D Physics

이 강좌에서 쓸 표준 배치를 정해둡니다.

번호 이름 무엇
1 world 벽, 바닥, 지형
2 player 플레이어 몸통
3 enemy 적 몸통
4 player_hitbox 플레이어의 공격 판정
5 enemy_hitbox 적의 공격 판정
6 pickup 아이템
7 interactable 상호작용 대상(문, NPC)
8 camera_bounds 카메라 경계

이름을 붙이면 인스펙터에 체크박스 옆에 이름이 나와서 실수가 확 줄어듭니다.

설정표 — 이대로 하면 된다#

노드 Layer (나는) Mask (내가 보는)
벽 (StaticBody2D) world (없음)
플레이어 (CharacterBody2D) player world
적 (CharacterBody2D) enemy world
플레이어 공격 (Area2D) player_hitbox enemy
적 공격 (Area2D) enemy_hitbox player
아이템 (Area2D) pickup (없음)
플레이어 줍기 영역 (Area2D) (없음) pickup
이 표를 읽는 법
  • 벽은 마스크가 비어 있습니다. 벽은 아무것도 찾아다니지 않습니다. 가만히 있고, 남들이 벽을 봅니다.
  • 플레이어 공격 Area의 마스크에 player없습니다. 그래서 자기 자신은 절대 때리지 않습니다.
  • 아이템은 레이어만 있고 마스크가 없습니다. 아이템이 플레이어를 찾는 게 아니라 플레이어의 줍기 영역이 아이템을 찾습니다.
자주 하는 실수 — 모든 칸을 다 켜기

"안 되니까 일단 다 켜보자"를 하면 당장은 되는 것처럼 보입니다. 그리고 나중에 이런 버그가 납니다.

  • 내 총알이 나를 맞힌다
  • 적의 공격이 다른 적을 맞힌다
  • 아이템이 벽에 걸려서 못 줍는다
  • 총알 100개가 서로 충돌 검사를 해서 프레임이 떨어진다

레이어/마스크는 성능 최적화이기도 합니다. 불필요한 조합을 끄면 검사 자체를 안 합니다.

히트박스와 허트박스#

액션 게임의 표준 구조입니다. 이름을 헷갈리지 마세요.

두 상자
  • 히트박스(Hitbox)때리는 영역. 공격 판정.
  • 허트박스(Hurtbox)맞는 영역. 피격 판정. (hurt = 다치다)

히트박스가 허트박스에 들어가면 데미지가 발생합니다.

text
Player              (CharacterBody2D)
                    layer: player / mask: world
├─ Sprite               (Sprite2D)
├─ CollisionShape2D     ← 벽과 부딪히는 몸
├─ Hurtbox          (Area2D)
│                   mask: enemy_hitbox
│   └─ CollisionShape2D ← 맞는 판정 (그림보다 작게)
└─ Attack           (Area2D)
                    layer: player_hitbox / mask: enemy
    └─ CollisionShape2D ← 때리는 판정 (평소엔 꺼둔다)
공격 판정은 평소에 꺼둔다

검을 휘두르는 순간에만 켜야 합니다. 항상 켜두면 지나가기만 해도 적이 죽습니다.

gdscript
# 공격 시작
$Attack/CollisionShape2D.disabled = false
# 0.15초 뒤 끄기
await get_tree().create_timer(0.15).timeout
$Attack/CollisionShape2D.disabled = true

disabled를 물리 처리 도중에 바꾸면 경고가 날 수 있습니다. 그때는 set_deferred("disabled", false) 를 씁니다.

왜 히트박스를 별도 Area로 두나

"몸통 콜라이더로 그냥 판정하면 안 되나요?" 안 되는 이유가 있습니다.

  1. 크기가 달라야 한다 — 몸통은 벽에 맞는 크기, 피격은 더 작은 크기
  2. 켜고 끌 수 있어야 한다 — 무적 시간 동안 허트박스만 끈다
  3. 여러 개일 수 있다 — 머리(약점, 2배 데미지), 몸통(1배), 꼬리(0.5배)
  4. 모양이 다르다 — 몸통은 캡슐, 공격은 부채꼴

역할을 나누면 각각을 독립적으로 조정할 수 있습니다.

실습 — 서로를 통과하는 총알#

목표: 플레이어 총알은 적만 맞히고, 아군과 자기 자신은 통과한다.

1) 레이어 이름 설정#

위 표대로 프로젝트 설정 → Layer Names → 2D Physics 에 1~6번 이름을 채웁니다.

2) 총알 씬#

text
Bullet              (Area2D)
├─ Sprite2D
└─ CollisionShape2D

인스펙터에서:

  • Collision → Layer: player_hitbox (4번)만 체크
  • Collision → Mask: enemy(3번), world(1번) 체크
gdscript
extends Area2D

@export var speed := 900.0
@export var damage := 10

func _ready() -> void:
	# 5초 뒤 자동 소멸 (화면 밖 총알이 쌓이는 걸 막는다)
	await get_tree().create_timer(5.0).timeout
	queue_free()

func _physics_process(delta: float) -> void:
	position += Vector2.RIGHT.rotated(rotation) * speed * delta

func _on_body_entered(body: Node2D) -> void:
	if body.has_method("take_damage"):
		body.take_damage(damage)
	queue_free()

시그널 연결은 12강에서 자세히 다룹니다. 지금은 Area2D를 선택 → 노드 독 → body_entered 를 더블클릭 → 이 스크립트에 연결하면 됩니다.

3) 확인#

  • 총알이 적에게 닿으면 사라진다
  • 총알이 플레이어를 통과한다
  • 총알이 벽에 닿으면 사라진다
  • 총알이 다른 총알을 무시한다
직접 바꿔보기
  1. 총알의 마스크에서 world를 꺼보세요. 벽을 뚫고 지나갑니까?
  2. 총알의 마스크에 player를 켜보세요. 자기가 쏜 총알에 맞습니까?
  3. 적을 두 마리 놓고, 적 총알의 마스크에 enemy를 켜보세요. 적끼리 서로 쏴 죽입니까? (아군 사격 버그의 정체입니다)
  4. 아이템을 만들고 줍기 영역 크기를 캐릭터의 3배로 키워보세요. 줍는 느낌이 어떻습니까?
  • 개발자 — 레이어 배치는 프로젝트 초기에 문서로 못 박으세요. 중간에 번호를 바꾸면 이미 만든 모든 씬을 열어 다시 체크해야 합니다.
  • 기획자 — "이 스킬은 적만 맞아야 하나요, 아군도 맞나요?"는 레이어 설계에 직결되는 질문입니다. 스킬 기획서에 이 항목을 아예 칸으로 만들어 두세요.
  • 디자이너 — 판정 크기는 그림 크기와 별개로 정해집니다. "보이는 것보다 작게/크게"를 어느 정도로 할지 개발자와 같이 정하고, 이펙트 크기가 판정 크기를 오해하게 만들지 않는지 확인하세요. 큰 폭발 이펙트에 작은 판정은 "왜 안 맞아?"를 만듭니다.
자주 나는 오류

"Area2D 시그널이 아예 안 온다" → ① 마스크에 상대 레이어가 없음, ② Monitoring이 꺼져 있음, ③ CollisionShape2D가 없거나 Shape이 비어 있음.

"body_entered는 오는데 area_entered는 안 온다" → 상대가 Area2Darea_entered, 물리 바디면 body_entered 입니다. 시그널을 잘못 연결한 것입니다. 상대가 뭔지 확인하세요.

"한 번 때렸는데 데미지가 여러 번 들어간다" → Area가 겹쳐 있는 동안 매 프레임 판정이 아니라, 들어오는 순간 한 번만 옵니다. 여러 번 들어온다면 히트박스가 여러 개이거나 시그널이 중복 연결되었습니다.

여기까지 확인
  • 그림과 콜라이더가 별개임을 안다
  • 레이어는 "나는 누구", 마스크는 "내가 보는 것"
  • 레이어 이름을 프로젝트 설정에 등록했다
  • 히트박스와 허트박스를 구분한다
  • 총알이 적만 맞히도록 만들었다

다음 강의 — 충돌이 일어났을 때 어떻게 알림을 받는지. Godot의 시그널과 이벤트 기반 사고를 봅니다.