Voltar para o Blog
Quest Log

Movimento de Personagem 2D de Plataforma na Prática: Aceleração, Fricção e Controle no Ar

Personagem 2D pixel art correndo e pulando entre plataformas flutuantes com trilhas de movimento

Tutorial prático de movimento plataforma 2D no Godot 4: aceleração, fricção, controle no ar e tuning de game feel com CharacterBody2D, com código completo.

Movimento de Personagem 2D de Plataforma na Prática: Aceleração, Fricção e Controle no Ar

Movimento plataforma 2D é o tipo de coisa que parece resolvida em dez linhas de código e na verdade define se o seu jogo é gostoso ou genérico. O template padrão do Godot move o personagem, sim. Mas ele liga e desliga a velocidade como um interruptor, e interruptor não tem peso, não tem momentum, não tem personalidade. A diferença entre "anda" e "controla bem" mora em três sistemas: aceleração, fricção e controle no ar.

Esse tutorial monta um controlador de plataforma completo no Godot 4 com CharacterBody2D, camada por camada. Todo código é GDScript que roda como está. No final você vai ter um script único com tudo exposto no Inspector pra ajustar sem recompilar nada na cabeça.

O esqueleto: CharacterBody2D e o loop de física

A estrutura de cena é a de sempre: um CharacterBody2D com uma CollisionShape2D (cápsula, de preferência, porque desliza em quina sem travar) e um Sprite2D ou AnimatedSprite2D.

Player (CharacterBody2D)
├── CollisionShape2D
└── AnimatedSprite2D

E o ponto de partida, o controlador mínimo:

extends CharacterBody2D

const SPEED = 300.0
const JUMP_VELOCITY = -400.0

var gravity = ProjectSettings.get_setting("physics/2d/default_gravity")

func _physics_process(delta):
    if not is_on_floor():
        velocity.y += gravity * delta

    if Input.is_action_just_pressed("jump") and is_on_floor():
        velocity.y = JUMP_VELOCITY

    var direction = Input.get_axis("move_left", "move_right")
    velocity.x = direction * SPEED

    move_and_slide()

Isso funciona. E é exatamente o que vamos desmontar, porque velocity.x = direction * SPEED é o interruptor: o personagem sai de 0 pra 300 pixels por segundo em um único frame e volta pra 0 no instante em que você solta o botão. Nenhum objeto com massa se comporta assim, e o cérebro do jogador percebe isso mesmo sem saber explicar.

Antes de seguir: tudo aqui vive em _physics_process, nunca em _process. O passo de física é fixo (60 vezes por segundo por padrão), então o movimento fica idêntico em qualquer máquina.

Aceleração e fricção: o coração do movimento plataforma 2D

A ideia é simples: em vez de setar a velocidade no alvo, você caminha até ela um pouco por frame. O move_toward() faz isso sem você escrever if pra cada direção:

@export var max_speed := 300.0
@export var acceleration := 2200.0  # pixels/s² indo na direção do input
@export var friction := 1800.0      # pixels/s² freando sem input

func apply_run(direction: float, delta: float) -> void:
    if direction != 0:
        velocity.x = move_toward(velocity.x, direction * max_speed, acceleration * delta)
    else:
        velocity.x = move_toward(velocity.x, 0.0, friction * delta)

Multiplicar por delta transforma os valores em taxa por segundo, então com acceleration = 2200 o personagem leva uns 0,14 segundo pra atingir velocidade máxima. Curto o bastante pra responder, longo o bastante pra ter peso.

Usei @export de propósito: esses números são tuning, não lógica. Expostos no Inspector, você ajusta com o jogo rodando e sente o resultado na hora. É assim que se calibra game feel, na mão, não na teoria.

O caso da virada

Tem um terceiro estado escondido aí que muita gente ignora: o jogador está correndo pra direita e aperta esquerda. Se você só aplica a aceleração normal, o personagem demora pra inverter e parece pesado demais. Jogos de plataforma responsivos aplicam uma desaceleração extra na virada:

@export var turn_boost := 2.0  # multiplicador de aceleração ao inverter direção

func apply_run(direction: float, delta: float) -> void:
    if direction != 0:
        var accel := acceleration
        # Input aponta contra a velocidade atual? É uma virada.
        if signf(direction) != signf(velocity.x) and velocity.x != 0:
            accel *= turn_boost
        velocity.x = move_toward(velocity.x, direction * max_speed, accel * delta)
    else:
        velocity.x = move_toward(velocity.x, 0.0, friction * delta)

São quatro linhas e o controle muda de figura. A virada rápida é um daqueles detalhes que ninguém nota quando existe e todo mundo sofre quando falta.

Fricção define o gênero

Repare na relação entre os dois valores. Fricção alta e aceleração alta dão um controle preciso, de puzzle platformer. Aceleração média com fricção baixa dá deslize, momentum, sensação de velocidade. Fase de gelo é só a mesma fórmula com fricção lá embaixo:

func get_friction() -> float:
    # Detecta o chão atual e devolve a fricção dele.
    if is_on_floor() and get_last_slide_collision():
        var collider = get_last_slide_collision().get_collider()
        if collider.is_in_group("gelo"):
            return 200.0
    return friction

Coloque as plataformas de gelo num grupo chamado gelo no editor e troque friction * delta por get_friction() * delta no freio. Pronto, mecânica de fase inteira saindo de uma função.

Próximo nível
Quer aprender isso na prática?

No CursoGame.Dev você sai dos tutoriais soltos e constrói jogos publicáveis, com trilha progressiva, quests práticas e feedback real.

Conhecer a plataforma
+500 alunos4.9/5Garantia 7 dias

Controle no ar: onde os jogos bons se separam

Aqui está a decisão de design mais subestimada do gênero: o que acontece com o input enquanto o personagem está no ar?

Na vida real, ninguém muda de direção no meio de um salto. Em jogo de plataforma, quase todos os clássicos permitem, porque controle aéreo é o que deixa o jogador corrigir o pulo e se sentir no comando. A questão não é se você permite, é quanto.

A solução limpa é separar aceleração e fricção do chão das do ar:

@export_group("Ar")
@export var air_acceleration := 1400.0
@export var air_friction := 400.0

func apply_run(direction: float, delta: float) -> void:
    var accel := acceleration if is_on_floor() else air_acceleration
    var fric := friction if is_on_floor() else air_friction

    if direction != 0:
        if signf(direction) != signf(velocity.x) and velocity.x != 0:
            accel *= turn_boost
        velocity.x = move_toward(velocity.x, direction * max_speed, accel * delta)
    else:
        velocity.x = move_toward(velocity.x, 0.0, fric * delta)

Dois detalhes de tuning que aprendi errando:

Aceleração no ar menor que no chão, mas não muito menor. Algo entre 50% e 80% do valor de chão funciona pra maioria dos jogos. Abaixo disso o personagem parece um tijolo arremessado.

Fricção no ar bem baixa. Se o jogador solta o direcional no meio do pulo, o esperado é que o corpo continue a trajetória, não que freie no ar como se houvesse atrito. Fricção aérea alta é um dos jeitos mais rápidos de deixar o pulo artificial.

Gravidade assimétrica e queda máxima

O pulo de arco perfeitamente simétrico, mesma velocidade subindo e descendo, parece flutuante. Quase todo plataforma bom desce mais rápido do que sobe. A implementação é uma gravidade multiplicada na descida:

@export var fall_gravity_multiplier := 1.6
@export var max_fall_speed := 700.0

func apply_gravity(delta: float) -> void:
    if is_on_floor():
        return
    var g := gravity
    if velocity.y > 0:  # descendo (Y cresce pra baixo no 2D)
        g *= fall_gravity_multiplier
    velocity.y = minf(velocity.y + g * delta, max_fall_speed)

O max_fall_speed resolve dois problemas de uma vez: queda longa não vira velocidade absurda que atravessa plataforma fina, e o jogador consegue mirar o pouso porque a velocidade terminal é previsível.

Pulo de altura variável

Pra fechar o controle aéreo, o corte de pulo: soltar o botão cedo encurta o salto. Com isso, um botão só produz desde um pulinho até o pulo cheio:

@export var jump_velocity := -420.0
@export var jump_cut := 0.45  # fração da velocidade que sobra ao soltar

func handle_jump() -> void:
    if Input.is_action_just_pressed("jump") and is_on_floor():
        velocity.y = jump_velocity
    if Input.is_action_just_released("jump") and velocity.y < 0:
        velocity.y *= jump_cut

Se quiser ir além, coyote time e jump buffer entram aqui também. Eu cobri os dois em detalhe no tutorial de física do Godot, então não vou repetir, mas a versão curta: são dois temporizadores que perdoam erro de timing do jogador e valem cada linha.

O script completo

Juntando tudo num único controlador:

extends CharacterBody2D

@export_group("Chão")
@export var max_speed := 300.0
@export var acceleration := 2200.0
@export var friction := 1800.0
@export var turn_boost := 2.0

@export_group("Ar")
@export var air_acceleration := 1400.0
@export var air_friction := 400.0
@export var fall_gravity_multiplier := 1.6
@export var max_fall_speed := 700.0

@export_group("Pulo")
@export var jump_velocity := -420.0
@export var jump_cut := 0.45

var gravity: float = ProjectSettings.get_setting("physics/2d/default_gravity")

func _physics_process(delta):
    apply_gravity(delta)
    handle_jump()
    apply_run(Input.get_axis("move_left", "move_right"), delta)
    move_and_slide()

func apply_gravity(delta: float) -> void:
    if is_on_floor():
        return
    var g := gravity
    if velocity.y > 0:
        g *= fall_gravity_multiplier
    velocity.y = minf(velocity.y + g * delta, max_fall_speed)

func handle_jump() -> void:
    if Input.is_action_just_pressed("jump") and is_on_floor():
        velocity.y = jump_velocity
    if Input.is_action_just_released("jump") and velocity.y < 0:
        velocity.y *= jump_cut

func apply_run(direction: float, delta: float) -> void:
    var accel := acceleration if is_on_floor() else air_acceleration
    var fric := friction if is_on_floor() else air_friction

    if direction != 0:
        if signf(direction) != signf(velocity.x) and velocity.x != 0:
            accel *= turn_boost
        velocity.x = move_toward(velocity.x, direction * max_speed, accel * delta)
    else:
        velocity.x = move_toward(velocity.x, 0.0, fric * delta)

Lembre de criar as ações move_left, move_right e jump em Project Settings > Input Map antes de rodar, senão o Godot reclama de ação inexistente.

Como ajustar os valores na prática

Os números que deixei são um ponto de partida honesto, não verdade absoluta. O processo de tuning que eu uso:

  1. Monte uma fase de teste feia. Plataformas em alturas e distâncias variadas, um corredor longo pra sentir a corrida, uma escadinha pra testar pulos curtos. Sem arte, só colisão.
  2. Ajuste um parâmetro por vez com o jogo rodando. Como tudo é @export, muda no Inspector e sente na hora.
  3. Ordem que funciona pra mim: velocidade máxima primeiro (escala geral do jogo), depois pulo e gravidade (o arco), depois aceleração e fricção (a textura do controle), e o ar por último.
  4. Grave a si mesmo jogando. Sério. Vendo a gravação você nota hesitação e correção de trajetória que não percebe jogando.

Um erro comum nessa fase é perseguir os valores de outro jogo. Celeste, Hollow Knight e Mario têm tunings completamente diferentes entre si, e os três controlam bem. O que eles têm em comum não são os números, é a coerência: cada um escolheu uma personalidade de movimento e calibrou tudo a favor dela.

Fechando

Movimento de plataforma bom não é um algoritmo secreto, é um punhado de decisões pequenas tomadas com intenção: acelerar em vez de teleportar a velocidade, frear diferente de acelerar, virar mais rápido do que correr, cair mais rápido do que subir, e dar ao jogador controle no ar suficiente pra ele se sentir dono do pulo.

O script desse tutorial cobre tudo isso em menos de 60 linhas. Copie, cole num CharacterBody2D e gaste uma hora só mexendo nos valores do Inspector. Essa uma hora de tuning vai te ensinar mais sobre game feel do que qualquer leitura, incluindo essa.