Voltar para o Blog
Quest Log

Combate por Turnos na Godot 4: Sistema de RPG Estilo JRPG com GDScript

Cena de uma batalha de RPG por turnos 2D, heróis de um lado e inimigos do outro

Monte um combate por turnos na Godot 4 com GDScript tipado: fila de turnos ordenada por velocidade, loop de ações, cálculo de dano e vitória.

Fazer um personagem andar e pular já saiu, e agora bate a vontade de construir aquele combate de RPG clássico: heróis de um lado, inimigos do outro, cada um esperando a sua vez de atacar. O combate por turnos que Pokémon e os Final Fantasy antigos usam parece simples de fora, mas trava muita gente na hora de programar, porque o desafio não está no visual, está na arquitetura. Quem representa um combatente? Quem decide a ordem das ações? Onde mora a regra de que o inimigo só age depois do herói? Este post monta esse sistema na Godot 4 com GDScript tipado, do zero até uma batalha funcional, focando na estrutura de verdade: uma classe para o combatente, uma fila de turnos ordenada por velocidade, um loop de turno e sinais avisando a interface. Nada de UI desenhada pixel a pixel, e sim o motor que faz o RPG de turnos rodar.

O que compõe um sistema de combate por turnos

Antes de escrever qualquer linha, vale separar as peças. Um combate por turnos na Godot é feito de quatro partes que conversam entre si:

  • O combatente. Um pacote de dados: pontos de vida, ataque, defesa, velocidade, nome. Herói e inimigo são o mesmo tipo de objeto, só mudam os números e quem controla a ação.
  • A fila de turnos. Uma lista com todos os combatentes vivos, ordenada por velocidade. Ela dita a ordem em que cada um age dentro da rodada.
  • O loop de turno. O ciclo que pega o próximo da fila, deixa ele escolher e executar uma ação, checa quem morreu e avança para o seguinte.
  • Os estados do combate. Turno do jogador, turno do inimigo, vitória, derrota. É o que impede o inimigo de agir enquanto o menu do herói está aberto.

A regra de ouro é separar dado de visual. A vida do herói é um número numa classe de dados, não uma propriedade do sprite. O sprite só reflete esse número na tela. Isso deixa a lógica testável sem abrir a janela do jogo e é o que permite ter cinco inimigos sem virar bagunça.

A classe Combatente com campos tipados

Começamos pela unidade básica. Um combatente precisa guardar seus atributos e saber responder a duas perguntas: está vivo? Quanto dano ele sofre? Como é só dado, sem precisar existir na árvore de cena, RefCounted é a base ideal. Ela é leve, é liberada da memória sozinha quando ninguém mais a referencia e não carrega o peso de um Node.

class_name Combatente
extends RefCounted

var nome: String = "Sem nome"
var hp_max: int = 30
var hp_atual: int = 30
var ataque: int = 10
var defesa: int = 5
var velocidade: int = 8
var controlado_pelo_jogador: bool = false

func _init(p_nome: String, p_hp: int, p_ataque: int, p_defesa: int, p_velocidade: int, p_jogador: bool = false) -> void:
    nome = p_nome
    hp_max = p_hp
    hp_atual = p_hp
    ataque = p_ataque
    defesa = p_defesa
    velocidade = p_velocidade
    controlado_pelo_jogador = p_jogador

func esta_vivo() -> bool:
    return hp_atual > 0

func receber_dano(quantidade: int) -> void:
    hp_atual = clampi(hp_atual - quantidade, 0, hp_max)

Repare em três decisões. Toda variável tem tipo explícito, então o autocomplete trabalha a seu favor e o erro de tipo aparece no editor, não em tempo de jogo. O class_name Combatente registra a classe globalmente, então você a usa como tipo em qualquer script. E receber_dano() usa clampi() para a vida nunca passar de zero nem estourar o máximo, o que evita hp negativo aparecendo na barra.

Criar um combatente fica direto e legível:

var heroi: Combatente = Combatente.new("Aria", 40, 12, 6, 10, true)
var goblin: Combatente = Combatente.new("Goblin", 22, 9, 3, 7, false)

O parâmetro final, true ou false, marca quem o jogador controla. É esse campo que o gerenciador vai ler para decidir se abre o menu de ações ou se deixa a IA agir.

O gerenciador de combate e a fila de turnos

Agora o cérebro do sistema. O gerenciador é um Node, porque ele vive na cena de batalha, emite sinais e coordena o fluxo. Ele guarda todos os combatentes, monta a fila ordenada por velocidade e controla de quem é a vez.

class_name GerenciadorCombate
extends Node

signal turno_iniciado(combatente: Combatente)
signal acao_executada(atacante: Combatente, alvo: Combatente, dano: int)
signal combate_terminou(jogador_venceu: bool)

var combatentes: Array[Combatente] = []
var fila_turnos: Array[Combatente] = []
var indice_atual: int = 0

func iniciar_combate(lista: Array[Combatente]) -> void:
    combatentes = lista
    _montar_fila()
    indice_atual = 0
    _iniciar_turno_atual()

func _montar_fila() -> void:
    fila_turnos = combatentes.filter(func(c: Combatente) -> bool: return c.esta_vivo())
    fila_turnos.sort_custom(func(a: Combatente, b: Combatente) -> bool: return a.velocidade > b.velocidade)

Três sinais no topo declaram tudo que o gerenciador conta para o mundo de fora: um turno começou, uma ação foi executada, o combate acabou. A UI e os efeitos visuais só conectam nesses sinais, nunca cutucam o gerenciador por dentro.

A fila nasce em _montar_fila(). Primeiro filter() joga fora quem já morreu, deixando só os vivos. Depois sort_custom() ordena por velocidade decrescente: o a.velocidade > b.velocidade garante que o mais rápido vem primeiro. Com o herói de velocidade 10 e o goblin de 7, a Aria age antes. Se você adicionar um segundo goblin de velocidade 12, ele passa a abrir a rodada, sem nenhuma outra mudança no código.

Uma observação honesta sobre empate: quando dois combatentes têm a mesma velocidade, sort_custom() mantém uma ordem estável baseada na posição original, então o resultado é previsível. Se quiser um desempate explícito por outro critério, é só estender a função de comparação.

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

O loop de turno: iniciar, executar, avançar

Com a fila pronta, o combate vira um ciclo. Pega o combatente do índice atual, anuncia que é a vez dele e espera a ação. Se for um herói, o gerenciador aguarda a escolha do jogador; se for um inimigo, a IA decide na hora.

func _iniciar_turno_atual() -> void:
    if _checar_fim_de_combate():
        return

    var atual: Combatente = fila_turnos[indice_atual]

    if not atual.esta_vivo():
        _avancar_turno()
        return

    turno_iniciado.emit(atual)

    if not atual.controlado_pelo_jogador:
        _agir_como_inimigo(atual)

Note que o gerenciador não pergunta à UI o que fazer. Ele emite turno_iniciado, e quem estiver ouvindo reage. Se o combatente da vez for um inimigo, o próprio gerenciador chama a rotina de IA. Se for um herói, ele para aqui e espera: a UI, ao receber o sinal, mostra o menu, e quando o jogador escolhe, a UI chama de volta a função de ação. O controle passa de mão sem nenhum dos lados conhecer os detalhes do outro.

A execução da ação é onde o dano acontece:

func executar_ataque(atacante: Combatente, alvo: Combatente) -> void:
    var dano: int = _calcular_dano(atacante, alvo)
    alvo.receber_dano(dano)
    acao_executada.emit(atacante, alvo, dano)
    _avancar_turno()

func _calcular_dano(atacante: Combatente, alvo: Combatente) -> int:
    var bruto: int = atacante.ataque - alvo.defesa
    return maxi(bruto, 1)

O cálculo de dano é de propósito simples e transparente: ataque do atacante menos defesa do alvo, com piso de 1 garantido por maxi(). Nenhum golpe causa zero, então o jogador nunca sente que atacou à toa, e um herói de ataque 12 contra um goblin de defesa 3 tira 9 de vida, um número fácil de balancear no papel. Depois de aplicar o dano, o gerenciador emite acao_executada (para a UI mostrar o número flutuante, tocar som, animar) e chama _avancar_turno().

Avançar o turno é caminhar na fila e, quando ela acaba, começar uma nova rodada:

func _avancar_turno() -> void:
    if _checar_fim_de_combate():
        return

    indice_atual += 1
    if indice_atual >= fila_turnos.size():
        _montar_fila()
        indice_atual = 0

    _iniciar_turno_atual()

Quando o índice passa do fim da fila, _montar_fila() roda de novo. Isso reordena por velocidade e, de quebra, remove quem morreu durante a rodada, então a próxima começa limpa e justa. É um detalhe pequeno com efeito grande: um inimigo derrotado no meio da rodada não ganha um turno fantasma.

A IA do inimigo e a seleção de alvo

O inimigo precisa escolher em quem bater. Uma IA mínima e honesta ataca um herói vivo qualquer, o que já é suficiente para uma primeira batalha funcionar:

func _agir_como_inimigo(inimigo: Combatente) -> void:
    var alvos: Array[Combatente] = fila_turnos.filter(
        func(c: Combatente) -> bool: return c.controlado_pelo_jogador and c.esta_vivo()
    )
    if alvos.is_empty():
        _avancar_turno()
        return

    var alvo: Combatente = alvos.pick_random()
    executar_ataque(inimigo, alvo)

O mesmo padrão de filter() seleciona só os heróis vivos como alvos possíveis, e pick_random() escolhe um. Trocar essa linha por uma que mira o herói de menor vida, ou o de menor defesa, é o caminho natural para uma IA mais esperta depois. Para o lado do jogador, a seleção de alvo é a UI mostrando os inimigos e chamando executar_ataque(heroi, inimigo_escolhido) quando o jogador clica. Com um único inimigo, você pode até pular a escolha e mirar automaticamente; com vários, o menu de alvos vira necessário, e a arquitetura já suporta isso porque executar_ataque() recebe qualquer alvo.

Vitória e derrota

O combate acaba quando um lado inteiro cai. A checagem é uma varredura simples que roda antes de cada turno:

func _checar_fim_de_combate() -> bool:
    var herois_vivos: bool = combatentes.any(
        func(c: Combatente) -> bool: return c.controlado_pelo_jogador and c.esta_vivo()
    )
    var inimigos_vivos: bool = combatentes.any(
        func(c: Combatente) -> bool: return not c.controlado_pelo_jogador and c.esta_vivo()
    )

    if not inimigos_vivos:
        combate_terminou.emit(true)
        return true
    if not herois_vivos:
        combate_terminou.emit(false)
        return true
    return false

any() responde com um booleano à pergunta "sobrou alguém vivo deste lado?". Se nenhum inimigo respira, o jogador venceu; se nenhum herói respira, perdeu. Nos dois casos o gerenciador emite combate_terminou com o resultado e retorna true, o que corta o loop de turno na raiz. A tela de vitória, a distribuição de experiência ou o game over ficam do lado de quem escuta o sinal, fora do gerenciador.

Como a interface se conecta

O ponto que amarra tudo é o desacoplamento por sinais. A cena de batalha instancia o gerenciador e conecta os três sinais, e cada um dispara uma reação na UI:

func _ready() -> void:
    var gerenciador: GerenciadorCombate = GerenciadorCombate.new()
    add_child(gerenciador)
    gerenciador.turno_iniciado.connect(_on_turno_iniciado)
    gerenciador.acao_executada.connect(_on_acao_executada)
    gerenciador.combate_terminou.connect(_on_combate_terminou)

    var equipe: Array[Combatente] = [
        Combatente.new("Aria", 40, 12, 6, 10, true),
        Combatente.new("Goblin", 22, 9, 3, 7, false),
        Combatente.new("Goblin Arqueiro", 18, 11, 2, 12, false),
    ]
    gerenciador.iniciar_combate(equipe)

func _on_turno_iniciado(combatente: Combatente) -> void:
    if combatente.controlado_pelo_jogador:
        _mostrar_menu_de_acoes(combatente)
    else:
        _esconder_menu_de_acoes()

_on_turno_iniciado() é o coração da separação entre turno do jogador e turno do inimigo. Ao receber o combatente da vez, a UI lê controlado_pelo_jogador: se for herói, abre o menu; se for inimigo, esconde e deixa o gerenciador tocar a IA. É assim que a interface sabe de quem é a vez sem nunca perguntar. Ela reage ao sinal. Se você já organizou fluxo de jogo com uma máquina de estados para organizar o fluxo, vai reconhecer o padrão: os estados de combate podem virar uma FSM formal quando o sistema crescer, com estados como Selecionando Ação, Executando e Fim.

O _on_acao_executada(atacante, alvo, dano) é onde entram os efeitos: número de dano flutuante sobre o alvo, animação de ataque, atualização da barra de vida. E _on_combate_terminou(jogador_venceu) mostra a tela final. Nenhuma dessas reações vive dentro do gerenciador, o que mantém a lógica pura e testável e a apresentação livre para mudar.

Esse esqueleto é a base do gênero, e daqui os caminhos se abrem. Se o seu projeto vai além da batalha e você quer montar o RPG completo, com mapa, diálogo e progressão, o guia de como fazer um jogo de RPG mostra o quadro maior. E se o combate por turnos era um passo antes de partir para ação em tempo real, vale ver como funciona um combate corpo a corpo em tempo real, que troca a fila de turnos por hitboxes e timing.

Onde evoluir a partir daqui

O sistema que você montou já roda uma batalha do início ao fim: combatentes tipados, fila por velocidade, loop de turno, dano com piso, vitória e derrota, tudo comunicando com a UI por sinais. A partir dessa base, as extensões são todas encaixes, não reescritas. Habilidades e magias entram como novas ações que o gerenciador executa, cada uma com o próprio efeito além do dano cru. Custo de mana vira mais um campo tipado no Combatente. Status como veneno ou defesa dobrada viram efeitos que rodam no começo do turno de cada um. Itens de cura chamam um receber_dano() com valor negativo, ou melhor, um curar() gêmeo.

O que segura tudo em pé é a decisão do começo: dado separado de visual, gerenciador falando por sinais. Enquanto a regra de combate morar em números numa classe limpa e a tela só refletir esses números, seu RPG de turnos cresce sem virar um nó cego de dependências. Abra a Godot, cole as classes, monte uma cena com um Node de batalha e veja a Aria trocar golpes com os goblins no console. É um combate por turnos de verdade, e é seu para expandir.

Perguntas frequentes

Como o sistema decide quem age primeiro no turno?

Pela velocidade de cada combatente. No início da rodada você monta uma fila com todos os vivos e ordena de forma decrescente pelo campo velocidade: quem tem o valor maior age antes. Em GDScript isso é uma linha com sort_custom(). Se dois combatentes empatam na velocidade, você desempata com um critério fixo, por exemplo a ordem original do array, para o resultado ser sempre determinístico e não depender de sorte.

Preciso de TileMap ou de um nó separado para cada combatente?

Combate por turnos clássico estilo JRPG não usa TileMap nem grade. Cada combatente vira um nó na cena (um Sprite2D ou uma cena própria) posicionado à mão: heróis de um lado, inimigos do outro. A lógica de dado (hp, ataque, defesa) mora numa classe de dados separada da parte visual, então o gerenciador de combate trabalha com números e o nó só reflete o estado na tela. TileMap só entra em jogos de turno tático, tipo Fire Emblem, onde a posição na grade importa.

Como calcular o dano de forma justa?

A fórmula mais honesta e legível é dano = ataque do atacante menos defesa do alvo, com um piso mínimo de 1 para nenhum golpe zerar. Ela é fácil de balancear porque a relação entre os números é direta: dobrar o ataque tem efeito previsível. Você pode somar uma pequena variação aleatória (mais ou menos 10 por cento) para o combate não ficar mecânico, mas mantenha o piso de 1 para o jogador nunca sentir que um ataque foi inútil.

Dá para ter mais de um inimigo na batalha?

Sim, e a arquitetura já nasce pronta para isso. Como a fila de turnos é só um array de combatentes, adicionar um segundo ou terceiro inimigo é colocar mais itens no array antes de ordenar por velocidade. O loop de turno não muda: ele avança combatente a combatente, seja herói ou inimigo. O que você acrescenta é a seleção de alvo, porque com vários inimigos o jogador precisa escolher em quem bater.

Como a interface sabe de quem é a vez?

Por sinais. O gerenciador de combate emite um signal como turno_iniciado(combatente) toda vez que avança o turno, e a UI conecta nesse sinal. Quando o combatente da vez é um herói, a interface mostra o menu de ações; quando é um inimigo, ela esconde o menu e deixa a IA agir. A UI nunca pergunta ao gerenciador de quem é a vez, ela reage ao sinal, o que mantém a lógica de combate e a interface desacopladas.