Voltar para o Blog
Quest Log

Como Criar um Sistema de Diálogo para Jogos: Tutorial Completo

Interface de sistema de diálogo em jogo mostrando caixas de texto, avatares de personagens e opções de escolha

Aprenda a criar sistemas de diálogo profissionais para jogos com tutorial passo a passo em Godot. Inclui código, UI/UX e narrativa interativa.

Quase todo dev que eu vejo travado em RPG ou aventura tropeça no mesmo ponto: o sistema de diálogo. Parece simples (é só mostrar texto na tela, certo?) e aí você descobre que precisa de ramificação, condições, efeito de digitação, escolhas que mudam o jogo, histórico, localização. Vira um nó.

Esse post é o caminho que eu queria ter tido quando comecei. Vou montar um sistema de diálogo em Godot do zero, com código que de fato roda, e ir explicando por que cada peça existe. Sem mágica, sem framework gigante. Você sai daqui com uma base que dá pra colocar no seu projeto hoje e expandir depois. Se você está dando os primeiros passos, um caminho mais leve pra começar é fazer uma visual novel no Godot, que usa uma versão enxuta dessas mesmas ideias.

O que um sistema de diálogo precisa fazer

No fundo, um sistema de diálogo gerencia quatro coisas:

  • Texto na tela: como e onde a fala aparece.
  • Fluxo da conversa: a ordem das falas e onde elas se dividem.
  • Escolhas do jogador: opções que mudam o rumo da narrativa.
  • Consequências: o que cada escolha dispara no resto do jogo (item, quest, relacionamento).

Olha Undertale, Disco Elysium ou The Witcher 3. O que faz o diálogo desses jogos funcionar não é efeito visual, é a sensação de que sua escolha importou. Esse é o objetivo. O resto é encanamento.

Componente 1: como guardar os diálogos

Antes de escrever uma linha de código, decida onde o texto vai morar. As opções que valem a pena:

  • JSON ou YAML: fácil de editar, qualquer escritor mexe sem saber programar. É por onde eu recomendo começar.
  • Custom Resources do Godot: integração nativa com a engine, dá pra editar no inspetor. Bom quando o projeto cresce.
  • Ink ou Yarn Spinner: linguagens feitas pra narrativa ramificada. Valem quando a história fica complexa de verdade (falo delas mais pra frente).

Banco de dados eu só usaria num jogo com dezenas de milhares de linhas, o que quase nunca é o seu caso no começo. Comece simples.

Aqui está uma estrutura JSON enxuta pra um diálogo com escolhas:

{
  "dialogue_id": "merchant_greeting",
  "speaker": "Mercador",
  "lines": [
    {
      "text": "Bem-vindo à minha loja! Procurando algo especial?",
      "choices": [
        { "text": "Mostre-me suas armas", "next": "merchant_weapons" },
        { "text": "Preciso de poções", "next": "merchant_potions" },
        { "text": "Só estou olhando", "next": "merchant_browsing" }
      ]
    }
  ]
}

Repare numa coisa: cada escolha tem um campo next que aponta pro dialogue_id de outro arquivo. É assim que a ramificação acontece. Escolheu "armas", o sistema carrega merchant_weapons.json. Guarde isso, porque é o que o gerenciador vai precisar resolver.

O DialogueManager é o cérebro. Ele carrega o JSON, controla em que linha você está, emite sinais quando algo acontece e carrega o próximo diálogo quando você escolhe uma opção. Uso sinais (signal) de propósito: assim a UI não precisa conhecer o gerenciador por dentro, só ouve o que ele anuncia.

extends Node
class_name DialogueManager

var current_dialogue: Dictionary = {}
var current_line_index: int = 0
var dialogue_history: Array = []

signal line_displayed(text: String, speaker: String)
signal choices_available(choices: Array)
signal dialogue_ended()

func load_dialogue(dialogue_id: String) -> void:
    var path := "res://dialogues/%s.json" % dialogue_id
    var file := FileAccess.open(path, FileAccess.READ)
    if file == null:
        push_error("Diálogo não encontrado: " + path)
        return

    var parsed = JSON.parse_string(file.get_as_text())
    if parsed == null:
        push_error("JSON inválido em: " + path)
        return

    current_dialogue = parsed
    current_line_index = 0
    display_current_line()

func display_current_line() -> void:
    if current_line_index >= current_dialogue["lines"].size():
        dialogue_ended.emit()
        return

    var line: Dictionary = current_dialogue["lines"][current_line_index]
    line_displayed.emit(line["text"], current_dialogue["speaker"])

    if line.has("choices"):
        choices_available.emit(line["choices"])

func choose_option(choice_index: int) -> void:
    var line: Dictionary = current_dialogue["lines"][current_line_index]
    var choice: Dictionary = line["choices"][choice_index]

    dialogue_history.append({
        "dialogue": current_dialogue["dialogue_id"],
        "choice": choice["text"],
    })

    if choice.has("next"):
        load_dialogue(choice["next"])
    else:
        next_line()

func next_line() -> void:
    current_line_index += 1
    display_current_line()

Duas decisões importantes aqui que vão te poupar dor de cabeça:

Primeiro, eu checo file == null e parsed == null antes de usar. JSON quebrado e arquivo faltando são os dois bugs mais comuns nessa parte, e sem o push_error você fica olhando pra uma tela travada sem saber o porquê.

Segundo, acesso os campos do JSON com colchetes (current_dialogue["lines"]), não com ponto. Quando o Godot faz parse de JSON, o resultado é um Dictionary puro. A notação de ponto funciona em alguns casos, mas com colchetes você nunca é pego de surpresa.

Componente 3: a interface

A UI ouve os sinais do gerenciador e desenha. Aqui ela faz três coisas: revela o texto com efeito de digitação, monta os botões de escolha na hora, e deixa o jogador pular a animação ou avançar com uma tecla.

extends Control

@onready var speaker_name: Label = $DialogueBox/SpeakerName
@onready var dialogue_text: Label = $DialogueBox/DialogueText
@onready var choices_container: VBoxContainer = $ChoicesContainer
@onready var continue_indicator: Control = $DialogueBox/ContinueIndicator

var dialogue_manager: DialogueManager
var is_typing: bool = false
var current_text: String = ""
var typing_speed: float = 0.04

func _ready() -> void:
    dialogue_manager = DialogueManager.new()
    add_child(dialogue_manager)

    dialogue_manager.line_displayed.connect(_on_line_displayed)
    dialogue_manager.choices_available.connect(_on_choices_available)
    dialogue_manager.dialogue_ended.connect(_on_dialogue_ended)

    hide()

func start_dialogue(dialogue_id: String) -> void:
    show()
    dialogue_manager.load_dialogue(dialogue_id)

func _on_line_displayed(text: String, speaker: String) -> void:
    speaker_name.text = speaker
    current_text = text
    dialogue_text.text = ""
    continue_indicator.hide()
    is_typing = true

    for i in text.length():
        if not is_typing:
            dialogue_text.text = current_text  # jogador pulou: mostra tudo
            break
        dialogue_text.text += text[i]
        await get_tree().create_timer(typing_speed).timeout

    is_typing = false
    continue_indicator.show()

func _on_choices_available(choices: Array) -> void:
    for child in choices_container.get_children():
        child.queue_free()

    for i in choices.size():
        var button := Button.new()
        button.text = choices[i]["text"]
        var index := i  # captura o valor agora, não a variável do loop
        button.pressed.connect(func(): dialogue_manager.choose_option(index))
        choices_container.add_child(button)

    choices_container.show()

func _on_dialogue_ended() -> void:
    choices_container.hide()
    hide()

func _input(event: InputEvent) -> void:
    if not visible:
        return
    if event.is_action_pressed("ui_accept"):
        if is_typing:
            is_typing = false  # pula a digitação
        elif choices_container.get_child_count() == 0:
            dialogue_manager.next_line()

Tem um detalhe sutil em _on_choices_available que pega quase todo mundo. Dentro do loop eu copio i pra uma variável index antes de usar na lambda. Se você fechar o connect direto sobre i, todas as escolhas acabam apontando pro mesmo valor de i quando o botão é clicado, e você seleciona sempre a opção errada. Copiar pra uma variável local resolve. Esse é o tipo de bug que custa uma tarde se você não souber que ele existe.

O resto é direto: o efeito de digitação é só um loop que adiciona um caractere e espera um timer. Apertou ui_accept no meio? is_typing vira false, o loop revela o texto inteiro de uma vez e para. É a interação que todo jogador espera de uma caixa de diálogo.

Recursos que separam o protótipo do jogo

A base acima já funciona. Agora os pedaços que fazem o diálogo parecer profissional.

Efeito de digitação com som

Em vez de concatenar caractere por caractere, dá pra usar visible_characters num RichTextLabel. O texto inteiro já está no label, você só controla quantos caracteres aparecem. Fica mais leve e funciona melhor com BBCode (negrito, cor, etc).

func typewriter_effect(text: String, label: RichTextLabel, speed: float = 0.04) -> void:
    label.text = text
    label.visible_characters = 0

    for i in text.length():
        label.visible_characters += 1
        if text[i] != " ":
            $TypingSound.play()
        await get_tree().create_timer(speed).timeout

Um aviso de quem já errou isso: tocar o som de digitação em todo caractere fica irritante rápido. Na prática eu toco o som a cada dois ou três caracteres, ou pulo vogais. Teste com fone, não com o som no talo, porque o que parece ok baixinho vira metralhadora no volume normal.

Variáveis e condições

Pra um diálogo reagir ao estado do jogo, você precisa de duas coisas: substituir variáveis no texto (o nome do jogador, por exemplo) e checar condições pra liberar ou esconder escolhas.

var game_state: Dictionary = {
    "player_name": "Herói",
    "merchant_friendship": 0,
    "has_sword": false,
    "completed_quests": [],
}

func check_condition(condition: String) -> bool:
    match condition:
        "has_sword":
            return game_state["has_sword"]
        "high_friendship":
            return game_state["merchant_friendship"] >= 50
        "completed_first_quest":
            return "first_quest" in game_state["completed_quests"]
    return false

func replace_variables(text: String) -> String:
    for key in game_state:
        text = text.replace("{%s}" % key, str(game_state[key]))
    return text

Com isso, uma fala como "E aí, {player_name}, já decidiu?" vira "E aí, Herói, já decidiu?" na tela. E uma escolha com um campo "requires": "high_friendship" só aparece se a amizade com o mercador passou de 50. É aqui que o diálogo começa a parecer vivo.

Ramificação de verdade

Com next e condições montados, a ramificação é só estruturar o JSON. Uma escolha moral, por exemplo:

{
  "dialogue_id": "moral_choice",
  "speaker": "Narrador",
  "lines": [
    {
      "text": "Você vê um ladrão fugindo. O que faz?",
      "choices": [
        { "text": "[Parar o ladrão]", "next": "heroic_path", "effects": ["alignment_good 10"] },
        { "text": "[Deixar escapar]", "next": "neutral_path" },
        { "text": "[Ajudar o ladrão]", "next": "villainous_path", "effects": ["alignment_evil 10"], "requires": "has_lockpick" }
      ]
    }
  ]
}

A última escolha só aparece se o jogador tem gazua, e cada caminho dispara um effect no fim. Como esses efeitos viram ação no jogo é o próximo assunto.

Conectando o diálogo ao resto do jogo

Um sistema de diálogo isolado não vale nada. Ele precisa ser disparado por algo e precisa mudar algo.

Disparar a conversa

O jeito mais comum é uma Area2D no mundo. O jogador entra, a conversa começa. Eu deixo configurável: disparo automático ou via botão de interação, e a opção de rodar só uma vez.

extends Area2D

@export var dialogue_id: String = ""
@export var auto_trigger: bool = true
@export var trigger_once: bool = true

var has_triggered: bool = false

func _ready() -> void:
    body_entered.connect(_on_body_entered)

func _on_body_entered(body: Node) -> void:
    if not body.is_in_group("player"):
        return
    if trigger_once and has_triggered:
        return

    if auto_trigger:
        trigger_dialogue()
    else:
        show_interaction_prompt()

func trigger_dialogue() -> void:
    var ui := get_node("/root/DialogueUI")
    ui.start_dialogue(dialogue_id)
    has_triggered = true

func show_interaction_prompt() -> void:
    pass  # mostre aqui o "[E] Falar", e chame trigger_dialogue() no input

Os efeitos das escolhas

Lembra dos effects no JSON? Aqui eles viram ação. Eu uso strings simples ("give_item espada 1") e faço o parse na hora. É menos elegante que um sistema de eventos formal, mas é fácil de escrever pra quem está montando os diálogos, que muitas vezes não é programador.

func process_dialogue_effects(effects: Array) -> void:
    for effect in effects:
        var parts: PackedStringArray = effect.split(" ")
        match parts[0]:
            "start_quest":
                QuestManager.start_quest(parts[1])
            "complete_quest":
                QuestManager.complete_quest(parts[1])
            "give_item":
                Inventory.add_item(parts[1], int(parts[2]))
            "change_relationship":
                game_state[parts[1]] += int(parts[2])

QuestManager e Inventory aqui são autoloads (singletons) do seu projeto. O ponto não é o nome deles, é o padrão: o diálogo não sabe nada sobre quests ou inventário, ele só grita "start_quest tal" e quem precisa escuta.

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

Quando partir pra uma ferramenta pronta

Tudo que eu mostrei até aqui você escreve à mão e controla 100%. Mas chega um ponto, normalmente quando a história tem muitas ramificações que se reencontram e condições aninhadas, em que escrever JSON na mão vira sofrimento. Aí vale olhar ferramenta dedicada.

Ink (da inkle) é uma linguagem de roteiro feita pra prosa ramificada. Foi usada em jogos como Heaven's Vault e 80 Days. A sintaxe é pensada pra escritor, não pra programador:

=== merchant_greeting ===
Bem-vindo à minha loja!
+ [Mostre suas armas] -> weapons
+ [Preciso de poções] -> potions
+ [Até logo] -> END

=== weapons ===
Temos as melhores lâminas da região.
{ player_gold >= 100: -> can_afford | -> too_poor }

Yarn Spinner é o mais popular no Godot e no Unity hoje, com plugin oficial pras duas engines. O formato lembra um roteiro de filme:

title: MerchantGreeting
---
Mercador: Olá, viajante! Como posso ajudar?
-> Mostre suas armas
    Mercador: Ah, um conhecedor! Veja estas belezas.
    <<jump WeaponsShop>>
-> Preciso de poções
    Mercador: Temos os melhores elixires!
    <<jump PotionsShop>>
===

Minha regra: se o seu jogo tem diálogo simples e linear com poucas escolhas, o sistema próprio que montamos aqui é mais que suficiente e você não carrega dependência. Se a narrativa é o coração do jogo e os escritores vão mexer no texto sem você, parta pro Yarn Spinner. Não escreva seu próprio Ink, alguém já fez e fez melhor.

UI/UX: os detalhes que ninguém comenta mas todo mundo sente

O código faz funcionar. Esses detalhes fazem parecer bom.

Legibilidade primeiro. Fonte sem serifa, contraste alto, e um padding generoso na caixa. Texto colado na borda parece amador. Limite o comprimento da linha pra algo em torno de 60 a 70 caracteres, porque linha muito longa o olho se perde voltando.

Feedback constante. O indicador de "continuar" precisa se mexer (pisca ou balança), senão o jogador não sabe se a fala acabou ou travou. Botão de escolha precisa de estado de hover óbvio. Transição com fade ao trocar de diálogo, nunca corte seco.

Respeite o tempo do jogador. Três coisas que parecem opcionais mas mudam tudo em jogo com muito texto:

  • Pular fala já vista: ninguém quer reler diálogo depois de morrer no boss.
  • Log de histórico: salva o que já foi dito, pro jogador que se perdeu.
  • Velocidade ajustável: tem gente que lê rápido e odeia esperar a animação.
var dialogue_seen: Dictionary = {}
var dialogue_log: Array = []

func mark_as_seen(dialogue_id: String) -> void:
    dialogue_seen[dialogue_id] = true

func can_skip(dialogue_id: String) -> bool:
    return dialogue_seen.has(dialogue_id)

func add_to_log(speaker: String, text: String) -> void:
    dialogue_log.append({ "speaker": speaker, "text": text })

Localização: pense nisso cedo

Traduzir um jogo depois que ele está pronto, com texto espalhado pelo código, é um dos piores retrabalhos que existe. Se há qualquer chance de você lançar em mais de um idioma, separe o texto desde o começo.

O Godot já tem um sistema de tradução nativo bom (arquivos CSV ou .po que você carrega como Translation, e a função tr()). Use ele de verdade num projeto sério. Mas pra você entender o conceito sem complicar, a ideia é só ter uma camada que troca a chave pelo texto no idioma atual:

var current_language: String = "pt_BR"

var translations: Dictionary = {
    "pt_BR": { "merchant_greeting": "Bem-vindo à minha loja!" },
    "en_US": { "merchant_greeting": "Welcome to my shop!" },
}

func get_text(key: String) -> String:
    var lang: Dictionary = translations.get(current_language, {})
    return lang.get(key, key)  # cai pra chave se faltar tradução

Repare que se a tradução não existe, ele devolve a própria chave em vez de quebrar. Isso te avisa visualmente no jogo (você vê merchant_greeting na tela) qual texto faltou traduzir. Pequeno truque, salva muito tempo de revisão.

Antes de considerar pronto: teste isto

Sistema de diálogo é traiçoeiro porque um caminho ramificado pode estar quebrado e você só descobre quando o jogador escolhe aquela opção específica. Rode esta lista:

  • Todo diálogo carrega sem erro (cheque o console por push_error).
  • Toda escolha leva ao diálogo certo. Teste os caminhos, não só o principal.
  • Variáveis são substituídas (nenhum {player_name} aparece cru na tela).
  • Condições liberam e escondem as escolhas certas.
  • Não existe beco sem saída: todo caminho ou continua ou chega ao fim.
  • A UI fica legível em resoluções diferentes.
  • Pular fala e histórico funcionam.

Pra caçar os bugs de fluxo, deixe uma função de debug à mão:

func debug_dialogue_state() -> void:
    print("Diálogo atual: ", current_dialogue.get("dialogue_id", "?"))
    print("Linha: ", current_line_index)
    print("Histórico: ", dialogue_history)

Por onde começar

Não tente fazer o sistema completo de uma vez. Esse é o erro clássico, e eu vi muito projeto morrer assim. O caminho que funciona:

  1. Faça só o básico: carregar JSON, mostrar texto, avançar com uma tecla.
  2. Escreva três ou quatro conversas reais do seu jogo e teste com elas.
  3. Aí sim adicione escolhas e ramificação.
  4. Depois o efeito de digitação e o som.
  5. Por último, condições, efeitos e localização, quando o jogo pedir.

Cada passo desses já é um sistema utilizável. Você nunca fica com um monte de código pela metade que não roda. Esse é o jeito que eu construo qualquer sistema hoje, depois de anos fazendo o contrário e me arrependendo.

O melhor sistema de diálogo não é o mais cheio de recurso. É o que some na frente da sua história e deixa a escolha do jogador importar. Comece pequeno, coloque texto de verdade pra rodar logo, e expanda quando o jogo precisar, não antes.

::blog-cta{title="Pronto para Criar Jogos Profissionais?" description="Aprenda a desenvolver sistemas complexos como diálogo, inventário e IA em nossos cursos práticos. Do básico ao avançado." buttonText="Candidate-se Agora" icon="fas fa-comments" variant="highlight"}::