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

12강Area2D와 시그널

감지 영역과 이벤트 기반 사고.

이 강의 목차 (6)
  1. 폴링과 이벤트, 다시
  2. 시그널 연결하는 세 가지 방법
  3. Area2D의 시그널들
  4. 실습 1 — 아이템 줍기
  5. 실습 2 — 밟으면 아픈 함정
  6. 커스텀 시그널 — 내가 만드는 방송
이번 강의의 목표
  • 시그널이 무엇이고 왜 필요한지 안다.
  • Area2D로 아이템 획득·함정·트리거를 만든다.
  • 커스텀 시그널을 정의하고 코드로 연결한다.

폴링과 이벤트, 다시#

7강에서 입력을 다룰 때 나왔던 구분이 여기서 다시 나옵니다. 이번엔 게임 전체 구조의 문제입니다.

폴링 방식 — 매 프레임 "혹시 지금?" 하고 물어봅니다.

gdscript
func _physics_process(_delta):
	for item in get_tree().get_nodes_in_group("items"):
		if position.distance_to(item.position) < 32:
			_pickup(item)

문제가 많습니다.

  • 아이템이 500개면 매 프레임 500번 거리 계산
  • 아이템 쪽 사정(이미 먹혔는지 등)을 플레이어가 다 알아야 함
  • 새 종류가 생길 때마다 플레이어 코드를 고쳐야 함

이벤트 방식 — "생기면 알려줘" 하고 등록해 둡니다.

gdscript
func _ready():
	$Pickup.body_entered.connect(_on_pickup)

func _on_pickup(body):
	...

물리 엔진이 이미 충돌 검사를 하고 있으므로 추가 비용이 거의 없고, 관심 있는 쪽만 반응합니다.

시그널은 방송이다

시그널을 내보내는 쪽은 누가 듣는지 모릅니다. 그냥 방송합니다. 듣고 싶은 쪽이 주파수를 맞춥니다.

이 구조의 힘은 내보내는 쪽이 받는 쪽을 몰라도 된다는 데 있습니다. 적이 죽을 때 died 시그널만 쏘면, 점수 UI·업적 시스템·사운드가 각자 알아서 반응합니다. 적 코드는 그런 게 있는지도 모릅니다.

시그널 연결하는 세 가지 방법#

방법 1 — 에디터에서 (가장 흔함)#

  1. Area2D 노드를 선택
  2. 우측 노드(Node) 독시그널(Signals)
  3. body_entered(body: Node2D) 를 더블클릭
  4. 연결할 노드를 고르고 확인 → 스크립트에 함수가 자동 생성됨

생성된 함수는 이렇게 생겼습니다.

gdscript
func _on_area_2d_body_entered(body: Node2D) -> void:
	pass
에디터 연결의 장점과 단점

장점 — 씬 독에 초록색 연결 표시가 남아서 누가 무엇에 반응하는지 한눈에 보입니다. 단점 — 노드 이름을 바꾸거나 옮기면 조용히 끊어질 수 있습니다.

방법 2 — 코드에서#

gdscript
func _ready() -> void:
	$Hurtbox.area_entered.connect(_on_hurt)
	$Timer.timeout.connect(_on_timeout)

func _on_hurt(area: Area2D) -> void:
	...

동적으로 생성한 노드(총알, 적)는 반드시 코드로 연결해야 합니다.

방법 3 — 추가 인자 넘기기#

gdscript
for i in 3:
	var btn := buttons[i]
	# 어느 버튼인지 번호를 같이 넘긴다.
	btn.pressed.connect(_on_slot_pressed.bind(i))

func _on_slot_pressed(index: int) -> void:
	print("슬롯 ", index)

bind()로 붙인 인자는 시그널 원래 인자 뒤에 붙습니다.

Area2D의 시그널들#

시그널 언제
body_entered(body) 물리 바디가 들어옴
body_exited(body) 물리 바디가 나감
area_entered(area) 다른 Area2D가 들어옴
area_exited(area) 다른 Area가 나감
body와 area를 헷갈리면 아무 일도 안 일어난다
  • 상대가 CharacterBody2D / RigidBody2D / StaticBody2Dbody_entered
  • 상대가 Area2Darea_entered

플레이어 몸통(CharacterBody2D)이 아이템(Area2D)에 닿는 상황이면, 아이템 쪽에서는 body_entered가 옵니다. 플레이어의 줍기 영역(Area2D)이 아이템(Area2D)에 닿는 거라면 area_entered입니다.

"시그널이 안 와요"의 절반은 이것입니다.

Area2D의 주요 속성:

속성 의미
monitoring 내가 남을 감지할까 (끄면 시그널 안 옴)
monitorable 남이 나를 감지할 수 있을까
priority 여러 Area가 겹칠 때 우선순위

실습 1 — 아이템 줍기#

아이템 씬 (coin.tscn)#

text
Coin                (Area2D)     layer: pickup, mask: (없음)
├─ Sprite2D
└─ CollisionShape2D              반지름 24 정도로 넉넉하게
gdscript
extends Area2D

@export var value := 10

func _on_body_entered(body: Node2D) -> void:
	if not body.is_in_group("player"):
		return
	# 실제 게임에선 전역 점수에 더한다(21강).
	print("코인 획득 +", value)
	# 먹힌 즉시 판정을 끈다. 안 그러면
	# 사라지기 전 프레임에 두 번 먹힐 수 있다.
	set_deferred("monitoring", false)
	_play_pickup_effect()

func _play_pickup_effect() -> void:
	var tw := create_tween()
	tw.set_parallel(true)
	tw.tween_property(self, "scale", Vector2(1.6, 1.6), 0.15)
	tw.tween_property(self, "modulate:a", 0.0, 0.15)
	tw.tween_property(self, "position:y",
		position.y - 24.0, 0.15)
	tw.chain().tween_callback(queue_free)
그룹(Group) — 태그로 분류하기

body.is_in_group("player")그룹을 확인합니다. 노드를 선택하고 우측 노드 독 → 그룹 탭에서 이름을 추가하면 됩니다.

gdscript
add_to_group("enemy")           # 코드로 추가
is_in_group("enemy")            # 확인
get_tree().get_nodes_in_group("enemy")  # 전부 가져오기
get_tree().call_group("enemy", "stun")  # 전부에게 호출

"이게 플레이어인가?"를 판별할 때 클래스 이름 비교보다 그룹이 안전합니다. 플레이어가 여러 종류로 늘어나도 코드가 안 바뀝니다.

queue_free()는 즉시 지우지 않는다

queue_free() 는 "이번 프레임 끝나면 지워라"입니다. 그 사이에 시그널이 한 번 더 올 수 있습니다. 그래서 위 코드에서 monitoring을 먼저 끕니다.

즉시 지우는 free() 도 있지만, 물리 처리 도중에 부르면 엔진이 터집니다. 쓰지 마세요.

실습 2 — 밟으면 아픈 함정#

들어올 때 한 번이 아니라 머무는 동안 계속 데미지를 주려면 "안에 있는 목록"을 관리합니다.

gdscript
extends Area2D

@export var dps := 20.0          # 초당 데미지
@export var tick := 0.5          # 데미지 간격

var _inside: Array[Node2D] = []
var _timer := 0.0

func _ready() -> void:
	body_entered.connect(_on_enter)
	body_exited.connect(_on_exit)

func _on_enter(body: Node2D) -> void:
	if body.has_method("take_damage"):
		_inside.append(body)

func _on_exit(body: Node2D) -> void:
	_inside.erase(body)

func _physics_process(delta: float) -> void:
	if _inside.is_empty():
		return
	_timer += delta
	if _timer < tick:
		return
	_timer = 0.0
	for body in _inside:
		if is_instance_valid(body):
			body.take_damage(dps * tick)
is_instance_valid를 꼭 확인한다

목록에 담아둔 사이에 그 노드가 죽어서 사라질 수 있습니다. 사라진 노드를 건드리면 "Previously freed object" 오류로 게임이 멈춥니다.

body_exited는 노드가 queue_free()로 사라질 때도 호출되지만, 타이밍에 따라 놓칠 수 있으므로 방어 코드를 둡니다.

커스텀 시그널 — 내가 만드는 방송#

내장 시그널만 쓰는 게 아니라 직접 정의할 수 있습니다. 여기서부터가 진짜입니다.

gdscript
extends CharacterBody2D

signal health_changed(current: int, maximum: int)
signal died

@export var max_hp := 100
var hp: int:
	set(value):
		hp = clampi(value, 0, max_hp)
		health_changed.emit(hp, max_hp)
		if hp == 0:
			died.emit()

func _ready() -> void:
	hp = max_hp

func take_damage(amount: int) -> void:
	hp -= amount

이제 다른 쪽에서 받습니다.

gdscript
# HUD 쪽
func _ready() -> void:
	var player := get_tree().get_first_node_in_group("player")
	player.health_changed.connect(_on_health_changed)
	player.died.connect(_on_died)

func _on_health_changed(cur: int, max_v: int) -> void:
	$Bar.value = float(cur) / max_v * 100.0

func _on_died() -> void:
	$GameOver.show()
이 구조가 왜 좋은가

플레이어 코드에는 UI에 대한 언급이 한 줄도 없습니다.

  • UI를 나중에 바꿔도 플레이어 코드는 그대로
  • 사운드·업적·통계를 붙일 때도 플레이어를 안 건드림
  • 플레이어를 다른 프로젝트에 통째로 가져다 써도 동작

이걸 느슨한 결합(loose coupling) 이라고 부릅니다. "위에서 아래로는 직접 부르고($Child.do()), 아래에서 위로는 시그널로 알린다"가 Godot의 관례입니다.

setter 문법 (`set:`)
gdscript
var hp: int:
	set(value):
		hp = value
		health_changed.emit(hp, max_hp)

변수에 값이 대입될 때마다 자동으로 실행되는 코드입니다. hp -= 10 한 줄만 써도 시그널이 알아서 나갑니다. take_damage, heal, 독 데미지 등 모든 경로에서 빠짐없이 시그널이 나가는 게 장점입니다.

주의: setter 안에서 hp = value 대신 self.hp = value 라고 쓰면 무한 재귀로 멈춥니다.

직접 바꿔보기
  1. 코인을 10개 놓고 전부 주워보세요. 점수가 정확히 합산됩니까?
  2. 함정의 tick을 0.1로 낮춰보세요. 데미지가 10배 빨라집니까? (그러면 안 됩니다 — dps * tick 이므로 초당 데미지는 같아야 합니다)
  3. 커스텀 시그널 died두 개의 함수를 연결해 보세요(UI 갱신 + 사운드). 둘 다 호출됩니까?
  4. queue_free() 직후에도 시그널이 오는지 확인해 보세요.
  • 개발자 — 시그널 연결이 많아지면 _ready()에 모아두고, disconnect가 필요한 경우 (일시적인 연결) 반드시 짝을 맞추세요. 노드가 사라지면 연결도 같이 사라지므로 대부분은 신경 안 써도 됩니다.
  • 기획자 — "적이 죽으면 무슨 일이 일어나나요?"의 목록이 곧 시그널 수신자 목록입니다. 점수, 드랍, 퀘스트 카운트, 사운드, 화면 흔들림… 이 목록을 기획서에 적어주면 개발자가 그대로 연결합니다.
  • 디자이너 — 획득·피격 같은 순간에 연출을 붙일 수 있는 지점(시그널)이 이미 있습니다. "여기에 이펙트 넣고 싶다"를 말하면 대개 새 코드 없이 붙습니다.
자주 나는 오류

"시그널이 두 번 호출된다" → 에디터에서 한 번, 코드에서 한 번 중복 연결했습니다. 씬 독의 초록색 연결 아이콘을 확인하세요.

"Signal 'xxx' is already connected" → 같은 함수를 두 번 연결했습니다. if not sig.is_connected(f): sig.connect(f) 로 방어합니다.

"Attempt to call function on a previously freed instance" → 사라진 노드를 참조했습니다. is_instance_valid() 로 확인하세요.

"에디터에서 연결했는데 함수가 안 불린다" → 씬을 인스턴스로 여러 개 놓았을 때, 원본 씬이 아니라 인스턴스 하나에만 연결했을 수 있습니다. 연결은 원본 씬 안에서 하세요.

여기까지 확인
  • 시그널은 "내보내는 쪽이 받는 쪽을 모른다"
  • body_enteredarea_entered를 구분한다
  • 아이템 줍기와 지속 데미지 함정을 만들었다
  • 커스텀 시그널을 정의하고 UI에 연결했다

다음 강의 — 충돌은 "닿았을 때"만 알려줍니다. 닿기 전에 미리 알고 싶다면? 레이캐스트를 봅니다.