Como escrevemos o código de rede do shooter PvP móvel: sincronização do jogador no cliente

Em um dos artigos anteriores , analisamos as tecnologias usadas em nosso novo projeto - um jogo de tiro rápido para dispositivos móveis. Agora, quero compartilhar como a parte do cliente do código de rede do jogo futuro é organizada, quais dificuldades encontramos e como resolvê-las.




Em geral, as abordagens para a criação de jogos multiplayer rápidos nos últimos 20 anos não mudaram muito. Vários métodos podem ser distinguidos na arquitetura do código de rede:

  1. Erro de cálculo do estado do mundo no servidor e exibição dos resultados no cliente sem previsão para o player local e com a possibilidade de perder a entrada do player (entrada). A propósito, essa abordagem é usada em outro projeto em desenvolvimento - você pode ler sobre isso aqui .
  2. Lockstep
  3. Sincronização do estado do mundo sem lógica determinística com previsão para um player local.
  4. Sincronização de entrada com lógica totalmente determinística e previsão para um player local.

A peculiaridade reside no fato de que, nos atiradores, o mais importante é a responsividade do controle - o jogador pressiona um botão (ou move o joystick) e deseja ver imediatamente o resultado de sua ação. Primeiro de tudo, porque o estado do mundo em tais jogos muda muito rapidamente e é necessário responder imediatamente à situação.

Como resultado, abordagens sem o mecanismo de previsão das ações do jogador local (previsão) não eram adequadas para o projeto, e adotamos um método de sincronização do estado do mundo, sem lógica determinística.

Vantagem da abordagem: menor complexidade na implementação em comparação com o método de sincronização ao trocar entrada.
Menos: um aumento no tráfego ao enviar todo o estado do mundo para o cliente. Tivemos que aplicar várias técnicas diferentes de otimização de tráfego para fazer o jogo funcionar de maneira estável em uma rede móvel.

No coração da arquitetura de jogo, temos o ECS, sobre o qual já falamos . Essa arquitetura permite que você armazene convenientemente dados sobre o mundo do jogo, serialize, copie e transfira pela rede. E também para executar o mesmo código no cliente e no servidor.

A simulação do mundo do jogo ocorre a uma frequência fixa de 30 ticks por segundo. Isso permite reduzir o atraso na entrada do player e quase não usar interpolação para exibir visualmente o estado do mundo. Mas há uma desvantagem significativa que deve ser considerada ao desenvolver um sistema desse tipo: para que o sistema de previsão do jogador local funcione corretamente, o cliente deve simular o mundo com a mesma frequência do servidor. E gastamos muito tempo para otimizar a simulação o suficiente para os dispositivos de destino.

Mecanismo de previsão de ação do jogador local (previsão)


O mecanismo de previsão do cliente é implementado com base no ECS devido à execução dos mesmos sistemas no cliente e no servidor. No entanto, nem todos os sistemas são executados no cliente, mas apenas aqueles que são responsáveis ​​pelo player local e não exigem dados relevantes sobre outros players.

Exemplo de listas de sistemas em execução no cliente e servidor:



No momento, temos cerca de 30 sistemas em execução no cliente que fornecem a previsão do jogador e cerca de 80 sistemas em execução no servidor. Mas não previmos coisas como causar dano, usar habilidades ou curar aliados. Existem dois problemas nessa mecânica:

  1. O cliente não sabe nada sobre como entrar em outros jogadores e prever coisas como dano ou cura quase sempre diverge dos dados no servidor.
  2. Criar novas entidades localmente (tiros, projéteis, habilidades únicas) geradas por um jogador carrega o problema de combinar com entidades criadas no servidor.

Para esse mecânico, o atraso oculta o jogador de outras maneiras.

Exemplo: desenhamos o efeito de acertar o tiro imediatamente e atualizamos a vida do inimigo somente após recebermos a confirmação do golpe do servidor.

O esquema geral do código de rede no projeto




O cliente e o servidor sincronizam o tempo por números de tick. Devido ao fato de a transmissão de dados pela rede levar algum tempo, o cliente está sempre à frente do servidor pela metade do RTT + o tamanho do buffer de entrada no servidor. O diagrama acima mostra que o cliente envia uma entrada para o tick 20 (a). Ao mesmo tempo, o tick 15 (b) é processado no servidor. Quando a entrada do cliente chegar ao servidor, o tick 20 será processado no servidor.

Todo o processo consiste nas seguintes etapas: o cliente envia a entrada do jogador para o servidor (a) → essa entrada é processada no servidor após o tamanho do buffer de entrada HRTT + (b) → o servidor envia o estado mundial resultante ao (s) cliente (s) → o cliente aplica o estado mundial confirmado com tempo do servidor RTT + tamanho do buffer de entrada + tamanho do buffer de interpolação do estado do jogo (d).

Depois que o cliente recebe um novo estado confirmado do mundo do servidor (d), ele precisa concluir o processo de reconciliação. O fato é que o cliente realiza previsões mundiais com base apenas nas informações do jogador local. As contribuições de outros jogadores não são conhecidas por ele. E, ao calcular o estado do mundo no servidor, o jogador pode estar em um estado diferente, diferente do que o cliente previu. Isso pode acontecer quando um jogador é atordoado ou morto.

O processo de aprovação consiste em duas partes:

  1. Comparações do estado previsto do mundo para o tick N recebido do servidor. Somente os dados relacionados ao player local estão envolvidos na comparação. O restante dos dados do mundo é sempre retirado do estado do servidor e não participa da coordenação.
  2. Durante a comparação, dois casos podem ocorrer:

- se o estado previsto do mundo coincidir com o confirmado do servidor, o cliente, usando os dados previstos para o player local e novos dados para o resto do mundo, continuará simulando o mundo no modo normal;
- se o estado previsto não corresponder, o cliente usará todo o estado do servidor no mundo e o histórico de informações do cliente e recontará o novo estado previsto no mundo do jogador.

No código, parece algo como isto:
GameState Reconcile(int currentTick, ServerGameStateData serverStateData, GameState currentState, uint playerID) { var serverState = serverStateData.GameState; var serverTick = serverState.Time; var predictedState = _localStateHistory.Get(serverTick); //if predicted state matches server last state use server predicted state with predicted player if (_gameStateComparer.IsSame(predictedState, serverState, playerID)) { _tempState.Copy(serverState); _gameStateCopier.CopyPlayerEntities(currentState, _tempState, playerID); return _localStateHistory.Put(_tempState); // replace predicted state with correct server state } //if predicted state doesn't match server state, reapply local inputs to server state var last = _localStateHistory.Put(serverState); // replace wrong predicted state with correct server state for (var i = serverTick; i < currentTick; i++) { last = _prediction.Predict(last); // resimulate all wrong states } return last; } 


A comparação de dois estados mundiais ocorre apenas para os dados que se relacionam com o player local e participam do sistema de previsão. Os dados são amostrados pelo ID do jogador.

Método de comparação:
 public bool IsSame(GameState s1, GameState s2, uint avatarId) { if (s1 == null && s2 != null || s1 != null && s2 == null) return false; if (s1 == null && s2 == null) return false; var entity1 = s1.WorldState[avatarId]; var entity2 = s2.WorldState[avatarId]; if (entity1 == null && entity2 == null) return false; if (entity1 == null || entity2 == null) return false; if (s1.Time != s2.Time) return false; if (s1.WorldState.Transform[avatarId] != s2.WorldState.Transform[avatarId]) return false; foreach (var s1Weapon in s1.WorldState.Weapon) { if (s1Weapon.Value.Owner.Id != avatarId) continue; var s2Weapon = s2.WorldState.Weapon[s1Weapon.Key]; if (s1Weapon.Value != s2Weapon) return false; var s1Ammo = s1.WorldState.WeaponAmmo[s1Weapon.Key]; var s2Ammo = s2.WorldState.WeaponAmmo[s1Weapon.Key]; if (s1Ammo != s2Ammo) return false; var s1Reload = s1.WorldState.WeaponReloading[s1Weapon.Key]; var s2Reload = s2.WorldState.WeaponReloading[s1Weapon.Key]; if (s1Reload != s2Reload) return false; } if (entity1.Aiming != entity2.Aiming) return false; if (entity1.ChangeWeapon != entity2.ChangeWeapon) return false; return true; } 


Operadores de comparação para componentes específicos são gerados junto com toda a estrutura EC, especialmente escrita por um gerador de código. Por exemplo, fornecerei o código gerado do operador de comparação de componentes Transform:

Código
 public static bool operator ==(Transform a, Transform b) { if ((object)a == null && (object)b == null) return true; if ((object)a == null && (object)b != null) return false; if ((object)a != null && (object)b == null) return false; if (Math.Abs(a.Angle - b.Angle) > 0.01f) return false; if (Math.Abs(a.Position.x - b.Position.x) > 0.01f || Math.Abs(a.Position.y - b.Position.y) > 0.01f) return false; return true; } 


Note-se que nossos valores de flutuação são comparados com um erro bastante alto. Isso é feito para reduzir a quantidade de dessincronização entre o cliente e o servidor. Para o jogador, esse erro será invisível, mas isso economiza significativamente os recursos de computação do sistema.

A complexidade do mecanismo de coordenação é que, no caso de uma sincronização incorreta dos estados do cliente e do servidor (previsão incorreta), é necessário simular repetidamente todos os estados previstos do cliente sobre os quais não há confirmação do servidor, até o tick atual em um quadro. Dependendo do ping do jogador, isso pode variar de 5 a 20 carrapatos de simulação. Tivemos que otimizar significativamente o código de simulação para ajustar-se ao prazo: 30 fps.

Para concluir o processo de aprovação, dois tipos de dados devem ser armazenados no cliente:

  1. Uma história dos estados previstos do jogador.
  2. E a história da entrada.

Para esses fins, usamos um buffer circular. O tamanho do buffer é de 32 ticks. Isso na frequência de 30 HZ dá cerca de 1 segundo em tempo real. O cliente pode continuar trabalhando com segurança no mecanismo de previsão, sem receber novos dados do servidor, até preencher esse buffer. Se a diferença entre a hora do cliente e o servidor começar a ser superior a um segundo, o cliente será forçado a se desconectar com uma tentativa de se reconectar. Temos esse tamanho de buffer devido aos custos do processo de coordenação em caso de discrepância entre os estados do mundo. Mas se a diferença entre o cliente e o servidor for superior a um segundo, é mais barato executar uma reconexão completa com o servidor.

Redução do tempo de atraso


O diagrama acima mostra que no jogo existem dois buffers no esquema de transferência de dados:

  • buffer de entrada no servidor;
  • um buffer de estados mundiais no cliente.

O objetivo desses buffers é o mesmo - para compensar os saltos de rede (tremulação). O fato é que a transmissão de pacotes pela rede é desigual. E como o mecanismo de rede opera com uma frequência fixa de 30 HZ, os dados devem ser fornecidos ao mecanismo na mesma frequência. Não temos a oportunidade de "esperar" alguns ms até que o próximo pacote chegue ao destinatário. Usamos buffers para dados de entrada e estados mundiais, a fim de ter uma margem de tempo para compensação de tremulação. Também usamos o buffer gamestate para interpolação se um dos pacotes for perdido.

No início do jogo, o cliente inicia a sincronização com o servidor somente depois de receber vários estados mundiais do servidor e o buffer do estado do jogo está cheio. Normalmente, o tamanho desse buffer é de 3 ticks (100 ms).

Ao mesmo tempo, quando o cliente sincroniza com o servidor, ele "executa" antes do horário do servidor pelo valor do buffer de entrada no servidor. I.e. o próprio cliente controla a distância do servidor. O tamanho inicial do buffer de entrada também é igual a 3 ticks (100 ms).

Inicialmente, implementamos o tamanho desses buffers como constantes. I.e. independentemente de o jitter realmente existir na rede ou não, houve um atraso fixo de 200 ms (tamanho do buffer de entrada + tamanho do buffer do estado do jogo) para atualizar os dados. Se adicionarmos a isso o ping médio estimado em dispositivos móveis em torno de 200 ms, o atraso real entre o uso da entrada no cliente e a confirmação do aplicativo no servidor foi de 400 ms!

Isso não nos convinha.

O fato é que alguns sistemas são executados apenas no servidor - como, por exemplo, o cálculo do HP do player. Com esse atraso, o jogador atira e somente após 400 ms vê como ele mata o oponente. Se isso acontecesse em movimento, geralmente o jogador conseguia correr atrás da parede ou se esconder e já estava morrendo lá. Os testes da equipe mostraram que esse atraso interrompe completamente a jogabilidade.

A solução para esse problema foi a implementação de tamanhos dinâmicos de buffers de entrada e gamestates:
  • para um buffer de gamestate, o cliente sempre conhece o conteúdo atual do buffer. No momento do cálculo do próximo tick, o cliente verifica quantos estados já estão no buffer;
  • para o buffer de entrada - o servidor, além do estado do jogo, começou a enviar ao cliente o valor do preenchimento atual do buffer de entrada para um cliente específico. O cliente, por sua vez, analisa esses dois valores.

O algoritmo de redimensionamento do buffer de gamestate é aproximadamente o seguinte:

  1. O cliente considera o valor médio do tamanho do buffer durante um período de tempo e variação.
  2. Se a variação estiver dentro dos limites normais (ou seja, durante um determinado período de tempo, não houve grandes saltos no preenchimento e na leitura do buffer), o cliente verificará o valor do tamanho médio do buffer para esse período de tempo.
  3. Se o preenchimento médio do buffer for maior que a condição do limite superior (ou seja, o buffer será preenchido mais do que o necessário), o cliente "reduzirá" o tamanho do buffer executando uma escala de simulação adicional.
  4. Se o preenchimento médio do buffer for menor que a condição do limite inferior (ou seja, o buffer não terá tempo para preencher antes que o cliente comece a lê-lo) - nesse caso, o cliente "aumentará" o tamanho do buffer ignorando um tique da simulação.
  5. No caso em que a variação estava acima do normal, não podemos confiar nesses dados, porque os surtos de rede por um determinado período foram muito grandes. Em seguida, o cliente descarta todos os dados atuais e começa a coletar estatísticas novamente.

Compensação de atraso do servidor


Devido ao fato de o cliente receber atualizações mundiais do servidor com um atraso (atraso), o jogador vê o mundo um pouco diferente do que existe no servidor. O jogador se vê no presente e no resto do mundo - no passado. No servidor, o mundo inteiro existe ao mesmo tempo.


Por esse motivo, a situação é que o jogador atira localmente em um alvo localizado no servidor em outro local.

Para compensar o atraso, usamos o tempo de retrocesso no servidor. O algoritmo de operação é aproximadamente o seguinte:

  1. O cliente com cada entrada envia adicionalmente ao servidor o tempo de verificação em que ele vê o resto do mundo.
  2. O servidor valida esse horário: é a diferença entre o horário atual e o horário visível do mundo do cliente no intervalo de confiança.
  3. Se o tempo for válido, o servidor deixa o jogador no horário atual e o resto do mundo volta ao estado que o jogador viu e calcula o resultado do arremesso.
  4. Se um jogador acertar, o dano será causado no horário atual do servidor.

O tempo de rebobinagem em um servidor funciona da seguinte maneira: a história do mundo (no ECS) e a história da física (suportada pelo mecanismo de Física Volátil ) são armazenadas no norte. No momento em que o arremesso foi calculado, os dados do jogador são retirados do estado atual do mundo e os demais jogadores do histórico.

O código para o sistema de validação de tiro é mais ou menos assim:
 public void Execute(GameState gs) { foreach (var shotPair in gs.WorldState.Shot) { var shot = shotPair.Value; var shooter = gs.WorldState[shotPair.Key]; var shooterTransform = shooter.Transform; var weaponStats = gs.WorldState.WeaponStats[shot.WeaponId]; // DeltaTime shouldn't exceed physics history size var shootDeltaTime = (int) (gs.Time - shot.ShotPlayerWorldTime); if (shootDeltaTime > PhysicsWorld.HistoryLength) { continue; } // Get the world at the time of shooting. var oldState = _immutableHistory.Get(shot.ShotPlayerWorldTime); var potentialTarget = oldState.WorldState[shot.Target.Id]; var hitTargetId = _singleShotValidator.ValidateTargetAvailabilityInLine(oldState, potentialTarget, shooter, shootDeltaTime, weaponStats.ShotDistance, shooter.Transform.Angle.GetDirection()); if (hitTargetId != 0) { gs.WorldState.CreateEntity().AddDamage(gs.WorldState[hitTargetId], shooter, weaponStats.ShotDamage); } } } 


Uma desvantagem significativa na abordagem é que confiamos no cliente nos dados sobre o horário do tick que ele vê. Potencialmente, um jogador pode obter uma vantagem aumentando artificialmente o ping. Porque quanto mais ping um jogador tem, mais ele dispara no passado.

Alguns problemas que encontramos


Durante a implementação desse mecanismo de rede, encontramos muitos problemas, alguns dos quais merecem um artigo separado, mas aqui abordarei apenas alguns deles.

Simulação do mundo inteiro em um sistema de previsão e cópia


Inicialmente, todos os sistemas em nosso ECS tinham apenas um método: void Execute (GameState gs). Nesse método, os componentes relacionados a todos os jogadores eram geralmente processados.

Um exemplo de um sistema de movimento na implementação inicial:
 public sealed class MovementSystem : ISystem { public void Execute(GameState gs) { foreach (var movementPair in gs.WorldState.Movement) { var transform = gs.WorldState.Transform[movementPair.Key]; transform.Position += movementPair.Value.Velocity * GameState.TickDuration; } } } 


Porém, no sistema local de previsão de jogadores, precisamos apenas processar os componentes relacionados a um jogador específico. Inicialmente, implementamos isso usando copy.

O processo de previsão foi o seguinte:

  1. Uma cópia do estado do jogo foi criada.
  2. Uma cópia foi fornecida à entrada do ECS.
  3. Houve uma simulação do mundo inteiro no ECS.
  4. Todos os dados relacionados ao player local foram copiados do gamestate recém-recebido.

O método de previsão ficou assim:
 void PredictNewState(GameState state) { var newState = _stateHistory.Get(state.Tick+1); var input = _inputHistory.Get(state.Tick); newState.Copy(state); _tempGameState.Copy(state); _ecsExecutor.Execute(_tempGameState, input); _playerEntitiesCopier.Copy(_tempGameState, newState); } 


Havia dois problemas nessa implementação:

  1. Porque usamos classes, não estruturas - a cópia é uma operação bastante cara para nós (aproximadamente 0,1-0,15 ms no iPhone 5S).
  2. A simulação de todo o mundo também leva muito tempo (cerca de 1,5 a 2 ms no iPhone 5S).

Se levarmos em conta que, durante o processo de coordenação, é necessário recalcular de 5 a 15 estados mundiais em um quadro, então, com essa implementação, tudo ficou terrivelmente lento.

A solução foi bastante simples: aprender a simular o mundo em partes, ou seja, simular apenas um jogador específico. Reescrevemos todos os sistemas para que você possa transferir o ID do jogador e simular apenas ele.

Um exemplo de um sistema de movimento após uma alteração:
 public sealed class MovementSystem : ISystem { public void Execute(GameState gs) { foreach (var movementPair in gs.WorldState.Movement) { Move(gs.WorldState.Transform[movementPair.Key], movementPair.Value); } } public void ExecutePlayer(GameState gs, uint playerId) { var movement = gs.WorldState.Movement[playerId]; if(movement != null) { Move(gs.WorldState.Transform[playerId], movement); } } private void Move(Transform transform, Movement movement) { transform.Position += movement.Velocity * GameState.TickDuration; } } 


Após as alterações, conseguimos livrar-nos de cópias desnecessárias no sistema de previsão e reduzir a carga no sistema correspondente.

Código:
 void PredictNewState(GameState state, uint playerId) { var newState = _stateHistory.Get(state.Tick+1); var input = _inputHistory.Get(state.Tick); newState.Copy(state); _ecsExecutor.Execute(newState, input, playerId); } 


Criando e excluindo entidades em um sistema de previsão


Em nosso sistema, a correspondência de entidades no servidor e no cliente ocorre por um identificador inteiro (id). Para todas as entidades, usamos numeração de ponta a ponta de identificadores, cada nova entidade possui o valor id = oldID + 1.

Essa abordagem é muito conveniente de implementar, mas tem uma desvantagem significativa: a ordem de criação de novas entidades no cliente e no servidor pode ser diferente e, como resultado, os identificadores das entidades serão diferentes.

Esse problema se manifestou quando implementamos um sistema para prever as tacadas dos jogadores. Cada tiro com a gente é uma entidade separada com o componente tiro. Para cada cliente, o ID das entidades de captura no sistema de previsão era seqüencial. Mas se, no mesmo momento, outro jogador disparou, no servidor o ID de todos os disparos diferia do cliente.

As fotos no servidor foram criadas em uma ordem diferente:



Para tiros, contornamos essa limitação, com base nos recursos de jogabilidade do jogo. Tiros são entidades de vida rápida que são destruídas no sistema uma fração de segundo após a criação. No cliente, destacamos um intervalo separado de IDs que não se cruzam com os IDs do servidor e não levam mais em consideração as capturas no sistema de coordenação. I.e. as tacadas dos jogadores locais são sempre sorteadas no jogo apenas de acordo com o sistema de previsão e não levam em consideração os dados do servidor.

Com essa abordagem, o jogador não vê artefatos na tela (exclusão, recriação, retrocessos de tiros) e as discrepâncias com o servidor são pequenas e não afetam a jogabilidade como um todo.

Este método permitiu resolver o problema com disparos, mas não o problema inteiro de criar entidades no cliente como um todo. Ainda estamos trabalhando em métodos possíveis para resolver a comparação de objetos criados no cliente e no servidor.

Deve-se notar também que esse problema diz respeito apenas à criação de novas entidades (com novos IDs). A adição e remoção de componentes em entidades já criadas é realizada sem problemas: os componentes não possuem identificadores e cada entidade pode ter apenas um componente de um tipo específico. Portanto, geralmente criamos entidades no servidor e, nos sistemas de previsão, apenas adicionamos / removemos componentes.

Concluindo, quero dizer que a tarefa de implementar o multiplayer não é a mais fácil e a mais rápida, mas há muitas informações sobre como fazer isso.

O que ler


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


All Articles