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

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.
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:
- 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.
- Ajuste um parâmetro por vez com o jogo rodando. Como tudo é
@export, muda no Inspector e sente na hora. - 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.
- 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.


