12강Area2D와 시그널
감지 영역과 이벤트 기반 사고.
- 시그널이 무엇이고 왜 필요한지 안다.
Area2D로 아이템 획득·함정·트리거를 만든다.- 커스텀 시그널을 정의하고 코드로 연결한다.
폴링과 이벤트, 다시#
7강에서 입력을 다룰 때 나왔던 구분이 여기서 다시 나옵니다. 이번엔 게임 전체 구조의 문제입니다.
폴링 방식 — 매 프레임 "혹시 지금?" 하고 물어봅니다.
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번 거리 계산
- 아이템 쪽 사정(이미 먹혔는지 등)을 플레이어가 다 알아야 함
- 새 종류가 생길 때마다 플레이어 코드를 고쳐야 함
이벤트 방식 — "생기면 알려줘" 하고 등록해 둡니다.
func _ready():
$Pickup.body_entered.connect(_on_pickup)
func _on_pickup(body):
...물리 엔진이 이미 충돌 검사를 하고 있으므로 추가 비용이 거의 없고, 관심 있는 쪽만 반응합니다.
시그널을 내보내는 쪽은 누가 듣는지 모릅니다. 그냥 방송합니다. 듣고 싶은 쪽이 주파수를 맞춥니다.
이 구조의 힘은 내보내는 쪽이 받는 쪽을 몰라도 된다는 데 있습니다.
적이 죽을 때 died 시그널만 쏘면, 점수 UI·업적 시스템·사운드가 각자 알아서 반응합니다.
적 코드는 그런 게 있는지도 모릅니다.
시그널 연결하는 세 가지 방법#
방법 1 — 에디터에서 (가장 흔함)#
Area2D노드를 선택- 우측 노드(Node) 독 → 시그널(Signals) 탭
body_entered(body: Node2D)를 더블클릭- 연결할 노드를 고르고 확인 → 스크립트에 함수가 자동 생성됨
생성된 함수는 이렇게 생겼습니다.
func _on_area_2d_body_entered(body: Node2D) -> void:
pass장점 — 씬 독에 초록색 연결 표시가 남아서 누가 무엇에 반응하는지 한눈에 보입니다. 단점 — 노드 이름을 바꾸거나 옮기면 조용히 끊어질 수 있습니다.
방법 2 — 코드에서#
func _ready() -> void:
$Hurtbox.area_entered.connect(_on_hurt)
$Timer.timeout.connect(_on_timeout)
func _on_hurt(area: Area2D) -> void:
...동적으로 생성한 노드(총알, 적)는 반드시 코드로 연결해야 합니다.
방법 3 — 추가 인자 넘기기#
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가 나감 |
- 상대가
CharacterBody2D/RigidBody2D/StaticBody2D→body_entered - 상대가
Area2D→area_entered
플레이어 몸통(CharacterBody2D)이 아이템(Area2D)에 닿는 상황이면,
아이템 쪽에서는 body_entered가 옵니다.
플레이어의 줍기 영역(Area2D)이 아이템(Area2D)에 닿는 거라면 area_entered입니다.
"시그널이 안 와요"의 절반은 이것입니다.
Area2D의 주요 속성:
| 속성 | 의미 |
|---|---|
monitoring |
내가 남을 감지할까 (끄면 시그널 안 옴) |
monitorable |
남이 나를 감지할 수 있을까 |
priority |
여러 Area가 겹칠 때 우선순위 |
실습 1 — 아이템 줍기#
아이템 씬 (coin.tscn)#
Coin (Area2D) layer: pickup, mask: (없음)
├─ Sprite2D
└─ CollisionShape2D 반지름 24 정도로 넉넉하게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)body.is_in_group("player") 는 그룹을 확인합니다.
노드를 선택하고 우측 노드 독 → 그룹 탭에서 이름을 추가하면 됩니다.
add_to_group("enemy") # 코드로 추가
is_in_group("enemy") # 확인
get_tree().get_nodes_in_group("enemy") # 전부 가져오기
get_tree().call_group("enemy", "stun") # 전부에게 호출"이게 플레이어인가?"를 판별할 때 클래스 이름 비교보다 그룹이 안전합니다. 플레이어가 여러 종류로 늘어나도 코드가 안 바뀝니다.
queue_free() 는 "이번 프레임 끝나면 지워라"입니다. 그 사이에 시그널이 한 번 더 올 수 있습니다.
그래서 위 코드에서 monitoring을 먼저 끕니다.
즉시 지우는 free() 도 있지만, 물리 처리 도중에 부르면 엔진이 터집니다. 쓰지 마세요.
실습 2 — 밟으면 아픈 함정#
들어올 때 한 번이 아니라 머무는 동안 계속 데미지를 주려면 "안에 있는 목록"을 관리합니다.
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)목록에 담아둔 사이에 그 노드가 죽어서 사라질 수 있습니다. 사라진 노드를 건드리면 "Previously freed object" 오류로 게임이 멈춥니다.
body_exited는 노드가 queue_free()로 사라질 때도 호출되지만,
타이밍에 따라 놓칠 수 있으므로 방어 코드를 둡니다.
커스텀 시그널 — 내가 만드는 방송#
내장 시그널만 쓰는 게 아니라 직접 정의할 수 있습니다. 여기서부터가 진짜입니다.
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이제 다른 쪽에서 받습니다.
# 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:`)
var hp: int:
set(value):
hp = value
health_changed.emit(hp, max_hp)변수에 값이 대입될 때마다 자동으로 실행되는 코드입니다.
hp -= 10 한 줄만 써도 시그널이 알아서 나갑니다.
take_damage, heal, 독 데미지 등 모든 경로에서 빠짐없이 시그널이 나가는 게 장점입니다.
주의: setter 안에서 hp = value 대신 self.hp = value 라고 쓰면 무한 재귀로 멈춥니다.
- 코인을 10개 놓고 전부 주워보세요. 점수가 정확히 합산됩니까?
- 함정의
tick을 0.1로 낮춰보세요. 데미지가 10배 빨라집니까? (그러면 안 됩니다 —dps * tick이므로 초당 데미지는 같아야 합니다) - 커스텀 시그널
died에 두 개의 함수를 연결해 보세요(UI 갱신 + 사운드). 둘 다 호출됩니까? 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_entered와area_entered를 구분한다 - 아이템 줍기와 지속 데미지 함정을 만들었다
- 커스텀 시그널을 정의하고 UI에 연결했다
다음 강의 — 충돌은 "닿았을 때"만 알려줍니다. 닿기 전에 미리 알고 싶다면? 레이캐스트를 봅니다.