Como Criar um Sistema de Stamina no Godot: Correr, Atacar e Dash

Aprenda a criar um sistema de stamina godot 4: gasto ao correr e dar dash, regeneração com delay, barra de UI com ProgressBar e estado de exaustão.
Como Criar um Sistema de Stamina no Godot: Correr, Atacar e Dash
Stamina é o recurso que transforma "segurar o botão de correr pra sempre" em decisão. Um sistema de stamina godot bem feito tem quatro peças: o gasto (correr drena por segundo, dash e ataque drenam de uma vez), a regeneração com um delay depois do último gasto, a barra de UI que mostra tudo isso, e o estado de exaustão que pune quem zera o recurso. Souls-like, survival, roguelike de ação: a estrutura é a mesma em todos.
O erro mais comum que eu vejo é espalhar a stamina pelo script do player: um if aqui descontando, outro ali regenerando, uma variável de delay solta no meio. Funciona até o dia em que o dash desconta mas a regeneração não para, e ninguém sabe por quê. Nesse tutorial eu monto o sistema como um componente isolado, com sinais pra UI e regras claras de quem pode gastar o quê. Todo código é GDScript do Godot 4.x.
A arquitetura: um node de stamina, não um amontoado de variáveis
A mesma lógica que eu defendo no sistema de vida e dano vale aqui: recurso de gameplay merece um node próprio. Crie um Node chamado Stamina como filho do player, com este script:
extends Node
class_name Stamina
signal changed(current: float, maximum: float)
signal depleted
signal recovered
@export var max_stamina: float = 100.0
@export var regen_per_second: float = 25.0
@export var regen_delay: float = 1.0
var current: float
var exhausted: bool = false
var _delay_left: float = 0.0
func _ready():
current = max_stamina
func _process(delta):
if _delay_left > 0.0:
_delay_left -= delta
return
if current < max_stamina:
current = min(current + regen_per_second * delta, max_stamina)
changed.emit(current, max_stamina)
if exhausted and current >= max_stamina * 0.3:
exhausted = false
recovered.emit()
func can_spend(amount: float) -> bool:
return not exhausted and current >= amount
func spend(amount: float) -> bool:
if not can_spend(amount):
return false
current -= amount
_delay_left = regen_delay
changed.emit(current, max_stamina)
if current <= 0.0:
current = 0.0
exhausted = true
depleted.emit()
return true
func drain(amount_per_second: float, delta: float) -> bool:
return spend(amount_per_second * delta)
Três decisões importantes estão embutidas aí. Primeira: spend() retorna bool. Quem chama pergunta "consegui gastar?" e age de acordo, em vez de checar a stamina por fora e gastar por dentro, que é receita pra dessincronizar. Segunda: todo gasto reinicia _delay_left, então a regeneração só começa depois de regen_delay segundos sem gastar nada. É esse delay que cria o ritmo de "para, respira, volta". Terceira: o estado exhausted só destrava quando a barra recupera 30% do total. Sem essa folga, o jogador zera, regenera um pinguinho, gasta de novo, e a stamina fica oscilando no zero, o que é frustrante de jogar e feio de ver.
Gastando stamina ao correr, atacar e dar dash
Com o componente pronto, o player só consome a API. Os três tipos de gasto clássicos:
- Correr: dreno contínuo, por segundo, enquanto o botão está pressionado.
- Dash: custo fixo, pago de uma vez, no momento da ação.
- Ataque: custo fixo também, geralmente maior pra ataques pesados.
O script do player junta tudo:
extends CharacterBody2D
const WALK_SPEED = 150.0
const RUN_SPEED = 260.0
const DASH_SPEED = 600.0
const RUN_DRAIN = 20.0 # por segundo
const DASH_COST = 30.0
const ATTACK_COST = 15.0
@onready var stamina: Stamina = $Stamina
var dash_time_left: float = 0.0
func _physics_process(delta):
var direction = Input.get_vector("move_left", "move_right", "move_up", "move_down")
if dash_time_left > 0.0:
dash_time_left -= delta
move_and_slide()
return
var speed = WALK_SPEED
var wants_to_run = Input.is_action_pressed("run") and direction != Vector2.ZERO
if wants_to_run and stamina.drain(RUN_DRAIN, delta):
speed = RUN_SPEED
velocity = direction * speed
if Input.is_action_just_pressed("dash") and direction != Vector2.ZERO:
if stamina.spend(DASH_COST):
velocity = direction * DASH_SPEED
dash_time_left = 0.15
if Input.is_action_just_pressed("attack"):
if stamina.spend(ATTACK_COST):
attack()
move_and_slide()
func attack():
# Aqui entra sua animação e hitbox de ataque.
pass
Repare como o bloqueio de ação sem stamina sai de graça: se spend() retorna false, o dash simplesmente não acontece e o player continua andando. Não tem um segundo if checando a barra, não tem flag duplicada. A corrida é o caso mais elegante: drain() gasta RUN_DRAIN * delta a cada frame, e no frame em que não dá mais, o retorno false derruba o jogador pra velocidade de caminhada automaticamente. Sem transição abrupta no código, ela emerge da regra.
Um detalhe de game feel que vale o ajuste: correr drena 20 por segundo e a regeneração repõe 25. Isso significa que correr nunca é de graça, mas o jogador recupera mais rápido do que gasta. Se a sua regeneração for mais lenta que o dreno de corrida, o jogo vira uma gestão sufocante de recurso. Às vezes é o que você quer (survival horror), quase nunca é o que um jogo de ação quer.
Barra de stamina com ProgressBar
A UI escuta o sinal changed e não pergunta nada pra ninguém. Estrutura na cena de HUD:
HUD (CanvasLayer)
└── StaminaBar (ProgressBar)
No Inspector da StaminaBar: min_value = 0, max_value = 100, e desligue show_percentage que aquele texto no meio da barra não combina com HUD de jogo. O script:
extends ProgressBar
@onready var player = get_tree().get_first_node_in_group("player")
func _ready():
var stamina: Stamina = player.get_node("Stamina")
max_value = stamina.max_stamina
value = stamina.current
stamina.changed.connect(_on_stamina_changed)
stamina.depleted.connect(_on_depleted)
stamina.recovered.connect(_on_recovered)
func _on_stamina_changed(current: float, _maximum: float):
value = current
func _on_depleted():
modulate = Color(1.0, 0.4, 0.4)
func _on_recovered():
modulate = Color.WHITE
A barra fica vermelha durante a exaustão e volta ao normal quando o estado destrava. É feedback barato e o jogador entende na hora por que o dash parou de funcionar.
Dois refinamentos que elevam a sensação. Primeiro, suavizar o movimento da barra com tween em vez de pular direto pro valor:
func _on_stamina_changed(current: float, _maximum: float):
var tween = create_tween()
tween.tween_property(self, "value", current, 0.1)
Segundo, esconder a barra quando está cheia, que é o padrão de Zelda e meio mundo de jogos de ação. Um tween de modulate:a pra 0 quando current >= max, e de volta pra 1 no primeiro gasto. A tela agradece o espaço.
O estado de exaustão: a punição que dá peso ao recurso
Sem punição, zerar a stamina só significa "espera um segundo". Com o estado exhausted, zerar significa ficar vulnerável: nada de correr, nada de dash, nada de ataque até recuperar 30% da barra. Isso muda o cálculo do jogador no meio do combate, que é exatamente o ponto de ter stamina.
O componente já bloqueia tudo via can_spend(), mas o player pode reagir de forma mais visível ao estado:
func _ready():
stamina.depleted.connect(_on_exhausted)
stamina.recovered.connect(_on_recovered)
func _on_exhausted():
# Animação de cansaço e movimento mais lento.
$AnimationPlayer.play("exhausted")
func _on_recovered():
$AnimationPlayer.play("idle")
Se quiser ir além, dá pra reduzir a velocidade de caminhada durante a exaustão, ou travar o movimento por meio segundo no momento em que zera, como um stagger. Em jogos com combate corpo a corpo isso cria o momento clássico de "abusou do ataque, agora apanha", que é o coração do balanceamento de stamina em souls-like.
Sobre o limiar de 30%: ele é um número de design, não de engenharia. Limiar baixo (10%) deixa o jogo permissivo, limiar alto (50%) força pausas longas. Como regen_delay e regen_per_second são @export, dá pra ajustar tudo pelo Inspector testando em tempo real, sem tocar no código.
Variações que o mesmo componente cobre
A graça de isolar o sistema num node é que as variações viram parâmetros ou métodos pequenos:
Custo de bloquear dano. Conecte o componente de vida ao de stamina: bloquear com escudo chama stamina.spend(custo_do_golpe), e se retornar false o bloqueio quebra e o dano passa. É a guarda quebrada de Sekiro e companhia.
Regeneração mais lenta em combate. Uma propriedade in_combat que multiplica regen_per_second por 0.5 dentro do _process. Quem liga e desliga é o sistema de combate, o componente só obedece.
Poções e buffs de stamina. Um método restore(amount) que soma, clampa com min() e emite changed. Três linhas.
Custo de habilidades com cooldown. Stamina e cooldown são restrições independentes que se combinam bem: a habilidade exige as duas. O lado do tempo eu cubro no artigo sobre Timer e cooldown no Godot, e a checagem vira if cooldown.is_stopped() and stamina.spend(custo).
Uma armadilha pra evitar: não use dois nodes de stamina pra coisas diferentes (um pra corrida, outro pra ataque) achando que isso organiza. O valor da stamina como mecânica é justamente ser um recurso único disputado por várias ações. Se correr e atacar não competem pelo mesmo tanque, você tem dois cooldowns disfarçados, e aí é melhor usar cooldown mesmo.
Fechando
Um sistema de stamina no Godot se resume a um node com quatro responsabilidades: gastar com validação, regenerar depois de um delay, anunciar mudanças por sinal e travar tudo no estado de exaustão. O player consome a API com spend() e drain(), a ProgressBar escuta changed, e nenhuma parte conhece o interior da outra.
Pra testar de verdade, monta uma cena com o player, dois ou três valores diferentes de regen_delay e brinca de balancear: delay de 0.5 segundo dá um jogo frenético, delay de 2 segundos dá um jogo de gestão. O código é o mesmo, o jogo muda completamente. Essa é a parte que nenhum tutorial faz por você.


