Animação de Sprite 2D: Como Animar os Personagens do Seu Jogo
Aprenda animação de sprite 2D na prática: sprite sheets, frames, AnimatedSprite2D no Godot 4, timing por FPS e como ligar a animação ao movimento.
Animação de Sprite 2D: Como Animar os Personagens do Seu Jogo
Animação de sprite 2D é o que separa um retângulo que desliza pela tela de um personagem que parece vivo. E a boa notícia: a parte técnica é bem menor do que parece. Você precisa entender o que é um sprite sheet, como a engine troca os frames, e como amarrar isso ao estado do personagem (parado, correndo, pulando). O resto é timing.
Esse tutorial cobre o fluxo completo no Godot 4: montar o sprite sheet, configurar o AnimatedSprite2D, tocar a animação certa na hora certa por código, lidar com animações que não fazem loop (ataque, dano) e ajustar o FPS até a coisa parecer boa. Todo código aqui é GDScript do Godot 4.x e roda como está.
O que é um sprite sheet (e por que usar)
Animação 2D clássica é troca de imagem: você mostra o frame 1, depois o frame 2, depois o 3, rápido o bastante pro olho ler movimento. Um sprite sheet é uma imagem única com todos esses frames lado a lado, geralmente numa grade. Um ciclo de corrida de 8 frames de 32x32 pixels vira uma imagem de 256x32.
Dá pra usar um arquivo separado por frame? Dá, e o Godot aceita. Mas o sprite sheet ganha em quase tudo:
- Performance: uma textura só na memória da GPU significa menos trocas de textura durante o desenho. Em jogo com muitos personagens na tela, isso importa.
- Organização: um arquivo por personagem (ou por ação) em vez de dezenas de PNGs soltos.
- Workflow: Aseprite, Libresprite e praticamente toda ferramenta de pixel art exporta sprite sheet direto, com os frames já alinhados na grade.
A única regra que vale seguir com disciplina: todos os frames com o mesmo tamanho de célula. Se o personagem é 32x32 mas o ataque com a espada estendida ocupa mais, exporte a célula maior (48x32, por exemplo) pra todas as animações daquele personagem. Frames de tamanhos diferentes na mesma sheet viram dor de cabeça de alinhamento depois.
Um detalhe que poupa retrabalho: defina o "pé" do personagem na mesma altura em todos os frames. Se o chão visual muda de um frame pro outro, o personagem parece flutuar ou afundar enquanto anda, e você vai caçar esse bug achando que é física.
Animação de sprite 2D no Godot: AnimatedSprite2D
O Godot tem dois caminhos pra animar sprite: o AnimatedSprite2D, que é o node especializado nisso, e o combo Sprite2D + AnimationPlayer, que é mais geral. Começo pelo primeiro porque ele resolve o caso comum com menos atrito.
A estrutura de cena pra um personagem jogável fica assim:
Player (CharacterBody2D)
├── CollisionShape2D
└── AnimatedSprite2D
Montando as animações no SpriteFrames
O AnimatedSprite2D guarda as animações num resource chamado SpriteFrames. No Inspector, clique em Sprite Frames > New SpriteFrames e depois clique no resource pra abrir o painel de edição na parte de baixo do editor.
Nesse painel, o fluxo é:
- Crie uma animação nova pra cada ação e nomeie:
idle,run,jump,fall,attack. Nomes minúsculos e consistentes, porque você vai digitar eles em código. - Com a animação selecionada, clique no ícone de grade (Add frames from sprite sheet), escolha o PNG da sheet e informe quantas colunas e linhas a grade tem. O Godot fatia a imagem e você seleciona quais células entram, na ordem.
- Configure o FPS de cada animação (campo de velocidade no painel). Esse número é quantos frames trocam por segundo, e a gente volta nele na seção de timing.
- Marque o loop nas animações cíclicas (
idle,run) e desmarque nas que tocam uma vez (attack,hit,death).
Se a arte é pixel art, tem um ajuste obrigatório: em Project Settings > Rendering > Textures, mude o Default Texture Filter pra Nearest. Sem isso o Godot suaviza a textura ao escalar e o pixel art vira uma mancha borrada.
Tocando animações por código
A API do AnimatedSprite2D é pequena e direta:
@onready var anim = $AnimatedSprite2D
func exemplos():
anim.play("run") # toca a animação
anim.stop() # para e volta pro frame inicial
anim.pause() # congela no frame atual
anim.frame = 0 # pula pra um frame específico
anim.flip_h = true # espelha horizontalmente
print(anim.animation) # nome da animação atual
Um comportamento que trabalha a seu favor: chamar play("run") quando run já está tocando não reinicia a animação. Isso permite chamar play() todo frame dentro da lógica de movimento sem a animação ficar travada no primeiro frame, que é um bug clássico de quem vem de outras engines esperando outro comportamento.
Ligando a animação ao movimento do personagem
Animação solta não serve pra nada. O que você quer é: o estado do personagem decide a animação. Aqui está um controlador de plataforma completo com a parte visual amarrada:
extends CharacterBody2D
const SPEED = 300.0
const JUMP_VELOCITY = -400.0
var gravity = ProjectSettings.get_setting("physics/2d/default_gravity")
@onready var anim = $AnimatedSprite2D
func _physics_process(delta):
if not is_on_floor():
velocity.y += gravity * delta
if Input.is_action_just_pressed("jump") and is_on_floor():
velocity.y = JUMP_VELOCITY
var direction = Input.get_axis("ui_left", "ui_right")
if direction:
velocity.x = direction * SPEED
# Espelha o sprite em vez de desenhar frames virados pro outro lado.
anim.flip_h = direction < 0
else:
velocity.x = move_toward(velocity.x, 0, SPEED)
move_and_slide()
_update_animation(direction)
func _update_animation(direction):
if not is_on_floor():
# Subindo é pulo, descendo é queda.
anim.play("jump" if velocity.y < 0 else "fall")
elif direction != 0:
anim.play("run")
else:
anim.play("idle")
Três decisões nesse código valem comentário.
O flip_h corta sua sheet pela metade. Você desenha o personagem virado só pra um lado e espelha por código. A exceção é personagem assimétrico (espada sempre na mão direita, cicatriz num lado do rosto), aí precisa desenhar os dois lados.
A animação é decidida depois do move_and_slide(). É o movimento que dita o visual, nunca o contrário. Se você um dia trocar o controle (dash, escada, nado), a função _update_animation continua sendo o único lugar que traduz estado em animação.
Pulo e queda são animações separadas. Dividir o ar em "subindo" e "descendo" usando o sinal de velocity.y é barato e melhora muito a leitura do movimento. Com 2 ou 3 frames de cada já fica bom.
Animações que não fazem loop: ataque, dano e morte
Animação cíclica é simples: toca e esquece. O problema aparece com ação que toca uma vez e precisa terminar antes do personagem voltar ao normal. Se o seu ataque pode ser interrompido pela animação de run no frame seguinte, o jogador nunca vê o golpe.
A solução é um sinal que o AnimatedSprite2D emite quando a animação acaba, o animation_finished. Com await, o código fica linear:
var attacking = false
func _unhandled_input(event):
if event.is_action_pressed("attack") and not attacking:
attack()
func attack():
attacking = true
anim.play("attack")
# Espera a animação de ataque terminar antes de liberar o estado.
await anim.animation_finished
attacking = false
func _update_animation(direction):
# Enquanto ataca, nenhuma outra animação pode atropelar.
if attacking:
return
if not is_on_floor():
anim.play("jump" if velocity.y < 0 else "fall")
elif direction != 0:
anim.play("run")
else:
anim.play("idle")
Detalhe que derruba muita gente: o animation_finished só dispara se a animação não estiver em loop. Se o seu ataque nunca "termina", volte no painel do SpriteFrames e confira se o loop está desmarcado naquela animação.
Esse padrão de flag (attacking) aguenta bem um personagem com poucas ações. Quando o personagem acumula estados (ataque, dano, dash, escada, agachar), a quantidade de if cresce até virar espaguete. Esse é o momento de migrar pra uma máquina de estados, onde cada estado é dono da sua animação. Mas não comece por aí: pra protótipo e pra jogo pequeno, a flag resolve.
Timing: o FPS da animação é game design
Aqui está a parte que ninguém te conta: o número de frames importa menos que o ritmo deles. Os valores de FPS que funcionam pra pixel art na prática:
- Idle: 4 a 6 FPS. Respiração lenta, quase parada.
- Corrida: 8 a 12 FPS. Abaixo disso parece câmera lenta, acima o olho perde os frames.
- Ataque: 12 a 15 FPS na descida do golpe. Ataque lento parece fraco, não importa o dano que cause.
Não trate esses números como lei, trate como ponto de partida. O método é o mesmo do tuning de física: roda, sente, ajusta. E lembre que FPS da animação não tem nada a ver com FPS do jogo: o jogo roda a 60, a animação troca frame no ritmo que você configurou no SpriteFrames.
Dois truques de timing que elevam o resultado sem desenhar nada novo:
Frames segurados. Em vez de distribuir o tempo igual, segure mais tempo nos frames de impacto e antecipação. No painel do SpriteFrames, cada frame tem um campo de duração relativa: um frame com duração 2.0 fica na tela o dobro do tempo. Um ataque com antecipação longa e golpe rápido parece muito mais forte que o mesmo desenho com timing uniforme.
speed_scale pra variação dinâmica. A propriedade multiplica a velocidade de qualquer animação em runtime. Personagem com buff de velocidade? anim.speed_scale = 1.5 e o ciclo de corrida acompanha, sem criar animação nova.
Sprite2D + AnimationPlayer: o outro caminho
O AnimatedSprite2D resolve troca de frames, e só. Quando a animação precisa de mais (mover a hitbox do ataque junto com o golpe, disparar som no frame exato do passo, escalar o sprite num squash and stretch), o combo é outro: um Sprite2D com a sheet inteira, animado por um AnimationPlayer.
O Sprite2D tem as propriedades hframes e vframes, que dizem quantas colunas e linhas a textura tem. Com elas configuradas, a propriedade frame escolhe qual célula aparece. No AnimationPlayer, você cria keyframes da propriedade frame ao longo do tempo, e na mesma timeline pode animar qualquer outra propriedade de qualquer node da cena: posição da hitbox, escala, modulate, chamadas de método.
Pra entender o mecanismo por baixo, dá até pra animar um Sprite2D na mão, sem AnimationPlayer:
extends Sprite2D
@export var fps = 10.0
var frame_acumulado = 0.0
func _process(delta):
frame_acumulado += fps * delta
frame = int(frame_acumulado) % (hframes * vframes)
Esse script é didático, não recomendação: em jogo de verdade, use AnimatedSprite2D pro caso simples e AnimationPlayer quando precisar sincronizar mais coisas que o frame. A regra prática que eu uso: começou a precisar de hitbox que acompanha o golpe ou som por frame, é hora do AnimationPlayer.
Erros comuns que fazem a animação parecer amadora
Depois de revisar muito projeto de aluno, os mesmos problemas aparecem em quase todos:
- Filtro de textura errado. Pixel art com filtro
Linearfica borrado. É o erro número um, e a correção é um dropdown nas configurações do projeto. - Animação reiniciando todo frame. Acontece quando se manipula
frame = 0ou se chamastop()seguido deplay()dentro do loop. Confie noplay(): ele já ignora a chamada se a animação atual é a mesma. - Ataque em loop. Animação de ação com loop marcado nunca emite
animation_finishede o personagem fica preso golpeando o ar. - Tudo no mesmo FPS. Idle a 12 FPS parece um personagem com pressa de existir. Cada animação tem o seu ritmo.
- Pé desalinhado entre frames. O personagem "nada" no chão enquanto corre. Se o movimento parece estranho e a física está certa, abra a sheet e confira o alinhamento.
Fechando
O caminho da animação de sprite 2D é curto: sheet com células uniformes, SpriteFrames com uma animação nomeada por ação, play() decidido pelo estado do personagem depois do movimento, await animation_finished pras ações que tocam uma vez. Isso cobre o personagem jogável inteiro de um jogo de plataforma.
O que separa o resultado mediano do bom não é técnica, é timing. Pegue um ciclo de corrida qualquer e teste a 6, 10 e 14 FPS: o desenho é o mesmo e a sensação muda completamente. Esse experimento de dez minutos ensina mais sobre animação do que qualquer parágrafo que eu escreva aqui.

