Voltar para o Blog
Quest Log

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

Personagem de jogo correndo com uma barra de stamina verde se esvaziando acima da cabeça

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.

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

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