Voltar para o Blog
Quest Log

Boss Fight na Godot: Chefe 2D com Fases e Barra de Vida

Pixel art de um herói pequeno enfrentando um chefe gigante blindado em arena 2D, com barra de vida do boss no topo da tela

Godot boss fight na prática: monte um chefe 2D com state machine tipada, fases por limiar de vida, telegraphs e barra de vida na HUD em GDScript real.

Godot boss fight é daqueles assuntos que parecem grandes demais até você quebrar o problema em pedaços. Um chefe 2D decente se resume a quatro sistemas conversando: uma state machine que decide o que o boss faz, um controle de fases ligado à vida, ataques com telegraph e cooldown, e uma barra de vida na HUD que reage a sinais. Neste tutorial você monta os quatro na Godot 4, com GDScript tipado e API real, do primeiro nó até o chefe morrendo em cena.

Se você nunca estruturou uma máquina de estados antes, vale ler state machine na Godot depois deste post. Aqui a gente usa a versão mais enxuta possível: enum e match.

Anatomia de um boss: o que ele precisa ter

Antes do código, o modelo mental. Todo boss fight que funciona tem três ingredientes:

  1. Vida em fases. A barra do chefe não é uma barra só, é uma sequência de limiares. Ao cruzar 60% e 30% de vida, o boss muda de comportamento. Isso mantém a luta interessante do começo ao fim.
  2. Padrões de ataque com telegraph. Cada ataque tem um aviso legível (a animação de antecipação), o golpe em si, e uma janela de recuperação onde o jogador pode punir. Sem telegraph, o dano parece injusto. Sem janela de punição, a luta vira espera.
  3. Feedback claro. Barra de vida visível, reação de stagger quando toma dano pesado, uma transição de fase dramática. O jogador precisa sentir que está progredindo.

O design fino de cada padrão é assunto para design de boss fight. Aqui o foco é a implementação.

Boss fight na Godot: estrutura de nós

A cena do chefe usa nós que você já conhece de qualquer inimigo, só que organizados com mais cuidado:

Boss (CharacterBody2D)
├── Sprite2D
├── CollisionShape2D
├── AnimationPlayer
├── AttackCooldown (Timer, one_shot = true)
└── Hitbox (Area2D)
    └── CollisionShape2D

Papel de cada um:

  • CharacterBody2D: corpo do boss, movimento via move_and_slide().
  • AnimationPlayer: toca idle, ataques, stagger, transição de fase e morte. As animações de ataque também ligam e desligam a hitbox no frame certo, usando keyframes na propriedade monitoring.
  • AttackCooldown (Timer): controla o intervalo entre ataques. one_shot = true, porque o boss só rearma o cooldown quando volta ao estado IDLE.
  • Hitbox (Area2D): a área que causa dano no jogador. Fica desligada por padrão e só liga durante os frames ativos do golpe.

State machine simples com enum e match

Um boss com seis estados não precisa de plugin nem de nós extras. Um enum e um match no próprio script dão conta e ficam fáceis de depurar. Este é o esqueleto do boss.gd:

extends CharacterBody2D
class_name Boss

signal health_changed(current: int, max_health: int)
signal phase_changed(phase: int)
signal died

enum State { IDLE, ATTACK_1, ATTACK_2, STAGGER, PHASE_TRANSITION, DEAD }

const PHASE_THRESHOLDS: Array[float] = [0.6, 0.3]

@export var max_health: int = 300
@export var move_speed: float = 80.0
@export var stagger_threshold: int = 40

var health: int = 0
var current_state: State = State.IDLE
var current_phase: int = 1
var active: bool = false

@onready var anim: AnimationPlayer = $AnimationPlayer
@onready var attack_cooldown: Timer = $AttackCooldown
@onready var hitbox: Area2D = $Hitbox
@onready var player: Node2D = get_tree().get_first_node_in_group("player")

func _ready() -> void:
    health = max_health
    hitbox.monitoring = false
    anim.animation_finished.connect(_on_animation_finished)
    attack_cooldown.timeout.connect(_on_attack_cooldown_timeout)

Repare nos três sinais tipados no topo. Eles são o contrato do boss com o resto do jogo: a HUD escuta health_changed e died, a arena pode escutar phase_changed para tremer a tela ou trocar a música.

O coração da state machine fica no _physics_process:

func _physics_process(_delta: float) -> void:
    if not active:
        return
    match current_state:
        State.IDLE:
            _chase_player()
        State.ATTACK_1, State.ATTACK_2, State.STAGGER, State.PHASE_TRANSITION, State.DEAD:
            velocity = Vector2.ZERO
    move_and_slide()

func _chase_player() -> void:
    if player == null:
        return
    var direction: Vector2 = global_position.direction_to(player.global_position)
    velocity.x = direction.x * move_speed

E as trocas de estado passam todas por uma função só, que centraliza animação e efeitos colaterais:

func _change_state(new_state: State) -> void:
    if current_state == State.DEAD:
        return
    current_state = new_state
    match new_state:
        State.IDLE:
            anim.play("idle")
            attack_cooldown.start()
        State.ATTACK_1:
            anim.play("attack_slash")
        State.ATTACK_2:
            anim.play("attack_slam")
        State.STAGGER:
            anim.play("stagger")
        State.PHASE_TRANSITION:
            anim.play("phase_roar")
            phase_changed.emit(current_phase)
            _apply_phase_stats()
        State.DEAD:
            anim.play("death")
            hitbox.set_deferred("monitoring", false)
            died.emit()

Quando uma animação de ação termina, o boss volta para IDLE e o cooldown rearma sozinho, porque _change_state(State.IDLE) chama attack_cooldown.start():

func _on_animation_finished(anim_name: StringName) -> void:
    match anim_name:
        &"attack_slash", &"attack_slam", &"stagger", &"phase_roar":
            _change_state(State.IDLE)

Esse fluxo cria de graça a janela de punição: entre o fim do golpe e o próximo ataque existe um intervalo em que o boss só anda. É aí que o jogador entra e bate.

Próximo nível
Quer aprender isso na prática?

No CursoGame.Dev você sai dos tutoriais soltos e constrói jogos publicáveis, com trilha progressiva, quests práticas e feedback real.

Conhecer a plataforma
+500 alunos4.9/5Garantia 7 dias

Escolhendo o ataque: Timer e sorteio por fase

O AttackCooldown dispara a decisão. Enquanto o boss está em IDLE e o timer estoura, ele sorteia um padrão. O truque para a luta evoluir é condicionar padrões novos à fase atual:

func _on_attack_cooldown_timeout() -> void:
    if current_state != State.IDLE:
        return
    var roll: float = randf()
    if current_phase >= 2 and roll < 0.4:
        _change_state(State.ATTACK_2)
    else:
        _change_state(State.ATTACK_1)

Na fase 1 o boss só usa o golpe básico. Da fase 2 em diante, 40% dos ataques viram o attack_slam, um padrão que o jogador ainda não viu. Isso é mais barato do que criar um boss novo e tem quase o mesmo efeito na percepção do jogador.

O telegraph mora dentro da animação: os primeiros frames de attack_slash e attack_slam são só antecipação (o boss levanta a arma, brilha, o que fizer sentido no seu jogo). A hitbox liga por keyframe apenas nos frames ativos do golpe. Assim o aviso e o dano nunca dessincronizam, porque vivem na mesma timeline do AnimationPlayer.

O dano no jogador sai da hitbox:

func _on_hitbox_body_entered(body: Node2D) -> void:
    if body.has_method("take_damage"):
        body.take_damage(25)

Conecte o sinal body_entered da Area2D a esse método pelo editor ou no _ready.

Troca de fase ao cruzar limiar de vida

Todo dano passa por take_damage, e é ali que o boss verifica morte, stagger e fase, nessa ordem:

func take_damage(amount: int) -> void:
    if current_state == State.DEAD or not active:
        return
    health = maxi(health - amount, 0)
    health_changed.emit(health, max_health)
    if health == 0:
        _change_state(State.DEAD)
        return
    if _check_phase():
        return
    if amount >= stagger_threshold and current_state == State.IDLE:
        _change_state(State.STAGGER)

A checagem de fase compara a vida percentual com os limiares definidos na constante PHASE_THRESHOLDS:

func _check_phase() -> bool:
    var ratio: float = float(health) / float(max_health)
    var target_phase: int = 1
    for threshold: float in PHASE_THRESHOLDS:
        if ratio <= threshold:
            target_phase += 1
    if target_phase > current_phase:
        current_phase = target_phase
        _change_state(State.PHASE_TRANSITION)
        return true
    return false

func _apply_phase_stats() -> void:
    match current_phase:
        2:
            move_speed = 110.0
            attack_cooldown.wait_time = 1.6
        3:
            move_speed = 140.0
            attack_cooldown.wait_time = 1.1

Dois detalhes importantes aqui. Primeiro, a fase só anda para frente: target_phase > current_phase garante que cura ou dano estranho nunca façam o boss regredir. Segundo, o estado PHASE_TRANSITION toca uma animação de rugido em que o boss fica parado. Essa pausa não é enfeite: é uma janela segura para o jogador respirar, reposicionar e entender que a regra do jogo mudou. O for threshold: float in tipado pede Godot 4.2 ou mais nova; em versões 4.x anteriores, troque por for threshold in PHASE_THRESHOLDS: que o comportamento é o mesmo.

Repare que _apply_phase_stats mexe em duas coisas: velocidade e cooldown. Combinado com o sorteio que libera ATTACK_2 na fase 2, cada fase fica visivelmente diferente sem uma linha sequer de conteúdo novo além da animação do golpe extra.

Barra de vida do boss na HUD

A barra do chefe vive em um CanvasLayer separado, com uma ProgressBar e um Label para o nome. Nada dentro da cena do boss: quem liga as duas pontas são os sinais. Se você ainda não montou uma HUD na Godot, o passo a passo completo está em HUD de vida e munição na Godot.

BossHud (CanvasLayer)
└── BossBar (ProgressBar)
    └── BossName (Label)

O script da HUD só conhece o contrato do boss, não a implementação:

extends CanvasLayer
class_name BossHud

@onready var boss_bar: ProgressBar = $BossBar
@onready var boss_name: Label = $BossBar/BossName

func _ready() -> void:
    boss_bar.visible = false

func bind_boss(boss: Boss, display_name: String) -> void:
    boss_name.text = display_name
    boss_bar.max_value = float(boss.max_health)
    boss_bar.value = float(boss.max_health)
    boss.health_changed.connect(_on_boss_health_changed)
    boss.died.connect(_on_boss_died)
    boss_bar.visible = true

func _on_boss_health_changed(current: int, max_health: int) -> void:
    boss_bar.max_value = float(max_health)
    boss_bar.value = float(current)

func _on_boss_died() -> void:
    boss_bar.visible = false

Falta o gatilho da arena. Uma Area2D na entrada acorda o boss, mostra a barra e se desliga para nunca disparar de novo:

extends Area2D
class_name ArenaTrigger

@export var boss: Boss
@export var hud: BossHud

func _ready() -> void:
    body_entered.connect(_on_body_entered)

func _on_body_entered(body: Node2D) -> void:
    if not body.is_in_group("player"):
        return
    hud.bind_boss(boss, "Guardião da Forja")
    boss.active = true
    boss.attack_cooldown.start()
    set_deferred("monitoring", false)

Arraste o boss e a HUD para os campos exportados no inspetor e pronto: o jogador cruza a porta, a barra aparece cheia, o chefe liga. Quando died é emitido, a barra some sozinha. Se quiser mais drama, troque o visible = true por um tween de fade na modulação da barra.

Dicas de design rápidas

O código acima é o esqueleto. O que separa um boss ok de um boss memorável são decisões de design baratas de implementar:

  • Telegraph legível vem antes de telegraph bonito. O jogador precisa distinguir attack_slash de attack_slam no primeiro frame de antecipação. Silhueta e cor diferentes resolvem mais que efeito de partícula.
  • Puna a ganância, não a presença. A janela após o golpe existe para o jogador atacar. Se ele ficar grudado tempo demais, o próximo ataque chega. É o cooldown fazendo o trabalho de disciplina, sem dano surpresa.
  • Fase final mais rápida, não mais esponja. Resista à tentação de inflar max_health. No exemplo, a fase 3 corta o cooldown quase pela metade e sobe a velocidade. A luta termina intensa e curta, que é como boss fight boa termina.
  • Stagger com moderação. O limiar de 40 de dano recompensa golpes carregados sem deixar o jogador travar o boss em stun infinito, porque o stagger só entra a partir do estado IDLE.
  • Teste a luta sem HUD. Se você consegue ler as fases do boss só pelo comportamento dele, o design está certo. A barra confirma, não substitui.

Próximos passos

Você tem um chefe 2D funcional: state machine com enum e match, três fases por limiar de vida, dois padrões de ataque com telegraph e uma barra de vida reativa por sinais. A partir daqui, as extensões naturais são adicionar um terceiro padrão exclusivo da fase 3, invocar adds durante a PHASE_TRANSITION e trocar a camada da música a cada phase_changed.

Se a state machine começar a crescer demais (boss com dez estados, sub-fases, ataques encadeados), aí sim vale migrar o enum para uma arquitetura de estados em nós ou classes. Até lá, o match resolve, compila rápido e cabe inteiro na sua cabeça, que é onde um boss fight precisa caber primeiro.

Perguntas frequentes

Como fazer um boss fight na Godot 4?

Use um CharacterBody2D com uma state machine baseada em enum e match, um AnimationPlayer para os ataques e telegraphs, um Timer para o cooldown entre ataques e sinais tipados para avisar a HUD sobre vida e troca de fase.

Como criar fases em um chefe na Godot?

Defina limiares de vida (por exemplo 60% e 30%). Sempre que o boss tomar dano, compare a vida atual com os limiares. Ao cruzar um deles, entre em um estado PHASE_TRANSITION, emita um sinal phase_changed e ajuste velocidade, cooldown e padrões de ataque.

Como mostrar a barra de vida do boss na HUD?

Coloque uma ProgressBar em um CanvasLayer, conecte-a ao sinal health_changed do boss e atualize value e max_value no callback. Mostre a barra quando o jogador entra na arena e esconda quando o boss morre.

Preciso de plugin de state machine para fazer um boss?

Não. Para um boss com meia dúzia de estados, um enum com match dentro do próprio script resolve bem e é mais fácil de depurar. Plugins e state machines em nós valem a pena quando o projeto cresce e vários inimigos compartilham estados.

O que é telegraph em um boss fight?

É o aviso visual ou sonoro que antecede o ataque: uma animação de antecipação, um brilho, um som. Ele existe para o jogador reagir. Na Godot, o telegraph é o começo da animação de ataque, antes do frame que liga a hitbox.

A fase final do boss deve ter mais vida?

Evite. Fase final com barra gigante vira esponja de dano e cansa. O padrão que funciona é a fase final ser mais rápida e perigosa, com menos vida proporcional, para o fim da luta ser o momento mais tenso e mais curto.