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.

Ferramenta:Godot

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() -> void:
    current = max_stamina

func _process(delta: float) -> void:
    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: float) -> void:
    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() -> void:
    # 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.

Curso de Godot
Quer fazer seu primeiro jogo na Godot?

No curso de Godot da CursoGame.Dev você vai do zero ao primeiro jogo, com um desafio prático em cada level e suporte individual no WhatsApp quando travar.

Conhecer o curso de Godot
Suporte no WhatsAppAcesso vitalícioGarantia de 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() -> void:
    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) -> void:
    value = current

func _on_depleted() -> void:
    modulate = Color(1.0, 0.4, 0.4)

func _on_recovered() -> void:
    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) -> void:
    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() -> void:
    stamina.depleted.connect(_on_exhausted)
    stamina.recovered.connect(_on_recovered)

func _on_exhausted() -> void:
    # Animação de cansaço e movimento mais lento.
    $AnimationPlayer.play("exhausted")

func _on_recovered() -> void:
    $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ê.