2D Godot 2D 게임 기초
P2 움직임과 물리

9강CharacterBody2D로 캐릭터 움직이기

물리 바디 4형제와 move_and_slide.

이 강의의 Godot 예제 프로젝트
9강 예제 내려받기 godot-2d-lecture-09-character-body.zip · 5.1 KB

압축을 풀고 Godot 4.3 이상에서 가져오기(Import) → project.godot 선택 → F5. 씬 1개 · 스크립트 2개 · 안내는 폴더 안 README.md 에 있습니다.

이 강의 목차 (7)
  1. 물리 바디 4형제
  2. CharacterBody2D의 구조
  3. move_and_slide() — 딱 한 줄이 해주는 일
  4. "미끄러진다"는 게 무슨 뜻인가
  5. 중력과 바닥 판정 (횡스크롤)
  6. motion_mode — 옵션이 아니라 알고리즘의 갈림길
  7. 실습 — 벽이 있는 방 만들기
이번 강의의 목표
  • 물리 바디 4형제의 역할을 구분한다.
  • CharacterBody2D + move_and_slide() 로 벽에 막히는 캐릭터를 만든다.
  • 벽에 부딪혔을 때 미끄러지는 이유를 안다.

물리 바디 4형제#

Godot 2D의 물리 노드는 넷입니다. 성격이 완전히 다릅니다.

노드 움직이나 밀리나 누가 조종하나 대표 용도
StaticBody2D ✕ ✕ 아무도 벽, 바닥, 지형
RigidBody2D ○ ○ 물리 엔진 상자, 공, 파편
CharacterBody2D ○ ✕ 내 코드 플레이어, 적
Area2D ○ — 내 코드 감지 영역, 함정, 아이템
핵심 구분 — 누가 위치를 정하는가
  • RigidBody2D — "던져 놓으면 알아서 굴러간다." 중력·마찰·반발을 물리 엔진이 계산합니다. 내가 position을 직접 대입하면 싸웁니다.
  • CharacterBody2D — "내가 매 프레임 어디로 갈지 정한다." 물리 엔진은 "거기 가려는데 벽이 있다" 만 알려줍니다. 중력도 내가 직접 더합니다.

플레이어는 왜 RigidBody가 아닐까요? 폭탄에 맞으면 날아가지만, 걸을 때는 물리 법칙을 무시하고 즉시 멈춰야 하기 때문입니다. "현실적인 물리"와 "기분 좋은 조작감"은 대개 반대입니다.

그럼 RigidBody2D는 언제 쓰나
  • 굴러다니는 통, 무너지는 상자 더미, 흩어지는 파편
  • 물리 퍼즐(앵그리버드 같은)
  • 밀 수 있는 짐

플레이어와 상호작용시킬 때는 CharacterBody2D 쪽에서 힘을 줍니다.

gdscript
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의 구조#

text
Player                (CharacterBody2D)
├─ Sprite             (Sprite2D)
└─ CollisionShape2D   ← 없으면 아무 일도 안 일어난다
콜라이더가 없으면 물리 바디가 아니다

CharacterBody2D를 만들어 놓고 CollisionShape2D를 안 붙이면 아무것도 안 부딪힙니다. 에디터에 노란 경고 삼각형이 뜹니다.

그리고 그림과 콜라이더는 아무 관계가 없습니다. 스프라이트를 2배로 키워도 콜라이더는 그대로입니다. 11강에서 자세히 다룹니다.

CollisionShape2D를 선택하고 인스펙터의 Shape 에서 도형을 고릅니다.

도형 특징
RectangleShape2D 사각형. 가장 흔하고 가장 빠름
CircleShape2D 원. 모서리에 안 걸려서 이동이 부드러움
CapsuleShape2D 알약. 캐릭터에 가장 많이 씀
ConvexPolygonShape2D 볼록 다각형
ConcavePolygonShape2D 오목 가능. 정적 지형에만
캐릭터에는 캡슐이 좋다

사각형 콜라이더는 계단이나 바닥 이음새의 미세한 턱에 모서리가 걸립니다. 캡슐은 아래가 둥글어서 자연스럽게 넘어갑니다. 탑다운이면 원, 횡스크롤이면 캡슐이 무난한 기본값입니다.

move_and_slide() — 딱 한 줄이 해주는 일#

gdscript
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()
move_and_slide()가 하는 일
  1. velocity 를 보고 이번 물리 스텝에 갈 거리를 계산한다 (delta를 내부에서 곱해준다)
  2. 그 방향으로 움직여 본다
  3. 뭔가에 부딪히면 거기서 멈추지 않고, 벽을 따라 미끄러진다
  4. 실제로 이동한 결과를 velocity 에 다시 반영한다
  5. 부딪힌 목록을 기록한다 (get_slide_collision() 으로 조회)
move_and_slide()에는 delta를 곱하지 않는다
gdscript
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) — 벽에 수직인 성분만 제거하고, 벽을 따라가는 성분은 유지
text
      벽
      │
 →→→→→│      멈추기: 여기서 끝
   ↘  │
     ↓│      미끄러지기: 벽을 따라 아래로 계속
     ↓│

이 덕분에 벽에 비스듬히 붙어서도 부드럽게 이동합니다. 대부분의 게임은 이 동작을 원합니다. 그래서 이름이 slide입니다.

move_and_collide 를 쓰는 경우

"미끄러지면 안 되는" 것들에 씁니다.

  • 벽에 닿으면 터지는 총알
  • 벽에 닿으면 튕겨 나오는 공 (반사각을 직접 계산)
gdscript
var col := move_and_collide(velocity * delta)
if col:
	# 벽 법선을 기준으로 반사
	velocity = velocity.bounce(col.get_normal())

move_and_collide 는 delta를 직접 곱해야 합니다. (move_and_slide 와 반대)

중력과 바닥 판정 (횡스크롤)#

탑다운이면 여기까지로 충분합니다. 횡스크롤이면 중력이 필요합니다.

gdscript
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()
is_on_floor()는 어떻게 판단하나

move_and_slide() 가 부딪힌 면들의 법선(수직 방향) 을 보고, 그 각도가 Floor Max Angle(기본 45도) 안쪽이면 "바닥"으로 칩니다.

CharacterBody2D 인스펙터의 Motion Mode 도 여기 관여합니다.

  • Grounded — 바닥/벽/천장을 구분한다. 횡스크롤·플랫포머용 (기본값)
  • Floating — 위아래 개념이 없다. 탑다운은 이걸로 바꾸세요

관련 함수들:

gdscript
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을 읽지도 않습니다. 부딪힌 모든 면을 그냥 "벽" 하나로 취급합니다.

법선(normal)이란

충돌한 면에 수직으로 바깥을 향하는 방향입니다. 길이는 1입니다.

  • 평평한 바닥에 서면 법선은 (0, -1) — 위쪽
  • 왼쪽 벽에 부딪히면 법선은 (1, 0) — 오른쪽
  • 30° 경사면이면 법선도 30° 기울어져 있습니다

즉 법선은 "그 면이 어느 쪽을 보고 있는가" 입니다. Grounded 는 이 방향과 위쪽을 비교해 바닥 여부를 판단합니다.

up_direction (0,-1)45°바닥천장벽벽법선이 어느 구역을 향하느냐로 갈린다완만한 경사up 과의 각도 15°→ 바닥가파른 면up 과의 각도 70°→ 벽머리 위 면up 과의 각도 170°→ 천장굵은 화살표 = 법선 (그 면이 보는 방향)
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 이기 때문입니다.

gdscript
if Input.is_action_just_pressed("jump") \
        and is_on_floor():
    velocity.y = -jump_force

Floating 에서는 이 조건문이 참이 될 방법이 없습니다. 버그가 아니라 그 모드에는 바닥이라는 개념 자체가 없는 것입니다.

move_and_slide()Groundedup_direction 을 본다법선 각도로 분류바닥벽천장바닥 스냅 · 경사 정지오르막 속도 보정천장 속도 제거움직이는 발판 상속Floatingup_direction 을 읽지 않는다전부 "벽" 하나로 취급법선 성분만 제거하는단순한 미끄러짐방향에 대한 편애 없음is_on_floor()각도로 판정is_on_floor()항상 false — 점프 불가
같은 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())
스냅 없음틈바닥 스냅snap바닥에서 떨어져 통통 튄다이동 후 아래로 캐스트해 붙인다
바닥 스냅이 없으면 내리막에서 캐릭터가 붕 뜬다. 이동한 뒤 아래로 짧게 캐스트해 다시 붙인다.
"내리막에서 캐릭터가 통통 튀어요"

floor_snap_length가 0 이거나 너무 짧은 경우입니다. 내려가는 경사에서 매 프레임 살짝 떠올랐다가 중력으로 떨어지기를 반복합니다. 그동안 is_on_floor()가 깜빡이므로 점프도 씹힙니다.

계단이나 급한 내리막이 있으면 floor_snap_length를 8~16 정도로 올려보세요.

Floating 의 미끄러짐은 단순한 반사#

Floating 쪽 코드는 대략 이런 모양입니다.

gdscript
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 이 발동해 위 방향 속도를 지워버리면, 플레이어는 "위로 가려는데 자꾸 멈춘다"고 느끼게 됩니다.

벽velocitynormal(막지 않았다면 여기까지 갔을 자리)버린다벽 안으로 들어가려는 성분남긴다= 벽을 따라 미끄러진다v.slide(n) = v − n × v.dot(n)dot 이 "n 방향으로 얼마나 가는지"를 재고, 그만큼을 뺀다
미끄러짐 = 이동 벡터에서 법선 방향 성분만 빼는 것. 남은 성분이 벽을 따라가는 방향이다.
Vector2.slide() 가 하는 일
gdscript
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) 벽 만들기#

  1. main.tscn에 StaticBody2D 추가, 이름 Wall
  2. 자식으로 CollisionShape2D 추가 → Shape을 RectangleShape2D로
  3. 뷰포트에서 주황색 핸들을 끌어 크기 조절
  4. 눈에 보이게 하려면 ColorRect나 Sprite2D를 자식으로 하나 더 붙입니다

같은 방식으로 벽을 3~4개 만들어 방을 만듭니다. (Wall을 선택하고 Ctrl+D로 복제하면 빠릅니다)

2) 플레이어를 CharacterBody2D로 바꾸기#

기존 player.tscn 루트가 Node2D라면 우클릭 → 타입 변경(Change Type) → CharacterBody2D 를 고릅니다. 그리고 CollisionShape2D를 자식으로 추가합니다.

3) 스크립트#

gdscript
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으로 바꾸는 것을 잊지 마세요.

여기까지 확인

캐릭터가 벽을 통과하지 않고, 벽에 비스듬히 밀면 벽을 따라 미끄러지면 성공입니다. 출력 패널에 부딪힌 벽 이름이 찍힙니다.

직접 바꿔보기
  1. 디버그 → 충돌 도형 표시(Visible Collision Shapes) 를 켜고 실행하세요. 파란 윤곽선이 실제 판정 영역입니다. 그림과 다른 게 보이나요?
  2. 콜라이더를 사각형 → 캡슐 → 원으로 바꿔가며 벽 모서리에 비볐을 때 느낌을 비교하세요.
  3. move_and_slide() 를 move_and_collide(velocity * delta) 로 바꿔보세요. 벽에 닿으면 어떻게 됩니까?
  4. 벽을 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으로 둔다
  • 충돌 도형 표시를 켜서 실제 판정을 봤다

다음 강의 — 이제 움직이긴 합니다. 그런데 기분이 좋지는 않습니다. 가속·감속·코요테 타임으로 조작감을 만듭니다.