Godot 4 이중 점프와 벽 타기: 노드 아키텍처로 안정적으로 구현하는 방법

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

✓ 복사 완료!
챕터시스템 아키텍처 & 패턴
난이도심화
상태● 검토 완료
대상 엔진
Godot
최종 업데이트
Godot 4 이중 점프와 벽 타기: 노드 아키텍처로 안정적으로 구현하는 방법

핵심 요약

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
PlayerMotorplayer_motor.gd수평 가속·감속, 중력 적용수평·수직 목표 속도
JumpControllerjump_controller.gd지상 점프, 이중 점프, 점프 컷점프 가능 횟수
WallControllerwall_controller.gd벽 판정, 미끄러짐, 벽 점프벽 방향, 낙하 속도 상한
CollisionShape2D없음플레이어 물리 형상없음

CharacterBody2D Player 아래에 이동 점프 벽 제어 노드가 배치된 Godot 4 씬 트리 예시

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()를 호출하고 그 결과를 다음 프레임의 상태 갱신에 사용한다는 순서를 명확히 정해야 한다.

벽에 붙었다고 즉시 벽 타기를 시작하면 바닥과 벽이 만나는 모서리에서 낙하 속도가 부자연스럽게 제한된다. 아래 세 조건을 모두 만족할 때만 벽 미끄러짐을 허용하는 편이 좋다.

  1. 플레이어가 바닥 위가 아니다.
  2. 플레이어가 벽에 충돌했다.
  3. 수직 속도가 아래 방향이다.

Godot 2D 좌표계에서 아래 방향은 양수다. 중력 가속도를 gravity라고 할 때 낙하 속도는 다음처럼 누적하며 벽 미끄러짐에서는 최대 속도만 wall_slide_speed로 낮춘다.

vy=min⁡(vy+gΔt,vfall_max)v_y = \min(v_y + g\Delta t, v_{fall\_max})

Step 1. Input Map과 Player 씬을 준비한다

Project > Project Settings > Input Map에서 아래 액션을 만든다.

액션권장 키
move_leftA 또는 왼쪽 화살표
move_rightD 또는 오른쪽 화살표
jumpSpace

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

벽 접촉 중 낙하 속도가 wall_slide_speed로 제한되고 벽 법선 방향으로 벽 점프하는 플레이어 동작 예시

여기서 지상 점프 후 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가 물리 이동을 단일하게 담당하고 점프 횟수와 벽 법선, 낙하 속도 상한을 분리해 관리하면 된다. 먼저 단일 스크립트로 충돌 순서와 조작 감각을 검증한 뒤 기능이 늘어나는 시점에 이동·점프·벽 제어를 자식 노드로 분리하면 유지보수 비용을 낮출 수 있다.