Cutscenes Simples com AnimationPlayer no Godot 4

Aprenda a montar uma cutscene godot com AnimationPlayer no Godot 4: anime câmera, personagens, fade e diálogo, tire o controle do player e permita pular.
Você não precisa de uma timeline cinematográfica nem de plugin pago para colocar uma cutscene no seu jogo. O AnimationPlayer, que provavelmente você já usou para animar sprites, dá conta de mover câmera, deslocar personagens, controlar visibilidade e até chamar funções no momento exato. Neste tutorial vou mostrar como montar uma cutscene godot do zero no Godot 4, tirando o controle do jogador durante a cena e devolvendo no final, com a opção de pular tudo apertando uma tecla.
A ideia é tratar a cutscene como uma animação comum, só que em vez de animar um único Sprite2D, animamos várias propriedades de vários nós ao mesmo tempo. Tudo na mesma linha do tempo.
Cutscenes Simples com AnimationPlayer no Godot 4
Antes de tocar em código, vale entender o que o AnimationPlayer realmente faz. Ele guarda uma lista de animações, e cada animação é um conjunto de tracks. Cada track aponta para uma propriedade de um nó (a posição de uma Camera2D, o modulate de um ColorRect, o visible de um personagem) e grava valores em pontos no tempo, os keyframes. Ao reproduzir, o Godot interpola entre os keyframes.
Para uma cutscene godot isso é suficiente. Você não está limitado a uma propriedade. Numa mesma animação chamada intro, dá para ter uma track movendo a câmera, outra fazendo um NPC andar, outra controlando o fade de tela e ainda uma quarta que dispara funções do seu script.
Montando a cena base
Comece com uma estrutura simples. Uma cena de fase com o player, um NPC, a câmera e o AnimationPlayer que vai reger tudo.
Level (Node2D)
├── Player (CharacterBody2D)
├── NPC (CharacterBody2D)
├── Camera2D
├── CutsceneTrigger (Area2D)
│ └── CollisionShape2D
├── CutsceneAnim (AnimationPlayer)
└── CanvasLayer
└── FadeRect (ColorRect)
O FadeRect é um ColorRect preto que cobre a tela inteira, com modulate no alpha 0 (invisível) por padrão. Ele fica num CanvasLayer para não ser afetado pelo movimento da câmera. Vamos usá-lo para escurecer a tela em transições dentro da cutscene.
Selecione o CutsceneAnim, crie uma nova animação chamada intro e defina uma duração, por exemplo 6 segundos. Agora é só ir adicionando tracks.
Animando câmera, personagens e fade
Com a animação aberta, mova o playhead para o tempo 0. Selecione a Camera2D, ajuste a propriedade position para o ponto de início e clique no ícone de chave ao lado de Transform > Position no inspetor para criar o keyframe. Avance o playhead para 2 segundos, mova a câmera para o NPC e crie outro keyframe. O Godot interpola o deslizamento entre os dois pontos.
Repita a lógica para os personagens. Quer que o NPC ande até o player? Keyframe da position no início, keyframe no fim, e pronto. Para um personagem aparecer no meio da cena, anime a propriedade visible (lembre que tracks booleanas usam interpolação do tipo discreta, sem suavização).
O fade é onde o modulate brilha. Selecione o FadeRect, e anime o canal alpha do modulate:
- Tempo 0: alpha 0 (transparente)
- Tempo 0.5: alpha 1 (tela preta)
- Tempo 1.0: alpha 0 (volta a aparecer)
Isso te dá um flash de transição no meio da cutscene, útil para cortar de um ângulo para outro. Se você quiser uma transição entre cenas completa em vez de só dentro da cutscene, vale combinar com a técnica que descrevi em transição de cena com fade no Godot.
Tudo isso é trabalho de editor, sem script. O resultado já é uma cena que se move sozinha. O que falta é orquestrar quando ela começa, o que acontece durante e o que acontece quando acaba.
Call Method Track: chamando funções no tempo certo
Aqui está a parte que transforma uma animação bonita numa cutscene de verdade. O AnimationPlayer tem um tipo especial de track chamado Call Method Track. Em vez de animar uma propriedade, ele chama um método de um nó num instante específico.
Para adicionar, clique em Add Track > Call Method Track e selecione o nó cujo script tem o método (geralmente o próprio Level ou um nó controlador). Posicione o playhead no tempo desejado, clique para inserir uma key e escolha o método na lista. Você pode passar argumentos.
Um uso clássico é disparar uma linha de diálogo no momento em que a câmera chega no NPC. No script do Level:
extends Node2D
@onready var anim: AnimationPlayer = $CutsceneAnim
@onready var dialogue: DialogueBox = $CanvasLayer/DialogueBox
func mostrar_linha(texto: String) -> void:
dialogue.show_line(texto)
func esconder_dialogo() -> void:
dialogue.hide()
Na track de método, no segundo 2, você chama mostrar_linha("Você não devia ter vindo aqui."). No segundo 5, chama esconder_dialogo(). A cutscene passa a falar com o sistema de diálogo do jogo sem nenhuma máquina de tempo manual com await espalhado pelo código. Se você ainda não tem uma caixa de diálogo, dá para montar uma seguindo o sistema de diálogo para jogos e plugar as chamadas aqui.
Call Method Track também serve para tocar um som num impacto, ligar um sistema de partículas, ou setar uma flag de progresso da história. É a ponte entre a parte visual e a lógica do jogo.
Tirando o controle do player
Durante a cutscene, o jogador não pode andar nem atirar. Existem duas formas comuns de lidar com isso, e a melhor depende de como seu player está estruturado.
A forma mais direta é uma flag de estado lida no _physics_process. No script do player:
extends CharacterBody2D
var controlavel: bool = true
const SPEED := 220.0
func _physics_process(_delta: float) -> void:
if not controlavel:
velocity = Vector2.ZERO
move_and_slide()
return
var direcao := Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down")
velocity = direcao * SPEED
move_and_slide()
Quando a cutscene começa, basta setar player.controlavel = false. O player para de responder ao input mas continua sujeito à física (não fica flutuando se estava caindo). Zerar a velocity evita que ele continue deslizando pela inércia do último frame.
A outra forma é set_process_input(false) ou set_physics_process(false) no nó do player. Funciona, mas é mais agressiva: se você desligar o _physics_process inteiro, o personagem ignora gravidade e colisões. Prefira a flag quando o player precisa continuar reagindo ao mundo, e o set_physics_process(false) quando quer congelá-lo por completo.
Repare que uma vantagem da flag é que o AnimationPlayer ainda pode mover o player via track de position mesmo com controlavel = false, já que estamos só ignorando o input, não a animação.
Disparando a cutscene a partir de um gatilho
Agora juntamos as peças. A Area2D chamada CutsceneTrigger detecta quando o player entra numa região e inicia a animação. Conecte o sinal body_entered dela ao script do Level:
extends Node2D
@onready var anim: AnimationPlayer = $CutsceneAnim
@onready var player: CharacterBody2D = $Player
var cutscene_ativa: bool = false
func _ready() -> void:
anim.animation_finished.connect(_on_anim_finished)
func _on_cutscene_trigger_body_entered(body: Node2D) -> void:
if body != player or cutscene_ativa:
return
iniciar_cutscene()
func iniciar_cutscene() -> void:
cutscene_ativa = true
player.controlavel = false
$CutsceneTrigger.set_deferred("monitoring", false)
anim.play("intro")
func _on_anim_finished(nome: StringName) -> void:
if nome != "intro":
return
cutscene_ativa = false
player.controlavel = true
Três detalhes importam aqui. Primeiro, checamos se quem entrou é o player e se a cutscene já não está rodando, evitando disparos duplicados. Segundo, desligamos o monitoring da Area2D com set_deferred para que ela não dispare de novo (você não quer a cutscene reiniciando se o player encostar nela ao sair). Terceiro, e mais importante: o controle volta no sinal animation_finished, não num await com tempo fixo. Assim, se você editar a duração da animação no editor, o código continua funcionando sem ajuste.
O sinal animation_finished recebe o nome da animação que terminou. Sempre verifique qual foi, porque o mesmo AnimationPlayer pode estar tocando outras coisas, e você só quer devolver o controle quando a intro específica acabar.
Deixando o player pular a cutscene
Ninguém aguenta ver a mesma cutscene pela terceira vez. Permitir pular é cortesia básica. A ideia é avançar a animação direto para o final quando o jogador apertar uma tecla.
func _unhandled_input(event: InputEvent) -> void:
if not cutscene_ativa:
return
if event.is_action_pressed("ui_accept") or event.is_action_pressed("ui_cancel"):
pular_cutscene()
get_viewport().set_input_as_handled()
func pular_cutscene() -> void:
var anim_atual := anim.current_animation
if anim_atual == "":
return
var fim := anim.get_animation(anim_atual).length
anim.seek(fim, true)
O seek(fim, true) move o playhead para o final da animação. O segundo argumento, update, força a aplicação imediata dos valores daquele instante, então a câmera e os personagens vão para suas posições finais de uma vez. Importante: o seek aplica os valores das tracks de propriedade, mas não executa retroativamente todas as Call Method Tracks que ficaram para trás. Se o final da cutscene depende de uma chamada de método (como esconder o diálogo ou setar uma flag de progresso), chame essa função explicitamente dentro de pular_cutscene para garantir o estado correto.
Uma alternativa mais simples, se sua cutscene é curta e você só quer terminá-la na hora, é chamar anim.advance(anim.get_animation(anim_atual).length), que avança o tempo processando as tracks. Mas para a maioria dos casos o seek para o fim resolve, e o animation_finished ainda dispara normalmente, devolvendo o controle pelo mesmo caminho de sempre.
Juntando tudo
O fluxo completo fica assim. O player anda até a Area2D, que dispara iniciar_cutscene. O controle é cortado, a Area2D é desativada e a animação intro toca. Durante a reprodução, tracks de propriedade movem câmera e personagens, a track de modulate faz o fade no momento da transição e Call Method Tracks soltam linhas de diálogo na hora certa. Se o jogador apertar confirmar, a animação pula para o fim. De um jeito ou de outro, animation_finished devolve o controle e reativa o jogo.
Repare que não escrevemos uma única máquina de estados temporal nem encadeamos await por toda parte. O AnimationPlayer é a fonte da verdade do tempo, e o GDScript só reage a dois sinais: a entrada na Area2D e o término da animação. Esse é o ponto inteiro de usar o AnimationPlayer para cutscenes: a parte difícil do timing fica no editor visual, onde você consegue ver e ajustar tudo, e o código só liga e desliga.
A partir daqui dá para crescer sem reescrever nada. Uma segunda cutscene é só outra animação no mesmo (ou em outro) AnimationPlayer. Se você quiser se aprofundar nas tracks, curvas de interpolação e easing, vale revisar os fundamentos do AnimationPlayer no Godot, que se aplicam igualzinho aqui. Comece com uma cena curta, de cinco ou seis segundos, faça-a funcionar de ponta a ponta com gatilho, controle e skip, e só depois adicione camadas de polimento como som e partículas. Uma cutscene boa não é a mais longa, é a que respeita o tempo de quem joga.

