9강CharacterBody2D로 캐릭터 움직이기
물리 바디 4형제와 move_and_slide.
이 강의 목차 (6)
- 물리 바디 4형제의 역할을 구분한다.
CharacterBody2D+move_and_slide()로 벽에 막히는 캐릭터를 만든다.- 벽에 부딪혔을 때 미끄러지는 이유를 안다.
물리 바디 4형제#
Godot 2D의 물리 노드는 넷입니다. 성격이 완전히 다릅니다.
| 노드 | 움직이나 | 밀리나 | 누가 조종하나 | 대표 용도 |
|---|---|---|---|---|
StaticBody2D |
✕ | ✕ | 아무도 | 벽, 바닥, 지형 |
RigidBody2D |
○ | ○ | 물리 엔진 | 상자, 공, 파편 |
CharacterBody2D |
○ | ✕ | 내 코드 | 플레이어, 적 |
Area2D |
○ | — | 내 코드 | 감지 영역, 함정, 아이템 |
RigidBody2D— "던져 놓으면 알아서 굴러간다." 중력·마찰·반발을 물리 엔진이 계산합니다. 내가position을 직접 대입하면 싸웁니다.CharacterBody2D— "내가 매 프레임 어디로 갈지 정한다." 물리 엔진은 "거기 가려는데 벽이 있다" 만 알려줍니다. 중력도 내가 직접 더합니다.
플레이어는 왜 RigidBody가 아닐까요? 폭탄에 맞으면 날아가지만, 걸을 때는 물리 법칙을 무시하고 즉시 멈춰야 하기 때문입니다. "현실적인 물리"와 "기분 좋은 조작감"은 대개 반대입니다.
그럼 RigidBody2D는 언제 쓰나
- 굴러다니는 통, 무너지는 상자 더미, 흩어지는 파편
- 물리 퍼즐(앵그리버드 같은)
- 밀 수 있는 짐
플레이어와 상호작용시킬 때는 CharacterBody2D 쪽에서 힘을 줍니다.
for i in get_slide_collision_count():
var c := get_slide_collision(i)
var body := c.get_collider()
if body is RigidBody2D:
body.apply_central_impulse(-c.get_normal() * 120.0)CharacterBody2D의 구조#
Player (CharacterBody2D)
├─ Sprite (Sprite2D)
└─ CollisionShape2D ← 없으면 아무 일도 안 일어난다CharacterBody2D를 만들어 놓고 CollisionShape2D를 안 붙이면
아무것도 안 부딪힙니다. 에디터에 노란 경고 삼각형이 뜹니다.
그리고 그림과 콜라이더는 아무 관계가 없습니다. 스프라이트를 2배로 키워도 콜라이더는 그대로입니다. 11강에서 자세히 다룹니다.
CollisionShape2D를 선택하고 인스펙터의 Shape 에서 도형을 고릅니다.
| 도형 | 특징 |
|---|---|
RectangleShape2D |
사각형. 가장 흔하고 가장 빠름 |
CircleShape2D |
원. 모서리에 안 걸려서 이동이 부드러움 |
CapsuleShape2D |
알약. 캐릭터에 가장 많이 씀 |
ConvexPolygonShape2D |
볼록 다각형 |
ConcavePolygonShape2D |
오목 가능. 정적 지형에만 |
사각형 콜라이더는 계단이나 바닥 이음새의 미세한 턱에 모서리가 걸립니다. 캡슐은 아래가 둥글어서 자연스럽게 넘어갑니다. 탑다운이면 원, 횡스크롤이면 캡슐이 무난한 기본값입니다.
move_and_slide() — 딱 한 줄이 해주는 일#
extends CharacterBody2D
@export var speed := 300.0
func _physics_process(_delta: float) -> void:
var dir := Input.get_vector(
"move_left", "move_right", "move_up", "move_down")
velocity = dir * speed
move_and_slide()velocity를 보고 이번 물리 스텝에 갈 거리를 계산한다 (delta를 내부에서 곱해준다)- 그 방향으로 움직여 본다
- 뭔가에 부딪히면 거기서 멈추지 않고, 벽을 따라 미끄러진다
- 실제로 이동한 결과를
velocity에 다시 반영한다 - 부딪힌 목록을 기록한다 (
get_slide_collision()으로 조회)
velocity = dir * speed # ○ 초당 속도를 그대로
move_and_slide()
velocity = dir * speed * delta # ✕ 두 번 곱해져 엄청 느려진다
move_and_slide()velocity 는 초당 픽셀 단위입니다. delta는 함수가 알아서 곱합니다.
Godot 3에서 4로 넘어오며 바뀐 부분이라 옛날 예제를 보면 헷갈립니다.
"미끄러진다"는 게 무슨 뜻인가#
벽에 45도로 부딪혔다고 해봅시다.
- 멈추기(
move_and_collide) — 벽에 닿는 순간 완전히 정지. 벽을 문지르며 걸을 수 없음 - 미끄러지기(
move_and_slide) — 벽에 수직인 성분만 제거하고, 벽을 따라가는 성분은 유지
벽
│
→→→→→│ 멈추기: 여기서 끝
↘ │
↓│ 미끄러지기: 벽을 따라 아래로 계속
↓│이 덕분에 벽에 비스듬히 붙어서도 부드럽게 이동합니다.
대부분의 게임은 이 동작을 원합니다. 그래서 이름이 slide입니다.
move_and_collide 를 쓰는 경우
"미끄러지면 안 되는" 것들에 씁니다.
- 벽에 닿으면 터지는 총알
- 벽에 닿으면 튕겨 나오는 공 (반사각을 직접 계산)
var col := move_and_collide(velocity * delta)
if col:
# 벽 법선을 기준으로 반사
velocity = velocity.bounce(col.get_normal())move_and_collide 는 delta를 직접 곱해야 합니다. (move_and_slide 와 반대)
중력과 바닥 판정 (횡스크롤)#
탑다운이면 여기까지로 충분합니다. 횡스크롤이면 중력이 필요합니다.
extends CharacterBody2D
@export var speed := 300.0
@export var jump_force := 420.0
@export var gravity := 1200.0
func _physics_process(delta: float) -> void:
# 1) 중력 — 공중에 있을 때만 누적
if not is_on_floor():
velocity.y += gravity * delta
# 2) 점프 — 바닥에 있을 때만
if Input.is_action_just_pressed("jump") and is_on_floor():
velocity.y = -jump_force # 위는 음수(5강)
# 3) 좌우 이동
var h := Input.get_axis("move_left", "move_right")
velocity.x = h * speed
move_and_slide()move_and_slide() 가 부딪힌 면들의 법선(수직 방향) 을 보고,
그 각도가 Floor Max Angle(기본 45도) 안쪽이면 "바닥"으로 칩니다.
CharacterBody2D 인스펙터의 Motion Mode 도 여기 관여합니다.
- Grounded — 바닥/벽/천장을 구분한다. 횡스크롤·플랫포머용 (기본값)
- Floating — 위아래 개념이 없다. 탑다운은 이걸로 바꾸세요
관련 함수들:
is_on_floor() # 바닥에 서 있나
is_on_wall() # 벽에 붙어 있나
is_on_ceiling() # 천장에 머리를 박았나
get_floor_normal() # 바닥의 기울기Motion Mode가 Grounded로 되어 있으면 Godot이 위쪽을 "하늘"로 간주해
이상한 판정을 합니다. 탑다운은 반드시 Floating 으로.
실습 — 벽이 있는 방 만들기#
1) 벽 만들기#
main.tscn에StaticBody2D추가, 이름Wall- 자식으로
CollisionShape2D추가 →Shape을RectangleShape2D로 - 뷰포트에서 주황색 핸들을 끌어 크기 조절
- 눈에 보이게 하려면
ColorRect나Sprite2D를 자식으로 하나 더 붙입니다
같은 방식으로 벽을 3~4개 만들어 방을 만듭니다.
(Wall을 선택하고 Ctrl+D로 복제하면 빠릅니다)
2) 플레이어를 CharacterBody2D로 바꾸기#
기존 player.tscn 루트가 Node2D라면 우클릭 → 타입 변경(Change Type) →
CharacterBody2D 를 고릅니다. 그리고 CollisionShape2D를 자식으로 추가합니다.
3) 스크립트#
extends CharacterBody2D
@export var speed := 300.0
func _physics_process(_delta: float) -> void:
var dir := Input.get_vector(
"move_left", "move_right", "move_up", "move_down")
velocity = dir * speed
move_and_slide()
# 부딪힌 것들을 확인해 본다.
for i in get_slide_collision_count():
var c := get_slide_collision(i)
print("부딪힘: ", c.get_collider().name,
" 법선 ", c.get_normal())Motion Mode를 Floating으로 바꾸는 것을 잊지 마세요.
캐릭터가 벽을 통과하지 않고, 벽에 비스듬히 밀면 벽을 따라 미끄러지면 성공입니다. 출력 패널에 부딪힌 벽 이름이 찍힙니다.
디버그 → 충돌 도형 표시(Visible Collision Shapes)를 켜고 실행하세요. 파란 윤곽선이 실제 판정 영역입니다. 그림과 다른 게 보이나요?- 콜라이더를 사각형 → 캡슐 → 원으로 바꿔가며 벽 모서리에 비볐을 때 느낌을 비교하세요.
move_and_slide()를move_and_collide(velocity * delta)로 바꿔보세요. 벽에 닿으면 어떻게 됩니까?- 벽을
StaticBody2D대신RigidBody2D로 만들고 밀어보세요.
- 개발자 —
CharacterBody2D는 물리 엔진이 밀어주지 않습니다. 넉백·바람·컨베이어 같은 외력은 전부 직접velocity에 더해야 합니다. - 기획자 — "적이 플레이어를 밀어냈으면 좋겠어요"는 자동으로 되는 게 아니라 밀어내는 코드를 새로 만드는 일입니다. 밀림 세기·지속시간을 함께 정해주세요.
- 디자이너 — 콜라이더는 그림보다 약간 작게 잡는 게 대부분 기분이 좋습니다. 특히 플레이어의 피격 판정은 보이는 것보다 작아야 "억울하게 맞았다"가 줄어듭니다. 반대로 플레이어의 공격 판정은 보이는 것보다 커야 시원합니다. (11강)
"캐릭터가 벽을 그냥 통과한다"
→ ① CollisionShape2D에 Shape이 비어 있음, ② 레이어/마스크가 안 맞음(11강),
③ position을 직접 대입하고 있음.
"캐릭터가 엄청 느리다"
→ velocity에 delta를 곱했습니다. 빼세요.
"바닥에 서 있는데 is_on_floor()가 false다"
→ Motion Mode가 Floating이면 바닥 개념이 없습니다. Grounded로 바꾸세요.
또는 중력을 안 더해서 바닥을 누르고 있지 않은 상태일 수 있습니다.
move_and_slide() 다음에 호출해야 값이 갱신됩니다.
"공중에서 무한 점프가 된다"
→ is_on_floor() 검사를 빠뜨렸습니다.
- 물리 바디 4형제를 구분한다
-
move_and_slide()에 delta를 곱하지 않는다 - 미끄러짐이 무엇인지 안다
- 탑다운은 Motion Mode를 Floating으로 둔다
- 충돌 도형 표시를 켜서 실제 판정을 봤다
다음 강의 — 이제 움직이긴 합니다. 그런데 기분이 좋지는 않습니다. 가속·감속·코요테 타임으로 조작감을 만듭니다.