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

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.
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.


