Como Programar um Pulo de Jogo de Plataforma com Coyote Time e Jump Buffer

Aprenda a programar um pulo de jogo de plataforma que responde bem: coyote time, jump buffer e gravidade variável com código GDScript pronto no Godot 4.
Como Programar um Pulo de Jogo de Plataforma com Coyote Time e Jump Buffer
O pulo de jogo de plataforma é a mecânica mais testada do seu jogo inteiro. O jogador vai apertar esse botão milhares de vezes, e cada aperto é um veredito: ou o controle responde do jeito que ele esperava, ou ele sente que o jogo "roubou". A boa notícia é que a distância entre um pulo travado e um pulo gostoso não é talento místico de designer. São quatro técnicas concretas, cada uma com menos de quinze linhas de código: gravidade variável, altura controlável, coyote time e jump buffer.
Nesse tutorial eu monto as quatro em GDScript no Godot 4, explicando o porquê de cada uma. No final tem o controlador completo, pronto pra colar num CharacterBody2D e ajustar pro seu jogo.
O que separa um pulo bom de um pulo de jogo de plataforma genérico
Pega o Celeste, o Hollow Knight ou qualquer Mario e observa o pulo quadro a quadro. Nenhum deles usa física "realista". Todos mentem a favor do jogador:
- O personagem sobe mais devagar do que cai. Queda rápida dá peso e devolve o controle logo.
- Segurar o botão pula alto, tocar de leve dá um pulinho. O jogador dosa a altura.
- Dá pra pular alguns frames depois de sair da borda da plataforma (coyote time).
- Apertar pulo um instante antes de pousar ainda conta (jump buffer).
As duas últimas existem por um motivo simples: humano erra timing. Numa tela a 60 FPS, a janela de um frame dura uns 16 milissegundos. Exigir precisão de frame do jogador transforma seu jogo num teste de reflexo involuntário. As técnicas abaixo perdoam o erro pequeno sem tirar a habilidade do jogo.
O pulo base (e por que ele parece ruim)
Esse é o pulo que todo tutorial de Godot ensina primeiro:
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
velocity.x = Input.get_axis("move_left", "move_right") * SPEED
move_and_slide()
Funciona, mas tem três problemas. A trajetória é uma parábola simétrica, então o personagem flutua na descida com a mesma lentidão da subida. A altura é fixa, apertou, pulou tudo. E o is_on_floor() no pulo é implacável: um pixel fora da plataforma e o input morre. É exatamente isso que vamos consertar, peça por peça.
Antes de seguir, crie as ações jump, move_left e move_right no Input Map (Project Settings > Input Map). Se preferir testar rápido, troque pelos nomes prontos ui_accept, ui_left e ui_right.
Gravidade variável: subir num ritmo, cair em outro
Em vez de chutar valores de gravidade e velocidade até parecer certo, dá pra fazer o contrário: você decide a altura do pulo e a duração da subida e da descida, e calcula a física a partir disso. É cinemática de colégio aplicada a game design:
- Velocidade inicial do pulo:
v = 2h / t_subida - Gravidade na subida:
g = 2h / t_subida² - Gravidade na queda:
g = 2h / t_descida²
Onde h é a altura do pulo em pixels e t é o tempo em segundos. Com tempos diferentes pra subida e descida, você ganha a queda mais rápida de graça, sem nenhum if mágico:
const JUMP_HEIGHT = 64.0 # altura do pulo em pixels
const JUMP_TIME_TO_PEAK = 0.35 # segundos até o topo
const JUMP_TIME_TO_DESCENT = 0.28 # segundos do topo até o chão
# Negativo porque no 2D do Godot o eixo Y cresce pra baixo.
var jump_velocity: float = (-2.0 * JUMP_HEIGHT) / JUMP_TIME_TO_PEAK
var jump_gravity: float = (2.0 * JUMP_HEIGHT) / (JUMP_TIME_TO_PEAK * JUMP_TIME_TO_PEAK)
var fall_gravity: float = (2.0 * JUMP_HEIGHT) / (JUMP_TIME_TO_DESCENT * JUMP_TIME_TO_DESCENT)
func get_gravity_for_state() -> float:
# Subindo (velocity.y negativa) usa uma gravidade, caindo usa outra.
return jump_gravity if velocity.y < 0.0 else fall_gravity
func _physics_process(delta):
if not is_on_floor():
velocity.y += get_gravity_for_state() * delta
# ... resto do controlador
O ganho prático é enorme na hora de ajustar o jogo. O level designer fala "preciso que o player alcance uma plataforma 64 pixels acima", você muda JUMP_HEIGHT pra 64 e pronto, garantido pela matemática. Nada de testar vinte combinações de gravidade e velocidade no olho.
Repare que com esses valores o pulo ignora a gravidade global do projeto. Pra esse personagem, tudo bem: o pulo é dele, não do mundo.
Altura controlável: o corte de pulo
Segurar o botão pra pular mais alto não exige simular força acumulada. O truque clássico é cortar a velocidade vertical no momento em que o jogador solta o botão durante a subida:
const JUMP_CUT = 0.4 # fração da velocidade que sobra ao soltar cedo
func _physics_process(delta):
# ... gravidade e pulo acima
if Input.is_action_just_released("jump") and velocity.y < 0.0:
velocity.y *= JUMP_CUT
Soltou cedo, a velocidade de subida despenca pra 40% e o personagem desacelera até o topo de um pulo curto. Segurou até o fim, pulo cheio. Duas linhas, e o jogador ganha controle analógico num botão digital.
Coyote time: o pulo depois da borda
O nome vem do Coiote do desenho animado, que corre além do penhasco e fica suspenso no ar até olhar pra baixo. No jogo, a ideia é: se o jogador apertou pulo logo depois que o personagem saiu da plataforma, o pulo ainda vale.
Sem isso, acontece uma cena que você já viveu: o jogador corre até a beirada, aperta pulo no que ele jura que era o último pixel do chão, e o personagem despenca. Pra ele, o jogo falhou. Tecnicamente o is_on_floor() já era falso fazia dois frames, mas o jogador não pensa em frames, pensa em intenção.
A implementação é um temporizador que recarrega enquanto há chão e esvazia no ar:
const COYOTE_TIME = 0.1 # segundos de tolerância após sair da borda
var coyote_timer := 0.0
func _physics_process(delta):
if is_on_floor():
coyote_timer = COYOTE_TIME
else:
coyote_timer -= delta
# O pulo agora checa o timer, não o chão diretamente.
if Input.is_action_just_pressed("jump") and coyote_timer > 0.0:
velocity.y = jump_velocity
coyote_timer = 0.0 # impede pulo duplo de graça
Zerar o timer depois do pulo é obrigatório. Sem isso, o jogador pula, e nos 0.1 segundos seguintes o timer ainda está positivo, liberando um segundo pulo no ar.
Valores comuns ficam entre 0.05 e 0.15 segundos, ou seja, de 3 a 9 frames a 60 FPS. É imperceptível no olho e gigante na sensação.
Jump buffer: o pulo antes do pouso
O jump buffer é o irmão gêmeo do coyote time, só que no pouso. O jogador está caindo, antecipa o chão e aperta pulo um tiquinho cedo demais. Sem buffer, o input cai no vazio e ele precisa apertar de novo, e o pulo encadeado que ele queria vira uma pausa esquisita.
Com buffer, o jogo guarda a intenção por uma fração de segundo e dispara o pulo assim que o personagem pousa:
const JUMP_BUFFER_TIME = 0.12 # segundos que o input fica guardado
var jump_buffer_timer := 0.0
func _physics_process(delta):
if Input.is_action_just_pressed("jump"):
jump_buffer_timer = JUMP_BUFFER_TIME
else:
jump_buffer_timer -= delta
# Pula quando há intenção recente E permissão de pulo.
if jump_buffer_timer > 0.0 and coyote_timer > 0.0:
velocity.y = jump_velocity
jump_buffer_timer = 0.0
coyote_timer = 0.0
Repare na elegância da condição final: o buffer responde "o jogador quis pular há pouco?" e o coyote responde "o personagem tem direito de pular?". Quando as duas são verdadeiras ao mesmo tempo, pula. Essa única linha cobre o pulo normal, o pulo na borda e o pulo bufferizado, sem nenhum caso especial.
O controlador completo
Juntando tudo, com movimento horizontal com aceleração pra fechar o pacote:
extends CharacterBody2D
const SPEED = 300.0
const ACCELERATION = 2000.0
const FRICTION = 1800.0
const JUMP_HEIGHT = 64.0
const JUMP_TIME_TO_PEAK = 0.35
const JUMP_TIME_TO_DESCENT = 0.28
const JUMP_CUT = 0.4
const COYOTE_TIME = 0.1
const JUMP_BUFFER_TIME = 0.12
var jump_velocity: float = (-2.0 * JUMP_HEIGHT) / JUMP_TIME_TO_PEAK
var jump_gravity: float = (2.0 * JUMP_HEIGHT) / (JUMP_TIME_TO_PEAK * JUMP_TIME_TO_PEAK)
var fall_gravity: float = (2.0 * JUMP_HEIGHT) / (JUMP_TIME_TO_DESCENT * JUMP_TIME_TO_DESCENT)
var coyote_timer := 0.0
var jump_buffer_timer := 0.0
func _physics_process(delta):
# Gravidade variável: uma na subida, outra na queda.
if not is_on_floor():
var g := jump_gravity if velocity.y < 0.0 else fall_gravity
velocity.y += g * delta
# Coyote: recarrega no chão, esvazia no ar.
if is_on_floor():
coyote_timer = COYOTE_TIME
else:
coyote_timer -= delta
# Buffer: recarrega ao apertar, esvazia com o tempo.
if Input.is_action_just_pressed("jump"):
jump_buffer_timer = JUMP_BUFFER_TIME
else:
jump_buffer_timer -= delta
# Pulo: intenção recente + permissão de pulo.
if jump_buffer_timer > 0.0 and coyote_timer > 0.0:
velocity.y = jump_velocity
jump_buffer_timer = 0.0
coyote_timer = 0.0
# Altura controlável: soltou cedo, corta a subida.
if Input.is_action_just_released("jump") and velocity.y < 0.0:
velocity.y *= JUMP_CUT
# Movimento horizontal com aceleração e fricção.
var direction := Input.get_axis("move_left", "move_right")
if direction:
velocity.x = move_toward(velocity.x, direction * SPEED, ACCELERATION * delta)
else:
velocity.x = move_toward(velocity.x, 0.0, FRICTION * delta)
move_and_slide()
São menos de 60 linhas e esse controlador já se comporta melhor que muito jogo publicado. A ordem das checagens importa: gravidade primeiro, timers depois, pulo, corte, movimento horizontal e o move_and_slide() fechando o frame.
Tuning: os números são do seu jogo
Nenhum valor acima é sagrado. Eles são um ponto de partida sensato, e o trabalho de verdade é iterar:
JUMP_HEIGHTe os tempos de pico: defina a partir do seu level design. Altura de plataforma padrão mais uma folga de uns 10% costuma funcionar bem.JUMP_TIME_TO_DESCENTmenor queJUMP_TIME_TO_PEAK: é o que dá peso. Se o personagem parece um balão, encurte a descida.COYOTE_TIMEentre 0.05 e 0.15: jogo rápido e punitivo pede menos, jogo casual aceita mais. Acima de 0.15 começa a ficar visível e parece pulo no ar.JUMP_BUFFER_TIMEentre 0.1 e 0.15: quanto mais encadeado for o platforming do seu jogo, mais o buffer trabalha.JUMP_CUTentre 0.3 e 0.5: mais baixo dá diferença maior entre pulo curto e cheio.
O método de teste é simples: exponha os valores com @export no script, rode o jogo, mude no Inspector com o jogo rodando e sinta a diferença na hora. Um truque que uso é desligar uma técnica de cada vez depois de tudo pronto. Você só percebe quanto o coyote time carrega o jogo nas costas quando o remove.
Fechando
Pulo gostoso não é acidente nem segredo de estúdio grande. É gravidade assimétrica calculada a partir da altura desejada, corte de velocidade ao soltar o botão, e dois temporizadores de tolerância que perdoam o erro humano de timing. Cada técnica é pequena, e juntas elas mudam completamente como o seu jogo de plataforma se sente na mão.
Implementa as quatro, mas implementa uma de cada vez, testando entre cada etapa. Sentir o antes e o depois de cada técnica vale mais do que qualquer parágrafo desse artigo, e te dá o critério pra ajustar os números pro seu jogo, não pro meu.


