Voltar para o Blog
Quest Log

Godot: Sistema de Status Effects (Buff e Debuff) do Zero

Personagem de jogo com efeitos de status buff e debuff no Godot

Status effects no Godot 4 do zero: monte veneno, queimadura, atordoamento e buffs de ataque com Resource tipado e um node gerenciador em GDScript.

Godot: Sistema de Status Effects (Buff e Debuff) do Zero

Veneno que drena vida a cada segundo, queimadura que empilha, atordoamento que trava o controle, um buff que aumenta seu ataque por dez segundos. Isso e a espinha dorsal de quase todo RPG, roguelike ou jogo de combate. Montar status effects no Godot 4 do zero nao e difícil, mas quase todo mundo comeca escrevendo um bool esta_envenenado no personagem e, três efeitos depois, tem um script cheio de flags que ninguem entende. Neste tutorial a gente resolve isso direito: um Resource tipado que descreve cada efeito (buff ou debuff) e um node gerenciador que aplica, processa e remove tudo sozinho.

A ideia central e a mesma que sustenta código limpo em qualquer sistema de jogo: separar o que um efeito e do que o efeito faz. O "e" (nome, duracao, dano por tick, se empilha) vira dado num Resource. O "faz" (contar tempo, aplicar dano periodico, avisar quando acabou) vira lógica num node. Com essa divisao, adicionar um novo efeito e criar um arquivo, nao reescrever o personagem.

Definindo o efeito com um Resource tipado

Comece pela definicao. Um status effect no Godot funciona muito bem como Resource, porque e dado puro, editável no Inspector e salvável em .tres. Repare que tudo e tipado, do enum ao array.

class_name StatusEffect
extends Resource

enum Tipo { BUFF, DEBUFF }

@export var id: StringName = &""
@export var nome: String = ""
@export var tipo: Tipo = Tipo.DEBUFF

## Quanto tempo o efeito dura, em segundos.
@export var duracao: float = 5.0

## Dano (positivo) ou cura (negativo) aplicado a cada tick.
@export var dano_por_tick: int = 0

## Intervalo entre ticks, em segundos. Use 0 para efeitos sem tick.
@export var intervalo_tick: float = 1.0

## Se true, aplicar de novo soma intensidade. Se false, so renova a duracao.
@export var empilha: bool = false

## Limite de stacks quando empilha e true.
@export var max_stacks: int = 5

## Modificadores de atributo enquanto o efeito estiver ativo.
@export var mod_ataque: int = 0
@export var mod_velocidade: float = 0.0

## Se true, trava o controle do personagem (atordoamento, congelamento).
@export var atordoa: bool = false

Cada campo é uma decisao de design que vira um slider no Inspector. Um veneno é tipo = DEBUFF, dano_por_tick = 3, intervalo_tick = 1.0, empilha = true. Um buff de fúria é tipo = BUFF, dano_por_tick = 0, mod_ataque = 10, empilha = false. Um atordoamento é atordoa = true, duracao = 1.5, e nada mais. O mesmo Resource cobre os três porque descreve dado, nao comportamento.

Salve o script, clique com o botao direito numa pasta no FileSystem, escolha Create New > Resource, busque StatusEffect e preencha os valores. Cada .tres é um efeito pronto para arrastar em qualquer lugar do projeto.

O estado de runtime: uma instancia ativa do efeito

Aqui entra a pegadinha mais comum de quem usa Resource: ele é compartilhado por referencia. Se voce contar a duracao dentro do próprio StatusEffect, todos os personagens que usam veneno.tres compartilham o mesmo contador, e o sistema quebra silenciosamente. A regra é: a definicao nunca guarda estado de runtime. O tempo restante e o número de stacks vivem numa classe separada.

class_name EfeitoAtivo
extends RefCounted

var dados: StatusEffect          # referencia compartilhada, so leitura
var tempo_restante: float = 0.0  # estado proprio desta instancia
var acumulador_tick: float = 0.0
var stacks: int = 1

func _init(p_dados: StatusEffect) -> void:
    dados = p_dados
    tempo_restante = p_dados.duracao

O EfeitoAtivo estende RefCounted, entao nao precisa de queue_free() nem entra na árvore de cena. Ele guarda o que muda quadro a quadro (tempo restante, acumulador do tick, stacks) e aponta para o dados que nunca muda. É o mesmo padrao definicao mais estado que sustenta inventário e qualquer sistema data-driven no Godot.

O node gerenciador: aplicar, processar e remover

Agora o coracao do sistema. O StatusEffectManager é um Node filho do personagem. Ele guarda os efeitos ativos, processa duracao e tick a cada frame no _process, cuida de empilhar contra renovar, remove ao expirar e emite sinais para o resto do jogo reagir.

class_name StatusEffectManager
extends Node

signal efeito_aplicado(efeito: EfeitoAtivo)
signal efeito_removido(dados: StatusEffect)
signal tick_aplicado(dados: StatusEffect, dano: int)

var _ativos: Array[EfeitoAtivo] = []

func aplicar(dados: StatusEffect) -> void:
    var existente: EfeitoAtivo = _buscar(dados.id)
    if existente != null:
        if dados.empilha and existente.stacks < dados.max_stacks:
            existente.stacks += 1
        # Empilhando ou nao, aplicar de novo sempre renova a duracao.
        existente.tempo_restante = dados.duracao
        return
    var novo := EfeitoAtivo.new(dados)
    _ativos.append(novo)
    efeito_aplicado.emit(novo)

func _buscar(id: StringName) -> EfeitoAtivo:
    for efeito in _ativos:
        if efeito.dados.id == id:
            return efeito
    return null

O método aplicar é onde stack e renovacao acontecem. Se o efeito ainda nao existe, cria um EfeitoAtivo e emite o sinal. Se ja existe e o Resource permite empilhar, soma um stack (respeitando o max_stacks) e reinicia a duracao. Se ja existe e nao empilha, so renova o tempo. Um único if resolve os três casos que quase todo mundo trata com código duplicado.

Falta o loop que faz o tempo andar. Ele roda todo frame, desconta o delta da duracao, aplica dano por tick no intervalo certo e remove o que expirou.

func _process(delta: float) -> void:
    # Itera de tras para frente porque removemos itens durante o loop.
    for i in range(_ativos.size() - 1, -1, -1):
        var efeito: EfeitoAtivo = _ativos[i]
        _processar_tick(efeito, delta)
        efeito.tempo_restante -= delta
        if efeito.tempo_restante <= 0.0:
            _remover_no_indice(i)

func _processar_tick(efeito: EfeitoAtivo, delta: float) -> void:
    if efeito.dados.intervalo_tick <= 0.0 or efeito.dados.dano_por_tick == 0:
        return
    efeito.acumulador_tick += delta
    while efeito.acumulador_tick >= efeito.dados.intervalo_tick:
        efeito.acumulador_tick -= efeito.dados.intervalo_tick
        var dano: int = efeito.dados.dano_por_tick * efeito.stacks
        tick_aplicado.emit(efeito.dados, dano)

func _remover_no_indice(indice: int) -> void:
    var dados: StatusEffect = _ativos[indice].dados
    _ativos.remove_at(indice)
    efeito_removido.emit(dados)

Dois detalhes valem atencao. O loop itera de trás para frente (_ativos.size() - 1 até 0) porque removemos itens dentro dele, e remover de um array enquanto avança para frente pula elementos. E o tick usa um acumulador de delta, nao um Timer: o while cobre o caso raro de um frame gigante que passa dois intervalos de uma vez, sem perder ticks. Repare que o dano do tick é multiplicado por stacks, entao dois venenos empilhados doem o dobro sem código extra.

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

Integrando com a vida do personagem

O gerenciador nunca toca na vida direto. Ele avisa por sinal, e o personagem decide o que fazer. Esse desacoplamento é o que deixa o sistema reutilizável: o mesmo StatusEffectManager serve para o player, para inimigos e para NPCs, cada um reagindo do seu jeito.

extends CharacterBody2D

@export var vida_maxima: int = 100
@export var ataque_base: int = 10
@export var velocidade_base: float = 200.0

var vida: int = 100

@onready var status: StatusEffectManager = $StatusEffectManager

func _ready() -> void:
    vida = vida_maxima
    status.tick_aplicado.connect(_on_tick_aplicado)

func _on_tick_aplicado(dados: StatusEffect, dano: int) -> void:
    # dano positivo tira vida (veneno), negativo cura (regeneracao).
    vida = clampi(vida - dano, 0, vida_maxima)
    if vida <= 0:
        morrer()

Do lado do dano por tick, o personagem só assina tick_aplicado e ajusta a vida. Um dano_por_tick positivo drena (veneno, queimadura), negativo cura (um buff de regeneracao). O clampi evita vida negativa ou passar do máximo. Se a fonte do veneno for um golpe do seu sistema de combate corpo a corpo, basta chamar alvo.status.aplicar(veneno) no momento do acerto, e todo o resto acontece sozinho.

Para buffs e debuffs que alteram atributos (ataque, velocidade), nao escreva no valor base. Guarde o base separado e recalcule o valor final somando os modificadores dos efeitos ativos. Assim, quando o buff expira, o atributo volta sozinho, porque o modificador simplesmente deixou de existir no cálculo.

func ataque_atual() -> int:
    var total: int = ataque_base
    for efeito in status.get_ativos():
        total += efeito.dados.mod_ataque * efeito.stacks
    return total

func velocidade_atual() -> float:
    var total: float = velocidade_base
    for efeito in status.get_ativos():
        total += efeito.dados.mod_velocidade
    return total

Para isso funcionar, exponha os ativos no gerenciador com um getter simples que devolve a lista sem deixar ninguem mexer nela por engano:

func get_ativos() -> Array[EfeitoAtivo]:
    return _ativos

Atordoamento: status effects no Godot que travam o controle

Nem todo status effect no Godot é dano ou modificador. O atordoamento, o congelamento e a paralisia bloqueiam a acao do personagem. Como o StatusEffect ja tem o campo atordoa, o personagem só precisa perguntar ao gerenciador se algum efeito ativo trava o controle antes de ler o input.

func esta_atordoado() -> bool:
    for efeito in status.get_ativos():
        if efeito.dados.atordoa:
            return true
    return false

func _physics_process(delta: float) -> void:
    if esta_atordoado():
        velocity = Vector2.ZERO
        move_and_slide()
        return
    # aqui entra seu movimento normal de input
    velocity.x = Input.get_axis("esquerda", "direita") * velocidade_atual()
    move_and_slide()

Enquanto houver um efeito com atordoa = true na lista, o input é ignorado. Quando o atordoamento expira (o _process do gerenciador remove ele e emite efeito_removido), esta_atordoado() volta a retornar false e o controle retorna sozinho. Esse tipo de troca de comportamento, controlável contra travado, é justamente o que uma maquina de estados (state machine) no Godot organiza com elegancia: em vez de espalhar if por vários métodos, o atordoamento vira um estado próprio que ignora input, e o gerenciador de status apenas dispara a transicao.

Aplicando e testando o sistema

Com as pecas no lugar, usar é trivial. Crie os .tres dos efeitos, referencie no código e chame aplicar.

@export var veneno: StatusEffect
@export var furia: StatusEffect

func _ready() -> void:
    status.aplicar(veneno)   # comeca a drenar vida por tick
    status.aplicar(furia)    # aumenta o ataque enquanto durar
    status.aplicar(veneno)   # empilha: agora o veneno doi o dobro

Antes de espalhar isso pelo jogo, monte uma cena de teste com um único personagem e conecte os três sinais a um print. Verifique quatro coisas: o veneno tira vida no intervalo certo, aplicar de novo empilha (ou renova, conforme o Resource), o buff de ataque some quando a duracao acaba e o atordoamento devolve o controle ao expirar. Se os quatro passam, o sistema está redondo.

O ganho de longo prazo é o mesmo de todo bom design de dados no Godot: efeito novo é arquivo novo, nao código novo. Uma queimadura que empilha, um lentidao que corta a velocidade pela metade, um escudo que reduz dano recebido, tudo vira mais um .tres e, no máximo, um campo a mais no Resource. Se você está montando esses sistemas ainda aprendendo a estruturar código de jogo, vale seguir uma base organizada como a trilha de Godot para iniciante, porque status effects, combate e máquina de estados são exatamente os pontos onde arquitetura fraca cobra caro mais tarde.

Pega o gerenciador deste tutorial, pluga no seu personagem e cria três efeitos: um veneno que empilha, um buff de ataque e um atordoamento. No dia em que você adicionar o décimo efeito em dois minutos, sem tocar no personagem, o padrao se paga.

Perguntas frequentes

Devo usar um Timer por efeito ou contar a duracao no _process?

Para poucos efeitos por personagem, contar duracao com delta no _process e mais simples e evita criar dezenas de nodes Timer. Um Timer por efeito so compensa se voce quer o sinal timeout desacoplado do frame ou intervalos muito longos. O sistema deste tutorial usa acumuladores de delta, o que da controle total sobre tick e duracao no mesmo lugar.

Qual a diferenca pratica entre empilhar (stack) e renovar a duracao?

Empilhar aumenta a intensidade: dois venenos causam o dobro de dano por tick. Renovar apenas reinicia o contador de duracao do efeito que ja existe, sem somar dano. O campo empilha do Resource decide qual comportamento acontece quando o mesmo efeito e aplicado de novo.

Como o status effect altera atributos como ataque e velocidade sem quebrar o valor base?

Nunca escreva no atributo base. Guarde o valor base separado e recalcule o valor final somando os modificadores dos buffs ativos. Ao remover o efeito, o valor volta sozinho porque ele nunca fez parte do calculo depois que o buff saiu.

O StatusEffect e um Resource ou um node?

A definicao do efeito (nome, duracao, dano por tick, se e buff ou debuff) e um Resource, porque e dado puro e reutilizavel via arquivo .tres. Quem processa duracao e tick por frame e o node StatusEffectManager, filho do personagem. Dado no Resource, comportamento no node.

Como evitar aplicar o mesmo efeito duas vezes por engano?

O gerenciador guarda os efeitos ativos numa lista e checa se ja existe um efeito com o mesmo id antes de adicionar. Se existir, ele decide entre empilhar ou renovar conforme o Resource, em vez de inserir uma copia duplicada.

Da para usar esse sistema em jogo de acao em tempo real, nao so em RPG por turno?

Da. Como a duracao e o tick sao contados por delta no _process, o sistema funciona igual em tempo real. Em jogo por turno voce so troca a fonte da contagem: em vez de delta, chama um metodo de avanco de turno que reduz a duracao em uma unidade por rodada.