Como programar dash no Godot 4 (2D): cooldown, i-frames e ghost trail

Tutorial completo de dash godot: cooldown com Timer, direção do input ou do facing, i-frames durante o dash, ghost trail e integração com state machine.
Como programar dash no Godot 4 (2D): cooldown, i-frames e ghost trail
Dash é daqueles mecanismos que parecem uma linha de código e viram uma tarde inteira. Aplicar velocidade na direção certa é fácil; o trabalho de verdade está nos detalhes: o cooldown que impede spam, a decisão entre direção do input ou do facing, a invencibilidade que faz o dash servir de esquiva, e o feedback visual que vende o movimento. Esse tutorial de dash godot cobre tudo isso, em GDScript do Godot 4.x, num CharacterBody2D que você consegue colar no seu projeto hoje.
Vou montar a versão direta primeiro, com flags e um Timer, porque ela funciona e é o que a maioria dos jogos pequenos precisa. No final mostro como o mesmo dash vira um estado dentro de uma state machine, que é o caminho quando o personagem começa a acumular habilidades. Assumo que você já tem um movimento básico funcionando; se não tiver, comece pelo guia de movimento de personagem em plataforma 2D e volte aqui.
A estrutura do dash godot: três perguntas antes do código
Todo dash responde três perguntas, e vale decidir antes de digitar qualquer coisa:
- Quanto dura? Dash bom é curto. Algo entre 0.15 e 0.25 segundo já dá sensação de explosão sem tirar o controle do jogador por tempo demais.
- Pra onde vai? Na direção que o jogador está segurando (estilo twin-stick e jogos top-down) ou na direção que o personagem está olhando (estilo plataforma clássico). São códigos diferentes e a escolha muda o feeling do jogo.
- O que ele interrompe? Durante o dash, a gravidade age? O jogador pode virar? Pode atacar? A resposta mais comum é: nada. Dash trava a direção e suspende a gravidade enquanto durar.
Com isso decidido, a cena fica assim:
Player (CharacterBody2D)
├── CollisionShape2D
├── Sprite2D
├── DashDuration (Timer)
└── DashCooldown (Timer)
Os dois timers são one_shot ligado e autostart desligado. Um conta quanto o dash dura, o outro conta quanto tempo até poder dar outro. Se Timer ainda é nebuloso pra você, eu destrinchei o node inteiro no artigo sobre Timer e cooldown no Godot.
O dash básico: velocidade, duração e cooldown
A versão mínima funcional, num plataforma 2D com facing:
extends CharacterBody2D
const SPEED = 220.0
const DASH_SPEED = 600.0
const GRAVITY = 980.0
@onready var dash_duration = $DashDuration
@onready var dash_cooldown = $DashCooldown
@onready var sprite = $Sprite2D
var is_dashing = false
var facing = 1 # 1 = direita, -1 = esquerda
func _ready():
dash_duration.wait_time = 0.2
dash_cooldown.wait_time = 0.8
dash_duration.timeout.connect(_on_dash_duration_timeout)
func _physics_process(delta):
if is_dashing:
# Direção travada, gravidade suspensa: só anda e segue.
move_and_slide()
return
var input_dir = Input.get_axis("move_left", "move_right")
if input_dir != 0:
facing = sign(input_dir)
sprite.flip_h = facing < 0
velocity.x = input_dir * SPEED
velocity.y += GRAVITY * delta
if Input.is_action_just_pressed("dash") and dash_cooldown.is_stopped():
start_dash()
move_and_slide()
func start_dash():
is_dashing = true
velocity = Vector2(facing * DASH_SPEED, 0.0)
dash_duration.start()
dash_cooldown.start()
func _on_dash_duration_timeout():
is_dashing = false
# Corta a velocidade pra não sair deslizando depois do dash.
velocity.x = facing * SPEED
Os pontos que importam nesse código:
is_dashingcurto-circuita o_physics_process. Enquanto dura o dash, nada de gravidade, nada de input de movimento, nada de virar. A velocidade foi definida emstart_dash()e fica congelada até o timer disparar.velocity.y = 0no início do dash. Está embutido noVector2(facing * DASH_SPEED, 0.0). Dash horizontal que mantém a queda vira uma diagonal estranha; zerar o eixo Y dá aquele dash reto que segura o personagem no ar.- O corte de velocidade no timeout. Sem ele, o personagem termina o dash a 600 de velocidade e o motor de física leva alguns frames pra normalizar. Cortar pra velocidade de corrida na mesma direção dá um final limpo.
dash_cooldown.is_stopped()é a única checagem de cooldown. Nada de booleana paralela. O Timer parado significa dash disponível, rodando significa em recarga.
Direção do input ou do facing
O código acima usa o facing: o dash vai pra onde o personagem olha, mesmo que o jogador esteja parado. É o comportamento certo pra plataforma, onde o dash quase sempre é horizontal.
Em jogo top-down, o jogador espera dar dash na direção que está segurando, incluindo diagonais. A mudança é trocar a fonte da direção:
func start_dash():
var input_vector = Input.get_vector("move_left", "move_right", "move_up", "move_down")
var dash_dir: Vector2
if input_vector != Vector2.ZERO:
dash_dir = input_vector.normalized()
else:
# Sem input, cai no facing como plano B.
dash_dir = Vector2(facing, 0.0)
is_dashing = true
velocity = dash_dir * DASH_SPEED
dash_duration.start()
dash_cooldown.start()
O normalized() ali não é detalhe: sem ele, dash na diagonal sai mais rápido que dash reto, porque o vetor (1, 1) tem comprimento maior que 1. É o mesmo bug clássico do movimento diagonal, só que mais visível porque dash é rápido.
O fallback pro facing também é decisão de design, não só de código. Dash com o analógico no neutro precisa fazer alguma coisa, e "vai pra onde estou olhando" é a resposta que ninguém questiona. A alternativa, ignorar o input e não dar dash, parece bug pra quem joga.
I-frames: o dash como esquiva
Se o seu jogo tem combate, dash sem invencibilidade é meio dash. Os i-frames (frames de invencibilidade) são o que transforma o movimento em ferramenta defensiva: o jogador atravessa o ataque em vez de só correr dele.
No Godot, o jeito limpo de fazer isso é desligar a camada de colisão de hurtbox durante o dash. Supondo que o dano chega por uma Area2D chamada Hurtbox no player:
@onready var hurtbox = $Hurtbox
func start_dash():
is_dashing = true
velocity = Vector2(facing * DASH_SPEED, 0.0)
hurtbox.set_deferred("monitorable", false)
dash_duration.start()
dash_cooldown.start()
func _on_dash_duration_timeout():
is_dashing = false
velocity.x = facing * SPEED
hurtbox.set_deferred("monitorable", true)
O set_deferred é obrigatório aqui: mexer em propriedade de colisão no meio do passo de física pode disparar erro ou comportamento inconsistente, então o Godot pede pra adiar a mudança pro fim do frame.
Uma alternativa, se o seu dano é checado por código (um método take_damage no player), é simplesmente ignorar o dano enquanto is_dashing for verdadeiro:
func take_damage(amount: int):
if is_dashing:
return
health -= amount
Mais simples, e funciona bem enquanto todo dano passa por essa função. A versão de camada de colisão é mais robusta porque vale pra qualquer fonte de dano, inclusive as que você adicionar daqui a três meses e esquecer de checar a flag.
Cuidado com a duração: i-frames que cobrem o dash inteiro mais um respiro no final deixam a esquiva generosa demais. Em geral, invencibilidade durante o dash e vulnerabilidade imediata no fim já dá o equilíbrio certo. Se quiser apertar, ligue os i-frames só nos primeiros dois terços da duração.
Ghost trail: o rastro que vende o dash
O dash funciona sem feedback visual, mas parece teleporte engasgado. O ghost trail, aquelas cópias semitransparentes que ficam pra trás, é barato de fazer e muda completamente a leitura do movimento.
A ideia: durante o dash, a cada intervalo curto, criar um Sprite2D que copia a textura e a posição do personagem e some com um fade. Um Timer em loop dentro do player resolve o intervalo, mas dá pra fazer ainda mais direto com acumulador de delta:
const GHOST_INTERVAL = 0.04
var ghost_timer = 0.0
func _physics_process(delta):
if is_dashing:
ghost_timer -= delta
if ghost_timer <= 0.0:
spawn_ghost()
ghost_timer = GHOST_INTERVAL
move_and_slide()
return
# ... resto do movimento
E a função que cria cada fantasma:
func spawn_ghost():
var ghost = Sprite2D.new()
ghost.texture = sprite.texture
ghost.flip_h = sprite.flip_h
ghost.global_position = sprite.global_position
ghost.modulate = Color(1.0, 1.0, 1.0, 0.5)
get_tree().current_scene.add_child(ghost)
var tween = ghost.create_tween()
tween.tween_property(ghost, "modulate:a", 0.0, 0.3)
tween.tween_callback(ghost.queue_free)
O Tween anima a transparência até zero em 0.3 segundo e depois destrói o fantasma. Como cada ghost cuida do próprio ciclo de vida, o player não precisa rastrear nada. Se o seu sprite é animado por AnimatedSprite2D, troque sprite.texture por sprite.sprite_frames.get_frame_texture(sprite.animation, sprite.frame) pra copiar o frame atual.
Dois ajustes finos que valem o teste: tingir o ghost com a cor do jogo (ghost.modulate = Color(0.4, 0.7, 1.0, 0.5) pra um rastro azulado) e espaçar mais os fantasmas em dash mais longo. Com intervalo de 0.04 e duração de 0.2, saem uns cinco fantasmas por dash, que é o suficiente.
Dash dentro da state machine
A flag is_dashing com return no topo do _physics_process aguenta bem até o personagem ter três ou quatro comportamentos. Quando entram wall jump, ataque, knockback e escada, as flags começam a brigar entre si e todo bug vira "por que ele dá dash no meio do ataque?".
A resposta é promover o dash a um estado. Se você já usa o padrão que mostrei no artigo de state machine no Godot, o dash vira uma classe de estado com entrada, atualização física e saída bem definidas:
extends State
class_name DashState
const DASH_SPEED = 600.0
const DASH_TIME = 0.2
var time_left = 0.0
func enter():
time_left = DASH_TIME
player.velocity = Vector2(player.facing * DASH_SPEED, 0.0)
player.hurtbox.set_deferred("monitorable", false)
player.dash_cooldown.start()
func physics_update(delta):
time_left -= delta
player.move_and_slide()
if time_left <= 0.0:
if player.is_on_floor():
transition_to("Run")
else:
transition_to("Fall")
func exit():
player.hurtbox.set_deferred("monitorable", true)
player.velocity.x = player.facing * player.SPEED
Repare que aqui eu troquei o Timer node pela contagem manual com delta. Dentro de um estado isso faz sentido: a duração é parte da lógica do estado e morre junto com ele, sem sinal pra conectar nem node pra consultar. O cooldown continua sendo Timer node no player, porque ele precisa sobreviver à troca de estados.
O ganho real da versão com estado é que as regras de interrupção ficam explícitas. Quem decide se pode entrar no dash é a transição (só de Run, Fall e Jump, por exemplo, nunca de Hurt), e a saída sempre passa pelo exit(), então os i-frames nunca ficam ligados por acidente. Aquele bug de "ficou invencível pra sempre" simplesmente deixa de existir, porque não há caminho de saída que pule a limpeza.
Se o seu jogo tem dash aéreo, o estado também é o lugar natural pra regra de uma carga por pulo: um contador no player que zera ao tocar o chão, no mesmo espírito do contador de pulos que aparece no artigo de coyote time e sistema de pulo.
Fechando
Dash no Godot 4 é uma receita de camadas: velocidade travada por um tempo curto, dois timers one shot pra duração e cooldown, direção vinda do input ou do facing conforme o gênero, hurtbox desligada com set_deferred pros i-frames, e ghosts com Tween pro rastro. Cada camada é pequena, e juntas elas fazem o movimento que o jogador descreve como "gostoso" sem saber explicar por quê.
Minha sugestão de ordem: faça o dash básico funcionar primeiro, sinta a duração e a velocidade no controle, e só então adicione i-frames e trail. Ajustar DASH_SPEED e wait_time com o jogo rodando vale mais que qualquer número que eu te der aqui. E quando o personagem passar de três habilidades, migre pra state machine antes que as flags cobrem o pedágio.


