Como Fazer um Jogo de Luta na Unity: Hitbox e Hurtbox
Para fazer um jogo de luta na Unity que pareça justo, separe duas caixas: a hurtbox, que é o corpo que pode apanhar, e a hitbox, que só existe nos frames em que o golpe está ativo. Aqui você liga a hitbox por Animation Events em frames específicos da animação, detecta o acerto com Physics2D.OverlapBox filtrado por layer, aplica dano, hitstun e knockback, e coloca dois jogadores no mesmo PC com o Input System. Tudo em C# para o Unity 6.3 LTS.
O fio que costura tudo é a consistência: o golpe acerta no mesmo frame toda vez, porque a checagem roda em passo fixo e não depende de quando a física resolveu disparar um evento.
Hitbox, hurtbox e frames de ataque
Todo golpe de jogo de luta tem três fases:
- Startup: o personagem começa o movimento. Ainda não machuca.
- Active: os frames em que a hitbox existe e pode acertar.
- Recovery: o personagem volta à posição. Já não machuca, e está vulnerável.
Um soco fraco pode ter 4 frames de startup, 3 ativos e 8 de recovery; um chute forte, 12, 4 e 20. Esses números são o design do golpe. Rápido e seguro ou lento e arriscado é decidido aqui, não no dano.
Na Unity, isso vira: a animação do soco roda num ritmo conhecido (por exemplo 12 quadros por segundo), e dois eventos na timeline marcam o início e o fim da fase ativa. Entre eles, o script consulta a área da hitbox a cada passo de física.
A hurtbox é um BoxCollider2D no corpo do lutador, numa layer própria (Hurtbox). A hitbox não precisa de collider nenhum: é só um retângulo definido por offset e tamanho, testado com OverlapBox quando o golpe está ativo. Isso evita colliders ligando e desligando, que é fonte clássica de bug. Para o lado de design (frames, combos e como pensar o elenco), o post como fazer um jogo de luta aprofunda sem depender de engine.
Personagem com Animator e estados
Cada lutador tem: Rigidbody2D Dynamic (gravidade normal, Freeze Rotation Z), um collider de corpo para o chão, a hurtbox na layer Hurtbox, SpriteRenderer e Animator. No Animator Controller, crie os estados Idle, Walk, Punch, Kick e Hitstun, e os parâmetros Speed (float), Punch e Kick (trigger) e Hurt (trigger).
O estado lógico do lutador vive no script, não no Animator. O Animator só mostra. Essa separação evita a armadilha de perguntar ao Animator "estou atacando?" e receber uma resposta atrasada por causa de transições.
Arquivo FighterState.cs:
public enum FighterState
{
Idle,
Attacking,
Hitstun
}
Jogo de luta na Unity: hitbox via Animation Events
Animation Events chamam um método do script que está no mesmo GameObject do Animator. Abra a animação Punch na janela Window > Animation > Animation, posicione a agulha no primeiro frame ativo, clique em Add Event e escolha a função HitboxOn. No último frame ativo, adicione HitboxOff. Repita no chute com os frames dele.
Os dados de cada golpe ficam num ScriptableObject, para você ajustar sem mexer em código.
Arquivo AttackData.cs:
using UnityEngine;
[CreateAssetMenu(fileName = "Attack", menuName = "Fighting/Attack")]
public class AttackData : ScriptableObject
{
[SerializeField] private string animatorTrigger = "Punch";
[SerializeField] private Vector2 hitboxOffset = new Vector2(0.7f, 0.2f);
[SerializeField] private Vector2 hitboxSize = new Vector2(0.6f, 0.4f);
[SerializeField] private int damage = 8;
[SerializeField] private float hitstun = 0.3f;
[SerializeField] private Vector2 knockback = new Vector2(3f, 1f);
public string AnimatorTrigger => animatorTrigger;
public Vector2 HitboxOffset => hitboxOffset;
public Vector2 HitboxSize => hitboxSize;
public int Damage => damage;
public float Hitstun => hitstun;
public Vector2 Knockback => knockback;
}
Arquivo Fighter.cs:
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.InputSystem;
[RequireComponent(typeof(Rigidbody2D), typeof(Animator), typeof(PlayerInput))]
public class Fighter : MonoBehaviour
{
[SerializeField] private float walkSpeed = 3.5f;
[SerializeField] private int maxHealth = 100;
[SerializeField] private AttackData punch;
[SerializeField] private AttackData kick;
[SerializeField] private LayerMask hurtboxMask;
[SerializeField] private Collider2D ownHurtbox;
private Rigidbody2D rb;
private Animator animator;
private InputAction moveAction;
private InputAction punchAction;
private InputAction kickAction;
private FighterState state = FighterState.Idle;
private AttackData currentAttack;
private bool hitboxActive;
private float hitstunEnd;
private int health;
private ContactFilter2D hurtboxFilter;
private readonly List<Collider2D> overlapResults = new List<Collider2D>();
private readonly HashSet<Fighter> alreadyHit = new HashSet<Fighter>();
public int Health => health;
public int MaxHealth => maxHealth;
public bool FacingRight { get; set; } = true;
private void Awake()
{
rb = GetComponent<Rigidbody2D>();
animator = GetComponent<Animator>();
PlayerInput playerInput = GetComponent<PlayerInput>();
moveAction = playerInput.actions.FindAction("Move");
punchAction = playerInput.actions.FindAction("Punch");
kickAction = playerInput.actions.FindAction("Kick");
health = maxHealth;
hurtboxFilter = new ContactFilter2D();
hurtboxFilter.SetLayerMask(hurtboxMask);
hurtboxFilter.useTriggers = true;
}
private void Update()
{
if (state == FighterState.Hitstun && Time.time >= hitstunEnd)
{
state = FighterState.Idle;
}
if (state != FighterState.Idle) return;
if (punchAction.WasPressedThisFrame()) StartAttack(punch);
else if (kickAction.WasPressedThisFrame()) StartAttack(kick);
}
private void FixedUpdate()
{
if (state == FighterState.Idle)
{
float x = moveAction.ReadValue<Vector2>().x;
rb.linearVelocity = new Vector2(x * walkSpeed, rb.linearVelocity.y);
animator.SetFloat("Speed", Mathf.Abs(x));
}
else if (state == FighterState.Attacking)
{
rb.linearVelocity = new Vector2(0f, rb.linearVelocity.y);
}
if (hitboxActive)
{
CheckHitbox();
}
}
private void StartAttack(AttackData attack)
{
state = FighterState.Attacking;
currentAttack = attack;
alreadyHit.Clear();
animator.SetTrigger(attack.AnimatorTrigger);
}
public void HitboxOn()
{
hitboxActive = true;
}
public void HitboxOff()
{
hitboxActive = false;
}
public void AttackFinished()
{
hitboxActive = false;
if (state == FighterState.Attacking)
{
state = FighterState.Idle;
}
}
private Vector2 HitboxCenter()
{
Vector2 offset = currentAttack.HitboxOffset;
if (!FacingRight) offset.x = -offset.x;
return (Vector2)transform.position + offset;
}
private void CheckHitbox()
{
int count = Physics2D.OverlapBox(HitboxCenter(), currentAttack.HitboxSize, 0f, hurtboxFilter, overlapResults);
for (int i = 0; i < count; i++)
{
Collider2D hit = overlapResults[i];
if (hit == ownHurtbox) continue;
Fighter target = hit.GetComponentInParent<Fighter>();
if (target == null || target == this) continue;
if (!alreadyHit.Add(target)) continue;
float direction = FacingRight ? 1f : -1f;
target.ReceiveHit(currentAttack, direction);
}
}
public void ReceiveHit(AttackData attack, float direction)
{
health = Mathf.Max(0, health - attack.Damage);
state = FighterState.Hitstun;
hitstunEnd = Time.time + attack.Hitstun;
hitboxActive = false;
rb.linearVelocity = new Vector2(attack.Knockback.x * direction, attack.Knockback.y);
animator.SetTrigger("Hurt");
}
private void OnDrawGizmos()
{
if (currentAttack == null) return;
Gizmos.color = hitboxActive ? Color.red : new Color(1f, 0.5f, 0f, 0.3f);
Gizmos.DrawWireCube(HitboxCenter(), currentAttack.HitboxSize);
}
}
Um terceiro evento, AttackFinished, vai no último frame da animação de ataque. É ele que devolve o lutador ao estado Idle; sem isso, o personagem fica preso em Attacking.
Detectar golpe com OverlapBox e layers
O Physics2D.OverlapBox tem, no Unity 6.3, uma sobrecarga que recebe um ContactFilter2D e uma List<Collider2D> e devolve quantos colliders achou. A lista é reaproveitada e só cresce se precisar, então não gera lixo a cada chamada. O filtro com SetLayerMask garante que a hitbox só enxergue a layer Hurtbox: o chão, as paredes e o collider de corpo ficam de fora.
Por que no FixedUpdate e não num OnTriggerEnter2D? Porque o trigger avisa quando dois colliders começam a se sobrepor. Se a hitbox já nasce dentro do oponente (o que é comum em golpe de curta distância), o resultado depende da ordem em que a física processa as coisas. Consultar a área ativamente, em passo fixo de 50 vezes por segundo (o padrão de Fixed Timestep 0,02), dá o mesmo resultado em qualquer máquina e com qualquer frame rate.
Duas proteções no CheckHitbox: ignorar a própria hurtbox (ownHurtbox) e pegar o Fighter com GetComponentInParent, já que a hurtbox costuma ser filha do lutador.
Dano, hitstun e knockback
O ReceiveHit faz três coisas. Tira vida. Coloca o lutador em Hitstun por um tempo definido pelo golpe, durante o qual o Update não aceita input. E aplica knockback setando a velocidade na direção do golpe, com um pouco de impulso para cima.
Os números de hitstun definem o jogo. Se o soco tem recovery de 0,35 segundo e hitstun de 0,3, quem acertou fica 0,05 segundo em desvantagem: o oponente age primeiro. Se o hitstun for 0,45, quem acertou tem vantagem e pode encaixar outro soco. Isso é um combo, e nasceu de dois números no AttackData, sem nenhuma linha de "sistema de combo".
Para o acerto ter peso, acrescente um congelamento curto (hitstop): pausar o Animator dos dois lutadores por 0,05 a 0,08 segundo no momento do impacto. Esse tipo de detalhe é o assunto do post sobre game feel e juice, e num jogo de luta ele faz mais diferença que qualquer partícula.
Com isso você tem o núcleo de um jogo de luta: golpes com frames, detecção confiável e reação. O que vem depois é onde quem estuda sozinho costuma travar: dez golpes por personagem com frames ajustados, defesa, pulo com ataque aéreo, menu de seleção, rounds, som de impacto e um jogo que dá para mostrar para alguém. Cada camada pede decisão de arquitetura, e errar a ordem custa semanas de retrabalho.
Dois jogadores locais com Input System
Crie um Input Actions asset FighterControls com um mapa Fighter e as ações Move (Value, Vector2), Punch e Kick (Button). Adicione dois esquemas de controle: Gamepad (bindings do controle) e Keyboard. Arraste o asset para o campo Actions do componente PlayerInput no prefab do lutador.
Para dois controles, crie um objeto vazio com o componente PlayerInputManager:
- Join Behavior: Join Players When Button Is Pressed
- Player Prefab: o prefab do lutador
- Max Player Count: 2
Cada controle que apertar um botão gera um lutador pareado com aquele dispositivo. O Fighter lê as ações de playerInput.actions, que é uma cópia por jogador: o soco do jogador 1 nunca dispara no jogador 2.
Para dois jogadores no mesmo teclado, o caminho é dividir o teclado em dois esquemas (KeyboardLeft com WASD e F/G, KeyboardRight com setas e K/L) e criar os jogadores por código.
Arquivo LocalVersusSpawner.cs:
using UnityEngine;
using UnityEngine.InputSystem;
public class LocalVersusSpawner : MonoBehaviour
{
[SerializeField] private GameObject fighterPrefab;
[SerializeField] private Transform spawnLeft;
[SerializeField] private Transform spawnRight;
private void Start()
{
PlayerInput p1 = PlayerInput.Instantiate(fighterPrefab, controlScheme: "KeyboardLeft", pairWithDevice: Keyboard.current);
PlayerInput p2 = PlayerInput.Instantiate(fighterPrefab, controlScheme: "KeyboardRight", pairWithDevice: Keyboard.current);
p1.transform.position = spawnLeft.position;
p2.transform.position = spawnRight.position;
p2.GetComponent<Fighter>().FacingRight = false;
}
}
O lado para onde cada lutador olha deve ser atualizado a cada frame com base na posição do oponente (se o oponente está à direita, FacingRight = true), e o sprite é espelhado com SpriteRenderer.flipX. Guarde uma referência ao oponente quando os dois forem criados.
Visualizar caixas com Gizmos
O OnDrawGizmos do Fighter desenha a hitbox: vermelha quando ativa, laranja translúcida no resto do golpe. Ligue Gizmos no Game view (o botão no canto superior direito) e jogue em câmera lenta com Time.timeScale = 0.2f. Você vê exatamente em qual frame a caixa liga e se ela alcança o oponente.
Desenhe também a hurtbox (em verde, com Gizmos.DrawWireCube usando os bounds do collider). Ajustar alcance de golpe olhando as caixas é como os jogos de luta são balanceados: o número na tela vale mais que a sensação.
Erros comuns: golpe acertando duas vezes
- Golpe acerta várias vezes. A hitbox fica ativa por 3 ou 4 passos de física e cada passo detecta o oponente de novo. O
HashSet<Fighter> alreadyHit, limpo no início de cada ataque, garante um acerto por golpe. - Golpe acerta o próprio lutador. A hitbox sobrepõe a própria hurtbox. Compare com
ownHurtboxou com oFighterdono. - Lutador preso atacando. Faltou o evento
AttackFinishedno fim da animação, ou a transição do Animator sai antes do frame do evento. Eventos em frames que não chegam a tocar não disparam. - Hitbox do lado errado. O offset não foi espelhado quando o lutador vira. Multiplique o x por -1.
- Hitbox fica ligada depois de apanhar. Se o lutador leva um golpe no meio do próprio ataque, o
HitboxOffnunca roda. Por isso oReceiveHitdesliga a hitbox. - Checagem em
Update. O resultado muda com o frame rate. UseFixedUpdate.
Próximo passo
Acrescente defesa: segurar para trás durante o ataque do oponente reduz o dano a 10% e aplica um "blockstun" menor que o hitstun. Com isso, ataque, defesa e punição passam a existir e o jogo vira jogo. Depois, leia o guia de Animation Events na Unity para organizar eventos de som e partícula na mesma timeline, e o de game feel para o hitstop e o tremor de tela no impacto.
Perguntas frequentes
Qual a diferença entre hitbox e hurtbox?
Hurtbox é a área do corpo do personagem que pode ser atingida; ela existe o tempo todo. Hitbox é a área do golpe que causa dano, e só existe durante os frames ativos do ataque. Um golpe acerta quando a hitbox de um lutador sobrepõe a hurtbox do outro.
Devo usar OnTriggerEnter2D para golpes num jogo de luta?
Funciona em protótipo, mas dá menos controle. O trigger depende de quando a física detecta a entrada, e um golpe que já começa sobreposto pode não disparar como você espera. Consultar Physics2D.OverlapBox no FixedUpdate durante os frames ativos dá um resultado previsível e fácil de depurar.
Como fazer multiplayer local na Unity com dois controles?
Use o componente PlayerInputManager do Input System com um prefab de jogador que tenha PlayerInput. Com Join Behavior em Join Players When Button Is Pressed, cada controle que apertar um botão cria um jogador com seu próprio dispositivo. Para dois jogadores no mesmo teclado, crie dois esquemas de controle e instancie cada jogador pareado com o teclado e um esquema diferente.
O que é hitstun?
É o tempo em que o personagem atingido fica sem poder agir depois de levar um golpe. É o hitstun que permite combos: se o segundo golpe chega antes de o hitstun acabar, o oponente não consegue se defender. Ajustar esse número muda o ritmo inteiro do jogo.



