Voltar para o Blog
Quest Log

Sistema de Diálogo na Unity com ScriptableObject

Caixa de diálogo de RPG na parte de baixo da tela com o retrato de um personagem e texto sendo digitado

Unity dialogue system sem asset pago: falas em ScriptableObject, caixa com nome e retrato, máquina de escrever, avanço pelo Input System e NPC que conversa.

Ferramenta:UnityC#

Sistema de Diálogo na Unity com ScriptableObject

Um unity dialogue system simples e sólido tem três partes: os dados das falas guardados em ScriptableObject (quem fala, retrato e texto), a caixa de diálogo na UI que mostra uma fala por vez com efeito de máquina de escrever, e um gatilho no NPC que inicia a conversa quando o jogador interage. Dá para fazer tudo isso sem asset pago, com umas 150 linhas de C#.

Aqui você monta as três partes no Unity 6.3 LTS: falas editáveis no Inspector, caixa com nome e retrato, digitação letra por letra que respeita rich text, avanço e pular com o Input System e um NPC que conversa. No fim, os erros que mais aparecem: texto cortado, input duplo e jogador andando no meio da conversa.

Como estruturar um sistema de diálogo na Unity

O erro mais comum é escrever as falas dentro do script do NPC, num array de strings. Funciona para uma conversa. Com dez NPCs, cada mudança de texto exige abrir a cena certa, achar o objeto certo e torcer para não apagar nada.

A separação que escala:

  • Dados (Fala e Conversa): só informação, sem lógica. Ficam em arquivos .asset na pasta do projeto.
  • Apresentação (CaixaDeDialogo): sabe mostrar uma conversa qualquer. Existe uma só na cena.
  • Gatilho (NpcFalante): sabe qual conversa iniciar e quando.

Antes de programar, vale pensar em que tipo de diálogo o jogo precisa: linear, com escolhas, com condições. O post sobre sistema de diálogo em jogos discute essas estruturas sem engine nenhuma. Aqui vamos de diálogo linear, que resolve a maioria dos jogos de plataforma, ação e aventura.

Dados das falas com ScriptableObject

Cada fala tem nome, retrato e texto. Uma classe serializável comum guarda isso.

Arquivo Fala.cs:

using System;
using UnityEngine;

[Serializable]
public class Fala
{
    [SerializeField] private string nomeDoPersonagem;
    [SerializeField] private Sprite retrato;
    [SerializeField, TextArea(2, 5)] private string texto;

    public string NomeDoPersonagem => nomeDoPersonagem;
    public Sprite Retrato => retrato;
    public string Texto => texto;
}

A conversa é um ScriptableObject com uma lista de falas.

Arquivo Conversa.cs:

using UnityEngine;

[CreateAssetMenu(fileName = "NovaConversa", menuName = "Dialogo/Conversa")]
public class Conversa : ScriptableObject
{
    [SerializeField] private Fala[] falas;

    public int Quantidade => falas.Length;

    public Fala ObterFala(int indice)
    {
        return falas[indice];
    }
}

O atributo CreateAssetMenu coloca a opção no menu de criação. Clique com o botão direito numa pasta (por exemplo, Assets/Dialogos) e use Create > Dialogo > Conversa. O arquivo novo aparece no Project; selecione e preencha as falas no Inspector. O TextArea(2, 5) faz o campo de texto ter várias linhas, o que deixa escrever confortável.

Por que ScriptableObject e não JSON? Para começar, o Inspector é o editor de diálogo: arrasta o retrato, escreve o texto, pronto. Se mais tarde o jogo tiver milhares de falas e tradução, você exporta para planilha ou JSON. Para entender o que mais dá para fazer com esse tipo de asset (itens, inimigos, configurações), o post da pauta sobre ScriptableObject na Unity aprofunda.

Caixa de diálogo: nome, retrato e texto

Monte a UI no Canvas do HUD:

  1. Um painel CaixaDialogo ancorado embaixo, esticado na largura, com uns 260 pixels de altura e uma Image de fundo escura.
  2. Dentro, uma Image Retrato à esquerda, 200 x 200, com Preserve Aspect marcado.
  3. Um TextMeshPro Nome, em negrito, acima do texto.
  4. Um TextMeshPro Texto, ocupando o resto da área, com Wrapping ligado e Overflow em Truncate (o motivo está nos erros comuns).
  5. Um pequeno ícone IndicadorContinuar (uma seta) no canto inferior direito, que aparece quando a fala terminou de digitar.
  6. Desligue o painel CaixaDialogo.

Efeito máquina de escrever com corrotina

O jeito ingênuo de digitar letra por letra é cortar a string: texto.text = fala.Substring(0, i). Isso tem dois problemas: cria uma string nova por letra e quebra tags de rich text. Uma fala com <color=red>perigo</color> mostra <colo na tela no meio da digitação.

O TextMeshPro tem um recurso feito para isso: maxVisibleCharacters. Você coloca o texto inteiro de uma vez e controla quantos caracteres aparecem. As tags já foram processadas, então a cor aparece certa desde a primeira letra, e o layout do texto não pula de linha no meio da digitação porque a quebra já está calculada para o texto completo.

Arquivo CaixaDeDialogo.cs:

using System.Collections;
using TMPro;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.UI;

public class CaixaDeDialogo : MonoBehaviour
{
    public static bool EmDialogo { get; private set; }

    [SerializeField] private GameObject painel;
    [SerializeField] private Image retrato;
    [SerializeField] private TMP_Text nome;
    [SerializeField] private TMP_Text texto;
    [SerializeField] private GameObject indicadorContinuar;
    [SerializeField] private float caracteresPorSegundo = 40f;

    private InputAction acaoAvancar;
    private Conversa conversaAtual;
    private int indiceDaFala;
    private bool digitando;
    private int frameDeInicio;
    private Coroutine rotinaDeDigitacao;

    private void Awake()
    {
        EmDialogo = false;
        acaoAvancar = new InputAction("Avancar", InputActionType.Button);
        acaoAvancar.AddBinding("<Keyboard>/space");
        acaoAvancar.AddBinding("<Keyboard>/enter");
        acaoAvancar.AddBinding("<Mouse>/leftButton");
        acaoAvancar.AddBinding("<Gamepad>/buttonSouth");
        painel.SetActive(false);
    }

    private void OnEnable()
    {
        acaoAvancar.performed += AoAvancar;
        acaoAvancar.Enable();
    }

    private void OnDisable()
    {
        acaoAvancar.performed -= AoAvancar;
        acaoAvancar.Disable();
    }

    private void OnDestroy()
    {
        acaoAvancar.Dispose();
    }

    public void Iniciar(Conversa conversa)
    {
        if (EmDialogo || conversa == null || conversa.Quantidade == 0)
        {
            return;
        }

        conversaAtual = conversa;
        indiceDaFala = 0;
        frameDeInicio = Time.frameCount;
        EmDialogo = true;
        painel.SetActive(true);
        MostrarFala();
    }

    private void AoAvancar(InputAction.CallbackContext contexto)
    {
        if (!EmDialogo || Time.frameCount == frameDeInicio)
        {
            return;
        }

        if (digitando)
        {
            CompletarFala();
            return;
        }

        indiceDaFala++;
        if (indiceDaFala >= conversaAtual.Quantidade)
        {
            Encerrar();
        }
        else
        {
            MostrarFala();
        }
    }

    private void MostrarFala()
    {
        Fala fala = conversaAtual.ObterFala(indiceDaFala);
        nome.text = fala.NomeDoPersonagem;
        retrato.sprite = fala.Retrato;
        retrato.enabled = fala.Retrato != null;

        if (rotinaDeDigitacao != null)
        {
            StopCoroutine(rotinaDeDigitacao);
        }

        rotinaDeDigitacao = StartCoroutine(Digitar(fala.Texto));
    }

    private IEnumerator Digitar(string conteudo)
    {
        digitando = true;
        indicadorContinuar.SetActive(false);

        texto.text = conteudo;
        texto.maxVisibleCharacters = 0;
        texto.ForceMeshUpdate();

        int totalDeCaracteres = texto.textInfo.characterCount;
        float intervalo = 1f / caracteresPorSegundo;

        for (int i = 1; i <= totalDeCaracteres; i++)
        {
            texto.maxVisibleCharacters = i;
            yield return new WaitForSeconds(intervalo);
        }

        CompletarFala();
    }

    private void CompletarFala()
    {
        if (rotinaDeDigitacao != null)
        {
            StopCoroutine(rotinaDeDigitacao);
            rotinaDeDigitacao = null;
        }

        texto.maxVisibleCharacters = int.MaxValue;
        digitando = false;
        indicadorContinuar.SetActive(true);
    }

    private void Encerrar()
    {
        EmDialogo = false;
        conversaAtual = null;
        painel.SetActive(false);
    }
}

Coloque o script num objeto sempre ativo (o Canvas, por exemplo), não no próprio CaixaDialogo, que começa desligado. Arraste painel, retrato, nome, texto e indicador para os campos.

O ponto central é o ForceMeshUpdate() antes de ler textInfo.characterCount. Sem ele, o TMP ainda não processou o texto novo naquele frame e a contagem vem zerada ou da fala anterior. A contagem é de caracteres visíveis, sem as tags, e é por isso que o loop não "digita" <color=red>.

Isso cobre a mecânica. Num jogo de verdade, o diálogo encosta em quase tudo: pausa o inimigo, trava o jogador, dispara missão, troca música, salva que a conversa já aconteceu. Ligar esses sistemas sem criar um emaranhado de referências, num projeto que você termina e publica, é o trecho em que mais gente para.

Curso de Unity
Quer fazer seu primeiro jogo na Unity?

No curso de Unity 6 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 Unity
Suporte no WhatsAppAcesso vitalícioGarantia de 7 dias

Avançar e pular com o Input System

O script cria uma InputAction própria com Espaço, Enter, clique e o botão sul do controle. Criar no código evita depender de editar o arquivo de ações do projeto, e o Dispose no OnDestroy libera a ação quando a cena sai.

O comportamento do AoAvancar segue o padrão que jogadores esperam:

  • Fala ainda digitando: o primeiro aperto completa a fala inteira na hora.
  • Fala completa: o aperto vai para a próxima.
  • Última fala completa: o aperto fecha a caixa.

Esse "primeiro aperto completa, segundo avança" é o que deixa quem lê rápido jogar no próprio ritmo sem pular texto sem querer. Para ajustar a velocidade, mude caracteresPorSegundo: entre 30 e 50 é confortável para leitura em português.

Iniciar o diálogo ao interagir com NPC

O NPC precisa de um Collider 2D com Is Trigger marcado (um Circle Collider 2D com raio de 1.5 funciona bem como área de conversa) e deste script. O jogador precisa da tag Player e de um Rigidbody2D, para o trigger disparar.

Arquivo NpcFalante.cs:

using UnityEngine;
using UnityEngine.InputSystem;

public class NpcFalante : MonoBehaviour
{
    [SerializeField] private Conversa conversa;
    [SerializeField] private CaixaDeDialogo caixa;
    [SerializeField] private GameObject avisoInteragir;

    private InputAction acaoInteragir;
    private bool jogadorPerto;

    private void Awake()
    {
        acaoInteragir = new InputAction("Interagir", InputActionType.Button);
        acaoInteragir.AddBinding("<Keyboard>/e");
        acaoInteragir.AddBinding("<Gamepad>/buttonWest");
        avisoInteragir.SetActive(false);
    }

    private void OnEnable()
    {
        acaoInteragir.Enable();
    }

    private void OnDisable()
    {
        acaoInteragir.Disable();
    }

    private void OnDestroy()
    {
        acaoInteragir.Dispose();
    }

    private void Update()
    {
        avisoInteragir.SetActive(jogadorPerto && !CaixaDeDialogo.EmDialogo);

        if (jogadorPerto && !CaixaDeDialogo.EmDialogo && acaoInteragir.WasPressedThisFrame())
        {
            caixa.Iniciar(conversa);
        }
    }

    private void OnTriggerEnter2D(Collider2D outro)
    {
        if (outro.CompareTag("Player"))
        {
            jogadorPerto = true;
        }
    }

    private void OnTriggerExit2D(Collider2D outro)
    {
        if (outro.CompareTag("Player"))
        {
            jogadorPerto = false;
        }
    }
}

O avisoInteragir é um ícone de "E" em cima do NPC que aparece só quando dá para conversar. Para 3D, troque os métodos por OnTriggerEnter e OnTriggerExit com Collider.

Ritmo da digitação: pausa na pontuação e som

Digitação em velocidade constante soa robótica. Dois ajustes pequenos fazem a fala ganhar ritmo de gente.

Pausa na pontuação. Dentro do loop do Digitar, depois de mostrar o caractere i, olhe qual foi: texto.textInfo.characterInfo[i - 1].character. Se for ponto, interrogação ou exclamação, espere umas quatro vezes o intervalo normal; se for vírgula, o dobro. O jogador "ouve" a frase respirar, e reticências viram suspense de graça.

Som de digitação. Um "blip" curto a cada dois ou três caracteres, tocado por uma AudioSource com PlayOneShot, é o que faz muitos jogos parecerem ter voz sem dublagem. Variar o pitch da AudioSource um pouco a cada blip (entre 0.95 e 1.05) evita o som de metralhadora. Um blip diferente por personagem, guardado na própria Fala como um AudioClip, dá identidade a cada um: o ferreiro grave, a criança aguda.

Os dois ajustes ficam no mesmo loop e não mudam a estrutura do sistema. É por isso que vale manter a digitação isolada numa corrotina só: tudo que é ritmo mora num lugar.

Erros comuns: texto cortado, input duplo e diálogo travando o jogador

Primeira fala pulada. Se a interação e o avanço usam o mesmo botão, o aperto que abriu a conversa também avança. O frameDeInicio no script ignora o avanço no frame em que o diálogo começou.

Texto cortado ou saindo da caixa. Fala longa demais para a área. Com Overflow em Truncate, o texto excedente some e você percebe no teste. Melhor ainda: limite o tamanho no próprio dado (três linhas por fala) e quebre falas longas em duas. Quem escreve diálogo para jogo sabe que fala curta lê melhor; o post sobre narrativa em jogos fala sobre isso.

Jogador anda e pula durante a conversa. A caixa não sabe do jogador. No script de movimento, no começo do Update, retorne se CaixaDeDialogo.EmDialogo for verdadeiro. Se o jogador usa as ações do projeto, desligar o mapa Player durante o diálogo resolve de uma vez.

Retrato esticado. Falta Preserve Aspect na Image do retrato.

Diálogo travado sem fechar. A conversa tem uma fala vazia no fim, ou o Iniciar foi chamado com a caixa já aberta. O if (EmDialogo ...) no começo de Iniciar evita conversas sobrepostas.

Diálogo congelado no menu de pausa. O WaitForSeconds respeita Time.timeScale, então a digitação para na pausa, que é o comportamento certo. O input de avançar, porém, continua: bloqueie o avanço quando o jogo estiver pausado.

Próximo passo: escolhas e eventos

Com o diálogo linear funcionando, o próximo passo natural é acrescentar no fim da Conversa uma lista de escolhas, cada uma apontando para outra Conversa. Também vale um UnityEvent ao terminar a conversa, para abrir uma porta ou dar um item sem que a caixa precise saber o que é porta ou item.

Perguntas frequentes

Como fazer efeito de máquina de escrever no texto da Unity?

Coloque o texto inteiro no TextMeshPro e aumente maxVisibleCharacters de 0 até o total de caracteres numa corrotina. Esse jeito não quebra tags de rich text como cor e negrito, que é o que acontece ao cortar a string com Substring.

Por que usar ScriptableObject para guardar diálogos?

Porque cada conversa vira um arquivo no projeto, editável no Inspector, separado do código e da cena. Vários NPCs podem usar a mesma conversa, e quem escreve o texto não precisa abrir script nenhum.

Por que o diálogo pula a primeira fala quando aperto para falar com o NPC?

O mesmo aperto que inicia o diálogo é lido pela caixa como avançar. Ignore o input de avanço no frame em que o diálogo começou, guardando Time.frameCount no início e comparando antes de avançar.

Como impedir o jogador de andar durante o diálogo?

Exponha um estado de diálogo ativo (por exemplo, uma propriedade estática) e faça o controle do jogador checar esse estado, ou desligue o mapa de ações Player do Input System enquanto a conversa estiver aberta.