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



Na primeira parte do artigo, examinamos como o modelo deve ser organizado para facilitar o uso, mas a depuração e a fixação das interfaces são simples. Nesta parte, consideraremos o retorno de comandos para mudanças no modelo, em toda a sua beleza e diversidade. Como antes, a prioridade para nós será a conveniência da depuração, minimizando os gestos que um programador precisa fazer para criar um novo recurso, bem como a legibilidade do código para uma pessoa.

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

Porquê Comando


O padrão Command soa alto, mas, na verdade, é apenas um objeto no qual tudo o necessário para a operação solicitada é adicionado e armazenado lá. Escolhemos essa abordagem, pelo menos porque nossas equipes serão enviadas pela rede e até obteremos algumas cópias do estado do jogo para uso oficial. Portanto, quando o usuário clica no botão, uma instância da classe de comando é criada e enviada ao destinatário. O significado da letra C na abreviação MVC é um pouco diferente.

Previsão de resultado e verificação de comandos pela rede


Nesse caso, o código específico é menos importante que a ideia. E aqui está a ideia:

Um jogo que se preze não pode esperar por uma resposta do servidor antes de reagir ao botão. Obviamente, a Internet está melhorando e você pode ter vários servidores em todo o mundo, e eu até conheço alguns jogos de sucesso aguardando uma resposta do servidor, um deles é até Invocando Guerras, mas ainda assim você não precisa fazer isso. Como para defasagens na Internet móvel de 5 a 15 segundos é mais provável que seja a norma do que uma exceção, em Moscou pelo menos o jogo deve ser realmente ótimo para que os jogadores não prestem atenção a ele.

Consequentemente, temos um estado de jogo que representa todas as informações necessárias para a interface, e os comandos são aplicados a ela imediatamente e somente depois são enviados ao servidor. Geralmente, os programadores java trabalham sentados no servidor duplicando todas as novas funcionalidades uma a uma em outro idioma. Em nosso projeto “veado”, seu número chegou a 3 pessoas, e os erros cometidos ao transportar eram uma fonte constante de alegria indescritível. Em vez disso, podemos fazer de maneira diferente. Executamos no servidor .Net e no lado do servidor o mesmo código de comando que no cliente.

O modelo descrito no último artigo nos oferece uma nova oportunidade interessante de autoteste. Depois de executar o comando no cliente, calcularemos o hash da alteração ocorrida na árvore do GameState e aplicá-lo-á à equipe. Se o servidor executar o mesmo código de comando e o hash das alterações não corresponder, algo deu errado.

Primeiras vantagens:

  • Essa solução acelera bastante o desenvolvimento e minimiza o número de programadores de servidores.
  • Se o programador cometeu erros que levaram a um comportamento não determinístico, por exemplo, ele obteve o primeiro valor do Dictionary ou usou DateTime.now e, geralmente, usou alguns valores não escritos nos campos de comando explicitamente; quando o heh for iniciado no servidor, eles não corresponderão e nós vamos descobrir sobre isso.
  • Por enquanto, o desenvolvimento do cliente pode ser realizado sem um servidor. Você pode até entrar em alfa amigável sem ter um servidor. Isso é útil não apenas para desenvolvedores independentes que perdem seu jogo dos sonhos à noite. Quando eu estava em Piksonik, houve um caso em que o programador do servidor perdeu todos os polímeros e nosso jogo foi forçado a sofrer moderação, tendo em vez do servidor um boneco defendendo estupidamente o estado inteiro do jogo de vez em quando.

Uma desvantagem que, por algum motivo, é sistematicamente subestimada:

  • Se o programador cliente fez algo errado e é invisível durante o teste, por exemplo, a probabilidade de mercadorias nas caixas misteriosas, não há ninguém para escrever a mesma coisa uma segunda vez e encontrar um erro. O código autoportável requer uma atitude muito mais responsável em relação aos testes.

Informações detalhadas sobre depuração


Uma de nossas prioridades declaradas é a conveniência da depuração. Se durante a execução da equipe capturamos a execução - tudo está claro, revertemos o estado do jogo, enviamos o estado completo para os logs e serializamos o comando que o soltou, tudo é conveniente e bonito. A situação é mais complicada se tivermos uma dessincronização com o servidor. Como o cliente já concluiu vários outros comandos desde então, verifica-se não apenas descobrir em que estado o modelo estava antes de executar o comando que levou ao desastre, mas eu realmente quero. A clonagem de um gamestate na frente de cada equipe é muito complicada e cara. Para resolver o problema, complicamos o esquema costurado sob o capô do motor.

No cliente, não teremos um estado de jogo, mas dois. O primeiro serve como interface principal para renderização, os comandos são aplicados a ele imediatamente. Depois disso, os comandos aplicados são colocados na fila para envio ao servidor. O servidor executa a mesma ação de lado e confirma que está tudo bem e correto. Após receber a confirmação, o cliente pega o mesmo comando e aplica-o ao segundo estado do jogo, levando-o ao estado que já foi confirmado pelo servidor como correto. Ao mesmo tempo, também temos a oportunidade de comparar o hash das alterações feitas para garantir a segurança e também podemos comparar o hash completo de toda a árvore no cliente, que podemos calcular depois que o comando é executado, pesa pouco e é considerado rápido o suficiente. Se o servidor não disser que está tudo bem, ele solicita ao cliente detalhes do que aconteceu, e o cliente pode enviar a ele um segundo estado de jogo serializado exatamente como antes de o comando ser executado com êxito no cliente.
A solução parece muito atraente, mas gera dois problemas que precisam ser resolvidos no nível do código:

  • Entre os parâmetros de comando, pode haver não apenas tipos simples, mas também links para modelos. Em outro gamestate, exatamente no mesmo local, há outros objetos do modelo. Resolvemos esse problema da seguinte maneira: Antes de o comando ser executado no cliente, serializamos todos os seus dados. Entre eles, pode haver links para modelos, que escreveremos na forma de Caminho para o modelo a partir da raiz do estado do jogo. Fazemos isso antes da equipe, pois após sua execução os caminhos podem mudar. Em seguida, enviamos esse caminho para o servidor, e o estado do servidor do servidor poderá obter um link para seu modelo ao longo do caminho. Da mesma forma, quando uma equipe é aplicada ao segundo estado do jogo, o modelo pode ser obtido a partir do segundo estado do jogo.
  • Além de tipos e modelos elementares, uma equipe pode ter links para coleções. Dicionário <chave, modelo>, dicionário <modelo, chave>, lista <modelo>, lista <valor>. Para todos eles, eles precisam escrever serializadores. É verdade que você não pode se apressar nisso, em um projeto real, esses campos surgem surpreendentemente raramente.
  • Enviar comandos para o servidor, um de cada vez, não é uma boa ideia, porque o usuário pode produzi-los mais rapidamente do que a Internet pode arrastá-los para frente e para trás; na Internet ruim, o conjunto de comandos não trabalhados pelo servidor aumentará. Em vez de enviar comandos um de cada vez, nós os enviaremos em lotes de várias partes. Nesse caso, após ter recebido uma resposta do servidor de que algo deu errado, primeiro você deverá aplicar ao segundo estado todos os comandos anteriores do mesmo pacote que foram confirmados pelo servidor e somente depois apagar e enviar o segundo estado de controle ao servidor.

Conveniência e facilidade de escrever comandos


O código de execução do comando é o segundo maior e o primeiro código mais responsável do jogo. Quanto mais simples e claro ele for, e quanto menos o programador precisar fazer o extra com as mãos para escrevê-lo, mais rápido o código será escrito, menos erros serão cometidos e, inesperadamente, mais feliz o programador será. Coloco o código de execução diretamente no próprio comando, além das partes e funções gerais localizadas em classes de regras estáticas separadas, na maioria das vezes na forma de extensões das classes de modelo com as quais eles trabalham. Vou mostrar alguns exemplos de comandos do meu projeto de estimação, um muito simples e o outro um pouco mais complicado:

namespace HexKingdoms { public class FCSetSideCostCommand : HexKingdomsCommand { //              protected override bool DetaliedLog { get { return true; } } public FCMatchModel match; public int newCost; protected override void HexApply(HexKingdomsRoot root) { match.sideCost = newCost; match.CalculateAssignments(); match.CalculateNextUnassignedPlayer(); } } } 

E aqui está o log que esse comando deixa depois de si mesmo, se esse log não estiver desativado.

 [FCSetSideCostCommand id=1 match=FCMatchModel[0] newCost=260] Execute:00:00:00.0027546 Apply:00:00:00.0008689 { "LOCAL_PERSISTENTS":{ "@changed":{ "0":{"SIDE_COST":260}, "1":{"POSSIBLE_COST":260}, "2":{"POSSIBLE_COST":260}}}} 

A primeira vez indicada no log é o tempo durante o qual todas as alterações necessárias no modelo foram feitas e a segunda é a hora em que todas as alterações foram trabalhadas pelos controladores de interface. Isso deve ser mostrado no log para não fazer acidentalmente algo terrivelmente lento, ou para perceber se as operações começam a levar muito tempo simplesmente por causa do tamanho do modelo em si.

Além das chamadas para objetos persistentes nos Id-shniks, que reduzem muito a legibilidade do log, que, aliás, poderia ter sido evitada aqui, o próprio código de comando e o log que ele fez com o estado do jogo são surpreendentemente claros. Observe que no texto do comando o programador não faz um único movimento extra. Tudo o que você precisa é feito pelo motor sob o capô.

Agora vamos ver um exemplo de uma equipe maior

 namespace HexKingdoms { public class FCSetUnitForPlayerCommand : HexKingdomsCommand { //            protected override bool DetaliedLog { get { return true; } } public FCSelectArmyScreenModel screen; public string unit; public int count; protected override void HexApply(HexKingdomsRoot root) { if (count == 0 && screen.player.units.ContainsKey(unit)) { screen.player.units.Remove(unit); screen.selectedUnits.Remove(unit); } else if (count != 0) { if (screen.player.units.ContainsKey(unit)) { screen.player.units[unit] = count; screen.selectedUnits[unit].count = count; } else { screen.player.units.Add(unit, count); screen.selectedUnits[unit] = new ReferenceUnitModel() { type = unit, count = count }; } } screen.SetSelectedReferenceUnits(); screen.player.CalculateUnitsCost(); var side = screen.match.sides[screen.side]; screen.match.CalculatePlayerAssignmentsAcceptablity(side); screen.match.CalculateNextUnassignedPlayer(screen.player); } } } 

E aqui está o log deixado pela equipe:

 [FCSetUnitForPlayerCommand id=3 screen=/UI_SCREENS[main] unit=militia count=1] Execute:00:00:00.0065625 Apply:00:00:00.0004573 { "LOCAL_PERSISTENTS":{ "@changed":{ "2":{ "UNITS":{ "@set":{"militia":1}}, "ASSIGNED":7}}}, "UI_SCREENS":{ "@changed":{ "main":{ "SELECTED_UNITS":{ "@set":{ "militia":{"@new":null, "TYPE":"militia", "REMARK":null, "COUNT":1, "SELECTED":false, "DISABLED":false, "HIGHLIGHT_GREEN":false, "HIGHLIGHT_RED":false, "BUTTON_ENABLED":false}}}}}}} 

Como se costuma dizer, é muito mais claro. Dispense tempo para equipar a equipe com um registro conveniente, compacto e informativo. Essa é a chave da sua felicidade. O modelo deve funcionar muito rapidamente; portanto, usamos uma variedade de truques com métodos de armazenamento e acesso aos campos. Os comandos são executados na pior das hipóteses uma vez por quadro, na verdade, várias vezes com menos frequência; portanto, realizaremos serialização e desserialização dos campos de comando sem qualquer fantasia, apenas através da reflexão. Somente classificamos os campos pelos nomes, para que a ordem seja fixa. Bem, compilaremos a lista de campos uma vez durante a vida do comando e leremos escrever usando os métodos nativos C #.

Modelo de informação para a interface.


Vamos dar o próximo passo para complicar nosso mecanismo, um passo que parece assustador, mas simplifica bastante a gravação e a depuração de interfaces. Muitas vezes, especialmente no padrão MVP relacionado, o modelo contém apenas lógica de negócios controlada pelo servidor, e as informações sobre o estado da interface são armazenadas no apresentador. Por exemplo, você deseja solicitar cinco ingressos. Você já selecionou o número deles, mas ainda não clicou no botão "encomendar". As informações sobre exatamente quantos tickets você escolheu no formulário podem ser armazenadas em algum lugar nos cantos secretos da classe, que serve como uma junta entre o modelo e sua exibição. Ou, por exemplo, o jogador muda de uma tela para outra, mas nada muda no modelo, e onde ele estava quando a tragédia aconteceu, o programador de depuração conhece apenas as palavras de um testador extremamente disciplinado. A abordagem é simples, compreensível, quase sempre usada e um pouco maliciosa, na minha opinião. Porque, se algo der errado, é absolutamente impossível descobrir o estado desse apresentador, que levou a um erro. Especialmente se o erro ocorreu no servidor de batalha durante a operação por US $ 1000, e não no testador em um ambiente controlado e reproduzível.

Em vez dessa abordagem usual, proibimos que qualquer pessoa, exceto o modelo, contenha informações sobre o estado da interface. Isso tem, como sempre, vantagens e desvantagens que precisam ser combatidas.

  • (+1) A vantagem mais importante, economizando meses de trabalho de programação - se algo der errado, o programador simplesmente carrega o estado do jogo antes do acidente e recebe exatamente o mesmo estado não apenas do modelo de negócios, mas de toda a interface até o último botão na tela.
  • (+2) Se alguma equipe mudou alguma coisa na interface, o programador pode facilmente acessar o log e ver o que exatamente mudou de uma forma conveniente do json, como na seção anterior.
  • (-1) Muitas informações redundantes aparecem no modelo que não são necessárias para entender a lógica de negócios do jogo e não são necessárias duas vezes pelo servidor.

Para resolver esse problema, marcaremos alguns campos como notServerVerified, é assim, por exemplo, assim:

 public EDictionary<string, UIStateModel> uiScreens { get { return UI_SCREENS.Get(this); } } public static PDictionaryModel<string, UIStateModel> UI_SCREENS = new PDictionaryModel<string, UIStateModel>() { notServerVerified = true }; 

Esta parte do modelo e tudo abaixo dele se relacionam exclusivamente com o cliente.

Se você ainda se lembra, os sinalizadores do que você precisa exportar e o que não são assim:

 [Flags] public enum ExportMode { all = 0x0, changes = 0x1, serverVerified = 0x2 } 

Assim, ao exportar ou computar um hash, você pode especificar se deseja exportar a árvore inteira ou apenas a parte dela que é verificada pelo servidor.

A primeira complicação óbvia que surge a partir daqui é a necessidade de criar comandos separados que precisam ser verificados pelo servidor e aqueles que não são necessários, mas também existem aqueles que precisam ser verificados não inteiramente. Para não carregar o programador com operações desnecessárias para configurar o comando, tentaremos novamente fazer todo o necessário com o capô do motor.

 public partial class Command { /** <summary>    ,      </summary> */ public virtual void Apply(ModelRoot root) {} /** <summary>         </summary> */ public virtual void ApplyClientSide(ModelRoot root) {} } 

O programador que cria o comando pode substituir uma ou ambas as funções. Tudo isso, é claro, é maravilhoso, mas como posso ter certeza de que o programador não estragou nada e se ele estragou algo - como ele pode ajudá-lo a corrigi-lo rápida e facilmente? Existem duas maneiras. Eu apliquei o primeiro, mas você pode gostar mais do segundo.

Primeira maneira


Usamos os recursos interessantes do nosso modelo:

  1. O mecanismo chama a primeira função, após a qual recebe um hash de alterações na parte verificada pelo servidor do estado do jogo. Se não houver alterações, estamos lidando exclusivamente com a equipe do cliente.
  2. Obtemos o hash de alterações do modelo em todo o modelo, não apenas no verificado pelo servidor. Se for diferente do hash anterior, o programador errou e alterou algo na parte do modelo que não foi verificada pelo servidor. Percorremos a árvore de estados e despejamos o programador como uma execução, uma lista completa dos campos notServerVerified = true e aqueles abaixo da árvore que ele alterou.
  3. Chamamos a segunda função. Obtemos do modelo um hash das alterações que ocorreram na parte marcada. Se não coincidir com o hash após a primeira chamada, na segunda função o programador fez alguma coisa. Se quisermos obter um log muito informativo nesse caso, reverteremos todo o modelo para seu estado original, serializá-lo em um arquivo, o programador será útil para depuração e depois o clonaremos inteiro (duas linhas - serialização-desserialização), e agora aplicamos primeiro a primeira , comprometemos as alterações para que o modelo pareça inalterado, após o qual aplicamos a segunda função. E então exportamos todas as alterações na parte verificada pelo servidor na forma de JSON e a incluímos na execução abusiva, para que o programador envergonhado possa ver imediatamente o que e onde ele mudou, o que não deve ser alterado.

Parece, é claro, assustador, mas na verdade são 7 linhas, porque as funções que fazem isso são todas (exceto atravessando a árvore a partir do segundo parágrafo) que estamos prontos. E, como se trata de recepção, podemos nos permitir agir de maneira não otimizada.

Segunda via


Um pouco mais brutal, agora no ModelRoot temos um campo de bloqueio, mas podemos dividi-lo em dois, um bloqueará apenas os campos marcados no servidor e o outro apenas os campos marcados. Nesse caso, o programador que fez algo errado obterá uma explicação sobre isso imediatamente, vinculando o local onde ele fez. A única desvantagem dessa abordagem é que, em nossa árvore, uma propriedade de modelo é marcada como não verificável, tudo na árvore abaixo dela, referente ao cálculo de hashes e controle de alterações, não será inspecionado, mesmo que cada campo não tenha sido marcado. Um bloqueio, é claro, não examinará a hierarquia, o que significa que todos os campos da parte não verificada da árvore deverão ser marcados e não funcionará em alguns lugares para usar as mesmas classes na interface do usuário e na parte usual da árvore. Como opção, essa construção é possível (escreverei simplificada):

 public class GameState : Model { public RootModelData data; public RootModelLocal local; } public class RootModel { public bool locked { get; } } 

Acontece que cada subárvore tem seu próprio bloqueio. O GameState herda modelos, porque é mais fácil do que apresentar uma implementação separada com a mesma funcionalidade.

Melhorias necessárias


Obviamente, o gerente responsável pelo processamento das equipes precisará adicionar novas funcionalidades. A essência das alterações será que nem todos os comandos serão enviados ao servidor, mas apenas aqueles que criam as alterações verificadas. O servidor do lado dele não aumentará a árvore de estado do jogo inteiro, mas apenas a parte que está sendo verificada e, portanto, o hash coincidirá apenas para a parte que está sendo verificada. Quando um comando é executado no servidor, apenas a primeira das duas funções do comando será ativada e, ao resolver referências a modelos no estado do jogo, se o caminho levar a uma parte não verificável da árvore, nulo será colocado na variável de comando em vez do modelo. Todas as equipes que não enviaram permanecerão honestamente alinhadas com as usuais, mas serão consideradas como já confirmadas. Assim que chegarem à fila e não houver outros não confirmados diante deles, serão imediatamente aplicados ao segundo estado.

Não há nada fundamentalmente complicado na implementação. Só que a propriedade de cada campo do modelo tem mais uma condição, uma travessia de árvore.

Outro refinamento necessário - você precisará do Factory for ParsistentModel separado nas partes verificadas e não verificadas da árvore e o NextFreeId será diferente.

Comandos iniciados pelo servidor


Há algum problema se o servidor quiser enviar seu comando ao cliente, porque o estado do cliente em relação ao servidor já pode dar alguns passos adiante. A idéia principal é que, se o servidor precisar enviar seu comando, ele enviará a notificação do servidor ao cliente com a próxima resposta e gravará no campo para notificações enviadas a esse cliente. O cliente recebe uma notificação, forma um comando e o coloca no final de sua fila, após aqueles que foram concluídos no cliente, mas ainda não chegaram ao servidor. Após algum tempo, o comando é enviado ao servidor como parte do processo normal de trabalho com o modelo. Após receber este comando para processamento, o servidor lança a notificação para fora da fila de saída. Se o cliente não respondeu à notificação dentro do tempo definido com o próximo pacote, um comando de reinicialização é enviado a ele. Se o cliente que recebeu a notificação caiu, se conecta mais tarde ou, por algum motivo, carrega o jogo, o servidor transforma todas as notificações em comandos antes de atribuir o estado a ela, executa-as de lado e somente depois disso dá ao novo cliente o novo estado. Observe que um jogador pode ter um estado conflitante com recursos negativos quando conseguiu gastar o dinheiro exatamente no momento em que o servidor os retirou. A coincidência é improvável, mas com um DAU grande, é quase inevitável. Portanto, as regras da interface e do jogo não devem cair até a morte em tal situação.

Comandos para executar, dos quais você precisa saber a resposta do servidor


Um erro típico é pensar que um número aleatório só pode ser obtido no servidor. Nada impede que você tenha o mesmo gerador de números pseudo-aleatórios em execução simultaneamente a partir do cliente e do servidor, iniciando a partir de um sid comum. Além disso, a semente atual pode ser armazenada diretamente no estado do jogo. Alguns podem achar difícil sincronizar a resposta desse gerador. De fato, para isso basta ter mais um número no mesmo artigo - exatamente quantos números foram recebidos do gerador até o momento. Se, por algum motivo, o seu gerador não convergir, há um erro em algum lugar e o código não funciona deterministicamente. E esse fato não deve estar oculto sob o tapete, mas resolvido e procurar um erro. Para a grande maioria dos casos, incluindo até as caixas misteriosas, essa abordagem é suficiente.

No entanto, há momentos em que essa opção não é adequada. Por exemplo, você está jogando um prêmio muito caro e não quer que o camarada astuto descompile o jogo e escreva um bot que avise com antecedência o que sairá da caixa de diamantes se você a abrir agora e se girar o tambor em outro lugar antes disso. Você pode armazenar sementes para cada variável aleatória separadamente, isso protegerá contra hackers frontais, mas não ajudará em nada o bot que diz quantas caixas o produto que você precisa está atualmente. Bem, o caso mais óbvio é que você pode não querer brilhar na configuração do cliente com informações sobre a probabilidade de algum evento raro. Em resumo, às vezes é necessário aguardar uma resposta do servidor.
Tais situações devem ser resolvidas não através dos recursos adicionais do mecanismo, mas dividindo a equipe em duas - a primeira prepara a situação e coloca a interface em um estado de espera por notificações, a segunda, na verdade, notificações, com a resposta que você precisa. Mesmo que você bloqueie firmemente a interface entre eles no cliente, outro comando poderá passar - por exemplo, uma unidade de energia será restaurada a tempo.

É importante entender que essas situações não são a regra, mas a exceção. De fato, a maioria dos jogos precisa apenas de um time esperando por uma resposta - GetInitialGameState. Outro pacote desses comandos é a interação entre jogadores em um meta-jogo, GetLeaderboard, por exemplo. Todas as outras duzentas peças são determinísticas.

Armazenamento de dados do servidor e o tópico enlameado da otimização do servidor


Reconheço imediatamente que sou cliente e, às vezes, ouvi essas idéias e algoritmos de servidores familiares que eles nem sequer apareciam na minha cabeça. Ao me comunicar com meus colegas, desenvolvi de alguma forma uma imagem de como minha arquitetura deveria funcionar no lado do servidor, no caso ideal. No entanto: existem contra-indicações, é necessário consultar um servidor especializado.

Primeiro sobre o armazenamento de dados. É o lado do servidor que pode ter restrições adicionais. Por exemplo, você pode ser proibido de usar campos estáticos. Além disso, o código de comandos e modelos é autoportável, mas o código de propriedade no cliente e no servidor não precisa coincidir. Tudo pode estar oculto lá, até a inicialização lenta dos valores de campo do memcache, por exemplo. Os campos de propriedade também podem receber parâmetros adicionais usados ​​pelo servidor, mas não afetam o trabalho do cliente.

A primeira diferença cardinal do servidor: onde os campos são serializados e desserializados. Uma solução razoável é que a maior parte da árvore de estados seja serializada em um enorme campo binário ou json. Ao mesmo tempo, alguns campos são obtidos de tabelas. Isso é necessário porque os valores de alguns campos serão constantemente necessários para que os serviços de interação entre os jogadores funcionem. Por exemplo, o ícone e o nível estão constantemente se contraindo por várias pessoas. Eles são mantidos em um banco de dados regular. Um estado completo ou parcial, mas detalhado, de uma pessoa será necessário por alguém que não seja ele muito raramente, quando alguém decide examinar seu território.

Além disso, puxar os campos da base, um de cada vez, é inconveniente e pode arrastar tudo por um longo tempo. Uma solução não padronizada, disponível apenas para nossa arquitetura, pode consistir no fato de o cliente, ao executar um comando, coletar informações sobre todos os campos armazenados separadamente em tabelas cujos getters conseguiram tocar, e adicionar essas informações ao comando para que o servidor possa aumentar esse grupo de campos uma solicitação para o banco de dados. Obviamente, com restrições razoáveis, para não implorar pelo DDOS causado por programadores de mãos curvadas que tocaram tudo de maneira desatenta.

Com esse armazenamento separado, deve-se considerar os mecanismos de transacionalidade quando um jogador rastreia os dados de outro, por exemplo, rouba dinheiro dele. Mas, no caso geral, fazemos isso por notificação. Ou seja, o ladrão recebe seu dinheiro imediatamente e a pessoa assaltada recebe uma notificação com instruções para anular o dinheiro quando se trata disso.

Como as equipes são divididas entre servidores


Agora, o segundo momento importante para o servidor. Existem duas abordagens. No início, para processar qualquer solicitação (ou um pacote de solicitações), todo o estado é gerado do banco de dados ou cache para a memória, processado e depois retornado ao banco de dados. As operações são executadas atomicamente em vários servidores de execução diferentes, e eles só têm uma base comum e, mesmo assim, nem sempre. Como cliente, elevar o estado inteiro para cada equipe é chocante, mas vi como ele funciona e funciona de maneira muito confiável e escalável. A segunda opção é que o estado suba na memória e permaneça lá até que o cliente caia, adicionando ocasionalmente seu estado atual ao banco de dados.Não sou competente para lhe dizer as vantagens e desvantagens deste ou daquele método. Seria bom se alguém nos comentários me explicasse por que o primeiro tem direito à vida em geral. A segunda opção levanta questões sobre como interagir entre jogadores que, por acaso, foram criados em diferentes servidores. Isso pode ser crítico, por exemplo, se vários membros do clã estiverem se preparando para lançar um ataque conjunto. Você não pode mostrar aos outros o estado do membro do seu grupo com um atraso de 10 salvamentos. Infelizmente, não vou abri-lo aqui, a interação através das notificações descritas acima, comandos de um servidor para outro - agora está fora de hora de salvar o estado atual do jogador criado lá. Se os servidores tiverem o mesmo nível de disponibilidade de lugares diferentes,e você pode gerenciar o balanceador, pode tentar transferir silenciosamente o player de um servidor para outro. Se você conhece uma solução melhor - não se esqueça de descrever nos comentários.

Dançar com o tempo


Vamos começar com a pergunta, que eu realmente gosto de derrubar as pessoas nas entrevistas: aqui você tem um cliente e um servidor, cada um com seu próprio relógio bastante preciso. Como descobrir o quanto eles diferem. Uma tentativa de resolver esse problema em uma cafeteria em um guardanapo revela as melhores e as piores qualidades de um programador. O fato é que o problema não tem uma solução matematicamente correta formal. Mas o entrevistado está ciente disso, em regra, dedique um minuto no quinto e somente após perguntas importantes. E a maneira como ele conhece essa percepção e o que ele faz a seguir - diz muito sobre a coisa mais importante em seu personagem - o que essa pessoa fará quando problemas reais começarem no seu projeto.

A melhor solução que eu conheço me permite descobrir não a diferença exata, mas esclarecer o intervalo em que ela se encaixa em muitas respostas a pedidos até o melhor pacote executado de cliente para servidor, além do tempo do melhor pacote executado de servidor para cliente. No total, isso fornecerá algumas dezenas de milissegundos de precisão. Isso é muitas vezes melhor que o necessário para o meta-jogo do jogo para celular; aqui não temos multijogador de VR ou CS, mas ainda é bom para o programador representar a escala e a natureza das dificuldades com a sincronização do relógio. Provavelmente, será suficiente que você conheça o atraso médio tomado como um ping ao meio, por um longo tempo com um corte de desvios de mais de 30%.

A segunda situação interessante que você provavelmente encontrará é realizar um jogo de deslize e transferir o relógio no seu telefone. Nos dois casos, o tempo no aplicativo será alterado de forma dramática e abrupta, e isso deve ser resolvido corretamente. Pelo menos, reinicie o jogo. Mas é melhor não reiniciar após cada deslizamento, para que você não possa usar o tempo que passou no aplicativo desde o lançamento.

Terceiro, a situação, por algum motivo, é um problema para alguns programadores, embora exista uma solução correta: as operações absolutamente não podem ser executadas no horário do servidor. Por exemplo, inicie a produção de mercadorias quando uma solicitação de produção chegar ao servidor. Caso contrário, dê adeus ao seu determinismo e pegue 35 mil dessincronizações por dia causadas por opiniões diferentes do cliente e do servidor sobre se já é possível clicar no prêmio. A decisão correta é que a equipe registre informações sobre o horário em que foi executada. O servidor, por sua vez, verifica se a diferença horária entre a hora atual do servidor e a hora no comando está dentro do intervalo permitido e, se o fizer, executa o comando da parte usando a hora declarada pelo cliente.
Outra tarefa para a entrevista: Tempo limite após o qual o cliente tentará reiniciar - 30 segundos. Quais são os limites da diferença de horário aceitável para o servidor? Dica 1: o intervalo não é simétrico. Dica 2: releia o primeiro parágrafo desta seção novamente, especifique como estender o intervalo para não detectar 3000 erros por dia nos efeitos de borda.

Para que isso funcione perfeitamente e corretamente, é melhor adicionar um parâmetro adicional aos parâmetros de chamada de comando - o tempo de chamada. Algo assim:

 public interface Command { void Apply(ModelRoot root, long time); } 

E o meu conselho para você, a propósito, não usa tipos nativos do Unity por um tempo no modelo - você vai sufocar. É melhor armazenar o UnixTime no horário do servidor, sempre que você precisar de métodos de conversão úteis, e armazená-los no modelo em um campo PTime especial que difere de PValue <long> apenas porque, ao exportar para JSON, ele adiciona informações redundantes entre colchetes que não podem ser lidas quando Importar: hora em um formato legível por humanos. Você não pode me obedecer. Eu te avisei.

Quarta situação: No estado do jogo, há situações em que uma equipe deve ser iniciada sem a participação de um jogador, a tempo, por exemplo, recuperação de energia. Uma situação muito comum, de fato. Eu quero ter um campo, é convenientemente praticando. Por exemplo, PTimeOut, no qual será possível registrar um ponto no tempo após o qual um comando deve ser criado e executado. No código, pode ser assim:

 public class MyModel : Model { public static PTimeOut RESTORE_ENERGY = new PTimeOut() {command = (model, property) => new RestoreEnergyCommand() { model = model}} public long restoreEnergy { get { return RESTORE_ENERGY.Get(this); } set { RESTORE_ENERGY.Set(this, value); }} } 

Obviamente, durante o carregamento inicial do player, o servidor deve primeiro provocar a criação e a execução de todos esses comandos, e somente depois fornecer o estado ao player. A armadilha aqui é que tudo isso interfere nas notificações que o jogador pode receber durante esse período. Portanto, será necessário primeiro desaparafusar o tempo antes da hora da primeira notificação, se você precisar puxar vários comandos ao mesmo tempo, criar um comando a partir da própria notificação, desaparafusar o tempo até a próxima notificação, depois trabalhar e assim por diante. Se todo esse feriado da vida não couber no tempo limite do servidor, e isso é possível se o jogador ficou muito tempo com notificações, escrevemos o estado atual da memória no banco de dados e respondemos com um comando para reconectar ao cliente.

Todos esses comandos devem, de alguma forma, aprender sobre o que eles precisam criar e executar. Minha solução um pouco atrevida, mas conveniente, é que o modelo tem mais um desafio, que percorre toda a hierarquia do modelo, que se contrai após cada comando ser executado e também no temporizador. Obviamente, essa é uma sobrecarga extra para percorrer a árvore quase em uma atualização; em vez disso, você pode se inscrever ou cancelar a inscrição no evento currentTime saindo do estado do jogo a cada alteração neste campo:

 public partial class Model { public void SetCurrentTime(long time); } vs public partial class RootModel { public event Action<long> setCurrentTime; } 

Isso é bom, mas o problema é que os modelos que são excluídos da árvore de modelos para sempre e que contêm esse campo permanecerão inscritos nesse evento e terão que resolvê-lo corretamente. Antes de enviar um comando, verifique se eles ainda estão na árvore e têm um elo fraco para esse evento ou inversão de controle, para que, por isso, eles não fiquem inacessíveis ao GC.

Apêndice 1, um caso típico retirado da vida real


Retiro dos comentários para a primeira parte. Nos brinquedos, muitas vezes algumas ações não ocorrem imediatamente após o comando do modelo, mas no final de algum tipo de animação. Em nossa prática, houve um caso em que as caixas misteriosas se abrem e, é claro, o dinheiro deve mudar apenas quando a animação terminar até o fim. Um de nossos desenvolvedores decidiu simplificar sua vida e não altera o valor no comando, mas informa ao servidor que ele mudou e, no final da animação, executa um retorno de chamada, que corrigirá os valores no modelo para os desejados. Bem feito, em suma. Ele fez essas caixas misteriosas por duas semanas e depois outros três erros muito difíceis de serem descobertos como resultado de sua atividade surgiram e tivemos que passar mais três semanas para pegá-las, apesar do fato de que o tempo para "reescrever normalmente" era, é claro, ninguém poderia destacar. Da qual se segue vividamentePenso que a conclusão é que era melhor fazer tudo desde o início com reatividade normal.

Então, minha decisão é algo assim. Obviamente, o dinheiro não está em um campo separado, mas é um dos objetos no dicionário de inventário, mas isso não é tão importante no momento. O modelo possui uma parte que é verificada pelo servidor e com base na qual a lógica de negócios funciona, e a outra que existe apenas no cliente. O dinheiro no modelo principal é acumulado imediatamente após a decisão ser tomada e, na segunda parte da lista "exibição adiada", um elemento é criado para a mesma quantia que inicia a animação pelo fato de sua aparência e, quando a animação termina, é iniciado um comando que remove esse elemento. Uma observação tão puramente do cliente "ainda não mostra esse valor". E em um campo real, não apenas o valor do campo é exibido, mas o valor do campo menos todos os adiamentos do cliente. A divisão em apenas duas equipes é feita porquee se o cliente reiniciar após o primeiro time, mas antes do segundo todo o dinheiro recebido pelo jogador estará em sua conta sem nenhuma marca e exceção. No código, será algo como isto:

 public class OpenMisterBox : Command { public BoxItemModel item; public int slot; //        ,  . public override void Apply(GameState state) { state.inventory[item.revardKey] += item.revardCount; } //       . public override void Apply(GameState state) { var cause = state.NewPersistent<WaitForCommand>(); cause.key = item.key; cause.value = item.value; state.ui.delayedInventoryVisualization.Add(cause); state.ui.mysteryBoxScreen.animations.Add(new Animation() {cause = item, slot = slot})); } } public class MysteryBoxView : View { /* ... */ public override void ConnectModel(MysteryBoxScreenModel model, List<Control> c) { model.Get(c, MysteryBoxScreenModel.ANIMATIONS) .Control(c, onAdd = item => animationFactory(item, OnComleteOrAbort => { AsincQueue(new RemoveAnimation() {cause = item.cause, animation = item}) }), onRemove = item => {} ) } } public class InventoryView : View<InventoryItem> { public Text text; public override void ConnectModel(InventoryItem model, List<Control> c) { model.GameState.ui.Get(c, UIModel.DELAYED_INVENTORY_VISUALIZATION). .Where(c, item => item.key == model.key) .Expression(c, onChange = (IList<InventoryItem> list) => { int sum = 0; for (int i = 0; i < list.Count; i++) sum += list[i].value; return sum; }, onAdd = null, onRemove = null ) //      .Join (c, model.GameState.Get(GameState.INVENTORY).ItemByKey(model.key)) .Expression(c, (delay, count) => count - delay) .SetText(c, text); //     ,      ,   ,  ,   ,       ,     : model.inventory.CreateVisibleInventoryItemCount(c, model.key).SetText(c, text); } } public class RemoveDelayedInventoryVisualization : Command { public DelayCauseModel cause; public override void Apply(GameState state) { state.ui.delayedInventoryVisualization.Remove(cause); } } public class RemoveAnimation : RemoveDelayedInventoryVisualization { public Animation animation public override void Apply(GameState state) { base.Apply(state); state.ui.mysteryBoxScreen.animations.Remove(animation); } } 

O que temos no final?Existem duas visualizações, em uma delas uma certa animação é reproduzida, cujo final aguarda a exibição do dinheiro em uma visualização completamente diferente, que não faz idéia de quem e por que deseja mostrar um significado diferente. Tudo é reativo. A qualquer momento, você pode carregar o estado completo do GameState no jogo e ele começará a ser reproduzido exatamente de onde paramos, incluindo a animação inicial. A verdade começará do começo, porque não apagamos o estágio de animação, mas se realmente precisamos, podemos apagá-lo.

Total


Tornando a lógica de negócios do jogo através de modelos, equipes e arquivos estáticos com regras, envolvendo-os com belos logs detalhados e gerados automaticamente e incluindo execuções informativas de muitos erros típicos cometidos pelo programador que vê novos recursos, este, na minha opinião, é o caminho certo para viver luz branca. E não apenas porque você pode arquivar novas funcionalidades várias vezes mais rapidamente. Isso ainda é muito importante, porque, se for fácil para você baixar e depurar um novo recurso, o criador do jogo terá tempo para fazer várias vezes mais experiências no design de jogos com os mesmos programadores. Com todo o respeito devido ao nosso trabalho, depende de nós se o jogo falha ou não, mas se dispara ou não depende dos gamedismos, e eles precisam ter espaço para experimentos.
E agora peço que você responda perguntas muito importantes para mim. Se você tem idéias sobre como fazer o que fiz mal ou apenas quer comentar minhas respostas, estou esperando por você nos comentários. Uma proposta de cooperação e instruções sobre vários erros de sintaxe, por favor, no PM.

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


All Articles