9강CharacterBody2D로 캐릭터 움직이기
물리 바디 4형제와 move_and_slide.
압축을 풀고 Godot 4.3 이상에서 가져오기(Import) →
project.godot 선택 → F5.
씬 1개 · 스크립트 2개 · 안내는 폴더 안 README.md 에 있습니다.
이 강의 목차 (7)
- 물리 바디 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 = 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 으로.
motion_mode — 옵션이 아니라 알고리즘의 갈림길#
Motion Mode는 체크박스 하나처럼 생겼지만, 실제로는 move_and_slide() 안에서
완전히 다른 두 갈래의 코드로 갈립니다. 값 하나가 바뀌는 게 아니라 알고리즘이 바뀝니다.
핵심 차이 — up_direction을 보느냐#
Grounded 는 up_direction(기본 (0, -1))을 기준선으로 삼습니다.
충돌한 면의 법선이 이 기준선과 이루는 각도를 재서, 그 면이 바닥인지 벽인지 천장인지 분류합니다.
| 조건 | 분류 |
|---|---|
법선과 up_direction의 각도 ≤ floor_max_angle(기본 45°) |
바닥 |
법선과 -up_direction의 각도 ≤ floor_max_angle |
천장 |
| 그 외 | 벽 |
Floating 은 이 분류를 아예 하지 않습니다. up_direction을 읽지도 않습니다.
부딪힌 모든 면을 그냥 "벽" 하나로 취급합니다.
충돌한 면에 수직으로 바깥을 향하는 방향입니다. 길이는 1입니다.
- 평평한 바닥에 서면 법선은
(0, -1)— 위쪽 - 왼쪽 벽에 부딪히면 법선은
(1, 0)— 오른쪽 - 30° 경사면이면 법선도 30° 기울어져 있습니다
즉 법선은 "그 면이 어느 쪽을 보고 있는가" 입니다. Grounded 는 이 방향과 위쪽을 비교해 바닥 여부를 판단합니다.
up_direction과 법선이 이루는 각도로 바닥·벽·천장이 갈린다. 45°(floor_max_angle) 안쪽이면 바닥이다.그래서 이런 결과가 나옵니다.
| Grounded | Floating | |
|---|---|---|
is_on_floor() |
각도로 판정 | 항상 false |
is_on_ceiling() |
각도로 판정 | 항상 false |
is_on_wall() |
각도로 판정 | 부딪히면 true |
get_floor_normal() |
바닥 법선 | (0, 0) |
is_on_floor()가 상수 false 이기 때문입니다.
if Input.is_action_just_pressed("jump") \
and is_on_floor():
velocity.y = -jump_forceFloating 에서는 이 조건문이 참이 될 방법이 없습니다. 버그가 아니라 그 모드에는 바닥이라는 개념 자체가 없는 것입니다.
move_and_slide() 호출이 모드에 따라 완전히 다른 경로를 탄다.Grounded 가 추가로 하는 일들#
분류 말고도 "바닥이 있는 세계"를 가정한 보정이 여럿 붙습니다. 이것들이 전부 Floating 에서는 동작하지 않습니다.
| 기능 | 하는 일 |
|---|---|
floor_snap_length |
직전 프레임에 바닥이었고 위로 가는 중이 아니면, 이동 후 아래로 짧게 캐스트해 바닥에 붙인다. 기본 1px |
floor_stop_on_slope |
경사면에서 입력 없이 서 있을 때 중력만으로 미끄러져 내려가는 것을 막는다 |
floor_constant_speed |
오르막에서 속도가 느려지지 않게 보정한다 |
floor_block_on_wall |
너무 가파른 면을 바닥처럼 타고 올라가는 것을 막는다 |
slide_on_ceiling |
천장에 머리를 박으면 위 방향 속도를 제거한다 |
| 미끄러짐 분해 | 이동을 up_direction 성분과 수직 성분으로 나눠 "바닥면을 따라" 가게 한다 |
| 움직이는 발판 | 딛고 있는 바디의 속도를 물려받는다 (get_platform_velocity()) |
floor_snap_length가 0 이거나 너무 짧은 경우입니다.
내려가는 경사에서 매 프레임 살짝 떠올랐다가 중력으로 떨어지기를 반복합니다.
그동안 is_on_floor()가 깜빡이므로 점프도 씹힙니다.
계단이나 급한 내리막이 있으면 floor_snap_length를 8~16 정도로 올려보세요.
Floating 의 미끄러짐은 단순한 반사#
Floating 쪽 코드는 대략 이런 모양입니다.
var motion := velocity * delta
for i in max_slides:
var col := move_and_collide(motion)
if col == null:
break
# 법선 방향 성분만 제거하고 나머지는 유지
motion = col.get_remainder() \
.slide(col.get_normal())
velocity = velocity.slide(col.get_normal())방향에 대한 편애가 없습니다. 위에서 부딪히든 아래에서 부딪히든 같은 규칙으로 미끄러집니다.
탑다운에서 원하는 게 정확히 이것입니다.
화면 위쪽 벽이 "천장"으로 특별 취급되면 곤란하니까요.
그 벽에 부딪혔을 때 slide_on_ceiling 이 발동해 위 방향 속도를 지워버리면,
플레이어는 "위로 가려는데 자꾸 멈춘다"고 느끼게 됩니다.
v.slide(n) # n 방향 성분을 v 에서 제거수식으로는 v - n * v.dot(n) 입니다.
dot(내적)이 "v가 n 방향으로 얼마나 가고 있는지"를 재고, 그만큼을 빼는 것입니다.
bounce(n) 은 빼는 대신 반대로 뒤집습니다 — 공이 튕기는 동작입니다.
max_slides 는 왜 필요한가
한 프레임에 미끄러짐을 몇 번까지 반복할지 정하는 값입니다(기본 4).
좁은 V자 홈에 끼면 왼쪽 벽에 미끄러지고 → 오른쪽 벽에 부딪히고 → 다시 왼쪽… 이 반복이 끝나지 않을 수 있습니다. 상한이 없으면 한 프레임이 영원히 안 끝납니다.
값을 올리면 복잡한 지형에서 더 매끄럽게 빠져나오지만 계산이 늘어납니다. 대부분 기본값으로 충분합니다.
- 개발자 —
motion_mode는 처음에 정하고 바꾸지 마세요. 중간에 바꾸면 점프·경사·발판 관련 코드가 전부 재작성 대상이 됩니다. - 기획자 — "탑다운인데 벽을 타고 올라가요" 같은 증상은 대개 모드가 잘못된 것입니다. 게임의 시점(탑다운/횡스크롤)은 기술 스펙에 직결되는 결정이니 초기에 확정해 주세요.
- 디자이너 — 경사면 각도는
floor_max_angle(기본 45°)에 걸립니다. 45°보다 가파른 언덕을 그리면 캐릭터가 그 위를 걸을 수 없습니다. 레벨에 경사가 있다면 이 값을 개발자와 먼저 맞추세요.
실습 — 벽이 있는 방 만들기#
1) 벽 만들기#
전부 CC0 다. 에셋 모음 전체 보기 · 한꺼번에 받기 (ZIP)
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으로 둔다
- 충돌 도형 표시를 켜서 실제 판정을 봤다
다음 강의 — 이제 움직이긴 합니다. 그런데 기분이 좋지는 않습니다. 가속·감속·코요테 타임으로 조작감을 만듭니다.