Sistema de Combate Corpo a Corpo: Como Programar Melee no Seu Jogo

Aprenda a programar um sistema de combate corpo a corpo no seu jogo: hitbox, hurtbox, frames de ataque e combos em GDScript no Godot 4, com código real.
Sistema de Combate Corpo a Corpo: Como Programar Melee no Seu Jogo
Um sistema de combate de jogo melee bem feito se resume a três peças: hitbox (a área que causa dano), hurtbox (a área que recebe dano) e os frames de ataque que ditam quando cada uma está ativa. Hollow Knight, Dead Cells, Devil May Cry: por trás de estilos completamente diferentes, a estrutura é essa. O que muda é o tuning.
A boa notícia é que dá pra montar essa base num fim de semana. A má notícia é que a maioria dos tutoriais ensina o atalho errado: detectar dano por distância, ou ligar a colisão da espada o tempo inteiro. Funciona na demo e desmorona no primeiro inimigo que precisa de timing.
Esse tutorial monta o caminho certo em GDScript no Godot 4: hitbox e hurtbox separadas com Area2D, ataque dividido em startup, active e recovery, e um combo de três golpes com input buffer. Os mesmos conceitos valem pra Unity ou Unreal, só muda a sintaxe.
A base de um sistema de combate: hitbox vs hurtbox
A separação mais importante do combate melee é essa, e quem mistura as duas sofre depois:
- Hitbox: a área que causa dano. Vive na arma ou no golpe. Fica desligada quase o tempo todo e só acorda durante os frames ativos do ataque.
- Hurtbox: a área que recebe dano. Vive no corpo do personagem. Fica ligada quase o tempo todo (desliga em dodge com invencibilidade, por exemplo).
No Godot, as duas são Area2D. Não use o corpo físico (CharacterBody2D) pra detectar dano: corpo físico é pra colisão de movimento, parede e chão. Dano é detecção de overlap, e Area2D existe exatamente pra isso, além de ser mais barata.
A estrutura de nodes do player fica assim:
Player (CharacterBody2D)
├── CollisionShape2D # colisão física (parede, chão)
├── AnimatedSprite2D
├── Hurtbox (Area2D)
│ └── CollisionShape2D # cobre o corpo
└── HitboxPivot (Node2D)
└── Hitbox (Area2D)
└── CollisionShape2D # cobre o arco do golpe, começa desativada
O HitboxPivot existe por um motivo prático: quando o personagem vira pro outro lado, você inverte scale.x do pivot e a hitbox espelha junto, sem recalcular posição.
Layers: quem detecta quem
Configure camadas de colisão dedicadas, separadas das camadas de física. Por exemplo: camada 5 = player_hurtbox, camada 6 = inimigo_hurtbox.
- Hitbox do player: layer vazia, mask na camada 6 (detecta hurtbox de inimigo)
- Hurtbox do inimigo: layer 6, mask vazia (só é detectada, não detecta nada)
E o espelho disso pro inimigo atacar o player. Com as masks limpas, a hitbox do player nunca dispara na hurtbox do próprio player, e você elimina a categoria inteira de bug "me ataquei sozinho" sem nenhum if no código.
O código das duas áreas
A hitbox carrega os dados do golpe. Só isso:
class_name Hitbox
extends Area2D
@export var dano := 10
@export var forca_knockback := 250.0
A hurtbox detecta e avisa o dono via sinal:
class_name Hurtbox
extends Area2D
signal atingido(hitbox: Hitbox)
func _ready():
area_entered.connect(_on_area_entered)
func _on_area_entered(area: Area2D):
if area is Hitbox:
atingido.emit(area)
E quem decide o que fazer com o dano é o personagem, não a hurtbox:
# No script do inimigo (ou do player, a lógica é a mesma)
var vida := 30
func _ready():
$Hurtbox.atingido.connect(_on_atingido)
func _on_atingido(hitbox: Hitbox):
vida -= hitbox.dano
# Empurra na direção oposta ao golpe.
var direcao = (global_position - hitbox.global_position).normalized()
velocity = direcao * hitbox.forca_knockback
if vida <= 0:
queue_free()
Esse desenho escala bem: a hitbox não sabe quem ela acerta, a hurtbox não sabe quanto de vida o dono tem, e cada personagem decide a própria reação (inimigo blindado ignora knockback, chefe tem fase de invencibilidade, e por aí vai).
Frames de ataque: startup, active e recovery
Todo golpe de melee decente tem três fases, e é isso que separa combate com peso de combate que parece queijo:
- Startup: a animação começou, a arma está subindo, mas ainda não causa dano. É a janela de reação do oponente.
- Active: os frames em que a hitbox está ligada e o golpe acerta. Em geral é a fase mais curta.
- Recovery: o golpe terminou, a hitbox desligou, mas o personagem ainda está preso na animação. É o custo de ter atacado.
A comunidade de jogo de luta mede isso em frames a 60 FPS. Um jab rápido tem startup curtíssimo e um golpe carregado pode passar de meio segundo só de startup. Pro seu primeiro sistema, uma proporção que funciona como ponto de partida: startup um pouco maior que active, recovery maior que os dois. Depois ajuste no teste.
Implementando com AnimationPlayer
O jeito mais limpo no Godot é deixar a própria animação controlar a hitbox. No AnimationPlayer, dentro da animação ataque_1, adicione uma track de propriedade apontando pra Hitbox/CollisionShape2D:disabled com três keyframes:
- Tempo 0.0:
disabled = true(startup) - Início dos frames ativos:
disabled = false - Fim dos frames ativos:
disabled = true(recovery até a animação acabar)
A vantagem é que o timing do dano fica colado no timing visual. Se você esticar a animação, o golpe estica junto, e nunca acontece de a espada acertar antes de aparecer na tela. Dá pra fazer o mesmo por código com timers, mas aí o timing vive em dois lugares e em algum momento eles dessincronizam. Já passei por isso, não recomendo.
Um detalhe que pega muita gente: como a shape fica desativada fora dos frames ativos, o sinal area_entered dispara uma vez por alvo a cada golpe, e o problema de "um golpe deu dano 14 vezes" simplesmente não existe. Se um dia você precisar de uma hitbox que fica ligada por mais tempo (um raio contínuo, por exemplo), aí sim vai precisar guardar uma lista de alvos já atingidos por ativação.
Combo básico: três golpes encadeados
Combo é uma máquina de estados pequena com uma regra de ouro: o jogador pode apertar o botão do próximo golpe antes do atual terminar, e o jogo guarda essa intenção. Isso se chama input buffer, e sem ele o combo só sai se o jogador apertar no frame exato, o que ninguém faz.
A lógica: se não estou atacando, ataco. Se já estou atacando, guardo o pedido. Quando a animação termina, verifico se tem pedido guardado e se ainda cabe golpe no combo; se sim, encadeio o próximo, senão volto pro estado livre.
extends CharacterBody2D
const COMBO_MAX := 3
@onready var anim: AnimationPlayer = $AnimationPlayer
var atacando := false
var combo_atual := 0
var ataque_no_buffer := false
func _ready():
anim.animation_finished.connect(_on_animacao_terminou)
func _physics_process(delta):
if Input.is_action_just_pressed("atacar"):
if atacando:
ataque_no_buffer = true
else:
_iniciar_golpe(1)
if not atacando:
_mover(delta) # movimento normal só fora do ataque
move_and_slide()
func _iniciar_golpe(indice: int):
atacando = true
combo_atual = indice
velocity.x = 0 # atacar planta o personagem no lugar
anim.play("ataque_%d" % indice)
func _on_animacao_terminou(nome: StringName):
if not nome.begins_with("ataque"):
return
if ataque_no_buffer and combo_atual < COMBO_MAX:
ataque_no_buffer = false
_iniciar_golpe(combo_atual + 1)
else:
atacando = false
combo_atual = 0
ataque_no_buffer = false
Cada animação (ataque_1, ataque_2, ataque_3) tem a própria track ligando e desligando a hitbox, então cada golpe do combo pode ter timing, alcance e dano diferentes. O clássico: dois golpes rápidos e um finalizador lento que empurra. Pra variar o dano por golpe, mude o dano da hitbox dentro de _iniciar_golpe() ou adicione uma track de propriedade pra isso também.
Dois refinamentos que valem a pena quando a base estiver rodando:
- Janela de combo com tolerância: em vez de aceitar o buffer durante o golpe inteiro, aceite só da metade em diante. Isso evita que dois toques nervosos no botão disparem o combo inteiro sem intenção.
- Reset por tempo: se quiser que o combo quebre quando o jogador demora entre golpes, é só o buffer já resolver isso, porque sem input guardado o estado volta pro golpe 1 naturalmente.
Game feel: o que faz o golpe ter peso
Com hitbox, frames e combo funcionando, o combate está correto mas ainda parece de plástico. Três adições baratas mudam isso:
Hitstop. Congelar o jogo por alguns milissegundos no momento do impacto. É o truque mais desproporcional do combate: quase nenhum código, diferença enorme.
func aplicar_hitstop(duracao := 0.08):
Engine.time_scale = 0.05
# O quarto argumento (ignore_time_scale) faz o timer
# correr em tempo real, senão ele congela junto.
await get_tree().create_timer(duracao, true, false, true).timeout
Engine.time_scale = 1.0
Chame isso quando a hurtbox do inimigo emitir atingido. Entre 0.05 e 0.1 segundo já dá a sensação de impacto sem parecer travada.
Knockback. Já está no código da hurtbox lá em cima: empurrar o alvo na direção oposta ao golpe. Pro empurrão decair em vez de cortar seco, aplique fricção no _physics_process do alvo com move_toward.
Flip da hitbox. Quando o personagem vira, espelhe o pivot junto com o sprite:
var dir = Input.get_axis("esquerda", "direita")
if dir != 0:
$AnimatedSprite2D.flip_h = dir < 0
$HitboxPivot.scale.x = sign(dir)
Sem isso, o personagem olha pra esquerda e ataca pra direita, e é o bug número um de quem monta melee pela primeira vez.
Pra depurar tudo isso, ligue Debug > Visible Collision Shapes no editor e rode o jogo. Você vê a hitbox piscando nos frames ativos e a hurtbox parada no corpo. Nove entre dez bugs de "o golpe não acerta" ficam óbvios na primeira olhada: shape pequena demais, ativando tarde demais, ou no lado errado.
Fechando
Um sistema de combate corpo a corpo sólido é menos sobre código esperto e mais sobre separação de responsabilidades: hitbox causa, hurtbox recebe, animação dita o timing, personagem decide a reação. Com essa base, adicionar arma nova é criar uma hitbox com números diferentes, e adicionar inimigo novo é pendurar uma hurtbox nele.
Monte nessa ordem: primeiro um golpe único com as três fases, depois o dano e o knockback, depois o combo, e só então hitstop e polimento. Cada etapa roda e é testável sozinha. E teste apanhando também, não só batendo: combate bom é legível dos dois lados, o jogador precisa enxergar o startup do inimigo pra reagir. Esse é o detalhe que separa combate justo de combate que parece roubado.


