Soluções arquitetônicas para um jogo para celular. Parte 1: Modelo

Epígrafe:
- Como vou avaliar se você não sabe o que fazer?
- Bem, haverá telas e botões.
- Dima, você descreveu toda a minha vida em três palavras!
(c) Diálogo real em uma manifestação em uma empresa de jogos



Um conjunto de necessidades e as soluções que as atendem, que discutirei neste artigo, foi formado durante minha participação em cerca de uma dúzia de grandes projetos, primeiro no Flash e depois no Unity. O maior dos projetos teve mais de 200.000 DAUs e complementou meu cofrinho com novos desafios originais. Por outro lado, a relevância e a necessidade de descobertas anteriores foram confirmadas.

Em nossa dura realidade, todo mundo que pelo menos uma vez projetou um projeto grande, pelo menos em seus pensamentos, tem suas próprias idéias sobre como fazê-lo e, com frequência, está pronto para defendê-las até a última gota de sangue. Para outros, isso me faz sorrir, e a gerência geralmente vê tudo isso como uma enorme caixa preta que não descansa contra ninguém. Mas e se eu lhe disser que as soluções corretas ajudarão a reduzir a criação de novas funcionalidades em 2 a 3 vezes, a busca de erros nos antigos 5 a 10 vezes e permitirá que você faça muitas coisas novas e importantes que antes não estavam disponíveis? É o suficiente para deixar a arquitetura entrar em seu coração!
Soluções arquitetônicas para um jogo para celular. Parte 2: Comando e suas filas
Soluções arquitetônicas para um jogo para celular. Parte 3: Vista sobre o impulso do jato


Modelo


Acesso a campos


A maioria dos programadores reconhece a importância de usar algo como o MVC. Poucas pessoas usam o MVC puro do livro de uma turma de quatro, mas todas as decisões dos escritórios normais são de alguma forma semelhantes a esse padrão em espírito. Hoje falaremos sobre a primeira das letras nesta abreviação. Como grande parte do trabalho dos programadores em um jogo para dispositivos móveis são novos recursos no meta-jogo, implementados como manipulações com o modelo e parafusando milhares de interfaces nesses recursos. E a conveniência do modelo desempenha um papel fundamental nesta lição.

Eu não forneço o código completo, porque é um pouco complicado, e geralmente não é sobre ele. Ilustrarei meu raciocínio com um exemplo simples:

public class PlayerModel { public int money; public InventoryModel inventory; /* Using */ public void SomeTestChanges() { money = 10; inventory.capacity++; } } 

Essa opção não é adequada para nós, porque o modelo não envia eventos sobre as mudanças que ocorrem nele. Se as informações sobre quais campos foram afetados pelas alterações, quais não são e quais precisam ser redesenhadas, e quais não são, o programador indicará manualmente de uma forma ou de outra - essa se tornará a principal fonte de erros e tempo. E não é preciso ter olhos surpresos.Na maioria dos grandes escritórios em que trabalhei, o programador enviou todos os tipos de InventoryUpdatedEvent, e em alguns casos também os preencheu manualmente. Alguns desses escritórios fizeram milhões, você acha, obrigado ou apesar disso?

Usaremos nossa própria classe ReactiveProperty <T>, que ocultará todas as manipulações para enviar as mensagens necessárias. Será algo parecido com isto:

 public class PlayerModel : Model { public ReactiveProperty<int> money = new ReactiveProperty<int>(); public ReactiveProperty<InventoryModel> inventory = new ReactiveProperty<InventoryModel>(); /* Using */ public void SomeTestChanges() { money.Value = 10; inventory.Value.capacity.Value++; } public void Subscription(Text text) { money.SubscribeWithState(text, (x, t) => t.text = x.ToString()); } } 

Esta é a primeira versão do modelo. Essa opção já é um sonho para muitos programadores, mas ainda não gosto. A primeira coisa que não gosto é que acessar valores é complicado. Consegui me confundir enquanto escrevia este exemplo, esquecendo o Valor em um só lugar, e são precisamente essas manipulações de dados que compõem a maior parte de tudo o que é feito e confundido com o modelo. Se você estiver usando a versão do idioma 4.x, poderá fazer o seguinte:

 public ReactiveProperty<int> money { get; private set; } = new ReactiveProperty<int>(); 

mas isso não resolve todos os problemas. Gostaria de escrever simplesmente: inventário.capacidade ++;. Suponha que tentemos obter para cada campo do modelo; conjunto; Mas, para se inscrever em eventos, também precisamos acessar o próprio ReactiveProperty. Inconveniente claro e fonte de confusão. Apesar do fato de que precisamos apenas indicar qual campo vamos monitorar. E aqui eu vim com uma manobra complicada que eu gostei.

Vamos ver se você gosta.

Não é ReactiveProperty que está inserido no modelo concreto com o qual o programador está lidando, está inserido, mas seu descritor estático PValue, o herdeiro da Propriedade mais geral, identifica o campo e, dentro do capô do construtor Model, está oculta a criação e o armazenamento da ReactiveProperty do tipo desejado. Não é o melhor nome, mas aconteceu, depois renomeou.

No código, fica assim:

 public class PlayerModel : Model { public static PValue<int> MONEY = new PValue<int>(); public int money { get { return MONEY.Get(this); } set { MONEY.Set(this, value) } } public static PModel<InventoryModel> INVENTORY = new PModel<InventoryModel>(); public InventoryModel inventory { get { return INVENTORY.Get(this); } set { INVENTORY.Set(this, value) } } /* Using */ public void SomeTestChanges() { money = 10; inventory.capacity++; } public void Subscription(Text text) { this.Get(MONEY).SubscribeWithState(text, (x, t) => t.text = x.ToString()); } } 

Esta é a segunda opção. O ancestral geral do Modelo, é claro, era complicado às custas da criação e extração de uma ReactiveProperty real de acordo com seu descritor, mas isso pode ser feito muito rapidamente e sem reflexão, ou melhor, aplicando a reflexão apenas uma vez no estágio de inicialização da classe. E este é o trabalho que é feito uma vez pelo criador do mecanismo, e depois será usado por todos. Além disso, esse design evita tentativas acidentais de manipular o próprio ReactiveProperty em vez dos valores armazenados nele. A criação do campo é confusa, mas em todos os casos é exatamente a mesma e pode ser criada com um modelo.

No final do artigo, há uma enquete sobre a opção que você mais gosta.
Tudo descrito abaixo pode ser implementado nas duas versões.

Transações


Desejo que os programadores possam alterar os campos do modelo somente quando isso for permitido pelas restrições adotadas no mecanismo, isto é, dentro da equipe e nunca mais. Para fazer isso, o setter deve ir a algum lugar e verificar se o comando de transação está aberto no momento e somente depois permitir que as informações sejam editadas no modelo. Isso é muito necessário, porque os usuários do mecanismo regularmente tentam fazer algo estranho para ignorar um processo típico, quebrando a lógica do mecanismo e causando erros sutis. Vi isso mais de uma ou duas vezes.

Há uma crença de que, se você criar uma interface separada para ler dados do modelo e escrever, isso ajudará de alguma forma. Na realidade, o modelo está cheio de arquivos adicionais e operações adicionais tediosas. Essas restrições são finais: os programadores são obrigados, em primeiro lugar, a conhecê-los e a pensar constantemente sobre eles: “o que cada função específica, modelo ou interface deve oferecer” e, em segundo lugar, também surgem situações em que essas restrições precisam ser contornadas; na saída, temos d'Artagnan, que criou tudo isso em branco, e muitos usuários de seu mecanismo, que são maus guardas do gerente de projetos e, apesar dos constantes abusos, nada funciona como planejado. Portanto, prefiro bloquear firmemente a possibilidade de tal erro. Reduza a dose de convenções, por assim dizer.

O configurador ReactiveProperty deve ter um link para o local em que o estado atual da transação deve ser verificado. Digamos que este lugar seja classCModelRoot. A opção mais fácil é passá-lo explicitamente ao construtor do modelo. A segunda versão do código ao chamar o RProperty recebe um link para isso explicitamente e pode obter todas as informações necessárias a partir daí. Para a primeira versão do código, você precisará executar os campos do tipo ReactiveProperty no construtor com uma reflexão e fornecer um link para isso para outras manipulações. Um pequeno inconveniente é a necessidade de criar um construtor explícito com um parâmetro em cada modelo, algo como isto:

 public class PlayerModel : Model { public PlayerModel(ModelRoot gamestate) : base (gamestate) {} } 

Mas para outras características dos modelos, é muito útil que o modelo tenha um link para o modelo pai, formando uma construção biconetada. No nosso exemplo, este será player.inventory.Parent == player. E então esse construtor pode ser evitado. Qualquer modelo poderá obter e armazenar em cache um link para um lugar mágico de seus pais, e aquele de seus pais, e assim por diante, até que o próximo pai se torne esse lugar mágico. Como resultado, no nível das declarações, tudo isso será assim:

 public class ModelRoot : Model { public bool locked { get; private set; } } public partial class Model { public Model Parent { get; protected set; } public ModelRoot Root { get; } } 

Toda essa beleza será preenchida automaticamente quando o modelo entrar na árvore de gamestate. Sim, o modelo recém-criado, que ainda não chegou lá, não poderá aprender sobre a transação e bloquear as manipulações consigo mesmo, mas se o estado da transação for proibido, ele não poderá entrar no estado depois disso, o setter do futuro pai não o permitirá, portanto, a integridade do gamestate não será afetada. Sim, isso exigirá trabalho adicional na fase de programação do mecanismo, mas, por outro lado, um programador que use o mecanismo eliminará completamente a necessidade de conhecer e pensar sobre ele até que ele tente fazer algo errado e coloque as mãos nele.

Desde o início da conversa sobre transatividade, as mensagens sobre alterações não devem ser processadas imediatamente após a alteração, mas somente quando todas as manipulações com o modelo na estrutura do comando atual forem concluídas. Há duas razões para isso: a primeira é a consistência dos dados. Nem todos os estados dos dados são internamente consistentes. Talvez você não possa tentar renderizá-los. Ou se você estiver impaciente, por exemplo, para classificar uma matriz ou alterar alguma variável de modelo em um loop. Você não deve receber centenas de mensagens de alteração.

Existem duas maneiras de fazer isso. A primeira é usar a função complicada ao assinar atualizações de uma variável, que adicionará um fluxo de finalizações de transação ao fluxo de alterações na variável e passará apenas as mensagens após elas. Isso é fácil o suficiente, se você estiver usando o UniRX, por exemplo. Mas esta opção tem muitas deficiências, em particular, gera muitos movimentos desnecessários. Pessoalmente, gosto da outra opção.

Cada ReactiveProperty lembrará seu estado antes do início da transação e seu estado atual. Uma mensagem sobre a alteração e a correção das alterações será feita apenas no final da transação. No caso de o objeto da alteração ser algum tipo de coleção, isso permitirá incluir explicitamente informações sobre as alterações que ocorreram na mensagem enviada.Por exemplo, esses dois itens da lista foram adicionados e esses dois foram excluídos. Em vez de apenas dizer que algo mudou, e forçar o destinatário a analisar uma lista de mil elementos em busca de informações que precisam ser redesenhadas.

 public partial class Model { public void DispatchChanges(Command transaction); public void FixChanges(); public void RevertChanges(); } 

A opção consome mais tempo na fase de criação do mecanismo, mas o custo de uso é menor. E o mais importante, abre a possibilidade para a próxima melhoria.

Informações sobre alterações feitas no modelo


Eu quero mais do modelo. A qualquer momento, desejo ver com facilidade e conveniência o que mudou no estado do modelo como resultado de minhas ações. Por exemplo, neste formulário:

 {"player":{"money":10, "inventory":{"capacity":11}}} 

Na maioria das vezes, é útil que o programador veja a diferença entre o estado do modelo antes do início do comando e após seu término ou em algum momento dentro do comando. Alguns deles clonam todo o estado do jogo antes do início da equipe e depois comparam. Isso resolve parcialmente o problema no estágio de depuração, mas é absolutamente impossível executá-lo no produto. Essa clonagem de estado, que calcula a diferença insignificante entre as duas listas, é uma operação monstruosamente cara relacionada a qualquer espirro.

Portanto, ReactiveProperty deve armazenar não apenas seu estado atual, mas também o estado anterior. Isso dá origem a todo um grupo de oportunidades extremamente úteis. Em primeiro lugar, a extração da diferença em tal situação é rápida e podemos despejar tudo com calma nos alimentos. Em segundo lugar, você não pode obter um diff volumoso, mas um pequeno hash compacto de alterações e compará-lo com um hash de alterações em outro mesmo estado de jogo. Se não concordar, você tem problemas. Em terceiro lugar, se a execução do comando falhou com a execução, você sempre pode cancelar as alterações e descobrir o estado não preservado no momento em que a transação foi iniciada. Juntamente com a equipe aplicada ao estado, essas informações são inestimáveis, pois é possível reproduzir facilmente a situação com precisão. Obviamente, para isso, você precisa ter uma funcionalidade pronta para serialização e desserialização convenientes do estado do jogo, mas será necessário de qualquer maneira.

Serialização de mudanças de modelo


O mecanismo fornece serialização e binário, e em json - e isso não é por acaso. Obviamente, a serialização binária ocupa muito menos espaço e funciona muito mais rapidamente, o que é importante, especialmente durante a inicialização inicial. Mas este não é um formato legível por humanos, e aqui oramos pela conveniência da depuração. Além disso, há outra armadilha. Quando o jogo começar, você precisará mudar constantemente de versão para versão. Se seus programadores seguirem algumas precauções simples e não excluírem nada do estado do jogo desnecessariamente, você não sentirá essa transição. E no formato binário, não há nomes de cadeias de campos por razões óbvias, e se as versões não corresponderem, você terá que ler o binário com a versão antiga do estado, exportá-lo para algo mais informativo, por exemplo, o mesmo json, importá-lo para um novo estado e exportá-lo para o binário, escreva e somente depois de todo esse trabalho, como de costume. Como resultado, em alguns projetos, as configurações são gravadas em binários, tendo em vista o tamanho das ciclópicas, e eles já preferem arrastar o estado para frente e para trás na forma de json. Avalie a sobrecarga e escolha você.

 [Flags] public enum ExportMode { all = 0x0, changes = 0x1, serverVerified = 0x2, //    ,    } /**    */ public partial class Model { public bool GetHashCode(ExportMode mode, out int code); public bool Import(BinaryReader binarySerialization); public bool Import(JSONReader json); public void ExportAll(ExportMode mode, BinaryWriter binarySerialization); public void ExportAll(ExportMode mode, JSONWriter json); public bool Export(ExportMode mode, out Dictionary<string, object> data); } 

A assinatura do método Export (modo ExportMode, out Dictionary <string, object> data) é um tanto alarmante. E o problema é o seguinte: quando você serializa a árvore inteira, pode escrever diretamente no fluxo, ou no nosso caso, no JSONWriter, que é um complemento simples para o StringWriter. Mas quando você exporta as alterações, não é tão simples, porque quando você se aprofunda em uma árvore e entra em um dos galhos, ainda não sabe se deseja exportar algo. Portanto, nessa fase, eu criei duas soluções, uma mais simples, a segunda mais complicada e econômica. Uma mais simples é que, ao exportar apenas alterações, você transforma todas as alterações em uma árvore do Dicionário <string, objeto> e da Lista <object>. E então o que aconteceu, alimente seu serializador favorito. Esta é uma abordagem simples que não exige que você dance com um pandeiro. Mas sua desvantagem é que, no processo de exportar alterações para o heap, um local para coleções únicas será alocado. De fato, não há muito espaço, porque essa exportação completa fornece uma árvore grande e o comando típico deixa muito poucas alterações na árvore.

No entanto, muitas pessoas acreditam que alimentar o Coletor de Lixo como aquele troll não é necessário sem extrema necessidade. Para eles, e para acalmar minha consciência, preparei uma solução mais complexa:

 /**    */ public partial class Model { public void ExportAll(ExportMode mode, Type propertyType, JSONWriter writer, bool newModel = false); public bool DetectChanges(ExportMode mode, Stack<Model> ierarchyChanged = null); public void ExportChanges(ExportMode mode, Type propertyType, JSONWriter writer, Queue<Model> ierarchyChanges = null); } 

A essência desse método é percorrer a árvore duas vezes. Pela primeira vez, visualize todos os modelos que se mudaram ou que tenham alterações nos modelos filhos e escreva todos na Fila <Model> ierarchyChanges exatamente na ordem em que aparecem na árvore em seu estado atual. Não há muitas alterações, a fila não será longa. Além disso, nada impede manter a pilha <Model> e a fila <Model> entre as chamadas e, em seguida, haverá muito poucas alocações durante a chamada.

E já passando pela segunda vez pela árvore, será possível olhar para o topo da fila a cada vez e entender se é necessário entrar nesse ramo da árvore ou seguir imediatamente em frente. Isso permite que o JSONWriter escreva imediatamente sem retornar outros resultados intermediários.

É muito provável que essa complicação não seja realmente necessária, pois mais tarde você verá que a exportação de alterações na árvore é necessária apenas para depuração ou ao travar com o Exception. Durante a operação normal, tudo se limita ao GetHashCode (modo ExportMode, out int code), para o qual todas essas delícias são profundamente estranhas.

Antes de continuarmos a complicar nosso modelo, vamos falar sobre isso.

Por que isso é tão importante?


Todos os programadores dizem que isso é extremamente importante, mas geralmente ninguém acredita neles. Porque

Primeiro, porque todos os programadores dizem que você precisa jogar fora o antigo e escrever no novo. Isso é tudo, independentemente das qualificações. Não existe uma maneira gerencial de descobrir se isso é verdade ou não, e os experimentos geralmente são muito caros. O gerente será forçado a escolher um programador e confiar em seu julgamento. O problema é que esse consultor geralmente é aquele com quem a gerência trabalha há muito tempo e o avalia se conseguiu realizar suas idéias.E todas as suas melhores idéias já estão incorporadas na realidade. Portanto, essa também não é uma maneira ideal de descobrir quão boas são as idéias de outras pessoas e diferentes.

Em segundo lugar, 80% de todos os jogos para celular trazem menos de US $ 500 em toda a vida. Portanto, no início do projeto, o gerenciamento tem outros problemas, mais importante ainda a arquitetura. Mas as decisões tomadas no início do projeto tornam as pessoas reféns e não deixam passar de seis meses a três anos. O processo de refatoração e mudança para outras idéias em um projeto já em funcionamento, que também tem clientes, é um negócio muito difícil, caro e arriscado. Se, para um projeto no início, investir três meses-homem em uma arquitetura normal parece um luxo inadmissível, o que você pode dizer sobre o custo de adiar a atualização com novos recursos por alguns meses?

Terceiro, mesmo que a idéia de “como deveria ser” seja boa e ideal, não se sabe quanto tempo levará sua implementação. A dependência do tempo gasto com a frieza do programador é muito não linear. O senhor fará uma tarefa simples, não muito mais rápida que o júnior. Uma vez e meia, talvez. Mas cada programador tem seu próprio "limite de complexidade", além do qual sua eficácia cai drasticamente. Tive um caso em minha vida em que precisava realizar uma tarefa arquitetônica bastante complicada, e até mesmo me concentrar completamente no problema de desligar a Internet em casa e pedir refeições prontas por um mês não ajudou, mas dois anos depois, depois de ler livros interessantes e resolver tarefas relacionadas , Resolvi esse problema em três dias. Estou certo de que todos se lembrarão de algo assim em sua carreira. E aqui está o problema! O fato é que, se uma idéia engenhosa lhe veio à mente como deveria ser, provavelmente essa nova idéia está em algum lugar do seu limite pessoal de complexidade e talvez até um pouco atrás dela. A gerência, tendo repetidamente queimado isso, começa a soprar novas idéias. E se você faz o jogo por si mesmo, o resultado pode ser ainda pior, porque não haverá ninguém para impedi-lo.

Mas como, então, alguém consegue usar boas soluções? Existem várias maneiras.

Em primeiro lugar, cada empresa deseja contratar uma pessoa pronta que já fez isso com um empregador anterior. Essa é a maneira mais comum de transferir o ônus da experimentação para outra pessoa.

Em segundo lugar, as empresas ou pessoas que fizeram seu primeiro jogo de sucesso, beberam e iniciaram o próximo projeto estão prontas para mudanças.

Terceiro, admita honestamente que às vezes faz algo não por uma questão de salário, mas pelo prazer do processo. O principal é encontrar tempo para isso.

Quarto, é um conjunto de soluções e bibliotecas comprovadas, juntamente com as pessoas, que compõem os principais fundos da empresa de jogos, e essa é a única coisa que permanecerá quando uma pessoa-chave sair e se mudar para a Austrália.

A última razão, embora não a mais óbvia: porque é terrivelmente benéfica. Boas soluções levam a uma redução múltipla no tempo para escrever novos recursos, depurá-los e detectar erros. Deixe-me dar um exemplo: dois dias atrás, o cliente teve uma execução em um novo recurso, cuja probabilidade é de 1 em 1.000, ou seja, o controle de qualidade torturará para reproduzi-lo e, se você der, são 200 mensagens de erro por dia. Quanto tempo leva para você reproduzir a situação e capturar o cliente no ponto de interrupção uma linha antes de tudo entrar em colapso? Por exemplo, eu tenho 10 minutos.

Modelo


Árvore modelo


O modelo consiste em muitos objetos. Diferentes programadores decidem de maneira diferente como conectá-los. A primeira maneira é quando o modelo é identificado pelo local onde está. Isso é muito conveniente e simples quando a referência ao modelo pertence a um único local no ModelRoot. Talvez ele possa ser deslocado de um lugar para outro, mas dois elos de lugares diferentes nunca levam a isso. Faremos isso introduzindo uma nova versão do descritor ModelProperty, que lidará com links de um modelo para outros modelos localizados nele. No código, ficará assim:

 public class PModel<T> : Property<T> where T:Model {} public partial class PlayerModel : Model { public PModel<InventoryModel> INVENTORY = new PModel<InventoryModel>(); public InventoryModel inventory { get { return INVENTORY.Value(this); } set { INVENTORY.Value(this, value); } } } 

Qual a diferença? Quando um novo modelo é adicionado a este campo, o modelo no qual foi adicionado é gravado no campo Pai e, quando excluído, o campo Pai é redefinido. Em teoria, está tudo bem, mas há muitas armadilhas. Os primeiros - programadores que o usarão, podem estar enganados. Para evitar isso, impomos verificações ocultas nesse processo, de diferentes ângulos:

  1. Fixaremos o PValue para que ele verifique o tipo de seu valor e jura pelos especialistas ao tentar armazenar uma referência ao modelo, indicando que, para isso, é necessário usar uma construção diferente, apenas para não ficar confuso. Isso, é claro, é uma verificação de tempo de execução, mas jura na primeira tentativa de iniciar, assim será.
  2. PModel Parent - , . . , .

Um efeito colateral surge disso, se você precisar mudar esse modelo de um lugar para outro, você deve primeiro removê-lo do primeiro lugar e depois adicioná-lo ao segundo - caso contrário, as verificações o repreenderão. Mas isso realmente acontece muito raramente.

Como o modelo está em um local estritamente definido e tem uma referência ao seu pai, podemos adicionar um novo método a ele - ele pode dizer de que maneira está na árvore ModelRoot. Isso é extremamente conveniente para depuração, mas também é necessário para que possa ser identificado exclusivamente. Por exemplo, encontre outro exatamente o mesmo modelo em outro mesmo estado de jogo ou indique no comando transmitido ao servidor um link para o modelo que o comando contém. Parece algo como isto:

 public class ModelPath { public Property[] properties; public Object[] indexes; public override ToString(); public static ModelPath FromString(string path); } public partial class Model { public ModelPath Path(); } public partial class ModelRoot : Model { public Model GetByPath(ModelPath path); } 

E por que, de fato, é impossível ter um objeto enraizado em um lugar e fazer referência a ele de outro? E porque você imagina que está desserializando um objeto do JSON, e aqui encontrará um link para um objeto enraizado em um local completamente diferente. E ainda não há lugar para isso, ele será criado apenas através do piso da desserialização. Opa Por favor, não ofereça desserialização multipass. Essa é a limitação deste método. Portanto, criaremos um segundo método:

Todos os modelos criados pelo segundo método são criados em um local mágico e, em todos os outros locais do estado do jogo, apenas links são inseridos neles. Durante a desserialização, se houver várias referências ao objeto, na primeira vez em que você acessar o local mágico, o objeto será criado e com todas as referências subsequentes ao mesmo objeto serão retornadas. Para implementar outros recursos, assumimos que o jogo pode ter vários gamestates, portanto, o local mágico não deve ser um comum, mas deve estar localizado, por exemplo, no gamestate. Para referências a esses modelos, usamos outra variação do descritor PPersistent. O modelo em si será tornado mais especial pelo Persistent: Model. No código, será algo parecido com isto:

 public class Persistent : Model { public int id { get { return ID.Get(this); } set { ID.Set(this, value); } } public static RProperty<int> ID = new RProperty<int>(); } public partial class ModelRoot : Model { public int nextFreePersistentId { get { return NEXT_FREE_PERSISTENT_ID.Get(this); } set { NEXT_FREE_PERSISTENT_ID.Set(this, value); } } public static RProperty<int> NEXT_FREE_PERSISTENT_ID = new RProperty<int>(); public static PDictionaryModel<int, Persistent> PERSISTENT = new PDictionaryModel<int, Persistent>() { notServerVerified = true }; /// <summary>      Id-. </summary> public PersistentT Persistent<PersistentT>(int localId) where PersistentT : Persistent, new(); /// <summary> C    Id. </summary> public PersistentT Persistent<PersistentT>() where PersistentT : Persistent, new(); } 

Um pouco complicado, mas pode ser usado. Para colocar canudos, Persistent pode fixar o construtor com o parâmetro ModelRoot, que disparará um alarme se eles tentarem criar esse modelo, não através dos métodos deste ModelRoot.

Eu tenho as duas opções no meu código, e a pergunta é: por que usar a primeira opção se a segunda cobrir totalmente todos os casos possíveis?

A resposta é que o estado do jogo deve ser, antes de tudo, legível pelas pessoas. Como é se, se possível, a primeira opção for usada?

 { "persistents":{}, "player":{ "money":10, "inventory":{"capacity":11} } } 

E agora, como seria se apenas a segunda opção fosse usada:
 { "persistents":{ "1":{"money":10, "inventory":2}, "2":{"capacity":11} }, "player":1 } 

Para depurar pessoalmente, prefiro a primeira opção.

Propriedades do modelo de acesso


No final, o acesso a instalações de armazenamento reativo para propriedades ficou oculto sob o capô do modelo. Não é muito óbvio como fazê-lo funcionar para que ele funcione rapidamente, sem muito código nos modelos finais e sem muita reflexão. Vamos dar uma olhada.

A primeira coisa que é útil saber sobre o Dicionário é que a leitura dele não leva muito tempo constante, independentemente do tamanho do dicionário. Criaremos um dicionário estático privado no Modelo, no qual cada tipo de modelo recebe uma descrição de quais campos estão nele e o acessaremos uma vez ao construir o modelo. No construtor type, procuramos ver se existe uma descrição para o nosso tipo, caso contrário, criamos, se sim, pegamos o que foi finalizado. Assim, a descrição será criada apenas uma vez para cada classe. Ao criar uma descrição, colocamos em cada Propriedade estática (descrição do campo) os dados extraídos por reflexão - o nome do campo e o índice sob o qual o armazenamento de dados para esse campo estará na matriz. Desta maneiraquando acessado através da descrição do campo, seu armazenamento será retirado da matriz em um índice conhecido anteriormente, ou seja, rapidamente.

No código, ficará assim:

 public class Model : IModelInternals { #region Properties protected static Dictionary<Type, Property[]> propertiesDictionary = new Dictionary<Type, Property[]>(); protected static Dictionary<Type, Property[]> propertiesForBinarySerializationDictionary = new Dictionary<Type, Property[]>(); protected Property[] _properties, _propertiesForBinarySerialization; protected BaseStorage[] _storages; public Model() { Type targetType = GetType(); if (!propertiesDictionary.ContainsKey(targetType)) RegisterModelsProperties(targetType, new List<Property>(), new List<Property>()); _properties = propertiesDictionary[targetType]; _storages = new BaseStorage[_properties.Length]; for (var i = 0; i < _storages.Length; i++) _storages[i] = _properties[i].CreateStorage(); } private void RegisterModelsProperties(Type target, List<Property> registered, List<Property> registeredForBinary) { if (!propertiesDictionary.ContainsKey(target)) { if (target.BaseType != typeof(Model) && typeof(Model).IsAssignableFrom(target.BaseType)) RegisterModelsProperties(target.BaseType, registered, registeredForBinary); var fields = target.GetFields(BindingFlags.Public | BindingFlags.Static); // | BindingFlags.DeclaredOnly List<Property> alphabeticSorted = new List<Property>(); for (int i = 0; i < fields.Length; i++) { var field = fields[i]; if (typeof(Property).IsAssignableFrom(field.FieldType)) { var prop = field.GetValue(this) as Property; prop.Name = field.Name; prop.Parent = target; prop.storageIndex = registered.Count; registered.Add(prop); alphabeticSorted.Add(prop); } } alphabeticSorted.Sort((p1, p2) => String.Compare(p1.Name, p2.Name)); registeredForBinary.AddRange(alphabeticSorted); Property[] properties = new Property[registered.Count]; for (int i = 0; i < registered.Count; i++) properties[i] = registered[i]; propertiesDictionary.Add(target, properties); properties = new Property[registered.Count]; for (int i = 0; i < registeredForBinary.Count; i++) properties[i] = registeredForBinary[i]; propertiesForBinarySerializationDictionary.Add(target, properties); } else { registered.AddRange(propertiesDictionary[target]); registeredForBinary.AddRange(propertiesForBinarySerializationDictionary[target]); } } CastType IModelInternals.GetStorage<CastType>(Property property) { try { return (CastType)_storages[property.storageIndex]; } catch { UnityEngine.Debug.LogError(string.Format("{0}.GetStorage<{1}>({2})",GetType().Name, typeof(CastType).Name, property.ToString())); return null; } } #endregion } 

O design é um pouco simples, porque os descritores de propriedades estáticas declarados nos ancestrais deste modelo já podem ter índices de armazenamento registrados, e a ordem de retorno das propriedades de Type.GetFields () não é garantida. Para a ordem e para que as propriedades não sejam reinicializadas em dois vezes, você precisa se monitorar.

Propriedades da coleção


Na seção da árvore de modelos, foi possível notar uma construção que não foi mencionada anteriormente: PDictionaryModel <int, Persistent> - um descritor para um campo que contém uma coleção. Está claro que teremos que criar nosso próprio repositório para coleções, que armazena informações sobre a aparência da coleção antes do início da transação e como ela é agora. A pedra subaquática aqui é do tamanho de uma Pedra do Trovão sob Peter I. Consiste no fato de que, tendo dois dicionários longos à mão, é uma tarefa extremamente cara calcular o diferencial entre eles. Suponho que esses modelos devem ser usados ​​para todas as tarefas relacionadas à meta, o que significa que eles devem funcionar rapidamente. Em vez de armazenar dois estados, cloná-los e compará-los com dispendiosos, faço um gancho complicado - apenas o estado atual do dicionário é armazenado na loja.Outros dois dicionários são valores excluídos,e valores antigos de elementos substituídos. Por fim, um conjunto de novas chaves adicionadas ao dicionário é armazenado. Essas informações são preenchidas com facilidade e rapidez. É fácil gerar todas as diferenças necessárias com ela e é suficiente restaurar o estado anterior, se necessário. No código, fica assim:

 public class DictionaryStorage<TKey, TValues> : BaseStorage { public Dictionary<TKey, TValues> current = new Dictionary<TKey, TValues>(); public Dictionary<TKey, TValues> removed = new Dictionary<TKey, TValues>(); public Dictionary<TKey, TValues> changedValues = new Dictionary<TKey, TValues>(); public HashSet<TKey> newKeys = new HashSet<TKey>(); } 

Não consegui criar um repositório igualmente bonito para a Lista, ou não tenho tempo suficiente, mantenho duas cópias. É necessário um complemento adicional para tentar minimizar o tamanho do diff.

 public class ListStorage<TValue> : BaseStorage { public List<TValue> current = new List<TValue>(); public List<TValue> previouse = new List<TValue>(); //        public List<int> order = new List<int>(); //       . } 

Total


Se você souber claramente o que deseja receber e como, poderá escrever tudo isso em algumas semanas. A velocidade de desenvolvimento do jogo ao mesmo tempo muda tão dramaticamente que, quando tentei, nem comecei meus próprios jogos de criação de jogos sem ter um bom motor. Só porque no primeiro mês o investimento para mim obviamente deu resultado. Obviamente, isso só se aplica à meta. A jogabilidade tem que ser feita da maneira antiga.

Na próxima parte do artigo, falarei sobre comandos, rede e previsão de respostas do servidor. E também tenho algumas perguntas importantes para você. Se suas respostas diferirem das apresentadas entre parênteses, terei prazer em lê-las nos comentários ou talvez você até escreva um artigo. Agradecemos antecipadamente pelas respostas.

PS: Uma proposta de cooperação e instruções sobre vários erros de sintaxe, por favor, no PM.

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


All Articles