Godot 4 이중 점프와 벽 타기: 노드 아키텍처로 안정적으로 구현하는 방법
Godot Engine 4의 CharacterBody2D와 노드 기반 구조로 이중 점프, 벽 미끄러짐, 벽 점프를 구현합니다. 충돌 순서, 벽 법선, 점프 횟수, 코요테 타임과 입력 버퍼까지 실전 GDScript 코드로 정리합니다.

핵심 요약
Godot Engine 4의 이중 점프와 벽 타기는 CharacterBody2D를 물리 본체로 두고 이동·점프·벽 판정을 책임별 노드로 나누면 안정적으로 구현할 수 있다. 공중 점프 횟수, 벽 충돌 법선, 낙하 속도 상한을 분리해 관리하고 move_and_slide() 이후의 충돌 정보를 다음 물리 프레임에 사용한다.
이 글에서는 Player 아래에 PlayerMotor, JumpController, WallController를 배치하는 구조를 기준으로 설명한다. 실제 velocity 변경과 move_and_slide() 호출은 루트 CharacterBody2D 한 곳에서만 담당한다.
Godot 4 플레이어 노드 아키텍처를 어떻게 나눌까?
플랫폼 액션의 기능을 Player.gd 하나에 계속 추가하면 점프 조건과 벽 상태가 서로를 덮어쓰기 쉽다. 특히 is_on_floor()와 is_on_wall()은 이동 및 충돌 처리 뒤의 결과이므로 호출 순서가 섞이면 한 프레임 전 상태를 잘못 사용할 수 있다.
| 노드 | 스크립트 | 책임 | 관리하는 값 |
|---|---|---|---|
Player (CharacterBody2D) | player.gd | 물리 루프, 속도 합성, move_and_slide() | velocity |
PlayerMotor | player_motor.gd | 수평 가속·감속, 중력 적용 | 수평·수직 목표 속도 |
JumpController | jump_controller.gd | 지상 점프, 이중 점프, 점프 컷 | 점프 가능 횟수 |
WallController | wall_controller.gd | 벽 판정, 미끄러짐, 벽 점프 | 벽 방향, 낙하 속도 상한 |
CollisionShape2D | 없음 | 플레이어 물리 형상 | 없음 |

Player 씬은 다음처럼 구성한다.
flowchart TD
Player["Player (CharacterBody2D)"]
Player --> CollisionShape2D["CollisionShape2D"]
Player --> AnimatedSprite2D["AnimatedSprite2D"]
Player --> PlayerMotor["PlayerMotor"]
Player --> JumpController["JumpController"]
Player --> WallController["WallController"]
초기 프로토타입은 하나의 스크립트로 시작해도 된다. 다만 대시, 사다리, 벽 달리기처럼 이동 기능이 늘어난다면 이 구조가 조건문 증가와 기능 간 충돌을 줄인다.
이중 점프와 벽 타기에서 충돌 판정이 어긋나는 이유는 무엇인가?
CharacterBody2D의 is_on_floor()와 is_on_wall()은 move_and_slide()가 수행한 충돌 결과를 기준으로 한다. 따라서 현재 프레임의 입력으로 속도를 계산한 뒤 move_and_slide()를 호출하고 그 결과를 다음 프레임의 상태 갱신에 사용한다는 순서를 명확히 정해야 한다.
벽에 붙었다고 즉시 벽 타기를 시작하면 바닥과 벽이 만나는 모서리에서 낙하 속도가 부자연스럽게 제한된다. 아래 세 조건을 모두 만족할 때만 벽 미끄러짐을 허용하는 편이 좋다.
- 플레이어가 바닥 위가 아니다.
- 플레이어가 벽에 충돌했다.
- 수직 속도가 아래 방향이다.
Godot 2D 좌표계에서 아래 방향은 양수다. 중력 가속도를 gravity라고 할 때 낙하 속도는 다음처럼 누적하며 벽 미끄러짐에서는 최대 속도만 wall_slide_speed로 낮춘다.
Step 1. Input Map과 Player 씬을 준비한다
Project > Project Settings > Input Map에서 아래 액션을 만든다.
| 액션 | 권장 키 |
|---|---|
move_left | A 또는 왼쪽 화살표 |
move_right | D 또는 오른쪽 화살표 |
jump | Space |
Player 루트는 CharacterBody2D로 만들고 자식으로 CollisionShape2D를 추가한다. 바닥과 벽 타일의 충돌 레이어·마스크가 플레이어 마스크와 맞는지도 확인한다. 설정이 맞지 않으면 is_on_floor()와 is_on_wall()은 항상 false가 된다.
Step 2. CharacterBody2D에서 이중 점프를 어떻게 구현할까?
아래 스크립트는 단독으로 실행 가능한 기준 구현이다. 검증이 끝난 뒤 _apply_horizontal_movement(), _handle_jump(), _apply_wall_slide()를 각각의 자식 노드 스크립트로 옮길 수 있다.
extends CharacterBody2D
@export_category("Movement")
@export var move_speed := 280.0
@export var ground_acceleration := 2200.0
@export var air_acceleration := 1400.0
@export var ground_deceleration := 2600.0
@export var gravity := 1800.0
@export var max_fall_speed := 900.0
@export_category("Jump")
@export var jump_speed := 620.0
@export var max_jump_count := 2
@export var jump_cut_multiplier := 0.5
@export_category("Wall")
@export var wall_slide_speed := 140.0
@export var wall_jump_velocity := Vector2(420.0, -580.0)
@export var wall_jump_input_lock_time := 0.12
var jumps_used := 0
var wall_normal := Vector2.ZERO
var wall_jump_lock_remaining := 0.0
func _physics_process(delta: float) -> void:
_update_wall_contact()
_apply_gravity(delta)
_apply_horizontal_movement(delta)
_handle_jump()
_apply_wall_slide()
move_and_slide()
if is_on_floor():
jumps_used = 0
func _apply_gravity(delta: float) -> void:
if not is_on_floor():
velocity.y = min(velocity.y + gravity * delta, max_fall_speed)
func _apply_horizontal_movement(delta: float) -> void:
wall_jump_lock_remaining = maxf(wall_jump_lock_remaining - delta, 0.0)
if wall_jump_lock_remaining > 0.0:
return
var axis := Input.get_axis("move_left", "move_right")
var target_speed := axis * move_speed
var acceleration := ground_acceleration if is_on_floor() else air_acceleration
if not is_zero_approx(axis):
velocity.x = move_toward(velocity.x, target_speed, acceleration * delta)
else:
velocity.x = move_toward(velocity.x, 0.0, ground_deceleration * delta)
func _handle_jump() -> void:
if Input.is_action_just_pressed("jump"):
if _can_wall_jump():
velocity = Vector2(wall_normal.x * wall_jump_velocity.x, wall_jump_velocity.y)
wall_jump_lock_remaining = wall_jump_input_lock_time
jumps_used = 1
elif is_on_floor() or jumps_used < max_jump_count:
velocity.y = -jump_speed
jumps_used += 1
if Input.is_action_just_released("jump") and velocity.y < 0.0:
velocity.y *= jump_cut_multiplier
func _apply_wall_slide() -> void:
if _is_wall_sliding():
velocity.y = minf(velocity.y, wall_slide_speed)
func _is_wall_sliding() -> bool:
return not is_on_floor() and wall_normal != Vector2.ZERO and velocity.y > 0.0
func _can_wall_jump() -> bool:
return not is_on_floor() and wall_normal != Vector2.ZERO
func _update_wall_contact() -> void:
wall_normal = Vector2.ZERO
for index in get_slide_collision_count():
var collision := get_slide_collision(index)
var normal := collision.get_normal()
if absf(normal.x) > 0.9:
wall_normal = normal
return
get_slide_collision_count()와 get_slide_collision()은 직전 move_and_slide()에서 발생한 충돌을 읽는다. 따라서 _update_wall_contact()는 현재 프레임의 이동 전에 지난 프레임의 벽 접촉 정보를 가져온다.
지상 점프에서는 jumps_used가 1이 되고 공중에서 한 번 더 점프하면 2가 된다. max_jump_count가 2일 때 지상 점프와 공중 점프를 합쳐 정확히 두 번만 허용하는 방식이다.
Step 3. 벽 미끄러짐과 벽 점프를 안정화한다
벽 미끄러짐 중에도 중력은 계속 적용해야 한다. 중력을 끄고 속도를 고정하면 벽에서 떨어지거나 재접촉할 때 속도가 튀기 쉽다. 대신 _apply_wall_slide()에서 아래 방향 속도의 상한만 제한한다.
func _apply_wall_slide() -> void:
if _is_wall_sliding():
velocity.y = minf(velocity.y, wall_slide_speed)
벽 점프의 수평 방향은 충돌 법선 wall_normal.x를 사용한다. 왼쪽 벽의 법선은 오른쪽 방향이고 오른쪽 벽의 법선은 왼쪽 방향이므로 별도 좌우 분기 없이 벽 반대편으로 튕겨 나간다.
wall_jump_input_lock_time도 중요하다. 벽 점프 직후 플레이어가 벽 쪽 입력을 계속 누르면 수평 이동 로직이 반동 속도를 즉시 상쇄할 수 있다. 0.08~0.15초 정도 입력을 잠그면 점프 방향이 안정된다.
flowchart TD
A[물리 프레임 시작] --> B[직전 충돌에서 벽 법선 갱신]
B --> C[중력과 수평 이동 적용]
C --> D{점프 입력}
D -- 벽 접촉 --> E[벽 법선 방향으로 벽 점프]
D -- 지상 또는 잔여 점프 --> F[일반 또는 이중 점프]
D -- 없음 --> G[벽 미끄러짐 속도 상한 적용]
E --> H[move_and_slide]
F --> H
G --> H
H --> I{바닥 접촉}
I -- 예 --> J[점프 횟수 초기화]
I -- 아니오 --> A
노드 아키텍처로 분리할 때 어떤 인터페이스가 좋은가?
자식 노드가 Player.velocity를 서로 직접 수정하면 우선순위가 불분명해진다. 각 노드가 의도 또는 제한값을 반환하고 Player가 마지막에 속도를 합성하는 방식이 안전하다.
| 컴포넌트 | 입력 | 출력 예시 |
|---|---|---|
PlayerMotor | 이동 축, 현재 속도, delta | 목표 수평 속도 |
JumpController | 점프 입력, 바닥 여부 | 점프 수직 속도 요청 |
WallController | 벽 법선, 낙하 속도 | 슬라이드 여부, 벽 점프 벡터 |
Player | 모든 요청 | 최종 velocity, move_and_slide() |
예를 들어 WallController는 벽 상태만 노출할 수 있다.
class_name WallController
extends Node
@export var slide_speed := 140.0
var normal := Vector2.ZERO
func update_from_body(body: CharacterBody2D) -> void:
normal = Vector2.ZERO
for index in body.get_slide_collision_count():
var hit := body.get_slide_collision(index)
if absf(hit.get_normal().x) > 0.9:
normal = hit.get_normal()
return
func is_sliding(body: CharacterBody2D) -> bool:
return not body.is_on_floor() and normal != Vector2.ZERO and body.velocity.y > 0.0
이 경계가 있으면 벽 달리기나 벽 매달리기를 추가할 때 Player의 충돌 처리 루프를 건드리지 않고 WallController의 판정만 확장할 수 있다.
점프 감각을 개선하려면 무엇을 추가해야 할까?
기본 이중 점프가 동작한 뒤에는 조작 관용성을 추가하는 편이 좋다. 가장 효과적인 두 요소는 코요테 타임과 입력 버퍼다.
- 코요테 타임: 발판 끝에서 떨어진 직후에도 짧은 시간 동안 지상 점프를 허용한다.
- 입력 버퍼: 착지 직전에 누른 점프 입력을 기억했다가 착지 순간 점프로 소비한다.
각 기능은 타이머 하나와 조건 하나로 추가할 수 있으며 물리 프레임에서 시간이 감소해야 한다. 두 값은 0.08~0.12초에서 시작해 플레이 테스트로 조절한다.
@export var coyote_time := 0.1
@export var jump_buffer_time := 0.1
var coyote_remaining := 0.0
var jump_buffer_remaining := 0.0
func _update_jump_timers(delta: float) -> void:
coyote_remaining = coyote_time if is_on_floor() else maxf(coyote_remaining - delta, 0.0)
jump_buffer_remaining = maxf(jump_buffer_remaining - delta, 0.0)
func _buffer_jump_input() -> void:
if Input.is_action_just_pressed("jump"):
jump_buffer_remaining = jump_buffer_time
func _consume_ground_jump_if_possible() -> void:
if jump_buffer_remaining > 0.0 and coyote_remaining > 0.0:
velocity.y = -jump_speed
jumps_used = 1
jump_buffer_remaining = 0.0
coyote_remaining = 0.0

여기서 지상 점프 후 jumps_used = 1로 설정하는 이유는 총 점프 횟수를 2회로 유지하기 위해서다. 지상에서 첫 점프를 이미 사용한 것으로 계산해야 공중에서 한 번만 추가 점프할 수 있다.
자주 묻는 질문 (FAQ)
is_on_wall()만으로 벽 방향을 알 수 있나?
아니다. is_on_wall()은 벽 접촉 여부만 알려 준다. 벽 점프 방향에는 get_slide_collision()에서 얻은 충돌 법선의 x 값을 사용해야 한다.
벽에 닿자마자 계속 점프할 수 있는 문제는 어떻게 막나?
벽 점프 뒤 짧은 입력 잠금 시간을 두고 필요하다면 같은 벽 재접촉을 잠시 무시하는 타이머를 추가한다. 위 예제의 wall_jump_input_lock_time이 첫 번째 조치다.
RigidBody2D로도 같은 방식을 쓸 수 있나?
가능하지만 권장 구조가 다르다. 직접 속도와 충돌 이동을 제어하는 플랫폼 캐릭터에는 CharacterBody2D가 적합하고 물리 반응 자체가 핵심인 오브젝트에는 RigidBody2D가 적합하다.
정리
Godot 4에서 이중 점프와 벽 타기를 안정적으로 구현하려면 CharacterBody2D가 물리 이동을 단일하게 담당하고 점프 횟수와 벽 법선, 낙하 속도 상한을 분리해 관리하면 된다. 먼저 단일 스크립트로 충돌 순서와 조작 감각을 검증한 뒤 기능이 늘어나는 시점에 이동·점프·벽 제어를 자식 노드로 분리하면 유지보수 비용을 낮출 수 있다.



