Olá a todos, meu nome é Dmitrii Ivashchenko e sou engenheiro de software na MY.GAMES. Neste artigo, falaremos sobre o desenvolvimento de uma interface de usuário no Unity com base em estados e marcação de elementos.
Em primeiro lugar, deve-se notar que estaremos falando no contexto da tecnologia Unity UI (uGUI), que ainda é recomendada para Runtime de acordo com a documentação . A abordagem descrita não é aplicável ao UI Toolkit , IMGUI ou outros sistemas de construção de UI.
Na maioria das vezes, em projetos do Unity , você encontrará implementações de interface do usuário construídas em classes View herdadas de MonoBehaviour
e salpicadas com um grande número de campos SerializeField
. Essa abordagem fornece controle total sobre o comportamento da interface do usuário, mas também torna necessário escrever uma grande quantidade de código nos níveis de exibição e apresentador (dependendo da arquitetura usada).
Freqüentemente, conforme o desenvolvimento do projeto continua, essas classes aumentam para tamanhos incríveis, e os próprios componentes do GameObject são cobertos com um grande número de links para objetos internos:
Modificar componentes assim também não é agradável: para obter uma referência a um novo elemento em uma classe, você precisa adicionar SerializeField
, recompilar o código, localizar o novo campo no componente prefab e arrastar o objeto necessário para ele. À medida que o projeto cresce, o tempo de compilação, o número de campos e a complexidade da organização dos prefabs também aumentam.
Como resultado, acabamos com subclasses volumosas e sobrecarregadas de MonoBehaviour
(ou um grande número de pequenas, dependendo de sua preferência).
Também vale a pena considerar que qualquer alteração no comportamento de tal interface do usuário é uma tarefa para o programador, e essa tarefa vem com todos os custos associados: revisão de código, resolução de conflitos de mesclagem, cobertura de código com testes e assim por diante.
SerializeField
correspondente e, em seguida, uma grande quantidade de código é escrita para fazê-lo funcionar de acordo com os requisitos . Naturalmente, apenas um programador pode lidar com isso, e a implementação acaba sendo longa, cara e supereficiente (geralmente muito mais eficiente do que qualquer um pode perceber).
Agora que destacamos algumas das dificuldades de trabalhar com uGUI, gostaria de falar sobre uma abordagem diferente para resolver esse problema.
Durante meu trabalho em um dos meus projetos de estimação, desenvolvi uma biblioteca para desenvolvimento estruturado de interface do usuário no Unity. Mais tarde, minha equipe e eu testamos em produção e ficamos satisfeitos com os resultados.
O código-fonte da biblioteca está disponível para download no GitHub .
O elemento-chave da biblioteca é o componente StatefulComponent
. Este componente é colocado no GameObject raiz de cada tela e contém todas as referências necessárias aos elementos internos, distribuídos pelas abas:
Cada link é nomeado com base em sua função . Do ponto de vista do código, o conjunto de funções é um enum
regular. Conjuntos separados de funções são preparados para cada tipo de elemento da interface do usuário (botões, imagens, textos, etc.):
public enum ButtonRole { ... } public enum ImageRole { ... } public enum TextRole { ... } ...
As funções são geradas diretamente do componente e não há necessidade de editar manualmente o enum
. Esperar pela recompilação ao criar uma função também não é necessário, pois esses elementos enum
podem ser usados imediatamente após a criação.
Para simplificar os conflitos de mesclagem, os valores de enumeração são calculados com base nos nomes dos elementos:
[StatefulUI.Runtime.RoleAttributes.ButtonRoleAttribute] public enum ButtonRole { Unknown = 0, Start = -1436209294, // -1436209294 == "Start".GetHashCode() Settings = 681682073, Close = -1261564850, Quests = 1375430261, }
Isso permite que você evite quebrar valores serializados em prefabs se você e seus colegas criarem simultaneamente novas funções para botões em diferentes ramificações.
Cada tipo de elemento da interface do usuário (botões, textos, imagens) está localizado em sua própria guia:
Ao usar papéis, a marcação completa de todos os elementos dentro do prefab é alcançada. Conjuntos de SerializeField
não são mais necessários para acessar imagens e textos, bastando ter uma referência a StatefulComponent
e saber o papel da imagem desejada para, por exemplo, substituir seu sprite.
UnityEngine.UI.Text
e TextMeshProUGUI
UnityEngine.UI.InputField
e TMP_InputField
Existem métodos correspondentes para trabalhar com objetos anotados. No código, você pode usar uma referência a StatefulComponent
ou herdar a classe de StatefulView
:
public class ExamplePresenter { private StatefulComponent _view; public void OnOpen() { _view.GetButton(ButtonRole.Settings).onClick.AddListener(OnSettingsClicked); _view.GetButton(ButtonRole.Close).onClick.AddListener(OnCloseClicked); _view.GetSlider(SliderRole.Volume).onValueChanged.AddListener(OnVolumeChanged); } } public class ExampleScreen : StatefulView { private void Start() { SetText(TextRole.Title, "Hello World"); SetTextValues(TextRole.Timer, hours, minutes, seconds); SetImage(ImageRole.UserAvatar, avatarSprite); } }
A aba com textos, além da função e link para o objeto, contém as seguintes colunas:
A biblioteca não inclui um subsistema integrado para trabalhar com traduções. Para conectar seu sistema de localização, você precisará criar uma implementação da interface ILocalizationProvider
. Isso pode ser construído, por exemplo, com base em seu back-end, ScriptableObjects ou Google Sheets.
public class HardcodeLocalizationProvider : ILocalizationProvider { private Dictionary<string, string> _dictionary = new Dictionary<string, string> { { "timer" , "{0}h {1}m {2}s" }, { "title" , "Título do Jogo" }, { "description" , "Descrição longa do jogo" }, }; public string GetPhrase(string key, string defaultValue) { return _dictionary.TryGetValue(key, out var value) ? value : defaultValue; } }
Ao clicar no botão Copiar localização, o conteúdo das colunas Código e Valor será copiado para a área de transferência em um formato adequado para colar no Planilhas Google.
Freqüentemente, para facilitar a reutilização, partes separadas da interface do usuário são extraídas em prefabs separados. StatefulComponent
também nos permite criar uma hierarquia de componentes, onde cada componente trabalha apenas com seus próprios elementos de interface filho.
Na guia Inner Comps, você pode atribuir funções a componentes internos:
As funções configuradas podem ser usadas no código de forma semelhante a outros tipos de elementos:
var header = GetInnerComponent(InnerComponentRole.Header); header.GetButton(ButtonRole.Close).onClick.AddListener(OnCloseClicked); header.SetText(TextRole.Title, "Header Title"); var footer = GetInnerComponent(InnerComponentRole.Footer); footer.GetButton(ButtonRole.Continue).onClick.AddListener(OnContinueClicked); footer.SetText(TextRole.Message, "Footer Message");
Para criar uma lista de elementos semelhantes, você pode usar o componente ContainerView
. Você precisa especificar o prefab para instanciação e o objeto raiz (opcional). No modo de edição, você pode adicionar e remover elementos usando StatefulComponent
:
É conveniente usar StatefulComponent
para marcar o conteúdo de prefabs instanciados. Em Runtime, você pode usar os métodos AddInstance<T>
, AddStatefulComponent
ou FillWithItems
para preencher o contêiner:
var container = GetContainer(ContainerRole.Players); container.Clear(); container.FillWithItems(_player, (StatefulComponent view, PlayerData data) => { view.SetText(TextRole.Name, data.Name); view.SetText(TextRole.Level, data.Level); view.SetImage(ImageRole.Avatar, data.Avatar); });
Se o Object.Instantiate()
padrão não for adequado para a criação de objetos, você pode substituir esse comportamento, por exemplo, para instanciação usando o Zenject:
StatefulUiManager.Instance.CustomInstantiateMethod = prefab => { return _diContainer.InstantiatePrefab(prefab); };
Componentes internos e contêineres fornecem aninhamento estático e dinâmico para StatefulComponent
, respectivamente.
Consideramos a marcação de prefabs, localização e instanciação. Agora é hora de passar para a parte mais interessante — desenvolver UIs com base em estados.
Consideraremos o conceito de estado como um conjunto nomeado de alterações em um prefab. O nome, neste caso, é uma função da enumeração StateRole
, e exemplos de alterações no prefab podem ser:
Um conjunto de alterações (descrição do estado) pode ser configurado na guia Estados. Um estado configurado pode ser aplicado diretamente do inspetor:
Um estado configurado pode ser aplicado a partir do código usando o método SetState
:
switch (colorScheme) { case ColorScheme.Orange: SetState(StateRole.Orange); break; case ColorScheme.Red: SetState(StateRole.Red); break; case ColorScheme.Purple: SetState(StateRole.Purple); break; }
Na aba Tools, quando o parâmetro Apply Initial State On Enable estiver habilitado, pode-se configurar o State que será aplicado imediatamente após a instanciação do objeto.
O uso de estados permite uma redução significativa na quantidade de código necessária no nível da classe View. Simplesmente descreva cada estado de sua tela como um conjunto de alterações no StatefulComponent
e aplique o estado necessário do código, dependendo da situação do jogo.
Na verdade, desenvolver uma IU baseada em estados é incrivelmente conveniente. Tanto é assim que, com o tempo, isso leva a outro problema — à medida que o projeto evolui, a lista de estados para uma única janela pode crescer até um tamanho indisciplinado e, portanto, torna-se difícil de navegar. Além disso, existem estados que só fazem sentido no contexto de alguns outros estados. Para resolver esse problema, Statful UI possui outra ferramenta: State Tree. Você pode acessá-lo clicando no botão State Tree Editor na guia States.
Os estados pai (neste exemplo , Reward ) são aplicados toda vez que os estados filho são chamados:
O gerenciamento de um StatefulComponent
configurado se resume a uma quantidade mínima de código simples e compreensível que preenche os componentes com os dados necessários e muda os estados no momento certo:
public void ShowIntro() { SetState(StateRole.Intro); } public void ShowReward(IReward reward) { // Update the inner view with the reward reward.UpdateView(GetInnerComponent(InnerComponentRole.Reward)); // Switch on the type of reward switch (reward) { case ICardsReward cardsReward: SetState(StateRole.Cards); break; case IMoneyReward moneyReward: SetState(StateRole.Money); break; case IEmojiReward emojiReward: SetState(StateRole.Emoji); break; } } public void ShowResults(IEnumerable<IReward> rewards) { SetState(StateRole.Results); // Fill the container with the rewards GetContainer(ContainerRole.TotalReward) .FillWithItems(rewards, (view, reward) => reward.UpdateView(view)); }
As funções destinam-se a fornecer uma maneira conveniente e inequívoca de nomear links e estados para uso posterior no código. No entanto, há situações em que descrever um estado exigiria um nome muito longo e seria mais conveniente deixar um pequeno comentário sobre o que esse link aponta ou qual comportamento o estado reflete. Para esses casos, cada link e estado em um StatefulComponent
permite adicionar uma descrição:
Você já deve ter notado os botões Copiar API e Copiar documentos em cada guia — eles copiam as informações da seção selecionada. Além desses, existem botões semelhantes na guia Ferramentas - eles copiam as informações de todas as seções de uma só vez. Ao clicar no botão Copiar API, o código gerado para gerenciar esse objeto StatfulComponent
será copiado para a área de transferência. Aqui está um exemplo para nossa janela de recompensas:
// Insert the name of the chest here SetText(TextRole.Title, "Lootbox"); // Button to proceed to the reward issuance phase GetButton(ButtonRole.Overlay); // Button to display information about the card GetButton(ButtonRole.Info); // Container for displaying the complete list of awarded rewards GetContainer(ContainerRole.TotalReward); // Insert the card image here SetImage(ImageRole.Avatar, null); // Animated appearance of a chest SetState(StateRole.Intro);
Ao clicar no botão Copiar documentos, a documentação para este prefab será copiada para a área de transferência no formato Markdown:
### RewardScreen Buttons: - Overlay - Button to proceed to the reward issuance phase - Info - Button to display information about the card Texts: - Title - Insert the name of the chest here Containers: - TotalReward - Container for displaying the complete list of awarded rewards Images: - Avatar - Insert the card image here States: - Intro - Animated appearance of a chest - Cards - Displaying rewards in the form of a card - Money - Displaying rewards in the form of game currency - Emoji - Displaying rewards in the form of an emoji - Results - Displaying a complete list of issued rewards
É óbvio que cometer um erro ao implementar esta tela com instruções tão detalhadas é bastante difícil. Você pode facilmente manter informações atualizadas sobre sua organização de interface do usuário na base de conhecimento do projeto.
Ao mesmo tempo, Stateful UI permite delegar a criação de prefabs de UI. Na verdade, a marcação baseada em estado permite testar totalmente o comportamento do prefab antes de passá-lo para os programadores. Isso significa que designers de jogos , designers técnicos ou até mesmo desenvolvedores de interface do usuário separados podem preparar prefabs. Além disso, como uma API é criada entre o código e o prefab, a programação e a configuração dos prefabs podem ser feitas em paralelo! Tudo o que é necessário é formular a API com antecedência. Mas, mesmo que a tarefa de configurar prefabs permaneça com os programadores, o uso da Stateful UI acelera significativamente esse trabalho.
Como vimos, Stateful UI simplifica significativamente o trabalho com estados de elementos de UI. Longos ciclos não são mais necessários para criar SerializeFields, recompilar código e procurar referências entre um grande número de campos de classe View. Nas próprias classes View, não é mais necessário escrever uma grande quantidade de código para operações repetitivas, como ativar e desativar objetos ou alterar a cor do texto.
A biblioteca permite uma abordagem consistente para organizar layouts em um projeto, marcar objetos em prefabs, criar estados, vinculá-los a elementos de interface do usuário e fornecer uma API e documentação para gerenciamento de interface do usuário. Ele também permite delegar a criação de prefabs de interface do usuário e acelerar o trabalho com eles.
Expandir os recursos dos Estados, oferecer suporte a novos tipos de alterações na interface do usuário na Descrição, como novos tipos de animações, reproduzir sons nos estados e assim por diante
Adicionando suporte para paletas de cores para colorir texto e imagens
Adicionando suporte para listas de itens com reutilização de GameObjects
Compatível com um número maior de elementos de interface do usuário do Unity
Automatizando o descarregamento de textos adicionados para localização
Implementando um Framework de Teste. Como temos marcação exaustiva de nossos prefabs, podemos criar cenários baseados em ScriptableObject fáceis de configurar no seguinte formato:
Clique no botão ButtonRole.Settings
Verifique se o texto em TextRole.SomeText
é igual a "algum valor"
Verifique a imagem em ImageRole.SomeImage
para garantir que seja igual a um determinado sprite
Um sistema tutorial. Da mesma forma que o teste, o layout marcado permite criar cenários de tutorial baseados em ScriptableObject na forma de instruções como "Mostrar ponteiro no botão ButtonRole.UpgradeHero
".
O código-fonte do projeto está disponível no GitHub . Você é bem-vindo para criar problemas ou contribuir para a biblioteca!