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 (
FalaeConversa): só informação, sem lógica. Ficam em arquivos.assetna 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:
- Um painel
CaixaDialogoancorado embaixo, esticado na largura, com uns 260 pixels de altura e uma Image de fundo escura. - Dentro, uma Image
Retratoà esquerda, 200 x 200, com Preserve Aspect marcado. - Um TextMeshPro
Nome, em negrito, acima do texto. - Um TextMeshPro
Texto, ocupando o resto da área, com Wrapping ligado e Overflow em Truncate (o motivo está nos erros comuns). - Um pequeno ícone
IndicadorContinuar(uma seta) no canto inferior direito, que aparece quando a fala terminou de digitar. - 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.
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.



