Soluções arquitetônicas para um jogo para celular. Parte 3: Vista sobre o impulso do jato



Em artigos anteriores, descrevemos como um modelo deve ser organizado de forma conveniente e com amplos recursos, que tipo de sistema de comando seria adequado para ele, que atua como um controlador, é hora de falar sobre a terceira letra da nossa abreviação MVC alternativa.

Na verdade, o Assetstore possui uma biblioteca UniRX muito sofisticada e pronta para implementar, que reativa e controla a inversão da unidade. Mas falaremos sobre isso no final do artigo, porque essa ferramenta poderosa, enorme e compatível com RX para o nosso caso é bastante redundante. Fazer tudo o que precisamos é perfeitamente possível sem puxar o RX e, se você o possui, pode facilmente fazer o mesmo com ele.

Soluções arquitetônicas para um jogo para celular. Parte 1: Modelo
Soluções arquitetônicas para um jogo para celular. Parte 2: Comando e suas filas

Quando uma pessoa está apenas começando a escrever o primeiro jogo, parece lógico que ele exista uma função que desenhe toda a forma, ou parte dela, e a puxe toda vez que algo importante mudar. À medida que o tempo passa, a interface aumenta de tamanho, a forma e as partes dos moldes se tornam cem, depois duzentos, e quando a carteira muda de estado, um quarto deles precisa ser redesenhado. E então o gerente chega e diz que “como naquele jogo” você precisa fazer um pequeno ponto vermelho no botão se houver uma seção dentro do botão na qual existe uma subseção em que o botão está, e agora você tem recursos suficientes para fazer algo clicando nele isso é importante. E isso é tudo, navegou ...

O afastamento do conceito de desenho ocorre em várias etapas. Primeiro, o problema dos campos únicos é resolvido. Você tem, por exemplo, um campo no modelo e um campo de texto no qual todo o seu conteúdo deve ser exibido. Ok, iniciamos um objeto que assina atualizações desse campo e, a cada atualização, adiciona os resultados a um campo de texto. No código, algo como isto:

var observable = new ChildControl(FCPlayerModel.ASSIGNED, Player); observable.onChange(i => Assigned.text = i.ToString()) 

Agora não precisamos seguir o redesenho, basta criar esse design e tudo o que acontece no modelo cairá na interface. Bom, mas complicado, contém muitos gestos obviamente desnecessários que um programador terá que escrever com as mãos 100.500 vezes e às vezes cometer erros. Vamos agrupar esses anúncios em uma função de extensão que ocultará as letras extras sob o capô.

 Player.Get(c, FCPlayerModel.ASSIGNED).Action(c, i => Assigned.text = i.ToString()); 

Muito melhor, mas isso não é tudo. Mudar o campo do modelo para o campo de texto é uma operação tão frequente e típica que criaremos uma função de wrapper separada para ele. Agora, parece que é muito breve e bem, como me parece.

 Player.Get(c, FCPlayerModel.ASSIGNED).SetText(c, Assigned); 

Aqui, mostrei a idéia principal, pela qual serei guiado ao criar interfaces para o resto da minha vida: "Se um programador tiver que fazer algo pelo menos duas vezes, envolva-o em uma função especial conveniente e curta".

Coleta de lixo


Um efeito colateral da engenharia de interface reativa é a criação de vários objetos inscritos em algo e, portanto, não deixará a memória sem um chute especial. Para mim, nos tempos antigos, criei uma maneira que não é tão bonita, mas simples e acessível. Ao criar qualquer formulário, é criada uma lista de todos os controladores que são criados em conexão com este formulário; por questões de brevidade, é simplesmente chamado de "c". Todas as funções especiais do invólucro aceitam essa lista como o primeiro parâmetro necessário e quando DisconnectModel o formulário, ele passa a lista de todos os controles e a desestabiliza sem piedade com o código no ancestral comum. Sem beleza e graça, mas barato, confiável e relativamente prático. Você pode ter um pouco mais de segurança se, em vez da folha de controle, exigir que o IView entre e dê isso a todos esses lugares. Essencialmente a mesma coisa, esquecer de preencher da mesma forma não funcionará, mas é mais difícil de hackear. Tenho medo de esquecer, mas não tenho medo de que alguém quebre deliberadamente o sistema, porque essas pessoas inteligentes precisam ser combatidas com um cinto e outros métodos que não sejam de software, então me limito a c.

Uma abordagem alternativa pode ser desenhada no UniRX. Cada wrapper cria um novo objeto que possui um link para o anterior que ele escuta. E, no final, é chamado o método AddTo (component), que atribui toda a cadeia de controles a algum objeto destrutível. No nosso exemplo, esse código ficaria assim:

 Player.Get(FCPlayerModel.ASSIGNED).SetText(Assigned).AddTo(this); 

Se este último proprietário da cadeia decidir ser destruído, ele enviará a todos os controles atribuídos a ele o comando "mate-se por descartar se ninguém o ouvir, exceto eu". E toda a cadeia é obedientemente limpa. Então, é claro, é muito mais conciso, mas do meu ponto de vista, há uma falha importante. AddTo pode ser acidentalmente esquecido e ninguém nunca saberá disso até que seja tarde demais.

De fato, você pode usar o hack sujo do Unity e ficar sem qualquer código adicional no modo de exibição:

 public static T AddTo<T>(this T disposable, Component component) where T : IDisposable { var composite = new CompositeDisposable(disposable); Observable .EveryUpdate() .Where(_ => component == null) .Subscribe(_ => composite.Dispose()) .AddTo(composite); return disposable; } 

Como você sabe, um link para um componente Unicomponent ou GameObject no Unity é nulo. Mas você precisa entender que esse hakokostyl cria um ouvinte de atualização para cada cadeia de controles que é destruída, e isso já é um pouco educado.

Interface independente do modelo


Nosso ideal, que, no entanto, podemos facilmente alcançar, é a situação em que podemos carregar o GameState completo a qualquer momento, tanto o modelo verificado pelo servidor quanto o modelo de dados da interface do usuário, e o aplicativo estará exatamente no mesmo estado, até o estado de todos os botões. Existem duas razões para isso. A primeira é que alguns programadores gostam de armazenar dentro do controlador de formulário, ou mesmo na própria exibição, citando o fato de que seu ciclo de vida é exatamente igual ao do próprio formulário. A segunda é que, mesmo que todos os dados do formulário estejam em seu modelo, o comando para criar e preencher o próprio formulário assume a forma de uma chamada de função explícita, com alguns parâmetros adicionais, por exemplo, em qual campo da lista deve ser focado.

Você não precisa lidar com isso se realmente não deseja a conveniência da depuração. Mas não somos assim, queremos depurar a interface tão convenientemente quanto as operações básicas do modelo. Para fazer isso, o seguinte foco. Na parte da interface do usuário do modelo, uma variável é configurada, por exemplo .main, e nela, como parte do comando, você coloca o modelo do formulário que deseja ver. O estado dessa variável é monitorado por um controlador especial; se um modelo aparecer nessa variável, ele, dependendo do seu tipo, instancia o formulário desejado, coloca-o onde é necessário e envia uma chamada para o ConnectModel (modelo). Se a variável for liberada do modelo, o controlador removerá o formulário da tela e o utilizará. Portanto, nenhuma ação para ignorar o modelo ocorre e tudo o que você fez com a interface é claramente visível no modelo ExportChanges. E então somos guiados pelo princípio de "tudo o que foi feito duas vezes" e usamos exatamente o mesmo controlador em todos os níveis da interface. Se o molde tiver um local para outro molde, será criado um modelo de interface do usuário e uma variável será criada no modelo do molde pai. Exatamente o mesmo com as listas.

Um efeito colateral dessa abordagem é que dois arquivos são adicionados a qualquer formulário, um com um modelo de dados para esse formulário e o outro, geralmente um monobah contendo links para elementos da interface do usuário que, após receber o modelo em sua função ConnectModel, criarão todos os controladores reativos para todos campos de modelo e todos os elementos da interface do usuário. Bem, é ainda mais compacto, de modo que também é conveniente trabalhar com ele, provavelmente é impossível. Se possível, escreva nos comentários.

Controles de lista


Uma situação típica é quando o modelo possui uma lista de alguns elementos. Como eu quero que tudo seja feito de maneira muito conveniente e, de preferência em uma única linha, eu também queria fazer algo para listas que seriam convenientes de manusear. Uma linha é possível, mas acaba sendo desconfortavelmente longa. Empiricamente, verificou-se que quase toda a diversidade de casos é coberta por apenas dois tipos de controles. O primeiro monitora o estado de uma coleção e chama três funções lambda, a primeira é chamada quando algum elemento é adicionado à coleção, a segunda quando o elemento sai da coleção e, finalmente, o terceiro é chamado quando os elementos da coleção alteram a ordem. O segundo tipo de controle mais frequente monitora a lista e é a fonte da lista - páginas com um número específico. Ou seja, por exemplo, segue uma Lista com um comprimento de 102 elementos, e ele próprio retorna uma Lista de 10 elementos, do 20 ao 29. E os eventos gerados são exatamente os mesmos, como se fossem uma lista em si.

Obviamente, seguindo o princípio de “criar um invólucro para tudo o que foi feito duas vezes”, um grande número de invólucros convenientes apareceu, por exemplo, um que aceita apenas a entrada da fábrica, construindo uma correspondência entre os tipos de modelos e suas Visualizações, e um link para o Canvas no qual você precisa adicionar os elementos. E muitos outros similares, apenas cerca de uma dúzia de invólucros para casos típicos.

Controles mais complexos


Às vezes, surgem situações redundantes para expressar através do modelo, por mais óbvias que sejam. Aqui, os controles que executam algum tipo de operação em um valor podem ser salvos, bem como os controles que monitoram outros controles. Por exemplo, uma situação típica: uma ação tem um preço e o botão está ativo apenas se houver mais dinheiro na conta do que o preço.

 item.Get(c, FCUnitItem.COST).Join(c, Player.Get(c, MONEY)).Func(c, (cost, money) => cost <= money).SetActive(c, BuyButton); 

De fato, a situação é tão típica que, de acordo com meu princípio, existe um invólucro pronto para ele, mas depois mostrei seu conteúdo.

Pegamos o item a ser comprado, criamos um objeto que está inscrito em um de seus campos e tem um valor do tipo long. Eles adicionaram mais um controle, que também é do tipo long, o método retornou um controle que possui um par de valores e o evento Changed é acionado quando algum deles é alterado. Em seguida, o Func cria um objeto para qualquer alteração na entrada que calcula a função e o evento Changed se o valor final for calculado. função mudou.

O compilador criará com êxito o tipo de controle necessário com base nos tipos de dados de entrada e no tipo da expressão resultante. Em casos raros, quando o tipo retornado pela função lambda não é óbvio, o compilador solicitará que você esclareça explicitamente. Por fim, a última chamada ouve o controle booleano, dependendo de qual opção liga ou desliga o botão.

De fato, o invólucro real no projeto aceita dois botões como entrada, um para o caso em que há dinheiro e o outro quando não há dinheiro suficiente, e o comando para abrir a janela modal "Comprar moedas" também fica no segundo botão. E tudo isso em uma linha simples.

É fácil ver que, usando o Join e o Func, você pode construir estruturas arbitrariamente complexas. No meu código, havia uma função que gerava controles complexos, calculando quanto um jogador poderia comprar levando em consideração o número de jogadores do seu lado e a regra de que todos poderiam exceder o orçamento em 10% se todos juntos não excederem o orçamento total. E este é um exemplo de como não é necessário fazê-lo, porque o quanto é simples e fácil depurar o que está acontecendo nos modelos é igualmente difícil de detectar um erro nos controles reativos. Você até captura a execução e gasta muito tempo para entender o que levou a ela.

Portanto, o princípio geral do uso de controles complexos é o seguinte: Ao prototipar um formulário, você pode usar estruturas em controles reativos, especialmente se não tiver certeza de que eles se tornarão mais complicados no futuro, mas assim que suspeitar que, se quebrar, você não entenderá o que aconteceu, você deve transferir imediatamente essas manipulações para o modelo e colocar os cálculos que foram feitos anteriormente nos controles nos métodos de extensão nas classes de regras estáticas.

Isso é significativamente diferente do princípio de "Faça o bem imediatamente", tão amado entre os perfeccionistas, porque vivemos em um mundo de desenvolvedores de jogos e, quando você começa a pular um formulário, não pode ter certeza absoluta do que fará em três dias. Como um de meus colegas disse: "Se eu ganhasse cinco centavos cada vez que os designers de jogos mudassem de idéia, eu já seria uma pessoa muito rica". De fato, isso não é ruim, mas vice-versa. O jogo deve se desenvolver por tentativa e erro, porque se você não está fazendo um clone estúpido, não consegue imaginar o que os jogadores realmente precisam.

Uma fonte de dados para várias visualizações


Para tanto caso arquetípico, você precisa falar sobre isso separadamente. Acontece que o mesmo modelo de um elemento como parte de um modelo de interface é renderizado em uma Visualização diferente, dependendo de onde e em que contexto isso acontece. E usamos o princípio - "um tipo, uma visão". Por exemplo, você tem um cartão de compra de armas que contém as mesmas informações simples, mas em modos diferentes de loja, ele deve ser representado por diferentes prefabs. A solução consiste em duas partes para duas situações diferentes.

A primeira é quando essa Visualização é colocada dentro de duas Visualizações diferentes, por exemplo, uma loja na forma de uma lista curta e uma loja com imagens grandes. Nesse caso, duas fábricas separadas são configuradas para ajudar, criando uma correspondência pré-fabricada. No método ConnectModel de uma Visualização, você usará uma e a outra na outra. É um caso completamente diferente se você precisar mostrar cartões com informações absolutamente idênticas em um único local, um pouco diferente. Às vezes, nesse caso, o modelo de elemento possui um campo adicional que indica o plano de fundo festivo de um elemento específico e, às vezes, é apenas que o modelo de elemento possui um herdeiro que não possui campos e só precisa ser desenhado com outra pré-fabricada. Em princípio, nada contradiz.

Parece uma solução óbvia, mas eu vi o suficiente em um código estranho em danças estranhas com um pandeiro em torno dessa situação, e considerei necessário escrever sobre isso.

Caso especial: controles com muitas dependências


Há um caso muito especial sobre o qual quero falar separadamente. Esses são controles que monitoram um número muito grande de elementos. Por exemplo, um controle que monitora uma lista de modelos e resume o conteúdo de um campo dentro de cada um dos elementos. Com um grande overtube na lista, por exemplo, preenchendo-o com dados, esse controle corre o risco de capturar o maior número de eventos sobre a alteração que houver, mais um na lista de elementos. Recalcular a função agregada tantas vezes é obviamente uma má ideia. Especialmente nesses casos, fazemos um controle que assina o evento onTransactionFinished, que se destaca no GameState, e um link para o GameState, como lembramos, está disponível em qualquer modelo. E com qualquer alteração na entrada, esse controle simplesmente marca que os dados de origem foram alterados e somente serão recontados quando receber uma mensagem sobre o final da transação ou quando descobrir que a transação já está concluída no momento em que recebeu uma mensagem do fluxo de eventos de entrada . É claro que esse controle pode não ser protegido contra mensagens desnecessárias se houver dois desses controles na cadeia de processamento de fluxo. O primeiro acumulará uma nuvem de alterações, aguardará o final da transação, iniciará o fluxo de alterações ainda mais, e há outro que já capturou várias alterações, recebeu o evento sobre o final da transação (ele teve a infelicidade de estar na lista de funções inscritas no evento anteriormente), contou tudo e depois ele bam e outro evento de mudança e conte tudo pela segunda vez. Pode ser, mas raramente e mais importante, se seus controles fazem cálculos tão monstruosos mais de uma vez em um fluxo de cálculos, então você está fazendo algo errado e precisa transferir todas essas manipulações infernais para o modelo e as regras, onde eles , de fato, o lugar.

Biblioteca pronta para UniRX


E seria possível limitar-nos a tudo o que precede e começar a escrever calmamente sua obra-prima, especialmente porque, comparado ao modelo e às equipes de controle, é muito simples e eles são escritos em menos de uma semana, se a idéia de que você estava inventando uma bicicleta não obscureceu, e tudo já está pensado e escrito antes de mim é distribuído gratuitamente a todos.

Descobrindo o UniRX, encontramos um design bonito e compatível com os padrões que pode criar threads de tudo em geral, mesclá-los de maneira inteligente, filtrá-los do thread principal para o não-principal ou retornar o controle de volta ao thread principal, que possui um monte de ferramentas prontas para enviar a lugares diferentes, etc. mais adiante. Não temos exatamente duas coisas lá: simplicidade e conveniência da depuração. Você já tentou depurar algumas construções de vários andares no Linq em etapas no depurador? Então aqui ainda é muito pior. Ao mesmo tempo, nos falta completamente para que toda essa maquinaria sofisticada foi criada. Por uma questão de simplicidade dos estados de depuração e reprodução, nos falta completamente uma variedade de fontes de sinal, tudo acontece no fluxo principal, porque flertar com multithreading no meta-jogo é completamente redundante, toda a assincronia do processamento de comandos fica oculta dentro do mecanismo de envio de comandos, e a assincronia ocupa muito dele sem muito espaço, é prestada muito mais atenção a todos os tipos de verificações, autoverificações e às possibilidades de registro e reprodução.

Em geral, se você já sabe usar o UniRX, eu o farei especialmente para os modelos IObservable, e você pode usar os recursos de trunfo da sua biblioteca favorita onde precisar, mas, de resto, sugiro que não tente construir tanques de carros de alta velocidade e carros de tanques apenas no chão que ambos têm rodas.

No final do artigo, tenho para vocês, queridos leitores, perguntas tradicionais que são muito importantes para mim, minhas idéias sobre o belo e as perspectivas para o desenvolvimento do meu trabalho científico e técnico.

Source: https://habr.com/ru/post/pt436060/


All Articles